storm_envelope: the wind side of the failure envelope, per anchor (gate 2.4)
Second harness on B's post-ratingHint audit: measures what the STORM delivers at every dressed anchor — peak speedAt, tPeak, dynamic pressure (downdraft folded back in), dose, and Pa/hint (the wind-side failure-order prediction under load > rating x ratingHint). No cloth, no rig, no tension: independent by construction. Browser front-end dresses the yard the way the game does (createWorld + dress) and wires venturi + tree shelters exactly as main.js:446-447 does. envelope.selftest.js wired into c.test.js, every assert written to fail if a wiring line is deleted (sweep.selftest's pattern). Selftest 320/0/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8decac0626
commit
8208c80d78
140
tools/storm_envelope/envelope.html
Normal file
140
tools/storm_envelope/envelope.html
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!--
|
||||||
|
storm_envelope, browser front-end. [Lane C, SPRINT12 — gate 2.4's second harness]
|
||||||
|
|
||||||
|
B's site_audit measures the failure envelope through the CLOTH (peak corner
|
||||||
|
loads, kN). This measures the same envelope through the STORM: what the wind
|
||||||
|
actually delivers at every dressed anchor, per night — peak local speed, when,
|
||||||
|
dynamic pressure, dose, and the pressure/hint ranking that predicts failure
|
||||||
|
ORDER under B's `load > rating × ratingHint` wiring. Two harnesses, one
|
||||||
|
envelope; if they disagree, find the variable before anyone retunes E's hints.
|
||||||
|
|
||||||
|
Dressed positions the way the game gets them — createWorld(await loadSite(name))
|
||||||
|
then dress() — because a graybox envelope measures a yard that does not ship
|
||||||
|
(site_audit's own lesson, kept).
|
||||||
|
|
||||||
|
tools/storm_envelope/envelope.html ← the whole week
|
||||||
|
tools/storm_envelope/envelope.html?site=site_02_corner_block&storm=storm_03b_earlybuster
|
||||||
|
|
||||||
|
Served from the repo root (server.py).
|
||||||
|
-->
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>storm_envelope</title>
|
||||||
|
<style>
|
||||||
|
body { background:#111; color:#ddd; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; padding:20px; }
|
||||||
|
h1 { font-size:15px; color:#fff; margin:0 0 2px; }
|
||||||
|
h2 { font-size:13px; color:#9cf; margin:18px 0 2px; }
|
||||||
|
.sub { color:#888; margin-bottom:10px; white-space:pre-wrap; }
|
||||||
|
table { border-collapse:collapse; margin:6px 0; }
|
||||||
|
th { text-align:right; color:#789; font-weight:normal; padding:1px 12px 1px 0; }
|
||||||
|
th:first-child, td:first-child { text-align:left; }
|
||||||
|
td { padding:1px 12px 1px 0; white-space:nowrap; text-align:right; }
|
||||||
|
.weak { color:#e96; } .probe { color:#678; font-style:italic; }
|
||||||
|
</style>
|
||||||
|
<h1>storm_envelope — the wind side of the failure envelope</h1>
|
||||||
|
<div class="sub" id="sub">loading…</div>
|
||||||
|
<div id="out"></div>
|
||||||
|
|
||||||
|
<script type="importmap">
|
||||||
|
{ "imports": {
|
||||||
|
"three": "../../web/world/vendor/three.module.js",
|
||||||
|
"three/addons/": "../../web/world/vendor/addons/"
|
||||||
|
} }
|
||||||
|
</script>
|
||||||
|
<script type="module">
|
||||||
|
import * as THREE from '../../web/world/vendor/three.module.js';
|
||||||
|
import { createWorld, loadSite } from '../../web/world/js/world.js';
|
||||||
|
import { NIGHTS } from '../../web/world/js/week.js';
|
||||||
|
import { stormEnvelope } from './envelope.js';
|
||||||
|
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
const loadJSON = async (path) => (await fetch(path)).json();
|
||||||
|
|
||||||
|
// One dressed world per site, cached — the yard doesn't change between storms.
|
||||||
|
const siteCache = new Map();
|
||||||
|
async function dressedSite(name) {
|
||||||
|
if (siteCache.has(name)) return siteCache.get(name);
|
||||||
|
const site = await loadSite(name);
|
||||||
|
const calmStub = {
|
||||||
|
sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
|
||||||
|
speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
|
||||||
|
gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
|
||||||
|
};
|
||||||
|
const world = createWorld(new THREE.Scene(), { wind: calmStub, site });
|
||||||
|
let dressed = false;
|
||||||
|
if (world.dress) { try { await world.dress(); dressed = true; } catch (e) { /* flagged below */ } }
|
||||||
|
const anchors = world.anchors.map((a) => ({
|
||||||
|
id: a.id, type: a.type, ratingHint: a.ratingHint,
|
||||||
|
pos: { x: a.pos.x, y: a.pos.y, z: a.pos.z },
|
||||||
|
}));
|
||||||
|
const out = { site, anchors, dressed, bed: world.gardenBed };
|
||||||
|
siteCache.set(name, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(title, siteName, stormName, res, dressed) {
|
||||||
|
const h = document.createElement('h2');
|
||||||
|
h.textContent = title;
|
||||||
|
el('out').appendChild(h);
|
||||||
|
const sub = document.createElement('div');
|
||||||
|
sub.className = 'sub';
|
||||||
|
sub.textContent = `${siteName} × ${stormName} — downdraftOfTotal ${res.downFrac}, ${res.duration}s`
|
||||||
|
+ (dressed ? '' : ' ⚠ dress() FAILED — graybox positions, not what ships');
|
||||||
|
el('out').appendChild(sub);
|
||||||
|
|
||||||
|
const tbl = document.createElement('table');
|
||||||
|
const head = tbl.insertRow();
|
||||||
|
for (const c of ['anchor', 'type', 'hint', 'peak m/s', 'tPeak s', 'peak Pa', 'Pa/hint', 'dose m²/s', 'eff. cara/shackle/rated N']) {
|
||||||
|
const th = document.createElement('th'); th.textContent = c; head.appendChild(th);
|
||||||
|
}
|
||||||
|
for (const r of res.rows) {
|
||||||
|
const tr = tbl.insertRow();
|
||||||
|
if (r.probe) tr.className = 'probe';
|
||||||
|
else if (r.hint < 0.5) tr.className = 'weak';
|
||||||
|
tr.insertCell().textContent = r.id;
|
||||||
|
tr.insertCell().textContent = r.type;
|
||||||
|
tr.insertCell().textContent = r.hint.toFixed(2);
|
||||||
|
tr.insertCell().textContent = r.peak.toFixed(2);
|
||||||
|
tr.insertCell().textContent = r.tPeak.toFixed(1);
|
||||||
|
tr.insertCell().textContent = r.peakPa.toFixed(0);
|
||||||
|
tr.insertCell().textContent = r.paPerHint.toFixed(0);
|
||||||
|
tr.insertCell().textContent = r.dose.toFixed(0);
|
||||||
|
tr.insertCell().textContent = r.probe ? '—' : r.effN.map((n) => n.toFixed(0)).join(' / ');
|
||||||
|
}
|
||||||
|
el('out').appendChild(tbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPair(siteName, stormName, title) {
|
||||||
|
const { site, anchors, dressed, bed } = await dressedSite(siteName);
|
||||||
|
const stormDef = await loadJSON(`../../web/world/data/storms/${stormName}.json`);
|
||||||
|
const venturi = site.wind?.venturi ?? [];
|
||||||
|
const probes = [{ id: 'bed centre', pos: { x: bed.x, z: bed.z } }];
|
||||||
|
if (venturi.length) probes.push({ id: 'throat', pos: { x: venturi[0].x, z: venturi[0].z } });
|
||||||
|
const res = stormEnvelope({ anchors, stormDef, venturi, probes });
|
||||||
|
render(title, siteName, stormName, res, dressed);
|
||||||
|
return { site: siteName, storm: stormName, dressed,
|
||||||
|
rows: res.rows.map(({ id, type, hint, peak, tPeak, peakPa, paPerHint, dose }) =>
|
||||||
|
({ id, type, hint, peak, tPeak, peakPa, paPerHint, dose })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const single = q.get('site') || q.get('storm');
|
||||||
|
const pairs = single
|
||||||
|
? [{ site: q.get('site') || 'backyard_01', storm: q.get('storm') || 'storm_02_wildnight', title: 'requested pair' }]
|
||||||
|
: NIGHTS.map((n, i) => ({ site: n.site, storm: n.storm, title: `night ${i + 1}` }));
|
||||||
|
el('sub').textContent = `${pairs.length} pair(s) · sampling at FIXED_DT over each storm · `
|
||||||
|
+ `venturi + tree shelters wired the way main.js wires them`;
|
||||||
|
const results = [];
|
||||||
|
for (const p of pairs) results.push(await runPair(p.site, p.storm, p.title));
|
||||||
|
// machine-readable, so the page can be driven headless-in-browser
|
||||||
|
window.__envelope = results;
|
||||||
|
document.title = 'storm_envelope — done';
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((e) => {
|
||||||
|
el('sub').textContent = `storm_envelope crashed: ${e.message}\n${e.stack || ''}`;
|
||||||
|
window.__envelope = { error: e.message };
|
||||||
|
document.title = 'storm_envelope — ERROR';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
107
tools/storm_envelope/envelope.js
Normal file
107
tools/storm_envelope/envelope.js
Normal file
@ -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 };
|
||||||
|
}
|
||||||
100
tools/storm_envelope/envelope.selftest.js
Normal file
100
tools/storm_envelope/envelope.selftest.js
Normal file
@ -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;
|
||||||
@ -21,6 +21,7 @@ import { createDebris } from '../debris.js';
|
|||||||
import { createSkyFx, RainShadow } from '../skyfx.js';
|
import { createSkyFx, RainShadow } from '../skyfx.js';
|
||||||
import { SailRig, HARDWARE } from '../sail.js';
|
import { SailRig, HARDWARE } from '../sail.js';
|
||||||
import { weatherCases } from './weather.selftest.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
|
// 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
|
// 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);
|
const { cases } = weatherCases(storms);
|
||||||
for (const c of cases) t.test(c.name, c.fn);
|
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 ---
|
// --- the bits that need the THREE adapter, not just the core ---
|
||||||
|
|
||||||
t.test('weather.js satisfies the wind contract', () => {
|
t.test('weather.js satisfies the wind contract', () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user