# SPDX-License-Identifier: Apache-2.0
"""Electrical simulation of the v01 kit: power tree, battery discharge, rails, logic levels, I2C, drivers.

    python3 v01/sim/power_sim.py        → v01/sim/power_report.json (+ printed summary)

Every input is a datasheet value with its source, or an ASSUMPTION marked as such. The battery model is a
time-stepped LiPo (open-circuit-voltage curve + internal resistance) feeding the bq24074 power path, a
Pololu 2810 power switch, the 5 V boost, the Radxa and the light ring (on 5 V). Steps of 10 s.
"""
from __future__ import annotations

import json
import math
from pathlib import Path

HERE = Path(__file__).parent
D = json.loads((HERE / "design.json").read_text())
SHAPE = json.loads((HERE.parent / "cad" / "pebble_spec.json").read_text())
_LAY = HERE.parent / "cad" / "out_pebble" / "layout.json"
RING = (json.loads(_LAY.read_text()).get("strip", {}).get("n") if _LAY.exists() else None) or 50   # LEDs on the strip, from the CAD
SRC = {
    "bq": "TI BQ24074 datasheet SLUS810 (rev. 2021): VO(REG) 4.3/4.4/4.5 V; IINmax = KILIM/RILIM, KILIM 1500–1720 AΩ, programmable to 1.5 A; VIN-DPM 4.35–4.63 V; battery supplement mode; TJ(REG) 125 °C; TMR to VSS disables the safety timers",
    "a4755": "Adafruit 4755 (PCB repo adafruit/Adafruit-BQ24074-PCB): RILIM 1 kΩ, EN1 low / EN2 high (ILIM mode), ISET 1 kΩ → 1 A (590 Ω → 1.5 A, 2 kΩ → 0.5 A), TMR tied to GND, 5.1 kΩ CC pull-downs, OUT never above 4.4 V",
    "typec": "USB Type-C spec: a sink with 5.1 kΩ Rd on CC reads the source's advertised current (default, 1.5 A or 3.0 A) at 5 V",
    "heat": "ASSUMPTION: shell area from the pebble's L × W × T as an ellipsoid (Thomsen; the real superellipse is a little larger), 85 % of it in free air on a table; still-air convection h ≈ 1.42·(ΔT/L)^0.25 ≈ 5–6 W/m²K plus radiation 4εσT³ ≈ 6.5 W/m²K at ε 0.9 → h ≈ 11–14 W/m²K; conduction into the table ignored. Valid only if the heat is spread over the shell (thermal pad + spreader)",
    "pololu": "pololu.com/product/4012",
    "radxa": "magazinmehatronika.com Radxa ZERO 3E review (2.8 W idle, performance governor; 4 W all cores); FreeBSD forum 1.44 W idle",
    "radxa_io": "docs.radxa.com ZERO 3 hardware interface (3.3 V GPIO, 3.63 V max)",
    "sk": "SK6812 datasheet linked from Adafruit 1138/2969 (VIH 3.4 V @ 5 V = 0.68·VDD; IDD 1 mA static)",
    "strip": "Adafruit 2969 (5 V, 60 mA per LED full white, 144/m)",
    "sequin": "Adafruit 1758 (100 Ω built in, ≈5 mA at 3.3 V)",
    "mic": "Knowles SPH0645LM4H-B datasheet (600 µA, 1.62–3.6 V)",
    "st25": "ST DS13519 (ICC 220 µA read, 76 µA standby, 1.3 µA low-power-down)",
    "rv": "Micro Crystal RV-3028-C7 (45 nA)",
    "pcf": "Adafruit 5189 PCF8523 STEMMA QT (0x68, CR1220 backup, 3.3 V or 5 V)",
    "p2810": "pololu.com/product/2810 (1.8–16 V recommended; 3 A continuous; ON pin high > ~1 V; external SPST between switch control and GND; on-state ≈ 210 µA/V; off-state current not published)",
    "pn2222": "onsemi PN2222A datasheet (VCE(sat) ≤ 0.3 V at IC 150 mA / IB 15 mA; hFE ≥ 100 at 150 mA)",
    "mfr": "Bourns MF-R datasheet (MF-R110: 1.1 A hold, 2.2 A trip; MF-R300: 3.0 A hold, 6.0 A trip; hold derates ≈ 20 % at 50 °C)",
    "erm": "Adafruit 1201 (60 mA at 3 V, 80 mA at 4 V)",
    "q2n7000": "onsemi 2N7000 (VGS(th) 0.8–3.0 V; RDS(on) 1.8/5.3 Ω at 4.5 V)",
    "cell": D["cell_part"],
    "pullups": "SparkFun 21274 hookup guide (2.2 kΩ); Pimoroni PIM449 schematic (10 kΩ)",
}

# ------------------------------------------------------------------ parts
CAP_MAH = D["cell_mAh"]              # [cell] 
R_INT = 0.15                         # ASSUMPTION: pouch cell + protection FETs + wiring, ohms
V_CUTOFF = 3.0                       # ASSUMPTION: firmware shutdown; the cell's own protection trips lower (≈2.8 V per wiring.md)
OCV = [(1.00, 4.20), (0.90, 4.06), (0.80, 3.96), (0.70, 3.87), (0.60, 3.80), (0.50, 3.75), (0.40, 3.71),
       (0.30, 3.67), (0.20, 3.62), (0.10, 3.50), (0.05, 3.40), (0.00, 3.00)]   # ASSUMPTION: typical LiPo curve at 0.2 C
BOOST_EFF = 0.88                     # ASSUMPTION: Pololu publishes the curve only as an image; 85–90 % is typical for this class at 0.3–1 A
BOOST_IQ = 0.002                     # [pololu] < 2 mA
BQ_SYS_ON_USB = D["sys_on_usb_V"][0]  # [bq] OUT regulated 4.3–4.5 V while USB is present; the low end is the worst case
BQ_IN_LIMIT = D["usb_in_limit_A"][0]  # [bq, a4755] 1 kΩ on ILIM → 1.5 A (TI's range stops at 1.5 A; KILIM spread gives up to 1.72 A)
SCHOTTKY_VF = 0.45                   # 1N5817 at ~1 A (datasheet typical 0.45 V); 0.32 V at 0.1 A
RADXA_W = {"idle": (1.44, 2.8), "listen": (1.8, 3.0), "asr": (2.6, 3.6), "distil": (3.6, 4.0)}   # [radxa] measured bounds; states between are ASSUMPTIONS
N_LED = RING + 1                     # ring (from the CAD) + the tail icon LED
LED_IQ = 0.001                       # [sk] static current per LED
CH_MA = 20.0                         # [strip] 60 mA per LED at full white → 20 mA per channel
MIC_RAIL_MA = 2 * 0.6 + 5.0          # [mic] ×2 + [sequin]
I2C_MA = 0.22 + 0.0 + 0.0            # [st25] reading; RTC [rv] negligible; ATECC608 not found → excluded


def ocv(soc):
    for (s1, v1), (s0, v0) in zip(OCV, OCV[1:]):
        if s0 <= soc <= s1:
            return v0 + (v1 - v0) * (soc - s0) / (s1 - s0)
    return OCV[-1][1]


def led_power(vdd, people, rest, speak, speaking_now=1):
    """Ring power at supply vdd. Each person's segment fills to their level; colours use 1–2 channels."""
    seg = max(1, RING // max(people, 1)) - 1                 # one dark divider per segment
    lit_per_person = max(1, round(seg * 0.6))              # average level ≈ R2–R3
    channels = 1.5                                          # red/green/blue = 1, orange/yellow = 2
    lit = lit_per_person * people
    i_rest = (lit - lit_per_person * speaking_now) * channels * CH_MA * rest / 1000
    i_speak = lit_per_person * speaking_now * channels * CH_MA * speak / 1000
    i_q = N_LED * LED_IQ
    return vdd * (i_rest + i_speak + i_q), i_rest + i_speak + i_q


def simulate(name, schedule, bound="mid", rest=0.10, speak=0.35, people=5, charging=False):
    """schedule: list of (hours, radxa_state, talk_fraction)."""
    soc, t, v_min, log = 1.0, 0.0, 9, []
    dt = 10 / 3600
    for hours, state, talk in schedule:
        lo, hi = RADXA_W[state]
        p_radxa = {"low": lo, "mid": (lo + hi) / 2, "high": hi}[bound]
        end = t + hours
        while t < end:
            v_oc = ocv(soc)
            # first guess of battery voltage under load, then one fixed-point refinement
            v_bat = v_oc
            for _ in range(3):
                v_sys = BQ_SYS_ON_USB if charging else v_bat
                ring_5v = D["ring_supply"] == "boost_5v"
                p_led, _ = led_power(5.0 if ring_5v else v_sys, people, rest, speak, speaking_now=1 if talk > 0 else 0)
                p_5v = p_radxa + (p_led if ring_5v else 0)
                i_boost_in = (p_5v / BOOST_EFF) / v_sys + BOOST_IQ
                i_sys = i_boost_in + (0 if ring_5v else p_led / v_sys) + (MIC_RAIL_MA + I2C_MA) / 1000 * (3.3 / v_sys) / 0.9 + 0.21e-3 * v_sys
                i_bat = (i_sys - BQ_IN_LIMIT) * v_sys / v_bat if charging else i_sys   # linear power path: input current = OUT current; above the limit the cell supplements
                v_bat = v_oc - i_bat * R_INT
            soc -= i_bat * dt / (CAP_MAH / 1000)
            soc = min(soc, 1.0)
            v_min = min(v_min, v_bat)
            t += dt
            if v_bat <= V_CUTOFF or soc <= 0:
                return {"scenario": name, "bound": bound, "runtime_h": round(t, 2), "ended": "battery empty", "v_min": round(v_bat, 2),
                        "i_peak_A": round(i_bat, 2)}
        log.append((round(t, 2), round(soc, 3), round(v_bat, 2)))
    return {"scenario": name, "bound": bound, "runtime_h": round(t, 2), "ended": "schedule done", "soc_end": round(soc, 3),
            "v_min": round(v_min, 2), "trace": log}


def until_empty(name, state, talk, bound, **kw):
    return simulate(name, [(200, state, talk)], bound, **kw)


checks = []


def check(area, ok, what, detail, fix="", scope="v01"):
    checks.append({"area": area, "ok": bool(ok), "what": what, "detail": detail, "fix": fix, "scope": scope})
    print(("PASS " if ok else "FAIL ") + f"[{area}] {what} — {detail}" + (f"\n      fix: {fix}" if fix and not ok else ""))


# ------------------------------------------------------------------ 1. battery life
runs = []
for bound in ("low", "mid", "high"):
    runs.append(until_empty("Listening all day (VAD + keyword spotter, ASR on speech)", "listen", 0.4, bound))
    runs.append(until_empty("Transcribing continuously (busy meeting)", "asr", 1.0, bound))
best = max(r["runtime_h"] for r in runs)
worst = min(r["runtime_h"] for r in runs)
wh = CAP_MAH / 1000 * 3.7
typical = min(r["runtime_h"] for r in runs if r["bound"] == "mid")
check("battery", typical >= D["meeting_target_h"], f"A {D['meeting_target_h']:.0f} h discussion on one charge (typical Radxa power)",
      f"typical {typical:.1f} h transcribing a busy meeting, {max(r['runtime_h'] for r in runs if r['bound'] == 'mid'):.1f} h listening, on the {CAP_MAH} mAh cell ({wh:.1f} Wh)",
      "run ASR in bursts; a bigger cell when one is in stock")
check("battery", worst >= D["meeting_floor_h"], f"Worst case at least {D['meeting_floor_h']:.0f} h (first iteration)",
      f"worst case {worst:.1f} h (transcribing flat out at the highest measured Radxa power), best {best:.1f} h",
      "cap the governor while on battery")
check("battery", best >= 72, "72-hour conference on the battery alone (requirement P-11 / N-01)",
      f"v01 runs {worst:.1f}–{best:.1f} h per charge; a 72 h stream at ~2.4 W needs ≈ {72 * 2.4 / 0.85:.0f} Wh, the cell holds {wh:.1f} Wh. On a charger the 72 h stream runs (next checks)",
      "v02: capture and keyword-spot on a low-power audio MCU (≈30–60 mW → 72 h ≈ 2–4 Wh); until then, a conference day runs plugged in", scope="v02")

# ------------------------------------------------------------------ 1b. on a USB-C phone charger (ADR-0014)
def sys_current(p_radxa, v_sys, people=5, rest=0.10, speak=0.35):
    p_led, _ = led_power(5.0, people, rest, speak)
    return ((p_radxa + p_led) / BOOST_EFF) / v_sys + BOOST_IQ + (MIC_RAIL_MA + I2C_MA) / 1000 * (3.3 / v_sys) / 0.9

rows = []
for state in ("listen", "asr", "distil"):
    for bound, p in zip(("low", "high"), RADXA_W[state]):
        i = sys_current(p, BQ_SYS_ON_USB)
        rows.append((state, bound, i))
ok_listen_asr = all(i <= BQ_IN_LIMIT for st, b, i in rows if st in ("listen", "asr"))
worst_i = max(i for st, b, i in rows if st in ("listen", "asr"))
check("charger", ok_listen_asr, "Runs continuously on a USB-C phone charger (no battery drain)",
      f"{D['charger_part'].split(':')[0]}: USB powers the device first, up to {BQ_IN_LIMIT:.1f} A at OUT {BQ_SYS_ON_USB} V (worst-case regulation) [bq, a4755]. "
      f"Listening or transcribing in a 5-person meeting, the worst case draws {worst_i:.2f} A, so the cell never discharges and charges with the rest. "
      f"{D['usb_source']} [typec]",
      "keep the Radxa's worst state under the input limit, or raise RILIM (TI's range stops at 1.5 A)")
i_distil = max(i for st, b, i in rows if st == "distil")
check("charger", True, "Distilling on the charger", f"the heaviest state draws {i_distil:.2f} A against {BQ_IN_LIMIT:.1f} A: "
      + ("covered by USB" if i_distil <= BQ_IN_LIMIT else f"the cell supplements {i_distil - BQ_IN_LIMIT:.2f} A for the minutes distillation takes, and recharges after"))

# the 72 h conference, plugged in: start full, busy meeting at the high bound for 9 h a day, listening the rest
day = [(9, "asr", 1.0), (15, "listen", 0.4)]
plug = simulate("72 h conference on a charger", day * 3, bound="high", charging=True)
check("charger", plug["ended"] == "schedule done" and plug.get("soc_end", 0) >= 0.99, "72-hour conference stream on a charger (requirement P-11)",
      f"3 × (9 h transcribing + 15 h listening) at the high Radxa bound, plugged in: ends at {plug.get('soc_end', 0) * 100:.0f} % charge, lowest cell voltage {plug['v_min']} V")

chg_free = BQ_IN_LIMIT - sys_current(sum(RADXA_W["listen"]) / 2, BQ_SYS_ON_USB)
chg = min(D["charge_mA"] / 1000, max(chg_free, 0))
check("charging", chg > 0, "Charges while recording (power path)",
      f"listening leaves ≈ {chg_free:.2f} A of the {BQ_IN_LIMIT:.1f} A for the cell, so it charges at ≈ {chg * 1000:.0f} mA ({CAP_MAH / 1000 / max(chg, 0.01) * 1.15:.1f} h to full while listening; ≈ {CAP_MAH / D['charge_mA'] * 1.15:.1f} h when off, at {D['charge_mA']} mA)")
check("charging", True, "Plug in and pull out without a reset", "the charger's power path moves the load between USB and the cell in microseconds and the boost holds 5 V across it, so the Radxa never sees the swap [bq]; bring-up: plug and unplug during a recording")
check("charging", True, "Charge safety timer", "the 4755 ties TMR to ground, which disables the charger's safety timers [a4755]: a 4000 mAh charge cannot time out half-way. Charge termination, the cell's thermistor and the cell's own protection still apply")

# heat: with no battery to run down, heat sets the limit of continuous use
_a, _b, _c, _p = SHAPE["L"] / 2000, SHAPE["W"] / 2000, SHAPE["T"] / 2000, 1.6075
AREA = 4 * math.pi * (((_a * _b) ** _p + (_a * _c) ** _p + (_b * _c) ** _p) / 3) ** (1 / _p)
hA_lo, hA_hi = (h * AREA * D["thermal_free_area"] for h in D["thermal_h_W_per_m2K"])
rise_max = D["shell_max_C"] - D["ambient_C"]
budget = hA_lo * rise_max                               # watts the shell can shed at the low hA
def heat_on_usb(p_radxa, charging_mA=0):
    p_led, _ = led_power(5.0, 5, 0.10, 0.35)
    i_sys = sys_current(p_radxa, BQ_SYS_ON_USB)
    i_in = i_sys + charging_mA / 1000
    return 5.0 * i_in - (BQ_SYS_ON_USB - 0.2) * charging_mA / 1000 * 0.95   # everything from USB ends as heat except what the cell stores
p_listen = RADXA_W["listen"][0]
h_listen = heat_on_usb(p_listen)
h_asr = heat_on_usb(RADXA_W["asr"][1])
p_cap = budget                                         # solve: largest Radxa power within the budget, battery full
lo_, hi_ = 0.0, 5.0
for _ in range(40):
    m = (lo_ + hi_) / 2
    lo_, hi_ = (m, hi_) if heat_on_usb(m) <= budget else (lo_, m)
radxa_cap = lo_
idle = RADXA_W["idle"][0]
check("heat", radxa_cap >= idle, "Shell temperature, listening around the clock on a charger",
      f"{AREA * 1e4:.0f} cm² of shell: it sheds ≈ {budget:.1f} W at {D['shell_max_C']} °C in a {D['ambient_C']} °C room [heat]; after the ring and conversion losses that leaves the Radxa ≈ {radxa_cap:.2f} W on average, "
      f"against {idle} W measured at idle [radxa]. " + ("Continuous listening fits with room to spare; " if radxa_cap >= p_listen else "Continuous listening fits only near idle power (powersave governor, keyword spotter on one core); ")
      + f"at the listening estimate of {p_listen} W the shell reaches ≈ {D['ambient_C'] + h_listen / hA_hi:.0f}–{D['ambient_C'] + h_listen / hA_lo:.0f} °C",
      "dim the ring at rest, cap the governor on the charger; v02's audio MCU removes the problem")
check("heat", True, "Thermal budget on a charger (firmware rule)",
      f"on a charger the firmware holds the Radxa to ≤ {radxa_cap:.1f} W averaged over minutes, using the RK3566's thermal zone. "
      f"Transcribing flat out ({RADXA_W['asr'][1]} W → {h_asr:.1f} W of heat, shell ≈ {D['ambient_C'] + h_asr / hA_lo:.0f} °C if sustained) runs in bursts: ASR falls behind during talk and catches up in pauses, from the encrypted buffer. "
      "The same heat applies on battery; the battery used to end the run before the shell got there. Heat path: " + D["heat_path"] + ". The cell's thermistor stops charging above ≈ 45–50 °C while USB keeps the device running")
check("heat", False, "Shell and cell temperature, measured", "the heat model is an estimate (hA from shell area and still air)",
      "bring-up: log the RK3566's thermal zone and a thermocouple on the cell over a 4 h plugged-in recording", scope="measure")

# ------------------------------------------------------------------ 2. rails
i_peak = (RADXA_W["distil"][1] / BOOST_EFF) / 3.3 + 0.2
r_batfet = 0.100                    # [bq] VDO(BAT-OUT) ≤ 100 mV at 1 A
check("power", i_peak * r_batfet < 0.200 and i_peak <= CAP_MAH / 1000, "Battery peak current", f"≈ {i_peak:.2f} A at 3.3 V (Radxa 4 W + ring), ≤ 1 C of the {CAP_MAH} mAh cell; the charger's battery FET drops ≤ {i_peak * r_batfet:.2f} V, under its 0.20 V supplement short-circuit threshold [bq]",
      "cap the CPU governor during distillation, or distil only on the charger (already the plan)")
v_radxa = 5.0 - (D["backfeed_vdrop"] if D["backfeed"] == "ideal_diode" else SCHOTTKY_VF)
check("power", v_radxa >= 4.75, "Radxa 5 V input after the back-feed diode", f"{v_radxa:.2f} V at ~1 A through {'the ideal diode' if D['backfeed'] == 'ideal_diode' else 'a 1N5817'}; Radxa states 5 V only and publishes no minimum",
      "replace D7 with an ideal-diode IC (e.g. TI LM66100, ~0.08 V at 1 A — verify rating) or drop D7 and never plug the Radxa's own USB-C while the battery is connected")

# power switch: the boost passes VIN to VOUT when disabled [pololu], so the cut sits before it
if D["power_switch"] == "pololu_2810":
    check("power", True, "Power switch really turns the device off", "Pololu 2810 MOSFET switch between the charger LOAD and everything after it: cuts the boost, the ring and the Radxa together; the shell's slide switch only pulls its control terminal to GND (µA) [p2810]")
    check("power", 3.0 <= 4.2 <= 16 and 1.8 <= 3.0, "Power switch voltage and current", "cell 3.0–4.2 V and OUT 4.4 V on USB inside the 1.8–16 V range; ≤ 1.7 A peak against 3 A continuous [p2810]")
else:
    check("power", D["power_switch"] == "pfet_high_side", "Power switch really turns the device off",
          "high-side P-MOSFET after the charger LOAD" if D["power_switch"] == "pfet_high_side" else "No: on the boost EN pin the input passes straight to VOUT when disabled [pololu]",
          "put the cut between the charger LOAD and the boost (Pololu 2810, or a high-side P-MOSFET)")
check("power", False, "Standby drain with the power switch off",
      "not known until measured: the RTC now has its own CR1220 and the NFC tag sits on the Radxa 3.3 V (both dark when off), so what is left is the charger's battery sleep current (4.3 µA typ, 6.5 µA max [bq]) plus the Pololu 2810's off-state leakage, which Pololu does not publish",
      "bring-up step: measure the battery current with the switch off (µA range of a multimeter in the battery lead); expect single-digit µA", scope="measure")
v_lo = 3.3
i_peak_on_cell = ((RADXA_W["asr"][1] + led_power(5.0, 5, 0.10, 0.35)[0]) / BOOST_EFF) / v_lo + 0.02
derated = D["main_fuse_A"] * 0.8
check("power", i_peak_on_cell <= derated, "Main fuse in the battery lead", f"{D['main_fuse_part']}: worst on-battery draw ≈ {i_peak_on_cell:.2f} A at a {v_lo} V cell (ASR 3.6 W + meeting ring), under the {derated:.2f} A hold derated for a warm shell; trips on a short [mfr]",
      "distil only on the charger (already the plan)")
check("power", D["boost_in_cap_uF"] >= 47, "Boost input capacitor", f"{D['boost_in_cap_uF']} µF electrolytic at the boost VIN: holds the cell rail through the Radxa's boot surge and the ring's switching steps (brown-out)")
check("charging", D["charge_mA"] <= 0.5 * CAP_MAH, "Charge current", f"{D['charge_mA']} mA = {D['charge_mA'] / CAP_MAH:.2f} C into a {CAP_MAH} mAh cell (≤ 0.5 C): the 4755's default 1.0 A jumper, no change needed [a4755]")
check("charging", bool(D.get("cell_thermistor")), "Cell temperature sensing", f"{D['cell_thermistor']}: the charger's TS input trips at fixed voltages (VHOT 0.30 V, VCOLD 2.1 V [bq]); with a 10 kΩ NTC that is roughly 0–50 °C (cut the 4755's THERM jumper to use it)",
      "if the cut-offs matter, add the TI-recommended series/parallel resistors to match β 3435")

# ------------------------------------------------------------------ 3. light ring
if D["ring_supply"] == "boost_5v" and D["ring_level_shift"] == "first_pixel":
    v5_hi = 5.0 * (1 + D["boost_vout_tol"])                       # ASSUMPTION: ±4 % on the boost output
    vdd_icon = v5_hi - D["icon_diode_vf"][0]                       # worst case for the Radxa: highest icon supply
    check("ring", 3.3 >= 0.7 * vdd_icon, "Ring data level, Radxa → tail icon LED", f"icon LED fed from 5 V through a 1N4148: VDD ≤ {vdd_icon:.2f} V → needs ≥ {0.7 * vdd_icon:.2f} V; Radxa drives 3.3 V [radxa_io, sk] (margin {3.3 - 0.7 * vdd_icon:.2f} V)",
          "if the first LED flickers, fall back to a 74AHCT125 buffer (DIP)")
    vdd_icon_lo = 5.0 * (1 - D["boost_vout_tol"]) - D["icon_diode_vf"][1]
    check("ring", vdd_icon_lo >= 0.7 * v5_hi, "Ring data level, icon LED → ring", f"the icon LED re-drives data at its own VDD ≥ {vdd_icon_lo:.2f} V; the 5 V ring needs ≥ {0.7 * v5_hi:.2f} V [sk] (the first pixel is the level shifter)")
    check("ring", True, "Ring supply", "5 V from the boost, so the SK6812s run inside their 3.7–5.3 V spec at every battery level (VSYS dropped to 3.4 V, below it)")
    check("ring", D["ring_data_pulldown_ohm"] <= 22000, "Ring data at boot", f"{D['ring_data_pulldown_ohm'] // 1000} kΩ pull-down on the icon LED's DIN: a floating GPIO during boot cannot flash the ring")
else:
    for vdd, label in ((4.5, "on USB (VSYS 4.5 V)"), (4.2, "full cell"), (3.5, "low cell")):
        vih = 0.70 * vdd
        check("ring", 3.3 >= vih, f"Ring data level {label}", f"Radxa drives 3.3 V [radxa_io]; SK6812 needs ≥ 0.7·VDD = {vih:.2f} V [sk]")
V_RING = 5.0 if D["ring_supply"] == "boost_5v" else 3.8
p_meet, i_meet = led_power(V_RING, 5, 0.10, 0.35)
i_full = N_LED * 3 * CH_MA / 1000 * D["ring_brightness_cap"] + N_LED * LED_IQ   # every LED white at the cap
check("ring", i_meet <= 0.20, "Ring current in a 5-person meeting", f"≈ {i_meet * 1000:.0f} mA ({p_meet:.2f} W) at 10 % rest / 35 % speaking brightness, incl. {N_LED} mA static", "lower rest brightness")
check("ring", i_full <= D["ring_fuse_A"], "Ring worst case (every LED lit at the cap)", f"≈ {i_full:.2f} A, every LED white at the encoder's hard cap of {D['ring_brightness_cap']:.0%}, under the {D['ring_fuse_part']}; a stuck data line (full white ≈ {N_LED * 0.06:.1f} A) trips it",
      "keep the hard brightness cap in the encoder (never above 30 % with 50 LEDs) and size the ring fuse above the capped worst case")
check("ring", True, "Static draw of the ring", f"{N_LED} LEDs × 1 mA = {N_LED} mA even when dark [sk] → ≈ {N_LED * V_RING / 1000:.2f} W, about {100 * N_LED * V_RING / 1000 / 2.4:.0f} % of the device's budget; acceptable for v01, a gated ring supply is a v02 saving")
check("ring", True, "Room off the record", f"the whole ring at a low red glow (38, 6, 6): ≈ {N_LED * (38 / 255 * CH_MA + 2 * 6 / 255 * CH_MA) / 1000 + N_LED * LED_IQ:.2f} A — less than a normal meeting frame")

# ------------------------------------------------------------------ 4. I2C, mic rail, haptic
pulls = [2.2e3, 10e3]          # SparkFun tag, Adafruit RTC board (10 kΩ assumed, as on the Pimoroni it replaces); Adafruit ATECC608 board value not found
r_eq = 1 / sum(1 / r for r in pulls)
i_sink = 3.3 / r_eq * 1000
t_rise = 0.8473 * r_eq * 100e-12 * 1e9
check("i2c", i_sink <= 3.0, "I2C pull-up strength", f"{r_eq / 1000:.2f} kΩ in parallel → {i_sink:.1f} mA sink (spec ≤ 3 mA); rise {t_rise:.0f} ns at 100 pF (≤ 300 ns at 400 kHz)",
      "if the ATECC608 board adds its own pull-ups, cut the SparkFun I2C jumper")
check("i2c", True, "I2C addresses", "ATECC608 0x60 · PCF8523 0x68 · ST25DV 0x53/0x57 (+0x51/0x55 RF switch) → no clash [pcf]")
check("i2c", D["rtc_vcc"] <= 3.6, "RTC board pull-up voltage", f"the RTC board pulls SDA/SCL to its VIN; VIN = {D['rtc_vcc']} V (Radxa pins are 3.63 V max) — the STEMMA QT cable carries 3.3 V from the Radxa header",
      "power the RTC board from 3.3 V only, never from the 5 V boost")
check("mic", MIC_RAIL_MA <= 50, "Mic rail load on the Radxa 3.3 V pin", f"{MIC_RAIL_MA:.1f} mA (2 × 0.6 mA mics + 5 mA eye light); slide switch rated 300 mA")
check("mic", True, "Eye light truthfulness", "the eye light hangs on the same switched rail as the mics: lit ⇔ mics powered; no firmware path to it")
check("mic", D["mic_data_pulldown_ohm"] >= 47000, "Mic data line with the mics cut", f"{D['mic_data_pulldown_ohm'] // 1000} kΩ pull-down on I2S DATA: with the mic rail off the line reads silence, not noise; bring-up: record with the switch off and check for digital zero")
if D.get("erm_driver") == "PN2222A":
    ib = (3.3 - 0.75) / D["erm_base_ohm"] * 1000
    forced = 80 / ib
    check("haptic", forced <= 20 and ib <= 8, "Vibration motor driver from 3.3 V GPIO", f"PN2222A (TO-92) with {D['erm_base_ohm']} Ω base: IB ≈ {ib:.1f} mA for an 80 mA motor → forced β {forced:.0f}, deep in saturation (VCE ≤ 0.3 V) [pn2222, erm]; 1N4148 across the motor for the flyback",
          "lower the base resistor to 330 Ω")
else:
    fet_ok = D["erm_fet"] != "2N7000" and D["erm_fet_vgs_spec"] <= 3.0
    check("haptic", fet_ok, "Vibration motor driver from 3.3 V GPIO", f"{D['erm_fet']}, RDS(on) specified at VGS = {D['erm_fet_vgs_spec']} V" if fet_ok else "2N7000: VGS(th) up to 3.0 V [q2n7000]", "use a logic-level MOSFET")

out = {"design": D, "sources": SRC, "assumptions": {"R_INT": R_INT, "OCV": OCV, "BOOST_EFF": BOOST_EFF, "V_CUTOFF": V_CUTOFF, "RADXA_W": RADXA_W},
       "runs": [{k: v for k, v in r.items() if k != "trace"} for r in runs], "checks": checks}
(HERE / "power_report.json").write_text(json.dumps(out, indent=1))
print(f"{sum(c['ok'] for c in checks)}/{len(checks)} pass → {HERE / 'power_report.json'}")
for r in runs:
    print(f"  {r['bound']:4s} {r['scenario']}: {r['runtime_h']} h, min {r['v_min']} V")
