import { baseline, defaults, climates, configure, validateDesign, simulateDay, summarize, formatHour, MODEL_VERSION, ROOM, } from "./model.mjs"; export const STUDY_LIMIT = 32768; export const IDS = ["A", "B"]; const exact = (value, fields, label) => { if ( !value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length !== fields.length || fields.some((key) => !Object.hasOwn(value, key)) ) throw new Error(label + " has missing or unknown fields."); return value; }; const name = (value) => { if (typeof value !== "string" || !value.trim() || value.trim().length > 60) throw new Error("Give each alternative a name of 1–60 characters."); for (const c of value) { const n = c.codePointAt(0); if ( n < 32 || n === 127 || n === 133 || n === 0x2028 || n === 0x2029 || (n >= 0xd800 && n <= 0xdfff) ) throw new Error("Names must be single-line text with valid Unicode."); } return value.trim(); }; export function newStudy() { return validateStudy({ schema: "threshold-study/v1", model: MODEL_VERSION, climate: "humid", hour: 16, lens: "heat", active: "B", alternatives: { A: { name: "Example starting point", design: { ...baseline } }, B: { name: "My alternative", design: { ...defaults } }, }, }); } export function validateStudy(input) { const s = exact( input, ["schema", "model", "climate", "hour", "lens", "active", "alternatives"], "Study", ); if (s.schema !== "threshold-study/v1" || s.model !== MODEL_VERSION) throw new Error("Unsupported THRESHOLD study or model version."); if (!IDS.includes(s.active)) throw new Error("Choose alternative A or B."); exact(s.alternatives, IDS, "Alternatives"); const alternatives = Object.fromEntries( IDS.map((id) => { const option = exact(s.alternatives[id], ["name", "design"], "Alternative " + id); return [id, { name: name(option.name), design: validateDesign(option.design) }]; }), ); // Validate every imported field against a known valid configuration, not a // partially trusted prior object whose untouched fields escape configure(). const view = configure( { design: { ...defaults }, climate: "humid", hour: 16, lens: "heat" }, { climate: s.climate, hour: s.hour, lens: s.lens, ...alternatives[s.active].design }, ); return { schema: s.schema, model: MODEL_VERSION, climate: view.climate, hour: view.hour, lens: view.lens, active: s.active, alternatives, }; } export function saveStudy(input) { return JSON.stringify(validateStudy(input), null, 2) + "\n"; } export function parseStudy(text) { if (new TextEncoder().encode(text).byteLength > STUDY_LIMIT) throw new Error("Choose a THRESHOLD study JSON file of 32 KiB or smaller."); return validateStudy(JSON.parse(text)); } export const optionLabel = (study, id) => `${id} · ${study.alternatives[id].name}`; export const MECHANISMS = { orientation: "Orientation changes modeled facade solar exposure and the wind-facing factor, not just the direction of the drawing.", glazing: "Glazing changes window solar gain and envelope conductance. It is not a daylight-quality calculation.", shade: "Overhang depth changes direct-beam shading; diffuse and ground-reflected radiation remain unshaded. Daylight quality is not modeled.", mass: "Storage changes effective heat capacity, not insulation. A lower peak need not mean more occupied time inside the study band.", ventilation: "The effective opening sets potential wind/stack flow, but actual ACH depends on temperature and controller timing. A larger opening need not increase occupied mean ventilation.", }; export function evaluateStudy( input, calculate = (climate, design) => { const simulation = simulateDay(climates[climate], design); return { simulation, summary: summarize(simulation) }; }, ) { const s = validateStudy(input); const values = Object.fromEntries( IDS.map((id) => [id, calculate(s.climate, s.alternatives[id].design)]), ); const a = values.A.summary, b = values.B.summary; const changes = Object.keys(defaults) .filter((key) => s.alternatives.A.design[key] !== s.alternatives.B.design[key]) .map((key) => ({ key, from: s.alternatives.A.design[key], to: s.alternatives.B.design[key], mechanism: MECHANISMS[key], })); return { study: s, ...values, changes, settled: a.converged && b.converged, delta: { peakC: b.peak.temperature - a.peak.temperature, bandHours: b.bandHours - a.bandHours, solarKWh: b.solarKWh - a.solarKWh, meanACH: b.meanACH - a.meanACH, purgeHours: b.purgeHours - a.purgeHours, }, }; } export const signed = (value, digits = 2) => { const n = Math.abs(value) < 0.5 * 10 ** -digits ? 0 : value; return (n > 0 ? "+" : "") + n.toFixed(digits); }; export function peakInterval(value) { const half = value.simulation.minutes / 120; return `${formatHour(value.summary.peak.hour - half)}–${formatHour(value.summary.peak.hour + half)}`; } export function comparisonSentence(result) { if (!result.settled) return "Unsettled comparison: at least one alternative did not reach a repeating day. Do not read the displayed differences as a settled design comparison."; const d = result.delta; return `B minus A: daily peak ${signed(d.peakC)}°C; occupied study-band time ${signed(d.bandHours)} h; window solar gain ${signed(d.solarKWh)} kWh; occupied mean ventilation ${signed(d.meanACH)} ACH. These are separate trade-offs, not an overall quality score. The two peaks can occur at different times.`; } export function studyBrief(input) { const r = evaluateStudy(input), s = r.study, quote = (value) => JSON.stringify(value); const lines = [ "THRESHOLD | TWO-ALTERNATIVE STUDY BRIEF", `Model: ${MODEL_VERSION}. Shared synthetic day: ${climates[s.climate].name}.`, "Teaching model only. No measured site weather, human comfort prediction, energy certificate or construction advice.", "", comparisonSentence(r), "", "ALTERNATIVES", ]; for (const id of IDS) { const v = r[id], a = s.alternatives[id], m = v.summary; lines.push( `${id}: ${quote(a.name)}`, `Settings: ${JSON.stringify(a.design)}`, `Repeating day ${m.converged ? "settled" : "UNSETTLED"} after ${m.warmupDays} cycles.`, `Daily maximum ${v.simulation.minutes}-minute step-average model temperature: ${m.peak.temperature.toFixed(2)}°C during ${peakInterval(v)} local solar time.`, `Occupied time inside 22–28°C: ${m.bandHours.toFixed(2)} of ${m.occupiedHours} h; window solar gain ${m.solarKWh.toFixed(2)} kWh/day; occupied mean ventilation ${m.meanACH.toFixed(2)} ACH; purge active ${m.purgeHours.toFixed(2)} h/day.`, ); } lines.push("", "WHAT CHANGED"); if (!r.changes.length) lines.push( "The two designs have identical settings; names and viewing controls do not change the calculation.", ); for (const c of r.changes) lines.push(`${c.key}: ${c.from} → ${c.to}. ${c.mechanism}`); lines.push( r.changes.length > 1 ? "Multiple settings changed together. Do not attribute their combined differences to a single lever. Copy one alternative to the other, then change one setting for a controlled model comparison." : "Change one setting at a time to explore this model; it is not empirical causal evidence.", "", "SHARED ASSUMPTIONS", `Room dimensions 6 × 6 × 3 m. Fixed parameters: ${JSON.stringify(ROOM)}`, "One effective temperature for mixed air and thermally coupled fabric; adiabatic floor; one 18 m² window facade. Ten-minute exact constant-forcing steps repeat up to 60 days.", "Internal gains 4 W/m² during [08:00,20:00), otherwise 1 W/m². Study band 22–28°C is illustrative, not a comfort standard.", "Fixed solar declination +20°. Wind coefficient 0.25; stack discharge 0.6; stack height 1.5 m. Purge begins above 25°C indoors with outdoors 0.5°C cooler; full opening at 25.5°C and 1°C difference.", "No HVAC, moisture/latent heat, indoor air velocity, air quality, radiant comfort, detailed daylight, adjacent buildings or energy bills are calculated. Use qualified review, real weather and validated tools for real design decisions.", "", "KEEP YOUR WORK", "Save study JSON separately to reopen both alternatives and viewing settings. This text brief is not an editable backup. Nothing is autosaved.", "Names are user-supplied. Review the file before sharing.", "https://skailiner-threshold.static.hf.space/index.html", ); return lines.join("\n") + "\n"; } export class StudyWorkspace { constructor(input = newStudy()) { this.value = validateStudy(input); this.names = { A: this.value.alternatives.A.name, B: this.value.alternatives.B.name }; this.epoch = 0; this.busy = false; this.pending = null; } read() { return structuredClone({ study: this.value, names: this.names, epoch: this.epoch, busy: this.busy, pending: this.pending, dirty: this.dirty(), }); } dirty() { return IDS.some((id) => this.names[id] !== this.value.alternatives[id].name); } unlocked() { if (this.busy || this.pending) throw new Error("Finish or cancel the open file/confirmation first."); } guard() { this.unlocked(); if (this.dirty()) throw new Error("Apply or discard the unfinished alternative names first."); } commit(input) { const valid = validateStudy(input); this.value = valid; this.names = { A: valid.alternatives.A.name, B: valid.alternatives.B.name }; this.busy = false; this.pending = null; this.epoch++; } editName(id, text) { this.unlocked(); if (!IDS.includes(id) || typeof text !== "string") throw new Error("Invalid name draft."); this.names = { ...this.names, [id]: text }; this.epoch++; } applyNames() { this.unlocked(); this.commit({ ...this.value, alternatives: Object.fromEntries( IDS.map((id) => [id, { ...this.value.alternatives[id], name: this.names[id] }]), ), }); } discardNames() { this.unlocked(); this.names = { A: this.value.alternatives.A.name, B: this.value.alternatives.B.name }; this.epoch++; } configure(input) { this.guard(); const s = this.value, view = configure( { design: s.alternatives[s.active].design, climate: s.climate, hour: s.hour, lens: s.lens }, input, ); this.commit({ ...s, climate: view.climate, hour: view.hour, lens: view.lens, alternatives: { ...s.alternatives, [s.active]: { ...s.alternatives[s.active], design: view.design }, }, }); } select(id) { this.guard(); if (!IDS.includes(id)) throw new Error("Choose A or B."); this.commit({ ...this.value, active: id }); } request(input, label) { this.guard(); const candidate = validateStudy(input); this.pending = { study: candidate, label }; this.epoch++; } copy() { this.guard(); const from = this.value.active, to = from === "A" ? "B" : "A"; this.request( { ...this.value, alternatives: { ...this.value.alternatives, [to]: { ...this.value.alternatives[to], design: { ...this.value.alternatives[from].design }, }, }, }, `Copy ${from} settings to ${to}`, ); } confirm() { if (!this.pending) throw new Error("No replacement is pending."); this.commit(this.pending.study); } dismiss() { if (!this.pending) throw new Error("No replacement is pending."); this.pending = null; this.epoch++; } beginFile() { this.guard(); this.busy = true; return ++this.epoch; } stageFile(ticket, text) { if (!this.busy || ticket !== this.epoch) throw new Error("This file read is stale. Newer work is unchanged."); const candidate = parseStudy(text); this.busy = false; this.pending = { study: candidate, label: "Open saved two-alternative study" }; this.epoch++; } cancelFile(ticket) { if (this.busy && ticket === this.epoch) { this.busy = false; this.epoch++; } } save() { this.guard(); return saveStudy(this.value); } brief() { this.guard(); return studyBrief(this.value); } }