Compare commits

...

13 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
znetsixe
4252292ae1 pumpingStation schema: rename basin/control thresholds to wiki naming
Matches the 5-threshold convention (dryRunLevel, minLevel, startLevel,
maxLevel, overflowLevel) introduced in the pumpingStation wiki:

  basin.heightInlet              → basin.inflowLevel
  basin.heightOutlet             → basin.outflowLevel
  basin.heightOverflow           → basin.overflowLevel
  control.levelbased.stopLevel   → control.levelbased.minLevel
  control.levelbased.maxFlowLevel → control.levelbased.maxLevel
  control.levelbased.minFlowLevel → removed (redundant with startLevel)
  control.levelbased.startLevel  → unchanged

Description strings tightened to reference the semantic role instead
of generic "min level to scale flow" prose.

Breaking change for existing saved flows. Ties in with pumpingStation
commit a218945 which updates the consumer code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:14:15 +02:00
znetsixe
693517cc8f fix: conditional abort recovery — don't auto-transition on routine aborts
The unconditional transition to 'operational' after every movement abort
caused a bounce loop when MGC called abortActiveMovements on each demand
tick: abort→operational→new-flowmovement→abort→operational→... endlessly.
Pumps never reached their setpoint.

Fix: abortCurrentMovement now takes an options.returnToOperational flag
(default false). Routine MGC aborts leave the pump in accelerating/
decelerating — the pump continues its residual movement and reaches
operational naturally. Shutdown/emergency-stop paths pass
returnToOperational:true so the FSM unblocks for the stopping transition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:01:41 +02:00
znetsixe
086e5fe751 fix: remove bogus machineCurve default that poisoned prediction splines
The schema default for machineCurve.nq had a dummy pressure slice at
key "1" with x=[1..5] y=[10..50]. configUtils.updateConfig deep-merges
defaults into the real config, so this fake slice survived alongside the
real pressure slices (70000, 80000, ..., 390000 Pa). The predict class
then included it in its pressure-dimension spline, pulling all
interpolated y-values toward the dummy data at low pressures and
producing NEGATIVE flow predictions (e.g. -243 m³/h) where the real
curve is strictly positive.

Fix: default to empty objects {nq: {}, np: {}} so the deep merge adds
nothing. The validateMachineCurve function already returns the whole
default if the real curve is missing or invalid, so the empty default
doesn't break the no-curve-data path — it just stops poisoning the
real curve data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:27:59 +02:00
znetsixe
29b78a3f9b fix(childRegistrationUtils): alias rotatingmachine/machinegroupcontrol so production parents see them
The MGC and pumpingStation registerChild handlers dispatch on
softwareType === 'machine' / 'machinegroup' / 'pumpingstation' /
'measurement'. But buildConfig sets functionality.softwareType to the
lowercased node name, so in production rotatingMachine reports
'rotatingmachine' and machineGroupControl reports 'machinegroupcontrol'.
Result: the MGC <-> rotatingMachine and pumpingStation <-> MGC wiring
silently never hit the right branch in production, even though every
unit test passes (tests pass an already-aliased softwareType manually).

Fix: tiny SOFTWARE_TYPE_ALIASES map at the central registerChild
dispatcher in childRegistrationUtils. Real production names get
translated to the dispatch keys parents already check for, while tests
that pass already-aliased keys are unaffected (their values aren't in
the alias map and pass through unchanged).

  rotatingmachine        -> machine
  machinegroupcontrol    -> machinegroup

Verified end-to-end on Dockerized Node-RED: MGC now reports
'3 machine(s) connected' when wired to 3 rotatingMachine ports;
pumpingStation registers MGC as a machinegroup child and listens to
its predicted-flow stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:53:21 +02:00
znetsixe
43f69066af fix(asset-menu): supplier->type->model cascade lost the model dropdown
Reproduction (any node using assetMenu — measurement, rotatingMachine,
pumpingStation, monster, …):
  open node -> pick Vega supplier -> pick Pressure type
  -> model dropdown stays "Awaiting Type Selection"

Root cause: two interacting bugs in the chained dropdown wiring.

1. populate() inside both wireEvents() and loadData() auto-dispatched a
   synthetic 'change' event whenever the value of the rebuilt <select>
   differed from before the rebuild. That meant rebuilding 'type' inside
   the supplier change handler could fire the *type* change handler
   mid-way through, populate the model dropdown, and then return — only
   for the supplier handler to continue and unconditionally call
   populate(elems.model, [], '', undefined, 'Awaiting Type Selection'),
   wiping the model dropdown back to empty.

2. loadData() ran the same auto-dispatch path, so on initial open of a
   saved node the synthetic change cascaded through wireEvents listeners
   AND loadData's own sequential populate calls double-populated each
   level. The visible state depended on which path won the race.

Fix: convert the chain to an explicit downward cascade.

- populate() no longer dispatches change events. It simply rebuilds the
  <select> with placeholder + options and assigns the requested value.
- New cascadeFromSupplier / cascadeFromType / cascadeFromModel helpers
  read the *current DOM value* of each upstream <select>, look up the
  matching item in menuData, and rebuild the next level — then call the
  next cascade explicitly. Order is now deterministic and the parent
  handler can never wipe the child after the child was populated.
- Each <select>'s native 'change' listener is just the corresponding
  cascade function. Same code path runs for user picks AND for initial
  load, so saved-node restore behaves identically to a fresh pick.
- The cascades are exposed under window.EVOLV.nodes.<name>.assetMenu._cascade
  so loadData (or future sync code) can re-run them after async data
  arrives without duplicating logic.

No new DOM dependencies, no test framework changes. Existing
generalFunctions tests still 52/61 (same 9 pre-existing failures
unrelated to this change).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:50:45 +02:00
znetsixe
e50be2ee66 feat: permissive unit check for user-defined measurement types + measurement digital-mode schema
MeasurementContainer.isUnitCompatible now short-circuits to accept any unit
when the measurement type is not in the built-in measureMap. Known types
(pressure, flow, power, temperature, volume, length, mass, energy) still
validate strictly. This unblocks user-defined types in the measurement
node's new digital/MQTT mode — e.g. 'humidity' with unit '%', 'co2' with
'ppm' — without forcing those units into the convert-module unit system.

measurement.json schema: add 'mode.current' (analog | digital) and
'channels' (array) so the validator stops stripping them from the runtime
config. Ignored in analog mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:42:31 +02:00
znetsixe
75d16c620a fix: make movement abort unblock subsequent FSM transitions + add rotatingMachine schema keys
state.js: When moveTo catches a 'Movement aborted' or 'Transition aborted'
error, transition the FSM back to 'operational'. This ensures a subsequent
shutdown or emergency-stop sequence is accepted — previously the FSM stayed
stuck in 'accelerating'/'decelerating' and rejected stopping/idle
transitions, silently dropping shutdown commands issued mid-ramp. Also
emits a 'movementAborted' event for observability.

rotatingMachine.json: Add schema entries for functionality.distance,
functionality.distanceUnit, functionality.distanceDescription, and top-level
output.{process,dbase}. These keys are produced by buildConfig / the HTML
editor but were previously stripped by the validator with an
'Unknown key' warning on every deploy.

configs/index.js: Trim buildConfig so it no longer unconditionally injects
distanceUnit/distanceDescription — those keys are rotatingMachine-specific
and would otherwise produce Unknown-key warnings on every other node.

Verified via Docker-hosted Node-RED E2E: shutdown from accelerating now
reaches idle; emergency stop from accelerating reaches off; 0 Unknown-key
warnings in container logs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:21:18 +02:00
znetsixe
024db5533a fix: correct 3 anomalous power values in hidrostal-H05K-S03R curve
At pressures 1600, 3200, and 3300 mbar, flow values had leaked into the
np (power) section. Replaced with linearly interpolated values from
adjacent pressure levels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:37:06 +02:00
11 changed files with 578 additions and 178 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

@@ -153,7 +153,7 @@
100
],
"y": [
52.14679487594751,
11.142207365162072,
20.746724065725342,
31.960270693111905,
45.6989826531509,
@@ -411,7 +411,7 @@
"y": [
8.219999984177646,
13.426327986363882,
57.998168647814666,
25.971821741448165,
42.997354839160536,
64.33911122026377
]
@@ -427,7 +427,7 @@
"y": [
8.219999984177646,
13.426327986363882,
53.35067019159144,
25.288156424842576,
42.48429874246399,
64.03769740244357
]

View File

@@ -109,7 +109,7 @@ class ConfigManager {
functionality: {
softwareType: nodeName.toLowerCase(),
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
distance: uiConfig.hasDistance ? uiConfig.distance : undefined
distance: uiConfig.hasDistance ? uiConfig.distance : null
},
output: {
process: uiConfig.processOutputFormat || 'process',

View File

@@ -411,6 +411,34 @@
}
}
},
"mode": {
"current": {
"default": "analog",
"rules": {
"type": "enum",
"values": [
{
"value": "analog",
"description": "Single-scalar input mode (classic 4-20mA / PLC style). msg.payload is a number; the node runs one offset/scaling/smoothing/outlier pipeline and emits one MeasurementContainer slot."
},
{
"value": "digital",
"description": "Multi-channel input mode (MQTT / IoT JSON style). msg.payload is an object keyed by channel names declared under config.channels; the node routes each key through its own pipeline and emits N slots from one input message."
}
],
"description": "Selects how incoming msg.payload is interpreted."
}
}
},
"channels": {
"default": [],
"rules": {
"type": "array",
"itemType": "object",
"minLength": 0,
"description": "Channel map used in digital mode. Each entry is a self-contained pipeline definition: {key, type, position, unit, scaling?, smoothing?, outlierDetection?, distance?}. Ignored in analog mode."
}
},
"outlierDetection": {
"enabled": {
"default": false,

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,
@@ -235,23 +287,23 @@
"description": "Unit used for level related setpoints and thresholds."
}
},
"heightInlet": {
"inflowLevel": {
"default": 2,
"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)."
}
},
"heightOutlet": {
"outflowLevel": {
"default": 0.2,
"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)."
}
},
"heightOverflow": {
"overflowLevel": {
"default": 2.5,
"rules": {
"type": "number",
@@ -433,36 +485,86 @@
}
},
"levelbased": {
"minLevel": {
"default": 1,
"rules": {
"type": "number",
"min": 0,
"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": {
"default": 1,
"rules": {
"type": "number",
"min": 0,
"description": "start of pump / group when level reaches this in meters starting from bottom."
"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": 1,
"default": null,
"rules": {
"type": "number",
"nullable": true,
"min": 0,
"description": "stop of pump / group when level reaches this in meters starting from bottom"
"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."
}
},
"minFlowLevel": {
"default": 1,
"rules": {
"type": "number",
"min": 0,
"description": "min level to scale the flow lineair"
}
},
"maxFlowLevel": {
"maxLevel": {
"default": 4,
"rules": {
"type": "number",
"min": 0,
"description": "max level to scale the flow lineair"
"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."
}
}
},
@@ -638,19 +740,18 @@
"description": "Volume percentage below which dry run protection activates."
}
},
"dryRunDebounceSeconds": {
"default": 30,
"rules": {
"type": "number",
"min": 0,
"description": "Time the low-volume condition must persist before dry-run protection engages (seconds)."
}
},
"enableOverfillProtection": {
"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": {
@@ -659,15 +760,16 @@
"type": "number",
"min": 0,
"max": 100,
"description": "Volume percentage above which overfill protection activates."
"description": "Deprecated alias for highVolumeSafetyThresholdPercent."
}
},
"overfillDebounceSeconds": {
"default": 30,
"highVolumeSafetyThresholdPercent": {
"default": 98,
"rules": {
"type": "number",
"min": 0,
"description": "Time the high-volume condition must persist before overfill protection engages (seconds)."
"max": 100,
"description": "Percentage of maxVolAtOverflow where high-volume safety activates before actual overflow."
}
},
"timeleftToFullOrEmptyThresholdSeconds": {

View File

@@ -91,7 +91,55 @@
],
"description": "Defines the position of the measurement relative to its parent equipment or system."
}
},
"distance": {
"default": null,
"rules": {
"type": "number",
"nullable": true,
"description": "Optional spatial offset from the parent equipment reference. Populated from the editor when hasDistance is enabled; null otherwise."
}
},
"distanceUnit": {
"default": "m",
"rules": {
"type": "string",
"description": "Unit for the functionality.distance offset (e.g. 'm', 'cm')."
}
},
"distanceDescription": {
"default": "",
"rules": {
"type": "string",
"description": "Free-text description of what the distance offset represents (e.g. 'cable length from control panel to motor')."
}
}
},
"output": {
"process": {
"default": "process",
"rules": {
"type": "enum",
"values": [
{ "value": "process", "description": "Delta-compressed process message (default)." },
{ "value": "json", "description": "Raw 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 line-protocol payload (default)." },
{ "value": "json", "description": "Raw JSON payload." },
{ "value": "csv", "description": "CSV-formatted payload." }
],
"description": "Format of the telemetry payload emitted on output port 1."
}
}
},
"asset": {
"uuid": {
@@ -234,43 +282,9 @@
},
"machineCurve": {
"default": {
"nq": {
"1": {
"x": [
1,
2,
3,
4,
5
],
"y": [
10,
20,
30,
40,
50
]
}
},
"np": {
"1": {
"x": [
1,
2,
3,
4,
5
],
"y": [
10,
20,
30,
40,
50
]
}
}
},
"nq": {},
"np": {}
},
"rules": {
"type": "machineCurve",
"description": "All machine curves must have a 'nq' and 'np' curve. nq stands for the flow curve, np stands for the power curve. Together they form the efficiency curve."

View File

@@ -1,3 +1,17 @@
// Map a child's raw softwareType (the lowercased node name from
// buildConfig) to the "role" key that parent registerChild() handlers
// dispatch on. Without this, MGC/pumpingStation register-handlers (which
// branch on 'machine' / 'machinegroup' / 'pumpingstation' / 'measurement')
// silently miss every real production child because rotatingMachine
// reports softwareType='rotatingmachine' and machineGroupControl reports
// 'machinegroupcontrol'. Existing tests that pass already-aliased keys
// ('machine', 'machinegroup') stay green because those aren't in the
// alias map and pass through unchanged.
const SOFTWARE_TYPE_ALIASES = {
rotatingmachine: 'machine',
machinegroupcontrol: 'machinegroup',
};
class ChildRegistrationUtils {
constructor(mainClass) {
this.mainClass = mainClass;
@@ -15,7 +29,8 @@ class ChildRegistrationUtils {
return false;
}
const softwareType = (child.config.functionality.softwareType || '').toLowerCase();
const rawSoftwareType = (child.config.functionality.softwareType || '').toLowerCase();
const softwareType = SOFTWARE_TYPE_ALIASES[rawSoftwareType] || rawSoftwareType;
const name = child.config.general.name || child.config.general.id || 'unknown';
const id = child.config.general.id || name;

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;
@@ -141,11 +186,17 @@ class MeasurementContainer {
}
isUnitCompatible(measurementType, unit) {
const desc = this._describeUnit(unit);
if (!desc) return false;
// Unknown type (not in measureMap): accept any unit. This lets user-
// defined measurement types (e.g. 'humidity', 'co2', arbitrary IoT
// channels in digital mode) pass through without being rejected just
// because their unit string ('%', 'ppm', …) is not a known physical
// unit to the convert module. Known types are still validated strictly.
const normalizedType = this._normalizeType(measurementType);
const expectedMeasure = this.measureMap[normalizedType];
if (!expectedMeasure) return true;
const desc = this._describeUnit(unit);
if (!desc) return false;
return desc.measure === expectedMeasure;
}
@@ -374,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;
@@ -529,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

@@ -193,8 +193,13 @@ class AssetMenu {
return normalizeApiCategory(key, node.softwareType || key, payload.data);
}
// Non-dispatching populate (matches the wireEvents version). The
// load path below explicitly walks supplier -> type -> model ->
// unit in order using saved node.* values, so auto-dispatched
// change events (which previously cascaded through wireEvents'
// listeners and double-populated everything) are no longer needed.
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
const previous = selectEl.value;
if (!selectEl) return;
const mapper = typeof mapFn === 'function'
? mapFn
: (value) => ({ value, label: value });
@@ -227,9 +232,6 @@ class AssetMenu {
} else {
selectEl.value = '';
}
if (selectEl.value !== previous) {
selectEl.dispatchEvent(new Event('change'));
}
}
const categoryKey = resolveCategoryKey();
@@ -305,6 +307,28 @@ class AssetMenu {
getEventInjectionCode(nodeName) {
return `
// Asset event wiring for ${nodeName}
//
// The supplier -> type -> model -> unit chain is a strict downward
// cascade: each select rebuilds the next based on the currently
// selected value above it. Two earlier bugs in this code:
//
// 1. populate() auto-dispatched a synthetic 'change' event whenever
// the value of the rebuilt select differed from before the
// rebuild. That triggered the *child* select's listener mid-way
// through the *parent* listener, which then continued and
// blindly overwrote the child select with empty content. Net
// effect: model dropdown showed 'Awaiting Type Selection' even
// though a type was clearly selected.
//
// 2. Each downstream wipe ran unconditionally inside the parent
// handler, instead of being driven by the actual current value
// of the child select.
//
// Fix: populate() no longer dispatches change. Cascade is explicit
// via cascadeFromSupplier() / cascadeFromType() / cascadeFromModel()
// which are called from each handler. The same helpers run on
// initial load so behaviour is identical whether the user picked the
// value or it came from a saved node.
window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) {
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
const categories = menuAsset.categories || {};
@@ -316,11 +340,17 @@ class AssetMenu {
unit: document.getElementById('node-input-unit')
};
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
const previous = selectEl.value;
// populate(): rebuild a <select> with a placeholder + items.
// No change-event dispatch — cascading is done explicitly by the
// caller via cascadeFrom*() so the order of operations is
// predictable.
function populate(selectEl, items, selectedValue, mapFn, placeholderText) {
if (!selectEl) return;
if (!Array.isArray(items)) items = [];
if (!placeholderText) placeholderText = 'Select...';
const mapper = typeof mapFn === 'function'
? mapFn
: (value) => ({ value, label: value });
: (value) => ({ value: value, label: value });
selectEl.innerHTML = '';
@@ -331,11 +361,9 @@ class AssetMenu {
placeholder.selected = true;
selectEl.appendChild(placeholder);
items.forEach((item) => {
items.forEach(function (item) {
const option = mapper(item);
if (!option || typeof option.value === 'undefined') {
return;
}
if (!option || typeof option.value === 'undefined') return;
const opt = document.createElement('option');
opt.value = option.value;
opt.textContent = option.label;
@@ -344,111 +372,112 @@ class AssetMenu {
if (selectedValue) {
selectEl.value = selectedValue;
if (!selectEl.value) {
selectEl.value = '';
}
if (!selectEl.value) selectEl.value = '';
} else {
selectEl.value = '';
}
if (selectEl.value !== previous) {
selectEl.dispatchEvent(new Event('change'));
}
}
const resolveCategoryKey = () => {
if (node.softwareType && categories[node.softwareType]) {
return node.softwareType;
}
if (node.category && categories[node.category]) {
return node.category;
}
function resolveCategoryKey() {
if (node.softwareType && categories[node.softwareType]) return node.softwareType;
if (node.category && categories[node.category]) return node.category;
return defaultCategory;
};
const getActiveCategory = () => {
}
function getActiveCategory() {
const key = resolveCategoryKey();
return key ? categories[key] : null;
};
}
node.category = resolveCategoryKey();
elems.supplier.addEventListener('change', () => {
const category = getActiveCategory();
const supplier = category
? category.suppliers.find(
(item) => String(item.id || item.name) === String(elems.supplier.value)
)
: null;
// Lookup helpers — read from the *currently selected* values in the
// DOM, not from node.* (which may not yet be in sync).
function findSupplier() {
const cat = getActiveCategory();
if (!cat || !Array.isArray(cat.suppliers)) return null;
const id = String(elems.supplier.value);
return cat.suppliers.find(function (s) {
return String(s.id || s.name) === id;
}) || null;
}
function findType(supplier) {
if (!supplier || !Array.isArray(supplier.types)) return null;
const id = String(elems.type.value);
return supplier.types.find(function (t) {
return String(t.id || t.name) === id;
}) || null;
}
function findModel(type) {
if (!type || !Array.isArray(type.models)) return null;
const id = String(elems.model.value);
return type.models.find(function (m) {
return String(m.id || m.name) === id;
}) || null;
}
// === Cascade rebuild functions ==========================
// Each one rebuilds the dropdown for the *level it owns* plus all
// levels below it, using the current values in the DOM. Called by
// the corresponding change handler AND by initial load so both
// paths produce identical state.
function cascadeFromSupplier() {
const supplier = findSupplier();
const types = supplier ? supplier.types : [];
populate(
elems.type,
types,
node.assetType,
(type) => ({ value: type.id || type.name, label: type.name }),
function (t) { return { value: t.id || t.name, label: t.name }; },
supplier ? 'Select...' : 'Awaiting Supplier Selection'
);
node.modelMetadata = null;
populate(elems.model, [], '', undefined, 'Awaiting Type Selection');
populate(elems.unit, [], '', undefined, 'Awaiting Type Selection');
});
// After repopulating type, propagate down. cascadeFromType()
// will read the new elems.type.value (which was set by populate
// to either the saved node.assetType or '') and rebuild model.
cascadeFromType();
}
elems.type.addEventListener('change', () => {
const category = getActiveCategory();
const supplier = category
? category.suppliers.find(
(item) => String(item.id || item.name) === String(elems.supplier.value)
)
: null;
const type = supplier
? supplier.types.find(
(item) => String(item.id || item.name) === String(elems.type.value)
)
: null;
function cascadeFromType() {
const supplier = findSupplier();
const type = findType(supplier);
const models = type ? type.models : [];
populate(
elems.model,
models,
node.model,
(model) => ({ value: model.id || model.name, label: model.name }),
function (m) { return { value: m.id || m.name, label: m.name }; },
type ? 'Select...' : 'Awaiting Type Selection'
);
node.modelMetadata = null;
populate(
elems.unit,
[],
'',
undefined,
type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
);
});
cascadeFromModel();
}
elems.model.addEventListener('change', () => {
const category = getActiveCategory();
const supplier = category
? category.suppliers.find(
(item) => String(item.id || item.name) === String(elems.supplier.value)
)
: null;
const type = supplier
? supplier.types.find(
(item) => String(item.id || item.name) === String(elems.type.value)
)
: null;
const model = type
? type.models.find(
(item) => String(item.id || item.name) === String(elems.model.value)
)
: null;
function cascadeFromModel() {
const supplier = findSupplier();
const type = findType(supplier);
const model = findModel(type);
node.modelMetadata = model;
node.modelName = model ? model.name : '';
populate(
elems.unit,
model ? model.units || [] : [],
model ? (model.units || []) : [],
node.unit,
(unit) => ({ value: unit, label: unit }),
model ? 'Select...' : type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
function (u) { return { value: u, label: u }; },
model ? 'Select...' : (type ? 'Awaiting Model Selection' : 'Awaiting Type Selection')
);
});
}
elems.supplier.addEventListener('change', cascadeFromSupplier);
elems.type.addEventListener('change', cascadeFromType);
elems.model.addEventListener('change', cascadeFromModel);
// Expose the cascades so loadData() (or future code) can re-run
// them after async data arrives without duplicating logic.
window.EVOLV.nodes.${nodeName}.assetMenu._cascade = {
fromSupplier: cascadeFromSupplier,
fromType: cascadeFromType,
fromModel: cascadeFromModel,
};
};
`;
}

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");
@@ -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) {

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;
@@ -85,15 +111,46 @@ class state{
this.emitter.emit("movementComplete", { position: targetPosition });
await this.transitionToState("operational");
} catch (error) {
this.logger.error(error);
// Abort path: only return to 'operational' when explicitly requested
// (shutdown/emergency-stop needs it to unblock the FSM). Routine MGC
// demand-update aborts must NOT auto-transition — doing so causes a
// bounce loop where every tick aborts → operational → new move →
// abort → operational → ... and the pump never reaches its setpoint.
const msg = typeof error === 'string' ? error : error?.message;
if (msg === 'Transition aborted' || msg === 'Movement aborted') {
if (this._returnToOperationalOnAbort) {
this.logger.debug(`Movement aborted; returning to 'operational' (requested by caller).`);
try {
await this.transitionToState("operational");
} catch (e) {
this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`);
}
} else {
this.logger.debug(`Movement aborted; staying in current state (routine abort).`);
}
this._returnToOperationalOnAbort = false;
this.emitter.emit("movementAborted", { position: targetPosition });
} else {
this.logger.error(error);
}
}
}
// -------- State Transition Methods -------- //
abortCurrentMovement(reason = "group override") {
/**
* @param {string} reason - human-readable abort reason
* @param {object} [options]
* @param {boolean} [options.returnToOperational=false] - when true the FSM
* transitions back to 'operational' after the abort so a subsequent
* shutdown/emergency-stop sequence can proceed. Set to false (default)
* for routine demand updates where the caller will send a new movement
* immediately — auto-transitioning would cause a bounce loop.
*/
abortCurrentMovement(reason = "group override", options = {}) {
if (this.abortController && !this.abortController.signal.aborted) {
this.logger.warn(`Aborting movement: ${reason}`);
this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
this.abortController.abort();
}
}