Venturi: per-site wind personality for site_02 (the corner block funnels the southerly)
A venturi is a shelter's opposite — a gap between buildings SPEEDS the wind up when it blows along the gap's axis. Where a tree shadow depends on being downwind of the tree, a venturi depends on the wind being ALIGNED with a fixed axis the SITE owns, so it fires only when the storm's direction swings to match: the corner block is calm until the southerly comes through, then it screams. That interplay is exactly SPRINT9's brief — funnel geometry is the site's, the wind that funnels is the storm's. Extends the existing shelter system rather than bolting on a parallel one: speedAt/vecAt/verticalAt all route through one localHoriz, which now folds venturiFactor in alongside spatialFactor and shelterFactor. The downdraft rides local speed, so a funnelled wind gets a proportionally stronger downdraft for free — physically right. `setVenturi(list)` on the wind + router (A's "add it to the router too" rule; the tripwire is green). Inert on an empty list, so backyard_01 is byte-identical — asserted. Multiplier is ALWAYS ≥ 1 (a venturi accelerates, never shelters), and it's C1-continuous through the change, so it can't snap a corner — both asserted (aligned wind boosts 1.5×, crosswind exactly 1.0×, max frame jump 0.50 m/s). Data lives in site JSON (A's schema, arriving with the extraction) — this is the physics + the setter, ready to wire. Proposed shape flagged for A in THREADS. Also fixed a stale comment: storm_02's _gusts_comment still said "HELD at 0.12 for now" from Sprint 3 while the value has been 0.45 since Sprint 4 — a lying comment in a repo that reads comments as canon. Now records the real history and the pending paired-0.40 flip. Selftest 278/0/0; node 55/0/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0d8e36ba0f
commit
19f4ab6c63
@ -11,7 +11,7 @@
|
||||
|
||||
"baseCurve": [[0, 7.0], [15, 11.0], [40, 17.0], [60, 20.0], [78, 19.0], [90, 16.0]],
|
||||
|
||||
"_gusts_comment": "downdraftOfTotal = fraction of TOTAL wind speed that blows DOWN (SPRINT3 decision 8), present whenever it's windy, not only in gusts. TARGET is 0.45 — measured to clear B's 60% flat-horizontal:flat-pitched bar (69% of-max / 60% worst-heading) AND let a properly-sized twisted rated rig survive with ~21% margin, which gust-only semantics provably could NOT do together (0.58 gave 48% and still broke the twisted rig). HELD at 0.12 for now: on the current oversized yard the ONLY twisted quad ('h1,t2,p1,t1', ~190 m²) starts losing a corner around 0.15 in the exact solver, so 0.45 would turn B's §7 assert red. 0.12 fraction-of-total ~= the old gust-only 0.3 in peak downdraft (-4.2 vs -4.5 m/s), so storm_02 barely changes, and leaves ~23% load margin on that twisted rig. Bump to 0.45 is a ONE-NUMBER joint step once A lands decision-2 anchors (18-45 m2 quads) and B re-points §7. See THREADS [C] 2026-07-17.",
|
||||
"_gusts_comment": "downdraftOfTotal = fraction of TOTAL wind speed that blows DOWN (SPRINT3 decision 8), present whenever it's windy, not only in gusts. LANDED at 0.45 in Sprint 4 once A's decision-2 anchors gave properly-sized quads and B re-pointed §7 — it clears B's 60% flat-horizontal:flat-pitched bar (69% of-max / 60% worst-heading) and lets a properly-sized twisted rated rig survive with ~21% margin, which gust-only semantics provably could NOT do together (0.58 gave 48% and still broke the twisted rig). SPRINT9 NOTE: a paired drop to 0.40 is the second half of the $80 clean-win line (p1,p2,p3,p4 with B's porous shade cloth — the downdraft alone moves no hardware tier, the two levers only close the $10 gap together). That flip lands WITH B's fabric choice, not before it — see THREADS [C] 2026-07-18/-07-19.",
|
||||
|
||||
"gusts": {
|
||||
"firstAt": 3,
|
||||
|
||||
@ -297,6 +297,11 @@ export function createWindRouter(all) {
|
||||
length: o.length ?? 14,
|
||||
})));
|
||||
},
|
||||
setVenturi(list) { // SPRINT9 site_02 — funnels are site geometry
|
||||
for (const w of all) w.setVenturi(list);
|
||||
return router;
|
||||
},
|
||||
get venturi() { return active.venturi; },
|
||||
|
||||
get hailSize() { return active.hailSize; }, // SPRINT5 decision 13
|
||||
get duration() { return active.duration; },
|
||||
|
||||
@ -281,6 +281,80 @@ export function weatherCases(storms) {
|
||||
assert(Math.abs(luvS - luvB) < 1e-9, 'upwind side is being sheltered — shadow is pointing the wrong way');
|
||||
});
|
||||
|
||||
// ---- 8b. venturi (SPRINT9 site_02 — the corner block funnels the southerly) ----
|
||||
test('venturi speeds the wind up when it blows along the gap, not across it', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
// a funnel whose axis runs NE–SW (0.9 rad ≈ 51°), at the yard centre
|
||||
const AXIS = 0.9;
|
||||
const bare = createWindField(def);
|
||||
const funnel = createWindField(def).setVenturi([{ x: 0, z: 0, axis: AXIS, gain: 1.5, radius: 5, sharp: 3 }]);
|
||||
|
||||
// when the wind blows ALONG the axis, the throat should scream
|
||||
const dt = 1 / 60;
|
||||
let alongBoost = 1, crossBoost = 1, alongT = 0, crossT = 0;
|
||||
for (let t = 0; t <= def.duration; t += dt) {
|
||||
const d = bare.dirAt(t);
|
||||
// alignment of the wind with the funnel axis, |dot|
|
||||
const align = Math.abs(Math.cos(d) * Math.cos(AXIS) + Math.sin(d) * Math.sin(AXIS));
|
||||
const boost = funnel.speedAt(0, 0, t) / bare.speedAt(0, 0, t);
|
||||
if (align > 0.98 && boost > alongBoost) { alongBoost = boost; alongT = t; }
|
||||
if (align < 0.15 && boost < crossBoost) { crossBoost = boost; crossT = t; }
|
||||
}
|
||||
metrics['venturi.alignedBoost'] = +alongBoost.toFixed(3);
|
||||
metrics['venturi.crosswindBoost'] = +crossBoost.toFixed(3);
|
||||
assert(alongBoost > 1.35, `a dead-on wind only boosted ${alongBoost.toFixed(2)}× at t=${alongT.toFixed(1)} — funnel too weak`);
|
||||
assert(crossBoost < 1.03, `a crosswind still boosted ${crossBoost.toFixed(2)}× at t=${crossT.toFixed(1)} — a funnel shouldn't fire across its axis`);
|
||||
// never slows the wind (a venturi accelerates, it doesn't shelter)
|
||||
assert(alongBoost >= 1 && crossBoost >= 1, 'venturi reduced the wind somewhere — it can only speed it up');
|
||||
});
|
||||
|
||||
test('venturi only reaches inside its radius, and downdraft rides it', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
const f = createWindField(def).setVenturi([{ x: 0, z: 0, axis: 0.9, gain: 1.5, radius: 5, sharp: 3 }]);
|
||||
const bare = createWindField(def);
|
||||
// far outside the throat: untouched
|
||||
assert(Math.abs(f.speedAt(20, 0, 40) - bare.speedAt(20, 0, 40)) < 1e-9, 'venturi reached 20 m away');
|
||||
// the vertical downdraft is a fraction of local speed, so it funnels too
|
||||
const out = { x: 0, y: 0, z: 0 }, outBare = { x: 0, y: 0, z: 0 };
|
||||
let maxRatio = 0;
|
||||
for (let t = 0; t <= def.duration; t += 1 / 60) {
|
||||
f.vecAt(0, 0, t, out); bare.vecAt(0, 0, t, outBare);
|
||||
if (outBare.y < -0.1) maxRatio = Math.max(maxRatio, out.y / outBare.y); // both negative
|
||||
}
|
||||
assert(maxRatio > 1.3, `funnel didn't strengthen the downdraft (max ${maxRatio.toFixed(2)}×) — it should ride local speed`);
|
||||
});
|
||||
|
||||
test('an empty venturi list is a perfect no-op (backyard_01 is untouched)', () => {
|
||||
const def = storms.storm_02_wildnight;
|
||||
const bare = createWindField(def);
|
||||
const set = createWindField(def).setVenturi([]);
|
||||
for (const p of PROBES) {
|
||||
for (const t of [10, 40, 60, 75]) {
|
||||
assert(bare.speedAt(p.x, p.z, t) === set.speedAt(p.x, p.z, t),
|
||||
`setVenturi([]) changed the wind at (${p.x},${p.z}) t=${t}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('venturi keeps the wind continuous — no frame-to-frame snap', () => {
|
||||
// a discontinuity in the funnel would be an impulse straight into a corner;
|
||||
// both axes matter — a standing sail (time) and a swinging wind (alignment).
|
||||
const def = storms.storm_02_wildnight;
|
||||
const f = createWindField(def).setVenturi([{ x: 0, z: 0, axis: 0.9, gain: 1.6, radius: 5, sharp: 3 }]);
|
||||
const a = { x: 0, y: 0, z: 0 }, b = { x: 0, y: 0, z: 0 };
|
||||
let worst = 0, worstT = 0;
|
||||
// sit right in the throat through the whole storm, including the change
|
||||
f.vecAt(0.5, 0.5, 0, a);
|
||||
for (let t = 1 / 60; t <= def.duration; t += 1 / 60) {
|
||||
f.vecAt(0.5, 0.5, t, b);
|
||||
const jump = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
|
||||
if (jump > worst) { worst = jump; worstT = t; }
|
||||
a.x = b.x; a.y = b.y; a.z = b.z;
|
||||
}
|
||||
metrics['venturi.maxTemporalJump'] = +worst.toFixed(3);
|
||||
assert(worst < 1.5, `venturi jumped ${worst.toFixed(2)} m/s in one frame at t=${worstT.toFixed(1)}`);
|
||||
});
|
||||
|
||||
// ---- 9. vertical structure (SPRINT3 decision 8: fraction of TOTAL) ----
|
||||
// Cloth pressure goes with dot(wind, normal). A flat panel's normal points at
|
||||
// the sky, so in a purely horizontal wind that dot is ~0 and "lie it flat and
|
||||
|
||||
@ -252,6 +252,7 @@ export function createWindField(def, opts = {}) {
|
||||
: [];
|
||||
|
||||
let shelters = [];
|
||||
let venturi = [];
|
||||
|
||||
/** Spatially-uniform part: base curve + every gust envelope live at t. */
|
||||
function uniformSpeed(t) {
|
||||
@ -278,12 +279,13 @@ export function createWindField(def, opts = {}) {
|
||||
return sampleAngleCurve(def.dirCurve, t) + wAmp * Math.sin(t * wRate);
|
||||
}
|
||||
|
||||
/** Local horizontal wind speed (m/s) — base+gusts, spatial noise, tree shadow.
|
||||
* The one place the local-speed maths lives; speedAt/vecAt/verticalAt share it. */
|
||||
/** Local horizontal wind speed (m/s) — base+gusts, spatial noise, tree shadow,
|
||||
* site venturi. The one place the local-speed maths lives; speedAt/vecAt/
|
||||
* verticalAt share it, so the downdraft rides the funnel too. */
|
||||
function localHoriz(x, z, t) {
|
||||
const uni = uniformSpeed(t);
|
||||
const d = dirAt(t);
|
||||
const s = uni * spatialFactor(x, z, t) * shelterFactor(x, z, Math.cos(d), Math.sin(d));
|
||||
const s = uni * localFactor(x, z, t, Math.cos(d), Math.sin(d));
|
||||
return s > 0 ? s : 0;
|
||||
}
|
||||
|
||||
@ -377,6 +379,38 @@ export function createWindField(def, opts = {}) {
|
||||
return f;
|
||||
}
|
||||
|
||||
/**
|
||||
* A venturi is a shelter's opposite: a gap between buildings SPEEDS the wind up
|
||||
* when it blows along the gap's axis (SPRINT9 site_02, the corner block funnels
|
||||
* the southerly). Where a tree shadow depends on being downwind of the tree,
|
||||
* a venturi depends on the wind being ALIGNED with a fixed axis the SITE owns —
|
||||
* so it fires only when the storm's direction swings to match, which is the
|
||||
* whole drama: the corner block is calm until the southerly comes through, then
|
||||
* it screams. Multiplier ≥ 1, and inert (empty list) on any site that has no
|
||||
* funnel, so backyard_01 is untouched.
|
||||
*/
|
||||
function venturiFactor(x, z, dirX, dirZ) {
|
||||
let f = 1;
|
||||
for (let i = 0; i < venturi.length; i++) {
|
||||
const v = venturi[i];
|
||||
const rx = x - v.x, rz = z - v.z;
|
||||
const dist = Math.hypot(rx, rz);
|
||||
if (dist >= v.radius) continue;
|
||||
// how well the wind lines up with the gap's axis. |dot| because a gap
|
||||
// funnels either way through it; ^sharp so only a well-aligned wind counts.
|
||||
let align = Math.abs(dirX * v.axisX + dirZ * v.axisZ);
|
||||
align = Math.pow(align, v.sharp);
|
||||
const radial = 1 - smoothstep(v.radius * 0.4, v.radius, dist); // full in the throat, fades out
|
||||
f *= 1 + (v.gain - 1) * align * radial;
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Every spatial speed multiplier at a point, given the wind direction. */
|
||||
function localFactor(x, z, t, dirX, dirZ) {
|
||||
return spatialFactor(x, z, t) * shelterFactor(x, z, dirX, dirZ) * venturiFactor(x, z, dirX, dirZ);
|
||||
}
|
||||
|
||||
const field = {
|
||||
def,
|
||||
seed,
|
||||
@ -399,6 +433,32 @@ export function createWindField(def, opts = {}) {
|
||||
},
|
||||
get shelters() { return shelters; },
|
||||
|
||||
/**
|
||||
* A site's wind funnels (SPRINT9 site_02). Lane A calls this from the site
|
||||
* JSON after building the yard, the same way it calls setSheltersFromTrees.
|
||||
* Unset = no funnels, so backyard_01 is untouched. Each zone:
|
||||
* { x, z } centre of the throat, metres
|
||||
* axis direction the gap runs, RADIANS in the XZ plane
|
||||
* gain peak speed multiplier when the wind is dead-on (>1)
|
||||
* radius how far the acceleration reaches, metres
|
||||
* sharp alignment falloff exponent — higher = only a wind almost
|
||||
* exactly along the axis funnels (default 3)
|
||||
*/
|
||||
setVenturi(list) {
|
||||
venturi = (list || []).map((v) => {
|
||||
const axis = v.axis ?? 0;
|
||||
return {
|
||||
x: v.x, z: v.z,
|
||||
axisX: Math.cos(axis), axisZ: Math.sin(axis),
|
||||
gain: Math.max(1, v.gain ?? 1.4),
|
||||
radius: v.radius ?? 4,
|
||||
sharp: Math.max(1, v.sharp ?? 3),
|
||||
};
|
||||
});
|
||||
return field;
|
||||
},
|
||||
get venturi() { return venturi; },
|
||||
|
||||
/**
|
||||
* Scalar wind speed (m/s) at a point — HORIZONTAL only, which is what an
|
||||
* anemometer reads and what the HUD, rain and grass want. The gust downdraft
|
||||
|
||||
@ -136,6 +136,15 @@ export function createWind(def, opts = {}) {
|
||||
})));
|
||||
},
|
||||
|
||||
/**
|
||||
* A site's wind funnels (SPRINT9 site_02). Lane A: call with the site JSON's
|
||||
* `wind.venturi` after building the yard. Empty/absent = no funnels, so
|
||||
* backyard_01 reads exactly as it always has.
|
||||
* @param {Array<{x,z,axis,gain,radius,sharp}>} list
|
||||
*/
|
||||
setVenturi(list) { field.setVenturi(list); return wind; },
|
||||
get venturi() { return field.venturi; },
|
||||
|
||||
/** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */
|
||||
eventsBetween(a, b) { return field.eventsBetween(a, b); },
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user