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>
108 lines
5.0 KiB
JavaScript
108 lines
5.0 KiB
JavaScript
/**
|
||
* 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 };
|
||
}
|