HardYards/tools/storm_envelope/envelope.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

108 lines
5.0 KiB
JavaScript
Raw 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.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 };
}