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