HardYards/tools/storm_envelope/envelope.selftest.js
type-two 8208c80d78 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>
2026-07-17 20:10:17 +10:00

101 lines
5.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;