Download build/study.mjs from skailiner/threshold: direct link, hf CLI and curl.
- Browser
- Download file 12.6 kB
-
https://huggingface.co/spaces/skailiner/threshold/resolve/main/build/study.mjs
- Command line
-
hf download hf://spaces/skailiner/threshold/build/study.mjs
-
curl -L -o study.mjs https://huggingface.co/spaces/skailiner/threshold/resolve/main/build/study.mjs
12.6 kB
| 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); | |
| } | |
| } | |