Compare commits
14 Commits
fix/emerge
...
84126e9130
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84126e9130 | ||
|
|
9e8463b41d | ||
|
|
e058fe9245 | ||
|
|
c5bb375dd0 | ||
|
|
8f9150e160 | ||
|
|
5a8113a9d1 | ||
|
|
ecd5a4864b | ||
|
|
399e0a8c01 | ||
|
|
11d196f363 | ||
|
|
510a4233e6 | ||
|
|
26e253d030 | ||
|
|
c464b66b27 | ||
|
|
17b88870bb | ||
|
|
07af7cef40 |
23
CLAUDE.md
Normal file
23
CLAUDE.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# rotatingMachine — Claude Code context
|
||||
|
||||
Individual pump / compressor / blower control.
|
||||
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
|
||||
|
||||
## S88 classification
|
||||
|
||||
| Level | Colour | Placement lane |
|
||||
|---|---|---|
|
||||
| **Equipment Module** | `#86bbdd` | L3 |
|
||||
|
||||
## Flow layout rules
|
||||
|
||||
When wiring this node into a multi-node demo or production flow, follow the
|
||||
placement rule set in the **EVOLV superproject**:
|
||||
|
||||
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
|
||||
|
||||
Key points for this node:
|
||||
- Place on lane **L3** (x-position per the lane table in the rule).
|
||||
- Stack same-level siblings vertically.
|
||||
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
||||
- Wrap in a Node-RED group box coloured `#86bbdd` (Equipment Module).
|
||||
94
CONTRACT.md
Normal file
94
CONTRACT.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# rotatingMachine — Contract
|
||||
|
||||
Hand-maintained for Phase 5; the `## Inputs` table is generated from
|
||||
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 100 lines.
|
||||
|
||||
## Inputs (msg.topic on Port 0)
|
||||
|
||||
| Canonical | Aliases (deprecated) | Payload | Effect |
|
||||
|---|---|---|---|
|
||||
| `set.mode` | `setMode` | `string` — one of the allowed mode names | Calls `source.setMode(payload)`. |
|
||||
| `cmd.startup` | — | `{ source?: string }` | Calls `source.handleInput(payload.source ?? 'parent', 'execSequence', 'startup')`. |
|
||||
| `cmd.shutdown` | — | `{ source?: string }` | Calls `source.handleInput(payload.source ?? 'parent', 'execSequence', 'shutdown')`. |
|
||||
| `cmd.estop` | `emergencystop` | `{ source?: string, action?: string }` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'emergencystop')`. |
|
||||
| `execSequence` | — (legacy umbrella) | `{ source, action, parameter }` with `action ∈ {'startup','shutdown'}` | Content-based router: forwards to `cmd.startup` / `cmd.shutdown` handler based on `payload.action`. Unknown action logs `warn` and is dropped. Whole topic is legacy — prefer the canonical `cmd.*` topics. |
|
||||
| `set.setpoint` | `execMovement` | `{ source, action, setpoint }` — setpoint coerced to `Number` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'execMovement', Number(payload.setpoint))`. |
|
||||
| `set.flow-setpoint` | `flowMovement` | `{ source, action, setpoint }` | Calls `source.handleInput(payload.source ?? 'parent', payload.action ?? 'flowMovement', Number(payload.setpoint))`. |
|
||||
| `data.simulate-measurement` | `simulateMeasurement` | `{ type, position?, value, unit, timestamp? }` — `type ∈ {pressure, flow, temperature, power}`; `position` defaults to `'atEquipment'` | Validated dispatch: rejects non-finite `value`, unsupported `type`, missing `unit`, or unit that fails `isUnitValidForType`. Pressure routes via `updateSimulatedMeasurement(type, position, value, ctx)`; flow/temperature/power route via `updateMeasured<Type>(value, position, ctx)`. The injected `childId/childName = 'dashboard-sim'` marks the source. |
|
||||
| `query.curves` | `showWorkingCurves` | none | Calls `source.showWorkingCurves()` and replies on **Port 0** with `{ topic: 'showWorkingCurves', payload: <result> }` via `ctx.send`. |
|
||||
| `query.cog` | `CoG` | none | Calls `source.showCoG()` and replies on **Port 0** with `{ topic: 'showCoG', payload: <result> }`. |
|
||||
| `child.register` | `registerChild` | `string` — child Node-RED id; `msg.positionVsParent` carries position | Resolves child via `RED.nodes.getNode(payload)` and registers it through `childRegistrationUtils.registerChild(child.source, msg.positionVsParent)`. Unknown ids log `warn`. |
|
||||
|
||||
Aliases log a one-time deprecation warning the first time they fire.
|
||||
|
||||
### `execSequence` demux
|
||||
|
||||
The pre-refactor topic `execSequence` carried `{ source, action, parameter }`
|
||||
where `action` selected the verb (`startup` or `shutdown`). The command
|
||||
registry does not natively dispatch by payload content, so `execSequence`
|
||||
keeps its own descriptor whose handler **forwards directly** to the
|
||||
canonical `cmd.startup` / `cmd.shutdown` handler based on
|
||||
`payload.action`. The deprecation warning fires once. Future-Phase-7
|
||||
removal of `execSequence` is a behavioural change — callers must migrate
|
||||
to `cmd.startup` / `cmd.shutdown`.
|
||||
|
||||
## Outputs (msg.topic on Port 0/1/2)
|
||||
|
||||
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by
|
||||
`outputUtils.formatMsg(..., 'process')` from `getOutput()` — delta-compressed
|
||||
(only changed fields are emitted). On `query.curves` / `query.cog` the
|
||||
node additionally emits `{ topic: 'showWorkingCurves' | 'showCoG',
|
||||
payload: <result> }` as a synchronous reply on Port 0.
|
||||
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
|
||||
`'influxdb'` formatter.
|
||||
- **Port 2 (registration):** at startup the node sends one
|
||||
`{ topic: 'registerChild', payload: <node.id>, positionVsParent }` to
|
||||
the upstream parent (typically a `machineGroupControl` or
|
||||
`pumpingStation`). `positionVsParent` defaults to `'atEquipment'`.
|
||||
|
||||
## Events emitted by `source.measurements.emitter`
|
||||
|
||||
The `MeasurementContainer` fires `<type>.<variant>.<position>` whenever
|
||||
the corresponding series receives a new value. Parents subscribe via the
|
||||
generic `child.measurements.emitter.on(eventName, ...)` handshake.
|
||||
rotatingMachine publishes:
|
||||
|
||||
- `flow.predicted.atequipment`, `flow.predicted.downstream`,
|
||||
`flow.predicted.max`, `flow.predicted.min` — predicted operating point.
|
||||
- `power.predicted.atequipment` — predicted shaft power.
|
||||
- `temperature.measured.atequipment` — ambient/process temperature.
|
||||
- `atmPressure.measured.atequipment` — barometric reference.
|
||||
- `pressure.measured.upstream`, `pressure.measured.downstream`,
|
||||
`pressure.measured.differential` — when pressure children register or
|
||||
`data.simulate-measurement type=pressure` runs.
|
||||
- `flow.measured.<position>`, `power.measured.atequipment`,
|
||||
`temperature.measured.<position>` — when sensor children register or
|
||||
the `data.simulate-measurement` topic supplies values.
|
||||
|
||||
Position labels are normalised to lowercase in the event name. The exact
|
||||
set is data-driven by which children register and what they publish.
|
||||
|
||||
## Events emitted by `source.state.emitter`
|
||||
|
||||
- `positionChange` — fires when the position percentage changes (per
|
||||
movement tick). Data: `{ position, state, mode, timestamp }`.
|
||||
- `stateChange` — fires on transitions of the operating state machine
|
||||
(`idle → starting → warmingup → operational → accelerating →
|
||||
decelerating → stopping → coolingdown → idle`, plus `off`,
|
||||
`maintenance`). Data: the new state string.
|
||||
|
||||
## Children registered by this node
|
||||
|
||||
rotatingMachine accepts `measurement` children through the
|
||||
`childRegistrationUtils` handshake. Children typically have
|
||||
`asset.type ∈ {pressure, flow, power, temperature}`. The machine
|
||||
subscribes to the matching `<asset.type>.measured.<positionVsParent>`
|
||||
event and mirrors the value into its own `MeasurementContainer`.
|
||||
|
||||
Two **virtual** children are reserved by the `data.simulate-measurement`
|
||||
topic: incoming simulated values are tagged with
|
||||
`childId/childName = 'dashboard-sim'` so dashboard-driven inputs are
|
||||
distinguishable from real sensor children in downstream telemetry.
|
||||
|
||||
Position labels accepted from children are `upstream`, `downstream`,
|
||||
`atEquipment` (and case variants — normalised internally).
|
||||
117
README.md
117
README.md
@@ -1 +1,116 @@
|
||||
# rotating machine
|
||||
# rotatingMachine
|
||||
|
||||
Node-RED custom node for individual rotating-machine control — pumps, compressors, blowers. Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform developed by R&D at Waterschap Brabantse Delta.
|
||||
|
||||
Models a single asset with an S88 state machine, curve-backed flow/power prediction, and parent/child registration for orchestration by `machineGroupControl` or `pumpingStation`.
|
||||
|
||||
## Install
|
||||
|
||||
In a Node-RED user directory:
|
||||
|
||||
```bash
|
||||
cd ~/.node-red
|
||||
npm install github:gitea.wbd-rd.nl/RnD/rotatingMachine
|
||||
```
|
||||
|
||||
Or consume the whole platform:
|
||||
|
||||
```bash
|
||||
npm install github:gitea.wbd-rd.nl/RnD/EVOLV
|
||||
```
|
||||
|
||||
Run `node-red` and the node appears in the editor palette under the **EVOLV** category.
|
||||
|
||||
## Quick start
|
||||
|
||||
Drop a `rotatingMachine` onto a flow, fill the Asset menu (supplier, model — must match a curve in `generalFunctions/datasets`), and wire three debug nodes to the three output ports. Inject these in order:
|
||||
|
||||
| Topic | Payload | Effect |
|
||||
|---|---|---|
|
||||
| `setMode` | `"virtualControl"` | allow manual commands |
|
||||
| `simulateMeasurement` | `{type:"pressure",position:"upstream",value:200,unit:"mbar"}` | seed upstream pressure |
|
||||
| `simulateMeasurement` | `{type:"pressure",position:"downstream",value:1100,unit:"mbar"}` | seed downstream pressure |
|
||||
| `execSequence` | `{source:"GUI",action:"execSequence",parameter:"startup"}` | start the machine |
|
||||
| `execMovement` | `{source:"GUI",action:"execMovement",setpoint:60}` | ramp to 60 % controller position |
|
||||
| `execSequence` | `{source:"GUI",action:"execSequence",parameter:"shutdown"}` | shut down |
|
||||
|
||||
Ready-made example flows are in `examples/`:
|
||||
|
||||
- `01 - Basic Manual Control.json` — inject-only smoke test
|
||||
- `02 - Integration with Machine Group.json` — parent/child registration with `machineGroupControl`
|
||||
- `03 - Dashboard Visualization.json` — FlowFuse dashboard with live charts
|
||||
|
||||
Import via Node-RED **Import ▸ Examples ▸ EVOLV**.
|
||||
|
||||
## Input topics
|
||||
|
||||
| Topic | Payload | Notes |
|
||||
|---|---|---|
|
||||
| `setMode` | `"auto"` \| `"virtualControl"` \| `"fysicalControl"` | mode gates which sources may command the machine |
|
||||
| `execSequence` | `{source, action:"execSequence", parameter}` — parameter: `"startup"` \| `"shutdown"` \| `"entermaintenance"` \| `"exitmaintenance"` | runs an S88 sequence |
|
||||
| `execMovement` | `{source, action:"execMovement", setpoint}` — setpoint in controller % | moves controller position |
|
||||
| `flowMovement` | `{source, action:"flowMovement", setpoint}` — setpoint in configured flow unit | converts flow → controller %, then moves |
|
||||
| `emergencystop` | `{source, action:"emergencystop"}` | aborts any active movement and drives state to `off` |
|
||||
| `simulateMeasurement` | `{type, position, value, unit}` — type: `pressure` \| `flow` \| `temperature` \| `power` | dashboard-side measurement injection |
|
||||
| `showWorkingCurves` | — | diagnostic — reply on port 0 |
|
||||
| `CoG` | — | diagnostic — reply on port 0 |
|
||||
|
||||
Topic case is preserved; sequence parameter and action names are normalized to lowercase internally (so `"emergencyStop"`, `"EmergencyStop"`, `"emergencystop"` all work).
|
||||
|
||||
## Output ports
|
||||
|
||||
| Port | Label | Payload |
|
||||
|---|---|---|
|
||||
| 0 | `process` | delta-compressed process payload; keys are `type.variant.position.childId` (e.g. `flow.predicted.downstream.default`). Consumers must cache and merge each tick. |
|
||||
| 1 | `dbase` | InfluxDB line-protocol telemetry |
|
||||
| 2 | `parent` | `{topic:"registerChild", payload:<nodeId>, positionVsParent}` emitted once on deploy for parent group/station registration |
|
||||
|
||||
## State machine
|
||||
|
||||
```
|
||||
idle ─► starting ─► warmingup ─► operational ◄─┐
|
||||
▲ │
|
||||
│ ▼
|
||||
│ accelerating / decelerating
|
||||
│ │
|
||||
└──────────┘
|
||||
│
|
||||
▼
|
||||
stopping ─► coolingdown ─► idle
|
||||
│
|
||||
▼
|
||||
emergencystop ─► off
|
||||
```
|
||||
|
||||
- `warmingup` and `coolingdown` are **protected** — new commands cannot abort them.
|
||||
- `accelerating` and `decelerating` **are** interruptible. If a `shutdown` or `emergencystop` sequence is requested mid-ramp, the active movement is aborted automatically and the sequence proceeds once the FSM has returned to `operational`.
|
||||
- Timings come from the `Startup` / `Warmup` / `Shutdown` / `Cooldown` fields in the editor (seconds).
|
||||
|
||||
## Predictions
|
||||
|
||||
Flow and power outputs are curve-backed predictions driven by the controller position and the differential pressure across the machine. Inject both upstream and downstream pressures for best accuracy. With only one side present the node warns and falls back to the available side. With no pressure, predictions use the minimum pressure dimension (flow/power will look unrealistic).
|
||||
|
||||
The active curve is selected from `machineCurve.nq` and `machineCurve.np`, keyed by the closest matching pressure level. Curve units are declared in the Asset menu (default: `mbar`, `m³/h`, `kW`, `%`).
|
||||
|
||||
## Units
|
||||
|
||||
Canonical units are used internally (Pa / m³/s / W / K). All inputs and outputs convert at the boundary via the configured unit for each measurement type. The `speed` field in the editor is a ramp rate in controller-position units per second (so `speed: 1` → 1 %/s → a setpoint of 60 % from idle completes in ~60 s).
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cd nodes/rotatingMachine
|
||||
npm test
|
||||
```
|
||||
|
||||
79 tests cover construction, mode/input routing, config loading, sequences, emergency stop, shutdown, interruptible movement, movement lifecycle, prediction health, pressure initialization, CoolProp efficiency, registration, negative/null guards, output format, listener cleanup. Run the full suite in ~2 seconds.
|
||||
|
||||
For end-to-end verification, see `../../docker-compose.yml` — a Docker stack (Node-RED + InfluxDB + Grafana) that hosts the live node. The scripts in `../../../memory/` and `examples/` document the E2E protocol used for production-readiness benchmarks.
|
||||
|
||||
## Production status
|
||||
|
||||
Last reviewed **2026-04-13** — trial-ready. See the project memory file `node_rotatingMachine.md` for the latest benchmarks, known caveats, and wishlist.
|
||||
|
||||
## License
|
||||
|
||||
SEE LICENSE. Author: Rene De Ren, Waterschap Brabantse Delta R&D.
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"description": "Control module rotatingMachine",
|
||||
"main": "rotatingMachine.js",
|
||||
"scripts": {
|
||||
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js"
|
||||
"test": "node --test test/basic/*.test.js test/integration/*.test.js test/edge/*.test.js",
|
||||
"wiki:contract": "node ../generalFunctions/scripts/wikiGen.js contract ./src/commands/index.js --write ./wiki/Home.md",
|
||||
"wiki:datamodel": "node ../generalFunctions/scripts/wikiGen.js datamodel ./src/specificClass.js --write ./wiki/Home.md",
|
||||
"wiki:all": "npm run wiki:contract && npm run wiki:datamodel"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
category: "EVOLV",
|
||||
color: "#86bbdd",
|
||||
defaults: {
|
||||
name: { value: "" },
|
||||
|
||||
// Define specific properties
|
||||
speed: { value: 1, required: true },
|
||||
@@ -67,11 +68,15 @@
|
||||
|
||||
oneditprepare: function() {
|
||||
// wait for the menu scripts to load
|
||||
let menuRetries = 0;
|
||||
const maxMenuRetries = 100; // 5 seconds at 50ms intervals
|
||||
const waitForMenuData = () => {
|
||||
if (window.EVOLV?.nodes?.rotatingMachine?.initEditor) {
|
||||
window.EVOLV.nodes.rotatingMachine.initEditor(this);
|
||||
} else {
|
||||
} else if (++menuRetries < maxMenuRetries) {
|
||||
setTimeout(waitForMenuData, 50);
|
||||
} else {
|
||||
console.warn("rotatingMachine: menu scripts failed to load within 5 seconds");
|
||||
}
|
||||
};
|
||||
waitForMenuData();
|
||||
@@ -124,23 +129,28 @@
|
||||
<!-- Machine-specific controls -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-speed"><i class="fa fa-clock-o"></i> Reaction Speed</label>
|
||||
<input type="number" id="node-input-speed" style="width:60%;" />
|
||||
<input type="number" id="node-input-speed" style="width:60%;" placeholder="position units / second" />
|
||||
<div style="font-size:11px;color:#666;margin-left:160px;">Ramp rate of the controller position in units per second (0–100% controller range; e.g. 1 = 1%/s).</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-startup"><i class="fa fa-clock-o"></i> Startup Time</label>
|
||||
<input type="number" id="node-input-startup" style="width:60%;" />
|
||||
<input type="number" id="node-input-startup" style="width:60%;" placeholder="seconds" />
|
||||
<div style="font-size:11px;color:#666;margin-left:160px;">Seconds spent in the <code>starting</code> state before moving to <code>warmingup</code>.</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-warmup"><i class="fa fa-clock-o"></i> Warmup Time</label>
|
||||
<input type="number" id="node-input-warmup" style="width:60%;" />
|
||||
<input type="number" id="node-input-warmup" style="width:60%;" placeholder="seconds" />
|
||||
<div style="font-size:11px;color:#666;margin-left:160px;">Seconds spent in the protected <code>warmingup</code> state before reaching <code>operational</code>.</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-shutdown"><i class="fa fa-clock-o"></i> Shutdown Time</label>
|
||||
<input type="number" id="node-input-shutdown" style="width:60%;" />
|
||||
<input type="number" id="node-input-shutdown" style="width:60%;" placeholder="seconds" />
|
||||
<div style="font-size:11px;color:#666;margin-left:160px;">Seconds spent in the <code>stopping</code> state before moving to <code>coolingdown</code>.</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-cooldown"><i class="fa fa-clock-o"></i> Cooldown Time</label>
|
||||
<input type="number" id="node-input-cooldown" style="width:60%;" />
|
||||
<input type="number" id="node-input-cooldown" style="width:60%;" placeholder="seconds" />
|
||||
<div style="font-size:11px;color:#666;margin-left:160px;">Seconds spent in the protected <code>coolingdown</code> state before returning to <code>idle</code>.</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-movementMode"><i class="fa fa-exchange"></i> Movement Mode</label>
|
||||
@@ -180,11 +190,40 @@
|
||||
</script>
|
||||
|
||||
<script type="text/html" data-help-name="rotatingMachine">
|
||||
<p><b>Rotating Machine Node</b>: Configure a rotating‐machine asset.</p>
|
||||
<p><b>Rotating Machine</b>: individual pump / compressor / blower control module. Runs a 10-state S88 sequence, predicts flow and power from a supplier curve, and publishes process + telemetry outputs each second.</p>
|
||||
|
||||
<h3>Configuration</h3>
|
||||
<ul>
|
||||
<li><b>Reaction Speed, Startup, Warmup, Shutdown, Cooldown:</b> timing parameters.</li>
|
||||
<li><b>Supplier / SubType / Model / Unit:</b> choose via Asset menu.</li>
|
||||
<li><b>Enable Log / Log Level:</b> toggle via Logger menu.</li>
|
||||
<li><b>Position:</b> set Upstream / At Equipment / Downstream via Position menu.</li>
|
||||
<li><b>Reaction Speed</b>: controller ramp rate (position units / second). E.g. <code>1</code> = 1%/s, so Set 60% from idle reaches 60% in ~60 s.</li>
|
||||
<li><b>Startup / Warmup / Shutdown / Cooldown</b>: seconds per FSM phase. Warmup and Cooldown are <i>protected</i> — they cannot be aborted by a new command.</li>
|
||||
<li><b>Movement Mode</b>: <code>staticspeed</code> = linear ramp; <code>dynspeed</code> = ease-in/out.</li>
|
||||
<li><b>Asset</b> (menu): supplier, category, model (must match a curve in <code>generalFunctions</code>), flow unit (e.g. m³/h), curve units.</li>
|
||||
<li><b>Output Formats</b>: <code>process</code>/<code>json</code>/<code>csv</code> on port 0; <code>influxdb</code>/<code>json</code>/<code>csv</code> on port 1.</li>
|
||||
<li><b>Position</b> (menu): <code>upstream</code> / <code>atEquipment</code> / <code>downstream</code> relative to a parent group/station.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Input topics (<code>msg.topic</code>)</h3>
|
||||
<ul>
|
||||
<li><code>setMode</code> — <code>payload</code> = <code>auto</code> | <code>virtualControl</code> | <code>fysicalControl</code></li>
|
||||
<li><code>execSequence</code> — <code>payload</code> = <code>{source, action:"execSequence", parameter: "startup"|"shutdown"|"entermaintenance"|"exitmaintenance"}</code></li>
|
||||
<li><code>execMovement</code> — <code>payload</code> = <code>{source, action:"execMovement", setpoint: 0..100}</code> (controller %)</li>
|
||||
<li><code>flowMovement</code> — <code>payload</code> = <code>{source, action:"flowMovement", setpoint: <flow in configured unit>}</code></li>
|
||||
<li><code>emergencystop</code> — <code>payload</code> = <code>{source, action:"emergencystop"}</code>. Aborts any active movement.</li>
|
||||
<li><code>simulateMeasurement</code> — <code>payload</code> = <code>{type:"pressure"|"flow"|"temperature"|"power", position, value, unit}</code>. Injects dashboard-side measurement.</li>
|
||||
<li><code>showWorkingCurves</code>, <code>CoG</code> — diagnostics, reply arrives on port 0.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Output ports</h3>
|
||||
<ol>
|
||||
<li><b>process</b> — delta-compressed process payload. Consumers must cache and merge each tick. Keys use 4-segment format <code>type.variant.position.childId</code> (e.g. <code>flow.predicted.downstream.default</code>).</li>
|
||||
<li><b>dbase</b> — InfluxDB telemetry.</li>
|
||||
<li><b>parent</b> — <code>registerChild</code> handshake for a parent <code>machineGroupControl</code> / <code>pumpingStation</code>.</li>
|
||||
</ol>
|
||||
|
||||
<h3>State machine</h3>
|
||||
<p>States: <code>idle → starting → warmingup → operational → (accelerating ⇄ decelerating) → operational → stopping → coolingdown → idle</code>. <code>emergencystop → off</code> is reachable from every active state.</p>
|
||||
<p>If a <code>shutdown</code> or <code>emergencystop</code> sequence is requested while a setpoint move is in flight (<code>accelerating</code> / <code>decelerating</code>), the move is aborted automatically and the sequence proceeds once the FSM returns to <code>operational</code>.</p>
|
||||
|
||||
<h3>Predictions</h3>
|
||||
<p>Flow and power predictions only produce meaningful values once at least one pressure child is reporting (or a <code>simulateMeasurement</code> pressure is injected). Inject BOTH upstream and downstream for best accuracy.</p>
|
||||
</script>
|
||||
|
||||
150
src/commands/handlers.js
Normal file
150
src/commands/handlers.js
Normal file
@@ -0,0 +1,150 @@
|
||||
'use strict';
|
||||
|
||||
// Handler functions for rotatingMachine commands. Each handler receives:
|
||||
// source: the domain (specificClass) instance — exposes setMode, handleInput,
|
||||
// updateMeasured*, updateSimulatedMeasurement, isUnitValidForType,
|
||||
// showWorkingCurves, showCoG, childRegistrationUtils, logger.
|
||||
// msg: the Node-RED input message.
|
||||
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
|
||||
//
|
||||
// Pure functions: validation that goes beyond the registry's typeof-check
|
||||
// ladder lives here. Reply messages (query.*) use ctx.send when available.
|
||||
|
||||
const SUPPORTED_SIM_TYPES = new Set(['pressure', 'flow', 'temperature', 'power']);
|
||||
|
||||
function _logger(source, ctx) {
|
||||
return ctx?.logger || source?.logger || null;
|
||||
}
|
||||
|
||||
function _send(ctx, ports) {
|
||||
if (typeof ctx?.send === 'function') ctx.send(ports);
|
||||
}
|
||||
|
||||
exports.setMode = (source, msg) => {
|
||||
source.setMode(msg.payload);
|
||||
};
|
||||
|
||||
// Canonical execution handlers. The legacy execSequence demuxer below
|
||||
// forwards to these directly so behaviour is identical.
|
||||
exports.startup = async (source, msg) => {
|
||||
const p = msg.payload || {};
|
||||
await source.handleInput(p.source ?? 'parent', 'execSequence', 'startup');
|
||||
};
|
||||
|
||||
exports.shutdown = async (source, msg) => {
|
||||
const p = msg.payload || {};
|
||||
await source.handleInput(p.source ?? 'parent', 'execSequence', 'shutdown');
|
||||
};
|
||||
|
||||
exports.estop = async (source, msg) => {
|
||||
const p = msg.payload || {};
|
||||
// Legacy emergencystop carried { source, action } — action defaults to
|
||||
// 'emergencystop' when only source is supplied via the canonical topic.
|
||||
await source.handleInput(p.source ?? 'parent', p.action ?? 'emergencystop');
|
||||
};
|
||||
|
||||
// Content-based alias router: legacy `execSequence` carried payload.action in
|
||||
// {'startup','shutdown'}. We dispatch back into the canonical handler so the
|
||||
// behaviour and logs are identical regardless of which topic was used.
|
||||
exports.execSequenceAlias = async (source, msg, ctx) => {
|
||||
const log = _logger(source, ctx);
|
||||
const action = msg?.payload?.action;
|
||||
if (action === 'startup') return exports.startup(source, msg, ctx);
|
||||
if (action === 'shutdown') return exports.shutdown(source, msg, ctx);
|
||||
log?.warn?.(`execSequence: unsupported action '${action}'`);
|
||||
};
|
||||
|
||||
exports.setSetpoint = async (source, msg) => {
|
||||
const p = msg.payload || {};
|
||||
const action = p.action ?? 'execMovement';
|
||||
await source.handleInput(p.source ?? 'parent', action, Number(p.setpoint));
|
||||
};
|
||||
|
||||
exports.setFlowSetpoint = async (source, msg) => {
|
||||
const p = msg.payload || {};
|
||||
const action = p.action ?? 'flowMovement';
|
||||
await source.handleInput(p.source ?? 'parent', action, Number(p.setpoint));
|
||||
};
|
||||
|
||||
exports.simulateMeasurement = (source, msg, ctx) => {
|
||||
const log = _logger(source, ctx);
|
||||
const payload = msg.payload || {};
|
||||
const type = String(payload.type || '').toLowerCase();
|
||||
const position = payload.position || 'atEquipment';
|
||||
const value = Number(payload.value);
|
||||
const unit = typeof payload.unit === 'string' ? payload.unit.trim() : '';
|
||||
const context = {
|
||||
timestamp: payload.timestamp || Date.now(),
|
||||
unit,
|
||||
childName: 'dashboard-sim',
|
||||
childId: 'dashboard-sim',
|
||||
};
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
log?.warn?.('simulateMeasurement payload.value must be a finite number');
|
||||
return;
|
||||
}
|
||||
if (!SUPPORTED_SIM_TYPES.has(type)) {
|
||||
log?.warn?.(`Unsupported simulateMeasurement type: ${type}`);
|
||||
return;
|
||||
}
|
||||
if (!unit) {
|
||||
log?.warn?.('simulateMeasurement payload.unit is required');
|
||||
return;
|
||||
}
|
||||
if (typeof source.isUnitValidForType === 'function' &&
|
||||
!source.isUnitValidForType(type, unit)) {
|
||||
log?.warn?.(`simulateMeasurement payload.unit '${unit}' is invalid for type '${type}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
_dispatchSimulated(source, type, position, value, context);
|
||||
};
|
||||
|
||||
function _dispatchSimulated(source, type, position, value, context) {
|
||||
switch (type) {
|
||||
case 'pressure':
|
||||
if (typeof source.updateSimulatedMeasurement === 'function') {
|
||||
source.updateSimulatedMeasurement(type, position, value, context);
|
||||
} else {
|
||||
source.updateMeasuredPressure(value, position, context);
|
||||
}
|
||||
return;
|
||||
case 'flow':
|
||||
source.updateMeasuredFlow(value, position, context);
|
||||
return;
|
||||
case 'temperature':
|
||||
source.updateMeasuredTemperature(value, position, context);
|
||||
return;
|
||||
case 'power':
|
||||
source.updateMeasuredPower(value, position, context);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
exports.queryCurves = (source, msg, ctx) => {
|
||||
const reply = Object.assign({}, msg, {
|
||||
topic: 'showWorkingCurves',
|
||||
payload: source.showWorkingCurves(),
|
||||
});
|
||||
_send(ctx, [reply, null, null]);
|
||||
};
|
||||
|
||||
exports.queryCog = (source, msg, ctx) => {
|
||||
const reply = Object.assign({}, msg, {
|
||||
topic: 'showCoG',
|
||||
payload: source.showCoG(),
|
||||
});
|
||||
_send(ctx, [reply, null, null]);
|
||||
};
|
||||
|
||||
exports.registerChild = (source, msg, ctx) => {
|
||||
const log = _logger(source, ctx);
|
||||
const childId = msg.payload;
|
||||
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
|
||||
if (!childObj || !childObj.source) {
|
||||
log?.warn?.(`registerChild: child '${childId}' not found or has no .source`);
|
||||
return;
|
||||
}
|
||||
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
|
||||
};
|
||||
85
src/commands/index.js
Normal file
85
src/commands/index.js
Normal file
@@ -0,0 +1,85 @@
|
||||
'use strict';
|
||||
|
||||
// rotatingMachine command registry. Consumed by BaseNodeAdapter via
|
||||
// `static commands = require('./commands')`. Each descriptor maps a
|
||||
// canonical msg.topic to its handler; legacy names are listed under
|
||||
// `aliases` and emit a one-time deprecation warning at runtime.
|
||||
//
|
||||
// `execSequence` is special: the legacy payload carried `{source, action,
|
||||
// parameter}` where `action` selected the canonical verb (startup /
|
||||
// shutdown). The registry does not natively dispatch by payload content,
|
||||
// so we keep `execSequence` as its own descriptor whose handler routes to
|
||||
// the canonical `cmd.startup` / `cmd.shutdown` handler. Behaviour matches
|
||||
// the canonical topics exactly; the deprecation warning still fires once.
|
||||
|
||||
const handlers = require('./handlers');
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
topic: 'set.mode',
|
||||
aliases: ['setMode'],
|
||||
payloadSchema: { type: 'string' },
|
||||
handler: handlers.setMode,
|
||||
},
|
||||
{
|
||||
topic: 'cmd.startup',
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.startup,
|
||||
},
|
||||
{
|
||||
topic: 'cmd.shutdown',
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.shutdown,
|
||||
},
|
||||
{
|
||||
topic: 'cmd.estop',
|
||||
aliases: ['emergencystop'],
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.estop,
|
||||
},
|
||||
{
|
||||
// Legacy umbrella topic. Content-based demux inside the handler routes
|
||||
// to the canonical startup / shutdown logic. Emits the registry's
|
||||
// one-time deprecation warning the first time it fires.
|
||||
topic: 'execSequence',
|
||||
payloadSchema: { type: 'object' },
|
||||
handler: handlers.execSequenceAlias,
|
||||
_legacy: true,
|
||||
},
|
||||
{
|
||||
topic: 'set.setpoint',
|
||||
aliases: ['execMovement'],
|
||||
payloadSchema: { type: 'object' },
|
||||
handler: handlers.setSetpoint,
|
||||
},
|
||||
{
|
||||
topic: 'set.flow-setpoint',
|
||||
aliases: ['flowMovement'],
|
||||
payloadSchema: { type: 'object' },
|
||||
handler: handlers.setFlowSetpoint,
|
||||
},
|
||||
{
|
||||
topic: 'data.simulate-measurement',
|
||||
aliases: ['simulateMeasurement'],
|
||||
payloadSchema: { type: 'object' },
|
||||
handler: handlers.simulateMeasurement,
|
||||
},
|
||||
{
|
||||
topic: 'query.curves',
|
||||
aliases: ['showWorkingCurves'],
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.queryCurves,
|
||||
},
|
||||
{
|
||||
topic: 'query.cog',
|
||||
aliases: ['CoG'],
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.queryCog,
|
||||
},
|
||||
{
|
||||
topic: 'child.register',
|
||||
aliases: ['registerChild'],
|
||||
payloadSchema: { type: 'string' },
|
||||
handler: handlers.registerChild,
|
||||
},
|
||||
];
|
||||
19
src/curves/curveLoader.js
Normal file
19
src/curves/curveLoader.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const { loadCurve } = require('generalFunctions');
|
||||
|
||||
/**
|
||||
* Resolve a raw curve dataset by model name. Pure wrapper around
|
||||
* generalFunctions.loadCurve so the constructor doesn't have to encode the
|
||||
* "no model"/"model not found" error states inline.
|
||||
*/
|
||||
function loadModelCurve(model) {
|
||||
if (!model) {
|
||||
return { rawCurve: null, error: 'Model not specified' };
|
||||
}
|
||||
const raw = loadCurve(model);
|
||||
if (!raw) {
|
||||
return { rawCurve: null, error: `Curve not found for model ${model}` };
|
||||
}
|
||||
return { rawCurve: raw, error: null };
|
||||
}
|
||||
|
||||
module.exports = { loadModelCurve };
|
||||
117
src/curves/curveNormalizer.js
Normal file
117
src/curves/curveNormalizer.js
Normal file
@@ -0,0 +1,117 @@
|
||||
const { convert } = require('generalFunctions');
|
||||
|
||||
/**
|
||||
* Strict numeric unit conversion. Mirrors specificClass._convertUnitValue
|
||||
* so the curve normalizer is testable without a Machine instance.
|
||||
*/
|
||||
function convertUnitValue(value, fromUnit, toUnit, contextLabel = 'unit conversion') {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
throw new Error(`${contextLabel}: value '${value}' is not finite`);
|
||||
}
|
||||
if (!fromUnit || !toUnit || fromUnit === toUnit) return numeric;
|
||||
return convert(numeric).from(fromUnit).to(toUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one curve section (nq or np) from supplied units to canonical
|
||||
* units. Logs a warning when the per-pressure median y jumps by more than
|
||||
* 3x relative to the previous pressure level — that almost always means the
|
||||
* curve file is corrupt (mixed units, swapped rows) and the predict module
|
||||
* would otherwise silently produce nonsense values.
|
||||
*/
|
||||
function normalizeCurveSection(section, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName, logger) {
|
||||
const normalized = {};
|
||||
let prevMedianY = null;
|
||||
|
||||
for (const [pressureKey, pair] of Object.entries(section || {})) {
|
||||
const canonicalPressure = convertUnitValue(
|
||||
Number(pressureKey),
|
||||
fromPressureUnit,
|
||||
toPressureUnit,
|
||||
`${sectionName} pressure axis`
|
||||
);
|
||||
const xArray = Array.isArray(pair?.x) ? pair.x.map(Number) : [];
|
||||
const yArray = Array.isArray(pair?.y)
|
||||
? pair.y.map((v) => convertUnitValue(v, fromYUnit, toYUnit, `${sectionName} output`))
|
||||
: [];
|
||||
if (!xArray.length || !yArray.length || xArray.length !== yArray.length) {
|
||||
throw new Error(`Invalid ${sectionName} section at pressure '${pressureKey}'.`);
|
||||
}
|
||||
|
||||
const sortedY = [...yArray].sort((a, b) => a - b);
|
||||
const medianY = sortedY[Math.floor(sortedY.length / 2)];
|
||||
if (prevMedianY != null && prevMedianY > 0) {
|
||||
const ratio = medianY / prevMedianY;
|
||||
if (ratio > 3 || ratio < 0.33) {
|
||||
const msg = `Curve anomaly in ${sectionName} at pressure ${pressureKey}: median y=${medianY.toFixed(2)} ` +
|
||||
`deviates ${ratio.toFixed(1)}x from adjacent level (${prevMedianY.toFixed(2)}). Check curve data.`;
|
||||
if (logger && typeof logger.warn === 'function') {
|
||||
logger.warn(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
prevMedianY = medianY;
|
||||
|
||||
normalized[String(canonicalPressure)] = { x: xArray, y: yArray };
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw machine curve ({nq, np}) into canonical SI units, using
|
||||
* the unit declarations on the supplied UnitPolicy. `unitPolicy.curve` is
|
||||
* the source unit map; `unitPolicy.canonical(type)` gives the target.
|
||||
*/
|
||||
function normalizeMachineCurve(rawCurve, unitPolicy, logger) {
|
||||
if (!rawCurve || typeof rawCurve !== 'object' || !rawCurve.nq || !rawCurve.np) {
|
||||
throw new Error('Machine curve is missing required nq/np sections.');
|
||||
}
|
||||
const curveUnits = readCurveUnits(unitPolicy);
|
||||
const canonicalFlow = readCanonical(unitPolicy, 'flow');
|
||||
const canonicalPower = readCanonical(unitPolicy, 'power');
|
||||
const canonicalPressure = readCanonical(unitPolicy, 'pressure');
|
||||
return {
|
||||
nq: normalizeCurveSection(
|
||||
rawCurve.nq,
|
||||
curveUnits.flow,
|
||||
canonicalFlow,
|
||||
curveUnits.pressure,
|
||||
canonicalPressure,
|
||||
'nq',
|
||||
logger
|
||||
),
|
||||
np: normalizeCurveSection(
|
||||
rawCurve.np,
|
||||
curveUnits.power,
|
||||
canonicalPower,
|
||||
curveUnits.pressure,
|
||||
canonicalPressure,
|
||||
'np',
|
||||
logger
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// UnitPolicy stores curve units as a frozen object on `_curve`, exposed via
|
||||
// `curve(type)`. Accept either the live UnitPolicy or a plain {curve, canonical}
|
||||
// bag so the normalizer can also be driven from raw config fixtures in tests.
|
||||
function readCurveUnits(unitPolicy) {
|
||||
if (!unitPolicy) return {};
|
||||
if (typeof unitPolicy.curve === 'function') {
|
||||
return {
|
||||
flow: unitPolicy.curve('flow'),
|
||||
power: unitPolicy.curve('power'),
|
||||
pressure: unitPolicy.curve('pressure'),
|
||||
};
|
||||
}
|
||||
return unitPolicy.curve || {};
|
||||
}
|
||||
|
||||
function readCanonical(unitPolicy, type) {
|
||||
if (!unitPolicy) return null;
|
||||
if (typeof unitPolicy.canonical === 'function') return unitPolicy.canonical(type);
|
||||
return (unitPolicy.canonical || {})[type] || null;
|
||||
}
|
||||
|
||||
module.exports = { normalizeMachineCurve, normalizeCurveSection, convertUnitValue };
|
||||
17
src/curves/reverseCurve.js
Normal file
17
src/curves/reverseCurve.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Swap x and y of every pressure-keyed section so a forward "ctrl -> flow"
|
||||
* curve becomes a reverse "flow -> ctrl" curve. Used to build predictCtrl
|
||||
* from the same nq data feeding predictFlow.
|
||||
*/
|
||||
function reverseCurve(curveSection) {
|
||||
const reversed = {};
|
||||
for (const [pressure, values] of Object.entries(curveSection || {})) {
|
||||
reversed[pressure] = {
|
||||
x: [...values.y],
|
||||
y: [...values.x],
|
||||
};
|
||||
}
|
||||
return reversed;
|
||||
}
|
||||
|
||||
module.exports = { reverseCurve };
|
||||
61
src/display/workingCurves.js
Normal file
61
src/display/workingCurves.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Read-only snapshots of the active machine curves and the centre-of-gravity
|
||||
* statistics. These back the rotatingMachine admin endpoints used by the
|
||||
* editor (`/rotatingMachine/working-curves`, `/rotatingMachine/cog`).
|
||||
*
|
||||
* Both functions accept a single `predictors` argument — an object describing
|
||||
* the current curve state. By taking everything via that one parameter the
|
||||
* helpers stay pure and trivially testable with a plain fixture; the host
|
||||
* just passes itself (or a slim adapter) in.
|
||||
*
|
||||
* Expected shape of `predictors`:
|
||||
* {
|
||||
* hasCurve: boolean,
|
||||
* predictFlow, predictPower, // generalFunctions/predict instances
|
||||
* getCurrentCurves(): { powerCurve, flowCurve },
|
||||
* calcCog(): { cog, cogIndex, NCog, minEfficiency },
|
||||
* cog, cogIndex, NCog,
|
||||
* minEfficiency,
|
||||
* currentEfficiencyCurve,
|
||||
* absDistFromPeak, relDistFromPeak,
|
||||
* }
|
||||
*/
|
||||
|
||||
const NO_CURVE_ERROR = 'No curve data available';
|
||||
|
||||
function showCoG(predictors) {
|
||||
if (!predictors || !predictors.hasCurve) {
|
||||
return { error: NO_CURVE_ERROR, cog: 0, NCog: 0, cogIndex: 0 };
|
||||
}
|
||||
const { cog, cogIndex, NCog, minEfficiency } = predictors.calcCog();
|
||||
return {
|
||||
cog,
|
||||
cogIndex,
|
||||
NCog,
|
||||
NCogPercent: Math.round(NCog * 100 * 100) / 100,
|
||||
minEfficiency,
|
||||
currentEfficiencyCurve: predictors.currentEfficiencyCurve,
|
||||
absDistFromPeak: predictors.absDistFromPeak,
|
||||
relDistFromPeak: predictors.relDistFromPeak,
|
||||
};
|
||||
}
|
||||
|
||||
function showWorkingCurves(predictors) {
|
||||
if (!predictors || !predictors.hasCurve) {
|
||||
return { error: NO_CURVE_ERROR };
|
||||
}
|
||||
const { powerCurve, flowCurve } = predictors.getCurrentCurves();
|
||||
return {
|
||||
powerCurve,
|
||||
flowCurve,
|
||||
cog: predictors.cog,
|
||||
cogIndex: predictors.cogIndex,
|
||||
NCog: predictors.NCog,
|
||||
minEfficiency: predictors.minEfficiency,
|
||||
currentEfficiencyCurve: predictors.currentEfficiencyCurve,
|
||||
absDistFromPeak: predictors.absDistFromPeak,
|
||||
relDistFromPeak: predictors.relDistFromPeak,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { showWorkingCurves, showCoG };
|
||||
135
src/drift/driftAssessor.js
Normal file
135
src/drift/driftAssessor.js
Normal file
@@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* DriftAssessor — extracted from rotatingMachine specificClass.
|
||||
*
|
||||
* Wraps the generalFunctions errorMetrics into a per-metric drift
|
||||
* pipeline (flow / power). Holds the latest drift objects so
|
||||
* predictionHealth can reuse them; the host node still mirrors them
|
||||
* onto its own fields for output compatibility.
|
||||
*/
|
||||
|
||||
class DriftAssessor {
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* - errorMetrics: assessPoint(metricId, predicted, measured, opts) + assessDrift(...)
|
||||
* - measurements: MeasurementContainer (for assessDrift history pulls)
|
||||
* - driftProfiles: { flow, power, ... }
|
||||
* - resolveProcessRange(metricId, predicted, measured) -> { processMin, processMax }
|
||||
* - measurementPositionForMetric(metricId) -> string
|
||||
* - logger: { warn, debug, ... }
|
||||
*/
|
||||
constructor(ctx = {}) {
|
||||
this.errorMetrics = ctx.errorMetrics;
|
||||
this.measurements = ctx.measurements;
|
||||
this.driftProfiles = ctx.driftProfiles || {};
|
||||
this.resolveProcessRange = ctx.resolveProcessRange;
|
||||
this.measurementPositionForMetric = ctx.measurementPositionForMetric;
|
||||
this.logger = ctx.logger || { warn() {}, debug() {} };
|
||||
this.latest = { flow: null, power: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute drift for a metric given a freshly-arrived measured value.
|
||||
* Returns the drift object (or null on error / non-finite inputs).
|
||||
*/
|
||||
updateMetricDrift(metricId, measuredValue, context = {}) {
|
||||
const position = this._positionForMetric(metricId);
|
||||
const predictedValue = this._getPredicted(metricId, position);
|
||||
const measured = Number(measuredValue);
|
||||
if (!Number.isFinite(predictedValue) || !Number.isFinite(measured)) return null;
|
||||
|
||||
const { processMin, processMax } = this._processRange(metricId, predictedValue, measured);
|
||||
const timestamp = Number(context.timestamp || Date.now());
|
||||
const profile = this.driftProfiles[metricId] || {};
|
||||
|
||||
try {
|
||||
const drift = this.errorMetrics.assessPoint(metricId, predictedValue, measured, {
|
||||
...profile,
|
||||
processMin,
|
||||
processMax,
|
||||
predictedTimestamp: timestamp,
|
||||
measuredTimestamp: timestamp,
|
||||
});
|
||||
if (drift && drift.valid) this.latest[metricId] = drift;
|
||||
return drift;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Drift update failed for metric '${metricId}': ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull stored predicted/measured series and run a full drift assessment.
|
||||
*/
|
||||
assessDrift(measurement, processMin, processMax) {
|
||||
const metricId = String(measurement || '').toLowerCase();
|
||||
const position = this._positionForMetric(metricId);
|
||||
const predicted = this.measurements
|
||||
?.type(metricId).variant('predicted').position(position).getAllValues();
|
||||
const measured = this.measurements
|
||||
?.type(metricId).variant('measured').position(position).getAllValues();
|
||||
if (!predicted?.values || !measured?.values) return null;
|
||||
|
||||
return this.errorMetrics.assessDrift(
|
||||
predicted.values,
|
||||
measured.values,
|
||||
processMin,
|
||||
processMax,
|
||||
{
|
||||
metricId,
|
||||
predictedTimestamps: predicted.timestamps,
|
||||
measuredTimestamps: measured.timestamps,
|
||||
...(this.driftProfiles[metricId] || {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure helper: reduce a confidence figure by drift severity and push
|
||||
* matching flag strings. Returns the updated confidence.
|
||||
*/
|
||||
applyDriftPenalty(drift, confidence, flags, prefix) {
|
||||
if (!drift || !drift.valid || !Number.isFinite(drift.nrmse)) return confidence;
|
||||
if (drift.immediateLevel >= 3) {
|
||||
confidence -= 0.3;
|
||||
flags.push(`${prefix}_high_immediate_drift`);
|
||||
} else if (drift.immediateLevel === 2) {
|
||||
confidence -= 0.2;
|
||||
flags.push(`${prefix}_medium_immediate_drift`);
|
||||
} else if (drift.immediateLevel === 1) {
|
||||
confidence -= 0.1;
|
||||
flags.push(`${prefix}_low_immediate_drift`);
|
||||
}
|
||||
if (drift.longTermLevel >= 2) {
|
||||
confidence -= 0.1;
|
||||
flags.push(`${prefix}_long_term_drift`);
|
||||
}
|
||||
return confidence;
|
||||
}
|
||||
|
||||
_positionForMetric(metricId) {
|
||||
if (typeof this.measurementPositionForMetric === 'function') {
|
||||
return this.measurementPositionForMetric(metricId);
|
||||
}
|
||||
return metricId === 'flow' ? 'downstream' : 'atEquipment';
|
||||
}
|
||||
|
||||
_processRange(metricId, predicted, measured) {
|
||||
if (typeof this.resolveProcessRange === 'function') {
|
||||
return this.resolveProcessRange(metricId, predicted, measured);
|
||||
}
|
||||
const lo = Math.min(predicted, measured);
|
||||
const hi = Math.max(predicted, measured);
|
||||
return { processMin: lo, processMax: hi > lo ? hi : lo + 1 };
|
||||
}
|
||||
|
||||
_getPredicted(metricId, position) {
|
||||
return Number(
|
||||
this.measurements
|
||||
?.type(metricId).variant('predicted').position(position).getCurrentValue(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DriftAssessor;
|
||||
45
src/drift/healthRefresh.js
Normal file
45
src/drift/healthRefresh.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Composes the per-tick pressure-drift status + the PredictionHealth
|
||||
* shape used by the orchestrator. Lives separately from
|
||||
* DriftAssessor/PredictionHealth so the orchestrator only calls one
|
||||
* function per refresh.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const PredictionHealth = require('./predictionHealth');
|
||||
|
||||
function updatePressureDriftStatus(host) {
|
||||
const status = host.getPressureInitializationStatus();
|
||||
const flags = [];
|
||||
let level = 0;
|
||||
if (!status.initialized) { level = 2; flags.push('no_pressure_input'); }
|
||||
else if (!status.hasDifferential) { level = 1; flags.push('single_side_pressure'); }
|
||||
if (status.hasDifferential) {
|
||||
const diff = Number(host._getPreferredPressureValue('downstream')) - Number(host._getPreferredPressureValue('upstream'));
|
||||
if (Number.isFinite(diff) && diff < 0) { level = Math.max(level, 3); flags.push('negative_pressure_differential'); }
|
||||
}
|
||||
host.pressureDrift = { level, source: status.source, flags: flags.length ? flags : ['nominal'] };
|
||||
return host.pressureDrift;
|
||||
}
|
||||
|
||||
function updatePredictionHealth(host) {
|
||||
const pressureDrift = updatePressureDriftStatus(host);
|
||||
const helper = new PredictionHealth({
|
||||
getPressureInitializationStatus: () => host.getPressureInitializationStatus(),
|
||||
isOperational: () => host._isOperationalState(),
|
||||
applyDriftPenalty: (d, c, f, p) => host._applyDriftPenalty(d, c, f, p),
|
||||
resolveSetpointBounds: () => host._resolveSetpointBounds(),
|
||||
getCurrentPosition: () => host.state?.getCurrentPosition?.(),
|
||||
});
|
||||
const { health, confidence } = helper.evaluate({ flow: host.flowDrift, power: host.powerDrift, pressure: pressureDrift });
|
||||
const quality = confidence >= 0.8 ? 'high' : confidence >= 0.55 ? 'medium' : confidence >= 0.3 ? 'low' : 'invalid';
|
||||
host.predictionHealth = {
|
||||
quality, confidence,
|
||||
pressureSource: health.source ?? pressureDrift.source ?? null,
|
||||
flags: Array.isArray(health.flags) && health.flags.length ? [...health.flags] : ['nominal'],
|
||||
};
|
||||
return host.predictionHealth;
|
||||
}
|
||||
|
||||
module.exports = { updatePressureDriftStatus, updatePredictionHealth };
|
||||
132
src/drift/predictionHealth.js
Normal file
132
src/drift/predictionHealth.js
Normal file
@@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
|
||||
const { HealthStatus } = require('generalFunctions');
|
||||
|
||||
/**
|
||||
* PredictionHealth — composes per-metric drift snapshots + pressure
|
||||
* initialization status into a single HealthStatus plus a numeric
|
||||
* confidence figure.
|
||||
*
|
||||
* Per OPEN_QUESTIONS.md 2026-05-10: HealthStatus carries the standard
|
||||
* five fields; `confidence` is returned as a sibling on the result.
|
||||
*/
|
||||
|
||||
class PredictionHealth {
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* - getPressureInitializationStatus() -> { initialized, hasDifferential, source, ... }
|
||||
* - isOperational() -> boolean
|
||||
* - applyDriftPenalty(drift, confidence, flags, prefix) -> confidence (from DriftAssessor)
|
||||
* - resolveSetpointBounds?() -> { min, max }
|
||||
* - getCurrentPosition?() -> number
|
||||
*/
|
||||
constructor(ctx = {}) {
|
||||
this.getPressureInitializationStatus = ctx.getPressureInitializationStatus;
|
||||
this.isOperational = ctx.isOperational || (() => true);
|
||||
this.applyDriftPenalty = ctx.applyDriftPenalty || ((_d, c) => c);
|
||||
this.resolveSetpointBounds = ctx.resolveSetpointBounds;
|
||||
this.getCurrentPosition = ctx.getCurrentPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} driftSnapshots — { flow, power, pressure }
|
||||
* pressure: { level, flags, source } (already-assessed pressure-drift status)
|
||||
* @returns {{ health: object, confidence: number }}
|
||||
* health is a frozen HealthStatus shape; confidence ∈ [0,1].
|
||||
*/
|
||||
evaluate(driftSnapshots = {}) {
|
||||
const pressureDrift = driftSnapshots.pressure || { level: 0, flags: [], source: null };
|
||||
const status = this._safePressureStatus();
|
||||
const flags = Array.isArray(pressureDrift.flags) ? [...pressureDrift.flags] : [];
|
||||
|
||||
let confidence = this._baseConfidenceFromSource(status.source);
|
||||
if (!this.isOperational()) {
|
||||
confidence = 0;
|
||||
flags.push('not_operational');
|
||||
}
|
||||
|
||||
confidence = this._penaltyForPressureDriftLevel(pressureDrift.level, confidence);
|
||||
confidence = this._penaltyForCurveEdge(confidence, flags);
|
||||
|
||||
confidence = this.applyDriftPenalty(driftSnapshots.flow, confidence, flags, 'flow');
|
||||
confidence = this.applyDriftPenalty(driftSnapshots.power, confidence, flags, 'power');
|
||||
|
||||
confidence = Math.max(0, Math.min(1, confidence));
|
||||
|
||||
const dedupedFlags = flags.length ? Array.from(new Set(flags)) : ['nominal'];
|
||||
const worstLevel = this._worstLevelFromSnapshots(pressureDrift, driftSnapshots, dedupedFlags);
|
||||
const hasNonNominal = dedupedFlags.some((f) => f !== 'nominal');
|
||||
const effectiveLevel = hasNonNominal ? Math.max(1, worstLevel) : worstLevel;
|
||||
const sourceTag = pressureDrift.source ?? status.source ?? null;
|
||||
|
||||
const health = effectiveLevel === 0
|
||||
? HealthStatus.ok(this._qualityLabel(confidence), sourceTag)
|
||||
: HealthStatus.degraded(
|
||||
effectiveLevel,
|
||||
dedupedFlags,
|
||||
this._qualityLabel(confidence),
|
||||
sourceTag,
|
||||
);
|
||||
|
||||
return { health, confidence };
|
||||
}
|
||||
|
||||
_safePressureStatus() {
|
||||
if (typeof this.getPressureInitializationStatus !== 'function') {
|
||||
return { initialized: false, hasDifferential: false, source: null };
|
||||
}
|
||||
return this.getPressureInitializationStatus() || { source: null };
|
||||
}
|
||||
|
||||
_baseConfidenceFromSource(source) {
|
||||
if (source === 'differential') return 0.9;
|
||||
if (source === 'upstream' || source === 'downstream') return 0.55;
|
||||
return 0.2;
|
||||
}
|
||||
|
||||
_penaltyForPressureDriftLevel(level, confidence) {
|
||||
if (level >= 3) return confidence - 0.35;
|
||||
if (level === 2) return confidence - 0.2;
|
||||
if (level === 1) return confidence - 0.1;
|
||||
return confidence;
|
||||
}
|
||||
|
||||
_penaltyForCurveEdge(confidence, flags) {
|
||||
if (typeof this.getCurrentPosition !== 'function' || typeof this.resolveSetpointBounds !== 'function') {
|
||||
return confidence;
|
||||
}
|
||||
const cur = Number(this.getCurrentPosition());
|
||||
const bounds = this.resolveSetpointBounds() || {};
|
||||
const { min, max } = bounds;
|
||||
if (Number.isFinite(cur) && Number.isFinite(min) && Number.isFinite(max) && max > min) {
|
||||
const span = max - min;
|
||||
const edgeDist = Math.min(Math.abs(cur - min), Math.abs(max - cur));
|
||||
if (edgeDist < span * 0.05) {
|
||||
flags.push('near_curve_edge');
|
||||
return confidence - 0.1;
|
||||
}
|
||||
}
|
||||
return confidence;
|
||||
}
|
||||
|
||||
_worstLevelFromSnapshots(pressureDrift, snaps, flags) {
|
||||
let worst = Number.isFinite(pressureDrift.level) ? pressureDrift.level : 0;
|
||||
for (const id of ['flow', 'power']) {
|
||||
const d = snaps[id];
|
||||
if (!d || !d.valid) continue;
|
||||
const lvl = Math.max(d.immediateLevel || 0, d.longTermLevel || 0);
|
||||
if (lvl > worst) worst = lvl;
|
||||
}
|
||||
if (flags.includes('not_operational') && worst < 2) worst = 2;
|
||||
return Math.max(0, Math.min(3, worst));
|
||||
}
|
||||
|
||||
_qualityLabel(confidence) {
|
||||
if (confidence >= 0.8) return 'high';
|
||||
if (confidence >= 0.55) return 'medium';
|
||||
if (confidence >= 0.3) return 'low';
|
||||
return 'invalid';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PredictionHealth;
|
||||
85
src/flow/flowController.js
Normal file
85
src/flow/flowController.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Dispatches inbound control actions (execSequence / execMovement /
|
||||
* flowMovement / emergencyStop / enter|exitMaintenance / statusCheck)
|
||||
* to the state machine and motion helpers on the host.
|
||||
*
|
||||
* Behaviour mirrors the original specificClass.handleInput exactly:
|
||||
* - actions are lower-cased
|
||||
* - mode/source gating runs first
|
||||
* - flow-setpoints are unit-converted (output -> canonical) before
|
||||
* calcCtrl + setpoint
|
||||
* - thrown errors are caught + logged (no re-throw) so a misbehaving
|
||||
* parent never crashes the FSM
|
||||
*/
|
||||
|
||||
class FlowController {
|
||||
constructor(ctx) {
|
||||
if (!ctx || !ctx.host) {
|
||||
throw new Error('FlowController: ctx.host is required');
|
||||
}
|
||||
this.host = ctx.host;
|
||||
this.logger = ctx.logger || ctx.host.logger;
|
||||
}
|
||||
|
||||
async handle(source, action, parameter) {
|
||||
const host = this.host;
|
||||
|
||||
if (typeof action !== 'string') {
|
||||
this.logger.error('Action must be string');
|
||||
return;
|
||||
}
|
||||
action = action.toLowerCase();
|
||||
|
||||
if (!host.isValidActionForMode(action, host.currentMode)) return;
|
||||
if (!host.isValidSourceForMode(source, host.currentMode)) return;
|
||||
|
||||
this.logger.info(
|
||||
`Handling input from source '${source}' with action '${action}' in mode '${host.currentMode}'.`,
|
||||
);
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'execsequence':
|
||||
return await host.executeSequence(parameter);
|
||||
|
||||
case 'execmovement':
|
||||
return await host.setpoint(parameter);
|
||||
|
||||
case 'entermaintenance':
|
||||
case 'exitmaintenance':
|
||||
return await host.executeSequence(parameter);
|
||||
|
||||
case 'flowmovement': {
|
||||
const canonicalFlowSetpoint = host._convertUnitValue(
|
||||
parameter,
|
||||
host.unitPolicy.output.flow,
|
||||
host.unitPolicy.canonical.flow,
|
||||
'flowmovement setpoint',
|
||||
);
|
||||
const pos = host.calcCtrl(canonicalFlowSetpoint);
|
||||
return await host.setpoint(pos);
|
||||
}
|
||||
|
||||
case 'emergencystop':
|
||||
this.logger.warn(`Emergency stop activated by '${source}'.`);
|
||||
return await host.executeSequence('emergencystop');
|
||||
|
||||
case 'statuscheck':
|
||||
this.logger.info(
|
||||
`Status Check: Mode = '${host.currentMode}', Source = '${source}'.`,
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.warn(`Action '${action}' is not implemented.`);
|
||||
break;
|
||||
}
|
||||
this.logger.debug(`Action '${action}' successfully executed`);
|
||||
return { status: true, feedback: `Action '${action}' successfully executed.` };
|
||||
} catch (error) {
|
||||
this.logger.error(`Error handling input: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FlowController;
|
||||
90
src/io/output.js
Normal file
90
src/io/output.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Snapshot builders for rotatingMachine Port 0 output + Node-RED status
|
||||
* badge. Behaviour preserved verbatim from the pre-refactor surface so
|
||||
* dashboards and downstream consumers (formatMsg, status loops) keep
|
||||
* working.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { statusBadge } = require('generalFunctions');
|
||||
|
||||
const STATE_SYMBOLS = {
|
||||
off: '⬛', idle: '⏸️', operational: '⏵️',
|
||||
starting: '⏯️', warmingup: '🔄', accelerating: '⏩',
|
||||
stopping: '⏹️', coolingdown: '❄️',
|
||||
decelerating: '⏪', maintenance: '🔧',
|
||||
};
|
||||
const FILL = {
|
||||
off: 'red', idle: 'blue',
|
||||
operational: 'green', warmingup: 'green',
|
||||
starting: 'yellow', accelerating: 'yellow', stopping: 'yellow',
|
||||
coolingdown: 'yellow', decelerating: 'yellow', maintenance: 'grey',
|
||||
};
|
||||
const SHOW_METRICS = new Set(['operational', 'warmingup', 'accelerating', 'decelerating']);
|
||||
|
||||
function buildOutput(host) {
|
||||
const o = host.measurements.getFlattenedOutput({ requestedUnits: host.unitPolicy.output });
|
||||
o.state = host.state.getCurrentState();
|
||||
o.runtime = host.state.getRunTimeHours();
|
||||
o.ctrl = host.state.getCurrentPosition();
|
||||
o.moveTimeleft = host.state.getMoveTimeLeft();
|
||||
o.mode = host.currentMode;
|
||||
o.cog = host.cog; o.NCog = host.NCog;
|
||||
o.NCogPercent = Math.round(host.NCog * 100 * 100) / 100;
|
||||
o.maintenanceTime = host.state.getMaintenanceTimeHours();
|
||||
if (host.flowDrift != null) {
|
||||
const f = host.flowDrift;
|
||||
o.flowNrmse = f.nrmse;
|
||||
o.flowLongterNRMSD = f.longTermNRMSD;
|
||||
o.flowLongTermNRMSD = f.longTermNRMSD;
|
||||
o.flowImmediateLevel = f.immediateLevel;
|
||||
o.flowLongTermLevel = f.longTermLevel;
|
||||
o.flowDriftValid = f.valid;
|
||||
}
|
||||
if (host.powerDrift != null) {
|
||||
const p = host.powerDrift;
|
||||
o.powerNrmse = p.nrmse;
|
||||
o.powerLongTermNRMSD = p.longTermNRMSD;
|
||||
o.powerImmediateLevel = p.immediateLevel;
|
||||
o.powerLongTermLevel = p.longTermLevel;
|
||||
o.powerDriftValid = p.valid;
|
||||
}
|
||||
o.pressureDriftLevel = host.pressureDrift.level;
|
||||
o.pressureDriftSource = host.pressureDrift.source;
|
||||
o.pressureDriftFlags = host.pressureDrift.flags;
|
||||
o.predictionQuality = host.predictionHealth.quality;
|
||||
o.predictionConfidence = Math.round(host.predictionHealth.confidence * 1000) / 1000;
|
||||
o.predictionPressureSource = host.predictionHealth.pressureSource;
|
||||
o.predictionFlags = host.predictionHealth.flags;
|
||||
o.effDistFromPeak = host.absDistFromPeak;
|
||||
o.effRelDistFromPeak = host.relDistFromPeak;
|
||||
return o;
|
||||
}
|
||||
|
||||
function buildStatusBadge(host) {
|
||||
try {
|
||||
const stateName = host.state?.getCurrentState?.() ?? 'unknown';
|
||||
const needsPressure = SHOW_METRICS.has(stateName);
|
||||
const ps = host.pressureInit?.getStatus?.() ?? { initialized: true };
|
||||
if (needsPressure && !ps.initialized) {
|
||||
return statusBadge.text(`${host.currentMode}: pressure not initialized`, { fill: 'yellow', shape: 'ring' });
|
||||
}
|
||||
const symbol = STATE_SYMBOLS[stateName] || '❔';
|
||||
const fill = FILL[stateName] || 'grey';
|
||||
const parts = [`${host.currentMode}: ${symbol}`];
|
||||
if (SHOW_METRICS.has(stateName)) {
|
||||
const fu = host.unitPolicy.output.flow || 'm3/h';
|
||||
const flow = Math.round(host.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(fu) ?? 0);
|
||||
const power = Math.round(host.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue('kW') ?? 0);
|
||||
const pos = Math.round((host.state?.getCurrentPosition?.() ?? 0) * 100) / 100;
|
||||
parts.push(`${pos}%`, `💨${flow}${fu}`, `⚡${power}kW`);
|
||||
}
|
||||
return statusBadge.compose(parts, { fill, shape: 'dot' });
|
||||
} catch (err) {
|
||||
host.logger?.error?.(`getStatusBadge: ${err.message}`);
|
||||
return statusBadge.error('Status Error');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { buildOutput, buildStatusBadge };
|
||||
47
src/measurement/childRegistrar.js
Normal file
47
src/measurement/childRegistrar.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* registerChild adapter for rotatingMachine. Custom because:
|
||||
* - virtual + real pressure children share the upstream/downstream
|
||||
* position slots; real ones must be tracked for the preference order
|
||||
* - re-registration of the same child must dedup the emitter listener
|
||||
* - non-measurement softwareTypes are no-ops (Machine has no children
|
||||
* other than measurement nodes today)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
function registerMeasurementChild(host, child, softwareType) {
|
||||
const swType = softwareType || child?.config?.functionality?.softwareType || 'measurement';
|
||||
host.logger.debug(`Setting up child event for softwaretype ${swType}`);
|
||||
if (swType !== 'measurement') return;
|
||||
|
||||
const position = String(child.config.functionality.positionVsParent || 'atEquipment').toLowerCase();
|
||||
const measurementType = child.config.asset.type;
|
||||
const childId = child.config?.general?.id || `${measurementType}-${position}-unknown`;
|
||||
const isVirtual = Object.values(host.virtualPressureChildIds).includes(childId);
|
||||
if (measurementType === 'pressure' && !isVirtual) host.realPressureChildIds[position]?.add(childId);
|
||||
|
||||
const eventName = `${measurementType}.measured.${position}`;
|
||||
const key = `${childId}:${eventName}`;
|
||||
const existing = host.childMeasurementListeners.get(key);
|
||||
if (existing) {
|
||||
if (typeof existing.emitter.off === 'function') existing.emitter.off(existing.eventName, existing.handler);
|
||||
else if (typeof existing.emitter.removeListener === 'function') existing.emitter.removeListener(existing.eventName, existing.handler);
|
||||
}
|
||||
const handler = (eventData) => {
|
||||
host.logger.debug(`🔄 ${position} ${measurementType} from ${eventData.childName}: ${eventData.value} ${eventData.unit}`);
|
||||
host._callMeasurementHandler(measurementType, eventData.value, position, eventData);
|
||||
};
|
||||
child.measurements.emitter.on(eventName, handler);
|
||||
host.childMeasurementListeners.set(key, { emitter: child.measurements.emitter, eventName, handler });
|
||||
}
|
||||
|
||||
function detachAllListeners(host) {
|
||||
if (!host.childMeasurementListeners) return;
|
||||
for (const [, e] of host.childMeasurementListeners) {
|
||||
if (typeof e.emitter?.off === 'function') e.emitter.off(e.eventName, e.handler);
|
||||
else if (typeof e.emitter?.removeListener === 'function') e.emitter.removeListener(e.eventName, e.handler);
|
||||
}
|
||||
host.childMeasurementListeners.clear();
|
||||
}
|
||||
|
||||
module.exports = { registerMeasurementChild, detachAllListeners };
|
||||
181
src/measurement/measurementHandlers.js
Normal file
181
src/measurement/measurementHandlers.js
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Centralised measurement update routing for rotatingMachine.
|
||||
*
|
||||
* Wraps the four measurement types coming from child measurement nodes
|
||||
* (flow / power / temperature / pressure) and dispatches each to the
|
||||
* appropriate handler. Pressure is delegated to the host's pressureRouter
|
||||
* (built in P5.4); the other three are normalised + written + drift-tracked
|
||||
* here.
|
||||
*
|
||||
* The handlers reach back into the host for `_resolveMeasurementUnit`,
|
||||
* `_updateMetricDrift`, `_updatePredictionHealth`, `updatePosition` and the
|
||||
* measurements container. Behaviour is preserved 1:1 from the original
|
||||
* specificClass methods.
|
||||
*/
|
||||
|
||||
class MeasurementHandlers {
|
||||
constructor(ctx) {
|
||||
if (!ctx || !ctx.host) {
|
||||
throw new Error('MeasurementHandlers: ctx.host is required');
|
||||
}
|
||||
this.host = ctx.host;
|
||||
this.logger = ctx.logger || ctx.host.logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single entry point used by child-measurement event listeners.
|
||||
* Unknown types warn and fall back to a no-op position refresh so a
|
||||
* mis-configured child can't silently break the FSM tick.
|
||||
*/
|
||||
dispatch(measurementType, value, position, context = {}) {
|
||||
switch (measurementType) {
|
||||
case 'pressure':
|
||||
return this.host.updateMeasuredPressure(value, position, context);
|
||||
case 'flow':
|
||||
return this.updateMeasuredFlow(value, position, context);
|
||||
case 'power':
|
||||
return this.updateMeasuredPower(value, position, context);
|
||||
case 'temperature':
|
||||
return this.updateMeasuredTemperature(value, position, context);
|
||||
default:
|
||||
this.logger.warn(`No handler for measurement type: ${measurementType}`);
|
||||
return this.host.updatePosition();
|
||||
}
|
||||
}
|
||||
|
||||
updateMeasuredTemperature(value, position, context = {}) {
|
||||
const host = this.host;
|
||||
this.logger.debug(
|
||||
`Temperature update: ${value} at ${position} from ${context.childName || 'child'} (${context.childId || 'unknown-id'})`,
|
||||
);
|
||||
let unit;
|
||||
try {
|
||||
unit = host._resolveMeasurementUnit('temperature', context.unit);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Rejected temperature update: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
host.measurements
|
||||
.type('temperature')
|
||||
.variant('measured')
|
||||
.position(position || 'atEquipment')
|
||||
.child(context.childId)
|
||||
.value(value, context.timestamp, unit);
|
||||
}
|
||||
|
||||
updateMeasuredFlow(value, position, context = {}) {
|
||||
const host = this.host;
|
||||
if (!host._isOperationalState()) {
|
||||
this.logger.warn(`Machine not operational, skipping flow update from ${context.childName || 'unknown'}`);
|
||||
return;
|
||||
}
|
||||
this.logger.debug(`Flow update: ${value} at ${position} from ${context.childName || 'child'}`);
|
||||
let unit;
|
||||
try {
|
||||
unit = host._resolveMeasurementUnit('flow', context.unit);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Rejected flow update: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
host.measurements
|
||||
.type('flow').variant('measured').position(position).child(context.childId)
|
||||
.value(value, context.timestamp, unit);
|
||||
|
||||
if (host.predictFlow) {
|
||||
const canonical = host.unitPolicy.canonical.flow;
|
||||
const predicted = host.predictFlow.outputY || 0;
|
||||
host.measurements.type('flow').variant('predicted').position('downstream')
|
||||
.value(predicted, Date.now(), canonical);
|
||||
host.measurements.type('flow').variant('predicted').position('atEquipment')
|
||||
.value(predicted, Date.now(), canonical);
|
||||
}
|
||||
|
||||
const measuredCanonical = host.measurements
|
||||
.type('flow').variant('measured').position(position)
|
||||
.getCurrentValue(host.unitPolicy.canonical.flow);
|
||||
|
||||
host._updateMetricDrift('flow', measuredCanonical, context);
|
||||
host._updatePredictionHealth();
|
||||
}
|
||||
|
||||
updateMeasuredPower(value, position, context = {}) {
|
||||
const host = this.host;
|
||||
if (!host._isOperationalState()) {
|
||||
this.logger.warn(`Machine not operational, skipping power update from ${context.childName || 'unknown'}`);
|
||||
return;
|
||||
}
|
||||
this.logger.debug(`Power update: ${value} at ${position} from ${context.childName || 'child'}`);
|
||||
let unit;
|
||||
try {
|
||||
unit = host._resolveMeasurementUnit('power', context.unit);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Rejected power update: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
host.measurements
|
||||
.type('power').variant('measured').position(position).child(context.childId)
|
||||
.value(value, context.timestamp, unit);
|
||||
|
||||
if (host.predictPower) {
|
||||
host.measurements.type('power').variant('predicted').position('atEquipment')
|
||||
.value(host.predictPower.outputY || 0, Date.now(), host.unitPolicy.canonical.power);
|
||||
}
|
||||
|
||||
const measuredCanonical = host.measurements
|
||||
.type('power').variant('measured').position(position)
|
||||
.getCurrentValue(host.unitPolicy.canonical.power);
|
||||
|
||||
host._updateMetricDrift('power', measuredCanonical, context);
|
||||
host._updatePredictionHealth();
|
||||
}
|
||||
|
||||
/** Reconcile a measured-flow reading with the existing up/downstream slots. */
|
||||
handleMeasuredFlow() {
|
||||
const host = this.host;
|
||||
const diff = host.measurements.type('flow').variant('measured').difference();
|
||||
if (diff != null) {
|
||||
if (diff.value < 0.001) { this.logger.debug(`Flow match: ${diff.value}`); return diff.value; }
|
||||
this.logger.error('Something wrong with down or upstream flow measurement. Bailing out!');
|
||||
return null;
|
||||
}
|
||||
const up = host.measurements.type('flow').variant('measured').position('upstream').getCurrentValue();
|
||||
if (up != null) { this.logger.warn('Only upstream flow is present. Using it but results may be incomplete!'); return up; }
|
||||
const dn = host.measurements.type('flow').variant('measured').position('downstream').getCurrentValue();
|
||||
if (dn != null) { this.logger.warn('Only downstream flow is present. Using it but results may be incomplete!'); return dn; }
|
||||
this.logger.error('No upstream or downstream flow measurement. Bailing out!');
|
||||
return null;
|
||||
}
|
||||
|
||||
handleMeasuredPower() {
|
||||
const power = this.host.measurements.type('power').variant('measured').position('atEquipment').getCurrentValue();
|
||||
if (power != null) { this.logger.debug(`Measured power: ${power}`); return power; }
|
||||
this.logger.error('No measured power found. Bailing out!');
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Route a dashboard-sim pressure write to its virtual child; route any
|
||||
* other simulated measurement type through the normal handler dispatch. */
|
||||
updateSimulatedMeasurement(type, position, value, context = {}) {
|
||||
const host = this.host;
|
||||
const t = String(type || '').toLowerCase();
|
||||
const pos = String(position || 'atEquipment').toLowerCase();
|
||||
if (t !== 'pressure') { return this.dispatch(t, value, pos, context); }
|
||||
if (!host.virtualPressureChildIds[pos]) {
|
||||
this.logger.warn(`Unsupported simulated pressure position '${pos}'`);
|
||||
return;
|
||||
}
|
||||
const child = host.virtualPressureChildren[pos];
|
||||
if (!child?.measurements) {
|
||||
this.logger.error(`Virtual pressure child '${pos}' is missing`);
|
||||
return;
|
||||
}
|
||||
let unit;
|
||||
try { unit = host._resolveMeasurementUnit('pressure', context.unit); }
|
||||
catch (err) { this.logger.warn(`Rejected simulated pressure measurement: ${err.message}`); return; }
|
||||
child.measurements.type('pressure').variant('measured').position(pos)
|
||||
.value(value, context.timestamp || Date.now(), unit);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MeasurementHandlers;
|
||||
458
src/nodeClass.js
458
src/nodeClass.js
@@ -1,413 +1,61 @@
|
||||
/**
|
||||
* node class.js
|
||||
*
|
||||
* Encapsulates all node logic in a reusable class. In future updates we can split this into multiple generic classes and use the config to specifiy which ones to use.
|
||||
* This allows us to keep the Node-RED node clean and focused on wiring up the UI and event handlers.
|
||||
*/
|
||||
const { outputUtils, configManager, convert } = require('generalFunctions');
|
||||
const Specific = require("./specificClass");
|
||||
'use strict';
|
||||
|
||||
class nodeClass {
|
||||
/**
|
||||
* Create a Node.
|
||||
* @param {object} uiConfig - Node-RED node configuration.
|
||||
* @param {object} RED - Node-RED runtime API.
|
||||
*/
|
||||
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
||||
const { BaseNodeAdapter, convert } = require('generalFunctions');
|
||||
const Machine = require('./specificClass');
|
||||
const commands = require('./commands');
|
||||
|
||||
// Preserve RED reference for HTTP endpoints if needed
|
||||
this.node = nodeInstance; // This is the Node-RED node instance, we can use this to send messages and update status
|
||||
this.RED = RED; // This is the Node-RED runtime API, we can use this to create endpoints if needed
|
||||
this.name = nameOfNode; // This is the name of the node, it should match the file name and the node type in Node-RED
|
||||
this.source = null; // Will hold the specific class instance
|
||||
this.config = null; // Will hold the merged configuration
|
||||
this._pressureInitWarned = false;
|
||||
// Event-driven: state + measurement events drive recomputes via the
|
||||
// domain emitter. No tick loop. Status badge polled every second.
|
||||
class nodeClass extends BaseNodeAdapter {
|
||||
static DomainClass = Machine;
|
||||
static commands = commands;
|
||||
static tickInterval = null;
|
||||
static statusInterval = 1000;
|
||||
|
||||
// Load default & UI config
|
||||
this._loadConfig(uiConfig,this.node);
|
||||
|
||||
// Instantiate core class
|
||||
this._setupSpecificClass(uiConfig);
|
||||
|
||||
// Wire up event and lifecycle handlers
|
||||
this._bindEvents();
|
||||
this._registerChild();
|
||||
this._startTickLoop();
|
||||
this._attachInputHandler();
|
||||
this._attachCloseHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and merge default config with user-defined settings.
|
||||
* @param {object} uiConfig - Raw config from Node-RED UI.
|
||||
*/
|
||||
_loadConfig(uiConfig,node) {
|
||||
const cfgMgr = new configManager();
|
||||
const resolvedAssetUuid = uiConfig.assetUuid || uiConfig.uuid || null;
|
||||
const resolvedAssetTagCode = uiConfig.assetTagCode || uiConfig.assetTagNumber || null;
|
||||
const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow');
|
||||
const curveUnits = {
|
||||
pressure: this._resolveUnitOrFallback(uiConfig.curvePressureUnit, 'pressure', 'mbar', 'curve pressure'),
|
||||
flow: this._resolveUnitOrFallback(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit, 'curve flow'),
|
||||
power: this._resolveUnitOrFallback(uiConfig.curvePowerUnit, 'power', 'kW', 'curve power'),
|
||||
control: this._resolveControlUnitOrFallback(uiConfig.curveControlUnit, '%'),
|
||||
};
|
||||
|
||||
// Build config: base sections + rotatingMachine-specific domain config
|
||||
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
|
||||
flowNumber: uiConfig.flowNumber
|
||||
});
|
||||
|
||||
// Override asset with rotatingMachine-specific fields
|
||||
this.config.asset = {
|
||||
...this.config.asset,
|
||||
uuid: resolvedAssetUuid,
|
||||
tagCode: resolvedAssetTagCode,
|
||||
tagNumber: uiConfig.assetTagNumber || null,
|
||||
unit: flowUnit,
|
||||
curveUnits
|
||||
};
|
||||
|
||||
// Ensure general unit uses resolved flow unit
|
||||
this.config.general.unit = flowUnit;
|
||||
|
||||
// Utility for formatting outputs
|
||||
this._output = new outputUtils();
|
||||
}
|
||||
|
||||
_resolveUnitOrFallback(candidate, expectedMeasure, fallbackUnit, label) {
|
||||
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||
const fallback = String(fallbackUnit || '').trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const desc = convert().describe(raw);
|
||||
if (expectedMeasure && desc.measure !== expectedMeasure) {
|
||||
throw new Error(`expected '${expectedMeasure}' but got '${desc.measure}'`);
|
||||
}
|
||||
return raw;
|
||||
} catch (error) {
|
||||
this.node?.warn?.(`Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallback}'.`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
_resolveControlUnitOrFallback(candidate, fallback = '%') {
|
||||
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||
return raw || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate the core Measurement logic and store as source.
|
||||
*/
|
||||
_setupSpecificClass(uiConfig) {
|
||||
const machineConfig = this.config;
|
||||
|
||||
// need extra state for this
|
||||
const stateConfig = {
|
||||
general: {
|
||||
logging: {
|
||||
enabled: machineConfig.general.logging.enabled,
|
||||
logLevel: machineConfig.general.logging.logLevel
|
||||
}
|
||||
},
|
||||
movement: {
|
||||
speed: Number(uiConfig.speed),
|
||||
mode: uiConfig.movementMode
|
||||
},
|
||||
buildDomainConfig(uiConfig) {
|
||||
const flowUnit = _resolveUnit(uiConfig.unit, 'volumeFlowRate', 'm3/h');
|
||||
// Stash extras on the Machine class so its constructor (called by
|
||||
// BaseNodeAdapter via DomainClass) picks them up alongside the
|
||||
// machineConfig. Single-threaded JS makes the hand-off race-free.
|
||||
Machine._pendingExtras = {
|
||||
stateConfig: {
|
||||
general: { logging: { enabled: uiConfig.enableLog, logLevel: uiConfig.logLevel } },
|
||||
movement: { speed: Number(uiConfig.speed), mode: uiConfig.movementMode },
|
||||
time: {
|
||||
starting: Number(uiConfig.startup),
|
||||
warmingup: Number(uiConfig.warmup),
|
||||
stopping: Number(uiConfig.shutdown),
|
||||
coolingdown: Number(uiConfig.cooldown)
|
||||
}
|
||||
};
|
||||
|
||||
this.source = new Specific(machineConfig, stateConfig);
|
||||
|
||||
//store in node
|
||||
this.node.source = this.source; // Store the source in the node instance for easy access
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind events to Node-RED status updates. Using internal emitter. --> REMOVE LATER WE NEED ONLY COMPLETE CHILDS AND THEN CHECK FOR UPDATES
|
||||
*/
|
||||
_bindEvents() {
|
||||
|
||||
}
|
||||
|
||||
_updateNodeStatus() {
|
||||
const m = this.source;
|
||||
try {
|
||||
const mode = m.currentMode;
|
||||
const state = m.state.getCurrentState();
|
||||
const requiresPressurePrediction = ["operational", "warmingup", "accelerating", "decelerating"].includes(state);
|
||||
const pressureStatus = typeof m.getPressureInitializationStatus === "function"
|
||||
? m.getPressureInitializationStatus()
|
||||
: { initialized: true };
|
||||
|
||||
if (requiresPressurePrediction && !pressureStatus.initialized) {
|
||||
if (!this._pressureInitWarned) {
|
||||
this.node.warn("Pressure input is not initialized (upstream/downstream missing). Predictions are using minimum pressure.");
|
||||
this._pressureInitWarned = true;
|
||||
}
|
||||
return { fill: "yellow", shape: "ring", text: `${mode}: pressure not initialized` };
|
||||
}
|
||||
|
||||
if (pressureStatus.initialized) {
|
||||
this._pressureInitWarned = false;
|
||||
}
|
||||
const flowUnit = m?.config?.general?.unit || 'm3/h';
|
||||
const flow = Math.round(m.measurements.type("flow").variant("predicted").position('downstream').getCurrentValue(flowUnit));
|
||||
const power = Math.round(m.measurements.type("power").variant("predicted").position('atEquipment').getCurrentValue('kW'));
|
||||
let symbolState;
|
||||
switch(state){
|
||||
case "off":
|
||||
symbolState = "⬛";
|
||||
break;
|
||||
case "idle":
|
||||
symbolState = "⏸️";
|
||||
break;
|
||||
case "operational":
|
||||
symbolState = "⏵️";
|
||||
break;
|
||||
case "starting":
|
||||
symbolState = "⏯️";
|
||||
break;
|
||||
case "warmingup":
|
||||
symbolState = "🔄";
|
||||
break;
|
||||
case "accelerating":
|
||||
symbolState = "⏩";
|
||||
break;
|
||||
case "stopping":
|
||||
symbolState = "⏹️";
|
||||
break;
|
||||
case "coolingdown":
|
||||
symbolState = "❄️";
|
||||
break;
|
||||
case "decelerating":
|
||||
symbolState = "⏪";
|
||||
break;
|
||||
case "maintenance":
|
||||
symbolState = "🔧";
|
||||
break;
|
||||
}
|
||||
const position = m.state.getCurrentPosition();
|
||||
const roundedPosition = Math.round(position * 100) / 100;
|
||||
|
||||
let status;
|
||||
switch (state) {
|
||||
case "off":
|
||||
status = { fill: "red", shape: "dot", text: `${mode}: OFF` };
|
||||
break;
|
||||
case "idle":
|
||||
status = { fill: "blue", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
break;
|
||||
case "operational":
|
||||
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
|
||||
break;
|
||||
case "starting":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
break;
|
||||
case "warmingup":
|
||||
status = { fill: "green", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
|
||||
break;
|
||||
case "accelerating":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} | ${roundedPosition}%| 💨${flow}${flowUnit} | ⚡${power}kW` };
|
||||
break;
|
||||
case "stopping":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
break;
|
||||
case "coolingdown":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
break;
|
||||
case "decelerating":
|
||||
status = { fill: "yellow", shape: "dot", text: `${mode}: ${symbolState} - ${roundedPosition}% | 💨${flow}${flowUnit} | ⚡${power}kW` };
|
||||
break;
|
||||
default:
|
||||
status = { fill: "grey", shape: "dot", text: `${mode}: ${symbolState}` };
|
||||
}
|
||||
return status;
|
||||
} catch (error) {
|
||||
this.node.error("Error in updateNodeStatus: " + error.message);
|
||||
return { fill: "red", shape: "ring", text: "Status Error" };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Register this node as a child upstream and downstream.
|
||||
* Delayed to avoid Node-RED startup race conditions.
|
||||
*/
|
||||
_registerChild() {
|
||||
setTimeout(() => {
|
||||
this.node.send([
|
||||
null,
|
||||
null,
|
||||
{ topic: 'registerChild', payload: this.node.id, positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' },
|
||||
]);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic tick loop.
|
||||
*/
|
||||
_startTickLoop() {
|
||||
setTimeout(() => {
|
||||
this._tickInterval = setInterval(() => this._tick(), 1000);
|
||||
|
||||
// Update node status on nodered screen every second ( this is not the best way to do this, but it works for now)
|
||||
this._statusInterval = setInterval(() => {
|
||||
const status = this._updateNodeStatus();
|
||||
this.node.status(status);
|
||||
}, 1000);
|
||||
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tick: update measurement, format and send outputs.
|
||||
*/
|
||||
_tick() {
|
||||
//this.source.tick();
|
||||
|
||||
const raw = this.source.getOutput();
|
||||
const processMsg = this._output.formatMsg(raw, this.source.config, 'process');
|
||||
const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
|
||||
|
||||
// Send only updated outputs on ports 0 & 1
|
||||
this.node.send([processMsg, influxMsg, null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the node's input handler, routing control messages to the class.
|
||||
*/
|
||||
_attachInputHandler() {
|
||||
this.node.on('input', (msg, send, done) => {
|
||||
/* Update to complete event based node by putting the tick function after an input event */
|
||||
const m = this.source;
|
||||
const nodeSend = typeof send === 'function' ? send : (outMsg) => this.node.send(outMsg);
|
||||
|
||||
try {
|
||||
switch(msg.topic) {
|
||||
case 'registerChild': {
|
||||
// Register this node as a child of the parent node
|
||||
const childId = msg.payload;
|
||||
const childObj = this.RED.nodes.getNode(childId);
|
||||
if (!childObj || !childObj.source) {
|
||||
this.node.warn(`registerChild failed: child '${childId}' not found or has no source`);
|
||||
break;
|
||||
}
|
||||
m.childRegistrationUtils.registerChild(childObj.source ,msg.positionVsParent);
|
||||
break;
|
||||
}
|
||||
case 'setMode':
|
||||
m.setMode(msg.payload);
|
||||
break;
|
||||
case 'execSequence': {
|
||||
const { source, action, parameter } = msg.payload;
|
||||
m.handleInput(source, action, parameter);
|
||||
break;
|
||||
}
|
||||
case 'execMovement': {
|
||||
const { source: mvSource, action: mvAction, setpoint } = msg.payload;
|
||||
m.handleInput(mvSource, mvAction, Number(setpoint));
|
||||
break;
|
||||
}
|
||||
case 'flowMovement': {
|
||||
const { source: fmSource, action: fmAction, setpoint: fmSetpoint } = msg.payload;
|
||||
m.handleInput(fmSource, fmAction, Number(fmSetpoint));
|
||||
break;
|
||||
}
|
||||
case 'emergencystop': {
|
||||
const { source: esSource, action: esAction } = msg.payload;
|
||||
m.handleInput(esSource, esAction);
|
||||
break;
|
||||
}
|
||||
case 'simulateMeasurement':
|
||||
{
|
||||
const payload = msg.payload || {};
|
||||
const type = String(payload.type || '').toLowerCase();
|
||||
const position = payload.position || 'atEquipment';
|
||||
const value = Number(payload.value);
|
||||
const unit = typeof payload.unit === 'string' ? payload.unit.trim() : '';
|
||||
const supportedTypes = new Set(['pressure', 'flow', 'temperature', 'power']);
|
||||
const context = {
|
||||
timestamp: payload.timestamp || Date.now(),
|
||||
unit,
|
||||
childName: 'dashboard-sim',
|
||||
childId: 'dashboard-sim',
|
||||
};
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
this.node.warn('simulateMeasurement payload.value must be a finite number');
|
||||
break;
|
||||
}
|
||||
|
||||
if (!supportedTypes.has(type)) {
|
||||
this.node.warn(`Unsupported simulateMeasurement type: ${type}`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!unit) {
|
||||
this.node.warn('simulateMeasurement payload.unit is required');
|
||||
break;
|
||||
}
|
||||
|
||||
if (typeof m.isUnitValidForType === 'function' && !m.isUnitValidForType(type, unit)) {
|
||||
this.node.warn(`simulateMeasurement payload.unit '${unit}' is invalid for type '${type}'`);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'pressure':
|
||||
if (typeof m.updateSimulatedMeasurement === "function") {
|
||||
m.updateSimulatedMeasurement(type, position, value, context);
|
||||
} else {
|
||||
m.updateMeasuredPressure(value, position, context);
|
||||
}
|
||||
break;
|
||||
case 'flow':
|
||||
m.updateMeasuredFlow(value, position, context);
|
||||
break;
|
||||
case 'temperature':
|
||||
m.updateMeasuredTemperature(value, position, context);
|
||||
break;
|
||||
case 'power':
|
||||
m.updateMeasuredPower(value, position, context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'showWorkingCurves':
|
||||
nodeSend([{ ...msg, topic : "showWorkingCurves" , payload: m.showWorkingCurves() }, null, null]);
|
||||
break;
|
||||
case 'CoG':
|
||||
nodeSend([{ ...msg, topic : "showCoG" , payload: m.showCoG() }, null, null]);
|
||||
break;
|
||||
}
|
||||
if (typeof done === 'function') done();
|
||||
} catch (error) {
|
||||
if (typeof done === 'function') {
|
||||
done(error);
|
||||
} else {
|
||||
this.node.error(error, msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up timers and intervals when Node-RED stops the node.
|
||||
*/
|
||||
_attachCloseHandler() {
|
||||
this.node.on('close', (done) => {
|
||||
clearInterval(this._tickInterval);
|
||||
clearInterval(this._statusInterval);
|
||||
if (typeof done === 'function') done();
|
||||
});
|
||||
starting: Number(uiConfig.startup), warmingup: Number(uiConfig.warmup),
|
||||
stopping: Number(uiConfig.shutdown), coolingdown: Number(uiConfig.cooldown),
|
||||
},
|
||||
},
|
||||
errorMetricsConfig: {},
|
||||
};
|
||||
return {
|
||||
asset: {
|
||||
uuid: uiConfig.assetUuid || uiConfig.uuid || null,
|
||||
tagCode: uiConfig.assetTagCode || uiConfig.assetTagNumber || null,
|
||||
tagNumber: uiConfig.assetTagNumber || null,
|
||||
unit: flowUnit,
|
||||
curveUnits: {
|
||||
pressure: _resolveUnit(uiConfig.curvePressureUnit, 'pressure', 'mbar'),
|
||||
flow: _resolveUnit(uiConfig.curveFlowUnit || flowUnit, 'volumeFlowRate', flowUnit),
|
||||
power: _resolveUnit(uiConfig.curvePowerUnit, 'power', 'kW'),
|
||||
control: (typeof uiConfig.curveControlUnit === 'string' && uiConfig.curveControlUnit.trim()) || '%',
|
||||
},
|
||||
},
|
||||
general: { unit: flowUnit },
|
||||
flowNumber: uiConfig.flowNumber,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function _resolveUnit(candidate, expectedMeasure, fallback) {
|
||||
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
||||
const fb = String(fallback || '').trim();
|
||||
if (!raw) return fb;
|
||||
try {
|
||||
const desc = convert().describe(raw);
|
||||
if (expectedMeasure && desc.measure !== expectedMeasure) return fb;
|
||||
return raw;
|
||||
} catch (_) { return fb; }
|
||||
}
|
||||
|
||||
module.exports = nodeClass;
|
||||
|
||||
111
src/prediction/efficiencyMath.js
Normal file
111
src/prediction/efficiencyMath.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Efficiency / CoG math for rotatingMachine. Kept as host-aware
|
||||
* helpers so the orchestrator stays a thin stitch. `host` is the
|
||||
* Machine instance; the helpers read its predictors + measurements
|
||||
* container and update the legacy fields (cog, NCog, currentEfficiencyCurve,
|
||||
* absDistFromPeak, relDistFromPeak) on it in place — matching the
|
||||
* pre-refactor surface tests assert on.
|
||||
*/
|
||||
|
||||
const { gravity, coolprop } = require('generalFunctions');
|
||||
|
||||
function calcEfficiencyCurve(powerCurve, flowCurve) {
|
||||
const efficiencyCurve = [];
|
||||
let peak = 0; let peakIndex = 0; let minEfficiency = Infinity;
|
||||
if (!powerCurve?.y?.length || !flowCurve?.y?.length) {
|
||||
return { efficiencyCurve: [], peak: 0, peakIndex: 0, minEfficiency: 0 };
|
||||
}
|
||||
powerCurve.y.forEach((power, i) => {
|
||||
const flow = flowCurve.y[i];
|
||||
const eff = (power > 0 && flow >= 0) ? flow / power : 0;
|
||||
efficiencyCurve.push(eff);
|
||||
if (eff > peak) { peak = eff; peakIndex = i; }
|
||||
if (eff < minEfficiency) minEfficiency = eff;
|
||||
});
|
||||
if (!Number.isFinite(minEfficiency)) minEfficiency = 0;
|
||||
return { efficiencyCurve, peak, peakIndex, minEfficiency };
|
||||
}
|
||||
|
||||
function calcCog(host) {
|
||||
if (!host.hasCurve || !host.predictFlow || !host.predictPower) {
|
||||
return { cog: 0, cogIndex: 0, NCog: 0, minEfficiency: 0 };
|
||||
}
|
||||
const { powerCurve, flowCurve } = getCurrentCurves(host);
|
||||
const { efficiencyCurve, peak, peakIndex, minEfficiency } = calcEfficiencyCurve(powerCurve, flowCurve);
|
||||
const yMin = host.predictFlow.currentFxyYMin;
|
||||
const yMax = host.predictFlow.currentFxyYMax;
|
||||
const NCog = (flowCurve.y[peakIndex] - yMin) / (yMax - yMin);
|
||||
host.currentEfficiencyCurve = efficiencyCurve;
|
||||
host.cog = peak;
|
||||
host.cogIndex = peakIndex;
|
||||
host.NCog = NCog;
|
||||
host.minEfficiency = minEfficiency;
|
||||
return { cog: peak, cogIndex: peakIndex, NCog, minEfficiency };
|
||||
}
|
||||
|
||||
function getCurrentCurves(host) {
|
||||
if (!host.hasCurve || !host.predictPower || !host.predictFlow) {
|
||||
return { powerCurve: { x: [], y: [] }, flowCurve: { x: [], y: [] } };
|
||||
}
|
||||
return {
|
||||
powerCurve: host.predictPower.currentFxyCurve[host.predictPower.currentF],
|
||||
flowCurve: host.predictFlow.currentFxyCurve[host.predictFlow.currentF],
|
||||
};
|
||||
}
|
||||
|
||||
function getCompleteCurve(host) {
|
||||
if (!host.hasCurve || !host.predictPower || !host.predictFlow) return { powerCurve: null, flowCurve: null };
|
||||
return { powerCurve: host.predictPower.inputCurveData, flowCurve: host.predictFlow.inputCurveData };
|
||||
}
|
||||
|
||||
function calcDistanceFromPeak(currentEfficiency, peakEfficiency) {
|
||||
return Math.abs(currentEfficiency - peakEfficiency);
|
||||
}
|
||||
|
||||
function calcRelativeDistanceFromPeak(host, currentEfficiency, maxEfficiency, minEfficiency) {
|
||||
if (currentEfficiency != null && maxEfficiency !== minEfficiency) {
|
||||
return host.interpolation.interpolate_lin_single_point(currentEfficiency, maxEfficiency, minEfficiency, 0, 1);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function calcDistanceBEP(host, efficiency, maxEfficiency, minEfficiency) {
|
||||
host.absDistFromPeak = calcDistanceFromPeak(efficiency, maxEfficiency);
|
||||
host.relDistFromPeak = calcRelativeDistanceFromPeak(host, efficiency, maxEfficiency, minEfficiency);
|
||||
return { absDistFromPeak: host.absDistFromPeak, relDistFromPeak: host.relDistFromPeak };
|
||||
}
|
||||
|
||||
function calcEfficiency(host, power, flow, variant) {
|
||||
const pressureDiff = host.measurements.type('pressure').variant('measured').difference({ unit: 'Pa' });
|
||||
const g = gravity.getStandardGravity();
|
||||
const temp = host.measurements.type('temperature').variant('measured').position('atEquipment').getCurrentValue('K');
|
||||
const atm = host.measurements.type('atmPressure').variant('measured').position('atEquipment').getCurrentValue('Pa');
|
||||
let rho = null;
|
||||
try { rho = coolprop.PropsSI('D', 'T', temp, 'P', atm, 'WasteWater'); }
|
||||
catch (e) { host.logger.warn(`CoolProp density lookup failed: ${e.message}. Using fallback density.`); rho = 1000; }
|
||||
|
||||
const flowM3s = host.measurements.type('flow').variant(variant).position('atEquipment').getCurrentValue('m3/s');
|
||||
const powerW = host.measurements.type('power').variant(variant).position('atEquipment').getCurrentValue('W');
|
||||
host.logger.debug(`temp: ${temp} atmPressure : ${atm} rho : ${rho} pressureDiff: ${pressureDiff?.value || 0}`);
|
||||
host.logger.debug(`Flow : ${flowM3s} power: ${powerW}`);
|
||||
|
||||
if (power > 0 && flow > 0) {
|
||||
host.measurements.type('efficiency').variant(variant).position('atEquipment').value(flow / power);
|
||||
host.measurements.type('specificEnergyConsumption').variant(variant).position('atEquipment').value(power / flow);
|
||||
if (pressureDiff?.value != null && Number.isFinite(flowM3s) && Number.isFinite(powerW) && powerW > 0) {
|
||||
const diffPa = Number(pressureDiff.value);
|
||||
const head = (Number.isFinite(rho) && rho > 0) ? diffPa / (rho * g) : null;
|
||||
const hydraulicPowerW = diffPa * flowM3s;
|
||||
if (Number.isFinite(head)) host.measurements.type('pumpHead').variant(variant).position('atEquipment').value(head, Date.now(), 'm');
|
||||
host.measurements.type('hydraulicPower').variant(variant).position('atEquipment').value(hydraulicPowerW, Date.now(), 'W');
|
||||
host.measurements.type('nHydraulicEfficiency').variant(variant).position('atEquipment').value(hydraulicPowerW / powerW);
|
||||
}
|
||||
}
|
||||
return host.measurements.type('efficiency').variant(variant).position('atEquipment').getCurrentValue();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calcCog, calcEfficiencyCurve, calcEfficiency, calcDistanceBEP,
|
||||
calcDistanceFromPeak, calcRelativeDistanceFromPeak,
|
||||
getCurrentCurves, getCompleteCurve,
|
||||
};
|
||||
23
src/prediction/groupPredictors.js
Normal file
23
src/prediction/groupPredictors.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const { predict } = require('generalFunctions');
|
||||
|
||||
/**
|
||||
* Build group-scope predicts that share input curves (and splines) with the
|
||||
* individual ones via Predict.shareInputsFrom. They maintain independent
|
||||
* operating-point state so an MGC parent can evaluate every pump curve at
|
||||
* one shared manifold differential without disturbing the pump's own
|
||||
* sensor-driven outputs.
|
||||
*
|
||||
* Returns null when the source predictors are absent (curve load failed).
|
||||
*/
|
||||
function buildGroupPredictors(predictors) {
|
||||
if (!predictors || !predictors.predictFlow || !predictors.predictPower || !predictors.predictCtrl) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
groupPredictFlow: new predict({ shareInputsFrom: predictors.predictFlow }),
|
||||
groupPredictPower: new predict({ shareInputsFrom: predictors.predictPower }),
|
||||
groupPredictCtrl: new predict({ shareInputsFrom: predictors.predictCtrl }),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildGroupPredictors };
|
||||
82
src/prediction/operatingPoint.js
Normal file
82
src/prediction/operatingPoint.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Pure operating-point helper. Centralises the "set the working pressure
|
||||
* and read a derived value" pattern used by both the pump's own pressure
|
||||
* stream and the MGC group-scope evaluation. Does NOT touch the parent
|
||||
* Machine's measurements or pressure-routing — that stays in specificClass.
|
||||
*
|
||||
* `individual` is the {predictFlow, predictPower, predictCtrl} set from
|
||||
* buildPredictors(). `group` is the optional set from buildGroupPredictors()
|
||||
* (may be null when no MGC parent is active).
|
||||
*/
|
||||
class OperatingPoint {
|
||||
constructor(individual, group = null) {
|
||||
this._individual = individual || null;
|
||||
this._group = group || null;
|
||||
this._scope = 'individual';
|
||||
}
|
||||
|
||||
setGroupPredictors(group) {
|
||||
this._group = group || null;
|
||||
}
|
||||
|
||||
useIndividual() {
|
||||
this._scope = 'individual';
|
||||
return this;
|
||||
}
|
||||
|
||||
useGroup() {
|
||||
this._scope = 'group';
|
||||
return this;
|
||||
}
|
||||
|
||||
setIndividual(pressureDiff) {
|
||||
if (!this._individual) return false;
|
||||
if (!Number.isFinite(pressureDiff)) return false;
|
||||
this._individual.predictFlow.fDimension = pressureDiff;
|
||||
this._individual.predictPower.fDimension = pressureDiff;
|
||||
this._individual.predictCtrl.fDimension = pressureDiff;
|
||||
return true;
|
||||
}
|
||||
|
||||
setGroup(pressureDiff) {
|
||||
if (!this._group) return false;
|
||||
if (!Number.isFinite(pressureDiff)) return false;
|
||||
this._group.groupPredictFlow.fDimension = pressureDiff;
|
||||
this._group.groupPredictPower.fDimension = pressureDiff;
|
||||
this._group.groupPredictCtrl.fDimension = pressureDiff;
|
||||
return true;
|
||||
}
|
||||
|
||||
_activeFlow() {
|
||||
return this._scope === 'group' ? this._group?.groupPredictFlow : this._individual?.predictFlow;
|
||||
}
|
||||
_activePower() {
|
||||
return this._scope === 'group' ? this._group?.groupPredictPower : this._individual?.predictPower;
|
||||
}
|
||||
_activeCtrl() {
|
||||
return this._scope === 'group' ? this._group?.groupPredictCtrl : this._individual?.predictCtrl;
|
||||
}
|
||||
|
||||
flowFor(ctrl) {
|
||||
const p = this._activeFlow();
|
||||
if (!p) return null;
|
||||
p.currentX = ctrl;
|
||||
return p.y(ctrl);
|
||||
}
|
||||
|
||||
powerFor(ctrl) {
|
||||
const p = this._activePower();
|
||||
if (!p) return null;
|
||||
p.currentX = ctrl;
|
||||
return p.y(ctrl);
|
||||
}
|
||||
|
||||
ctrlFor(flow) {
|
||||
const p = this._activeCtrl();
|
||||
if (!p) return null;
|
||||
p.currentX = flow;
|
||||
return p.y(flow);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OperatingPoint;
|
||||
71
src/prediction/predictionMath.js
Normal file
71
src/prediction/predictionMath.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Curve-driven prediction math kept as host-aware helpers so the
|
||||
* specificClass orchestrator stays slim. Every helper mirrors a method
|
||||
* from the pre-refactor Machine class one-to-one — behaviour is
|
||||
* preserved verbatim including the "no curve → log + 0" fallback shape
|
||||
* and the operational-state guard.
|
||||
*/
|
||||
|
||||
function calcFlow(host, x) {
|
||||
const u = host.unitPolicy.canonical.flow;
|
||||
if (host.hasCurve) {
|
||||
if (!host._isOperationalState()) {
|
||||
host.measurements.type('flow').variant('predicted').position('downstream').value(0, Date.now(), u);
|
||||
host.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), u);
|
||||
host.logger.debug('Machine is not operational. Setting predicted flow to 0.');
|
||||
return 0;
|
||||
}
|
||||
const cFlow = Math.max(0, host.predictFlow.y(x));
|
||||
host.measurements.type('flow').variant('predicted').position('downstream').value(cFlow, Date.now(), u);
|
||||
host.measurements.type('flow').variant('predicted').position('atEquipment').value(cFlow, Date.now(), u);
|
||||
return cFlow;
|
||||
}
|
||||
host.logger.warn('No curve data available for flow calculation. Returning 0.');
|
||||
host.measurements.type('flow').variant('predicted').position('downstream').value(0, Date.now(), u);
|
||||
host.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), u);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function calcPower(host, x) {
|
||||
const u = host.unitPolicy.canonical.power;
|
||||
if (host.hasCurve) {
|
||||
if (!host._isOperationalState()) {
|
||||
host.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), u);
|
||||
host.logger.debug('Machine is not operational. Setting predicted power to 0.');
|
||||
return 0;
|
||||
}
|
||||
const cPower = Math.max(0, host.predictPower.y(x));
|
||||
host.measurements.type('power').variant('predicted').position('atEquipment').value(cPower, Date.now(), u);
|
||||
return cPower;
|
||||
}
|
||||
host.logger.warn('No curve data available for power calculation. Returning 0.');
|
||||
host.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), u);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function inputFlowCalcPower(host, flow) {
|
||||
if (host.hasCurve) {
|
||||
host.predictCtrl.currentX = flow;
|
||||
const cCtrl = host.predictCtrl.y(flow);
|
||||
host.predictPower.currentX = cCtrl;
|
||||
return host.predictPower.y(cCtrl);
|
||||
}
|
||||
host.logger.warn('No curve data available for power calculation. Returning 0.');
|
||||
host.measurements.type('power').variant('predicted').position('atEquipment')
|
||||
.value(0, Date.now(), host.unitPolicy.canonical.power);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function calcCtrl(host, x) {
|
||||
if (host.hasCurve) {
|
||||
host.predictCtrl.currentX = x;
|
||||
const cCtrl = host.predictCtrl.y(x);
|
||||
host.measurements.type('ctrl').variant('predicted').position('atEquipment').value(cCtrl);
|
||||
return cCtrl;
|
||||
}
|
||||
host.logger.warn('No curve data available for control calculation. Returning 0.');
|
||||
host.measurements.type('ctrl').variant('predicted').position('atEquipment').value(0, Date.now());
|
||||
return 0;
|
||||
}
|
||||
|
||||
module.exports = { calcFlow, calcPower, inputFlowCalcPower, calcCtrl };
|
||||
25
src/prediction/predictors.js
Normal file
25
src/prediction/predictors.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const { predict } = require('generalFunctions');
|
||||
const { reverseCurve } = require('../curves/reverseCurve');
|
||||
|
||||
/**
|
||||
* Build the three individual-scope predict instances that drive a single
|
||||
* pump's flow/power/ctrl outputs from its own pressure measurements.
|
||||
* predictFlow: ctrl -> flow (from machineCurve.nq)
|
||||
* predictPower: ctrl -> power (from machineCurve.np)
|
||||
* predictCtrl: flow -> ctrl (from reversed machineCurve.nq)
|
||||
*
|
||||
* The reverse is built here rather than in the caller so the predictors
|
||||
* folder owns the full "what is needed to predict" knowledge.
|
||||
*/
|
||||
function buildPredictors(machineCurve) {
|
||||
if (!machineCurve || !machineCurve.nq || !machineCurve.np) {
|
||||
throw new Error('buildPredictors: machineCurve.nq and .np are required');
|
||||
}
|
||||
return {
|
||||
predictFlow: new predict({ curve: machineCurve.nq }),
|
||||
predictPower: new predict({ curve: machineCurve.np }),
|
||||
predictCtrl: new predict({ curve: reverseCurve(machineCurve.nq) }),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildPredictors };
|
||||
100
src/pressure/pressureInitialization.js
Normal file
100
src/pressure/pressureInitialization.js
Normal file
@@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* PressureInitialization — tracks real pressure children per position
|
||||
* and reports the overall pressure-input status (initialized, has
|
||||
* differential, preferred source).
|
||||
*
|
||||
* Extracted from rotatingMachine specificClass.getPressureInitializationStatus
|
||||
* + the realPressureChildIds set tracking.
|
||||
*/
|
||||
|
||||
class PressureInitialization {
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* - measurements: MeasurementContainer
|
||||
* - virtualPressureChildIds: { upstream, downstream }
|
||||
* - realPressureChildIds?: { upstream: Set<string>, downstream: Set<string> }
|
||||
* - logger
|
||||
*/
|
||||
constructor(ctx = {}) {
|
||||
this.measurements = ctx.measurements;
|
||||
this.virtualPressureChildIds = ctx.virtualPressureChildIds || {};
|
||||
this.realPressureChildIds = ctx.realPressureChildIds || {
|
||||
upstream: new Set(),
|
||||
downstream: new Set(),
|
||||
};
|
||||
this.logger = ctx.logger || { warn() {}, debug() {} };
|
||||
}
|
||||
|
||||
registerReal(position, childId) {
|
||||
const pos = this._normPosition(position);
|
||||
if (!this.realPressureChildIds[pos]) this.realPressureChildIds[pos] = new Set();
|
||||
this.realPressureChildIds[pos].add(childId);
|
||||
}
|
||||
|
||||
unregisterReal(position, childId) {
|
||||
const pos = this._normPosition(position);
|
||||
if (this.realPressureChildIds[pos]) this.realPressureChildIds[pos].delete(childId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ hasUpstream, hasDownstream, hasDifferential, initialized, source }}
|
||||
* source ∈ 'differential' | 'upstream' | 'downstream' | null.
|
||||
* Matches the original getPressureInitializationStatus() shape.
|
||||
*/
|
||||
getStatus() {
|
||||
const upstream = this._getPreferred('upstream');
|
||||
const downstream = this._getPreferred('downstream');
|
||||
const hasUpstream = upstream != null;
|
||||
const hasDownstream = downstream != null;
|
||||
const hasDifferential = hasUpstream && hasDownstream;
|
||||
|
||||
let source = null;
|
||||
if (hasDifferential) source = 'differential';
|
||||
else if (hasDownstream) source = 'downstream';
|
||||
else if (hasUpstream) source = 'upstream';
|
||||
|
||||
return {
|
||||
hasUpstream,
|
||||
hasDownstream,
|
||||
hasDifferential,
|
||||
initialized: hasUpstream || hasDownstream,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the preferred pressure value at a position. Real children win
|
||||
* over virtual; final fallback is the bare (position-only) container slot.
|
||||
*/
|
||||
getPreferredValue(position) {
|
||||
return this._getPreferred(this._normPosition(position));
|
||||
}
|
||||
|
||||
_getPreferred(position) {
|
||||
const realIds = Array.from(this.realPressureChildIds[position] || []);
|
||||
for (const id of realIds) {
|
||||
const v = this._readChild(position, id);
|
||||
if (v != null) return v;
|
||||
}
|
||||
const virtualId = this.virtualPressureChildIds[position];
|
||||
if (virtualId) {
|
||||
const v = this._readChild(position, virtualId);
|
||||
if (v != null) return v;
|
||||
}
|
||||
return this.measurements
|
||||
?.type('pressure').variant('measured').position(position).getCurrentValue();
|
||||
}
|
||||
|
||||
_readChild(position, childId) {
|
||||
return this.measurements
|
||||
?.type('pressure').variant('measured').position(position).child(childId).getCurrentValue();
|
||||
}
|
||||
|
||||
_normPosition(position) {
|
||||
return String(position || '').toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PressureInitialization;
|
||||
80
src/pressure/pressureRouter.js
Normal file
80
src/pressure/pressureRouter.js
Normal file
@@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* PressureRouter — routes a measured pressure value into the right
|
||||
* MeasurementContainer slot and triggers downstream side-effects
|
||||
* (position recompute + drift/health refresh) only when the source
|
||||
* is a real child (not a dashboard-sim virtual one).
|
||||
*
|
||||
* Extracted from rotatingMachine specificClass.updateMeasuredPressure.
|
||||
*/
|
||||
|
||||
class PressureRouter {
|
||||
/**
|
||||
* @param {object} ctx
|
||||
* - measurements: MeasurementContainer
|
||||
* - virtualPressureChildIds: { upstream, downstream }
|
||||
* - resolveMeasurementUnit(type, unit) -> canonical unit string (throws on invalid)
|
||||
* - updatePosition?(): called after a real-source write
|
||||
* - refreshDrift?(): called after a real-source write (e.g. _updatePressureDriftStatus)
|
||||
* - refreshHealth?(): called after a real-source write (e.g. _updatePredictionHealth)
|
||||
* - getPressure?(): optional, returns the current preferred pressure (for logging)
|
||||
* - logger
|
||||
*/
|
||||
constructor(ctx = {}) {
|
||||
this.measurements = ctx.measurements;
|
||||
this.virtualPressureChildIds = ctx.virtualPressureChildIds || {};
|
||||
this.resolveMeasurementUnit = ctx.resolveMeasurementUnit || ((_t, u) => u);
|
||||
this.updatePosition = ctx.updatePosition;
|
||||
this.refreshDrift = ctx.refreshDrift;
|
||||
this.refreshHealth = ctx.refreshHealth;
|
||||
this.getPressure = ctx.getPressure;
|
||||
this.logger = ctx.logger || { warn() {}, debug() {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a measured pressure to the right container slot.
|
||||
* @returns {boolean} true on successful write, false on rejection.
|
||||
*/
|
||||
route(position, value, context = {}) {
|
||||
const pos = String(position || '').toLowerCase();
|
||||
const childId = context.childId;
|
||||
let unit;
|
||||
try {
|
||||
unit = this.resolveMeasurementUnit('pressure', context.unit);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Rejected pressure update: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.measurements
|
||||
?.type('pressure').variant('measured').position(pos).child(childId)
|
||||
.value(value, context.timestamp, unit);
|
||||
|
||||
const isVirtual = this._isVirtual(childId);
|
||||
this.logger.debug(`Pressure routed: ${value} ${unit} at ${pos} from ${context.childName || 'child'} (${childId || 'unknown-id'}) virtual=${isVirtual}`);
|
||||
|
||||
if (!isVirtual) {
|
||||
if (typeof this.updatePosition === 'function') this.updatePosition();
|
||||
if (typeof this.refreshDrift === 'function') this.refreshDrift();
|
||||
if (typeof this.refreshHealth === 'function') this.refreshHealth();
|
||||
}
|
||||
|
||||
if (typeof this.getPressure === 'function') {
|
||||
const p = this.getPressure();
|
||||
this.logger.debug(`Using pressure: ${p} for calculations`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_isVirtual(childId) {
|
||||
if (childId == null) return false;
|
||||
for (const id of Object.values(this.virtualPressureChildIds)) {
|
||||
if (id === childId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PressureRouter;
|
||||
52
src/pressure/pressureSelector.js
Normal file
52
src/pressure/pressureSelector.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Resolves the working pressure for prediction and pushes it onto
|
||||
* predictFlow/predictPower/predictCtrl.fDimension. After every push the
|
||||
* CoG, efficiency, and distance-from-BEP are recomputed so downstream
|
||||
* state stays consistent — exactly what the pre-refactor
|
||||
* getMeasuredPressure() did.
|
||||
*/
|
||||
|
||||
const eff = require('../prediction/efficiencyMath');
|
||||
|
||||
function getMeasuredPressure(host) {
|
||||
if (!host.hasCurve || !host.predictFlow || !host.predictPower || !host.predictCtrl) {
|
||||
host.logger.error('No valid curve available to calculate prediction using last known pressure');
|
||||
return 0;
|
||||
}
|
||||
const up = host._getPreferredPressureValue('upstream');
|
||||
const dn = host._getPreferredPressureValue('downstream');
|
||||
|
||||
const applyDiff = (diff) => {
|
||||
host.predictFlow.fDimension = diff;
|
||||
host.predictPower.fDimension = diff;
|
||||
host.predictCtrl.fDimension = diff;
|
||||
const { cog, minEfficiency } = eff.calcCog(host);
|
||||
const efficiency = eff.calcEfficiency(host, host.predictPower.outputY, host.predictFlow.outputY, 'predicted');
|
||||
eff.calcDistanceBEP(host, efficiency, cog, minEfficiency);
|
||||
};
|
||||
|
||||
if (up != null && dn != null) {
|
||||
const diff = dn - up;
|
||||
host.logger.debug(`Pressure differential: ${diff}`);
|
||||
applyDiff(diff);
|
||||
return diff;
|
||||
}
|
||||
if (dn != null) {
|
||||
host.logger.warn(`Using downstream pressure only for prediction: ${dn}. Prediction accuracy is degraded; inject upstream pressure too.`);
|
||||
applyDiff(dn);
|
||||
return dn;
|
||||
}
|
||||
if (up != null) {
|
||||
host.logger.warn(`Using upstream pressure only for prediction: ${up}. Prediction accuracy is degraded; inject downstream pressure too.`);
|
||||
applyDiff(up);
|
||||
return up;
|
||||
}
|
||||
host.logger.error('No valid pressure measurements available to calculate prediction using last known pressure');
|
||||
applyDiff(0);
|
||||
const fu = host.unitPolicy.canonical.flow;
|
||||
host.measurements.type('flow').variant('predicted').position('max').value(host.predictFlow.currentFxyYMax, Date.now(), fu);
|
||||
host.measurements.type('flow').variant('predicted').position('min').value(host.predictFlow.currentFxyYMin, Date.now(), fu);
|
||||
return 0;
|
||||
}
|
||||
|
||||
module.exports = { getMeasuredPressure };
|
||||
92
src/pressure/virtualChildren.js
Normal file
92
src/pressure/virtualChildren.js
Normal file
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
const { MeasurementContainer } = require('generalFunctions');
|
||||
|
||||
/**
|
||||
* VirtualPressureChildren — builds two dashboard-sim children backed
|
||||
* by their own MeasurementContainer (upstream + downstream). Children
|
||||
* are signed as belonging to a parent machine via `setParentRef`.
|
||||
*
|
||||
* Extracted from rotatingMachine specificClass._initVirtualPressureChildren.
|
||||
*/
|
||||
|
||||
const DEFAULT_IDS = {
|
||||
upstream: 'dashboard-sim-upstream',
|
||||
downstream: 'dashboard-sim-downstream',
|
||||
};
|
||||
|
||||
class VirtualPressureChildren {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* - logger: pass-through to MeasurementContainer
|
||||
* - unitPolicy: { canonical, output }
|
||||
* - parentRef: object to use as parent for setParentRef (optional)
|
||||
* - ids: override the default { upstream, downstream } id pair (optional)
|
||||
*/
|
||||
constructor({ logger, unitPolicy, parentRef = null, ids = DEFAULT_IDS } = {}) {
|
||||
this.logger = logger || { warn() {}, debug() {} };
|
||||
this.unitPolicy = unitPolicy;
|
||||
this.parentRef = parentRef;
|
||||
this.ids = { ...DEFAULT_IDS, ...(ids || {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ upstream: VirtualChild, downstream: VirtualChild }}
|
||||
* Each child = { config: { general, functionality, asset }, measurements }.
|
||||
*/
|
||||
build() {
|
||||
return {
|
||||
upstream: this._createChild('upstream'),
|
||||
downstream: this._createChild('downstream'),
|
||||
};
|
||||
}
|
||||
|
||||
_createChild(position) {
|
||||
const id = this.ids[position];
|
||||
const name = `dashboard-sim-${position}`;
|
||||
const measurements = new MeasurementContainer({
|
||||
autoConvert: true,
|
||||
defaultUnits: this._unitMap('output'),
|
||||
preferredUnits: this._unitMap('output'),
|
||||
canonicalUnits: this.unitPolicy?.canonical,
|
||||
storeCanonical: true,
|
||||
strictUnitValidation: true,
|
||||
throwOnInvalidUnit: true,
|
||||
requireUnitForTypes: ['pressure'],
|
||||
}, this.logger);
|
||||
|
||||
if (typeof measurements.setChildId === 'function') measurements.setChildId(id);
|
||||
if (typeof measurements.setChildName === 'function') measurements.setChildName(name);
|
||||
if (this.parentRef && typeof measurements.setParentRef === 'function') {
|
||||
measurements.setParentRef(this.parentRef);
|
||||
}
|
||||
|
||||
return {
|
||||
config: {
|
||||
general: { id, name },
|
||||
functionality: {
|
||||
softwareType: 'measurement',
|
||||
positionVsParent: position,
|
||||
},
|
||||
asset: {
|
||||
type: 'pressure',
|
||||
unit: this.unitPolicy?.output?.pressure,
|
||||
},
|
||||
},
|
||||
measurements,
|
||||
};
|
||||
}
|
||||
|
||||
_unitMap(section) {
|
||||
const src = this.unitPolicy?.[section] || {};
|
||||
return {
|
||||
pressure: src.pressure,
|
||||
flow: src.flow,
|
||||
power: src.power,
|
||||
temperature: src.temperature,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
VirtualPressureChildren.DEFAULT_IDS = DEFAULT_IDS;
|
||||
module.exports = VirtualPressureChildren;
|
||||
1795
src/specificClass.js
1795
src/specificClass.js
File diff suppressed because it is too large
Load Diff
86
src/state/sequenceController.js
Normal file
86
src/state/sequenceController.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Sequence + setpoint orchestration. Pre-refactor lived inline on
|
||||
* Machine; extracted so the orchestrator stays focused. All behaviour
|
||||
* is preserved verbatim including the interruptible-shutdown abort
|
||||
* dance and the operational-state ramp-to-zero before shutdown.
|
||||
*/
|
||||
|
||||
function resolveSetpointBounds(host) {
|
||||
const stateMin = Number(host.state?.movementManager?.minPosition);
|
||||
const stateMax = Number(host.state?.movementManager?.maxPosition);
|
||||
const curveMin = Number(host.predictFlow?.currentFxyXMin);
|
||||
const curveMax = Number(host.predictFlow?.currentFxyXMax);
|
||||
const minCands = [stateMin, curveMin].filter(Number.isFinite);
|
||||
const maxCands = [stateMax, curveMax].filter(Number.isFinite);
|
||||
const fbMin = Number.isFinite(stateMin) ? stateMin : 0;
|
||||
const fbMax = Number.isFinite(stateMax) ? stateMax : 100;
|
||||
let min = minCands.length ? Math.max(...minCands) : fbMin;
|
||||
let max = maxCands.length ? Math.min(...maxCands) : fbMax;
|
||||
if (min > max) {
|
||||
host.logger.warn(`Invalid setpoint bounds detected (min=${min}, max=${max}). Falling back to movement bounds.`);
|
||||
min = fbMin; max = fbMax;
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
async function setpoint(host, target) {
|
||||
try {
|
||||
if (!Number.isFinite(target)) { host.logger.error('Invalid setpoint: Setpoint must be a finite number.'); return; }
|
||||
const { min, max } = resolveSetpointBounds(host);
|
||||
const constrained = Math.min(Math.max(target, min), max);
|
||||
if (constrained !== target) host.logger.warn(`Requested setpoint ${target} constrained to ${constrained} (min=${min}, max=${max})`);
|
||||
host.logger.info(`Setting setpoint to ${constrained}. Current position: ${host.state.getCurrentPosition()}`);
|
||||
await host.state.moveTo(constrained);
|
||||
} catch (e) { host.logger.error(`Error setting setpoint: ${e}`); }
|
||||
}
|
||||
|
||||
function waitForOperational(host, timeoutMs = 2000) {
|
||||
if (host.state.getCurrentState() === 'operational') return Promise.resolve('operational');
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
host.state.emitter.off('stateChange', onChange);
|
||||
resolve(host.state.getCurrentState());
|
||||
}, timeoutMs);
|
||||
const onChange = (newState) => {
|
||||
if (done) return;
|
||||
if (newState === 'operational') {
|
||||
done = true; clearTimeout(timer);
|
||||
host.state.emitter.off('stateChange', onChange);
|
||||
resolve('operational');
|
||||
}
|
||||
};
|
||||
host.state.emitter.on('stateChange', onChange);
|
||||
});
|
||||
}
|
||||
|
||||
async function executeSequence(host, rawName) {
|
||||
const name = typeof rawName === 'string' ? rawName.toLowerCase() : rawName;
|
||||
const sequence = host.config.sequences[name];
|
||||
if (!sequence || sequence.size === 0) {
|
||||
host.logger.warn(`Sequence '${name}' not defined.`);
|
||||
return;
|
||||
}
|
||||
const interruptible = new Set(['shutdown', 'emergencystop']);
|
||||
if (interruptible.has(name)) host.state.delayedMove = null;
|
||||
const current = host.state.getCurrentState();
|
||||
if (interruptible.has(name) && (current === 'accelerating' || current === 'decelerating')) {
|
||||
host.logger.warn(`Sequence '${name}' requested during '${current}'. Aborting active movement.`);
|
||||
host.state.abortCurrentMovement(`${name} sequence requested`, { returnToOperational: true });
|
||||
await waitForOperational(host, 2000);
|
||||
}
|
||||
if (host.state.getCurrentState() === 'operational' && name === 'shutdown') {
|
||||
host.logger.info(`Machine will ramp down to position 0 before performing ${name} sequence`);
|
||||
await setpoint(host, 0);
|
||||
}
|
||||
host.logger.info(` --------- Executing sequence: ${name} -------------`);
|
||||
for (const s of sequence) {
|
||||
try { await host.state.transitionToState(s); }
|
||||
catch (e) { host.logger.error(`Error during sequence '${name}': ${e}`); break; }
|
||||
}
|
||||
host.updatePosition();
|
||||
}
|
||||
|
||||
module.exports = { setpoint, executeSequence, resolveSetpointBounds, waitForOperational };
|
||||
58
src/state/stateBindings.js
Normal file
58
src/state/stateBindings.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Thin adapter over the generalFunctions state machine emitter.
|
||||
* Holds no state of its own — exposes bind/unbind and the
|
||||
* shared definition of which states count as "operational" for
|
||||
* downstream measurement processing.
|
||||
*/
|
||||
|
||||
const OPERATIONAL_STATES = [
|
||||
'operational',
|
||||
'accelerating',
|
||||
'decelerating',
|
||||
'warmingup',
|
||||
];
|
||||
|
||||
/**
|
||||
* Attaches positionChange / stateChange listeners to a state machine.
|
||||
* Returns an idempotent teardown function. Both handlers are required —
|
||||
* the bindings encode the lifecycle contract between the FSM and the
|
||||
* specificClass orchestrator, so leaving one half wired is a bug.
|
||||
*/
|
||||
function bindStateEvents(ctx) {
|
||||
if (!ctx || !ctx.state || !ctx.state.emitter) {
|
||||
throw new Error('bindStateEvents: ctx.state.emitter is required');
|
||||
}
|
||||
const { state, onPositionChange, onStateChange } = ctx;
|
||||
if (typeof onPositionChange !== 'function' || typeof onStateChange !== 'function') {
|
||||
throw new Error('bindStateEvents: onPositionChange and onStateChange handlers are required');
|
||||
}
|
||||
|
||||
state.emitter.on('positionChange', onPositionChange);
|
||||
state.emitter.on('stateChange', onStateChange);
|
||||
|
||||
let removed = false;
|
||||
return function teardown() {
|
||||
if (removed) return;
|
||||
removed = true;
|
||||
state.emitter.off('positionChange', onPositionChange);
|
||||
state.emitter.off('stateChange', onStateChange);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the FSM is in a state that should accept measurement
|
||||
* updates and recompute predictions. Pure helper, accepts the state
|
||||
* machine instance so callers can pass a fake in tests.
|
||||
*/
|
||||
function isOperationalState(stateInstance) {
|
||||
if (!stateInstance || typeof stateInstance.getCurrentState !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return OPERATIONAL_STATES.includes(stateInstance.getCurrentState());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bindStateEvents,
|
||||
isOperationalState,
|
||||
OPERATIONAL_STATES,
|
||||
};
|
||||
275
test/basic/commands.basic.test.js
Normal file
275
test/basic/commands.basic.test.js
Normal file
@@ -0,0 +1,275 @@
|
||||
// Basic tests for the rotatingMachine commands registry.
|
||||
// Run with: node --test test/basic/commands.basic.test.js
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { createRegistry } = require('generalFunctions');
|
||||
const commands = require('../../src/commands');
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
function makeLogger() {
|
||||
const calls = { warn: [], error: [], info: [], debug: [] };
|
||||
return {
|
||||
calls,
|
||||
warn: (m) => calls.warn.push(String(m)),
|
||||
error: (m) => calls.error.push(String(m)),
|
||||
info: (m) => calls.info.push(String(m)),
|
||||
debug: (m) => calls.debug.push(String(m)),
|
||||
};
|
||||
}
|
||||
|
||||
function makeSource({ name = 'rm-1', unitValid = true } = {}) {
|
||||
const calls = {
|
||||
setMode: [],
|
||||
handleInput: [],
|
||||
registerChild: [],
|
||||
sim: [],
|
||||
updatePressure: [],
|
||||
updateFlow: [],
|
||||
updateTemp: [],
|
||||
updatePower: [],
|
||||
showWorkingCurves: 0,
|
||||
showCoG: 0,
|
||||
};
|
||||
const source = {
|
||||
logger: makeLogger(),
|
||||
config: { general: { name } },
|
||||
setMode: (m) => calls.setMode.push(m),
|
||||
handleInput: async (src, action, parameter) => {
|
||||
calls.handleInput.push({ src, action, parameter });
|
||||
},
|
||||
isUnitValidForType: () => unitValid,
|
||||
updateSimulatedMeasurement: (type, position, value, ctx) =>
|
||||
calls.sim.push({ type, position, value, ctx }),
|
||||
updateMeasuredPressure: (v, p, c) => calls.updatePressure.push({ v, p, c }),
|
||||
updateMeasuredFlow: (v, p, c) => calls.updateFlow.push({ v, p, c }),
|
||||
updateMeasuredTemperature: (v, p, c) => calls.updateTemp.push({ v, p, c }),
|
||||
updateMeasuredPower: (v, p, c) => calls.updatePower.push({ v, p, c }),
|
||||
showWorkingCurves: () => { calls.showWorkingCurves++; return { curves: 'mock' }; },
|
||||
showCoG: () => { calls.showCoG++; return { cog: 'mock' }; },
|
||||
childRegistrationUtils: {
|
||||
registerChild: (childSource, position) =>
|
||||
calls.registerChild.push({ childSource, position }),
|
||||
},
|
||||
};
|
||||
return { source, calls };
|
||||
}
|
||||
|
||||
function makeCtx({ child = null, logger = makeLogger(), sendSpy = null } = {}) {
|
||||
return {
|
||||
logger,
|
||||
RED: { nodes: { getNode: (id) => (child && child.id === id ? child : undefined) } },
|
||||
node: {},
|
||||
send: sendSpy || (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRegistry(logger) {
|
||||
return createRegistry(commands, { logger });
|
||||
}
|
||||
|
||||
// --- tests -----------------------------------------------------------------
|
||||
|
||||
test('canonical topics dispatch to their handlers', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.mode', payload: 'GUI' }, source, makeCtx());
|
||||
assert.deepEqual(calls.setMode, ['GUI']);
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'cmd.startup', payload: { source: 'GUI' } }, source, makeCtx());
|
||||
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execSequence', parameter: 'startup' });
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'cmd.shutdown', payload: { source: 'GUI' } }, source, makeCtx());
|
||||
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execSequence', parameter: 'shutdown' });
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'cmd.estop', payload: { source: 'GUI', action: 'emergencystop' } }, source, makeCtx());
|
||||
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'emergencystop', parameter: undefined });
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'set.setpoint', payload: { source: 'GUI', action: 'execMovement', setpoint: '75' } },
|
||||
source, makeCtx());
|
||||
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'execMovement', parameter: 75 });
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'set.flow-setpoint', payload: { source: 'GUI', action: 'flowMovement', setpoint: '12' } },
|
||||
source, makeCtx());
|
||||
assert.deepEqual(calls.handleInput.at(-1), { src: 'GUI', action: 'flowMovement', parameter: 12 });
|
||||
});
|
||||
|
||||
test('aliases dispatch to the same handler and log a one-time deprecation', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(ctxLogger);
|
||||
|
||||
await reg.dispatch({ topic: 'setMode', payload: 'GUI' }, source, makeCtx({ logger: ctxLogger }));
|
||||
await reg.dispatch({ topic: 'setMode', payload: 'virtualControl' }, source, makeCtx({ logger: ctxLogger }));
|
||||
assert.deepEqual(calls.setMode, ['GUI', 'virtualControl']);
|
||||
let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated"));
|
||||
assert.equal(warns.length, 1);
|
||||
|
||||
await reg.dispatch({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
warns = ctxLogger.calls.warn.filter((m) => m.includes("'emergencystop' is deprecated"));
|
||||
assert.equal(warns.length, 1);
|
||||
|
||||
await reg.dispatch({ topic: 'execMovement', payload: { source: 'GUI', action: 'execMovement', setpoint: 50 } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
warns = ctxLogger.calls.warn.filter((m) => m.includes("'execMovement' is deprecated"));
|
||||
assert.equal(warns.length, 1);
|
||||
|
||||
await reg.dispatch({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 5 } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
warns = ctxLogger.calls.warn.filter((m) => m.includes("'flowMovement' is deprecated"));
|
||||
assert.equal(warns.length, 1);
|
||||
});
|
||||
|
||||
test('execSequence with payload.action=startup reaches cmd.startup handler', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(ctxLogger);
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
|
||||
assert.equal(calls.handleInput.length, 1);
|
||||
assert.deepEqual(calls.handleInput[0], { src: 'GUI', action: 'execSequence', parameter: 'startup' });
|
||||
// Registry logs the legacy-topic deprecation (no canonical alias, but
|
||||
// the demux handler accepts both startup/shutdown actions).
|
||||
});
|
||||
|
||||
test('execSequence with payload.action=shutdown reaches cmd.shutdown handler', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'execSequence', payload: { source: 'GUI', action: 'shutdown' } },
|
||||
source, makeCtx());
|
||||
|
||||
assert.equal(calls.handleInput.length, 1);
|
||||
assert.deepEqual(calls.handleInput[0], { src: 'GUI', action: 'execSequence', parameter: 'shutdown' });
|
||||
});
|
||||
|
||||
test('execSequence with unknown action logs warn and does not call handleInput', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'execSequence', payload: { source: 'GUI', action: 'frobnicate' } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
assert.equal(calls.handleInput.length, 0);
|
||||
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('execSequence') && m.includes('frobnicate')),
|
||||
`expected warn, got: ${JSON.stringify(ctxLogger.calls.warn)}`);
|
||||
});
|
||||
|
||||
test('data.simulate-measurement happy path dispatches to the right updater', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'data.simulate-measurement',
|
||||
payload: { type: 'pressure', position: 'upstream', value: 1013, unit: 'mbar' } },
|
||||
source, makeCtx());
|
||||
assert.equal(calls.sim.length, 1);
|
||||
assert.equal(calls.sim[0].type, 'pressure');
|
||||
assert.equal(calls.sim[0].value, 1013);
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'data.simulate-measurement',
|
||||
payload: { type: 'flow', value: 30, unit: 'm3/h' } },
|
||||
source, makeCtx());
|
||||
assert.equal(calls.updateFlow.length, 1);
|
||||
});
|
||||
|
||||
test('data.simulate-measurement validation: bad type / missing unit / non-finite value', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
// unsupported type
|
||||
await reg.dispatch(
|
||||
{ topic: 'data.simulate-measurement', payload: { type: 'voltage', value: 1, unit: 'V' } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('Unsupported simulateMeasurement type: voltage')));
|
||||
|
||||
// missing unit
|
||||
await reg.dispatch(
|
||||
{ topic: 'data.simulate-measurement', payload: { type: 'pressure', value: 1013 } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('unit is required')));
|
||||
|
||||
// non-finite value
|
||||
await reg.dispatch(
|
||||
{ topic: 'data.simulate-measurement', payload: { type: 'pressure', value: 'abc', unit: 'mbar' } },
|
||||
source, makeCtx({ logger: ctxLogger }));
|
||||
assert.ok(ctxLogger.calls.warn.some((m) => m.includes('must be a finite number')));
|
||||
|
||||
// nothing was forwarded to the source
|
||||
assert.equal(calls.sim.length, 0);
|
||||
assert.equal(calls.updateFlow.length, 0);
|
||||
assert.equal(calls.updatePressure.length, 0);
|
||||
});
|
||||
|
||||
test('query.curves and query.cog reply on Port 0 via ctx.send', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const sent = [];
|
||||
const ctx = makeCtx({ sendSpy: (ports) => sent.push(ports) });
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'query.curves' }, source, ctx);
|
||||
await reg.dispatch({ topic: 'query.cog' }, source, ctx);
|
||||
|
||||
assert.equal(calls.showWorkingCurves, 1);
|
||||
assert.equal(calls.showCoG, 1);
|
||||
assert.equal(sent.length, 2);
|
||||
// First port carries the reply; Ports 1 & 2 are null.
|
||||
assert.equal(sent[0][0].topic, 'showWorkingCurves');
|
||||
assert.deepEqual(sent[0][0].payload, { curves: 'mock' });
|
||||
assert.equal(sent[0][1], null);
|
||||
assert.equal(sent[0][2], null);
|
||||
assert.equal(sent[1][0].topic, 'showCoG');
|
||||
assert.deepEqual(sent[1][0].payload, { cog: 'mock' });
|
||||
});
|
||||
|
||||
test('child.register canonical resolves child via RED.nodes.getNode', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const child = { id: 'm-1', source: { tag: 'm-domain' } };
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'child.register', payload: 'm-1', positionVsParent: 'upstream' },
|
||||
source,
|
||||
makeCtx({ child })
|
||||
);
|
||||
assert.equal(calls.registerChild.length, 1);
|
||||
assert.equal(calls.registerChild[0].childSource, child.source);
|
||||
assert.equal(calls.registerChild[0].position, 'upstream');
|
||||
});
|
||||
|
||||
test('child.register with unknown id logs warn and does not throw', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await assert.doesNotReject(() =>
|
||||
reg.dispatch(
|
||||
{ topic: 'child.register', payload: 'missing-id', positionVsParent: 'atEquipment' },
|
||||
source,
|
||||
makeCtx({ logger: ctxLogger })
|
||||
)
|
||||
);
|
||||
assert.equal(calls.registerChild.length, 0);
|
||||
assert.ok(
|
||||
ctxLogger.calls.warn.some((m) => m.includes('registerChild') && m.includes('missing-id')),
|
||||
`expected warn about missing child, got: ${JSON.stringify(ctxLogger.calls.warn)}`
|
||||
);
|
||||
});
|
||||
30
test/basic/curveLoader.basic.test.js
Normal file
30
test/basic/curveLoader.basic.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { loadModelCurve } = require('../../src/curves/curveLoader');
|
||||
|
||||
test('curveLoader: valid model returns rawCurve and null error', () => {
|
||||
const result = loadModelCurve('hidrostal-H05K-S03R');
|
||||
assert.equal(result.error, null);
|
||||
assert.ok(result.rawCurve);
|
||||
assert.ok(result.rawCurve.np);
|
||||
assert.ok(result.rawCurve.nq);
|
||||
});
|
||||
|
||||
test('curveLoader: missing model returns Model not specified', () => {
|
||||
const result = loadModelCurve('');
|
||||
assert.equal(result.rawCurve, null);
|
||||
assert.equal(result.error, 'Model not specified');
|
||||
});
|
||||
|
||||
test('curveLoader: undefined model returns Model not specified', () => {
|
||||
const result = loadModelCurve(undefined);
|
||||
assert.equal(result.rawCurve, null);
|
||||
assert.equal(result.error, 'Model not specified');
|
||||
});
|
||||
|
||||
test('curveLoader: unknown model returns Curve not found error', () => {
|
||||
const result = loadModelCurve('this-model-does-not-exist');
|
||||
assert.equal(result.rawCurve, null);
|
||||
assert.match(result.error, /Curve not found for model/);
|
||||
});
|
||||
88
test/basic/curveNormalizer.basic.test.js
Normal file
88
test/basic/curveNormalizer.basic.test.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { UnitPolicy } = require('generalFunctions');
|
||||
const {
|
||||
normalizeMachineCurve,
|
||||
normalizeCurveSection,
|
||||
convertUnitValue,
|
||||
} = require('../../src/curves/curveNormalizer');
|
||||
|
||||
function makePolicy() {
|
||||
return UnitPolicy.declare({
|
||||
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
|
||||
output: { pressure: 'mbar', flow: 'm3/h', power: 'kW', temperature: 'C' },
|
||||
curve: { pressure: 'mbar', flow: 'm3/h', power: 'kW', control: '%' },
|
||||
});
|
||||
}
|
||||
|
||||
function captureLogger() {
|
||||
const warns = [];
|
||||
return {
|
||||
warn: (m) => warns.push(m),
|
||||
warns,
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizeMachineCurve: rejects raw without nq/np', () => {
|
||||
const policy = makePolicy();
|
||||
assert.throws(() => normalizeMachineCurve(null, policy), /missing required nq\/np/);
|
||||
assert.throws(() => normalizeMachineCurve({ nq: { 700: { x: [0], y: [0] } } }, policy), /missing required nq\/np/);
|
||||
assert.throws(() => normalizeMachineCurve({ np: { 700: { x: [0], y: [0] } } }, policy), /missing required nq\/np/);
|
||||
});
|
||||
|
||||
test('normalizeMachineCurve: converts pressure mbar -> Pa and flow m3/h -> m3/s', () => {
|
||||
const policy = makePolicy();
|
||||
const raw = {
|
||||
nq: {
|
||||
1000: { x: [0, 100], y: [0, 3600] }, // 3600 m3/h = 1 m3/s
|
||||
},
|
||||
np: {
|
||||
1000: { x: [0, 100], y: [0, 1] }, // 1 kW = 1000 W
|
||||
},
|
||||
};
|
||||
const out = normalizeMachineCurve(raw, policy);
|
||||
// 1000 mbar = 100000 Pa
|
||||
const pressureKey = Object.keys(out.nq)[0];
|
||||
assert.equal(Number(pressureKey), 100000);
|
||||
assert.ok(Math.abs(out.nq[pressureKey].y[1] - 1) < 1e-9, `expected 1 m3/s got ${out.nq[pressureKey].y[1]}`);
|
||||
assert.ok(Math.abs(out.np[pressureKey].y[1] - 1000) < 1e-6, `expected 1000 W got ${out.np[pressureKey].y[1]}`);
|
||||
});
|
||||
|
||||
test('normalizeCurveSection: warns on cross-pressure median > 3x jump', () => {
|
||||
const logger = captureLogger();
|
||||
const section = {
|
||||
1000: { x: [0, 50, 100], y: [0, 5, 10] }, // median 5
|
||||
1100: { x: [0, 50, 100], y: [0, 50, 100] }, // median 50 (10x jump)
|
||||
};
|
||||
normalizeCurveSection(section, 'm3/h', 'm3/h', 'mbar', 'mbar', 'nq', logger);
|
||||
const hit = logger.warns.find((w) => /Curve anomaly/.test(w));
|
||||
assert.ok(hit, `expected a Curve anomaly warning, got: ${JSON.stringify(logger.warns)}`);
|
||||
assert.match(hit, /pressure 1100/);
|
||||
});
|
||||
|
||||
test('normalizeCurveSection: does not warn on smooth progressions', () => {
|
||||
const logger = captureLogger();
|
||||
const section = {
|
||||
1000: { x: [0, 50, 100], y: [0, 5, 10] },
|
||||
1100: { x: [0, 50, 100], y: [0, 6, 11] },
|
||||
};
|
||||
normalizeCurveSection(section, 'm3/h', 'm3/h', 'mbar', 'mbar', 'nq', logger);
|
||||
assert.equal(logger.warns.filter((w) => /Curve anomaly/.test(w)).length, 0);
|
||||
});
|
||||
|
||||
test('normalizeCurveSection: throws when x/y length mismatch', () => {
|
||||
assert.throws(
|
||||
() => normalizeCurveSection({ 1000: { x: [0, 50], y: [0, 5, 10] } }, 'm3/h', 'm3/s', 'mbar', 'Pa', 'nq', null),
|
||||
/Invalid nq section/
|
||||
);
|
||||
});
|
||||
|
||||
test('convertUnitValue: identity when units match or missing', () => {
|
||||
assert.equal(convertUnitValue(42, 'm3/h', 'm3/h'), 42);
|
||||
assert.equal(convertUnitValue(42, null, null), 42);
|
||||
});
|
||||
|
||||
test('convertUnitValue: throws on non-finite input', () => {
|
||||
assert.throws(() => convertUnitValue('not-a-number', 'm3/h', 'm3/s', 'test'), /not finite/);
|
||||
});
|
||||
130
test/basic/driftAssessor.basic.test.js
Normal file
130
test/basic/driftAssessor.basic.test.js
Normal file
@@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const DriftAssessor = require('../../src/drift/driftAssessor');
|
||||
|
||||
/* ---- fakes ---- */
|
||||
function fakeMeasurements(predictedValue) {
|
||||
return {
|
||||
type() { return this; },
|
||||
variant() { return this; },
|
||||
position() { return this; },
|
||||
getCurrentValue() { return predictedValue; },
|
||||
getAllValues() { return { values: [predictedValue], timestamps: [1] }; },
|
||||
};
|
||||
}
|
||||
|
||||
function makeErrorMetrics(driftFactory) {
|
||||
return {
|
||||
assessPoint: (metricId, predicted, measured, opts) => driftFactory(metricId, predicted, measured, opts),
|
||||
assessDrift: () => ({ nrmse: 0.1, valid: true }),
|
||||
};
|
||||
}
|
||||
|
||||
const SILENT = { warn() {}, debug() {} };
|
||||
|
||||
test('updateMetricDrift returns drift object when predicted+measured both finite', () => {
|
||||
const drift = { valid: true, nrmse: 0.05, immediateLevel: 0, longTermLevel: 0 };
|
||||
const assessor = new DriftAssessor({
|
||||
errorMetrics: makeErrorMetrics(() => drift),
|
||||
measurements: fakeMeasurements(10),
|
||||
driftProfiles: { flow: {} },
|
||||
logger: SILENT,
|
||||
});
|
||||
|
||||
const out = assessor.updateMetricDrift('flow', 11);
|
||||
assert.deepEqual(out, drift);
|
||||
assert.equal(assessor.latest.flow, drift);
|
||||
});
|
||||
|
||||
test('updateMetricDrift returns null when predicted is non-finite', () => {
|
||||
const assessor = new DriftAssessor({
|
||||
errorMetrics: makeErrorMetrics(() => ({ valid: true })),
|
||||
measurements: fakeMeasurements(NaN),
|
||||
driftProfiles: {},
|
||||
logger: SILENT,
|
||||
});
|
||||
assert.equal(assessor.updateMetricDrift('flow', 5), null);
|
||||
});
|
||||
|
||||
test('updateMetricDrift catches errorMetrics throw and logs', () => {
|
||||
const warns = [];
|
||||
const assessor = new DriftAssessor({
|
||||
errorMetrics: { assessPoint() { throw new Error('boom'); } },
|
||||
measurements: fakeMeasurements(10),
|
||||
driftProfiles: {},
|
||||
logger: { warn(m) { warns.push(m); }, debug() {} },
|
||||
});
|
||||
const out = assessor.updateMetricDrift('flow', 11);
|
||||
assert.equal(out, null);
|
||||
assert.match(warns[0], /Drift update failed for metric 'flow'/);
|
||||
});
|
||||
|
||||
test('applyDriftPenalty leaves confidence unchanged for null/invalid drift', () => {
|
||||
const assessor = new DriftAssessor({ logger: SILENT });
|
||||
const flags = [];
|
||||
assert.equal(assessor.applyDriftPenalty(null, 0.9, flags, 'flow'), 0.9);
|
||||
assert.equal(assessor.applyDriftPenalty({ valid: false }, 0.9, flags, 'flow'), 0.9);
|
||||
assert.deepEqual(flags, []);
|
||||
});
|
||||
|
||||
test('applyDriftPenalty level 1 reduces confidence by 0.1 + flag', () => {
|
||||
const assessor = new DriftAssessor({ logger: SILENT });
|
||||
const flags = [];
|
||||
const c = assessor.applyDriftPenalty(
|
||||
{ valid: true, nrmse: 0.1, immediateLevel: 1, longTermLevel: 0 },
|
||||
0.9, flags, 'flow',
|
||||
);
|
||||
assert.ok(Math.abs(c - 0.8) < 1e-9);
|
||||
assert.deepEqual(flags, ['flow_low_immediate_drift']);
|
||||
});
|
||||
|
||||
test('applyDriftPenalty level 2 reduces confidence by 0.2 + flag', () => {
|
||||
const assessor = new DriftAssessor({ logger: SILENT });
|
||||
const flags = [];
|
||||
const c = assessor.applyDriftPenalty(
|
||||
{ valid: true, nrmse: 0.2, immediateLevel: 2, longTermLevel: 0 },
|
||||
0.9, flags, 'power',
|
||||
);
|
||||
assert.ok(Math.abs(c - 0.7) < 1e-9);
|
||||
assert.deepEqual(flags, ['power_medium_immediate_drift']);
|
||||
});
|
||||
|
||||
test('applyDriftPenalty level 3 reduces confidence by 0.3 + flag', () => {
|
||||
const assessor = new DriftAssessor({ logger: SILENT });
|
||||
const flags = [];
|
||||
const c = assessor.applyDriftPenalty(
|
||||
{ valid: true, nrmse: 0.5, immediateLevel: 3, longTermLevel: 0 },
|
||||
0.9, flags, 'flow',
|
||||
);
|
||||
assert.ok(Math.abs(c - 0.6) < 1e-9);
|
||||
assert.deepEqual(flags, ['flow_high_immediate_drift']);
|
||||
});
|
||||
|
||||
test('applyDriftPenalty stacks long-term penalty', () => {
|
||||
const assessor = new DriftAssessor({ logger: SILENT });
|
||||
const flags = [];
|
||||
const c = assessor.applyDriftPenalty(
|
||||
{ valid: true, nrmse: 0.4, immediateLevel: 2, longTermLevel: 2 },
|
||||
0.9, flags, 'flow',
|
||||
);
|
||||
assert.ok(Math.abs(c - 0.6) < 1e-9);
|
||||
assert.deepEqual(flags, ['flow_medium_immediate_drift', 'flow_long_term_drift']);
|
||||
});
|
||||
|
||||
test('assessDrift returns null if no stored series', () => {
|
||||
const assessor = new DriftAssessor({
|
||||
errorMetrics: makeErrorMetrics(() => ({ valid: true })),
|
||||
measurements: {
|
||||
type() { return this; },
|
||||
variant() { return this; },
|
||||
position() { return this; },
|
||||
getAllValues() { return {}; },
|
||||
},
|
||||
driftProfiles: {},
|
||||
logger: SILENT,
|
||||
});
|
||||
assert.equal(assessor.assessDrift('flow', 0, 1), null);
|
||||
});
|
||||
132
test/basic/flowController.basic.test.js
Normal file
132
test/basic/flowController.basic.test.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const FlowController = require('../../src/flow/flowController');
|
||||
|
||||
function makeLogger() {
|
||||
const calls = { debug: [], info: [], warn: [], error: [] };
|
||||
return {
|
||||
calls,
|
||||
debug: (m) => calls.debug.push(m),
|
||||
info: (m) => calls.info.push(m),
|
||||
warn: (m) => calls.warn.push(m),
|
||||
error: (m) => calls.error.push(m),
|
||||
};
|
||||
}
|
||||
|
||||
function makeHost({
|
||||
mode = 'auto',
|
||||
allowedActions = new Set(['execsequence', 'execmovement', 'flowmovement', 'emergencystop', 'statuscheck', 'entermaintenance', 'exitmaintenance']),
|
||||
allowedSources = true,
|
||||
setpointError,
|
||||
} = {}) {
|
||||
const logger = makeLogger();
|
||||
const host = {
|
||||
logger,
|
||||
currentMode: mode,
|
||||
unitPolicy: {
|
||||
canonical: { flow: 'm3/s' },
|
||||
output: { flow: 'm3/h' },
|
||||
},
|
||||
isValidActionForMode: (action) => allowedActions.has(action),
|
||||
isValidSourceForMode: () => allowedSources,
|
||||
calls: { executeSequence: [], setpoint: [], calcCtrl: [], convertUnit: [] },
|
||||
async executeSequence(seq) { host.calls.executeSequence.push(seq); return { ran: seq }; },
|
||||
async setpoint(sp) {
|
||||
host.calls.setpoint.push(sp);
|
||||
if (setpointError) throw setpointError;
|
||||
return { moved: sp };
|
||||
},
|
||||
calcCtrl: (canonicalFlow) => { host.calls.calcCtrl.push(canonicalFlow); return canonicalFlow / 2; },
|
||||
_convertUnitValue: (val, from, to, label) => {
|
||||
host.calls.convertUnit.push({ val, from, to, label });
|
||||
return val * 1000; // pretend m3/h -> m3/s factor
|
||||
},
|
||||
};
|
||||
return host;
|
||||
}
|
||||
|
||||
test('handle("parent","execSequence","startup") triggers executeSequence', async () => {
|
||||
const host = makeHost();
|
||||
const fc = new FlowController({ host });
|
||||
const result = await fc.handle('parent', 'execSequence', 'startup');
|
||||
assert.deepEqual(host.calls.executeSequence, ['startup']);
|
||||
assert.deepEqual(result, { ran: 'startup' });
|
||||
});
|
||||
|
||||
test('handle("parent","execMovement",50) invokes setpoint(50)', async () => {
|
||||
const host = makeHost();
|
||||
const fc = new FlowController({ host });
|
||||
const result = await fc.handle('parent', 'execMovement', 50);
|
||||
assert.deepEqual(host.calls.setpoint, [50]);
|
||||
assert.deepEqual(result, { moved: 50 });
|
||||
});
|
||||
|
||||
test('handle("parent","flowMovement",X) converts unit -> calcCtrl -> setpoint', async () => {
|
||||
const host = makeHost();
|
||||
const fc = new FlowController({ host });
|
||||
await fc.handle('parent', 'flowMovement', 36);
|
||||
assert.equal(host.calls.convertUnit.length, 1);
|
||||
assert.equal(host.calls.convertUnit[0].from, 'm3/h');
|
||||
assert.equal(host.calls.convertUnit[0].to, 'm3/s');
|
||||
assert.deepEqual(host.calls.calcCtrl, [36 * 1000]);
|
||||
assert.deepEqual(host.calls.setpoint, [(36 * 1000) / 2]);
|
||||
});
|
||||
|
||||
test('handle("parent","emergencyStop") fires executeSequence("emergencystop") and logs warn', async () => {
|
||||
const host = makeHost();
|
||||
const fc = new FlowController({ host });
|
||||
await fc.handle('parent', 'emergencyStop');
|
||||
assert.deepEqual(host.calls.executeSequence, ['emergencystop']);
|
||||
assert.ok(host.logger.calls.warn.some((m) => /Emergency stop activated/.test(m)));
|
||||
});
|
||||
|
||||
test('handle rejects non-string action', async () => {
|
||||
const host = makeHost();
|
||||
const fc = new FlowController({ host });
|
||||
await fc.handle('parent', 123, 'x');
|
||||
assert.deepEqual(host.calls.executeSequence, []);
|
||||
assert.deepEqual(host.calls.setpoint, []);
|
||||
assert.ok(host.logger.calls.error.some((m) => /Action must be string/.test(m)));
|
||||
});
|
||||
|
||||
test('handle bails out when action not allowed for mode', async () => {
|
||||
const host = makeHost({ allowedActions: new Set(['statuscheck']) });
|
||||
const fc = new FlowController({ host });
|
||||
await fc.handle('parent', 'execSequence', 'startup');
|
||||
assert.deepEqual(host.calls.executeSequence, []);
|
||||
});
|
||||
|
||||
test('handle bails out when source not allowed for mode', async () => {
|
||||
const host = makeHost({ allowedSources: false });
|
||||
const fc = new FlowController({ host });
|
||||
await fc.handle('externalApi', 'execSequence', 'startup');
|
||||
assert.deepEqual(host.calls.executeSequence, []);
|
||||
});
|
||||
|
||||
test('handle catches downstream errors and logs them (does not propagate)', async () => {
|
||||
const host = makeHost({ setpointError: new Error('boom') });
|
||||
const fc = new FlowController({ host });
|
||||
const result = await fc.handle('parent', 'execMovement', 12);
|
||||
assert.equal(result, undefined);
|
||||
assert.ok(host.logger.calls.error.some((m) => /Error handling input/.test(m)));
|
||||
});
|
||||
|
||||
test('handle returns a success envelope for statuscheck', async () => {
|
||||
const host = makeHost();
|
||||
const fc = new FlowController({ host });
|
||||
const out = await fc.handle('parent', 'statusCheck');
|
||||
assert.equal(out.status, true);
|
||||
assert.ok(out.feedback.includes('statuscheck'));
|
||||
});
|
||||
|
||||
test('handle warns on unimplemented action', async () => {
|
||||
const host = makeHost({ allowedActions: new Set(['weirdaction']) });
|
||||
const fc = new FlowController({ host });
|
||||
await fc.handle('parent', 'weirdAction');
|
||||
assert.ok(host.logger.calls.warn.some((m) => /is not implemented/.test(m)));
|
||||
});
|
||||
|
||||
test('constructor validates host', () => {
|
||||
assert.throws(() => new FlowController({}), /ctx\.host is required/);
|
||||
});
|
||||
51
test/basic/groupPredictors.basic.test.js
Normal file
51
test/basic/groupPredictors.basic.test.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { predict } = require('generalFunctions');
|
||||
const { buildPredictors } = require('../../src/prediction/predictors');
|
||||
const { buildGroupPredictors } = require('../../src/prediction/groupPredictors');
|
||||
|
||||
function makeCanonicalCurve() {
|
||||
return {
|
||||
nq: {
|
||||
100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] },
|
||||
120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] },
|
||||
},
|
||||
np: {
|
||||
100000: { x: [0, 50, 100], y: [0, 500, 1000] },
|
||||
120000: { x: [0, 50, 100], y: [0, 600, 1200] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('buildGroupPredictors: returns null when source predictors absent', () => {
|
||||
assert.equal(buildGroupPredictors(null), null);
|
||||
assert.equal(buildGroupPredictors({ predictFlow: null, predictPower: null, predictCtrl: null }), null);
|
||||
});
|
||||
|
||||
test('buildGroupPredictors: returns three group-scope Predict instances', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const group = buildGroupPredictors(predictors);
|
||||
assert.ok(group);
|
||||
assert.ok(group.groupPredictFlow instanceof predict);
|
||||
assert.ok(group.groupPredictPower instanceof predict);
|
||||
assert.ok(group.groupPredictCtrl instanceof predict);
|
||||
});
|
||||
|
||||
test('buildGroupPredictors: group instances share input curves with individuals', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const group = buildGroupPredictors(predictors);
|
||||
// Predict._adoptInputsFrom copies these refs from the source.
|
||||
assert.equal(group.groupPredictFlow.inputCurve, predictors.predictFlow.inputCurve);
|
||||
assert.equal(group.groupPredictPower.inputCurve, predictors.predictPower.inputCurve);
|
||||
assert.equal(group.groupPredictCtrl.inputCurve, predictors.predictCtrl.inputCurve);
|
||||
});
|
||||
|
||||
test('buildGroupPredictors: group operating-point state is independent of individual', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const group = buildGroupPredictors(predictors);
|
||||
predictors.predictFlow.fDimension = 100000;
|
||||
group.groupPredictFlow.fDimension = 120000;
|
||||
assert.equal(predictors.predictFlow.currentF, 100000);
|
||||
assert.equal(group.groupPredictFlow.currentF, 120000);
|
||||
});
|
||||
149
test/basic/measurementHandlers.basic.test.js
Normal file
149
test/basic/measurementHandlers.basic.test.js
Normal file
@@ -0,0 +1,149 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MeasurementHandlers = require('../../src/measurement/measurementHandlers');
|
||||
|
||||
function makeChainable(sink) {
|
||||
const builder = {
|
||||
_path: {},
|
||||
type(t) { this._path.type = t; return this; },
|
||||
variant(v) { this._path.variant = v; return this; },
|
||||
position(p){ this._path.position = p; return this; },
|
||||
child(id) { this._path.child = id; return this; },
|
||||
value(v, ts, unit) {
|
||||
sink.push({ ...this._path, value: v, ts, unit });
|
||||
this._path = {};
|
||||
},
|
||||
getCurrentValue(unit) {
|
||||
return sink._currentValue != null ? sink._currentValue : 0;
|
||||
},
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
|
||||
function makeLogger() {
|
||||
const calls = { debug: [], info: [], warn: [], error: [] };
|
||||
return {
|
||||
calls,
|
||||
debug: (m) => calls.debug.push(m),
|
||||
info: (m) => calls.info.push(m),
|
||||
warn: (m) => calls.warn.push(m),
|
||||
error: (m) => calls.error.push(m),
|
||||
};
|
||||
}
|
||||
|
||||
function makeHost({ operational = true } = {}) {
|
||||
const writes = [];
|
||||
const logger = makeLogger();
|
||||
const host = {
|
||||
logger,
|
||||
writes,
|
||||
measurementUnits: { flow: 'm3/h', power: 'kW', temperature: 'C', pressure: 'mbar' },
|
||||
unitPolicy: {
|
||||
canonical: { flow: 'm3/s', power: 'W', temperature: 'K', pressure: 'Pa' },
|
||||
output: { flow: 'm3/h', power: 'kW', temperature: 'C', pressure: 'mbar' },
|
||||
},
|
||||
predictFlow: { outputY: 7 },
|
||||
predictPower: { outputY: 1234 },
|
||||
measurements: makeChainable(writes),
|
||||
_isOperationalState: () => operational,
|
||||
_resolveMeasurementUnit: (type, unit) => {
|
||||
if (!unit) throw new Error(`Missing unit for ${type} measurement.`);
|
||||
return unit;
|
||||
},
|
||||
_updateMetricDrift: (...args) => { host.driftCalls.push(args); },
|
||||
_updatePredictionHealth: () => { host.healthCalls++; },
|
||||
driftCalls: [],
|
||||
healthCalls: 0,
|
||||
updateMeasuredPressure: (...args) => { host.pressureCalls.push(args); },
|
||||
pressureCalls: [],
|
||||
updatePosition: () => { host.positionCalls++; },
|
||||
positionCalls: 0,
|
||||
};
|
||||
return host;
|
||||
}
|
||||
|
||||
test('dispatch("flow", …) routes to updateMeasuredFlow', () => {
|
||||
const host = makeHost();
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('flow', 5, 'downstream', { unit: 'm3/h', childId: 'c1', childName: 'FT-1' });
|
||||
|
||||
const flowWrite = host.writes.find((w) => w.type === 'flow' && w.variant === 'measured');
|
||||
assert.ok(flowWrite, 'expected measured flow write');
|
||||
assert.equal(flowWrite.value, 5);
|
||||
assert.equal(flowWrite.position, 'downstream');
|
||||
assert.equal(flowWrite.child, 'c1');
|
||||
|
||||
const predictedWrites = host.writes.filter((w) => w.type === 'flow' && w.variant === 'predicted');
|
||||
assert.equal(predictedWrites.length, 2, 'two predicted writes (downstream+atEquipment)');
|
||||
assert.equal(host.driftCalls.length, 1);
|
||||
assert.equal(host.driftCalls[0][0], 'flow');
|
||||
assert.equal(host.healthCalls, 1);
|
||||
});
|
||||
|
||||
test('dispatch("temperature", …) writes to measurements (works in non-operational state too)', () => {
|
||||
const host = makeHost({ operational: false });
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('temperature', 22.5, 'atEquipment', { unit: 'C', childId: 'tc', childName: 'TT-1', timestamp: 111 });
|
||||
|
||||
const write = host.writes.find((w) => w.type === 'temperature');
|
||||
assert.ok(write);
|
||||
assert.equal(write.value, 22.5);
|
||||
assert.equal(write.unit, 'C');
|
||||
assert.equal(write.ts, 111);
|
||||
});
|
||||
|
||||
test('dispatch("power", …) routes to updateMeasuredPower and respects unit', () => {
|
||||
const host = makeHost();
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('power', 1500, 'atEquipment', { unit: 'kW', childId: 'pwr', childName: 'P-1' });
|
||||
|
||||
const measured = host.writes.find((w) => w.type === 'power' && w.variant === 'measured');
|
||||
assert.ok(measured);
|
||||
assert.equal(measured.unit, 'kW');
|
||||
const predicted = host.writes.find((w) => w.type === 'power' && w.variant === 'predicted');
|
||||
assert.ok(predicted);
|
||||
assert.equal(host.driftCalls.length, 1);
|
||||
assert.equal(host.driftCalls[0][0], 'power');
|
||||
});
|
||||
|
||||
test('flow/power updates are skipped when machine is not operational', () => {
|
||||
const host = makeHost({ operational: false });
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('flow', 5, 'downstream', { unit: 'm3/h' });
|
||||
mh.dispatch('power', 99, 'atEquipment', { unit: 'kW' });
|
||||
|
||||
assert.equal(host.writes.length, 0);
|
||||
assert.equal(host.driftCalls.length, 0);
|
||||
assert.ok(host.logger.calls.warn.some((m) => /Machine not operational/.test(m)));
|
||||
});
|
||||
|
||||
test('dispatch("pressure", …) delegates to host.updateMeasuredPressure (pressureRouter)', () => {
|
||||
const host = makeHost();
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('pressure', 1013, 'upstream', { unit: 'mbar', childId: 'PT-1' });
|
||||
|
||||
assert.equal(host.pressureCalls.length, 1);
|
||||
assert.deepEqual(host.pressureCalls[0][0], 1013);
|
||||
});
|
||||
|
||||
test('dispatch(unknown, …) logs warn and falls back to updatePosition', () => {
|
||||
const host = makeHost();
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('vibration', 1, 'atEquipment', {});
|
||||
|
||||
assert.equal(host.positionCalls, 1);
|
||||
assert.ok(host.logger.calls.warn.some((m) => /No handler for measurement type/.test(m)));
|
||||
});
|
||||
|
||||
test('handler rejects update when unit resolution throws', () => {
|
||||
const host = makeHost();
|
||||
const mh = new MeasurementHandlers({ host });
|
||||
mh.dispatch('flow', 5, 'downstream', { /* no unit */ });
|
||||
assert.equal(host.writes.length, 0);
|
||||
assert.ok(host.logger.calls.warn.some((m) => /Rejected flow update/.test(m)));
|
||||
});
|
||||
|
||||
test('constructor validates host', () => {
|
||||
assert.throws(() => new MeasurementHandlers({}), /ctx\.host is required/);
|
||||
});
|
||||
@@ -2,13 +2,19 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const NodeClass = require('../../src/nodeClass');
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { makeNodeStub } = require('../helpers/factories');
|
||||
|
||||
// After the BaseNodeAdapter migration, _loadConfig + _setupSpecificClass
|
||||
// are gone — config building lives in buildDomainConfig(). These tests
|
||||
// drive that contract through a prototype-derived nodeClass instance so
|
||||
// we exercise the surface without booting Node-RED.
|
||||
|
||||
function makeUiConfig(overrides = {}) {
|
||||
return {
|
||||
unit: 'm3/h',
|
||||
enableLog: true,
|
||||
logLevel: 'debug',
|
||||
enableLog: false,
|
||||
logLevel: 'error',
|
||||
supplier: 'hidrostal',
|
||||
category: 'machine',
|
||||
assetType: 'pump',
|
||||
@@ -28,82 +34,53 @@ function makeUiConfig(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test('_loadConfig maps legacy editor fields for asset identity', () => {
|
||||
function callBuildDomainConfig(ui) {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
inst.node = makeNodeStub();
|
||||
inst.name = 'rotatingMachine';
|
||||
// Clear any leftover pending extras so this test's call is the only one
|
||||
// that stamps Machine._pendingExtras.
|
||||
Machine._pendingExtras = null;
|
||||
return inst.buildDomainConfig(ui);
|
||||
}
|
||||
|
||||
inst._loadConfig(
|
||||
makeUiConfig({
|
||||
uuid: 'uuid-from-editor',
|
||||
assetTagNumber: 'TAG-123',
|
||||
}),
|
||||
inst.node
|
||||
);
|
||||
|
||||
assert.equal(inst.config.asset.uuid, 'uuid-from-editor');
|
||||
assert.equal(inst.config.asset.tagCode, 'TAG-123');
|
||||
assert.equal(inst.config.asset.tagNumber, 'TAG-123');
|
||||
test('buildDomainConfig maps legacy editor fields for asset identity', () => {
|
||||
const cfg = callBuildDomainConfig(makeUiConfig({ uuid: 'uuid-from-editor', assetTagNumber: 'TAG-123' }));
|
||||
assert.equal(cfg.asset.uuid, 'uuid-from-editor');
|
||||
assert.equal(cfg.asset.tagCode, 'TAG-123');
|
||||
assert.equal(cfg.asset.tagNumber, 'TAG-123');
|
||||
});
|
||||
|
||||
test('_loadConfig prefers explicit assetUuid/assetTagCode when present', () => {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
inst.node = makeNodeStub();
|
||||
inst.name = 'rotatingMachine';
|
||||
|
||||
inst._loadConfig(
|
||||
makeUiConfig({
|
||||
uuid: 'legacy-uuid',
|
||||
assetUuid: 'explicit-uuid',
|
||||
assetTagNumber: 'legacy-tag',
|
||||
assetTagCode: 'explicit-tag',
|
||||
}),
|
||||
inst.node
|
||||
);
|
||||
|
||||
assert.equal(inst.config.asset.uuid, 'explicit-uuid');
|
||||
assert.equal(inst.config.asset.tagCode, 'explicit-tag');
|
||||
test('buildDomainConfig prefers explicit assetUuid/assetTagCode when present', () => {
|
||||
const cfg = callBuildDomainConfig(makeUiConfig({
|
||||
uuid: 'legacy-uuid', assetUuid: 'explicit-uuid',
|
||||
assetTagNumber: 'legacy-tag', assetTagCode: 'explicit-tag',
|
||||
}));
|
||||
assert.equal(cfg.asset.uuid, 'explicit-uuid');
|
||||
assert.equal(cfg.asset.tagCode, 'explicit-tag');
|
||||
});
|
||||
|
||||
test('_loadConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
inst.node = makeNodeStub();
|
||||
inst.name = 'rotatingMachine';
|
||||
|
||||
inst._loadConfig(
|
||||
makeUiConfig({
|
||||
unit: 'not-a-unit',
|
||||
curvePressureUnit: 'mbar',
|
||||
curveFlowUnit: 'm3/h',
|
||||
curvePowerUnit: 'kW',
|
||||
curveControlUnit: '%',
|
||||
}),
|
||||
inst.node
|
||||
);
|
||||
|
||||
assert.equal(inst.config.general.unit, 'm3/h');
|
||||
assert.equal(inst.config.asset.unit, 'm3/h');
|
||||
assert.equal(inst.config.asset.curveUnits.pressure, 'mbar');
|
||||
assert.equal(inst.config.asset.curveUnits.flow, 'm3/h');
|
||||
assert.equal(inst.config.asset.curveUnits.power, 'kW');
|
||||
assert.equal(inst.config.asset.curveUnits.control, '%');
|
||||
assert.ok(inst.node._warns.length >= 1);
|
||||
test('buildDomainConfig builds explicit curveUnits and falls back for invalid flow unit', () => {
|
||||
const cfg = callBuildDomainConfig(makeUiConfig({
|
||||
unit: 'not-a-unit',
|
||||
curvePressureUnit: 'mbar', curveFlowUnit: 'm3/h',
|
||||
curvePowerUnit: 'kW', curveControlUnit: '%',
|
||||
}));
|
||||
assert.equal(cfg.general.unit, 'm3/h');
|
||||
assert.equal(cfg.asset.unit, 'm3/h');
|
||||
assert.equal(cfg.asset.curveUnits.pressure, 'mbar');
|
||||
assert.equal(cfg.asset.curveUnits.flow, 'm3/h');
|
||||
assert.equal(cfg.asset.curveUnits.power, 'kW');
|
||||
assert.equal(cfg.asset.curveUnits.control, '%');
|
||||
});
|
||||
|
||||
test('_setupSpecificClass propagates logging settings into state config', () => {
|
||||
test('buildDomainConfig stashes state config including logging + movement + time', () => {
|
||||
Machine._pendingExtras = null;
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
inst.node = makeNodeStub();
|
||||
inst.name = 'rotatingMachine';
|
||||
const uiConfig = makeUiConfig({
|
||||
enableLog: true,
|
||||
logLevel: 'warn',
|
||||
uuid: 'uuid-test',
|
||||
assetTagNumber: 'TAG-9',
|
||||
});
|
||||
|
||||
inst._loadConfig(uiConfig, inst.node);
|
||||
inst._setupSpecificClass(uiConfig);
|
||||
|
||||
assert.equal(inst.source.state.config.general.logging.enabled, true);
|
||||
assert.equal(inst.source.state.config.general.logging.logLevel, 'warn');
|
||||
inst.buildDomainConfig(makeUiConfig({ enableLog: true, logLevel: 'warn', speed: 5, startup: 3 }));
|
||||
const extras = Machine._pendingExtras;
|
||||
assert.ok(extras, 'Machine._pendingExtras should be set by buildDomainConfig');
|
||||
assert.equal(extras.stateConfig.general.logging.enabled, true);
|
||||
assert.equal(extras.stateConfig.general.logging.logLevel, 'warn');
|
||||
assert.equal(extras.stateConfig.movement.speed, 5);
|
||||
assert.equal(extras.stateConfig.time.starting, 3);
|
||||
Machine._pendingExtras = null;
|
||||
});
|
||||
|
||||
73
test/basic/operatingPoint.basic.test.js
Normal file
73
test/basic/operatingPoint.basic.test.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { buildPredictors } = require('../../src/prediction/predictors');
|
||||
const { buildGroupPredictors } = require('../../src/prediction/groupPredictors');
|
||||
const OperatingPoint = require('../../src/prediction/operatingPoint');
|
||||
|
||||
function makeCanonicalCurve() {
|
||||
return {
|
||||
nq: {
|
||||
100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] },
|
||||
120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] },
|
||||
},
|
||||
np: {
|
||||
100000: { x: [0, 50, 100], y: [0, 500, 1000] },
|
||||
120000: { x: [0, 50, 100], y: [0, 600, 1200] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('OperatingPoint.setIndividual: updates working pressure on all three predictors', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const op = new OperatingPoint(predictors);
|
||||
const ok = op.setIndividual(100000);
|
||||
assert.equal(ok, true);
|
||||
assert.equal(predictors.predictFlow.currentF, 100000);
|
||||
assert.equal(predictors.predictPower.currentF, 100000);
|
||||
assert.equal(predictors.predictCtrl.currentF, 100000);
|
||||
});
|
||||
|
||||
test('OperatingPoint.setIndividual: rejects non-finite pressure', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const op = new OperatingPoint(predictors);
|
||||
assert.equal(op.setIndividual(NaN), false);
|
||||
assert.equal(op.setIndividual('not-a-number'), false);
|
||||
});
|
||||
|
||||
test('OperatingPoint.setGroup: no-op when group predictors absent', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const op = new OperatingPoint(predictors, null);
|
||||
assert.equal(op.setGroup(100000), false);
|
||||
});
|
||||
|
||||
test('OperatingPoint.setGroup: updates only group predictors', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const group = buildGroupPredictors(predictors);
|
||||
const op = new OperatingPoint(predictors, group);
|
||||
predictors.predictFlow.fDimension = 120000;
|
||||
op.setGroup(100000);
|
||||
assert.equal(group.groupPredictFlow.currentF, 100000);
|
||||
assert.equal(predictors.predictFlow.currentF, 120000);
|
||||
});
|
||||
|
||||
test('OperatingPoint.flowFor: returns a finite predicted flow', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const op = new OperatingPoint(predictors);
|
||||
op.setIndividual(100000);
|
||||
const flow = op.flowFor(50);
|
||||
assert.ok(Number.isFinite(flow), `expected finite flow, got ${flow}`);
|
||||
assert.ok(flow > 0);
|
||||
});
|
||||
|
||||
test('OperatingPoint.useGroup: switches getters to group predictors', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
const group = buildGroupPredictors(predictors);
|
||||
const op = new OperatingPoint(predictors, group);
|
||||
op.setIndividual(100000);
|
||||
op.setGroup(120000);
|
||||
const indivFlow = op.useIndividual().flowFor(50);
|
||||
const groupFlow = op.useGroup().flowFor(50);
|
||||
assert.ok(Number.isFinite(indivFlow));
|
||||
assert.ok(Number.isFinite(groupFlow));
|
||||
});
|
||||
93
test/basic/predictionHealth.basic.test.js
Normal file
93
test/basic/predictionHealth.basic.test.js
Normal file
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const PredictionHealth = require('../../src/drift/predictionHealth');
|
||||
const DriftAssessor = require('../../src/drift/driftAssessor');
|
||||
|
||||
function makeHealth(overrides = {}) {
|
||||
return new PredictionHealth({
|
||||
getPressureInitializationStatus: () => ({
|
||||
initialized: true, hasDifferential: true, source: 'differential',
|
||||
}),
|
||||
isOperational: () => true,
|
||||
applyDriftPenalty: new DriftAssessor({}).applyDriftPenalty.bind(new DriftAssessor({})),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('empty snapshots + differential pressure → nominal health, confidence=0.9', () => {
|
||||
const ph = makeHealth();
|
||||
const { health, confidence } = ph.evaluate({
|
||||
flow: null,
|
||||
power: null,
|
||||
pressure: { level: 0, flags: [], source: 'differential' },
|
||||
});
|
||||
assert.equal(health.level, 0);
|
||||
assert.ok(Math.abs(confidence - 0.9) < 1e-9);
|
||||
assert.equal(typeof health.message, 'string');
|
||||
});
|
||||
|
||||
test('pressure not initialized + flow drift level 2 → composite level >= 2 and multiple flags', () => {
|
||||
const ph = makeHealth({
|
||||
getPressureInitializationStatus: () => ({
|
||||
initialized: false, hasDifferential: false, source: null,
|
||||
}),
|
||||
});
|
||||
const { health, confidence } = ph.evaluate({
|
||||
flow: { valid: true, nrmse: 0.3, immediateLevel: 2, longTermLevel: 0 },
|
||||
power: null,
|
||||
pressure: { level: 2, flags: ['no_pressure_input'], source: null },
|
||||
});
|
||||
assert.ok(health.level >= 2);
|
||||
assert.ok(health.flags.includes('no_pressure_input'));
|
||||
assert.ok(health.flags.includes('flow_medium_immediate_drift'));
|
||||
assert.ok(confidence < 0.5);
|
||||
});
|
||||
|
||||
test('returned object has both health and confidence', () => {
|
||||
const ph = makeHealth();
|
||||
const out = ph.evaluate({ flow: null, power: null, pressure: { level: 0, flags: [], source: 'differential' } });
|
||||
assert.ok('health' in out);
|
||||
assert.ok('confidence' in out);
|
||||
assert.equal(typeof out.confidence, 'number');
|
||||
assert.equal(typeof out.health.level, 'number');
|
||||
});
|
||||
|
||||
test('non-operational forces confidence=0 and bumps level >=2', () => {
|
||||
const ph = makeHealth({ isOperational: () => false });
|
||||
const { health, confidence } = ph.evaluate({
|
||||
flow: null, power: null,
|
||||
pressure: { level: 0, flags: [], source: 'differential' },
|
||||
});
|
||||
assert.equal(confidence, 0);
|
||||
assert.ok(health.flags.includes('not_operational'));
|
||||
assert.ok(health.level >= 2);
|
||||
});
|
||||
|
||||
test('curve-edge penalty applies when current position is near min/max', () => {
|
||||
const ph = makeHealth({
|
||||
getCurrentPosition: () => 0.01,
|
||||
resolveSetpointBounds: () => ({ min: 0, max: 1 }),
|
||||
});
|
||||
const { health, confidence } = ph.evaluate({
|
||||
flow: null, power: null,
|
||||
pressure: { level: 0, flags: [], source: 'differential' },
|
||||
});
|
||||
assert.ok(health.flags.includes('near_curve_edge'));
|
||||
assert.ok(confidence < 0.9);
|
||||
});
|
||||
|
||||
test('HealthStatus shape — has the standardised five fields', () => {
|
||||
const ph = makeHealth();
|
||||
const { health } = ph.evaluate({
|
||||
flow: null, power: null,
|
||||
pressure: { level: 0, flags: [], source: 'differential' },
|
||||
});
|
||||
assert.ok('level' in health);
|
||||
assert.ok('flags' in health);
|
||||
assert.ok('message' in health);
|
||||
assert.ok('source' in health);
|
||||
assert.ok(Array.isArray(health.flags));
|
||||
});
|
||||
49
test/basic/predictors.basic.test.js
Normal file
49
test/basic/predictors.basic.test.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { predict } = require('generalFunctions');
|
||||
const { buildPredictors } = require('../../src/prediction/predictors');
|
||||
|
||||
function makeCanonicalCurve() {
|
||||
// Canonical units already applied: pressure Pa, flow m3/s, power W,
|
||||
// x-axis is control %. Two pressure levels, monotonically rising y.
|
||||
return {
|
||||
nq: {
|
||||
100000: { x: [0, 50, 100], y: [0, 0.005, 0.01] },
|
||||
120000: { x: [0, 50, 100], y: [0, 0.006, 0.012] },
|
||||
},
|
||||
np: {
|
||||
100000: { x: [0, 50, 100], y: [0, 500, 1000] },
|
||||
120000: { x: [0, 50, 100], y: [0, 600, 1200] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('buildPredictors: returns three Predict instances', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
assert.ok(predictors.predictFlow instanceof predict);
|
||||
assert.ok(predictors.predictPower instanceof predict);
|
||||
assert.ok(predictors.predictCtrl instanceof predict);
|
||||
});
|
||||
|
||||
test('buildPredictors: predictFlow yMax/yMin reflect input range', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
// After buildAllFxyCurves the fDimension is initialised to fValues.min.
|
||||
// currentFxyYMin/Max are the y-range at that pressure curve.
|
||||
assert.ok(Number.isFinite(predictors.predictFlow.currentFxyYMax));
|
||||
assert.ok(Number.isFinite(predictors.predictFlow.currentFxyYMin));
|
||||
assert.ok(predictors.predictFlow.currentFxyYMax > predictors.predictFlow.currentFxyYMin);
|
||||
});
|
||||
|
||||
test('buildPredictors: predictCtrl is built from reversed nq (flow->ctrl mapping)', () => {
|
||||
const predictors = buildPredictors(makeCanonicalCurve());
|
||||
// predictCtrl's x-axis values must come from y-values in nq.
|
||||
// sanity-check via currentFxyXMax being in the flow range
|
||||
assert.ok(predictors.predictCtrl.currentFxyXMax <= 0.02, // flow range upper bound
|
||||
`expected predictCtrl xMax in flow-range, got ${predictors.predictCtrl.currentFxyXMax}`);
|
||||
});
|
||||
|
||||
test('buildPredictors: throws when machineCurve is missing nq or np', () => {
|
||||
assert.throws(() => buildPredictors(null), /machineCurve\.nq and \.np are required/);
|
||||
assert.throws(() => buildPredictors({ nq: {} }), /required/);
|
||||
});
|
||||
103
test/basic/pressureInitialization.basic.test.js
Normal file
103
test/basic/pressureInitialization.basic.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const PressureInitialization = require('../../src/pressure/pressureInitialization');
|
||||
|
||||
const SILENT = { warn() {}, debug() {} };
|
||||
|
||||
/* A tiny in-memory stand-in for MeasurementContainer's chained API. */
|
||||
function makeFakeMeasurements() {
|
||||
const store = new Map();
|
||||
const key = (pos, childId) => `${pos}::${childId == null ? '*' : childId}`;
|
||||
return {
|
||||
_write(pos, childId, value) { store.set(key(pos, childId), value); },
|
||||
type() { return this; },
|
||||
variant() { return this; },
|
||||
position(p) { this._pos = p; return this; },
|
||||
child(c) { this._child = c; return this; },
|
||||
getCurrentValue() {
|
||||
const k = key(this._pos, this._child);
|
||||
this._child = null;
|
||||
const v = store.get(k);
|
||||
if (v != null) return v;
|
||||
// fallback to bare position when no child specified
|
||||
return store.get(key(this._pos, null));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('getStatus reports initialized:false when neither real nor virtual data present', () => {
|
||||
const init = new PressureInitialization({
|
||||
measurements: makeFakeMeasurements(),
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
logger: SILENT,
|
||||
});
|
||||
const s = init.getStatus();
|
||||
assert.equal(s.initialized, false);
|
||||
assert.equal(s.hasDifferential, false);
|
||||
assert.equal(s.source, null);
|
||||
});
|
||||
|
||||
test('registerReal then getStatus reports initialized:true for that position', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
const init = new PressureInitialization({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
logger: SILENT,
|
||||
});
|
||||
|
||||
init.registerReal('upstream', 'pt-101');
|
||||
meas._write('upstream', 'pt-101', 5000);
|
||||
|
||||
const s = init.getStatus();
|
||||
assert.equal(s.initialized, true);
|
||||
assert.equal(s.hasUpstream, true);
|
||||
assert.equal(s.hasDownstream, false);
|
||||
assert.equal(s.hasDifferential, false);
|
||||
assert.equal(s.source, 'upstream');
|
||||
});
|
||||
|
||||
test('hasDifferential true only when both upstream + downstream have data', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
const init = new PressureInitialization({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
logger: SILENT,
|
||||
});
|
||||
init.registerReal('upstream', 'pt-1');
|
||||
meas._write('upstream', 'pt-1', 5000);
|
||||
assert.equal(init.getStatus().hasDifferential, false);
|
||||
|
||||
init.registerReal('downstream', 'pt-2');
|
||||
meas._write('downstream', 'pt-2', 7000);
|
||||
const s = init.getStatus();
|
||||
assert.equal(s.hasDifferential, true);
|
||||
assert.equal(s.source, 'differential');
|
||||
});
|
||||
|
||||
test('virtual fallback when no real children registered', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
const init = new PressureInitialization({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
logger: SILENT,
|
||||
});
|
||||
meas._write('upstream', 'sim-u', 5000);
|
||||
const s = init.getStatus();
|
||||
assert.equal(s.hasUpstream, true);
|
||||
assert.equal(s.source, 'upstream');
|
||||
});
|
||||
|
||||
test('unregisterReal removes a tracked child id', () => {
|
||||
const init = new PressureInitialization({
|
||||
measurements: makeFakeMeasurements(),
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
logger: SILENT,
|
||||
});
|
||||
init.registerReal('upstream', 'pt-1');
|
||||
assert.ok(init.realPressureChildIds.upstream.has('pt-1'));
|
||||
init.unregisterReal('upstream', 'pt-1');
|
||||
assert.ok(!init.realPressureChildIds.upstream.has('pt-1'));
|
||||
});
|
||||
101
test/basic/pressureRouter.basic.test.js
Normal file
101
test/basic/pressureRouter.basic.test.js
Normal file
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const PressureRouter = require('../../src/pressure/pressureRouter');
|
||||
|
||||
const SILENT = { warn() {}, debug() {} };
|
||||
|
||||
function makeFakeMeasurements() {
|
||||
const writes = [];
|
||||
return {
|
||||
writes,
|
||||
type() { return this; },
|
||||
variant() { return this; },
|
||||
position(p) { this._pos = p; return this; },
|
||||
child(c) { this._child = c; return this; },
|
||||
value(v, t, u) { writes.push({ pos: this._pos, child: this._child, value: v, t, u }); },
|
||||
};
|
||||
}
|
||||
|
||||
test('route("upstream", 1, ctx) writes to the upstream pressure slot', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
const router = new PressureRouter({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
resolveMeasurementUnit: () => 'mbar',
|
||||
logger: SILENT,
|
||||
});
|
||||
router.route('upstream', 1, { childId: 'real-1', unit: 'mbar', timestamp: 1234 });
|
||||
assert.equal(meas.writes.length, 1);
|
||||
assert.equal(meas.writes[0].pos, 'upstream');
|
||||
assert.equal(meas.writes[0].child, 'real-1');
|
||||
assert.equal(meas.writes[0].value, 1);
|
||||
assert.equal(meas.writes[0].u, 'mbar');
|
||||
});
|
||||
|
||||
test('virtual source: refresh hooks NOT called', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
let posCalled = 0, driftCalled = 0, healthCalled = 0;
|
||||
const router = new PressureRouter({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
resolveMeasurementUnit: () => 'mbar',
|
||||
updatePosition: () => { posCalled++; },
|
||||
refreshDrift: () => { driftCalled++; },
|
||||
refreshHealth: () => { healthCalled++; },
|
||||
logger: SILENT,
|
||||
});
|
||||
router.route('upstream', 7, { childId: 'sim-u', unit: 'mbar' });
|
||||
assert.equal(posCalled, 0);
|
||||
assert.equal(driftCalled, 0);
|
||||
assert.equal(healthCalled, 0);
|
||||
});
|
||||
|
||||
test('real source: all refresh hooks called', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
let posCalled = 0, driftCalled = 0, healthCalled = 0;
|
||||
const router = new PressureRouter({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
resolveMeasurementUnit: () => 'mbar',
|
||||
updatePosition: () => { posCalled++; },
|
||||
refreshDrift: () => { driftCalled++; },
|
||||
refreshHealth: () => { healthCalled++; },
|
||||
logger: SILENT,
|
||||
});
|
||||
router.route('upstream', 7, { childId: 'real-pt-1', unit: 'mbar' });
|
||||
assert.equal(posCalled, 1);
|
||||
assert.equal(driftCalled, 1);
|
||||
assert.equal(healthCalled, 1);
|
||||
});
|
||||
|
||||
test('rejected unit returns false and skips the write', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
const warns = [];
|
||||
const router = new PressureRouter({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: {},
|
||||
resolveMeasurementUnit: () => { throw new Error('bad unit'); },
|
||||
logger: { warn(m) { warns.push(m); }, debug() {} },
|
||||
});
|
||||
const ok = router.route('upstream', 1, { childId: 'x', unit: 'wat' });
|
||||
assert.equal(ok, false);
|
||||
assert.equal(meas.writes.length, 0);
|
||||
assert.match(warns[0], /Rejected pressure update/);
|
||||
});
|
||||
|
||||
test('childId null is treated as not-virtual', () => {
|
||||
const meas = makeFakeMeasurements();
|
||||
let posCalled = 0;
|
||||
const router = new PressureRouter({
|
||||
measurements: meas,
|
||||
virtualPressureChildIds: { upstream: 'sim-u' },
|
||||
resolveMeasurementUnit: () => 'mbar',
|
||||
updatePosition: () => { posCalled++; },
|
||||
logger: SILENT,
|
||||
});
|
||||
router.route('upstream', 2, { unit: 'mbar' });
|
||||
assert.equal(posCalled, 1);
|
||||
});
|
||||
29
test/basic/reverseCurve.basic.test.js
Normal file
29
test/basic/reverseCurve.basic.test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { reverseCurve } = require('../../src/curves/reverseCurve');
|
||||
|
||||
test('reverseCurve: swaps x and y for each pressure key', () => {
|
||||
const input = {
|
||||
700: { x: [0, 50, 100], y: [0, 10, 20] },
|
||||
800: { x: [0, 50, 100], y: [0, 11, 22] },
|
||||
};
|
||||
const out = reverseCurve(input);
|
||||
assert.deepEqual(out['700'].x, [0, 10, 20]);
|
||||
assert.deepEqual(out['700'].y, [0, 50, 100]);
|
||||
assert.deepEqual(out['800'].x, [0, 11, 22]);
|
||||
assert.deepEqual(out['800'].y, [0, 50, 100]);
|
||||
});
|
||||
|
||||
test('reverseCurve: returns a fresh object with cloned arrays', () => {
|
||||
const input = { 700: { x: [1, 2], y: [3, 4] } };
|
||||
const out = reverseCurve(input);
|
||||
out['700'].x.push(999);
|
||||
assert.deepEqual(input['700'].x, [1, 2]);
|
||||
assert.deepEqual(input['700'].y, [3, 4]);
|
||||
});
|
||||
|
||||
test('reverseCurve: handles empty input', () => {
|
||||
assert.deepEqual(reverseCurve({}), {});
|
||||
assert.deepEqual(reverseCurve(null), {});
|
||||
});
|
||||
91
test/basic/stateBindings.basic.test.js
Normal file
91
test/basic/stateBindings.basic.test.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const { bindStateEvents, isOperationalState, OPERATIONAL_STATES } =
|
||||
require('../../src/state/stateBindings');
|
||||
|
||||
function makeFakeState() {
|
||||
const emitter = new EventEmitter();
|
||||
let current = 'idle';
|
||||
return {
|
||||
emitter,
|
||||
setState(s) { current = s; },
|
||||
getCurrentState() { return current; },
|
||||
};
|
||||
}
|
||||
|
||||
test('bindStateEvents attaches both listeners and they fire on emit', () => {
|
||||
const state = makeFakeState();
|
||||
let posCalls = 0;
|
||||
let stateCalls = 0;
|
||||
let lastStateArg = null;
|
||||
|
||||
bindStateEvents({
|
||||
state,
|
||||
onPositionChange: () => { posCalls++; },
|
||||
onStateChange: (newState) => { stateCalls++; lastStateArg = newState; },
|
||||
});
|
||||
|
||||
assert.equal(state.emitter.listenerCount('positionChange'), 1);
|
||||
assert.equal(state.emitter.listenerCount('stateChange'), 1);
|
||||
|
||||
state.emitter.emit('positionChange', 42);
|
||||
state.emitter.emit('stateChange', 'operational');
|
||||
|
||||
assert.equal(posCalls, 1);
|
||||
assert.equal(stateCalls, 1);
|
||||
assert.equal(lastStateArg, 'operational');
|
||||
});
|
||||
|
||||
test('bindStateEvents teardown removes both listeners and is idempotent', () => {
|
||||
const state = makeFakeState();
|
||||
const teardown = bindStateEvents({
|
||||
state,
|
||||
onPositionChange: () => {},
|
||||
onStateChange: () => {},
|
||||
});
|
||||
|
||||
assert.equal(state.emitter.listenerCount('positionChange'), 1);
|
||||
assert.equal(state.emitter.listenerCount('stateChange'), 1);
|
||||
|
||||
teardown();
|
||||
assert.equal(state.emitter.listenerCount('positionChange'), 0);
|
||||
assert.equal(state.emitter.listenerCount('stateChange'), 0);
|
||||
|
||||
teardown();
|
||||
assert.equal(state.emitter.listenerCount('positionChange'), 0);
|
||||
});
|
||||
|
||||
test('bindStateEvents validates context shape', () => {
|
||||
assert.throws(() => bindStateEvents(null), /ctx\.state\.emitter is required/);
|
||||
assert.throws(
|
||||
() => bindStateEvents({ state: makeFakeState() }),
|
||||
/handlers are required/,
|
||||
);
|
||||
});
|
||||
|
||||
test('isOperationalState returns true for operational/accelerating/decelerating/warmingup', () => {
|
||||
const state = makeFakeState();
|
||||
for (const s of ['operational', 'accelerating', 'decelerating', 'warmingup']) {
|
||||
state.setState(s);
|
||||
assert.equal(isOperationalState(state), true, `expected ${s} to be operational`);
|
||||
}
|
||||
});
|
||||
|
||||
test('isOperationalState returns false for non-operational states and bad input', () => {
|
||||
const state = makeFakeState();
|
||||
for (const s of ['idle', 'starting', 'stopping', 'coolingdown', 'emergencystopped']) {
|
||||
state.setState(s);
|
||||
assert.equal(isOperationalState(state), false, `expected ${s} not to be operational`);
|
||||
}
|
||||
assert.equal(isOperationalState(null), false);
|
||||
assert.equal(isOperationalState({}), false);
|
||||
});
|
||||
|
||||
test('OPERATIONAL_STATES list is exported and frozen-ish (no extras beyond contract)', () => {
|
||||
assert.deepEqual(
|
||||
[...OPERATIONAL_STATES].sort(),
|
||||
['accelerating', 'decelerating', 'operational', 'warmingup'],
|
||||
);
|
||||
});
|
||||
70
test/basic/virtualChildren.basic.test.js
Normal file
70
test/basic/virtualChildren.basic.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const VirtualPressureChildren = require('../../src/pressure/virtualChildren');
|
||||
|
||||
const SILENT = { warn() {}, debug() {}, info() {}, error() {} };
|
||||
|
||||
const UNIT_POLICY = {
|
||||
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K', atmPressure: 'Pa' },
|
||||
output: { pressure: 'mbar', flow: 'm3/h', power: 'kW', temperature: 'C' },
|
||||
};
|
||||
|
||||
test('build() returns two children with the expected config shape', () => {
|
||||
const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY });
|
||||
const { upstream, downstream } = factory.build();
|
||||
|
||||
for (const child of [upstream, downstream]) {
|
||||
assert.ok(child.config.general.id);
|
||||
assert.ok(child.config.general.name);
|
||||
assert.equal(child.config.functionality.softwareType, 'measurement');
|
||||
assert.ok(['upstream', 'downstream'].includes(child.config.functionality.positionVsParent));
|
||||
assert.equal(child.config.asset.type, 'pressure');
|
||||
assert.equal(child.config.asset.unit, 'mbar');
|
||||
}
|
||||
|
||||
assert.equal(upstream.config.functionality.positionVsParent, 'upstream');
|
||||
assert.equal(downstream.config.functionality.positionVsParent, 'downstream');
|
||||
});
|
||||
|
||||
test('each child has its own MeasurementContainer instance', () => {
|
||||
const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY });
|
||||
const { upstream, downstream } = factory.build();
|
||||
assert.ok(upstream.measurements);
|
||||
assert.ok(downstream.measurements);
|
||||
assert.notStrictEqual(upstream.measurements, downstream.measurements);
|
||||
});
|
||||
|
||||
test('the MeasurementContainer accepts pressure writes (unit policy applied)', () => {
|
||||
const factory = new VirtualPressureChildren({ logger: SILENT, unitPolicy: UNIT_POLICY });
|
||||
const { upstream } = factory.build();
|
||||
upstream.measurements
|
||||
.type('pressure').variant('measured').position('upstream')
|
||||
.value(1000, Date.now(), 'mbar');
|
||||
const v = upstream.measurements
|
||||
.type('pressure').variant('measured').position('upstream').getCurrentValue();
|
||||
assert.ok(v != null);
|
||||
});
|
||||
|
||||
test('setParentRef wires children to the supplied parent ref', () => {
|
||||
const parent = { id: 'parent-machine' };
|
||||
const factory = new VirtualPressureChildren({
|
||||
logger: SILENT, unitPolicy: UNIT_POLICY, parentRef: parent,
|
||||
});
|
||||
const { upstream, downstream } = factory.build();
|
||||
assert.equal(typeof upstream.measurements.setParentRef, 'function');
|
||||
assert.equal(typeof downstream.measurements.setParentRef, 'function');
|
||||
});
|
||||
|
||||
test('custom ids are honoured', () => {
|
||||
const factory = new VirtualPressureChildren({
|
||||
logger: SILENT,
|
||||
unitPolicy: UNIT_POLICY,
|
||||
ids: { upstream: 'sim-u', downstream: 'sim-d' },
|
||||
});
|
||||
const { upstream, downstream } = factory.build();
|
||||
assert.equal(upstream.config.general.id, 'sim-u');
|
||||
assert.equal(downstream.config.general.id, 'sim-d');
|
||||
});
|
||||
83
test/basic/workingCurves.basic.test.js
Normal file
83
test/basic/workingCurves.basic.test.js
Normal file
@@ -0,0 +1,83 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { showWorkingCurves, showCoG } = require('../../src/display/workingCurves');
|
||||
|
||||
function makePredictors(overrides = {}) {
|
||||
return {
|
||||
hasCurve: true,
|
||||
cog: 0.65,
|
||||
cogIndex: 7,
|
||||
NCog: 0.5,
|
||||
minEfficiency: 0.4,
|
||||
currentEfficiencyCurve: { x: [0, 1], y: [0.4, 0.8] },
|
||||
absDistFromPeak: 0.15,
|
||||
relDistFromPeak: 0.3,
|
||||
calcCog: () => ({ cog: 0.65, cogIndex: 7, NCog: 0.5, minEfficiency: 0.4 }),
|
||||
getCurrentCurves: () => ({
|
||||
powerCurve: { x: [0, 1], y: [10, 20] },
|
||||
flowCurve: { x: [0, 1], y: [0, 5] },
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('showWorkingCurves returns the expected shape when curves exist', () => {
|
||||
const p = makePredictors();
|
||||
const out = showWorkingCurves(p);
|
||||
assert.deepEqual(out.powerCurve, { x: [0, 1], y: [10, 20] });
|
||||
assert.deepEqual(out.flowCurve, { x: [0, 1], y: [0, 5] });
|
||||
assert.equal(out.cog, 0.65);
|
||||
assert.equal(out.cogIndex, 7);
|
||||
assert.equal(out.NCog, 0.5);
|
||||
assert.equal(out.minEfficiency, 0.4);
|
||||
assert.deepEqual(out.currentEfficiencyCurve, { x: [0, 1], y: [0.4, 0.8] });
|
||||
assert.equal(out.absDistFromPeak, 0.15);
|
||||
assert.equal(out.relDistFromPeak, 0.3);
|
||||
});
|
||||
|
||||
test('showWorkingCurves returns error envelope when hasCurve is false', () => {
|
||||
const out = showWorkingCurves(makePredictors({ hasCurve: false }));
|
||||
assert.deepEqual(out, { error: 'No curve data available' });
|
||||
});
|
||||
|
||||
test('showWorkingCurves handles null predictors safely', () => {
|
||||
const out = showWorkingCurves(null);
|
||||
assert.equal(out.error, 'No curve data available');
|
||||
});
|
||||
|
||||
test('showCoG returns CoG data with rounded NCogPercent when curves exist', () => {
|
||||
const p = makePredictors();
|
||||
const out = showCoG(p);
|
||||
assert.equal(out.cog, 0.65);
|
||||
assert.equal(out.cogIndex, 7);
|
||||
assert.equal(out.NCog, 0.5);
|
||||
// 0.5 * 100 = 50.0, rounded *100 /100 still 50
|
||||
assert.equal(out.NCogPercent, 50);
|
||||
assert.equal(out.minEfficiency, 0.4);
|
||||
assert.deepEqual(out.currentEfficiencyCurve, { x: [0, 1], y: [0.4, 0.8] });
|
||||
assert.equal(out.absDistFromPeak, 0.15);
|
||||
assert.equal(out.relDistFromPeak, 0.3);
|
||||
});
|
||||
|
||||
test('showCoG rounds NCogPercent to 2 decimal places', () => {
|
||||
const p = makePredictors({
|
||||
calcCog: () => ({ cog: 0.1, cogIndex: 1, NCog: 0.123456, minEfficiency: 0.2 }),
|
||||
});
|
||||
const out = showCoG(p);
|
||||
assert.equal(out.NCogPercent, 12.35);
|
||||
});
|
||||
|
||||
test('showCoG returns degraded shape when hasCurve is false', () => {
|
||||
const out = showCoG(makePredictors({ hasCurve: false }));
|
||||
assert.equal(out.error, 'No curve data available');
|
||||
assert.equal(out.cog, 0);
|
||||
assert.equal(out.NCog, 0);
|
||||
assert.equal(out.cogIndex, 0);
|
||||
});
|
||||
|
||||
test('showCoG handles null predictors safely', () => {
|
||||
const out = showCoG(null);
|
||||
assert.equal(out.error, 'No curve data available');
|
||||
assert.equal(out.cog, 0);
|
||||
});
|
||||
48
test/e2e/README.md
Normal file
48
test/e2e/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# rotatingMachine — End-to-End Benchmarks
|
||||
|
||||
These are live-deploy benchmarks, not unit tests. They require a running Docker-hosted Node-RED with the EVOLV package mounted, and they drive the node through its real runtime: admin-API deploy, debug websocket capture, inject-triggered commands, 1-second tick loop.
|
||||
|
||||
Unit tests live in `../basic/`, `../integration/`, `../edge/`. Run those with `npm test`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
cd /mnt/d/gitea/EVOLV
|
||||
docker compose up -d nodered influxdb
|
||||
# wait for http://localhost:1880/nodes to return 200
|
||||
pip install --user --break-system-packages websocket-client requests
|
||||
```
|
||||
|
||||
## Benchmarks
|
||||
|
||||
### `curve-prediction-benchmark.py`
|
||||
|
||||
Deploys one rotatingMachine per shipped pump curve (`hidrostal-H05K-S03R`, `hidrostal-C5-D03R-SHN1`) and runs a per-pump (pressure × ctrl) sweep. For each pump the sweep covers its own low / mid / high pressure slices with controller setpoints of 20 / 40 / 60 / 80 %.
|
||||
|
||||
Reports:
|
||||
|
||||
- Count of samples inside the curve envelope ("good") vs out-of-range ("bad").
|
||||
- Monotonicity of flow across the ctrl sweep at fixed pressure.
|
||||
- Full sample table with state, ctrl, flow, power, NCog, cog.
|
||||
|
||||
```bash
|
||||
python3 nodes/rotatingMachine/test/e2e/curve-prediction-benchmark.py
|
||||
cat /tmp/rm_curve_bench.json
|
||||
```
|
||||
|
||||
#### Expected output (green run, 2026-04-13)
|
||||
|
||||
| Pump | Samples | Flow range | Power range | Pressures | Envelope OK | Monotonic |
|
||||
|-------|---------|-----------:|------------:|----------:|:-----------:|:---------:|
|
||||
| H05K | 12 | 10.3–208.3 m³/h | 12.3–50.3 kW | 700–3900 mbar | ✅ | ✅ |
|
||||
| C5 | 12 | 8.7–45.6 m³/h | 0.69–13.0 kW | 400–2900 mbar | ✅ | ✅ |
|
||||
|
||||
#### Known limitation — out-of-envelope pressure extrapolation
|
||||
|
||||
Feeding a pressure **below** the curve's lowest slice produces extrapolated flow values that can exceed the envelope by orders of magnitude. Example: H05K at 400 mbar (curve min 700 mbar), ctrl=20% → flow ≈ 30 000 m³/h (envelope max 227 m³/h).
|
||||
|
||||
The node does not clamp pressure to the curve envelope; in production this is defended by upstream `measurement` nodes with realistic ranges. Operators deploying a machine should confirm the sensor range matches the curve.
|
||||
|
||||
### `../../../../memory/` companion benchmarks
|
||||
|
||||
The earlier shutdown, interruptibility, and clean-path benchmarks (`rm_e2e_benchmark.py`, `rm_clean.py`, `rm_e2e_verify.py`) live in `/tmp/` during a review session. Promote them into this directory when they need to become permanent smoke tests.
|
||||
449
test/e2e/curve-prediction-benchmark.py
Normal file
449
test/e2e/curve-prediction-benchmark.py
Normal file
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Dual-curve E2E prediction benchmark for rotatingMachine.
|
||||
|
||||
Deploys a Node-RED flow containing TWO rotatingMachine nodes, one per pump
|
||||
curve shipped in generalFunctions/datasets/assetData/curves/. For each curve
|
||||
we run a controlled ctrl x pressure sweep and record the predicted flow and
|
||||
power, plus the efficiency / CoG metrics. Output is a table the team can
|
||||
compare against supplier data sheets.
|
||||
|
||||
This is a live-deploy benchmark (not a unit test) — it exercises the full
|
||||
Node-RED runtime path including delta compression on port 0, curve loading
|
||||
via generalFunctions, and output formatting.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
import websocket
|
||||
|
||||
BASE = "http://localhost:1880"
|
||||
WS = "ws://localhost:1880/comms"
|
||||
CURVES_DIR = "/mnt/d/gitea/EVOLV/nodes/generalFunctions/datasets/assetData/curves"
|
||||
|
||||
PUMPS = [
|
||||
{
|
||||
"id": "H05K",
|
||||
"model": "hidrostal-H05K-S03R",
|
||||
},
|
||||
{
|
||||
"id": "C5",
|
||||
"model": "hidrostal-C5-D03R-SHN1",
|
||||
},
|
||||
]
|
||||
|
||||
events = []
|
||||
start = None
|
||||
lock = threading.Lock()
|
||||
ready = threading.Event()
|
||||
|
||||
|
||||
def on_message(ws, msg):
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except Exception:
|
||||
return
|
||||
for item in (data if isinstance(data, list) else [data]):
|
||||
if str(item.get("topic", "")).startswith("debug"):
|
||||
d = item.get("data", {}) or {}
|
||||
with lock:
|
||||
events.append({
|
||||
"t": round(time.time() - start, 3),
|
||||
"name": d.get("name"),
|
||||
"msg": d.get("msg"),
|
||||
})
|
||||
|
||||
|
||||
def on_open(ws):
|
||||
ws.send(json.dumps({"subscribe": "debug"}))
|
||||
ready.set()
|
||||
|
||||
|
||||
def ws_thread():
|
||||
websocket.WebSocketApp(WS, on_message=on_message, on_open=on_open).run_forever()
|
||||
|
||||
|
||||
def deploy(flow):
|
||||
r = requests.post(
|
||||
f"{BASE}/flows",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Node-RED-Deployment-Type": "full",
|
||||
},
|
||||
data=json.dumps(flow),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.text
|
||||
|
||||
|
||||
def inject(node_id):
|
||||
r = requests.post(f"{BASE}/inject/{node_id}", timeout=5)
|
||||
return r.status_code
|
||||
|
||||
|
||||
def port0(node_tag):
|
||||
"""Return the most recent parsed port-0 payload for a given pump tag."""
|
||||
debug_name = f"P0-{node_tag}"
|
||||
with lock:
|
||||
for e in reversed(events):
|
||||
if e["name"] == debug_name:
|
||||
try:
|
||||
return json.loads(e["msg"])
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def curve_envelope(model):
|
||||
d = json.load(open(os.path.join(CURVES_DIR, f"{model}.json")))
|
||||
pressures = sorted(int(k) for k in d["nq"].keys() if re.fullmatch(r"-?\d+", k))
|
||||
flow_vals = [v for p in pressures for v in d["nq"][str(p)]["y"]]
|
||||
power_vals = [v for p in pressures for v in d["np"][str(p)]["y"]]
|
||||
return {
|
||||
"pressures": pressures,
|
||||
"p_low": pressures[0],
|
||||
"p_mid": pressures[len(pressures) // 2],
|
||||
"p_high": pressures[-1],
|
||||
"flow_range": (min(flow_vals), max(flow_vals)),
|
||||
"power_range": (min(power_vals), max(power_vals)),
|
||||
}
|
||||
|
||||
|
||||
def build_flow():
|
||||
"""Construct a Node-RED flow with one tab holding both pumps + injects + function nodes."""
|
||||
flow = [{"id": "curve_bench_tab", "type": "tab", "label": "Curve Benchmark", "disabled": False}]
|
||||
|
||||
# Generate an id-pool for injects and function nodes
|
||||
def nid(prefix, i=0):
|
||||
return f"{prefix}-{i}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
for pump in PUMPS:
|
||||
pid = pump["id"]
|
||||
tab = "curve_bench_tab"
|
||||
|
||||
# rotatingMachine node
|
||||
rm_id = f"rm_{pid}"
|
||||
flow.append({
|
||||
"id": rm_id,
|
||||
"type": "rotatingMachine",
|
||||
"z": tab,
|
||||
"name": f"Pump-{pid}",
|
||||
"speed": "50", # fast ramp for benchmark
|
||||
"startup": "0",
|
||||
"warmup": "0",
|
||||
"shutdown": "0",
|
||||
"cooldown": "0",
|
||||
"movementMode": "staticspeed",
|
||||
"machineCurve": "",
|
||||
"uuid": f"bench-{pid}",
|
||||
"supplier": "hidrostal",
|
||||
"category": "pump",
|
||||
"assetType": "pump-centrifugal",
|
||||
"model": pump["model"],
|
||||
"unit": "m3/h",
|
||||
"curvePressureUnit": "mbar",
|
||||
"curveFlowUnit": "m3/h",
|
||||
"curvePowerUnit": "kW",
|
||||
"curveControlUnit": "%",
|
||||
"enableLog": False,
|
||||
"logLevel": "error",
|
||||
"positionVsParent": "atEquipment",
|
||||
"positionIcon": "",
|
||||
"hasDistance": False,
|
||||
"distance": 0,
|
||||
"distanceUnit": "m",
|
||||
"distanceDescription": "",
|
||||
"x": 500, "y": 100 + PUMPS.index(pump) * 400,
|
||||
"wires": [[f"fmt_{pid}"], [], []],
|
||||
})
|
||||
|
||||
# function node to merge deltas
|
||||
fmt_id = f"fmt_{pid}"
|
||||
flow.append({
|
||||
"id": fmt_id,
|
||||
"type": "function",
|
||||
"z": tab,
|
||||
"name": f"merge-{pid}",
|
||||
"func": (
|
||||
"const p = msg.payload || {};\n"
|
||||
"const c = context.get('c') || {};\n"
|
||||
"Object.assign(c, p);\n"
|
||||
"context.set('c', c);\n"
|
||||
"function find(prefix) {\n"
|
||||
" for (var k in c) if (k.indexOf(prefix) === 0) return c[k];\n"
|
||||
" return null;\n"
|
||||
"}\n"
|
||||
"msg.payload = {\n"
|
||||
" state: c.state || 'idle',\n"
|
||||
" mode: c.mode || 'auto',\n"
|
||||
" ctrl: c.ctrl != null ? Number(c.ctrl) : null,\n"
|
||||
" flow: find('flow.predicted.downstream.'),\n"
|
||||
" power: find('power.predicted.atequipment.'),\n"
|
||||
" NCog: c.NCog != null ? Number(c.NCog) : null,\n"
|
||||
" cog: c.cog != null ? Number(c.cog) : null,\n"
|
||||
" pU: find('pressure.measured.upstream.'),\n"
|
||||
" pD: find('pressure.measured.downstream.')\n"
|
||||
"};\n"
|
||||
"return msg;"
|
||||
),
|
||||
"outputs": 1,
|
||||
"x": 760, "y": 100 + PUMPS.index(pump) * 400,
|
||||
"wires": [[f"dbg_{pid}"]],
|
||||
})
|
||||
|
||||
# debug node
|
||||
flow.append({
|
||||
"id": f"dbg_{pid}",
|
||||
"type": "debug",
|
||||
"z": tab,
|
||||
"name": f"P0-{pid}",
|
||||
"active": True, "tosidebar": True, "console": False, "tostatus": False,
|
||||
"complete": "payload", "targetType": "msg",
|
||||
"x": 1000, "y": 100 + PUMPS.index(pump) * 400,
|
||||
"wires": [],
|
||||
})
|
||||
|
||||
# injects
|
||||
def mk_inject(name, topic, payload, y_offset):
|
||||
return {
|
||||
"id": f"inj_{pid}_{name.replace(' ', '_')}",
|
||||
"type": "inject",
|
||||
"z": tab,
|
||||
"name": name,
|
||||
"props": [
|
||||
{"p": "topic", "vt": "str"},
|
||||
{"p": "payload"},
|
||||
],
|
||||
"topic": topic,
|
||||
"payload": payload,
|
||||
"payloadType": "json",
|
||||
"repeat": "", "crontab": "", "once": False, "onceDelay": "",
|
||||
"x": 200, "y": y_offset,
|
||||
"wires": [[rm_id]],
|
||||
}
|
||||
|
||||
base_y = 100 + PUMPS.index(pump) * 400
|
||||
flow.append({
|
||||
**mk_inject("setMode-virtual", "setMode", "\"virtualControl\"", base_y + 40),
|
||||
"payloadType": "str",
|
||||
"payload": "virtualControl",
|
||||
})
|
||||
flow.append(mk_inject(
|
||||
"Startup", "execSequence",
|
||||
json.dumps({"source": "GUI", "action": "execSequence", "parameter": "startup"}),
|
||||
base_y + 80,
|
||||
))
|
||||
|
||||
return flow
|
||||
|
||||
|
||||
def run_sweep(pump_id, model, envelope):
|
||||
"""For one pump, sweep (pressure, ctrl) and collect predictions."""
|
||||
results = []
|
||||
# Use 3 pressures (low/mid/high) and 4 ctrl levels
|
||||
pressures = [envelope["p_low"], envelope["p_mid"], envelope["p_high"]]
|
||||
ctrls = [20, 40, 60, 80]
|
||||
|
||||
for p in pressures:
|
||||
# Inject pressures via the simulateMeasurement topic -- we'll do this
|
||||
# via the Node-RED admin API using a raw msg injection helper: send
|
||||
# via a synthetic inject. Easiest: create ephemeral inject? Simpler:
|
||||
# just POST directly to the node using the admin API is not possible
|
||||
# without a pre-wired inject. Instead we call the node via websocket
|
||||
# notify? Simpler: deploy a pair of dedicated 'sim' injects per pump.
|
||||
# But we want a dynamic sweep. Workaround: use the Node-RED http-in?
|
||||
# Best path: spawn a temporary inject at deploy time. Not trivial.
|
||||
#
|
||||
# Alternative that works with the deployed flow: post a message by
|
||||
# using the /inject admin endpoint with an inject node whose payload
|
||||
# we rewrite via PUT /flow. Simplest in practice: keep the flow
|
||||
# static but use the programmable approach: send msg via socket.
|
||||
# Here we'll just use 3 simulate injects per pump (low/mid/high).
|
||||
# Since we haven't built those, we fall back to modifying the flow
|
||||
# dynamically for each pressure.
|
||||
pass # <-- replaced below with alt strategy
|
||||
return results
|
||||
|
||||
|
||||
def build_sweep_flow(pressure):
|
||||
"""Build a flow where pressures for both pumps are pinned to `pressure`."""
|
||||
flow = build_flow()
|
||||
for pump in PUMPS:
|
||||
pid = pump["id"]
|
||||
rm_id = f"rm_{pid}"
|
||||
tab = "curve_bench_tab"
|
||||
base_y = 100 + PUMPS.index(pump) * 400
|
||||
|
||||
def inj(name, topic, payload_json, y):
|
||||
return {
|
||||
"id": f"sim_{pid}_{name}",
|
||||
"type": "inject",
|
||||
"z": tab,
|
||||
"name": name,
|
||||
"props": [{"p": "topic", "vt": "str"}, {"p": "payload"}],
|
||||
"topic": topic,
|
||||
"payload": payload_json,
|
||||
"payloadType": "json",
|
||||
"repeat": "", "crontab": "", "once": True, "onceDelay": "1",
|
||||
"x": 200, "y": y,
|
||||
"wires": [[rm_id]],
|
||||
}
|
||||
|
||||
flow.append(inj(
|
||||
"sim-pU", "simulateMeasurement",
|
||||
json.dumps({"type": "pressure", "position": "upstream", "value": 0, "unit": "mbar"}),
|
||||
base_y + 160,
|
||||
))
|
||||
flow.append(inj(
|
||||
"sim-pD", "simulateMeasurement",
|
||||
json.dumps({"type": "pressure", "position": "downstream", "value": pressure, "unit": "mbar"}),
|
||||
base_y + 200,
|
||||
))
|
||||
|
||||
# Setpoint injects (20/40/60/80)
|
||||
for k, val in enumerate([20, 40, 60, 80]):
|
||||
flow.append({
|
||||
"id": f"mv_{pid}_{val}",
|
||||
"type": "inject",
|
||||
"z": tab,
|
||||
"name": f"Set {val}%",
|
||||
"props": [{"p": "topic", "vt": "str"}, {"p": "payload"}],
|
||||
"topic": "execMovement",
|
||||
"payload": json.dumps({"source": "GUI", "action": "execMovement", "setpoint": val}),
|
||||
"payloadType": "json",
|
||||
"repeat": "", "crontab": "", "once": False, "onceDelay": "",
|
||||
"x": 200, "y": base_y + 240 + k * 40,
|
||||
"wires": [[rm_id]],
|
||||
})
|
||||
|
||||
return flow
|
||||
|
||||
|
||||
def main():
|
||||
global start
|
||||
start = time.time()
|
||||
threading.Thread(target=ws_thread, daemon=True).start()
|
||||
ready.wait(5)
|
||||
|
||||
results_by_pump = {p["id"]: {"model": p["model"], "envelope": curve_envelope(p["model"]), "sweeps": []} for p in PUMPS}
|
||||
|
||||
# Per-pump pressure plan: each pump sees only pressures inside its own
|
||||
# curve envelope. Out-of-range extrapolation is a known limitation
|
||||
# (see rm memory / known-issues) and is tested separately below.
|
||||
pressure_plan = []
|
||||
seen = set()
|
||||
for p in PUMPS:
|
||||
env = results_by_pump[p["id"]]["envelope"]
|
||||
for label, val in (("low", env["p_low"]), ("mid", env["p_mid"]), ("high", env["p_high"])):
|
||||
key = (p["id"], val)
|
||||
if key not in seen:
|
||||
pressure_plan.append({"pump_id": p["id"], "pressure": val, "label": label})
|
||||
seen.add(key)
|
||||
|
||||
# Group by pressure so both pumps share a sweep when pressures overlap.
|
||||
pressures = sorted({row["pressure"] for row in pressure_plan})
|
||||
pump_allowed_at = {p: [row["pump_id"] for row in pressure_plan if row["pressure"] == p] for p in pressures}
|
||||
|
||||
for pressure in pressures:
|
||||
allowed = pump_allowed_at[pressure]
|
||||
flow = build_sweep_flow(pressure)
|
||||
print(f"\n=== Deploying sweep at pressure={pressure} mbar (pumps in range: {allowed}) ===")
|
||||
with lock:
|
||||
events.clear()
|
||||
deploy(flow)
|
||||
# allow pumps to register and reach operational
|
||||
time.sleep(4)
|
||||
# startup both pumps
|
||||
for pump in PUMPS:
|
||||
pid = pump["id"]
|
||||
inject(f"inj_{pid}_setMode-virtual")
|
||||
time.sleep(0.2)
|
||||
inject(f"inj_{pid}_Startup")
|
||||
time.sleep(3) # reach operational (startup=0, warmup=0 -> immediate)
|
||||
|
||||
# pressure injects were set to once=True so they fire on deploy. Wait.
|
||||
time.sleep(2)
|
||||
|
||||
for val in [20, 40, 60, 80]:
|
||||
for pump in PUMPS:
|
||||
if pump["id"] not in allowed:
|
||||
continue
|
||||
inject(f"mv_{pump['id']}_{val}")
|
||||
# ramp takes (val)/(speed=50) = val/50 s; plus a safety tick
|
||||
time.sleep(max(2.5, val / 50 + 1.5))
|
||||
for pump in PUMPS:
|
||||
if pump["id"] not in allowed:
|
||||
continue
|
||||
pid = pump["id"]
|
||||
data = port0(pid)
|
||||
if not data:
|
||||
continue
|
||||
entry = {
|
||||
"pressure": pressure,
|
||||
"setpoint": val,
|
||||
"state": data.get("state"),
|
||||
"ctrl": data.get("ctrl"),
|
||||
"flow": data.get("flow"),
|
||||
"power": data.get("power"),
|
||||
"NCog": data.get("NCog"),
|
||||
"cog": data.get("cog"),
|
||||
}
|
||||
results_by_pump[pump["id"]]["sweeps"].append(entry)
|
||||
print(f" [{pump['id']}] p={pressure} setpoint={val} ctrl={entry['ctrl']} flow={entry['flow']} power={entry['power']} NCog={entry['NCog']}")
|
||||
|
||||
# Envelope sanity check
|
||||
print("\n======== SUMMARY ========")
|
||||
out = {}
|
||||
for pid, info in results_by_pump.items():
|
||||
env = info["envelope"]
|
||||
good = 0; bad = 0; notes = []
|
||||
prior_flow_by_p = {}
|
||||
for row in info["sweeps"]:
|
||||
if row["flow"] is None or row["power"] is None:
|
||||
bad += 1; continue
|
||||
if row["flow"] < -1:
|
||||
bad += 1; notes.append(f"negative flow: {row}")
|
||||
elif row["power"] < -1:
|
||||
bad += 1; notes.append(f"negative power: {row}")
|
||||
elif row["flow"] > env["flow_range"][1] * 2:
|
||||
bad += 1; notes.append(f"flow above envelope {env['flow_range'][1]}: {row}")
|
||||
else:
|
||||
good += 1
|
||||
# monotonicity in ctrl at fixed pressure
|
||||
by_p = {}
|
||||
for row in info["sweeps"]:
|
||||
by_p.setdefault(row["pressure"], []).append(row)
|
||||
mono_ok = True
|
||||
for p, rows in by_p.items():
|
||||
rows.sort(key=lambda r: r["setpoint"])
|
||||
flows = [r["flow"] for r in rows if r["flow"] is not None]
|
||||
for i in range(1, len(flows)):
|
||||
if flows[i] < flows[i-1] * 0.95:
|
||||
mono_ok = False
|
||||
notes.append(f"flow drops at p={p}: {flows}")
|
||||
break
|
||||
print(f"\n[{pid}] model={info['model']}")
|
||||
print(f" envelope flow {env['flow_range']} power {env['power_range']} pressures {env['p_low']}..{env['p_high']} mbar")
|
||||
print(f" sweep samples: good={good} bad={bad}")
|
||||
print(f" ctrl-monotonic: {mono_ok}")
|
||||
if notes:
|
||||
print(f" notes: {notes[:3]}")
|
||||
out[pid] = {
|
||||
"model": info["model"],
|
||||
"envelope": env,
|
||||
"samples": info["sweeps"],
|
||||
"good": good, "bad": bad, "mono_ok": mono_ok,
|
||||
}
|
||||
json.dump(out, open("/tmp/rm_curve_bench.json", "w"), indent=2, default=str)
|
||||
print("\nfull results -> /tmp/rm_curve_bench.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -34,22 +34,20 @@ test('setpoint is constrained to safe movement/curve bounds', async () => {
|
||||
assert.equal(requested[1], max);
|
||||
});
|
||||
|
||||
test('nodeClass _updateNodeStatus returns error status on internal failure', () => {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
const node = makeNodeStub();
|
||||
inst.node = node;
|
||||
inst.source = {
|
||||
test('source.getStatusBadge returns error status on internal failure', () => {
|
||||
// Status badge lives on the domain post-refactor. Build a tiny stub
|
||||
// that throws to verify the error-path returns an error badge.
|
||||
const errors = [];
|
||||
const source = {
|
||||
currentMode: 'auto',
|
||||
state: {
|
||||
getCurrentState() {
|
||||
throw new Error('boom');
|
||||
},
|
||||
},
|
||||
state: { getCurrentState() { throw new Error('boom'); } },
|
||||
logger: { error: (m) => errors.push(m) },
|
||||
};
|
||||
|
||||
const status = inst._updateNodeStatus();
|
||||
assert.equal(status.text, 'Status Error');
|
||||
assert.equal(node._errors.length, 1);
|
||||
const { buildStatusBadge } = require('../../src/io/output');
|
||||
const status = buildStatusBadge(source);
|
||||
assert.match(status.text, /Status Error/);
|
||||
assert.equal(status.fill, 'red');
|
||||
assert.equal(errors.length, 1);
|
||||
});
|
||||
|
||||
test('measurement handlers reject incompatible units', () => {
|
||||
|
||||
63
test/edge/listener-cleanup.edge.test.js
Normal file
63
test/edge/listener-cleanup.edge.test.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { makeMachineConfig, makeStateConfig, makeChildMeasurement } = require('../helpers/factories');
|
||||
|
||||
test('childMeasurementListeners are cleared and state emitter cleaned on simulated close', () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
// Register a child measurement — this adds listeners
|
||||
const child = makeChildMeasurement({ id: 'pt-1', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
|
||||
machine.registerChild(child, 'measurement');
|
||||
|
||||
assert.ok(machine.childMeasurementListeners.size > 0, 'Should have listeners after registration');
|
||||
|
||||
const stateEmitterListenerCount = machine.state.emitter.listenerCount('positionChange') +
|
||||
machine.state.emitter.listenerCount('stateChange');
|
||||
assert.ok(stateEmitterListenerCount > 0, 'State emitter should have listeners');
|
||||
|
||||
// Simulate the cleanup that nodeClass close handler does
|
||||
for (const [, entry] of machine.childMeasurementListeners) {
|
||||
if (typeof entry.emitter?.off === 'function') {
|
||||
entry.emitter.off(entry.eventName, entry.handler);
|
||||
} else if (typeof entry.emitter?.removeListener === 'function') {
|
||||
entry.emitter.removeListener(entry.eventName, entry.handler);
|
||||
}
|
||||
}
|
||||
machine.childMeasurementListeners.clear();
|
||||
machine.state.emitter.removeAllListeners();
|
||||
|
||||
assert.equal(machine.childMeasurementListeners.size, 0, 'Listeners map should be empty after cleanup');
|
||||
assert.equal(machine.state.emitter.listenerCount('positionChange'), 0);
|
||||
assert.equal(machine.state.emitter.listenerCount('stateChange'), 0);
|
||||
});
|
||||
|
||||
test('re-registration does not accumulate listeners', () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
const child = makeChildMeasurement({ id: 'pt-1', positionVsParent: 'downstream', type: 'pressure', unit: 'mbar' });
|
||||
|
||||
// Register 3 times
|
||||
machine.registerChild(child, 'measurement');
|
||||
machine.registerChild(child, 'measurement');
|
||||
machine.registerChild(child, 'measurement');
|
||||
|
||||
// Should only have 1 listener entry per child+event combo
|
||||
const eventName = 'pressure.measured.downstream';
|
||||
const listenerCount = child.measurements.emitter.listenerCount(eventName);
|
||||
assert.equal(listenerCount, 1, `Should have exactly 1 listener, got ${listenerCount}`);
|
||||
});
|
||||
|
||||
test('virtual pressure children have their listeners managed', () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
// Virtual children are created in constructor — verify listeners exist
|
||||
const upstreamChild = machine.virtualPressureChildren.upstream;
|
||||
const downstreamChild = machine.virtualPressureChildren.downstream;
|
||||
|
||||
assert.ok(upstreamChild, 'Upstream virtual child should exist');
|
||||
assert.ok(downstreamChild, 'Downstream virtual child should exist');
|
||||
assert.ok(upstreamChild.measurements, 'Upstream should have measurements container');
|
||||
assert.ok(downstreamChild.measurements, 'Downstream should have measurements container');
|
||||
});
|
||||
132
test/edge/negative-zero-guards.edge.test.js
Normal file
132
test/edge/negative-zero-guards.edge.test.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||
|
||||
test('calcEfficiency with zero power and flow does not produce efficiency value', () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||
|
||||
machine.measurements.type('pressure').variant('measured').position('downstream').value(1000, Date.now(), 'mbar');
|
||||
machine.measurements.type('pressure').variant('measured').position('upstream').value(800, Date.now(), 'mbar');
|
||||
machine.measurements.type('flow').variant('predicted').position('atEquipment').value(0, Date.now(), 'm3/h');
|
||||
machine.measurements.type('power').variant('predicted').position('atEquipment').value(0, Date.now(), 'kW');
|
||||
|
||||
// Should not throw
|
||||
assert.doesNotThrow(() => machine.calcEfficiency(0, 0, 'predicted'));
|
||||
});
|
||||
|
||||
test('calcEfficiency with negative power does not produce corrupt efficiency', () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||
|
||||
machine.measurements.type('pressure').variant('measured').position('downstream').value(1000, Date.now(), 'mbar');
|
||||
machine.measurements.type('pressure').variant('measured').position('upstream').value(800, Date.now(), 'mbar');
|
||||
machine.measurements.type('flow').variant('predicted').position('atEquipment').value(100, Date.now(), 'm3/h');
|
||||
machine.measurements.type('power').variant('predicted').position('atEquipment').value(-5, Date.now(), 'kW');
|
||||
|
||||
// Should not crash or produce negative efficiency
|
||||
assert.doesNotThrow(() => machine.calcEfficiency(-5, 100, 'predicted'));
|
||||
|
||||
const eff = machine.measurements.type('efficiency').variant('predicted').position('atEquipment').getCurrentValue();
|
||||
// Efficiency should not have been updated with negative power (guard: power > 0)
|
||||
assert.ok(eff === undefined || eff === null || eff >= 0, 'Efficiency should not be negative');
|
||||
});
|
||||
|
||||
test('calcCog returns safe defaults when no curve data available', () => {
|
||||
const machine = new Machine(
|
||||
makeMachineConfig({ asset: { model: null } }),
|
||||
makeStateConfig()
|
||||
);
|
||||
|
||||
const result = machine.calcCog();
|
||||
|
||||
assert.equal(result.cog, 0);
|
||||
assert.equal(result.cogIndex, 0);
|
||||
assert.equal(result.NCog, 0);
|
||||
assert.equal(result.minEfficiency, 0);
|
||||
});
|
||||
|
||||
test('getCurrentCurves returns empty arrays when no curve data available', () => {
|
||||
const machine = new Machine(
|
||||
makeMachineConfig({ asset: { model: null } }),
|
||||
makeStateConfig()
|
||||
);
|
||||
|
||||
const { powerCurve, flowCurve } = machine.getCurrentCurves();
|
||||
|
||||
assert.deepEqual(powerCurve, { x: [], y: [] });
|
||||
assert.deepEqual(flowCurve, { x: [], y: [] });
|
||||
});
|
||||
|
||||
test('getCompleteCurve returns null when no curve data available', () => {
|
||||
const machine = new Machine(
|
||||
makeMachineConfig({ asset: { model: null } }),
|
||||
makeStateConfig()
|
||||
);
|
||||
|
||||
const { powerCurve, flowCurve } = machine.getCompleteCurve();
|
||||
|
||||
assert.equal(powerCurve, null);
|
||||
assert.equal(flowCurve, null);
|
||||
});
|
||||
|
||||
test('calcFlow returns 0 when no curve data available', () => {
|
||||
const machine = new Machine(
|
||||
makeMachineConfig({ asset: { model: null } }),
|
||||
makeStateConfig({ state: { current: 'operational' } })
|
||||
);
|
||||
|
||||
const flow = machine.calcFlow(50);
|
||||
assert.equal(flow, 0);
|
||||
});
|
||||
|
||||
test('calcPower returns 0 when no curve data available', () => {
|
||||
const machine = new Machine(
|
||||
makeMachineConfig({ asset: { model: null } }),
|
||||
makeStateConfig({ state: { current: 'operational' } })
|
||||
);
|
||||
|
||||
const power = machine.calcPower(50);
|
||||
assert.equal(power, 0);
|
||||
});
|
||||
|
||||
test('inputFlowCalcPower returns 0 when no curve data available', () => {
|
||||
const machine = new Machine(
|
||||
makeMachineConfig({ asset: { model: null } }),
|
||||
makeStateConfig({ state: { current: 'operational' } })
|
||||
);
|
||||
|
||||
const power = machine.inputFlowCalcPower(100);
|
||||
assert.equal(power, 0);
|
||||
});
|
||||
|
||||
test('getMeasuredPressure returns 0 when no curve data available', () => {
|
||||
const machine = new Machine(
|
||||
makeMachineConfig({ asset: { model: null } }),
|
||||
makeStateConfig()
|
||||
);
|
||||
|
||||
const pressure = machine.getMeasuredPressure();
|
||||
assert.equal(pressure, 0);
|
||||
});
|
||||
|
||||
test('updateCurve bootstraps predictors when they were null', () => {
|
||||
const machine = new Machine(
|
||||
makeMachineConfig({ asset: { model: null } }),
|
||||
makeStateConfig()
|
||||
);
|
||||
|
||||
assert.equal(machine.hasCurve, false);
|
||||
assert.equal(machine.predictFlow, null);
|
||||
|
||||
// Load a real curve into a machine that started without one
|
||||
const { loadCurve } = require('generalFunctions');
|
||||
const realCurve = loadCurve('hidrostal-H05K-S03R');
|
||||
|
||||
assert.doesNotThrow(() => machine.updateCurve(realCurve));
|
||||
|
||||
assert.equal(machine.hasCurve, true);
|
||||
assert.ok(machine.predictFlow !== null);
|
||||
assert.ok(machine.predictPower !== null);
|
||||
assert.ok(machine.predictCtrl !== null);
|
||||
});
|
||||
@@ -2,89 +2,75 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const NodeClass = require('../../src/nodeClass');
|
||||
const commands = require('../../src/commands');
|
||||
const { createRegistry } = require('generalFunctions');
|
||||
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
|
||||
|
||||
test('input handler routes topics to source methods', () => {
|
||||
// Post-BaseNodeAdapter, dispatch is the commands-registry. These tests
|
||||
// drive the same surface from a prototype-derived nodeClass instance to
|
||||
// keep the routing covered without booting Node-RED.
|
||||
|
||||
function makeSourceStub() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
|
||||
childRegistrationUtils: { registerChild(childSource, pos) { calls.push(['registerChild', childSource, pos]); } },
|
||||
setMode(mode) { calls.push(['setMode', mode]); },
|
||||
handleInput(source, action, parameter) { calls.push(['handleInput', source, action, parameter]); return Promise.resolve(); },
|
||||
showWorkingCurves() { return { ok: true }; },
|
||||
showCoG() { return { cog: 1 }; },
|
||||
updateSimulatedMeasurement(type, position, value) { calls.push(['updateSimulatedMeasurement', type, position, value]); },
|
||||
updateMeasuredPressure(value, position) { calls.push(['updateMeasuredPressure', value, position]); },
|
||||
updateMeasuredFlow(value, position) { calls.push(['updateMeasuredFlow', value, position]); },
|
||||
updateMeasuredPower(value, position) { calls.push(['updateMeasuredPower', value, position]); },
|
||||
updateMeasuredTemperature(value, position) { calls.push(['updateMeasuredTemperature', value, position]); },
|
||||
isUnitValidForType() { return true; },
|
||||
};
|
||||
}
|
||||
|
||||
test('input handler routes topics to source methods via commands registry', async () => {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
const node = makeNodeStub();
|
||||
|
||||
const calls = [];
|
||||
const source = makeSourceStub();
|
||||
inst.node = node;
|
||||
inst.RED = makeREDStub({
|
||||
child1: {
|
||||
source: { id: 'child-source' },
|
||||
},
|
||||
});
|
||||
|
||||
inst.source = {
|
||||
childRegistrationUtils: {
|
||||
registerChild(childSource, pos) {
|
||||
calls.push(['registerChild', childSource, pos]);
|
||||
},
|
||||
},
|
||||
setMode(mode) {
|
||||
calls.push(['setMode', mode]);
|
||||
},
|
||||
handleInput(source, action, parameter) {
|
||||
calls.push(['handleInput', source, action, parameter]);
|
||||
},
|
||||
showWorkingCurves() {
|
||||
return { ok: true };
|
||||
},
|
||||
showCoG() {
|
||||
return { cog: 1 };
|
||||
},
|
||||
updateSimulatedMeasurement(type, position, value) {
|
||||
calls.push(['updateSimulatedMeasurement', type, position, value]);
|
||||
},
|
||||
updateMeasuredPressure(value, position) {
|
||||
calls.push(['updateMeasuredPressure', value, position]);
|
||||
},
|
||||
updateMeasuredFlow(value, position) {
|
||||
calls.push(['updateMeasuredFlow', value, position]);
|
||||
},
|
||||
updateMeasuredPower(value, position) {
|
||||
calls.push(['updateMeasuredPower', value, position]);
|
||||
},
|
||||
updateMeasuredTemperature(value, position) {
|
||||
calls.push(['updateMeasuredTemperature', value, position]);
|
||||
},
|
||||
isUnitValidForType() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
inst.RED = makeREDStub({ child1: { source: { id: 'child-source' } } });
|
||||
inst.source = source;
|
||||
inst._commands = createRegistry(commands, { logger: source.logger });
|
||||
inst._attachInputHandler();
|
||||
const onInput = node._handlers.input;
|
||||
|
||||
onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
|
||||
onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'execSequence', parameter: 'startup' } }, () => {}, () => {});
|
||||
onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
|
||||
onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
|
||||
onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
|
||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
|
||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {});
|
||||
await onInput({ topic: 'setMode', payload: 'auto' }, () => {}, () => {});
|
||||
await onInput({ topic: 'execSequence', payload: { source: 'GUI', action: 'startup' } }, () => {}, () => {});
|
||||
await onInput({ topic: 'flowMovement', payload: { source: 'GUI', action: 'flowMovement', setpoint: 123 } }, () => {}, () => {});
|
||||
await onInput({ topic: 'emergencystop', payload: { source: 'GUI', action: 'emergencystop' } }, () => {}, () => {});
|
||||
await onInput({ topic: 'registerChild', payload: 'child1', positionVsParent: 'downstream' }, () => {}, () => {});
|
||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 250, unit: 'mbar' } }, () => {}, () => {});
|
||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'power', position: 'atEquipment', value: 7.5, unit: 'kW' } }, () => {}, () => {});
|
||||
|
||||
assert.deepEqual(calls[0], ['setMode', 'auto']);
|
||||
assert.deepEqual(calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
|
||||
assert.deepEqual(calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
|
||||
assert.deepEqual(calls[3], ['handleInput', 'GUI', 'emergencystop', undefined]);
|
||||
assert.deepEqual(calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
|
||||
assert.deepEqual(calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
|
||||
assert.deepEqual(calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
|
||||
assert.deepEqual(source.calls[0], ['setMode', 'auto']);
|
||||
assert.deepEqual(source.calls[1], ['handleInput', 'GUI', 'execSequence', 'startup']);
|
||||
assert.deepEqual(source.calls[2], ['handleInput', 'GUI', 'flowMovement', 123]);
|
||||
// estop handler defaults action to 'emergencystop' even without one
|
||||
// supplied, so the trailing arg is undefined — passed as positional.
|
||||
assert.deepEqual(source.calls[3].slice(0, 3), ['handleInput', 'GUI', 'emergencystop']);
|
||||
assert.deepEqual(source.calls[4], ['registerChild', { id: 'child-source' }, 'downstream']);
|
||||
assert.deepEqual(source.calls[5], ['updateSimulatedMeasurement', 'pressure', 'upstream', 250]);
|
||||
assert.deepEqual(source.calls[6], ['updateMeasuredPower', 7.5, 'atEquipment']);
|
||||
});
|
||||
|
||||
test('simulateMeasurement warns and ignores invalid payloads', () => {
|
||||
test('simulateMeasurement warns and ignores invalid payloads', async () => {
|
||||
const warns = [];
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
const node = makeNodeStub();
|
||||
|
||||
const calls = [];
|
||||
inst.node = node;
|
||||
inst.RED = makeREDStub();
|
||||
inst.source = {
|
||||
logger: { warn: (m) => warns.push(m), info: () => {}, debug: () => {}, error: () => {} },
|
||||
childRegistrationUtils: { registerChild() {} },
|
||||
setMode() {},
|
||||
handleInput() {},
|
||||
handleInput() { return Promise.resolve(); },
|
||||
showWorkingCurves() { return {}; },
|
||||
showCoG() { return {}; },
|
||||
updateSimulatedMeasurement() { calls.push('updateSimulatedMeasurement'); },
|
||||
@@ -92,90 +78,67 @@ test('simulateMeasurement warns and ignores invalid payloads', () => {
|
||||
updateMeasuredFlow() { calls.push('updateMeasuredFlow'); },
|
||||
updateMeasuredPower() { calls.push('updateMeasuredPower'); },
|
||||
updateMeasuredTemperature() { calls.push('updateMeasuredTemperature'); },
|
||||
isUnitValidForType() { return true; },
|
||||
};
|
||||
|
||||
inst._commands = createRegistry(commands, { logger: inst.source.logger });
|
||||
inst._attachInputHandler();
|
||||
const onInput = node._handlers.input;
|
||||
|
||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
|
||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
|
||||
onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
|
||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'pressure', position: 'upstream', value: 'not-a-number' } }, () => {}, () => {});
|
||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'flow', position: 'upstream', value: 12 } }, () => {}, () => {});
|
||||
await onInput({ topic: 'simulateMeasurement', payload: { type: 'unknown', position: 'upstream', value: 12, unit: 'm3/h' } }, () => {}, () => {});
|
||||
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(node._warns.length, 3);
|
||||
assert.match(String(node._warns[0]), /finite number/i);
|
||||
assert.match(String(node._warns[1]), /payload\.unit is required/i);
|
||||
assert.match(String(node._warns[2]), /unsupported simulatemeasurement type/i);
|
||||
// Filter out the one-time deprecation warning for the legacy
|
||||
// 'simulateMeasurement' alias — only the three invalid-payload warns
|
||||
// matter for this assertion.
|
||||
const payloadWarns = warns.filter((w) => !/deprecated/i.test(String(w)));
|
||||
assert.equal(payloadWarns.length, 3);
|
||||
assert.match(String(payloadWarns[0]), /finite number/i);
|
||||
assert.match(String(payloadWarns[1]), /payload\.unit is required/i);
|
||||
assert.match(String(payloadWarns[2]), /unsupported simulatemeasurement type/i);
|
||||
});
|
||||
|
||||
test('status shows warning when pressure inputs are not initialized', () => {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
const node = makeNodeStub();
|
||||
|
||||
inst.node = node;
|
||||
inst.source = {
|
||||
test('source.getStatusBadge shows warning when pressure inputs are not initialized', () => {
|
||||
// Status badge now lives on the domain (Machine). Build a tiny stub.
|
||||
const source = {
|
||||
currentMode: 'virtualControl',
|
||||
state: {
|
||||
getCurrentState() {
|
||||
return 'operational';
|
||||
},
|
||||
getCurrentPosition() {
|
||||
return 50;
|
||||
},
|
||||
},
|
||||
getPressureInitializationStatus() {
|
||||
return { initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false };
|
||||
},
|
||||
measurements: {
|
||||
type() {
|
||||
return {
|
||||
variant() {
|
||||
return {
|
||||
position() {
|
||||
return { getCurrentValue() { return 0; } };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
state: { getCurrentState: () => 'operational', getCurrentPosition: () => 50 },
|
||||
pressureInit: { getStatus: () => ({ initialized: false, hasUpstream: false, hasDownstream: false, hasDifferential: false }) },
|
||||
measurements: { type() { return { variant() { return { position() { return { getCurrentValue() { return 0; } }; } }; } }; } },
|
||||
unitPolicy: { output: { flow: 'm3/h' } },
|
||||
logger: { error: () => {} },
|
||||
};
|
||||
|
||||
const status = inst._updateNodeStatus();
|
||||
const statusAgain = inst._updateNodeStatus();
|
||||
|
||||
// Import the buildStatusBadge helper directly — it's the same code the
|
||||
// domain's getStatusBadge() invokes.
|
||||
const { buildStatusBadge } = require('../../src/io/output');
|
||||
const status = buildStatusBadge(source);
|
||||
assert.equal(status.fill, 'yellow');
|
||||
assert.equal(status.shape, 'ring');
|
||||
assert.match(status.text, /pressure not initialized/i);
|
||||
assert.equal(statusAgain.fill, 'yellow');
|
||||
assert.equal(node._warns.length, 1);
|
||||
assert.match(String(node._warns[0]), /Pressure input is not initialized/i);
|
||||
});
|
||||
|
||||
test('showWorkingCurves and CoG route reply messages to process output index', () => {
|
||||
test('showWorkingCurves and CoG route reply messages to process output index', async () => {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
const node = makeNodeStub();
|
||||
const source = {
|
||||
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
|
||||
childRegistrationUtils: { registerChild() {} },
|
||||
setMode() {}, handleInput() { return Promise.resolve(); },
|
||||
showWorkingCurves() { return { curve: [1, 2, 3] }; },
|
||||
showCoG() { return { cog: 0.77 }; },
|
||||
};
|
||||
inst.node = node;
|
||||
inst.RED = makeREDStub();
|
||||
inst.source = {
|
||||
childRegistrationUtils: { registerChild() {} },
|
||||
setMode() {},
|
||||
handleInput() {},
|
||||
showWorkingCurves() {
|
||||
return { curve: [1, 2, 3] };
|
||||
},
|
||||
showCoG() {
|
||||
return { cog: 0.77 };
|
||||
},
|
||||
};
|
||||
|
||||
inst.source = source;
|
||||
inst._commands = createRegistry(commands, { logger: source.logger });
|
||||
inst._attachInputHandler();
|
||||
const onInput = node._handlers.input;
|
||||
const sent = [];
|
||||
const send = (out) => sent.push(out);
|
||||
|
||||
onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
|
||||
onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
|
||||
await onInput({ topic: 'showWorkingCurves', payload: { request: true } }, send, () => {});
|
||||
await onInput({ topic: 'CoG', payload: { request: true } }, send, () => {});
|
||||
|
||||
assert.equal(sent.length, 2);
|
||||
assert.equal(Array.isArray(sent[0]), true);
|
||||
|
||||
121
test/edge/output-format.edge.test.js
Normal file
121
test/edge/output-format.edge.test.js
Normal file
@@ -0,0 +1,121 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||
|
||||
test('getOutput contains all required fields in idle state', () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
const output = machine.getOutput();
|
||||
|
||||
// Core state fields
|
||||
assert.equal(output.state, 'idle');
|
||||
assert.ok('runtime' in output);
|
||||
assert.ok('ctrl' in output);
|
||||
assert.ok('moveTimeleft' in output);
|
||||
assert.ok('mode' in output);
|
||||
assert.ok('maintenanceTime' in output);
|
||||
|
||||
// Efficiency fields
|
||||
assert.ok('cog' in output);
|
||||
assert.ok('NCog' in output);
|
||||
assert.ok('NCogPercent' in output);
|
||||
assert.ok('effDistFromPeak' in output);
|
||||
assert.ok('effRelDistFromPeak' in output);
|
||||
|
||||
// Prediction health fields
|
||||
assert.ok('predictionQuality' in output);
|
||||
assert.ok('predictionConfidence' in output);
|
||||
assert.ok('predictionPressureSource' in output);
|
||||
assert.ok('predictionFlags' in output);
|
||||
|
||||
// Pressure drift fields
|
||||
assert.ok('pressureDriftLevel' in output);
|
||||
assert.ok('pressureDriftSource' in output);
|
||||
assert.ok('pressureDriftFlags' in output);
|
||||
});
|
||||
|
||||
test('getOutput flow drift fields appear after sufficient measured flow samples', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||
await machine.handleInput('parent', 'execMovement', 50);
|
||||
|
||||
// Provide multiple measured flow samples to trigger valid drift assessment
|
||||
const baseTime = Date.now();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
machine.updateMeasuredFlow(100 + i, 'downstream', {
|
||||
timestamp: baseTime + (i * 1000),
|
||||
unit: 'm3/h',
|
||||
childId: 'flow-sensor',
|
||||
childName: 'FT-1',
|
||||
});
|
||||
}
|
||||
|
||||
const output = machine.getOutput();
|
||||
|
||||
// Drift fields should appear once enough samples provide a valid assessment
|
||||
if ('flowNrmse' in output) {
|
||||
assert.ok(typeof output.flowNrmse === 'number');
|
||||
assert.ok('flowDriftValid' in output);
|
||||
}
|
||||
// At minimum, prediction health fields should always be present
|
||||
assert.ok('predictionQuality' in output);
|
||||
assert.ok('predictionConfidence' in output);
|
||||
});
|
||||
|
||||
test('getOutput prediction confidence is 0 in non-operational state', () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
const output = machine.getOutput();
|
||||
|
||||
assert.equal(output.predictionConfidence, 0);
|
||||
});
|
||||
|
||||
test('getOutput prediction confidence reflects differential pressure', () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||
|
||||
// Differential pressure → high confidence
|
||||
machine.updateMeasuredPressure(800, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
|
||||
machine.updateMeasuredPressure(1200, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||
|
||||
const output = machine.getOutput();
|
||||
|
||||
assert.ok(output.predictionConfidence >= 0.8, `Confidence ${output.predictionConfidence} should be >= 0.8 with differential pressure`);
|
||||
assert.equal(output.predictionPressureSource, 'differential');
|
||||
});
|
||||
|
||||
test('getOutput values are in configured output units not canonical', () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||
|
||||
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||
machine.updatePosition();
|
||||
|
||||
const output = machine.getOutput();
|
||||
|
||||
// Flow keys should contain values in m3/h (configured), not m3/s (canonical)
|
||||
// Predicted flow at minimum pressure should be in a reasonable m3/h range, not ~0.003 m3/s
|
||||
const flowKey = Object.keys(output).find(k => k.startsWith('flow.predicted.downstream'));
|
||||
if (flowKey) {
|
||||
const flowVal = output[flowKey];
|
||||
assert.ok(typeof flowVal === 'number', 'Flow output should be a number');
|
||||
// m3/h values are typically 0-300, m3/s values are 0-0.08
|
||||
// If in canonical units it would be very small
|
||||
if (flowVal > 0) {
|
||||
assert.ok(flowVal > 0.1, `Flow value ${flowVal} looks like canonical m3/s, should be m3/h`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('getOutput NCogPercent is correctly derived from NCog', () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||
|
||||
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||
machine.updatePosition();
|
||||
|
||||
const output = machine.getOutput();
|
||||
const expected = Math.round(output.NCog * 100 * 100) / 100;
|
||||
assert.equal(output.NCogPercent, expected, 'NCogPercent should be NCog * 100, rounded to 2 decimals');
|
||||
});
|
||||
164
test/integration/abort-deadlock.integration.test.js
Normal file
164
test/integration/abort-deadlock.integration.test.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// Reproducer: pump's state machine deadlocks in 'accelerating' under
|
||||
// rapid setpoint retargeting.
|
||||
//
|
||||
// The demo flow drives MGC to call `abortActiveMovements` on every
|
||||
// handleInput. If a movement aborts mid-flight, state.moveTo's catch
|
||||
// block keeps the FSM in 'accelerating' (avoids a bounce loop). Any
|
||||
// NEXT setpoint then hits state.moveTo's early-return at the top:
|
||||
//
|
||||
// if (this.stateManager.getCurrentState() !== "operational") {
|
||||
// this.delayedMove = targetPosition;
|
||||
// return; // ← never moves
|
||||
// }
|
||||
//
|
||||
// `delayedMove` only fires from the SUCCESS branch of an active
|
||||
// moveTo, which can't run because state is stuck. Result: pump's
|
||||
// currentPosition freezes; ctrl.predicted keeps updating (set inside
|
||||
// calcCtrl regardless of whether setpoint actually moves) so the
|
||||
// dashboard shows non-zero ctrl% but the editor badge stays at 0.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { POSITIONS } = require('generalFunctions');
|
||||
|
||||
const stateConfig = {
|
||||
general: { logging: { enabled: false, logLevel: 'error' } },
|
||||
state: { current: 'idle' },
|
||||
movement: { mode: 'staticspeed', speed: 10, maxSpeed: 100, interval: 50 },
|
||||
// Match demo's slow ramp.
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
};
|
||||
|
||||
function machineConfig() {
|
||||
return {
|
||||
general: { id: 'p1', name: 'p1', unit: 'm3/h',
|
||||
logging: { enabled: false, logLevel: 'error' } },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal',
|
||||
model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
allowedSources: { auto: ['parent', 'GUI'] },
|
||||
},
|
||||
sequences: {
|
||||
startup: ['starting', 'warmingup', 'operational'],
|
||||
shutdown: ['stopping', 'coolingdown', 'idle'],
|
||||
emergencystop: ['emergencystop', 'off'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeMachineOperational() {
|
||||
const m = new Machine(machineConfig(), stateConfig);
|
||||
m.updateMeasuredPressure(0, 'upstream',
|
||||
{ timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: 'up-1' });
|
||||
m.updateMeasuredPressure(1100, 'downstream',
|
||||
{ timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: 'dn-1' });
|
||||
return m;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
test('parking deadlock: state stuck in accelerating swallows new setpoints', async () => {
|
||||
// Direct reproducer of state.moveTo's early-return path. Force the
|
||||
// FSM into 'accelerating' (the post-abort residue), then issue a new
|
||||
// setpoint. The early-return at state.js:68 saves delayedMove and
|
||||
// returns; delayedMove never fires because nothing transitions back
|
||||
// to operational.
|
||||
|
||||
const m = makeMachineOperational();
|
||||
await m.handleInput('parent', 'execsequence', 'startup');
|
||||
for (let i = 0; i < 50 && m.state.getCurrentState() !== 'operational'; i++) await sleep(20);
|
||||
assert.equal(m.state.getCurrentState(), 'operational');
|
||||
|
||||
// Force state to 'accelerating' (mimic the post-abort residue) by
|
||||
// poking the underlying stateManager directly. This bypasses the
|
||||
// race conditions and isolates the early-return branch.
|
||||
await m.state.stateManager.transitionTo('accelerating');
|
||||
assert.equal(m.state.getCurrentState(), 'accelerating');
|
||||
const positionBefore = m.state.getCurrentPosition();
|
||||
|
||||
// Issue a fresh setpoint (what MGC's optimalControl would do).
|
||||
await m.handleInput('parent', 'flowmovement', 200);
|
||||
await sleep(800); // generous — at speed=10 u/s, 8 units in 0.8s.
|
||||
|
||||
const positionAfter = m.state.getCurrentPosition();
|
||||
const stateFinal = m.state.getCurrentState();
|
||||
console.log({
|
||||
positionBefore, positionAfter,
|
||||
stateFinal,
|
||||
delayedMove: m.state.delayedMove,
|
||||
delta: (positionAfter - positionBefore).toFixed(3),
|
||||
});
|
||||
|
||||
assert.ok(positionAfter - positionBefore > 1,
|
||||
`[BUG] currentPosition stuck at ${positionBefore.toFixed(2)} — moveTo's early-return at state.js:68 swallowed the setpoint. ` +
|
||||
`delayedMove=${m.state.delayedMove} state=${stateFinal}`);
|
||||
});
|
||||
|
||||
test('chain deadlock: aborted move + new setpoint freezes position (race-condition path)', async () => {
|
||||
// Deterministic reproducer of the deadlock the user observed live in
|
||||
// Node-RED. Key invariant being asserted: AFTER a routine abort, a
|
||||
// subsequent setpoint MUST eventually move the pump toward the new
|
||||
// target. Today it freezes because state.moveTo's early-return at
|
||||
// the top stores the target in `delayedMove` but `delayedMove` only
|
||||
// fires from inside an active moveTo's success branch — and there
|
||||
// is none, since state stays in 'accelerating'.
|
||||
|
||||
const m = makeMachineOperational();
|
||||
|
||||
await m.handleInput('parent', 'execsequence', 'startup');
|
||||
for (let i = 0; i < 50 && m.state.getCurrentState() !== 'operational'; i++) await sleep(20);
|
||||
assert.equal(m.state.getCurrentState(), 'operational');
|
||||
|
||||
// Step 1: kick off a long traversal to position 80. Speed=10, so this
|
||||
// takes ~8 s. We need it to be reliably in 'accelerating' when we abort.
|
||||
m.setpoint(80); // not awaited
|
||||
// movementManager interval is 50ms; wait two ticks so position has
|
||||
// demonstrably advanced and state is firmly in 'accelerating'.
|
||||
await sleep(150);
|
||||
assert.equal(m.state.getCurrentState(), 'accelerating',
|
||||
`precondition: pump should be accelerating mid-traversal; got ${m.state.getCurrentState()}`);
|
||||
const positionDuringMove = m.state.getCurrentPosition();
|
||||
assert.ok(positionDuringMove > 0 && positionDuringMove < 80,
|
||||
`precondition: pump should be mid-traversal, got ${positionDuringMove}`);
|
||||
|
||||
// Step 2: routine abort, exactly what MGC's abortActiveMovements does.
|
||||
m.abortMovement('routine retarget');
|
||||
// Wait for the abort signal to propagate through the setInterval.
|
||||
await sleep(120);
|
||||
|
||||
const stateAfterAbort = m.state.getCurrentState();
|
||||
const positionAfterAbort = m.state.getCurrentPosition();
|
||||
|
||||
// Step 3: a fresh setpoint — what MGC's optimalControl issues next.
|
||||
// Use a target DIFFERENT from current position so the early-return
|
||||
// `targetPosition === currentPosition` doesn't apply.
|
||||
await m.handleInput('parent', 'flowmovement', 200); // m³/h → distinct ctrl%
|
||||
// Give it half a second, plenty of time for movement to advance at
|
||||
// speed=10 u/s if it actually proceeds.
|
||||
await sleep(500);
|
||||
|
||||
const stateFinal = m.state.getCurrentState();
|
||||
const positionFinal = m.state.getCurrentPosition();
|
||||
|
||||
console.log({
|
||||
positionDuringMove,
|
||||
stateAfterAbort, positionAfterAbort,
|
||||
stateFinal, positionFinal,
|
||||
delayedMove: m.state?.delayedMove,
|
||||
delta: (positionFinal - positionAfterAbort).toFixed(3),
|
||||
});
|
||||
|
||||
// The bug: position stays parked exactly where the abort left it.
|
||||
// Either the FSM is still in 'accelerating' (so moveTo's top-level
|
||||
// early-return stored the new setpoint in delayedMove and bailed), or
|
||||
// both — state stuck AND delayedMove holding the new target. After
|
||||
// the fix, position should advance toward the new setpoint.
|
||||
assert.ok(positionFinal - positionAfterAbort > 1,
|
||||
`[BUG] currentPosition frozen at ${positionAfterAbort.toFixed(2)} — moveTo's early-return swallowed the new setpoint, ` +
|
||||
`delayedMove=${m.state?.delayedMove}, finalState=${stateFinal}`);
|
||||
});
|
||||
@@ -48,7 +48,12 @@ test('predictions use initialized medium pressure and not the minimum-pressure f
|
||||
assert.equal(pressureStatus.initialized, true);
|
||||
assert.equal(pressureStatus.hasDifferential, true);
|
||||
|
||||
const expectedDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa canonical
|
||||
assert.equal(Math.round(machine.predictFlow.fDimension), expectedDiff);
|
||||
const rawDiff = (mediumDownstreamMbar - mediumUpstreamMbar) * 100; // mbar -> Pa = 40000
|
||||
// fDimension is clamped to [fValues.min, fValues.max]. The H05K curve's
|
||||
// minimum pressure slice is 70000 Pa (700 mbar). A 40000 Pa differential
|
||||
// is below the curve minimum, so it gets clamped to 70000.
|
||||
const curveMinPressure = 70000;
|
||||
const expected = Math.max(rawDiff, curveMinPressure);
|
||||
assert.equal(Math.round(machine.predictFlow.fDimension), expected);
|
||||
assert.ok(machine.predictFlow.fDimension > 0);
|
||||
});
|
||||
|
||||
180
test/integration/curve-prediction.integration.test.js
Normal file
180
test/integration/curve-prediction.integration.test.js
Normal file
@@ -0,0 +1,180 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||
const { loadCurve } = require('generalFunctions');
|
||||
|
||||
/**
|
||||
* Prediction benchmarks across all rotatingMachine curves currently shipped
|
||||
* with generalFunctions. This guards the curve-backed prediction path against
|
||||
* regressions in the loader, the reverse-nq inversion, and the pressure
|
||||
* slicing logic — across machines of very different sizes.
|
||||
*
|
||||
* Ranges are derived from the curve data itself (loaded at test time) plus
|
||||
* physical sanity properties (monotonicity in ctrl, inverse-monotonicity in
|
||||
* pressure for flow, non-negative power, curve-backed CoG non-zero).
|
||||
*/
|
||||
|
||||
// Curves the node is expected to support. Add new entries here as soon as a
|
||||
// new curve file lands in generalFunctions/datasets/assetData/curves/.
|
||||
const PUMP_CURVES = [
|
||||
{ model: 'hidrostal-H05K-S03R', unit: 'm3/h', pUnit: 'mbar', powUnit: 'kW' },
|
||||
{ model: 'hidrostal-C5-D03R-SHN1', unit: 'm3/h', pUnit: 'mbar', powUnit: 'kW' },
|
||||
];
|
||||
|
||||
function curveExtents(curveData) {
|
||||
const pressures = Object.keys(curveData.nq)
|
||||
.filter((k) => /^-?\d+$/.test(k))
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
const slice = (set, p) => curveData[set][String(p)];
|
||||
const lowP = pressures[0];
|
||||
const midP = pressures[Math.floor(pressures.length / 2)];
|
||||
const highP = pressures[pressures.length - 1];
|
||||
const allFlowY = pressures.flatMap((p) => slice('nq', p).y);
|
||||
const allPowerY = pressures.flatMap((p) => slice('np', p).y);
|
||||
return {
|
||||
pressures,
|
||||
lowP, midP, highP,
|
||||
flowMin: Math.min(...allFlowY), flowMax: Math.max(...allFlowY),
|
||||
powerMin: Math.min(...allPowerY), powerMax: Math.max(...allPowerY),
|
||||
};
|
||||
}
|
||||
|
||||
async function makeRunningMachine({ model, unit }) {
|
||||
const cfg = makeMachineConfig({
|
||||
general: { id: `rm-${model}`, name: model, unit, logging: { enabled: false, logLevel: 'error' } },
|
||||
asset: {
|
||||
supplier: 'hidrostal', category: 'pump', type: 'Centrifugal', model, unit,
|
||||
curveUnits: { pressure: 'mbar', flow: unit, power: 'kW', control: '%' },
|
||||
},
|
||||
});
|
||||
const m = new Machine(cfg, makeStateConfig());
|
||||
await m.handleInput('parent', 'execSequence', 'startup');
|
||||
assert.equal(m.state.getCurrentState(), 'operational', `${model}: should reach operational`);
|
||||
return m;
|
||||
}
|
||||
|
||||
for (const curve of PUMP_CURVES) {
|
||||
const { model, unit, pUnit, powUnit } = curve;
|
||||
|
||||
test(`[${model}] curve loads and has both nq and np slices`, () => {
|
||||
const raw = loadCurve(model);
|
||||
assert.ok(raw, `loadCurve('${model}') must return data`);
|
||||
assert.ok(raw.nq && Object.keys(raw.nq).length > 0, `${model}: nq has pressure slices`);
|
||||
assert.ok(raw.np && Object.keys(raw.np).length > 0, `${model}: np has pressure slices`);
|
||||
// Same pressure slices in both
|
||||
const nqP = Object.keys(raw.nq).filter((k) => /^-?\d+$/.test(k)).sort();
|
||||
const npP = Object.keys(raw.np).filter((k) => /^-?\d+$/.test(k)).sort();
|
||||
assert.deepEqual(nqP, npP, `${model}: nq and np must share pressure slices`);
|
||||
});
|
||||
|
||||
test(`[${model}] predicted flow and power at mid-pressure, mid-ctrl are finite and in-range`, async () => {
|
||||
const raw = loadCurve(model);
|
||||
const ext = curveExtents(raw);
|
||||
const m = await makeRunningMachine(curve);
|
||||
|
||||
// Feed differential pressure = midP (upstream 0, downstream = midP)
|
||||
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||
|
||||
await m.handleInput('parent', 'execMovement', 50);
|
||||
|
||||
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||||
const power = m.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue(powUnit);
|
||||
|
||||
assert.ok(Number.isFinite(flow), `${model}: flow must be finite`);
|
||||
assert.ok(Number.isFinite(power), `${model}: power must be finite`);
|
||||
// Flow can be negative at the low-end slice of some curves due to spline extrapolation,
|
||||
// but at mid-pressure mid-ctrl it must be positive.
|
||||
assert.ok(flow > 0, `${model}: flow ${flow} ${unit} must be > 0 at mid-pressure mid-ctrl`);
|
||||
assert.ok(power >= 0, `${model}: power ${power} ${powUnit} must be >= 0`);
|
||||
// Loose bracket against curve envelope (2x margin accommodates interpolation overshoot)
|
||||
assert.ok(flow <= ext.flowMax * 2, `${model}: flow ${flow} exceeds curve envelope ${ext.flowMax}`);
|
||||
assert.ok(power <= ext.powerMax * 2, `${model}: power ${power} exceeds curve envelope ${ext.powerMax}`);
|
||||
});
|
||||
|
||||
test(`[${model}] flow is monotonically non-decreasing in ctrl at fixed pressure`, async () => {
|
||||
const raw = loadCurve(model);
|
||||
const ext = curveExtents(raw);
|
||||
const m = await makeRunningMachine(curve);
|
||||
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||
|
||||
const samples = [];
|
||||
for (const setpoint of [10, 30, 50, 70, 90]) {
|
||||
await m.handleInput('parent', 'execMovement', setpoint);
|
||||
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||||
samples.push({ setpoint, flow });
|
||||
}
|
||||
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
// Allow 1% tolerance for spline wiggle but reject any clear regression.
|
||||
assert.ok(
|
||||
samples[i].flow >= samples[i - 1].flow - Math.abs(samples[i - 1].flow) * 0.01,
|
||||
`${model}: flow not monotonic across ctrl sweep: ${JSON.stringify(samples)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(`[${model}] flow decreases (or stays level) when pressure rises at fixed ctrl`, async () => {
|
||||
const raw = loadCurve(model);
|
||||
const ext = curveExtents(raw);
|
||||
const m = await makeRunningMachine(curve);
|
||||
|
||||
const samples = [];
|
||||
for (const p of [ext.lowP, ext.midP, ext.highP]) {
|
||||
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||
m.updateMeasuredPressure(p, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||
await m.handleInput('parent', 'execMovement', 60);
|
||||
const flow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||||
samples.push({ pressure: p, flow });
|
||||
}
|
||||
|
||||
// Highest pressure must not exceed lowest pressure flow by more than 1%.
|
||||
// (Centrifugal pump: head up -> flow down at a given speed.)
|
||||
const first = samples[0].flow;
|
||||
const last = samples[samples.length - 1].flow;
|
||||
assert.ok(
|
||||
last <= first * 1.01,
|
||||
`${model}: flow at p=${samples[samples.length - 1].pressure} (${last}) exceeds flow at p=${samples[0].pressure} (${first}); samples=${JSON.stringify(samples)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test(`[${model}] cog and NCog are computed and finite after an operational move`, async () => {
|
||||
const raw = loadCurve(model);
|
||||
const ext = curveExtents(raw);
|
||||
const m = await makeRunningMachine(curve);
|
||||
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||
await m.handleInput('parent', 'execMovement', 50);
|
||||
|
||||
assert.ok(Number.isFinite(m.cog), `${model}: cog must be finite, got ${m.cog}`);
|
||||
assert.ok(Number.isFinite(m.NCog), `${model}: NCog must be finite, got ${m.NCog}`);
|
||||
// CoG is a controller-% location of peak efficiency; must fall inside the ctrl range of the curve.
|
||||
assert.ok(m.cog >= 0 && m.cog <= 100, `${model}: cog=${m.cog} must be within [0,100]`);
|
||||
});
|
||||
|
||||
test(`[${model}] reverse predictor (ctrl for requested flow) round-trips within tolerance`, async () => {
|
||||
const raw = loadCurve(model);
|
||||
const ext = curveExtents(raw);
|
||||
const m = await makeRunningMachine(curve);
|
||||
m.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-up' });
|
||||
m.updateMeasuredPressure(ext.midP, 'downstream', { timestamp: Date.now(), unit: pUnit, childName: 'pt-down' });
|
||||
|
||||
// Move to a known controller position and read the flow.
|
||||
await m.handleInput('parent', 'execMovement', 60);
|
||||
const observedFlow = m.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue(unit);
|
||||
assert.ok(observedFlow > 0, `${model}: need non-zero flow to invert`);
|
||||
|
||||
// Convert flow back to ctrl via calcCtrl (uses reversed nq internally) —
|
||||
// note calcCtrl takes canonical flow (m3/s), so convert.
|
||||
const canonicalFlow = observedFlow / 3600; // m3/h -> m3/s
|
||||
const predictedCtrl = m.calcCtrl(canonicalFlow);
|
||||
assert.ok(
|
||||
Number.isFinite(predictedCtrl) && Math.abs(predictedCtrl - 60) <= 10,
|
||||
`${model}: reverse predictor ctrl=${predictedCtrl} should be within 10 of 60 for flow=${observedFlow}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
147
test/integration/efficiency-cog.integration.test.js
Normal file
147
test/integration/efficiency-cog.integration.test.js
Normal file
@@ -0,0 +1,147 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||
|
||||
function makePressurizedOperationalMachine() {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||
machine.updateMeasuredPressure(800, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
|
||||
machine.updateMeasuredPressure(1200, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||
return machine;
|
||||
}
|
||||
|
||||
test('calcCog returns valid peak efficiency and index', () => {
|
||||
const machine = makePressurizedOperationalMachine();
|
||||
|
||||
const result = machine.calcCog();
|
||||
|
||||
assert.ok(Number.isFinite(result.cog), 'cog should be finite');
|
||||
assert.ok(result.cog > 0, 'peak efficiency should be positive');
|
||||
assert.ok(Number.isFinite(result.cogIndex), 'cogIndex should be finite');
|
||||
assert.ok(result.cogIndex >= 0, 'cogIndex should be non-negative');
|
||||
assert.ok(Number.isFinite(result.NCog), 'NCog should be finite');
|
||||
assert.ok(result.NCog >= 0 && result.NCog <= 1, 'NCog should be between 0 and 1');
|
||||
assert.ok(Number.isFinite(result.minEfficiency), 'minEfficiency should be finite');
|
||||
assert.ok(result.minEfficiency >= 0, 'minEfficiency should be non-negative');
|
||||
});
|
||||
|
||||
test('calcCog peak is always >= minEfficiency', () => {
|
||||
const machine = makePressurizedOperationalMachine();
|
||||
|
||||
const result = machine.calcCog();
|
||||
assert.ok(result.cog >= result.minEfficiency, 'Peak must be >= min');
|
||||
});
|
||||
|
||||
test('calcEfficiencyCurve produces correct specific flow ratio', () => {
|
||||
const machine = makePressurizedOperationalMachine();
|
||||
const { powerCurve, flowCurve } = machine.getCurrentCurves();
|
||||
|
||||
const { efficiencyCurve, peak, peakIndex, minEfficiency } = machine.calcEfficiencyCurve(powerCurve, flowCurve);
|
||||
|
||||
assert.ok(efficiencyCurve.length > 0, 'Efficiency curve should not be empty');
|
||||
assert.equal(efficiencyCurve.length, powerCurve.y.length, 'Should match curve length');
|
||||
|
||||
// Verify each point: efficiency = flow / power (unrounded, canonical units)
|
||||
for (let i = 0; i < efficiencyCurve.length; i++) {
|
||||
const power = powerCurve.y[i];
|
||||
const flow = flowCurve.y[i];
|
||||
if (power > 0 && flow >= 0) {
|
||||
const expected = flow / power;
|
||||
assert.ok(Math.abs(efficiencyCurve[i] - expected) < 1e-12, `Mismatch at index ${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Peak should be the max
|
||||
const actualMax = Math.max(...efficiencyCurve);
|
||||
assert.equal(peak, actualMax, 'Peak should match max of efficiency curve');
|
||||
assert.equal(efficiencyCurve[peakIndex], peak, 'peakIndex should point to peak value');
|
||||
assert.equal(minEfficiency, Math.min(...efficiencyCurve), 'minEfficiency should match min');
|
||||
});
|
||||
|
||||
test('calcEfficiencyCurve handles empty curves gracefully', () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||
|
||||
const result = machine.calcEfficiencyCurve({ x: [], y: [] }, { x: [], y: [] });
|
||||
|
||||
assert.deepEqual(result.efficiencyCurve, []);
|
||||
assert.equal(result.peak, 0);
|
||||
assert.equal(result.peakIndex, 0);
|
||||
assert.equal(result.minEfficiency, 0);
|
||||
});
|
||||
|
||||
test('calcDistanceBEP returns absolute and relative distances', () => {
|
||||
const machine = makePressurizedOperationalMachine();
|
||||
|
||||
const efficiency = 5;
|
||||
const maxEfficiency = 10;
|
||||
const minEfficiency = 2;
|
||||
|
||||
const result = machine.calcDistanceBEP(efficiency, maxEfficiency, minEfficiency);
|
||||
|
||||
assert.ok(Number.isFinite(result.absDistFromPeak), 'abs distance should be finite');
|
||||
assert.equal(result.absDistFromPeak, Math.abs(efficiency - maxEfficiency));
|
||||
assert.ok(Number.isFinite(result.relDistFromPeak), 'rel distance should be finite');
|
||||
});
|
||||
|
||||
test('calcRelativeDistanceFromPeak returns 1 when maxEfficiency equals minEfficiency', () => {
|
||||
const machine = makePressurizedOperationalMachine();
|
||||
|
||||
const result = machine.calcRelativeDistanceFromPeak(5, 5, 5);
|
||||
assert.equal(result, 1, 'Should return default distance when max==min (division by zero guard)');
|
||||
});
|
||||
|
||||
test('showCoG returns structured data with curve guards', () => {
|
||||
const machine = makePressurizedOperationalMachine();
|
||||
|
||||
const result = machine.showCoG();
|
||||
|
||||
assert.ok('cog' in result);
|
||||
assert.ok('cogIndex' in result);
|
||||
assert.ok('NCog' in result);
|
||||
assert.ok('NCogPercent' in result);
|
||||
assert.ok('minEfficiency' in result);
|
||||
assert.ok('currentEfficiencyCurve' in result);
|
||||
assert.ok(result.cog > 0);
|
||||
assert.equal(result.NCogPercent, Math.round(result.NCog * 100 * 100) / 100);
|
||||
});
|
||||
|
||||
test('showCoG returns safe fallback when no curve is available', () => {
|
||||
const machine = new Machine(
|
||||
makeMachineConfig({ asset: { model: null } }),
|
||||
makeStateConfig()
|
||||
);
|
||||
|
||||
const result = machine.showCoG();
|
||||
assert.equal(result.cog, 0);
|
||||
assert.ok('error' in result);
|
||||
});
|
||||
|
||||
test('showWorkingCurves returns safe fallback when no curve is available', () => {
|
||||
const machine = new Machine(
|
||||
makeMachineConfig({ asset: { model: null } }),
|
||||
makeStateConfig()
|
||||
);
|
||||
|
||||
const result = machine.showWorkingCurves();
|
||||
assert.ok('error' in result);
|
||||
});
|
||||
|
||||
test('efficiency output fields are present in getOutput', () => {
|
||||
const machine = makePressurizedOperationalMachine();
|
||||
|
||||
// Move to a position so predictions produce values
|
||||
machine.state.transitionToState('operational');
|
||||
machine.updatePosition();
|
||||
|
||||
const output = machine.getOutput();
|
||||
|
||||
assert.ok('cog' in output);
|
||||
assert.ok('NCog' in output);
|
||||
assert.ok('NCogPercent' in output);
|
||||
assert.ok('effDistFromPeak' in output);
|
||||
assert.ok('effRelDistFromPeak' in output);
|
||||
assert.ok('predictionQuality' in output);
|
||||
assert.ok('predictionConfidence' in output);
|
||||
assert.ok('predictionPressureSource' in output);
|
||||
});
|
||||
59
test/integration/emergency-stop.integration.test.js
Normal file
59
test/integration/emergency-stop.integration.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||
|
||||
test('emergencystop sequence reaches off state from operational', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
// First start the machine
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||
|
||||
// Execute emergency stop
|
||||
await machine.handleInput('GUI', 'emergencystop');
|
||||
assert.equal(machine.state.getCurrentState(), 'off');
|
||||
});
|
||||
|
||||
test('emergencystop sequence reaches off state from idle', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||
|
||||
await machine.handleInput('GUI', 'emergencystop');
|
||||
assert.equal(machine.state.getCurrentState(), 'off');
|
||||
});
|
||||
|
||||
test('emergencystop clears predicted flow and power to zero', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
// Start and set a position so predictions are non-zero
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||
await machine.handleInput('parent', 'execMovement', 50);
|
||||
|
||||
const flowBefore = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
|
||||
assert.ok(flowBefore > 0, 'Flow should be positive before emergency stop');
|
||||
|
||||
// Emergency stop
|
||||
await machine.handleInput('GUI', 'emergencystop');
|
||||
|
||||
const flowAfter = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
|
||||
const powerAfter = machine.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue();
|
||||
assert.equal(flowAfter, 0, 'Flow should be zero after emergency stop');
|
||||
assert.equal(powerAfter, 0, 'Power should be zero after emergency stop');
|
||||
});
|
||||
|
||||
test('emergencystop is rejected when source is not allowed in current mode', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
// In auto mode, only 'parent' source is typically allowed for sequences
|
||||
machine.setMode('auto');
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||
|
||||
// GUI source attempting emergency stop in auto mode — should still work
|
||||
// because emergencystop is allowed from all sources in config
|
||||
await machine.handleInput('GUI', 'emergencystop');
|
||||
// If we get here without throwing, action was either accepted or safely rejected
|
||||
});
|
||||
93
test/integration/interruptible-movement.integration.test.js
Normal file
93
test/integration/interruptible-movement.integration.test.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||
|
||||
/**
|
||||
* Regression tests for the FSM interruptible-movement fix (2026-04-13).
|
||||
*
|
||||
* Before the fix, `executeSequence("shutdown")` was silently rejected by the
|
||||
* state manager if the machine was mid-move (accelerating/decelerating),
|
||||
* because allowedTransitions for those states only permits returning to
|
||||
* `operational` or `emergencystop`. Operators pressing Stop during a ramp
|
||||
* would see the transition error-logged but no actual stop.
|
||||
*
|
||||
* The fix aborts the active movement, waits for the FSM to return to
|
||||
* `operational`, then runs the normal shutdown / emergency-stop sequence.
|
||||
*/
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function makeSlowMoveMachine() {
|
||||
// Slow movement so the test can reliably interrupt during accelerating.
|
||||
// speed=20%/s, interval=10ms -> 80% setpoint takes ~4s of real movement.
|
||||
return new Machine(
|
||||
makeMachineConfig(),
|
||||
makeStateConfig({
|
||||
movement: { mode: 'staticspeed', speed: 20, maxSpeed: 1000, interval: 10 },
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test('shutdown during accelerating aborts the move and reaches idle', async () => {
|
||||
const machine = makeSlowMoveMachine();
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||
machine.updateMeasuredPressure(200, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-up' });
|
||||
|
||||
// Fire a setpoint that needs ~4 seconds. Do NOT await it.
|
||||
const movePromise = machine.handleInput('parent', 'execMovement', 80);
|
||||
|
||||
// Wait a moment for the FSM to enter accelerating.
|
||||
await sleep(100);
|
||||
assert.equal(machine.state.getCurrentState(), 'accelerating');
|
||||
|
||||
// Issue shutdown while the move is still accelerating.
|
||||
await machine.handleInput('GUI', 'execSequence', 'shutdown');
|
||||
|
||||
// Let the aborted move unwind.
|
||||
await movePromise.catch(() => {});
|
||||
|
||||
assert.equal(
|
||||
machine.state.getCurrentState(),
|
||||
'idle',
|
||||
'shutdown issued mid-ramp must still drive FSM back to idle',
|
||||
);
|
||||
});
|
||||
|
||||
test('emergency stop during accelerating reaches off', async () => {
|
||||
const machine = makeSlowMoveMachine();
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt-down' });
|
||||
|
||||
const movePromise = machine.handleInput('parent', 'execMovement', 80);
|
||||
|
||||
await sleep(100);
|
||||
assert.equal(machine.state.getCurrentState(), 'accelerating');
|
||||
|
||||
await machine.handleInput('GUI', 'emergencystop');
|
||||
await movePromise.catch(() => {});
|
||||
|
||||
assert.equal(
|
||||
machine.state.getCurrentState(),
|
||||
'off',
|
||||
'emergency stop issued mid-ramp must still drive FSM to off',
|
||||
);
|
||||
});
|
||||
|
||||
test('executeSequence accepts mixed-case sequence names', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||
|
||||
// Parent orchestrators (e.g. machineGroupControl) use "emergencyStop" with
|
||||
// a capital S in their configs. The sequence key in rotatingMachine.json
|
||||
// is lowercase. Normalization must bridge that gap without a warn.
|
||||
await machine.executeSequence('EmergencyStop');
|
||||
assert.equal(machine.state.getCurrentState(), 'off');
|
||||
});
|
||||
75
test/integration/movement-lifecycle.integration.test.js
Normal file
75
test/integration/movement-lifecycle.integration.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||
|
||||
test('movement from 0 to 50% updates position and predictions', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||
|
||||
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||
|
||||
await machine.handleInput('parent', 'execMovement', 50);
|
||||
|
||||
const pos = machine.state.getCurrentPosition();
|
||||
const { min, max } = machine._resolveSetpointBounds();
|
||||
// Position should be constrained to bounds
|
||||
assert.ok(pos >= min && pos <= max, `Position ${pos} should be within [${min}, ${max}]`);
|
||||
|
||||
const flow = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
|
||||
assert.ok(flow > 0, 'Predicted flow should be positive at non-zero position');
|
||||
});
|
||||
|
||||
test('flowmovement sets position based on flow setpoint', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||
|
||||
// Request 100 m3/h flow — the machine should calculate the control position
|
||||
await machine.handleInput('parent', 'flowMovement', 100);
|
||||
|
||||
const pos = machine.state.getCurrentPosition();
|
||||
assert.ok(pos > 0, 'Position should be non-zero for a non-zero flow setpoint');
|
||||
});
|
||||
|
||||
test('sequential movements update position correctly', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||
|
||||
await machine.handleInput('parent', 'execMovement', 30);
|
||||
const pos30 = machine.state.getCurrentPosition();
|
||||
|
||||
await machine.handleInput('parent', 'execMovement', 60);
|
||||
const pos60 = machine.state.getCurrentPosition();
|
||||
|
||||
assert.ok(pos60 > pos30, 'Position at 60 should be greater than at 30');
|
||||
});
|
||||
|
||||
test('movement to 0 sets flow and power predictions to minimum curve values', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||
|
||||
await machine.handleInput('parent', 'execMovement', 0);
|
||||
|
||||
const pos = machine.state.getCurrentPosition();
|
||||
assert.equal(pos, 0, 'Position should be at 0');
|
||||
});
|
||||
|
||||
test('movement is rejected in non-operational state', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||
|
||||
// Attempt movement in idle state — handleInput should process but no movement happens
|
||||
await machine.handleInput('parent', 'execMovement', 50);
|
||||
|
||||
// Machine should still be idle (movement requires operational state via sequence first)
|
||||
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||
});
|
||||
@@ -14,7 +14,10 @@ test('pressure initialization combinations are handled explicitly', () => {
|
||||
assert.equal(status.source, null);
|
||||
const noPressureValue = machine.getMeasuredPressure();
|
||||
assert.equal(noPressureValue, 0);
|
||||
assert.ok(machine.predictFlow.fDimension <= 1);
|
||||
// With no pressure injected, fDimension is clamped to the curve minimum
|
||||
// (70000 Pa for H05K). Previously a schema default at pressure "1" made
|
||||
// fValues.min=1 — that was a data-poisoning bug, now fixed.
|
||||
assert.ok(machine.predictFlow.fDimension >= 70000);
|
||||
|
||||
// upstream only
|
||||
machine = createMachine();
|
||||
@@ -44,9 +47,11 @@ test('pressure initialization combinations are handled explicitly', () => {
|
||||
assert.equal(Math.round(downstreamValue), downstreamOnly * 100);
|
||||
assert.equal(Math.round(machine.predictFlow.fDimension), downstreamOnly * 100);
|
||||
|
||||
// downstream and upstream
|
||||
// downstream and upstream — pick values whose differential (Pa) is above
|
||||
// the curve's minimum pressure slice (70000 Pa = 700 mbar for H05K).
|
||||
// 200 mbar upstream + 1100 mbar downstream → diff = 900 mbar = 90000 Pa.
|
||||
machine = createMachine();
|
||||
const upstream = 700;
|
||||
const upstream = 200;
|
||||
const downstream = 1100;
|
||||
machine.measurements.type('pressure').variant('measured').position('upstream').value(upstream, Date.now(), 'mbar');
|
||||
machine.measurements.type('pressure').variant('measured').position('downstream').value(downstream, Date.now(), 'mbar');
|
||||
|
||||
@@ -14,11 +14,16 @@ test('execSequence startup reaches operational with zero transition times', asyn
|
||||
|
||||
test('execMovement constrains controller position to safe bounds in operational state', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig({ state: { current: 'operational' } }));
|
||||
const { max } = machine._resolveSetpointBounds();
|
||||
const { min, max } = machine._resolveSetpointBounds();
|
||||
|
||||
// Test upper constraint: setpoint above max gets clamped to max
|
||||
await machine.handleInput('parent', 'execMovement', max + 50);
|
||||
let pos = machine.state.getCurrentPosition();
|
||||
assert.equal(pos, max, `setpoint above max should be clamped to ${max}`);
|
||||
|
||||
// Test that a valid setpoint within bounds is applied as-is
|
||||
await machine.handleInput('parent', 'execMovement', 10);
|
||||
|
||||
const pos = machine.state.getCurrentPosition();
|
||||
assert.ok(pos <= max);
|
||||
assert.equal(pos, max);
|
||||
pos = machine.state.getCurrentPosition();
|
||||
assert.equal(pos, 10, 'setpoint within bounds should be applied as-is');
|
||||
assert.ok(pos >= min && pos <= max);
|
||||
});
|
||||
|
||||
146
test/integration/shutdown-sequence.integration.test.js
Normal file
146
test/integration/shutdown-sequence.integration.test.js
Normal file
@@ -0,0 +1,146 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Machine = require('../../src/specificClass');
|
||||
const { makeMachineConfig, makeStateConfig } = require('../helpers/factories');
|
||||
|
||||
test('shutdown sequence from operational reaches idle', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||
});
|
||||
|
||||
test('shutdown from operational ramps down position before stopping', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
await machine.handleInput('parent', 'execMovement', 50);
|
||||
|
||||
const posBefore = machine.state.getCurrentPosition();
|
||||
assert.ok(posBefore > 0, 'Machine should be at non-zero position');
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||
|
||||
const posAfter = machine.state.getCurrentPosition();
|
||||
assert.ok(posAfter <= posBefore, 'Position should have decreased after shutdown');
|
||||
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||
});
|
||||
|
||||
test('shutdown clears predicted flow and power', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
machine.updateMeasuredPressure(1000, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'pt' });
|
||||
await machine.handleInput('parent', 'execMovement', 50);
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||
|
||||
const flow = machine.measurements.type('flow').variant('predicted').position('downstream').getCurrentValue();
|
||||
const power = machine.measurements.type('power').variant('predicted').position('atEquipment').getCurrentValue();
|
||||
assert.equal(flow, 0, 'Flow should be zero after shutdown');
|
||||
assert.equal(power, 0, 'Power should be zero after shutdown');
|
||||
});
|
||||
|
||||
test('entermaintenance sequence from operational reaches maintenance state', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||
|
||||
await machine.handleInput('parent', 'enterMaintenance', 'entermaintenance');
|
||||
assert.equal(machine.state.getCurrentState(), 'maintenance');
|
||||
});
|
||||
|
||||
test('exitmaintenance requires mode with exitmaintenance action allowed', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
// Use auto mode (has execsequence + entermaintenance) to reach maintenance
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||
|
||||
await machine.handleInput('parent', 'enterMaintenance', 'entermaintenance');
|
||||
assert.equal(machine.state.getCurrentState(), 'maintenance');
|
||||
|
||||
// Switch to fysicalControl which allows exitmaintenance
|
||||
machine.setMode('fysicalControl');
|
||||
await machine.handleInput('fysical', 'exitMaintenance', 'exitmaintenance');
|
||||
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||
});
|
||||
|
||||
test('shutdown clears delayedMove synchronously, before the abort/await path runs', async () => {
|
||||
// Regression: when MGC parks a setpoint in state.delayedMove during a
|
||||
// dead-zone keep-alive, then PS commands shutdown via turnOffAllMachines,
|
||||
// the shutdown's interruptible-abort path triggers transitionToState
|
||||
// ('operational'), which auto-picks up delayedMove and re-starts the
|
||||
// pump. Pump bounces accelerating ↔ decelerating forever and the
|
||||
// shutdown sequence never reaches idle. Observed live in the
|
||||
// pumpingstation-complete-example demo: basin drained past stopLevel
|
||||
// with one pump stuck at minimum flow.
|
||||
//
|
||||
// Fix: executeSequence clears state.delayedMove for shutdown/emergencystop
|
||||
// BEFORE the abort+await path. Asserting synchronously (race the first
|
||||
// microtask) is the precise behavioural check — without the fix, the
|
||||
// auto-pickup could still re-engage the pump on the way to idle even if
|
||||
// the value is null after the call returns.
|
||||
|
||||
const slowMove = makeStateConfig({
|
||||
movement: { mode: 'staticspeed', speed: 50, maxSpeed: 100, interval: 10 },
|
||||
});
|
||||
const machine = new Machine(makeMachineConfig(), slowMove);
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
assert.equal(machine.state.getCurrentState(), 'operational');
|
||||
machine.setpoint(80);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
assert.equal(machine.state.getCurrentState(), 'accelerating');
|
||||
|
||||
machine.state.delayedMove = 75;
|
||||
|
||||
// Kick off the shutdown but do not await — capture state before the
|
||||
// abort path's await yields.
|
||||
const shutdownPromise = machine.handleInput('parent', 'execSequence', 'shutdown');
|
||||
// Yield once to allow the synchronous prelude of executeSequence to run
|
||||
// (lookup, lowercase, the new delayedMove=null assignment) without
|
||||
// letting any await resolve.
|
||||
await Promise.resolve();
|
||||
assert.equal(machine.state.delayedMove, null,
|
||||
'delayedMove must be cleared synchronously by the shutdown prelude — otherwise the abort path will auto-pick it up');
|
||||
|
||||
await shutdownPromise;
|
||||
assert.equal(machine.state.getCurrentState(), 'idle');
|
||||
});
|
||||
|
||||
test('emergencystop also clears queued delayedMove', async () => {
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
await machine.handleInput('parent', 'execMovement', 30);
|
||||
machine.state.delayedMove = 60;
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'emergencystop');
|
||||
|
||||
assert.equal(machine.state.delayedMove, null,
|
||||
'emergency-stop must clear delayedMove');
|
||||
});
|
||||
|
||||
test('startup does NOT clear delayedMove (only shutdown/emergencystop does)', async () => {
|
||||
// delayedMove serves a legitimate purpose for non-stop sequences — e.g.
|
||||
// setpoints arriving while the pump is in 'starting' get queued and
|
||||
// auto-picked-up when state lands in 'operational'. The fix must be
|
||||
// narrowly scoped to interruptible (stop) sequences.
|
||||
const machine = new Machine(makeMachineConfig(), makeStateConfig());
|
||||
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
machine.state.delayedMove = 42;
|
||||
|
||||
// Re-running startup from operational is a no-op for state, but the
|
||||
// delayedMove must still be there afterwards for the auto-pickup to fire.
|
||||
await machine.handleInput('parent', 'execSequence', 'startup');
|
||||
|
||||
assert.equal(machine.state.delayedMove, 42,
|
||||
'non-stop sequences must preserve delayedMove for the auto-pickup');
|
||||
});
|
||||
348
wiki/Home.md
Normal file
348
wiki/Home.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# rotatingMachine
|
||||
|
||||
> **Reflects code as of `afc304b` · regenerated `2026-05-11` via `npm run wiki:all`**
|
||||
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
|
||||
|
||||
## 1. What this node is
|
||||
|
||||
**rotatingMachine** models a single pump, compressor, or blower. It loads a supplier characteristic curve, takes upstream + downstream pressure measurements (or simulated values), predicts the resulting flow + power, drives a startup/shutdown state machine, and assesses prediction drift against measured flow / power. Used as a child of `machineGroupControl` when grouped, or directly under a `pumpingStation`.
|
||||
|
||||
## 2. Position in the platform
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
parent[machineGroupControl /<br/>pumpingStation]:::unit -->|flowmovement<br/>execsequence| rm[rotatingMachine<br/>Equipment]:::equip
|
||||
m_up[measurement<br/>pressure upstream]:::ctrl -.data.-> rm
|
||||
m_dn[measurement<br/>pressure downstream]:::ctrl -.data.-> rm
|
||||
sim[dashboard-sim<br/>virtual pressure children]:::ctrl -.data.-> rm
|
||||
rm -->|child.register| parent
|
||||
rm -.->|flow.predicted.*<br/>power.predicted.atequipment| parent
|
||||
classDef unit fill:#50a8d9,color:#000
|
||||
classDef equip fill:#86bbdd,color:#000
|
||||
classDef ctrl fill:#a9daee,color:#000
|
||||
```
|
||||
|
||||
S88 colours: Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md`.
|
||||
|
||||
## 3. Capability matrix
|
||||
|
||||
| Capability | Status | Notes |
|
||||
|---|---|---|
|
||||
| Curve-based flow prediction | ✅ | Built from `asset.model` via `curves/curveLoader`. |
|
||||
| Curve-based power prediction | ✅ | Reverse curve composed inside `buildPredictors`. |
|
||||
| FSM (startup / shutdown / movement) | ✅ | Shared `state/state.js` from generalFunctions. |
|
||||
| Interruptible movements | ✅ | `abortMovement` from MGC overrides on new demand. |
|
||||
| Drift assessment (flow + power) | ✅ | `DriftAssessor` with EWMA + alignment tolerance. |
|
||||
| Virtual pressure children for sim | ✅ | `dashboard-sim-upstream / -downstream`. |
|
||||
| Real-pressure child preference | ✅ | `pressureSelector` prefers real over virtual. |
|
||||
| Group operating-point prediction | ✅ | `setGroupOperatingPoint` for MGC integration. |
|
||||
| `cmd.estop` hard cut | ✅ | Forces `emergencystop` state. |
|
||||
| `data.simulate-measurement` injection | ✅ | Pressure / flow / power / temperature. |
|
||||
| Auto-recovery from prediction loss | ⚠️ | Reverts to null predictors silently — health falls to `invalid`. |
|
||||
| Multi-parent registration | ⚠️ | Accepted but not exercised in production. |
|
||||
|
||||
## 4. Code map
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
|
||||
nc["buildDomainConfig()<br/>static DomainClass, commands"]
|
||||
end
|
||||
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
|
||||
sc["Machine.configure()<br/>_setupCurves / _setupState /<br/>_setupDrift / _setupPressure /<br/>_setupChildren"]
|
||||
end
|
||||
subgraph concerns["src/ concern modules"]
|
||||
curves["curves/<br/>loadModelCurve + normalize"]
|
||||
prediction["prediction/<br/>buildPredictors + math"]
|
||||
drift["drift/<br/>DriftAssessor + healthRefresh"]
|
||||
pressure["pressure/<br/>init + router + selector + virtual"]
|
||||
state["state/<br/>FSM bindings + sequenceController"]
|
||||
measurement["measurement/<br/>handlers + childRegistrar"]
|
||||
flow["flow/<br/>flowController (handleInput)"]
|
||||
display["display/<br/>workingCurves + CoG"]
|
||||
io["io/<br/>output + status"]
|
||||
commands["commands/<br/>topic registry + handlers"]
|
||||
end
|
||||
nc --> sc
|
||||
sc --> curves
|
||||
sc --> prediction
|
||||
sc --> drift
|
||||
sc --> pressure
|
||||
sc --> state
|
||||
sc --> measurement
|
||||
sc --> flow
|
||||
sc --> display
|
||||
sc --> io
|
||||
nc --> commands
|
||||
```
|
||||
|
||||
| Module | Owns | Read first if you're changing… |
|
||||
|---|---|---|
|
||||
| `curves/` | Supplier curve loader + normaliser + reverse | Curve fitting, unit mismatches, fallback. |
|
||||
| `prediction/` | Per-machine + group predictors, math helpers | Predicted flow / power values. |
|
||||
| `drift/` | DriftAssessor (EWMA, alignment), healthRefresh | Prediction quality, flags, confidence. |
|
||||
| `pressure/` | init + router + selector + virtual children | Pressure plumbing, sim vs real preference. |
|
||||
| `state/` | FSM bindings + setpoint / sequence orchestration | Startup / shutdown sequences. |
|
||||
| `measurement/` | Measurement handlers + child registrar | Measured value plumbing per type. |
|
||||
| `flow/` | `flowController.handle(source, action, parameter)` | Top-level input dispatch. |
|
||||
| `display/` | `showWorkingCurves`, `showCoG` | `query.curves` / `query.cog` outputs. |
|
||||
| `io/` | `getOutput`, `getStatusBadge` | Output shape, badge text. |
|
||||
| `commands/` | Input-topic registry and handlers | New input topics, payload validation. |
|
||||
|
||||
## 5. Topic contract
|
||||
|
||||
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
|
||||
|
||||
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||
|
||||
| Canonical topic | Aliases | Payload | Effect |
|
||||
|---|---|---|---|
|
||||
| `set.mode` | `setMode` | `string` | Replaces the named state value with the supplied payload. |
|
||||
| `cmd.startup` | _(none)_ | `any` | Triggers an action / sequence — not idempotent. |
|
||||
| `cmd.shutdown` | _(none)_ | `any` | Triggers an action / sequence — not idempotent. |
|
||||
| `cmd.estop` | `emergencystop` | `any` | Triggers an action / sequence — not idempotent. |
|
||||
| `execSequence` | _(none)_ | `object` | _(see handler)_ |
|
||||
| `set.setpoint` | `execMovement` | `object` | Replaces the named state value with the supplied payload. |
|
||||
| `set.flow-setpoint` | `flowMovement` | `object` | Replaces the named state value with the supplied payload. |
|
||||
| `data.simulate-measurement` | `simulateMeasurement` | `object` | Pushes a value into the node's measurement stream. |
|
||||
| `query.curves` | `showWorkingCurves` | `any` | Read-only query; node replies on the same msg. |
|
||||
| `query.cog` | `CoG` | `any` | Read-only query; node replies on the same msg. |
|
||||
| `child.register` | `registerChild` | `string` | Parent/child plumbing — registers or unregisters a child node. |
|
||||
|
||||
<!-- END AUTOGEN: topic-contract -->
|
||||
|
||||
## 6. Child registration
|
||||
|
||||
`measurement` children register through `childRegistrationUtils`; the machine subscribes to the matching `<asset.type>.measured.<positionVsParent>` event.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph kids["accepted children (softwareType)"]
|
||||
m_pu["measurement<br/>type=pressure<br/>position=upstream"]:::ctrl
|
||||
m_pd["measurement<br/>type=pressure<br/>position=downstream"]:::ctrl
|
||||
m_f["measurement<br/>type=flow"]:::ctrl
|
||||
m_pw["measurement<br/>type=power"]:::ctrl
|
||||
m_t["measurement<br/>type=temperature"]:::ctrl
|
||||
end
|
||||
m_pu -->|pressure.measured.upstream| router[pressureRouter.route]
|
||||
m_pd -->|pressure.measured.downstream| router
|
||||
m_f -->|flow.measured.<pos>| mh[measurementHandlers]
|
||||
m_pw -->|power.measured.atequipment| mh
|
||||
m_t -->|temperature.measured.<pos>| mh
|
||||
router --> upd[updatePosition + drift refresh]
|
||||
mh --> upd
|
||||
classDef ctrl fill:#a9daee,color:#000
|
||||
```
|
||||
|
||||
| softwareType | filter | wired to | side-effect |
|
||||
|---|---|---|---|
|
||||
| `measurement` | `type=pressure, position=upstream` | `pressureRouter.route('upstream', ...)` | Sets upstream pressure; refresh prediction + drift. |
|
||||
| `measurement` | `type=pressure, position=downstream` | `pressureRouter.route('downstream', ...)` | Sets downstream pressure; refresh prediction + drift. |
|
||||
| `measurement` | `type=flow, position=*` | `measurementHandlers.updateMeasuredFlow` | Stored; drift assessed against predicted. |
|
||||
| `measurement` | `type=power, position=atEquipment` | `measurementHandlers.updateMeasuredPower` | Stored; drift assessed against predicted. |
|
||||
| `measurement` | `type=temperature, position=*` | `measurementHandlers.updateMeasuredTemperature` | Stored; used by power correction if relevant. |
|
||||
|
||||
Two **virtual children** are auto-registered at startup: `dashboard-sim-upstream` and `dashboard-sim-downstream`. `data.simulate-measurement` payloads land on these. Real pressure children, when registered, are preferred over the virtuals by `pressureSelector`.
|
||||
|
||||
## 7. Lifecycle — what one event does
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant parent as MGC / pumpingStation
|
||||
participant rm as rotatingMachine
|
||||
participant fsm as state FSM
|
||||
participant pred as predictors
|
||||
participant out as Port-0 output
|
||||
|
||||
parent->>rm: flowmovement (Q)
|
||||
rm->>rm: flowController.handle('parent', 'flowmovement', Q)
|
||||
rm->>fsm: setpoint(Q) → maybe transitionToState('accelerating')
|
||||
Note over fsm: state.emitter 'positionChange' per tick
|
||||
fsm-->>rm: positionChange → updatePosition()
|
||||
rm->>pred: calcFlowPower(x) → cFlow, cPower
|
||||
rm->>rm: calcEfficiency / cog / distance-BEP
|
||||
rm->>rm: drift refresh on every measured tick
|
||||
rm->>out: msg{topic, payload} (delta-compressed)
|
||||
parent->>rm: execsequence ('startup' | 'shutdown')
|
||||
rm->>fsm: transitionToState('starting' | 'stopping')
|
||||
fsm-->>rm: stateChange → _updateState()
|
||||
```
|
||||
|
||||
## 8. Data model — `getOutput()`
|
||||
|
||||
Composed in `io/output.js → buildOutput(this)`, then delta-compressed.
|
||||
|
||||
<!-- BEGIN AUTOGEN: data-model -->
|
||||
|
||||
| Key | Type | Unit | Sample |
|
||||
|---|---|---|---|
|
||||
| `NCog` | number | — | `0` |
|
||||
| `NCogPercent` | number | — | `0` |
|
||||
| `atmPressure.measured.atequipment.wikigen-rotatingmachine-id` | number | — | `101325` |
|
||||
| `cog` | number | — | `0` |
|
||||
| `ctrl` | number | — | `0` |
|
||||
| `effDistFromPeak` | number | — | `0` |
|
||||
| `effRelDistFromPeak` | number | — | `0` |
|
||||
| `flow.predicted.max.wikigen-rotatingmachine-id` | number | m3/s | `0` |
|
||||
| `flow.predicted.min.wikigen-rotatingmachine-id` | number | m3/s | `0` |
|
||||
| `maintenanceTime` | number | — | `0` |
|
||||
| `mode` | string | — | `"auto"` |
|
||||
| `moveTimeleft` | number | — | `0` |
|
||||
| `predictionConfidence` | number | — | `0` |
|
||||
| `predictionFlags` | array | — | `[…]` |
|
||||
| `predictionPressureSource` | null | — | `null` |
|
||||
| `predictionQuality` | string | — | `"invalid"` |
|
||||
| `pressureDriftFlags` | array | — | `[…]` |
|
||||
| `pressureDriftLevel` | number | — | `0` |
|
||||
| `pressureDriftSource` | null | — | `null` |
|
||||
| `runtime` | number | — | `0` |
|
||||
| `state` | string | — | `"idle"` |
|
||||
| `temperature.measured.atequipment.wikigen-rotatingmachine-id` | number | K | `15` |
|
||||
|
||||
<!-- END AUTOGEN: data-model -->
|
||||
|
||||
**Concrete sample** (live, from a known-good test run — pump warming up with simulated upstream/downstream pressure):
|
||||
|
||||
~~~json
|
||||
{
|
||||
"state": "warmingup",
|
||||
"flow.predicted.downstream.default": 0.00345,
|
||||
"flow.predicted.atequipment.default": 0.00345,
|
||||
"power.predicted.atequipment.default": 1820,
|
||||
"pressure.measured.upstream.dashboard-sim-upstream": 101325,
|
||||
"pressure.measured.downstream.dashboard-sim-downstream": 145000,
|
||||
"temperature.measured.atequipment.default": 15,
|
||||
"atmPressure.measured.atequipment.default": 101325,
|
||||
"predictionHealth": {
|
||||
"quality": "warming",
|
||||
"confidence": 0.35,
|
||||
"pressureSource": "dashboard-sim",
|
||||
"flags": ["pressure_init_warming"]
|
||||
},
|
||||
"cog": 0.62, "NCog": 0.71,
|
||||
"absDistFromPeak": 0.04, "relDistFromPeak": 0.12
|
||||
}
|
||||
~~~
|
||||
|
||||
Position labels are normalised to lowercase in MeasurementContainer keys (`atequipment`, `downstream`, `upstream`, `max`, `min`). The trailing `<childId>` segment is the registering child's id (or `default` for own predictions / virtuals tagged via `dashboard-sim-*`).
|
||||
|
||||
## 9. Configuration — editor form ↔ config keys
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph editor["Node-RED editor form"]
|
||||
f1[Asset model dropdown]
|
||||
f2[Mode current]
|
||||
f3[Position vs parent]
|
||||
f4[State times: starting, warmingup, ...]
|
||||
f5[Movement mode + speed]
|
||||
f6[Position min / max / initial]
|
||||
f7[Allowed sources / actions per mode]
|
||||
f8[Output unit (flow, pressure, power)]
|
||||
end
|
||||
subgraph cfg["Domain config slice"]
|
||||
c1[asset.model]
|
||||
c2[mode.current]
|
||||
c3[functionality.positionVsParent]
|
||||
c4[time.starting / warmingup / stopping / coolingdown]
|
||||
c5[movement.mode / speed / maxSpeed / interval]
|
||||
c6[position.min / max / initial]
|
||||
c7[mode.allowedSources / allowedActions]
|
||||
c8[general.unit / asset.unit]
|
||||
end
|
||||
f1 --> c1
|
||||
f2 --> c2
|
||||
f3 --> c3
|
||||
f4 --> c4
|
||||
f5 --> c5
|
||||
f6 --> c6
|
||||
f7 --> c7
|
||||
f8 --> c8
|
||||
```
|
||||
|
||||
| Form field | Config key | Default | Range | Where used |
|
||||
|---|---|---|---|---|
|
||||
| Asset model | `asset.model` | `Unknown` | string (must resolve in curve loader) | `_setupCurves` |
|
||||
| Mode | `mode.current` | `auto` | enum (`auto`, `manual`) | `flowController.handle` source check |
|
||||
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | enum | child-register payload + event suffix |
|
||||
| State time — starting | `time.starting` | `10` (s) | ≥ 0 | FSM timing |
|
||||
| State time — warmingup | `time.warmingup` | `5` (s) | ≥ 0 | FSM timing |
|
||||
| State time — stopping | `time.stopping` | `5` (s) | ≥ 0 | FSM timing |
|
||||
| State time — coolingdown | `time.coolingdown` | `10` (s) | ≥ 0 | FSM timing |
|
||||
| Movement mode | `movement.mode` | `dynspeed` | enum (`staticspeed`, `dynspeed`) | position trajectory |
|
||||
| Movement speed | `movement.speed` | `1` | ≤ `maxSpeed` | trajectory rate |
|
||||
| Position min/max | `position.min` / `position.max` | `0` / `100` | numeric | setpoint clamp |
|
||||
| Output unit (flow) | `general.unit` | `l/s` | unit string | unit policy `output.flow` |
|
||||
|
||||
## 10. State chart
|
||||
|
||||
The FSM is the canonical state set declared in `generalFunctions/src/state/stateConfig.json`. `emergencystop` is reachable from *every* state. Allowed transitions per `stateConfig.allowedTransitions`.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> idle
|
||||
idle --> starting: execsequence(startup)
|
||||
idle --> off: off
|
||||
idle --> maintenance: maintenance
|
||||
starting --> warmingup: timer
|
||||
warmingup --> operational: timer
|
||||
operational --> accelerating: flowmovement / setpoint up
|
||||
operational --> decelerating: flowmovement / setpoint down
|
||||
accelerating --> operational: target reached
|
||||
decelerating --> operational: target reached
|
||||
operational --> stopping: execsequence(shutdown)
|
||||
stopping --> coolingdown: timer
|
||||
stopping --> idle: timer
|
||||
coolingdown --> idle: timer
|
||||
coolingdown --> off: off
|
||||
off --> idle: execsequence(startup)
|
||||
off --> maintenance: maintenance
|
||||
maintenance --> idle: maintenance done
|
||||
maintenance --> off: off
|
||||
|
||||
note right of operational
|
||||
any state -> emergencystop
|
||||
via cmd.estop
|
||||
end note
|
||||
```
|
||||
|
||||
`accelerating` / `decelerating` are abortable on new demand via `abortMovement(reason)`; the controller does **not** auto-transition back to `operational` after an abort (see `state.js` comment "Abort path"). `warmingup` and `coolingdown` are **protected** — abort signals are dropped for safety. `activeStates = { operational, starting, warmingup, accelerating, decelerating }` is the set MGC treats as "machine alive".
|
||||
|
||||
## 11. Examples
|
||||
|
||||
| Tier | File | What it shows | Status |
|
||||
|---|---|---|---|
|
||||
| Basic | `examples/01 - Basic Manual Control.json` | Inject + dashboard, simulated pressure, manual startup/shutdown | ✅ validated |
|
||||
| Integration | `examples/02 - Integration with Machine Group.json` | rotatingMachine wired under MGC | ⏳ pending validation |
|
||||
| Dashboard | `examples/03 - Dashboard Visualization.json` | FlowFuse charts: flow / power / pressure trends | ✅ in repo |
|
||||
| Legacy | `examples/basic.flow.json` / `integration.flow.json` / `edge.flow.json` | Pre-refactor flows | ⚠️ kept until new Tier 2 is validated |
|
||||
|
||||
Screenshots will land under `wiki/_partial-screenshots/rotatingMachine/` once captured from the live demo.
|
||||
|
||||
## 12. Debug recipes
|
||||
|
||||
| Symptom | First thing to check | Where to look |
|
||||
|---|---|---|
|
||||
| `state` stuck on `idle`, no startup | Source not in `mode.allowedSources[currentMode]`. Check `flowController` warn log. | `_setupState` + `isValidSourceForMode`. |
|
||||
| `flow.predicted.*` is 0 or `NaN` | Pressure not initialised — `predictionHealth.flags` will say `pressure_init_warming`. Inject pressure via `data.simulate-measurement` or wire real measurement children. | `getMeasuredPressure` + `pressureSelector`. |
|
||||
| `predictionHealth.quality='invalid'` | Curve normalisation failed at startup — null predictors installed. Check container log for `Curve normalization failed for model …`. | `_setupCurves`. |
|
||||
| Drift `level=3` after startup | Less than 10 paired samples (`minSamplesForLongTerm`) — wait a few ticks before judging. | `driftProfiles.minSamplesForLongTerm`. |
|
||||
| `cmd.estop` doesn't recover | After `emergencystop`, only `idle` / `off` / `maintenance` are allowed. Send `cmd.shutdown` then `cmd.startup`, or reset via maintenance. | `stateConfig.allowedTransitions.emergencystop`. |
|
||||
| Position bounces around target | Movement mode `dynspeed` ease-in/out may overshoot at high speed; try `staticspeed`. | `movement.mode`. |
|
||||
|
||||
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors.
|
||||
|
||||
## 13. When you would NOT use this node
|
||||
|
||||
- Use rotatingMachine for a **single** pump / compressor / blower. For groups of 2+ with load sharing, wire `machineGroupControl` as the parent.
|
||||
- Don't use rotatingMachine to model a **passive non-return valve** — use `valve` (no curve, no FSM-driven motor).
|
||||
- Don't use rotatingMachine without a **curve model** — flow / power predictions degrade to zero and drift is meaningless.
|
||||
|
||||
## 14. Known limitations / current issues
|
||||
|
||||
| # | Issue | Tracked in |
|
||||
|---|---|---|
|
||||
| 1 | Drift confidence drops to 0 when pressure source is missing > 30 s — health flips to `invalid` silently. | `pressure/pressureInitialization.js`. |
|
||||
| 2 | Multi-parent registration accepted by `childRegistrationUtils` but ordering of teardown is not test-covered. | Open question — `OPEN_QUESTIONS.md`. |
|
||||
| 3 | `data.simulate-measurement` does not unset previous values on missing keys — stale sim data can persist after toggling off. | `measurementHandlers.updateSimulatedMeasurement`. |
|
||||
| 4 | `execSequence` legacy umbrella topic kept alive in registry; planned removal in Phase 7. | `commands/index.js` `_legacy: true`. |
|
||||
Reference in New Issue
Block a user