Add threshold guardrails, fix calibratePredictedLevel bug, rewrite tests

### Guardrails (specificClass.js)

New _validateThresholdOrdering() runs in the constructor. Checks every
ordered pair of basin + control + derived-safety levels and logs a
warning for each violation; returns the list as this.thresholdIssues
so tests and the eval harness can inspect. Non-fatal — we prefer a
running-but-warned station to a refusal-to-start (availability-first).

Strict invariants (bottom → top):
  0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
  dryRunLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overfillLevel

Uses a list-of-checks pattern rather than a switch — easier to add new
invariants without reflowing cases, and the list itself is readable
documentation.

### Bug fix (specificClass.js)

calibratePredictedLevel was writing the volume value into the LEVEL
slot. Root cause: MeasurementContainer is stateful — its type()/
variant()/position() calls mutate the container's own cursor, so
caching chain references (const levelChain = ...; const volumeChain
= ...) doesn't isolate them. The second cached chain ended up sharing
the state of the last type() call. Rebuilt chains fresh each time,
matching the calibratePredictedVolume pattern that already worked.

### Tests (test/basic/specificClass.test.js)

Ported from Jest to node:test + node:assert — the project's standard
per .claude/rules/testing.md. Deleted the stale test/specificClass.test.js
(tests referenced methods that no longer exist post-rename).

New coverage, 42 passing subtests:
- Basin geometry derivations + minHeightBasedOn
- Level/volume roundtrip
- Threshold guardrails (5 violation cases)
- Direction derivation
- Mode change accept/reject
- Calibration (volume and level paths — catches the bug above)
- Levelbased control zones (STOP / DEAD ZONE / RAMP / saturate)
- getOutput flattening
- setManualInflow

Run with: node --test test/basic/*.test.js

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-22 16:38:41 +02:00
parent a2189457f6
commit 016433abe6
3 changed files with 368 additions and 278 deletions

View File

@@ -112,10 +112,12 @@ class PumpingStation {
const thresholdFromConfig = Number(this.config.general?.flowThreshold);
this.flowThreshold = Number.isFinite(thresholdFromConfig) ? thresholdFromConfig : 1e-4;
// Compute basin geometry from config and seed the predicted volume
// at the basin's minimum volume (outflowLevel or inflowLevel based
// on config.hydraulics.minHeightBasedOn).
// Geometry + threshold ordering check. initBasinProperties seeds
// predicted volume at minVol; _validateThresholdOrdering warns if
// any physical/control invariant is violated. Non-fatal — prefer
// continuity over refusal to start (availability-first).
this.initBasinProperties();
this.thresholdIssues = this._validateThresholdOrdering();
this.logger.debug('PumpingStation initialized');
}
@@ -244,23 +246,22 @@ class PumpingStation {
}
calibratePredictedLevel(val, timestamp = Date.now(), unit = 'm') {
const volumeChain = this.measurements.type('volume').variant('predicted').position('atequipment');
const levelChain = this.measurements.type('level').variant('predicted').position('atequipment');
const volumeMeasurement = volumeChain.exists() ? volumeChain.get() : null;
if (volumeMeasurement) {
volumeMeasurement.values = [];
volumeMeasurement.timestamps = [];
// Rebuild the chain each time — MeasurementContainer is stateful
// (its type/variant/position methods mutate the container itself,
// so cached chain references share one cursor).
const volMeas = this.measurements.type('volume').variant('predicted').position('atequipment');
if (volMeas.exists()) {
const m = volMeas.get();
m.values = []; m.timestamps = [];
}
const lvlMeas = this.measurements.type('level').variant('predicted').position('atequipment');
if (lvlMeas.exists()) {
const m = lvlMeas.get();
m.values = []; m.timestamps = [];
}
const levelMeasurement = levelChain.exists() ? levelChain.get() : null;
if (levelMeasurement) {
levelMeasurement.values = [];
levelMeasurement.timestamps = [];
}
levelChain.value(val, timestamp).unit(unit);
volumeChain.value(this._calcVolumeFromLevel(val), timestamp, 'm3');
this.measurements.type('level').variant('predicted').position('atequipment').value(val, timestamp, unit);
this.measurements.type('volume').variant('predicted').position('atequipment').value(this._calcVolumeFromLevel(val), timestamp, 'm3');
this._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
}
@@ -775,6 +776,60 @@ class PumpingStation {
this.measurements.type('volume').variant('predicted').position('atequipment').value(minVol).unit('m3');
}
/**
* Validate basin + control threshold ordering.
*
* Every pair is a strict physical or control invariant. Violations
* don't throw — they log a warning and return the list so callers
* (tests, node-status, the eval harness) can surface them. Returning
* [] means "all invariants hold".
*
* Strict invariants (bottom → top):
* 0 < outflowLevel < inflowLevel < overflowLevel ≤ basinHeight
* dryRunTriggerLevel ≤ minLevel ≤ startLevel < maxLevel ≤ overflowLevel
*
* dryRunTriggerLevel and the overfill trigger are DERIVED — computed
* from minVol × (1 + dryRunThresholdPercent/100) and overflowLevel ×
* overfillThresholdPercent/100 in the safety layer. Validating those
* catches config that would let minLevel sit below where safety has
* already force-stopped the pumps (no-op control band).
*/
_validateThresholdOrdering() {
const basin = this.basin;
const lvl = this.config.control?.levelbased || {};
const safety = this.config.safety || {};
// Derived safety trigger levels (level-space equivalents of what
// _safetyController does in volume-space).
const dryRunPct = Number(safety.dryRunThresholdPercent) || 0;
const overfillPct = Number(safety.overfillThresholdPercent) || 100;
const refLowLevel = basin.minHeightBasedOn === 'inlet' ? basin.inflowLevel : basin.outflowLevel;
const dryRunLevel = refLowLevel * (1 + dryRunPct / 100);
const overfillLevel = basin.overflowLevel * (overfillPct / 100);
const checks = [
['outflowLevel', basin.outflowLevel, '<', 'inflowLevel', basin.inflowLevel],
['inflowLevel', basin.inflowLevel, '<', 'overflowLevel', basin.overflowLevel],
['overflowLevel', basin.overflowLevel, '<=', 'basinHeight', basin.heightBasin],
['dryRunLevel', dryRunLevel, '<=', 'minLevel', lvl.minLevel],
['minLevel', lvl.minLevel, '<=', 'startLevel', lvl.startLevel],
['startLevel', lvl.startLevel, '<', 'maxLevel', lvl.maxLevel],
['maxLevel', lvl.maxLevel, '<=', 'overfillLevel', overfillLevel],
];
const issues = [];
for (const [aName, a, op, bName, b] of checks) {
if (!Number.isFinite(a) || !Number.isFinite(b)) continue;
const ok = op === '<' ? a < b : a <= b;
if (!ok) {
const msg = `Threshold invariant violated: ${aName} (${a}) must be ${op} ${bName} (${b})`;
issues.push({ aName, a, op, bName, b, msg });
this.logger.warn(msg);
}
}
return issues;
}
/** Convert level (m from floor) → volume (m3). Clamps to 0. */
_calcVolumeFromLevel(level) {
return Math.max(level, 0) * this.basin.surfaceArea;