sail: break diverged corners on the production path; enforce pond cap post-flow
A NaN corner load compares false to every rating, so a diverged corner held forever while NaN spread node-to-node — watchDivergence only ever guarded the tests. Non-finite load now breaks the corner instantly with reason:'divergence', load zeroed for the HUD. The pond per-node cap only ran on the rain-add: once rain stopped, consolidation could stack a basin node past POND_MAX_KG_M2 unbounded. Clamp moved post-flow, every step. Also fixes the dumpPond assert that compared dumped to itself through a helper that returned its argument. All three mutation-checked red before landing green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3432ea0d1c
commit
9d6db356d7
@ -542,8 +542,6 @@ export class SailRig {
|
||||
const a = this._nodeArea[n];
|
||||
if (a > 1e-9 && kgPerM2PerSec > 0) {
|
||||
w[n] += kgPerM2PerSec * this._nodeFlat[n] * dt; // rain lands on the horizontal projection
|
||||
const cap = POND_MAX_KG_M2 * a;
|
||||
if (w[n] > cap) w[n] = cap;
|
||||
}
|
||||
flow[n] = 0;
|
||||
}
|
||||
@ -569,7 +567,17 @@ export class SailRig {
|
||||
const moved = Math.min(w[n], w[n] * POND_FLOW * bestGrad * dt);
|
||||
flow[n] -= moved; flow[best] += moved;
|
||||
}
|
||||
for (let n = 0; n < N2; n++) w[n] += flow[n];
|
||||
// The per-node ceiling is enforced AFTER the flow step, not on the rain-add:
|
||||
// the old rain-add clamp only ran while it was raining, so once the rain
|
||||
// stopped, downhill consolidation could quietly stack a basin node past
|
||||
// POND_MAX_KG_M2 with nothing left to trim it (the belly-tear guard below
|
||||
// only bounds the gross runaway). Water over the cap sheets off the cloth —
|
||||
// it leaves the sail, same as the rim spill; it is not conserved.
|
||||
for (let n = 0; n < N2; n++) {
|
||||
w[n] += flow[n];
|
||||
const a = this._nodeArea[n];
|
||||
if (a > 1e-9 && w[n] > POND_MAX_KG_M2 * a) w[n] = POND_MAX_KG_M2 * a;
|
||||
}
|
||||
|
||||
// A rim node spills over the edge ONLY when the edge is downhill — i.e. it
|
||||
// has no lower interior neighbour to send water to. A hypar's low corners
|
||||
@ -916,6 +924,22 @@ export class SailRig {
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const c = this.corners[k];
|
||||
if (c.broken) continue;
|
||||
// A non-finite load can never trip the rating check below — NaN compares
|
||||
// false to everything — so a diverged corner would otherwise hold FOREVER
|
||||
// while NaN spreads node-to-node through the spring network. Divergence
|
||||
// is a failure, not weather: break the corner now, on the production
|
||||
// path, not just under the test-only watchDivergence tripwire. The event
|
||||
// carries reason:'divergence' so a log can tell it from an honest blow.
|
||||
if (!Number.isFinite(c.load)) {
|
||||
c.broken = true;
|
||||
c.overload = 0;
|
||||
c.load = 0;
|
||||
c.loadVec.x = c.loadVec.y = c.loadVec.z = 0;
|
||||
this.invMass[this.cornerIdx[k]] = 1 / this.nodeMass;
|
||||
this.dumpPond('corner blew');
|
||||
this.events.emit('break', { type: 'break', corner: c, anchorId: c.anchorId, hw: c.hw.name, t: this.t, reason: 'divergence' });
|
||||
continue;
|
||||
}
|
||||
if (c.load > c.hw.rating * (c.anchor.ratingHint ?? 1)) c.overload += dt;
|
||||
else c.overload = Math.max(0, c.overload - dt * OVERLOAD_RECOVER);
|
||||
if (c.overload > OVERLOAD_SECS) {
|
||||
|
||||
@ -197,6 +197,48 @@ test('sim stays finite through a full storm', () => {
|
||||
return `peak ${kN(r.corners.reduce((m, c) => Math.max(m, c.peakLoad), 0))}`;
|
||||
});
|
||||
|
||||
test('divergence BREAKS on the production path — a NaN corner cannot hold forever', () => {
|
||||
// watchDivergence stays OFF here on purpose: that tripwire is test-only, and
|
||||
// this test is about the SHIPPED path. Before the guard in _checkFailure,
|
||||
// `load > rating` was false for NaN — a diverged corner never broke, never
|
||||
// recovered, and the sail froze silently while NaN ate the spring network.
|
||||
const r = new SailRig({ anchors: makeAnchors(HEIGHTS_FLAT), gridN: 10 });
|
||||
r.attach(ALL_IDS, Array(4).fill(HARDWARE[2]), 1.0);
|
||||
const broke = [];
|
||||
r.events.on('break', (e) => broke.push(e));
|
||||
const w = makeStubWind({ stormLen: 90 });
|
||||
runStorm(r, w, 2);
|
||||
assert(broke.length === 0, 'rig should be healthy before the poison');
|
||||
// Poison one interior node — the shape a solver blow-up leaves behind.
|
||||
r.pos[(r.N + 1) * 3 + 1] = NaN;
|
||||
runStorm(r, w, 2);
|
||||
assert(broke.some((e) => e.reason === 'divergence'),
|
||||
`NaN spread through the cloth and no corner broke by divergence — the silent freeze is back (${broke.length} breaks)`);
|
||||
const b = r.corners.find((c) => c.broken);
|
||||
assert(b && b.load === 0, 'a corner broken by divergence must read load 0, not NaN, for the HUD');
|
||||
return `${broke.filter((e) => e.reason === 'divergence').length}/4 corners let go by divergence, HUD reads 0`;
|
||||
});
|
||||
|
||||
test('ponding: consolidation cannot stack one node past the cap (post-flow clamp)', () => {
|
||||
// The rain-add clamp only ran WHILE raining; once rain stopped, downhill
|
||||
// flow could pile a basin node arbitrarily high and nothing trimmed it.
|
||||
// Inject an absurd pond onto the settled belly and take one step: the
|
||||
// clamp must shed it — and it must be the CLAMP doing it, not a belly
|
||||
// tear, or the assert is measuring the wrong mechanism.
|
||||
const r = carportRig(UNBREAKABLE);
|
||||
const still = { sample: (_p, _t, out) => (out ? out.set(0, 0, 0) : { x: 0, y: 0, z: 0 }), rainMmPerHour: () => 0 };
|
||||
runStorm(r, still, 5); // settle: the belly is the basin
|
||||
const dumps = [];
|
||||
r.events.on('pondDump', (e) => dumps.push(e));
|
||||
const belly = Math.floor(r.N / 2) * r.N + Math.floor(r.N / 2);
|
||||
r.water[belly] = 2000; // ~7x any sane per-node cap
|
||||
r.step(SIM_DT, still, 5 + SIM_DT);
|
||||
assert(dumps.length === 0, `the belly tore (${dumps[0]?.reason}) — reduce the injection, this test is about the clamp`);
|
||||
assert(r.water[belly] < 400,
|
||||
`node ${belly} still holds ${r.water[belly].toFixed(0)} kg after a step — the per-node cap is not enforced once rain stops`);
|
||||
return `injected 2000 kg, one step later the node holds ${r.water[belly].toFixed(0)} kg, no tear`;
|
||||
});
|
||||
|
||||
test('sail sags under gravity when calm', () => {
|
||||
const r = rig(HEIGHTS_FLAT);
|
||||
runStorm(r, makeStubWind({ calm: true }), 6);
|
||||
@ -846,12 +888,17 @@ test('ponding: mass conserves until something dumps it', () => {
|
||||
const dry = { ...w, rainAt: () => 0, rainMmPerHour: () => 0 };
|
||||
runStorm(r, dry, 5);
|
||||
assert(r.pondMass() <= held + 1e-6, `pond GREW from ${held.toFixed(0)} to ${r.pondMass().toFixed(0)} kg with no rain`);
|
||||
// The old assert here compared `dumped` to itself through a helper that
|
||||
// returned its argument — a tautology that could not fail no matter what
|
||||
// dumpPond returned. Say the real thing: what it reports dropping is what
|
||||
// was on the cloth.
|
||||
const before = r.pondMass();
|
||||
assert(before > 1, `only ${before.toFixed(1)} kg left to dump — test is vacuous`);
|
||||
const dumped = r.dumpPond('test');
|
||||
assert(Math.abs(dumped - r_prev(r, dumped)) < 1e-9 || dumped > 0, 'dumpPond should report what it dropped');
|
||||
assert(Math.abs(dumped - before) < 1e-9, `dumpPond reported ${dumped.toFixed(1)} kg but ${before.toFixed(1)} kg was on the cloth`);
|
||||
assert(r.pondMass() === 0, 'dumpPond left water behind');
|
||||
return `held ${held.toFixed(0)} kg, dumped ${dumped.toFixed(0)} kg, sail now dry`;
|
||||
});
|
||||
function r_prev(_r, d) { return d; }
|
||||
|
||||
test('ponding: a blown corner tips the pond off (DESIGN.md sudden dump)', () => {
|
||||
const r = rig(HEIGHTS_FLAT, { hw: HARDWARE[1] });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user