Compare commits
5 Commits
4252292ae1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a998191cd | ||
|
|
94bcc90b4b | ||
|
|
a516c2b2b6 | ||
|
|
4b6250cc42 | ||
|
|
35f648f64e |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal 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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
@@ -116,6 +144,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)
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user