import assert from 'node:assert/strict'; import { climates, defaults, baseline, configure, validateDesign, solarAt, beamShadeFraction, thermalStep, ventilationAt, simulateDay, summarize, comparisonText, ROOM } from '../build/model.mjs'; let count = 0; function test(name, run) { run(); count++; console.log('PASS', name); } const near = (a,b,tolerance=1e-8) => assert.ok(Math.abs(a-b) <= tolerance, `${a} differs from ${b} by more than ${tolerance}`); test('Exact thermal integration and energy conservation', () => { const input = { inside: 20, outside: 35, conductance: 90, capacity: 1e6, gains: 200, seconds: 600 }; const result = thermalStep(input); const equilibrium = 35 + 200/90; near(result.temperature, equilibrium + (20-equilibrium)*Math.exp(-90*600/1e6)); near(result.residualJ, 0, 1e-6); near(thermalStep({...input, inside:25, outside:25, gains:0}).temperature, 25); near(thermalStep({...input, conductance:0}).temperature, 20.12); }); test('Heat flow is signed and thermal storage slows changes', () => { const input = { inside:25, outside:35, conductance:50, capacity:1e6, gains:0, seconds:600 }; assert.ok(thermalStep({...input, conductance:150}).temperature > thermalStep(input).temperature); assert.ok(thermalStep({...input, outside:15, conductance:150}).temperature < thermalStep({...input,outside:15}).temperature); assert.ok(thermalStep({...input,capacity:5e6}).temperature < thermalStep(input).temperature); }); test('Night solar is zero for every facade and climate', () => { for (const climate of Object.values(climates)) for (let orientation=0;orientation<=315;orientation+=45) { for (const hour of [0,1,3,21,23]) { const s = solarAt(climate,{...defaults,orientation},hour); assert.equal(s.watts,0); assert.equal(s.beam,0); assert.equal(s.diffuse,0); } for(let hour=0;hour<24;hour+=0.125) { const s=solarAt(climate,{...defaults,orientation},hour); assert.ok(Number.isFinite(s.watts) && s.watts>=0); } } }); test('Horizontal overhang blocks more direct beam at high sun and greater depth', () => { assert.ok(beamShadeFraction(.9,68,0)>beamShadeFraction(.9,30,0)); assert.ok(beamShadeFraction(1.8,30,0)>beamShadeFraction(.9,30,0)); assert.equal(beamShadeFraction(0,68,0),0); assert.equal(beamShadeFraction(2.4,-3,0),0); for (const climate of Object.values(climates)) for(let hour=0;hour<24;hour+=.25) { assert.ok(solarAt(climate,{...defaults,shade:2.4},hour).watts <= solarAt(climate,{...defaults,shade:0},hour).watts + 1e-8); } }); test('Purge boundaries, proportional ramp and actual infiltration', () => { for(const [outdoor,inside] of [[20,25],[24.5,25],[26,26.5],[35,25]]) { const v=ventilationAt(climates.humid,defaults,outdoor,inside); assert.equal(v.purge,false); near(v.ach,ROOM.infiltrationACH); } const partial=ventilationAt(climates.humid,defaults,24,25.25); const full=ventilationAt(climates.humid,defaults,24,25.5); near(partial.openingFraction,.5); near(full.openingFraction,1); assert.ok(full.ach>partial.ach); }); test('Complete configuration validation is atomic and rejects coercion', () => { const original={design:{...defaults},climate:'humid',hour:16,lens:'heat'}; const frozen=JSON.stringify(original); for(const input of [ {shade:1.5,lens:'invalid'}, {glazing:20,climate:'nowhere'}, {mass:50,hour:24}, {shade:null},{mass:true},{glazing:'34'},{hour:NaN},{unknown:1},{orientation:135.1},[], ]) { assert.throws(()=>configure(original,input)); assert.equal(JSON.stringify(original),frozen); } const changed=configure(original,{shade:1.5,climate:'arid',hour:23,lens:'air'}); assert.equal(changed.design.shade,1.5); assert.equal(changed.hour,23); assert.equal(JSON.stringify(original),frozen); assert.deepEqual(validateDesign(defaults),defaults); }); test('Occupied duration is exactly twelve hours; gain integral has correct units', () => { const s=simulateDay(climates.humid,defaults); const result=summarize(s); assert.equal(s.steps.filter(x=>x.occupied).length,72); assert.equal(result.occupiedHours,12); const constant={...s,steps:s.steps.map(row=>({...row,solarW:100}))}; near(summarize(constant).solarKWh,2.4); assert.equal(s.hourly.length,24); }); test('Periodic convergence is independent of starting temperature', () => { for(const climate of Object.values(climates)) for(const design of [defaults,baseline,{...defaults,mass:100,ventilation:5}]) { const cold=simulateDay(climate,design,{initialTemperature:5}); const hot=simulateDay(climate,design,{initialTemperature:50}); assert.ok(cold.converged && hot.converged); near(summarize(cold).peak.temperature,summarize(hot).peak.temperature,.02); for(let i=0;i { for(const climate of Object.values(climates)) for(const design of [defaults,baseline,{...defaults,mass:0,ventilation:100}]) { const ten=summarize(simulateDay(climate,design)); const five=summarize(simulateDay(climate,design,{minutes:5})); near(ten.peak.temperature,five.peak.temperature,.2); near(ten.bandHours,five.bandHours,.5); near(ten.meanACH,five.meanACH,.2); near(ten.purgeHours,five.purgeHours,.5); } }); test('Design grid remains finite, deterministic, energy-balanced and converged', () => { let cases=0; for(const climate of Object.values(climates)) for(const orientation of [0,90,180,270]) for(const glazing of [12,72]) for(const shade of [0,2.4]) for(const mass of [0,100]) for(const ventilation of [5,100]) { const design={orientation,glazing,shade,mass,ventilation}; const s=simulateDay(climate,design); assert.ok(s.converged,JSON.stringify({climate:climate.name,design})); assert.ok(s.steps.every(r=>Number.isFinite(r.temperature) && r.ach>=.35 && r.solarW>=0)); assert.ok(s.steps.every(r=>Math.abs(r.residualJ)<1e-5)); assert.ok(summarize(s).bandPercent>=0 && summarize(s).bandPercent<=100); cases++; } assert.equal(cases,192); assert.deepEqual(simulateDay(climates.humid,defaults),simulateDay(climates.humid,defaults)); }); test('Worse schemes are reported as higher, never as zero improvement', () => { const reference={peak:{temperature:30}}; assert.match(comparisonText({peak:{temperature:32}},reference),/2.0°C higher/); assert.match(comparisonText({peak:{temperature:28}},reference),/2.0°C lower/); }); console.log(`\n${count} test groups passed, including 192 parameter-grid cases.`);