diff --git a/docs/engine-standardization.md b/docs/engine-standardization.md index abf6d145..f75dffd5 100644 --- a/docs/engine-standardization.md +++ b/docs/engine-standardization.md @@ -173,6 +173,19 @@ load-bearing). the §1 table regenerated from the engine**, so this document's figures acquire a guard. Blast radius: nine live pages load the engine. + *Landed 2026-10-06 (v3.92.0) — `tests/psychro-mixair.spec.js`.* The + §1 table reproduced from the engine figure for figure, so it is + unchanged here. The spec now recomputes every row under forms A / E + / B / F (plus C / D, which are B at the page's precision) and checks + each figure twice: against literals in the spec, and against **this + table's own cells**, which it parses out of §1 — so an edit to the + table that the engine does not back goes red, and the table's + markdown shape (row labels, column order) is now load-bearing. + Column A stays the *inline* page form, transcribed in the spec, since + that is the pre-#236 arithmetic steps 1–2 replace. Line cites in §2 + for the engine's `cp` sites predate the fold: it was `:224` / `:231` + / `:261` plus the inversion at `:517` on `a2ad39f`, and all four now + call the private `cp(W)`. 1. **psychrometric-chart** — one call site, already loads the engine; label `:334` gains "by mass". Smallest diff, first page. 2. **air-mixing** — both tabs, three streams. `exact.fogging` becomes diff --git a/html/scripts/psychro-engine.js b/html/scripts/psychro-engine.js index 52f9ffd1..387a995b 100644 --- a/html/scripts/psychro-engine.js +++ b/html/scripts/psychro-engine.js @@ -33,6 +33,8 @@ // Psychro.computeProcess(stage, cfm) // Psychro.invertProcess(inlet, opts) // Psychro.mixStreams(streams, P) +// Psychro.mixAir({ streams: [{ state, share }, …], basis, P }) +// Psychro.mixFraction({ oa, ra, targetTdb, basis, recovery, P }) // // Why two tiers: the primitives are the unopinionated ASHRAE math any // future psych tool will want directly (a coil-sizing calculator wants @@ -43,8 +45,35 @@ // math in computeProcess) — keeping those namespaced gives future tools // room to grow their own solver methods (Psychro.coilSizing, etc.) // without bare-name collisions and lets a future Psychro.* namespace -// flip survive contained churn. Candidate second consumers — air-mixing, -// coil-sizing, economizer-ratio — are tracked in site-ideas-and-friction. +// flip survive contained churn. +// +// mixStreams is the KERNEL — basis-agnostic weights, fog re-solve — and +// mixAir / mixFraction are the PAGE-FACING combinators over it +// (codebase-issues #228; design note docs/engine-standardization.md §3). +// What a stream's `share` IS depends on the `basis` the caller names: +// • basis 'volume' — `share` is CFM, or anything proportional to +// volumetric flow (a damper split, a % OA by volume). Each share is +// converted to a dry-air mass weight as share ÷ state.v before the +// kernel sees it, which is the exact arithmetic air-mixing's flow +// tab does by hand (CFM ÷ v). +// • basis 'mass' — `share` is lb_da/h or a plain mass fraction, and +// goes to the kernel unchanged. +// Either way the shares are normalised, so fractions need not sum to 1 +// (a page's own sum check stays on the page), and the result reports +// BOTH normalised sets — shares.mass and shares.volume — so a page can +// print which quantity its "% OA" was. `linear` is the hand arithmetic +// (Σ share·tdb, Σ share·W) on the shares AS GIVEN, i.e. on the stated +// basis; `exact` is always the kernel on the MASS weights. +// mixFraction inverts the two-stream case: 'linear' recovery is +// (MA − RA) ÷ (OA − RA) on dry-bulbs, 'exact' bisects mixAir's exact +// dry-bulb onto the target, and the returned fraction is on the basis +// named. `within` is true when the target lies between the two stream +// dry-bulbs (inclusive). Outside them, 'linear' still returns the +// arithmetic fraction (below 0 or above 1) with within:false, and +// 'exact' returns ok:false — a two-stream mix has no bracket there. +// When `P` is omitted, both default to the pressure the first stream's +// state was solved at (`state.P`), never to sea level, so a state +// solved at altitude is not silently re-mixed at P_STD. // // What lives here: anything pure. What does NOT live here: anything that // touches the DOM, window.Units, or any specific page's HTML structure. @@ -135,6 +164,12 @@ function wetBulbFromHumRatio(W, tdb, P) { const Psychro = (function () { 'use strict'; + // Humid specific heat, Btu / (lb_da·°F): the 0.240 dry-air term plus + // 0.444·W for the vapour the pound of dry air carries. Private — one + // home for the constant pair computeProcess, invertProcess and the + // mixStreams dry-bulb recovery all use. + const cp = W => 0.240 + 0.444 * W; + // Build moist-air state from (mode, dry-bulb, the second value, pressure). // mode ∈ {rh, wb, dp, w, h}. function solveState(mode, tdb, second, P) { @@ -221,14 +256,14 @@ const Psychro = (function () { if (cfm && inlet.v > 0) { const mDot = cfm * 60 / inlet.v; // lb_da / h qTotal = mDot * dH; // Btu/h - const cpIn = 0.240 + 0.444 * inlet.W; // Btu/(lb_da·°F) + const cpIn = cp(inlet.W); // Btu/(lb_da·°F) qSens = mDot * cpIn * dDb; qLat = qTotal - qSens; } let shr = null; if (type === 'cool') { // SHR by enthalpy ratios (CFM-independent — works even without flow). - const cpIn = 0.240 + 0.444 * inlet.W; + const cpIn = cp(inlet.W); const dhSens = cpIn * dDb; if (Math.abs(dH) > 1e-9) shr = dhSens / dH; } @@ -258,7 +293,7 @@ const Psychro = (function () { if (!isFinite(qSens) || qSens < 0) return { ok: false, error: 'Sensible load can’t be negative.' }; if (type === 'cool' && (!isFinite(qLat) || qLat < 0)) return { ok: false, error: 'Latent load can’t be negative.' }; const mDot = cfm * 60 / inlet.v; // lb dry air / h - const cpIn = 0.240 + 0.444 * inlet.W; // Btu / (lb_da·°F) + const cpIn = cp(inlet.W); // Btu / (lb_da·°F) const sign = type === 'cool' ? -1 : 1; // cooling lowers dry-bulb and enthalpy const tdbOut = inlet.tdb + sign * qSens / (mDot * cpIn); const hOut = inlet.h + sign * (qSens + qLat) / mDot; @@ -514,7 +549,7 @@ const Psychro = (function () { W += f * streams[i].state.W; h += f * streams[i].state.h; } - const tdb = (h - 1061 * W) / (0.240 + 0.444 * W); + const tdb = (h - 1061 * W) / cp(W); const Wsat = satHumRatio(tdb, P); // `Wsat > 0` is not paranoia: above the boiling point for the // pressure the saturation formula degenerates NEGATIVE @@ -540,5 +575,154 @@ const Psychro = (function () { return out; } - return { solveState, buildState, computeProcess, invertProcess, mixStreams }; + // Page-facing mixer over the mixStreams kernel (codebase-issues #228, + // docs/engine-standardization.md §3). opts = + // { streams: [{ state, share }, …], basis: 'mass' | 'volume', P } + // `state` is a solveState / buildState result; what `share` means is + // set by `basis` (see the header). Returns + // { ok: true, basis, P, + // shares: { mass: [f…], volume: [f…] }, both sets normalised + // exact: mixStreams on the MASS weights (fogging / condensate too) + // linear: { tdb, W, state } } Σ share·x on the shares as + // given, + buildState of it + // or buildState's { ok: false, error } shape. Guards run in a fixed + // order — no streams · an invalid state · a share non-finite or + // negative after ONE Number() coercion (mixStreams' '0200' lesson) · + // zero total · basis outside the enum · under 'volume', a state with + // no positive specific volume — and a kernel refusal (a mix past the + // boiling point, #238) is handed back as-is. + // + // Under 'mass' the kernel gets the coerced shares unchanged, so + // `exact` IS mixStreams on the same weights, bit for bit. Under + // 'volume' it gets share ÷ v per stream — air-mixing's CFM ÷ v. + // The engine knows nothing about display precision: rounding the + // linear blend to what a page prints is the page's arithmetic. + function mixAir(opts) { + const o = opts || {}; + const streams = o.streams; + if (!Array.isArray(streams) || streams.length < 1) { + return { ok: false, error: 'Mix at least one air stream.' }; + } + const raw = []; + for (let i = 0; i < streams.length; i++) { + const s = streams[i]; + if (!s || !s.state || !s.state.ok) { + return { ok: false, error: 'One of the mixed streams has an invalid air state.' }; + } + } + let total = 0; + for (let i = 0; i < streams.length; i++) { + const f = Number(streams[i].share); + if (!isFinite(f)) return { ok: false, error: 'Enter a numeric share for every stream.' }; + if (f < 0) return { ok: false, error: 'Stream share can’t be negative.' }; + raw.push(f); + total += f; + } + if (!(total > 0)) return { ok: false, error: 'Enter a positive total share.' }; + const basis = o.basis; + if (basis !== 'mass' && basis !== 'volume') { + return { ok: false, error: 'Name the mixing basis — mass or volume.' }; + } + if (basis === 'volume') { + for (let i = 0; i < streams.length; i++) { + if (!(streams[i].state.v > 0)) { + return { ok: false, error: 'One of the mixed streams has no positive specific volume.' }; + } + } + } + const P = o.P === undefined ? streams[0].state.P : o.P; + + const given = raw.map(f => f / total); + // Kernel weights: the shares themselves on a mass basis, share ÷ v + // (dry-air mass per unit of volumetric share) on a volume basis. + const massW = basis === 'mass' + ? raw + : raw.map((f, i) => f / streams[i].state.v); + const volW = basis === 'volume' + ? raw + : raw.map((f, i) => f * streams[i].state.v); + const sum = a => a.reduce((x, y) => x + y, 0); + const massTotal = sum(massW); + const volTotal = sum(volW); + const shares = { + mass: basis === 'mass' ? given : massW.map(m => m / massTotal), + volume: basis === 'volume' ? given : volW.map(q => q / volTotal), + }; + + const exact = mixStreams( + streams.map((s, i) => ({ state: s.state, flow: massW[i] })), P); + if (!exact.ok) return exact; + + let tdb = 0, W = 0; + for (let i = 0; i < streams.length; i++) { + tdb += given[i] * streams[i].state.tdb; + W += given[i] * streams[i].state.W; + } + return { + ok: true, basis, P, shares, exact, + linear: { tdb, W, state: buildState(tdb, W, P) }, + }; + } + + // Inverse of a two-stream mixAir: the outdoor-air fraction that lands + // the mixed dry-bulb on `targetTdb`. opts = + // { oa, ra, targetTdb, basis: 'mass' | 'volume', + // recovery: 'linear' | 'exact', P } + // `oa` / `ra` are solveState / buildState results. Returns + // { ok: true, fraction, within } + // with `fraction` the OA share on the basis named (0…1, not percent). + // • 'linear' — (MA − RA) ÷ (OA − RA), economizer-ratio's form, which + // is exactly the inverse of mixAir(…).linear.tdb. Basis-free in + // its arithmetic; the basis argument names what the fraction is. + // A target outside the two dry-bulbs still returns the arithmetic + // fraction, below 0 or above 1, with within:false. + // • 'exact' — bisects mixAir(…).exact.tdb onto the target. Outside + // the two dry-bulbs there is no bracket, so it returns ok:false. + // Guards, in order: an invalid stream state · a non-finite target · + // basis outside the enum · recovery outside the enum · OA and RA at + // the same dry-bulb (no unique fraction) · ('exact' only) no bracket. + function mixFraction(opts) { + const o = opts || {}; + const oa = o.oa, ra = o.ra; + if (!oa || !oa.ok || !ra || !ra.ok) { + return { ok: false, error: 'Outdoor or return air has an invalid air state.' }; + } + const target = Number(o.targetTdb); + if (!isFinite(target)) return { ok: false, error: 'Enter a numeric mixed-air target.' }; + const basis = o.basis; + if (basis !== 'mass' && basis !== 'volume') { + return { ok: false, error: 'Name the mixing basis — mass or volume.' }; + } + const recovery = o.recovery; + if (recovery !== 'linear' && recovery !== 'exact') { + return { ok: false, error: 'Name the recovery — linear or exact.' }; + } + if (oa.tdb === ra.tdb) { + return { ok: false, error: 'Outdoor and return air share a dry-bulb — no unique fraction.' }; + } + const within = target >= Math.min(oa.tdb, ra.tdb) && target <= Math.max(oa.tdb, ra.tdb); + if (recovery === 'linear') { + return { ok: true, fraction: (target - ra.tdb) / (oa.tdb - ra.tdb), within }; + } + if (!within) { + return { ok: false, error: 'The target is outside the two air streams — no mix reaches it.' }; + } + const P = o.P === undefined ? oa.P : o.P; + // More outdoor air moves the mix toward OA's dry-bulb, so `dir` + // says which side of the target a too-small fraction lands on. + const dir = oa.tdb > ra.tdb ? 1 : -1; + let lo = 0, hi = 1; + for (let i = 0; i < 64; i++) { + const mid = (lo + hi) / 2; + const m = mixAir({ + streams: [{ state: oa, share: mid }, { state: ra, share: 1 - mid }], + basis, P, + }); + if (!m.ok) return m; + if ((m.exact.tdb - target) * dir < 0) lo = mid; else hi = mid; + } + return { ok: true, fraction: (lo + hi) / 2, within }; + } + + return { solveState, buildState, computeProcess, invertProcess, mixStreams, mixAir, mixFraction }; })(); diff --git a/package-lock.json b/package-lock.json index d56e2207..da3cdaaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "controlsfreak.dev", - "version": "3.91.0", + "version": "3.92.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "controlsfreak.dev", - "version": "3.91.0", + "version": "3.92.0", "license": "MIT", "devDependencies": { "@11ty/eleventy": "^3.1.6", diff --git a/package.json b/package.json index 1e33e775..c7f58365 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "controlsfreak.dev", - "version": "3.91.0", + "version": "3.92.0", "private": true, "description": "Open calculators and reference tools for building-controls engineers — BACnet, Modbus, HVAC, and building automation work. No login, no ads, just tools that are actually useful on a job site.", "scripts": { diff --git a/tests/psychro-mixair.spec.js b/tests/psychro-mixair.spec.js new file mode 100644 index 00000000..e09a9764 --- /dev/null +++ b/tests/psychro-mixair.spec.js @@ -0,0 +1,513 @@ +// Engine-direct tests for Psychro.mixAir and Psychro.mixFraction — the +// page-facing mixing combinators over the mixStreams kernel +// (codebase-issues #228 step 0; contract: docs/engine-standardization.md +// §3). Lives under tests/*.spec.js so the same `npm test` (Playwright) +// runner picks it up; Playwright workers are Node processes, the `page` +// fixture is just unused here. +// +// LOADER: the psychro-mixstreams.spec.js shape — one BARE vm context, +// the engine run into it, the symbols read back out of that context's +// global lexical scope. The bare `{}` IS the purity assertion: the +// engine promises to touch neither `document` nor `window` (nor +// `window.Units`), and a violation throws ReferenceError right here. +// +// WHAT IS PINNED, AND HOW HARD: +// • The contract shape — mass-basis `exact` IS mixStreams on the same +// weights; `linear` IS Σ share·x; a volume basis converts through +// specificVolume; shares normalise; every ok:false guard fires IN +// ORDER with its message; no caveat string rides on a result (the +// owner's Q2 default, rulings §18.5). +// • The private cp(W) fold is a ZERO numeric change — asserted with +// toBe against the literal 0.240 + 0.444·W expression it replaced. +// • THE §1 TABLE. docs/engine-standardization.md §1 publishes seven +// mixing cases under every form the site ships; the last describe +// block recomputes them from the engine and checks each figure +// twice — against literals in this file, and against the doc's own +// table cells — so the design note's numbers have a guard. If a +// figure moves, this goes red and the doc gets re-derived, not the +// literal nudged. + +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { test, expect } = require('@playwright/test'); + +const ROOT = path.join(__dirname, '..'); +const ENGINE = path.join(ROOT, 'html', 'scripts', 'psychro-engine.js'); +const DESIGN = path.join(ROOT, 'docs', 'engine-standardization.md'); + +function loadEngine() { + const ctx = vm.createContext({}); + vm.runInContext(fs.readFileSync(ENGINE, 'utf8'), ctx, { filename: 'psychro-engine.js' }); + return vm.runInContext( + '({ Psychro, P_STD, enthalpy, specificVolume, satHumRatio, pressFromAltitude });', ctx); +} + +// Every numeric field a mixStreams / buildState result carries. +const FIELDS = ['tdb', 'W', 'P', 'pw', 'rh', 'twb', 'tdp', 'h', 'v', 'condensate']; + +function expectSameState(a, b, tol = 1e-9) { + expect(a.ok).toBe(true); + expect(b.ok).toBe(true); + expect(a.fogging).toBe(b.fogging); + for (const k of FIELDS) { + if (b[k] === undefined) continue; + if (!isFinite(b[k])) { expect(a[k]).toBe(b[k]); continue; } + expect(Math.abs(a[k] - b[k]), k).toBeLessThanOrEqual(tol); + } +} + +// A spread of stream pairs: winter, design-day, summer, hot-dry, and the +// fog corner (the case the #236 re-solve exists for). +function pairs(E) { + const S = (m, t, x) => E.Psychro.solveState(m, t, x, E.P_STD); + return [ + { oa: S('rh', 35, 80), ra: S('rh', 75, 50), f: 0.5 }, + { oa: S('rh', 0, 40), ra: S('rh', 75, 50), f: 0.2 }, + { oa: S('wb', 95, 75), ra: S('rh', 75, 50), f: 0.25 }, + { oa: S('rh', 105, 10), ra: S('rh', 75, 50), f: 0.3 }, + { oa: S('rh', -20, 40), ra: S('rh', 80, 50), f: 0.7 }, + ]; +} + +test.describe('Psychro.mixAir — contract', () => { + + test('both combinators load headless and sit on the Psychro namespace', () => { + const E = loadEngine(); + expect(typeof E.Psychro.mixAir).toBe('function'); + expect(typeof E.Psychro.mixFraction).toBe('function'); + // The kernel stays (owner default Q1) — added, not renamed. + expect(typeof E.Psychro.mixStreams).toBe('function'); + }); + + test('pure: runs in a bare context, and no caveat string rides on a result', () => { + const E = loadEngine(); + const [p] = pairs(E); + const m = E.Psychro.mixAir({ + streams: [{ state: p.oa, share: p.f }, { state: p.ra, share: 1 - p.f }], + basis: 'volume', P: E.P_STD, + }); + expect(Object.keys(m).sort()).toEqual(['P', 'basis', 'exact', 'linear', 'ok', 'shares']); + expect(Object.keys(m.linear).sort()).toEqual(['W', 'state', 'tdb']); + expect(Object.keys(m.shares).sort()).toEqual(['mass', 'volume']); + const f = E.Psychro.mixFraction({ oa: p.oa, ra: p.ra, targetTdb: 55, basis: 'volume', recovery: 'exact', P: E.P_STD }); + expect(Object.keys(f).sort()).toEqual(['fraction', 'ok', 'within']); + // Owner Q2 default: the engine returns numbers, never page prose. + // Code only — the header legitimately NAMES window.Units as the + // thing the engine does not touch. + const src = fs.readFileSync(ENGINE, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + expect(src).not.toMatch(/\bnote\s*:/); + expect(src).not.toMatch(/\bdocument\./); + expect(src).not.toMatch(/\bwindow\./); + }); + + test("mass basis: `exact` IS mixStreams on the same weights, every field", () => { + const E = loadEngine(); + for (const p of pairs(E)) { + for (const w of [[p.f, 1 - p.f], [3, 7], ['2', '5']]) { + const m = E.Psychro.mixAir({ + streams: [{ state: p.oa, share: w[0] }, { state: p.ra, share: w[1] }], + basis: 'mass', P: E.P_STD, + }); + const k = E.Psychro.mixStreams( + [{ state: p.oa, flow: w[0] }, { state: p.ra, flow: w[1] }], E.P_STD); + expect(m.ok).toBe(true); + expectSameState(m.exact, k); + } + } + // Three streams, unnormalised — the kernel's associativity carries over. + const S = (t, r) => E.Psychro.solveState('rh', t, r, E.P_STD); + const st = [S(10, 60), S(72, 45), S(55, 90)]; + const sh = [1.5, 6, 2.5]; + const m = E.Psychro.mixAir({ streams: st.map((s, i) => ({ state: s, share: sh[i] })), basis: 'mass', P: E.P_STD }); + const k = E.Psychro.mixStreams(st.map((s, i) => ({ state: s, flow: sh[i] })), E.P_STD); + expectSameState(m.exact, k); + }); + + test('`linear` IS Σ share·x on the shares as given, and its state is buildState of that pair', () => { + const E = loadEngine(); + for (const p of pairs(E)) { + for (const basis of ['mass', 'volume']) { + const m = E.Psychro.mixAir({ + streams: [{ state: p.oa, share: p.f }, { state: p.ra, share: 1 - p.f }], + basis, P: E.P_STD, + }); + const tdb = p.f * p.oa.tdb + (1 - p.f) * p.ra.tdb; + const W = p.f * p.oa.W + (1 - p.f) * p.ra.W; + expect(Math.abs(m.linear.tdb - tdb)).toBeLessThan(1e-12); + expect(Math.abs(m.linear.W - W)).toBeLessThan(1e-15); + expectSameState(m.linear.state, E.Psychro.buildState(m.linear.tdb, m.linear.W, E.P_STD), 0); + // Basis-free by definition: the hand arithmetic does not + // care what the shares measure, only that they are given. + } + } + }); + + test('volume basis: mass weight = share ÷ specificVolume, and `exact` is the kernel on it', () => { + const E = loadEngine(); + for (const p of pairs(E)) { + const cfm = [p.f * 10000, (1 - p.f) * 10000]; + const m = E.Psychro.mixAir({ + streams: [{ state: p.oa, share: cfm[0] }, { state: p.ra, share: cfm[1] }], + basis: 'volume', P: E.P_STD, + }); + const v = [p.oa, p.ra].map(s => E.specificVolume(s.tdb, s.W, E.P_STD)); + expect(Math.abs(v[0] - p.oa.v)).toBeLessThan(1e-12); + const mw = cfm.map((q, i) => q / v[i]); + const M = mw[0] + mw[1]; + expect(Math.abs(m.shares.mass[0] - mw[0] / M)).toBeLessThan(1e-12); + expect(Math.abs(m.shares.mass[1] - mw[1] / M)).toBeLessThan(1e-12); + expect(Math.abs(m.shares.volume[0] - p.f)).toBeLessThan(1e-12); + expectSameState(m.exact, E.Psychro.mixStreams( + [{ state: p.oa, flow: mw[0] }, { state: p.ra, flow: mw[1] }], E.P_STD)); + // Same mix, named by its MASS shares, lands on the same air. + const back = E.Psychro.mixAir({ + streams: [{ state: p.oa, share: m.shares.mass[0] }, { state: p.ra, share: m.shares.mass[1] }], + basis: 'mass', P: E.P_STD, + }); + expectSameState(back.exact, m.exact); + expect(Math.abs(back.shares.volume[0] - p.f)).toBeLessThan(1e-12); + } + }); + + test('shares normalise — [2, 2] is [0.5, 0.5], and numeric strings coerce once', () => { + const E = loadEngine(); + for (const p of pairs(E)) { + for (const basis of ['mass', 'volume']) { + const run = sh => E.Psychro.mixAir({ + streams: [{ state: p.oa, share: sh[0] }, { state: p.ra, share: sh[1] }], + basis, P: E.P_STD, + }); + const a = run([2, 2]), b = run([0.5, 0.5]), c = run(['200', '200']); + for (const r of [a, c]) { + expectSameState(r.exact, b.exact, 1e-12); + expect(Math.abs(r.linear.tdb - b.linear.tdb)).toBeLessThan(1e-12); + expect(r.shares.mass[0]).toBeCloseTo(b.shares.mass[0], 12); + expect(r.shares.volume[0]).toBeCloseTo(b.shares.volume[0], 12); + } + expect(a.shares[basis]).toEqual([0.5, 0.5]); + expect(a.shares.mass[0] + a.shares.mass[1]).toBeCloseTo(1, 12); + expect(a.shares.volume[0] + a.shares.volume[1]).toBeCloseTo(1, 12); + } + } + }); + + test('P defaults to the first stream’s solve pressure, not to sea level', () => { + const E = loadEngine(); + const P = E.pressFromAltitude(5280); + const oa = E.Psychro.solveState('rh', 20, 60, P); + const ra = E.Psychro.solveState('rh', 72, 40, P); + const streams = [{ state: oa, share: 0.3 }, { state: ra, share: 0.7 }]; + const dflt = E.Psychro.mixAir({ streams, basis: 'mass' }); + const expl = E.Psychro.mixAir({ streams, basis: 'mass', P }); + expect(dflt.P).toBe(P); + expectSameState(dflt.exact, expl.exact, 0); + const fr = E.Psychro.mixFraction({ oa, ra, targetTdb: 55, basis: 'mass', recovery: 'exact' }); + const fe = E.Psychro.mixFraction({ oa, ra, targetTdb: 55, basis: 'mass', recovery: 'exact', P }); + expect(fr.fraction).toBe(fe.fraction); + }); + + test('every ok:false guard fires, in contract order, with its message', () => { + const E = loadEngine(); + const good = E.Psychro.solveState('rh', 70, 50, E.P_STD); + const bad = E.Psychro.solveState('rh', 70, 150, E.P_STD); + expect(bad.ok).toBe(false); + const noV = Object.assign({}, good, { v: 0 }); + const mix = (streams, basis = 'mass') => E.Psychro.mixAir({ streams, basis, P: E.P_STD }); + const cases = [ + // 1. no streams + [E.Psychro.mixAir({ streams: [], basis: 'mass', P: E.P_STD }), 'Mix at least one air stream.'], + [E.Psychro.mixAir(undefined), 'Mix at least one air stream.'], + // 2. an invalid state — beats a bad share and a bad basis on another stream + [mix([{ state: good, share: -1 }, { state: bad, share: 1 }], 'nope'), + 'One of the mixed streams has an invalid air state.'], + [mix([{ share: 1 }]), 'One of the mixed streams has an invalid air state.'], + // 3. a share non-finite or negative after one Number() coercion + [mix([{ state: good, share: 'abc' }], 'nope'), 'Enter a numeric share for every stream.'], + [mix([{ state: good, share: Infinity }]), 'Enter a numeric share for every stream.'], + [mix([{ state: good, share: -0.1 }, { state: good, share: 'x' }]), 'Stream share can’t be negative.'], + // 4. zero total — beats a bad basis + [mix([{ state: good, share: 0 }, { state: good, share: '0' }], 'nope'), 'Enter a positive total share.'], + // 5. basis outside the enum — beats a zero specific volume + [mix([{ state: noV, share: 1 }], 'cfm'), 'Name the mixing basis — mass or volume.'], + [E.Psychro.mixAir({ streams: [{ state: good, share: 1 }], P: E.P_STD }), 'Name the mixing basis — mass or volume.'], + // 6. under 'volume', a state with no positive specific volume + [mix([{ state: good, share: 1 }, { state: noV, share: 1 }], 'volume'), + 'One of the mixed streams has no positive specific volume.'], + ]; + for (const [r, msg] of cases) { + expect(r).toEqual({ ok: false, error: msg }); + } + // The v guard is volume-only: a mass basis never divides by v. + expect(mix([{ state: noV, share: 1 }], 'mass').ok).toBe(true); + }); + + test('the cp(W) fold is a zero numeric change', () => { + const E = loadEngine(); + const cpLit = W => 0.240 + 0.444 * W; + const inlet = E.Psychro.solveState('rh', 80, 50, E.P_STD); + const outlet = E.Psychro.solveState('rh', 55, 90, E.P_STD); + const pr = E.Psychro.computeProcess({ inlet, outlet, type: 'cool' }, 400); + const mDot = 400 * 60 / inlet.v; + expect(pr.qSens).toBe(mDot * cpLit(inlet.W) * (outlet.tdb - inlet.tdb)); + expect(pr.shr).toBe(cpLit(inlet.W) * (outlet.tdb - inlet.tdb) / (outlet.h - inlet.h)); + const inv = E.Psychro.invertProcess(inlet, { type: 'heat', cfm: 400, qSens: 5000 }); + expect(inv.tdb).toBe(inlet.tdb + 5000 / (mDot * cpLit(inlet.W))); + for (const p of pairs(E).slice(0, 4)) { + const W = 0.3 * p.oa.W + 0.7 * p.ra.W; + const h = 0.3 * p.oa.h + 0.7 * p.ra.h; + const k = E.Psychro.mixStreams([{ state: p.oa, flow: 0.3 }, { state: p.ra, flow: 0.7 }], E.P_STD); + expect(k.fogging).toBe(false); + expect(k.tdb).toBe((h - 1061 * W) / cpLit(W)); + } + }); + +}); + +test.describe('Psychro.mixFraction — contract', () => { + + test("'linear' is economizer-ratio's (MA − RA) ÷ (OA − RA), and closes on mixAir's linear blend", () => { + const E = loadEngine(); + for (const p of pairs(E)) { + for (const target of [p.ra.tdb, p.oa.tdb, 0.6 * p.oa.tdb + 0.4 * p.ra.tdb, 55]) { + const r = E.Psychro.mixFraction({ oa: p.oa, ra: p.ra, targetTdb: target, basis: 'volume', recovery: 'linear', P: E.P_STD }); + expect(r.ok).toBe(true); + // economizer-ratio.html's form, transcribed: (ma − ra) / (oa − ra). + expect(r.fraction).toBe((target - p.ra.tdb) / (p.oa.tdb - p.ra.tdb)); + const m = E.Psychro.mixAir({ + streams: [{ state: p.oa, share: r.fraction }, { state: p.ra, share: 1 - r.fraction }], + basis: 'volume', P: E.P_STD, + }); + if (r.within) expect(Math.abs(m.linear.tdb - target)).toBeLessThan(1e-9); + } + } + }); + + test("'within' flags the bracket; 'linear' still returns the arithmetic fraction outside it", () => { + const E = loadEngine(); + const S = (t, r) => E.Psychro.solveState('rh', t, r, E.P_STD); + const oa = S(35, 80), ra = S(75, 50); + const lin = t => E.Psychro.mixFraction({ oa, ra, targetTdb: t, basis: 'volume', recovery: 'linear', P: E.P_STD }); + expect(lin(55)).toEqual({ ok: true, fraction: 0.5, within: true }); + // (75 − 75) ÷ −40 is −0, so compare by value, not by Object.is. + expect(lin(75).within).toBe(true); + expect(lin(75).fraction === 0).toBe(true); + expect(lin(35)).toEqual({ ok: true, fraction: 1, within: true }); + const above = lin(85), below = lin(25); + expect(above.ok).toBe(true); + expect(above.within).toBe(false); + expect(above.fraction).toBeCloseTo(-0.25, 12); + expect(below.within).toBe(false); + expect(below.fraction).toBeCloseTo(1.25, 12); + // 'exact' has no bracket outside the two dry-bulbs. + const ex = t => E.Psychro.mixFraction({ oa, ra, targetTdb: t, basis: 'volume', recovery: 'exact', P: E.P_STD }); + expect(ex(85)).toEqual({ ok: false, error: 'The target is outside the two air streams — no mix reaches it.' }); + expect(ex(25).ok).toBe(false); + expect(ex(55).within).toBe(true); + }); + + test("'exact' closes: mixAir(…).exact.tdb lands on the target on both bases, fog included", () => { + const E = loadEngine(); + for (const p of pairs(E)) { + const lo = Math.min(p.oa.tdb, p.ra.tdb), hi = Math.max(p.oa.tdb, p.ra.tdb); + for (const target of [lo + 0.1 * (hi - lo), (lo + hi) / 2, hi - 0.1 * (hi - lo), lo, hi]) { + for (const basis of ['mass', 'volume']) { + // Both orientations: OA colder than RA and warmer than RA. + for (const [oa, ra] of [[p.oa, p.ra], [p.ra, p.oa]]) { + const r = E.Psychro.mixFraction({ oa, ra, targetTdb: target, basis, recovery: 'exact', P: E.P_STD }); + expect(r.ok).toBe(true); + expect(r.within).toBe(true); + expect(r.fraction).toBeGreaterThanOrEqual(0); + expect(r.fraction).toBeLessThanOrEqual(1); + const m = E.Psychro.mixAir({ + streams: [{ state: oa, share: r.fraction }, { state: ra, share: 1 - r.fraction }], + basis, P: E.P_STD, + }); + expect(Math.abs(m.exact.tdb - target)).toBeLessThan(1e-6); + } + } + } + } + }); + + test('the exact fraction differs from the linear one by the basis and cp effects, in the documented direction', () => { + // The §1 friction ruling: 0 °F / 40 % OA against 75 °F / 50 % RA. + // 20 % OA BY VOLUME mixes to 58.13 °F (the table's F column). + // Read that 58.13 back: the exact volume recovery returns the 20 %, + // the exact MASS recovery returns the 22.8 % mass share the table + // prints, and the linear form lands between them (~22.5 %) — it + // reads the shares as volume but ignores the density gap. + const E = loadEngine(); + const oa = E.Psychro.solveState('rh', 0, 40, E.P_STD); + const ra = E.Psychro.solveState('rh', 75, 50, E.P_STD); + const atVol20 = E.Psychro.mixAir({ streams: [{ state: oa, share: 0.2 }, { state: ra, share: 0.8 }], basis: 'volume', P: E.P_STD }); + const t = atVol20.exact.tdb; + const vol = E.Psychro.mixFraction({ oa, ra, targetTdb: t, basis: 'volume', recovery: 'exact', P: E.P_STD }); + const mass = E.Psychro.mixFraction({ oa, ra, targetTdb: t, basis: 'mass', recovery: 'exact', P: E.P_STD }); + expect(Math.abs(vol.fraction - 0.2)).toBeLessThan(1e-9); + expect(Math.abs(mass.fraction - atVol20.shares.mass[0])).toBeLessThan(1e-9); + expect(mass.fraction).toBeGreaterThan(vol.fraction); + const lin = E.Psychro.mixFraction({ oa, ra, targetTdb: t, basis: 'volume', recovery: 'linear', P: E.P_STD }); + expect(lin.fraction).toBeGreaterThan(vol.fraction); + expect(lin.fraction).toBeLessThan(mass.fraction); + expect(lin.fraction).toBeCloseTo((t - 75) / (0 - 75), 12); + }); + + test('every ok:false guard fires, in contract order, with its message', () => { + const E = loadEngine(); + const oa = E.Psychro.solveState('rh', 35, 80, E.P_STD); + const ra = E.Psychro.solveState('rh', 75, 50, E.P_STD); + const bad = { ok: false, error: 'x' }; + const same = E.Psychro.solveState('rh', 35, 20, E.P_STD); + const fr = o => E.Psychro.mixFraction(Object.assign( + { oa, ra, targetTdb: 55, basis: 'mass', recovery: 'exact', P: E.P_STD }, o)); + const cases = [ + [fr({ oa: bad, targetTdb: NaN, basis: 'x' }), 'Outdoor or return air has an invalid air state.'], + [fr({ ra: undefined }), 'Outdoor or return air has an invalid air state.'], + [E.Psychro.mixFraction(), 'Outdoor or return air has an invalid air state.'], + [fr({ targetTdb: 'abc', basis: 'x' }), 'Enter a numeric mixed-air target.'], + [fr({ targetTdb: Infinity }), 'Enter a numeric mixed-air target.'], + [fr({ basis: 'cfm', recovery: 'x' }), 'Name the mixing basis — mass or volume.'], + [fr({ recovery: 'quadratic', ra: same }), 'Name the recovery — linear or exact.'], + [fr({ ra: same, recovery: 'linear' }), 'Outdoor and return air share a dry-bulb — no unique fraction.'], + [fr({ ra: same }), 'Outdoor and return air share a dry-bulb — no unique fraction.'], + [fr({ targetTdb: 90 }), 'The target is outside the two air streams — no mix reaches it.'], + ]; + for (const [r, msg] of cases) { + expect(r).toEqual({ ok: false, error: msg }); + } + // A numeric-string target coerces once, like a share. + expect(fr({ targetTdb: '55' }).fraction).toBe(fr({ targetTdb: 55 }).fraction); + }); + +}); + +// ── docs/engine-standardization.md §1, regenerated from the engine ──────── +// +// Columns, as the doc names them: +// A exact, fraction as mass weight — the INLINE page form today +// (air-mixing frac tab :546-551, psychrometric-chart :672-676): +// weight W and h, recover tdb from the PRE-clamp W, buildState clamps. +// Transcribed below; it is the only column NOT computed by the +// engine's mixers, because it is the pre-#236 arithmetic the +// migration replaces. Clear of the curve it equals E; in fog it +// runs cold (the bold 10.42). +// E mixStreams on the fraction as weights (workbench) +// ≡ mixAir({ basis: 'mass' }).exact on the same fractions. +// B linear T + W (economizer-ratio) ≡ mixAir(…).linear.tdb. +// C linear T in tenths (coil-freeze-risk) — the integer operands make +// it B to one decimal. +// D linear T (air-handlers) — B to two decimals. +// F mass basis from CFM (air-mixing flow tab) +// ≡ mixAir({ basis: 'volume' }).exact on CFM shares. +// mass % ≡ that call's shares.mass[0]. fog ≡ its exact.fogging. +const TABLE = [ + { label: '#228 bench', oa: ['rh', 35, 80], ra: ['rh', 75, 50], f: 0.50, + A: '55.11', E: '55.11', B: '55.00', F: '54.24', mass: '52.2', fog: false }, + { label: '#228 design-day', oa: ['rh', 0, 60], ra: ['rh', 70, 30], f: 0.30, + A: '49.11', E: '49.11', B: '49.00', F: '46.87', mass: '33.2', fog: false }, + { label: 'friction ruling', oa: ['rh', 0, 40], ra: ['rh', 75, 50], f: 0.20, + A: '60.20', E: '60.20', B: '60.00', F: '58.13', mass: '22.8', fog: false }, + { label: 'air-handlers example', oa: ['rh', 35, 80], ra: ['rh', 75, 50], f: 0.20, + A: '67.07', E: '67.07', B: '67.00', F: '66.50', mass: '21.4', fog: false }, + { label: 'summer', oa: ['wb', 95, 75], ra: ['rh', 75, 50], f: 0.25, + A: '80.03', E: '80.03', B: '80.00', F: '79.87', mass: '24.2', fog: false }, + { label: 'hot-dry', oa: ['rh', 105, 10], ra: ['rh', 75, 50], f: 0.30, + A: '83.95', E: '83.95', B: '84.00', F: '83.65', mass: '29.0', fog: false }, + { label: 'fog corner', oa: ['rh', -20, 40], ra: ['rh', 80, 50], f: 0.70, + A: '10.42', E: '17.67', B: '10.00', F: '12.81', mass: '74.4', fog: true }, +]; + +// The inline A form, transcribed from the pages (see the column note). +function inlineExact(E, streams, weights) { + const M = weights.reduce((a, b) => a + b, 0); + let W = 0, h = 0; + streams.forEach((s, i) => { W += weights[i] * s.W / M; h += weights[i] * s.h / M; }); + return E.Psychro.buildState((h - 1061 * W) / (0.240 + 0.444 * W), W, E.P_STD); +} + +function regenerate(E, row) { + const oa = E.Psychro.solveState(row.oa[0], row.oa[1], row.oa[2], E.P_STD); + const ra = E.Psychro.solveState(row.ra[0], row.ra[1], row.ra[2], E.P_STD); + const frac = [row.f, 1 - row.f]; + const byFrac = E.Psychro.mixAir({ + streams: [{ state: oa, share: frac[0] }, { state: ra, share: frac[1] }], + basis: 'mass', P: E.P_STD, + }); + const cfm = frac.map(x => x * 10000); + const byCfm = E.Psychro.mixAir({ + streams: [{ state: oa, share: cfm[0] }, { state: ra, share: cfm[1] }], + basis: 'volume', P: E.P_STD, + }); + const kernel = E.Psychro.mixStreams([{ state: oa, flow: frac[0] }, { state: ra, flow: frac[1] }], E.P_STD); + return { + oa, ra, byFrac, byCfm, kernel, + A: inlineExact(E, [oa, ra], frac).tdb.toFixed(2), + E: byFrac.exact.tdb.toFixed(2), + Ek: kernel.tdb.toFixed(2), + B: byFrac.linear.tdb.toFixed(2), + F: byCfm.exact.tdb.toFixed(2), + Finline: inlineExact(E, [oa, ra], [cfm[0] / oa.v, cfm[1] / ra.v]).tdb.toFixed(2), + mass: (byCfm.shares.mass[0] * 100).toFixed(1), + fog: byCfm.exact.fogging, + }; +} + +// The doc's §1 table rows, keyed by label prefix; cells with markdown +// emphasis stripped. +function docRows() { + const md = fs.readFileSync(DESIGN, 'utf8'); + const sec = md.slice(md.indexOf('## 1.'), md.indexOf('## 2.')); + return sec.split('\n') + .filter(l => l.startsWith('| ') && !l.startsWith('| case') && !l.startsWith('|---')) + .map(l => l.split('|').slice(1, -1).map(c => c.trim().replace(/\*\*/g, ''))); +} + +test.describe('engine-standardization §1 table — regenerated from the engine', () => { + + test('every row reproduces the literal figures under forms A / E / B / F', () => { + const E = loadEngine(); + for (const row of TABLE) { + const g = regenerate(E, row); + expect(g.oa.ok && g.ra.ok, row.label).toBe(true); + expect(g.A, row.label + ' A').toBe(row.A); + expect(g.E, row.label + ' E').toBe(row.E); + expect(g.Ek, row.label + ' E (kernel)').toBe(row.E); + expect(g.B, row.label + ' B').toBe(row.B); + expect(g.F, row.label + ' F').toBe(row.F); + expect(g.mass, row.label + ' mass %').toBe(row.mass); + expect(g.fog, row.label + ' fog').toBe(row.fog); + expect(g.byFrac.exact.fogging, row.label + ' fog (mass)').toBe(row.fog); + // Clear of the curve the inline A form IS the engine's E. + if (!row.fog) expect(g.A).toBe(g.E); + } + }); + + test("the fog corner's inline flow-tab figure is the doc's 5.93", () => { + const E = loadEngine(); + const g = regenerate(E, TABLE[TABLE.length - 1]); + expect(g.Finline).toBe('5.93'); + expect(g.F).toBe('12.81'); + }); + + test("the design note's table carries exactly these figures", () => { + const rows = docRows(); + expect(rows.length).toBe(TABLE.length); + for (const row of TABLE) { + const cells = rows.find(c => c[0].startsWith(row.label)); + expect(cells, row.label).toBeTruthy(); + const [, A, Ecol, B, C, D, F, mass, fog] = cells; + expect(A, row.label + ' A').toBe(row.A); + expect(Ecol, row.label + ' E').toBe(row.E); + expect(B, row.label + ' B').toBe(row.B); + expect(C, row.label + ' C').toBe(Number(row.B).toFixed(1)); + expect(D, row.label + ' D').toBe(row.B); + expect(F.split(' ')[0], row.label + ' F').toBe(row.F); + expect(mass, row.label + ' mass %').toBe(row.mass + ' %'); + expect(fog, row.label + ' fog').toBe(row.fog ? 'yes' : 'no'); + } + }); + +});