diff --git a/tools/storm_envelope/envelope.html b/tools/storm_envelope/envelope.html new file mode 100644 index 0000000..3b62ea3 --- /dev/null +++ b/tools/storm_envelope/envelope.html @@ -0,0 +1,140 @@ + + + +storm_envelope + +

storm_envelope — the wind side of the failure envelope

+
loading…
+
+ + + diff --git a/tools/storm_envelope/envelope.js b/tools/storm_envelope/envelope.js new file mode 100644 index 0000000..a6424fe --- /dev/null +++ b/tools/storm_envelope/envelope.js @@ -0,0 +1,107 @@ +/** + * envelope.js — the STORM-side failure envelope, per anchor. [Lane C, SPRINT12] + * + * SPRINT12 gate 2.4: B measures the post-ratingHint failure envelope through the + * cloth sim (site_audit: settle, fly the storm, read peak corner loads in N). + * This is the SECOND harness on the same envelope, measured from the other side: + * no cloth, no rig, no tension — just what the STORM delivers at each anchor's + * position, through the same wind everyone samples (createWind + the site's + * venturi + the tree shelters, exactly as main.js wires them at site load). + * + * Two harnesses, one number — the repo rule. What must agree: + * · ORDERING: anchors ranked by wind-side pressure/hint should rank the same + * way B's peak corner loads do, once quad geometry is accounted for. An + * anchor whose load outranks its wind exposure has a variable to find. + * · TIMING: B's peak corner load should land near the wind-side tPeak (cloth + * lag is under a second; the venturi's alignment window is tens of seconds). + * What will NOT agree, by construction: absolute newtons. Corner load carries + * quad geometry, cloth porosity, tension and rain mass — all B's side. This + * harness deliberately knows none of it, which is what makes it independent. + * + * Per anchor, per storm: + * peak highest local horizontal wind speed over the storm, m/s + * (wind.speedAt — the anemometer number, same units as every probe + * table in THREADS) + * tPeak when it happened, s + * peakPa peak dynamic pressure, Pa: ½ρ·v²·(1+downFrac²) — the (1+d²) folds + * the downdraft back in, since cloth feels the full vector while + * speedAt deliberately reads horizontal-only + * dose ∫ v² dt over the whole storm, m²/s — exposure × time, so a long + * grind and a single spike stop looking alike + * paPerHint peakPa / ratingHint — the wind-side "who blows first" ranking. + * B's wiring is `load > hw.rating * ratingHint`, so dividing the + * delivered pressure by the hint predicts failure ORDER for equal + * hardware and equal geometry. Prediction, not measurement — B's + * harness is the one that owns the geometry. + * + * Sampling matches sweep.js's flight exactly: FIXED_DT steps at t = i·dt over + * the storm's duration, same seed path (createWind(def) — def.seed), so the two + * harnesses fly the SAME storm, not merely the same JSON. + */ + +import { createWind } from '../../web/world/js/weather.js'; +import { FIXED_DT, HARDWARE } from '../../web/world/js/contracts.js'; + +export const ENVELOPE = { + RHO: 1.225, // kg/m³, sea-level air — the constant, named once + DT: FIXED_DT, +}; + +/** + * Measure the storm at every anchor. + * + * @param {object} o + * @param {Array} o.anchors resolved anchors: { id, type, pos:{x,y,z}, ratingHint? } + * — pass the DRESSED world.anchors (browser) or a + * verified dump; graybox positions measure a yard + * that does not ship (site_audit's lesson). + * @param {object} o.stormDef parsed storm JSON + * @param {Array} [o.venturi] the SITE's funnel zones (siteDef.wind.venturi) — + * a sweep without it flies an easier yard than ships + * @param {Array} [o.probes] extra { id, pos } points (bed centre, throat…) + * measured alongside, hint fixed at 1 + * @param {number} [o.dt] + * @returns {{ rows, downFrac, duration }} rows sorted by paPerHint, descending + */ +export function stormEnvelope({ anchors, stormDef, venturi = [], probes = [], dt = FIXED_DT }) { + const wind = createWind(stormDef); + wind.setVenturi(venturi); + // exactly main.js:447 — every tree anchor casts a shadow, defaults and all + wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree')); + const downFrac = wind.core.downFrac; + const vecFold = 1 + downFrac * downFrac; // |v|² = h²·(1+d²) when vy = -d·h + + const rows = [ + ...anchors.map((a) => ({ + id: a.id, type: a.type, probe: false, + hint: Number.isFinite(a.ratingHint) ? a.ratingHint : 1, + pos: { x: a.pos.x, z: a.pos.z }, + peak: 0, tPeak: 0, dose: 0, + })), + ...probes.map((p) => ({ + id: p.id, type: 'probe', probe: true, hint: 1, + pos: { x: p.pos.x, z: p.pos.z }, + peak: 0, tPeak: 0, dose: 0, + })), + ]; + + const duration = stormDef.duration ?? 90; + const n = Math.round(duration / dt); + for (let i = 0; i <= n; i++) { + const t = i * dt; + for (const r of rows) { + const s = wind.speedAt(r.pos, t); + if (s > r.peak) { r.peak = s; r.tPeak = t; } + r.dose += s * s * dt; + } + } + + for (const r of rows) { + r.peakPa = 0.5 * ENVELOPE.RHO * r.peak * r.peak * vecFold; + r.paPerHint = r.peakPa / (r.hint > 0 ? r.hint : 1); + // what B's wiring makes each shop tier hold HERE: rating × hint, in N + r.effN = HARDWARE.map((h) => h.rating * r.hint); + } + rows.sort((a, b) => b.paPerHint - a.paPerHint); + return { rows, downFrac, duration }; +} diff --git a/tools/storm_envelope/envelope.selftest.js b/tools/storm_envelope/envelope.selftest.js new file mode 100644 index 0000000..28922e4 --- /dev/null +++ b/tools/storm_envelope/envelope.selftest.js @@ -0,0 +1,100 @@ +/** + * envelope.selftest.js — the second harness proves it measures. [Lane C, SPRINT12] + * + * Same shape as sweep.selftest.js: [name, fn] pairs, run under Lane A's + * selftest.html via js/tests/c.test.js. sweep.selftest exists because site_audit + * flew the corner block with the funnel switched off for two sprints; this file + * exists so the harness built to CHECK that tool can't quietly do the same. + * Every assert here is written to fail if a wiring line is deleted: + * · drop setVenturi → the throat anchor's funnelled/unfunnelled peaks + * collapse to equal and the strict < goes red + * · drop setSheltersFromTrees → the sheltered anchor stops reading lower + * · reseed per call → the determinism byte-compare goes red + * · flip paPerHint to × → the weak-anchor-ranks-first assert goes red + */ + +import { stormEnvelope } from './envelope.js'; + +const TESTS = []; +const test = (name, fn) => TESTS.push([name, fn]); +const assert = (cond, msg) => { if (!cond) throw new Error(msg); }; + +/** + * A synthetic storm, flat on purpose: constant 10 m/s, dir 0 (blowing +X), no + * gusts, no wander, no spatial noise — so every local effect is the ONLY thing + * moving a number, and the asserts can be strict instead of statistical. + */ +const FLAT = { + name: 'flat_test', seed: 7, duration: 20, + baseCurve: [[0, 10], [20, 10]], + dirCurve: [[0, 0], [20, 0]], + dirWander: { amp: 0, rate: 0 }, + spatial: { amp: 0 }, + gusts: { firstAt: 999, minGap: 6, maxGap: 12, powBase: 0, powRand: 0, powRamp: 0, downdraftOfTotal: 0.35 }, +}; + +const A = (id, x, z, o = {}) => ({ id, type: o.type ?? 'post', pos: { x, y: 4, z }, ...o }); + +test('envelope: deterministic — same def, same anchors, same numbers to the bit', () => { + const anchors = [A('a', 0, 0), A('b', 5, 3)]; + const def = JSON.parse(JSON.stringify(FLAT)); + const r1 = stormEnvelope({ anchors, stormDef: def }); + const r2 = stormEnvelope({ anchors, stormDef: JSON.parse(JSON.stringify(FLAT)) }); + for (let i = 0; i < r1.rows.length; i++) { + assert(r1.rows[i].peak === r2.rows[i].peak && r1.rows[i].dose === r2.rows[i].dose + && r1.rows[i].tPeak === r2.rows[i].tPeak, + `run 2 disagrees with run 1 at ${r1.rows[i].id}: ${r1.rows[i].peak} vs ${r2.rows[i].peak}`); + } +}); + +test('envelope: the venturi is wired — throat anchor reads the funnel, a far one does not', () => { + const anchors = [A('throat', 0, 0), A('far', 100, 0)]; + const venturi = [{ x: 0, z: 0, axis: 0, gain: 1.5, radius: 5, sharp: 3 }]; + const off = stormEnvelope({ anchors, stormDef: FLAT }); + const on = stormEnvelope({ anchors, stormDef: FLAT, venturi }); + const g = (res, id) => res.rows.find((r) => r.id === id); + // strict: delete envelope.js's setVenturi call and this collapses to equal + assert(g(on, 'throat').peak > g(off, 'throat').peak, + `funnel did not raise the throat: ${g(on, 'throat').peak} vs ${g(off, 'throat').peak} — setVenturi is not wired`); + assert(Math.abs(g(on, 'throat').peak - 15) < 0.01, + `gain 1.5 on 10 m/s dead-aligned should read 15 at the throat, got ${g(on, 'throat').peak}`); + assert(g(on, 'far').peak === g(off, 'far').peak, + 'the funnel reached an anchor 100 m away — radial falloff is broken'); +}); + +test('envelope: tree shelters are wired — a downwind anchor reads the shadow', () => { + // tree at origin, wind blowing +X: 'lee' sits 4 m downwind inside the shadow + const withTree = [A('tree', 0, 0, { type: 'tree' }), A('lee', 4, 0)]; + const without = [A('lee', 4, 0)]; + const sheltered = stormEnvelope({ anchors: withTree, stormDef: FLAT }).rows.find((r) => r.id === 'lee'); + const open = stormEnvelope({ anchors: without, stormDef: FLAT }).rows.find((r) => r.id === 'lee'); + // strict: delete envelope.js's setSheltersFromTrees call and these read equal + assert(sheltered.peak < open.peak, + `the tree cast no shadow: lee reads ${sheltered.peak} with it, ${open.peak} without — setSheltersFromTrees is not wired`); +}); + +test('envelope: paPerHint ranks the weak steel first at equal wind', () => { + // identical position, identical wind — only the hint differs (E's beam 0.22 + // vs a clean post at 1.0). The wind-side prediction must rank the beam first. + const anchors = [A('beam', 2, 2, { ratingHint: 0.22 }), A('clean', 2, 2, { ratingHint: 1 })]; + const { rows } = stormEnvelope({ anchors, stormDef: FLAT }); + assert(rows[0].id === 'beam', + `equal wind, hint 0.22 vs 1.0 — 'beam' must rank first, got ${rows.map((r) => r.id).join(',')}`); + assert(Math.abs(rows[0].paPerHint - rows[1].paPerHint / 0.22) < 1e-9, + 'paPerHint is not peakPa/hint — the ranking formula drifted'); + assert(Math.abs(rows[0].effN[0] - 1200 * 0.22) < 1e-9, + `effN must be rating×hint: carabiner on the beam should read ${1200 * 0.22} N, got ${rows[0].effN[0]}`); +}); + +test('envelope: peaks land inside the storm and doses are finite and positive', () => { + const anchors = [A('a', -3, 1), A('b', 6, -2)]; + const { rows, duration } = stormEnvelope({ anchors, stormDef: FLAT }); + for (const r of rows) { + assert(r.tPeak >= 0 && r.tPeak <= duration, `${r.id} peaked at t=${r.tPeak}, outside 0..${duration}`); + assert(Number.isFinite(r.dose) && r.dose > 0, `${r.id} dose is ${r.dose}`); + // flat 10 m/s for 20 s → dose ≈ 100·20; a wildly-off dose means the loop drifted + assert(Math.abs(r.dose - 2000) < 20, `${r.id} dose ${r.dose.toFixed(0)} — flat 10 m/s × 20 s should be ~2000`); + } +}); + +export const ENVELOPE_TESTS = TESTS; diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 03e7140..d9bd456 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -21,6 +21,7 @@ import { createDebris } from '../debris.js'; import { createSkyFx, RainShadow } from '../skyfx.js'; import { SailRig, HARDWARE } from '../sail.js'; import { weatherCases } from './weather.selftest.js'; +import { ENVELOPE_TESTS } from '../../../../tools/storm_envelope/envelope.selftest.js'; // Keep in step with data/storms/. The node runner globs the directory, so this // list going stale shows up here first — as it did when storm_03 landed and the @@ -41,6 +42,11 @@ export default async function run(t) { const { cases } = weatherCases(storms); for (const c of cases) t.test(c.name, c.fn); + // --- SPRINT12 gate 2.4: the storm-side envelope harness audits itself --- + // Same pattern as sweep.selftest in b.test.js: the tool that second-guesses + // B's audit must not be the thing that drifts. See envelope.selftest.js. + for (const [name, fn] of ENVELOPE_TESTS) t.test(name, fn); + // --- the bits that need the THREE adapter, not just the core --- t.test('weather.js satisfies the wind contract', () => {