Reconciles the 7-commit basin-docs-update feature branch (which never
landed on main before the platform refactor) with the post-refactor
architecture on development. Each basin-docs feature ported into the
relevant concern module:
control/levelBased.js
- stopLevel Schmitt-trigger + dead-band keep-alive
- Shifted ramp (arm % → hold @ 100% → ramp down to shiftLevel)
- Linear vs log up-curve (curveType + logCurveFactor)
measurement/flowAggregator.js
- Predicted-volume overflow clamp + spill flow stream
- Cumulative overflowVolume + underflowVolume
- Hard floor at 0 + dry-run-on-transition handling
basin/thresholdValidator.js
- computeSafetyPoints exposes dryRunLevel + highVolumeSafetyLevel
- startLevel ≤ inflowLevel invariant added
measurement/calibration.js + commands/
- Manual q_out path (set.outflow / q_out alias)
safety/safetyController.js
- Accepts both legacy + new high-volume threshold names
UI:
pumpingStation.html — restored the side-panel + SVG mode-preview block,
added defaults for stopLevel/shiftLevel/shiftArmPercent/levelCurveType/
logCurveFactor/enableShiftedRamp.
src/editor/* — basin-docs' 7-file modular editor (replaces single
src/editor.js, which is deleted).
pumpingStation.js — admin endpoint serves editor/:file.
Tests: 130/130 pass (125 basic + 5 integration). Two basin-docs test
files added: nodeClass-config.test.js, basic-dashboard-flow.test.js,
shifted-ramp-end-to-end.test.js. One pre-refactor control-levelBased
test adapted to match basin-docs canonical "no-shutdown in dead zone"
behaviour.
Human-review items (see commit context):
- rampFoot = inflowLevel (matches basin-docs test); basin-docs source
used rampFoot = startLevel. Domain owner: confirm intent.
- Naming kept dual (overfillLevel + highVolumeSafetyLevel).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
92 lines
3.2 KiB
JavaScript
92 lines
3.2 KiB
JavaScript
// Calibration helpers for the pumping-station predicted volume / level
|
|
// streams. Pure functions over a context bag holding the live
|
|
// MeasurementContainer + basin geometry. After every calibration the
|
|
// integrator state is reset so the next tick starts from the new anchor.
|
|
|
|
function _resetFlowState(ctx, timestamp) {
|
|
if (ctx.flowAggregator?.resetState) {
|
|
ctx.flowAggregator.resetState(timestamp);
|
|
return;
|
|
}
|
|
ctx._predictedFlowState = { inflow: 0, outflow: 0, lastTimestamp: timestamp };
|
|
}
|
|
|
|
function _clearSeries(measurements, type) {
|
|
const series = measurements.type(type).variant('predicted').position('atequipment');
|
|
if (series.exists()) {
|
|
const m = series.get();
|
|
if (m) {
|
|
m.values = [];
|
|
m.timestamps = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
function _levelFromVolume(basin, volume) {
|
|
const area = basin.surfaceArea;
|
|
return area > 0 ? Math.max(volume, 0) / area : 0;
|
|
}
|
|
|
|
function _volumeFromLevel(basin, level) {
|
|
const area = basin.surfaceArea;
|
|
return area > 0 ? Math.max(level, 0) * area : 0;
|
|
}
|
|
|
|
function calibratePredictedVolume(ctx, calibratedVol, timestamp = Date.now()) {
|
|
if (!ctx?.measurements || !ctx.basin) {
|
|
throw new Error('calibratePredictedVolume: ctx.measurements and ctx.basin required');
|
|
}
|
|
const { measurements, basin } = ctx;
|
|
|
|
_clearSeries(measurements, 'volume');
|
|
_clearSeries(measurements, 'level');
|
|
|
|
measurements.type('volume').variant('predicted').position('atequipment')
|
|
.value(calibratedVol, timestamp, 'm3').unit('m3');
|
|
measurements.type('level').variant('predicted').position('atequipment')
|
|
.value(_levelFromVolume(basin, calibratedVol), timestamp, 'm');
|
|
|
|
_resetFlowState(ctx, timestamp);
|
|
}
|
|
|
|
function calibratePredictedLevel(ctx, level, timestamp = Date.now(), unit = 'm') {
|
|
if (!ctx?.measurements || !ctx.basin) {
|
|
throw new Error('calibratePredictedLevel: ctx.measurements and ctx.basin required');
|
|
}
|
|
const { measurements, basin } = ctx;
|
|
|
|
_clearSeries(measurements, 'volume');
|
|
_clearSeries(measurements, 'level');
|
|
|
|
measurements.type('level').variant('predicted').position('atequipment')
|
|
.value(level, timestamp, unit);
|
|
measurements.type('volume').variant('predicted').position('atequipment')
|
|
.value(_volumeFromLevel(basin, level), timestamp, 'm3');
|
|
|
|
_resetFlowState(ctx, timestamp);
|
|
}
|
|
|
|
function setManualInflow(ctx, value, timestamp = Date.now(), unit = 'm3/s') {
|
|
if (!ctx?.measurements) throw new Error('setManualInflow: ctx.measurements required');
|
|
const num = Number(value);
|
|
ctx.measurements.type('flow').variant('predicted').position('in').child('manual-qin')
|
|
.value(num, timestamp, unit);
|
|
}
|
|
|
|
// Manual outflow injection mirroring setManualInflow — basin-docs adds this
|
|
// for the dashboard's q_out topic so tests can drive a drain stroke without
|
|
// instantiating a real pump.
|
|
function setManualOutflow(ctx, value, timestamp = Date.now(), unit = 'm3/s') {
|
|
if (!ctx?.measurements) throw new Error('setManualOutflow: ctx.measurements required');
|
|
const num = Number(value);
|
|
ctx.measurements.type('flow').variant('predicted').position('out').child('manual-qout')
|
|
.value(num, timestamp, unit);
|
|
}
|
|
|
|
module.exports = {
|
|
calibratePredictedVolume,
|
|
calibratePredictedLevel,
|
|
setManualInflow,
|
|
setManualOutflow,
|
|
};
|