5 Commits

Author SHA1 Message Date
Rene De Ren
9a998191cd state.moveTo: unpark post-abort residue on new setpoint
When MGC's per-tick abortActiveMovements parks the FSM in
'accelerating'/'decelerating' to avoid a bounce loop, a subsequent
moveTo previously fell into the early-return path and saved the new
setpoint to delayedMove — which never fired because nothing transitioned
back to 'operational'. Now distinguish residue states from genuine
non-operational states (starting/warmingup/...) and force-transition
out of residue so the new setpoint actually executes. Also picks up
in-flight predict shareInputsFrom plumbing and pumpingStation.json
stopLevel doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:19:34 +02:00
Rene De Ren
94bcc90b4b Ignore local package-lock.json stub
generalFunctions has no production deps of its own, so any
package-lock.json found here is a stub from a stray `npm install`
inside the submodule directory. Don't track it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:24:22 +02:00
Rene De Ren
a516c2b2b6 MeasurementContainer.get: strict-resolve explicit .child(name)
A read chain `.child(name).getCurrentValue()` previously fell through
silently to the implicit-default child or the first available sibling
when the named child did not exist. Caller asked for X, got Y, no
warning. Surfaced via pumpingStation spillPrev: a fresh basin's
.child('overflow').getCurrentValue() returned the value of
'manual-qout' (the only existing child at that position).

Split the resolution into two strictness levels:
  _currentChildId (per-chain .child(name)) → STRICT, missing = null.
  this.childId    (persistent setChildId)  → HINT, falls back to
                                              'default' then first.

The persistent path is what registered children (rotatingMachine etc.)
rely on: they write under composed ids ('up-<id>') but expect reads
without explicit .child() to still resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:17:58 +02:00
Rene De Ren
4b6250cc42 pumpingStation schema: shiftArmPercent + MeasurementContainer .default doc
- control.levelbased.shiftArmPercent (default 95): output % threshold that
  arms the shift on the way up. Once armed, the up-curve % at the
  filling→draining transition becomes the held value, kept until level
  drops to shiftLevel; from there it ramps to 0 % at startLevel.
- shiftLevel description updated — it is no longer the arming trigger,
  it's the level at which the held output begins ramping down.
- MeasurementContainer.js: prominent doc block on the class plus a
  JSDoc on getFlattenedOutput documenting the `${type}.${variant}.
  ${position}.${childId}` flatten format and the implicit 'default'
  childId convention. This was the #1 footgun for new dashboard
  consumers — the comments now make the rule impossible to miss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:46:15 +02:00
Rene De Ren
35f648f64e pumpingStation schema: add flow dead-band, output formats, level-armed shift
- general.flowThreshold: configurable m3/s dead-band for steady-flow detection
- output.process / output.dbase: enum for port-0 / port-1 payload format
- control.levelbased.enableShiftedRamp: hysteresis toggle
- control.levelbased.shiftLevel: arming level for the shifted ramp
- inflowLevel description clarified as "bottom/invert of inlet pipe"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:16:20 +02:00
5 changed files with 326 additions and 25 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
# Local stub generated by `npm install` in the submodule directory.
# generalFunctions has no production deps of its own.
package-lock.json

View File

@@ -22,6 +22,14 @@
"description": "The default flow unit used for reporting station throughput."
}
},
"flowThreshold": {
"default": 0.0001,
"rules": {
"type": "number",
"min": 0,
"description": "Flow dead-band in m3/s below which the station treats net flow as steady."
}
},
"logging": {
"logLevel": {
"default": "info",
@@ -127,6 +135,50 @@
}
}
},
"output": {
"process": {
"default": "process",
"rules": {
"type": "enum",
"values": [
{
"value": "process",
"description": "Delta-compressed process message."
},
{
"value": "json",
"description": "JSON payload."
},
{
"value": "csv",
"description": "CSV-formatted payload."
}
],
"description": "Format of the process payload emitted on output port 0."
}
},
"dbase": {
"default": "influxdb",
"rules": {
"type": "enum",
"values": [
{
"value": "influxdb",
"description": "InfluxDB telemetry payload."
},
{
"value": "json",
"description": "JSON payload."
},
{
"value": "csv",
"description": "CSV-formatted payload."
}
],
"description": "Format of the telemetry payload emitted on output port 1."
}
}
},
"asset": {
"uuid": {
"default": null,
@@ -240,7 +292,7 @@
"rules": {
"type": "number",
"min": 0,
"description": "Height of the inlet pipe measured from the basin floor (m)."
"description": "Bottom/invert height of the inlet pipe measured from the basin floor (m)."
}
},
"outflowLevel": {
@@ -248,7 +300,7 @@
"rules": {
"type": "number",
"min": 0,
"description": "Height of the outlet pipe measured from the basin floor (m)."
"description": "Top height of the outlet or pump-suction pipe measured from the basin floor (m)."
}
},
"overflowLevel": {
@@ -438,7 +490,7 @@
"rules": {
"type": "number",
"min": 0,
"description": "Below this level the MGC shuts down all pumps (unconditional stop). Above dryRunLevel (safety), below startLevel (DEAD ZONE)."
"description": "Below this level the MGC shuts down all pumps (unconditional stop). Between minLevel and the active ramp start, demand is held at 0 %."
}
},
"startLevel": {
@@ -446,7 +498,16 @@
"rules": {
"type": "number",
"min": 0,
"description": "Level at which the pump demand ramp begins at 0 %. Demand scales linearly from startLevel (0 %) to maxLevel (100 %)."
"description": "Pump-on threshold and ramp foot. Below this level demand is 0 %; at or above it demand scales 0 → 100 % across [startLevel, maxLevel] using the configured curve (linear or log). When enableShiftedRamp is on, this also serves as the bottom of the held-then-ramp curve during draining."
}
},
"stopLevel": {
"default": null,
"rules": {
"type": "number",
"nullable": true,
"min": 0,
"description": "Optional pump-off threshold. When set, PS sends an explicit turnOffAllMachines command to MGC the moment level drops below stopLevel. Independent of the ramp scaling — does NOT shift where the ramp starts. Pair with a startLevel above stopLevel to get hysteresis (pumps engage at startLevel rising, disengage at stopLevel falling). Must be ≥ minLevel and ≤ startLevel."
}
},
"maxLevel": {
@@ -456,6 +517,55 @@
"min": 0,
"description": "Level at which the pump demand saturates at 100 %. Above this, demand stays clamped."
}
},
"curveType": {
"default": "linear",
"rules": {
"type": "enum",
"values": [
{
"value": "linear",
"description": "Linear demand scaling between the active lower ramp level and maxLevel."
},
{
"value": "log",
"description": "Logarithmic demand scaling with fast response early in the ramp."
}
],
"description": "Demand curve used by levelbased control."
}
},
"logCurveFactor": {
"default": 9,
"rules": {
"type": "number",
"min": 0.001,
"description": "Shape factor for the levelbased log curve; higher values increase early response."
}
},
"enableShiftedRamp": {
"default": false,
"rules": {
"type": "boolean",
"description": "When true, arm a hysteresis shift: once level rises past shiftLevel the ramp foot moves left from inflowLevel to startLevel until level falls back below startLevel."
}
},
"shiftLevel": {
"default": 0,
"rules": {
"type": "number",
"min": 0,
"description": "Level (m) at which the held output starts ramping down during draining. Must be > startLevel and ≤ maxLevel. Ignored when enableShiftedRamp is false."
}
},
"shiftArmPercent": {
"default": 95,
"rules": {
"type": "number",
"min": 0,
"max": 100,
"description": "Output % threshold that arms the shift on the way up. Once armed, the output value at the moment direction flips to draining becomes the held value, and stays held until level drops to shiftLevel. Disarms when level reaches startLevel."
}
}
},
"pressureBased": {
@@ -634,7 +744,14 @@
"default": true,
"rules": {
"type": "boolean",
"description": "If true, high level alarms and shutdowns will be enforced to prevent overfilling."
"description": "Deprecated alias for enableHighVolumeSafety. If true, high level alarms and shutdowns will be enforced to preserve overflow margin."
}
},
"enableHighVolumeSafety": {
"default": true,
"rules": {
"type": "boolean",
"description": "If true, high-volume safety actions run before the basin reaches physical overflow."
}
},
"overfillThresholdPercent": {
@@ -643,7 +760,16 @@
"type": "number",
"min": 0,
"max": 100,
"description": "Volume percentage above which overfill protection activates."
"description": "Deprecated alias for highVolumeSafetyThresholdPercent."
}
},
"highVolumeSafetyThresholdPercent": {
"default": 98,
"rules": {
"type": "number",
"min": 0,
"max": 100,
"description": "Percentage of maxVolAtOverflow where high-volume safety activates before actual overflow."
}
},
"timeleftToFullOrEmptyThresholdSeconds": {

View File

@@ -3,6 +3,51 @@ const EventEmitter = require('events');
const convertModule = require('../convert/index');
const { POSITIONS } = require('../constants/positions');
/* ============================================================================
* MeasurementContainer — measurement storage with chainable type/variant/
* position/child addressing.
*
* INTERNAL STORAGE SHAPE
* measurements[type][variant][position][childId] = Measurement instance
*
* The childId layer is ALWAYS present, even when the caller doesn't specify
* one. _getOrCreateMeasurement defaults childId to 'default' when no
* .child(...) is in the chain. So writing
*
* mc.type('level').variant('measured').position('atequipment')
* .value(2.5, ts, 'm');
*
* stores the value at measurements.level.measured.atequipment.default.
*
* READING — the chainable getters resolve the default child transparently,
* so consumers usually don't see it:
*
* mc.type('level').variant('measured').position('atequipment')
* .getCurrentValue('m'); // returns 2.5
*
* FLATTENED OUTPUT — getFlattenedOutput() emits ONE key per child, including
* the implicit 'default' bucket:
*
* {
* 'level.measured.atequipment.default': 2.5, // implicit child
* 'flow.predicted.in.manual-qin': 0.05, // explicit .child('manual-qin')
* 'flow.predicted.in.from-pump-A': 0.03,
* …
* }
*
* ⚠ DASHBOARDS / DOWNSTREAM PARSERS MUST INCLUDE THE CHILD KEY
* The flat key format is `${type}.${variant}.${position}.${childId}`.
* When you have not used .child(), the childId is the literal string
* 'default'. Use 'level.measured.atequipment.default', NOT
* 'level.measured.atequipment'. This trips up new consumers — see the
* pumpingStation basic-dashboard parser for an example that gets it right.
*
* AGGREGATION — sum() folds all children of a position into one number:
*
* mc.sum('flow', 'predicted', ['in'], 'm3/s');
* // = manual-qin + from-pump-A + … + (default if any)
* ============================================================================
*/
class MeasurementContainer {
constructor(options = {},logger) {
this.logger = logger || null;
@@ -380,16 +425,34 @@ class MeasurementContainer {
// Legacy single measurement
if (posBucket?.getCurrentValue) return posBucket;
// Child-aware: pick requested child, otherwise fall back to default, otherwise first available
// Child-aware lookup. Two separate sources of "child-id" on the
// container, with DIFFERENT strictness:
//
// _currentChildId : transient, set by .child(name) inside a chain.
// Explicit per-call. STRICT — if the named child
// does not exist, return null. Silent fall-through
// to a sibling would mask a missing-stream read
// as a wrong-stream read (see pumpingStation
// spillPrev bug, 2026-05-06).
//
// this.childId : persistent, set by setChildId(id). HINT only —
// try it first, then fall back to 'default' then
// first available. Containers registered with a
// persistent id (rotatingMachine, etc.) write
// under composed child ids (e.g. 'up-<id>') that
// don't equal the persistent id, and reads must
// still resolve to those writes.
if (posBucket && typeof posBucket === 'object') {
const requestedKey = this._currentChildId || this.childId;
const keys = Object.keys(posBucket);
if (!keys.length) return null;
const measurement =
(requestedKey && posBucket[requestedKey]) ||
posBucket.default ||
posBucket[keys[0]];
return measurement || null;
if (this._currentChildId) {
return posBucket[this._currentChildId] || null;
}
return (this.childId && posBucket[this.childId]) ||
posBucket.default ||
posBucket[keys[0]] ||
null;
}
return null;
@@ -535,18 +598,43 @@ class MeasurementContainer {
.reduce((acc, v) => acc + v, 0);
}
/**
* Flatten the entire container to a key→value map, suitable for
* dashboards / InfluxDB / debug dumps.
*
* KEY FORMAT — child-bucketed series (the common case):
* `${type}.${variant}.${position}.${childId}`
*
* Even measurements written without an explicit `.child(...)` end up
* here under `childId === 'default'` (see _getOrCreateMeasurement).
* Examples:
* level.measured.atequipment.default // implicit child
* flow.predicted.in.manual-qin // explicit child
* flow.predicted.in.from-pump-A // explicit child
*
* Consumers (Node-RED dashboards, parsers) MUST include the trailing
* `.default` when reading default-bucket measurements. Stripping it
* silently misses the value. This is the #1 footgun for new code that
* uses MeasurementContainer.
*
* The "Legacy single series" branch below catches a pre-v2 storage
* shape where a position held a Measurement directly (no child layer);
* new code never produces that shape but old serialized state may.
*/
getFlattenedOutput(options = {}) {
const requestedUnits = options.requestedUnits || (options.usePreferredUnits ? this.preferredUnits : null);
const out = {};
Object.entries(this.measurements).forEach(([type, variants]) => {
Object.entries(variants).forEach(([variant, positions]) => {
Object.entries(positions).forEach(([position, entry]) => {
// Legacy single series
// Legacy single series (no childId layer)
if (entry?.getCurrentValue) {
out[`${type}.${variant}.${position}`] = this._resolveOutputValue(type, entry, requestedUnits);
return;
}
// Child-bucketed series
// Child-bucketed series — ALWAYS the case for new writes,
// including the implicit 'default' bucket when no .child() is
// used. The flat key carries the childId.
if (entry && typeof entry === 'object') {
Object.entries(entry).forEach(([childId, m]) => {
if (m?.getCurrentValue) {

View File

@@ -68,6 +68,13 @@ const Interpolation = require('./interpolation');
class Predict {
constructor(config = {}) {
// Capture share-source BEFORE config validation strips it (ConfigUtils
// mutates the input config to drop unknown keys, which would remove
// shareInputsFrom because it's not in predictConfig.json's schema).
const _sharedSource = (config && config.shareInputsFrom instanceof Predict)
? config.shareInputsFrom
: null;
// Initialize dependencies
this.emitter = new EventEmitter(); // Own EventEmitter
this.configUtils = new ConfigUtils(defaultConfig);
@@ -107,8 +114,29 @@ class Predict {
this.calculationPoints = this.config.normalization.parameters.curvePoints;
this.interpolationType = this.config.interpolation.type;
// Load curve if provided
if (config.curve) {
// Load curve if provided.
// shareInputsFrom: an existing Predict instance whose pre-built input
// curves and splines we adopt by reference. Used to create a parallel
// "view" of the same source curves (e.g. an MGC group-scope predict
// that mirrors a pump's individual predict). Per-instance state —
// currentF / currentX / currentFxyCurve / currentFxySplines /
// currentFxyY/X Min/Max / outputY — stays freshly initialised so the
// two views have independent operating points. Curve mutations on the
// source via updateCurve() are propagated through the source's
// "curveUpdated" emitter (see updateCurve below).
if (_sharedSource) {
this._adoptInputsFrom(_sharedSource);
this._sharedInputsSource = _sharedSource;
this._sharedInputsHandler = (newCurve) => {
this._adoptInputsFrom(this._sharedInputsSource);
// Keep our currentF in range; constrain re-uses the new fValues.
this.fDimension = this.constrain(this.currentF, this.fValues.min, this.fValues.max);
};
this._sharedInputsSource.emitter.on('curveUpdated', this._sharedInputsHandler);
// Initialise our own operating point to the source's min, same as
// the standard buildAllFxyCurves flow does at end of curve load.
this.fDimension = this.fValues.min;
} else if (config.curve) {
this.inputCurveData = config.curve;
} else {
this.logger.warn("No curve data provided. Please set curve data using setCurveData method. Using default");
@@ -117,6 +145,31 @@ class Predict {
}
// Adopt another Predict's input curves and splines by reference. Used by
// the shareInputsFrom constructor option and by the curveUpdated emitter
// handler to re-sync after the source's curves change. Does NOT touch
// per-instance state (currentF, currentX, currentFxy* etc.).
//
// Also copies the scalar parameters (calculationPoints, normMin/Max,
// interpolationType) so the clone uses the SAME pointsCount the source
// built fSplines with — otherwise buildSingleFxyCurve can iterate past
// the end of the shared fSplines.
_adoptInputsFrom(source) {
this.inputCurve = source.inputCurve;
this.normalizedCurve = source.normalizedCurve;
this.calculatedCurve = source.calculatedCurve;
this.fCurve = source.fCurve;
this.fSplines = source.fSplines;
this.normalizedSplines = source.normalizedSplines;
this.xValues = source.xValues;
this.fValues = source.fValues;
this.yValues = source.yValues;
this.calculationPoints = source.calculationPoints;
this.normMin = source.normMin;
this.normMax = source.normMax;
this.interpolationType = source.interpolationType;
}
// Improved function to get a local peak in an array by starting in the middle.
// It also handles the case of a tie by preferring the left side (arbitrary choice)
// when array[start] == leftValue or array[start] == rightValue.
@@ -348,6 +401,9 @@ class Predict {
this.buildAllFxyCurves(validatedCurve);
// Notify shared-input clones (see shareInputsFrom in the constructor).
// They re-adopt our inputs and clamp their own operating point.
this.emitter.emit('curveUpdated', validatedCurve);
}
constrain(value,min,max) {

View File

@@ -66,15 +66,41 @@ class state{
}
if (this.stateManager.getCurrentState() !== "operational") {
if (this.config.mode.current === "auto") {
this.delayedMove = targetPosition;
this.logger.warn(`Saving setpoint=${targetPosition} to execute once back in 'operational' state.`);
// 'accelerating' / 'decelerating' here is post-abort residue —
// the previous moveTo was aborted (e.g. MGC's per-tick
// abortActiveMovements) and the catch block intentionally
// doesn't auto-return to operational (avoids a bounce loop).
// BUT a new setpoint just arrived, so there's nothing for the
// anti-bounce policy to protect: the caller IS asking for a
// move. Fall through to operational and execute it. Without
// this the FSM gets parked, all subsequent setpoints land in
// delayedMove which never fires, and currentPosition freezes —
// see test/integration/abort-deadlock.integration.test.js for
// the exact deadlock scenario.
const movementResidueStates = ['accelerating', 'decelerating'];
if (movementResidueStates.includes(this.stateManager.getCurrentState())) {
this.logger.debug(`moveTo(${targetPosition}) arrived while parked in '${this.stateManager.getCurrentState()}' (post-abort). Returning to operational to service the new setpoint.`);
try {
await this.transitionToState("operational");
} catch (e) {
this.logger.warn(`Could not transition out of '${this.stateManager.getCurrentState()}': ${e?.message || e}`);
return;
}
// Fall through — state is now operational, proceed with new move.
} else {
// Genuine non-operational state (starting, warmingup, stopping,
// coolingdown, idle, off, emergencystop, maintenance) — these
// are sequence steps the caller can't legitimately interrupt
// with a setpoint. Save for later, exactly as before.
if (this.config.mode.current === "auto") {
this.delayedMove = targetPosition;
this.logger.warn(`Saving setpoint=${targetPosition} to execute once back in 'operational' state.`);
}
else{
this.logger.warn(`Not able to accept setpoint=${targetPosition} while not in ${this.stateManager.getCurrentState()} state`);
}
return;
}
else{
this.logger.warn(`Not able to accept setpoint=${targetPosition} while not in ${this.stateManager.getCurrentState()} state`);
}
//return early
return;
}
this.abortController = new AbortController();
const { signal } = this.abortController;