threshold / tests /model.test.mjs
citispire
Publish verified THRESHOLD studio with GitHub source snapshot
9b132d8
Raw History Blame Contribute Delete
6.83 kB
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<cold.steps.length;i++) near(cold.steps[i].temperature,hot.steps[i].temperature,.02);
}
const unsettled=simulateDay(climates.humid,defaults,{maximumDays:1,initialTemperature:5});
assert.equal(unsettled.converged,false); assert.equal(unsettled.warmupDays,1);
});
test('Ten-minute steps agree with five-minute steps within declared educational tolerance', () => {
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.`);