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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user