Compare commits
26 Commits
ea2857fb25
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f18f3cc673 | ||
|
|
2af6c904da | ||
|
|
f41e319b30 | ||
|
|
551ee6d70e | ||
|
|
b59d8e60f7 | ||
| e1e1977139 | |||
|
|
ddf2b07424 | ||
|
|
c982c9bef7 | ||
|
|
a47aa53d17 | ||
|
|
aeb938c205 | ||
|
|
a57e0095a3 | ||
|
|
047229c514 | ||
|
|
998e9bd758 | ||
|
|
6833e9f3a8 | ||
|
|
472402c62d | ||
|
|
26e92b54f7 | ||
|
|
d238270530 | ||
|
|
4cb9c5084c | ||
|
|
05de4ee29a | ||
|
|
7d19fc1db0 | ||
|
|
3ee1939b0a | ||
|
|
31324ae82d | ||
|
|
0e8cab5d3f | ||
|
|
045a941ab4 | ||
|
|
bb2f3bea82 | ||
|
|
619b1311d2 |
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Repo dev artifacts. Mirrors the deny list in .npmignore so the two stay
|
||||
# in sync — anything that shouldn't be committed AND shouldn't ship in the
|
||||
# npm tarball goes in both files.
|
||||
node_modules/
|
||||
package-lock.json
|
||||
*.tgz
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
|
||||
# Large local artifacts that don't belong in Git.
|
||||
# wiki/test.gif: screen recordings of the dashboard are kept locally for
|
||||
# reference but exceed 100 MB — use Git LFS or external storage if they
|
||||
# need to be shared.
|
||||
wiki/test.gif
|
||||
28
.npmignore
Normal file
28
.npmignore
Normal file
@@ -0,0 +1,28 @@
|
||||
# === Mirrors .gitignore — items below this block are also excluded from
|
||||
# the npm tarball. Kept here verbatim so npm pack doesn't fall back to
|
||||
# the .gitignore inheritance (silent + surprising). ===
|
||||
node_modules/
|
||||
package-lock.json
|
||||
*.tgz
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
|
||||
# Large local screen recording (>100 MB) — kept out of both repo and pack.
|
||||
wiki/test.gif
|
||||
|
||||
# === Dev-only content the npm tarball doesn't need ===
|
||||
# Tests + their harness — Node-RED loads the entry .js, not the test tree.
|
||||
test/
|
||||
*.test.js
|
||||
|
||||
# Wiki / docs — useful in the repo, big in the pack.
|
||||
wiki/
|
||||
|
||||
# Project memory + IDE configs.
|
||||
.claude/
|
||||
.codex/
|
||||
.repo-mem/
|
||||
CLAUDE.md
|
||||
CLAUDE.local.md
|
||||
25
CLAUDE.md
25
CLAUDE.md
@@ -21,3 +21,28 @@ Key points for this node:
|
||||
- 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 `#50a8d9` (Unit).
|
||||
|
||||
## Folder & File Layout
|
||||
|
||||
Every per-node file MUST use the folder name (`machineGroupControl`) **exactly**, case-sensitive. Full rule: [`.claude/rules/node-architecture.md`](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/.claude/rules/node-architecture.md) in the EVOLV superproject.
|
||||
|
||||
| Path | Required name |
|
||||
|---|---|
|
||||
| Entry file | `machineGroupControl.js` |
|
||||
| Editor HTML | `machineGroupControl.html` |
|
||||
| Node adapter | `src/nodeClass.js` |
|
||||
| Domain logic | `src/specificClass.js` |
|
||||
| Editor JS modules | `src/editor/*.js` (extract when inline editor JS exceeds ~50 lines) |
|
||||
| Tests | `test/{basic,integration,edge}/*.test.js` |
|
||||
| Example flows | `examples/*.flow.json` |
|
||||
|
||||
> ⚠️ **Legacy naming drift in this repo** — to be renamed when the file is next touched:
|
||||
>
|
||||
> | Path | Currently | Should be |
|
||||
> |---|---|---|
|
||||
> | Entry file | `mgc.js` | `machineGroupControl.js` |
|
||||
> | Editor HTML | `mgc.html` | `machineGroupControl.html` |
|
||||
>
|
||||
> Renames require updating: the file itself, `package.json#node-red.nodes`, any `require()` / `import` paths, and superproject submodule references in one commit.
|
||||
|
||||
When adding new files, read the rule above first to avoid drift.
|
||||
|
||||
70
CONTRACT.md
Normal file
70
CONTRACT.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# machineGroupControl — Contract
|
||||
|
||||
Hand-maintained for Phase 4; the `## Inputs` table is generated from
|
||||
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 80 lines.
|
||||
|
||||
## Inputs (msg.topic on Port 0)
|
||||
|
||||
| Canonical | Aliases (deprecated) | Payload | Effect |
|
||||
|---|---|---|---|
|
||||
| `set.mode` | `setMode` | `string` — one of `optimalControl`, `priorityControl`, `maintenance` (schema-validated) | Switches the control strategy via `source.setMode(payload)`. |
|
||||
| `child.register` | `registerChild` | `string` — the child node's Node-RED id | Resolves the child via `RED.nodes.getNode` and registers it through `childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent)`. |
|
||||
| `set.demand` | `Qd` | numeric (number or numeric string) | Calls `source.handleInput('parent', parseFloat(payload))`. On success, replies on Port 0 with `topic = source.config.general.name`, `payload = 'done'`. Non-numeric payloads log `error` and are skipped. |
|
||||
|
||||
Aliases log a one-time deprecation warning the first time they fire.
|
||||
|
||||
## 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 a successful `set.demand` dispatch the
|
||||
node additionally emits `{ topic: <name>, payload: 'done' }` as an
|
||||
acknowledgement.
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
machineGroupControl publishes:
|
||||
|
||||
- `flow.predicted.atequipment` — aggregated predicted group flow (sum of
|
||||
member-machine predicted flows at the group operating point).
|
||||
- `flow.predicted.downstream` — mirror of the live group flow seen at
|
||||
the discharge header (written by `handlePressureChange` for downstream
|
||||
consumers such as pumpingStation).
|
||||
- `power.predicted.atequipment` — aggregated predicted group power.
|
||||
- `efficiency.predicted.atequipment` — group efficiency = flow/power at
|
||||
the selected operating point.
|
||||
- `Ncog.predicted.atequipment` — group normalised cost-of-goods score.
|
||||
- `pressure.measured.upstream`, `pressure.measured.downstream`,
|
||||
`pressure.measured.differential` — mirrored from header-side
|
||||
measurement children (`asset.type='pressure'`), when registered.
|
||||
|
||||
The exact set is data-driven by which children register and what they
|
||||
publish; downstream consumers should subscribe by event name, not assume
|
||||
a fixed catalogue.
|
||||
|
||||
## Children registered by this node
|
||||
|
||||
machineGroupControl accepts two `softwareType`s through the
|
||||
`childRegistrationUtils` handshake:
|
||||
|
||||
- `machine` — a rotatingMachine. Stored in `source.machines[id]`.
|
||||
The group subscribes to its child's
|
||||
`pressure.measured.differential`, `pressure.measured.downstream`, and
|
||||
`flow.predicted.downstream` events to trigger `handlePressureChange`.
|
||||
- `measurement` — a header-side sensor (typically a pressure transmitter
|
||||
at the discharge or suction manifold). The group subscribes to the
|
||||
matching `<asset.type>.measured.<positionVsParent>` event and mirrors
|
||||
the value into its own MeasurementContainer; pressure events also
|
||||
trigger `handlePressureChange` so optimalControl can use ONE header
|
||||
operating point for all pumps.
|
||||
|
||||
Position labels accepted from children are `upstream`, `downstream`,
|
||||
`atequipment` (and case variants — normalised internally).
|
||||
83
examples/01-Basic.json
Normal file
83
examples/01-Basic.json
Normal file
@@ -0,0 +1,83 @@
|
||||
[
|
||||
{
|
||||
"id": "grp_drv_mode",
|
||||
"type": "group",
|
||||
"z": "tab_mgc_basic",
|
||||
"name": "1. Control mode",
|
||||
"style": {
|
||||
"stroke": "#666666",
|
||||
"fill": "#ffdf7f",
|
||||
"fill-opacity": "0.15",
|
||||
"label": true,
|
||||
"color": "#333333"
|
||||
},
|
||||
"nodes": [
|
||||
"inj_mode_optimal",
|
||||
"inj_mode_priority"
|
||||
],
|
||||
"x": 714,
|
||||
"y": 19,
|
||||
"w": 292,
|
||||
"h": 122
|
||||
},
|
||||
{
|
||||
"id": "inj_mode_optimal",
|
||||
"type": "inject",
|
||||
"z": "tab_mgc_basic",
|
||||
"g": "grp_drv_mode",
|
||||
"name": "set.mode = optimalControl",
|
||||
"props": [
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
},
|
||||
{
|
||||
"p": "payload",
|
||||
"v": "optimalControl",
|
||||
"vt": "str"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": false,
|
||||
"onceDelay": "",
|
||||
"topic": "set.mode",
|
||||
"x": 870,
|
||||
"y": 60,
|
||||
"wires": [
|
||||
[
|
||||
"mgc_basic_node"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "inj_mode_priority",
|
||||
"type": "inject",
|
||||
"z": "tab_mgc_basic",
|
||||
"g": "grp_drv_mode",
|
||||
"name": "set.mode = priorityControl",
|
||||
"props": [
|
||||
{
|
||||
"p": "topic",
|
||||
"vt": "str"
|
||||
},
|
||||
{
|
||||
"p": "payload",
|
||||
"v": "priorityControl",
|
||||
"vt": "str"
|
||||
}
|
||||
],
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": false,
|
||||
"onceDelay": "",
|
||||
"topic": "set.mode",
|
||||
"x": 870,
|
||||
"y": 100,
|
||||
"wires": [
|
||||
[
|
||||
"mgc_basic_node"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
1893
examples/02-Dashboard.json
Normal file
1893
examples/02-Dashboard.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,51 @@
|
||||
# machineGroupControl Example Flows
|
||||
# machineGroupControl - Example Flows
|
||||
|
||||
Import-ready Node-RED examples for machineGroupControl.
|
||||
Import-ready Node-RED examples for `machineGroupControl` (MGC). MGC is not a standalone node — it needs at least one `rotatingMachine` child to dispatch demand to. Both flows below ship three child pumps.
|
||||
|
||||
## Files
|
||||
- basic.flow.json
|
||||
- integration.flow.json
|
||||
- edge.flow.json
|
||||
|
||||
| File | Tier | What it shows |
|
||||
|---|---|---|
|
||||
| `01-Basic.json` | 1 | One MGC + three `rotatingMachine` pumps driven by inject buttons. Setup once-fires `virtualControl` + `cmd.startup` on all three pumps; mode / scaling / demand are then driven by buttons. |
|
||||
| `02-Dashboard.json` | 2 | Same command surface driven by a FlowFuse Dashboard 2.0 page — mode + scaling buttons, demand slider, live status rows, three trend charts, and a raw-output table. |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node-RED with the EVOLV package installed (`machineGroupControl` and `rotatingMachine` registered).
|
||||
- For `02-Dashboard.json`: `@flowfuse/node-red-dashboard` (Dashboard 2.0).
|
||||
|
||||
## Load a flow
|
||||
|
||||
```bash
|
||||
curl -X POST -H 'Content-Type: application/json' \
|
||||
--data @nodes/machineGroupControl/examples/01-Basic.json \
|
||||
http://localhost:1880/flows
|
||||
```
|
||||
|
||||
Or in the editor: Menu → Import → drag the file → Import.
|
||||
|
||||
## Canonical command surface
|
||||
|
||||
| Topic | Aliases | Payload | What it does |
|
||||
|---|---|---|---|
|
||||
| `set.mode` | `setMode` | `"optimalControl"`, `"priorityControl"`, `"prioritypercentagecontrol"`, `"maintenance"` | Switch dispatch strategy |
|
||||
| `set.scaling` | `setScaling` | `"normalized"`, `"absolute"` | Interpret demand as 0–100 % vs m³/h |
|
||||
| `set.demand` | `Qd` | number | Operator demand setpoint |
|
||||
| `child.register` | `registerChild` | child node id (string) | Manually register a child (Port 2 wiring does this automatically) |
|
||||
|
||||
## 01-Basic — what to try
|
||||
|
||||
1. Deploy. After ~1.5 s the Setup group auto-fires, putting all three pumps in `virtualControl` mode + sending `cmd.startup` to each.
|
||||
2. Click `set.demand = 50 %` — MGC's `optimalControl` picks the best pump combination by BEP-gravitation and dispatches `flowmovement` to the selected pumps.
|
||||
3. Click `set.demand = 100 %` — MGC switches to a higher combination, possibly engaging an extra pump.
|
||||
4. Switch mode to `priorityControl` and try the same demands — pumps now run equal-flow by priority order.
|
||||
5. Switch scaling to `absolute` — set.demand is now interpreted as m³/h (capped at the group min / max).
|
||||
6. `set.demand = 0` — MGC calls `turnOffAllMachines`, all pumps shut down.
|
||||
|
||||
## 02-Dashboard — what to try
|
||||
|
||||
1. Deploy → open `http://localhost:1880/dashboard/mgc-basic`.
|
||||
2. The dashboard auto-initialises the pumps; the `Initialize pumps` button on the page re-runs the setup manually.
|
||||
3. Drag the **Demand** slider — MGC dispatches and the Flow / Power / BEP charts react.
|
||||
4. Switch modes and scalings via the buttons; the Mode / Scaling rows in the Status panel reflect the change.
|
||||
5. Inspect the **Raw output** table for the full Port 0 surface (every field MGC emits, including `flowCapacityMax`, `machineCountActive`, `absDistFromPeak`, `relDistFromPeak`).
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
[
|
||||
{"id":"machineGroupControl_basic_tab","type":"tab","label":"machineGroupControl basic","disabled":false,"info":"machineGroupControl basic example"},
|
||||
{"id":"machineGroupControl_basic_node","type":"machineGroupControl","z":"machineGroupControl_basic_tab","name":"machineGroupControl basic","x":420,"y":180,"wires":[["machineGroupControl_basic_dbg"]]},
|
||||
{"id":"machineGroupControl_basic_inj","type":"inject","z":"machineGroupControl_basic_tab","name":"basic trigger","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"ping","payload":"1","payloadType":"str","x":160,"y":180,"wires":[["machineGroupControl_basic_node"]]},
|
||||
{"id":"machineGroupControl_basic_dbg","type":"debug","z":"machineGroupControl_basic_tab","name":"machineGroupControl basic debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
[
|
||||
{"id":"machineGroupControl_edge_tab","type":"tab","label":"machineGroupControl edge","disabled":false,"info":"machineGroupControl edge example"},
|
||||
{"id":"machineGroupControl_edge_node","type":"machineGroupControl","z":"machineGroupControl_edge_tab","name":"machineGroupControl edge","x":420,"y":180,"wires":[["machineGroupControl_edge_dbg"]]},
|
||||
{"id":"machineGroupControl_edge_inj","type":"inject","z":"machineGroupControl_edge_tab","name":"unknown topic","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"doesNotExist","payload":"x","payloadType":"str","x":170,"y":180,"wires":[["machineGroupControl_edge_node"]]},
|
||||
{"id":"machineGroupControl_edge_dbg","type":"debug","z":"machineGroupControl_edge_tab","name":"machineGroupControl edge debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":660,"y":180,"wires":[]}
|
||||
]
|
||||
@@ -1,6 +0,0 @@
|
||||
[
|
||||
{"id":"machineGroupControl_int_tab","type":"tab","label":"machineGroupControl integration","disabled":false,"info":"machineGroupControl integration example"},
|
||||
{"id":"machineGroupControl_int_node","type":"machineGroupControl","z":"machineGroupControl_int_tab","name":"machineGroupControl integration","x":420,"y":180,"wires":[["machineGroupControl_int_dbg"]]},
|
||||
{"id":"machineGroupControl_int_inj","type":"inject","z":"machineGroupControl_int_tab","name":"registerChild","props":[{"p":"topic","vt":"str"},{"p":"payload","vt":"str"}],"topic":"registerChild","payload":"example-child-id","payloadType":"str","x":170,"y":180,"wires":[["machineGroupControl_int_node"]]},
|
||||
{"id":"machineGroupControl_int_dbg","type":"debug","z":"machineGroupControl_int_tab","name":"machineGroupControl integration debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":680,"y":180,"wires":[]}
|
||||
]
|
||||
97
mgc.html
97
mgc.html
@@ -11,16 +11,66 @@
|
||||
<script src="/machineGroupControl/menu.js"></script> <!-- Load the menu script for dynamic dropdowns -->
|
||||
<script src="/machineGroupControl/configData.js"></script> <!-- Load the config script for node information -->
|
||||
|
||||
<!-- Editor JS modules — see nodes/machineGroupControl/src/editor/. Loaded in
|
||||
dependency order: index.js (namespace + helpers) → modules → oneditprepare. -->
|
||||
<script src="/machineGroupControl/editor/index.js"></script>
|
||||
<script src="/machineGroupControl/editor/mode-cards.js"></script>
|
||||
<script src="/machineGroupControl/editor/compact-fields.js"></script>
|
||||
<script src="/machineGroupControl/editor/oneditprepare.js"></script>
|
||||
|
||||
<style>
|
||||
/* MGC-specific UI: strategy mode cards + rendezvous toggle.
|
||||
Generic .evolv-icon-picker / .evolv-icon-option styles for the
|
||||
output-format pickers come from generalFunctions' iconHelpers (auto-
|
||||
injected by /menu.js). */
|
||||
.mgc-mode-cards,
|
||||
.mgc-toggle-row { display:flex; gap:6px; flex-wrap:wrap; margin:6px 0 4px 0; }
|
||||
.mgc-mode-card,
|
||||
.mgc-toggle-card {
|
||||
width:94px; height:86px; box-sizing:border-box;
|
||||
border:2px solid #d0d0d0; border-radius:4px; background:#fafafa;
|
||||
padding:4px; cursor:pointer; user-select:none;
|
||||
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px;
|
||||
transition:border-color 80ms ease-out, background 80ms ease-out;
|
||||
}
|
||||
.mgc-mode-card:hover,
|
||||
.mgc-toggle-card:hover { border-color:#86bbdd; background:#f5fafd; }
|
||||
.mgc-mode-card:focus,
|
||||
.mgc-toggle-card:focus { outline:2px solid #1F4E79; outline-offset:2px; }
|
||||
.mgc-mode-card-on,
|
||||
.mgc-toggle-card-on { border-color:#50a8d9; background:#eaf4fb; }
|
||||
.mgc-mode-card-svg,
|
||||
.mgc-toggle-card-svg { width:100%; height:54px; display:flex; align-items:center; justify-content:center; }
|
||||
.mgc-mode-card-svg svg,
|
||||
.mgc-toggle-card-svg svg { width:100%; height:100%; display:block; }
|
||||
.mgc-mode-card-label,
|
||||
.mgc-toggle-card-label { font-size:10px; line-height:1; font-weight:600; color:#333; white-space:nowrap; letter-spacing:0; }
|
||||
.mgc-toggle-card:not(.mgc-toggle-card-on) .mgc-toggle-card-svg { opacity:0.45; filter:grayscale(1); }
|
||||
.mgc-toggle-card:not(.mgc-toggle-card-on) .mgc-toggle-card-label { color:#888; }
|
||||
.mgc-hidden-checkbox { position:absolute; opacity:0; width:1px; height:1px; pointer-events:none; }
|
||||
.mgc-section-divider { border:0; border-top:1px solid #d6d6d6; margin:12px 0; }
|
||||
.mgc-output-row > label { white-space:nowrap; width:130px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
RED.nodes.registerType('machineGroupControl',{
|
||||
category: "EVOLV",
|
||||
color: "#50a8d9",
|
||||
color: "#B5651D",
|
||||
defaults: {
|
||||
// Define default properties
|
||||
name: { value: "" },
|
||||
processOutputFormat: { value: "process" },
|
||||
dbaseOutputFormat: { value: "influxdb" },
|
||||
|
||||
// Control strategy
|
||||
mode: { value: "optimalControl" }, // optimalControl | priorityControl | maintenance
|
||||
|
||||
// Same-time landing (rendezvous planner). When ON the planner
|
||||
// delays each pump's move so all pumps reach their setpoint at
|
||||
// the same wall-clock instant t* = max(eta_i). When OFF each
|
||||
// pump moves at its own pace and lands at its own eta.
|
||||
useRendezvous: { value: true },
|
||||
|
||||
//define asset properties
|
||||
uuid: { value: "" },
|
||||
supplier: { value: "" },
|
||||
@@ -52,10 +102,17 @@
|
||||
return (this.positionIcon || "") + " machineGroup";
|
||||
},
|
||||
oneditprepare: function() {
|
||||
// Initialize the menu data for the node
|
||||
const self = this;
|
||||
// Initialize the menu data for the node, then the visual modules.
|
||||
// Both attach to window.EVOLV.nodes.machineGroupControl.* — the
|
||||
// menu endpoint populates loggerMenu/positionMenu/initEditor; the
|
||||
// editor scripts populate editor.modeCards/rendezvousToggle/compactFields.
|
||||
const waitForMenuData = () => {
|
||||
if (window.EVOLV?.nodes?.machineGroupControl?.initEditor) {
|
||||
window.EVOLV.nodes.machineGroupControl.initEditor(this);
|
||||
window.EVOLV.nodes.machineGroupControl.initEditor(self);
|
||||
if (window.EVOLV.nodes.machineGroupControl.editor?.initVisuals) {
|
||||
window.EVOLV.nodes.machineGroupControl.editor.initVisuals(self);
|
||||
}
|
||||
} else {
|
||||
setTimeout(waitForMenuData, 50);
|
||||
}
|
||||
@@ -84,23 +141,49 @@
|
||||
|
||||
<script type="text/html" data-template-name="machineGroupControl">
|
||||
|
||||
<h3>Control strategy</h3>
|
||||
<!-- Hidden input is the canonical Node-RED-readable field. The visible
|
||||
picker is rendered by src/editor/mode-cards.js into the placeholder
|
||||
below, and clicks on a card write back to this input. -->
|
||||
<input type="hidden" id="node-input-mode" />
|
||||
<div id="mgc-mode-cards" class="mgc-mode-cards"
|
||||
role="radiogroup" aria-label="Control strategy mode">
|
||||
<!-- mode-cards.js renders three card divs here -->
|
||||
</div>
|
||||
<hr class="mgc-section-divider" />
|
||||
|
||||
<h3>Rendezvous planner</h3>
|
||||
<div class="form-row mgc-toggle-row">
|
||||
<input type="checkbox" id="node-input-useRendezvous" class="mgc-hidden-checkbox" />
|
||||
<div id="mgc-rendezvous-toggle" class="mgc-toggle-card"
|
||||
role="switch" tabindex="0" aria-label="Same-time landing"
|
||||
aria-checked="false" title="Same-time landing"></div>
|
||||
</div>
|
||||
<hr class="mgc-section-divider" />
|
||||
|
||||
<h3>Output Formats</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-row mgc-output-row">
|
||||
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||
<select id="node-input-processOutputFormat" style="width:60%;">
|
||||
<select id="node-input-processOutputFormat" class="evolv-native-hidden" style="width:60%;">
|
||||
<option value="process">process</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
<div id="mgc-process-output-picker" class="evolv-icon-picker"
|
||||
role="radiogroup" aria-label="Process output format"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-row mgc-output-row">
|
||||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||
<select id="node-input-dbaseOutputFormat" class="evolv-native-hidden" style="width:60%;">
|
||||
<option value="influxdb">influxdb</option>
|
||||
<option value="frost">frost</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
<div id="mgc-dbase-output-picker" class="evolv-icon-picker"
|
||||
role="radiogroup" aria-label="Database output format"></div>
|
||||
</div>
|
||||
<hr class="mgc-section-divider" />
|
||||
|
||||
<!-- Logger fields injected here -->
|
||||
<div id="logger-fields-placeholder"></div>
|
||||
|
||||
13
mgc.js
13
mgc.js
@@ -1,4 +1,5 @@
|
||||
const nameOfNode = 'machineGroupControl'; // this is the name of the node, it should match the file name and the node type in Node-RED
|
||||
const path = require('path');
|
||||
const nodeClass = require('./src/nodeClass.js'); // this is the specific node class
|
||||
const { MenuManager, configManager } = require('generalFunctions');
|
||||
|
||||
@@ -36,4 +37,16 @@ module.exports = function(RED) {
|
||||
res.status(500).send(`// Error generating configData: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Editor JS modules — loaded by mgc.html via <script src="/machineGroupControl/editor/*.js">.
|
||||
// Files live in src/editor/. Filename restricted to a safe charset to prevent
|
||||
// path-traversal. Mirrors pumpingStation.js:44-51.
|
||||
RED.httpAdmin.get(`/${nameOfNode}/editor/:file`, (req, res) => {
|
||||
const safe = String(req.params.file || '').replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
if (!safe.endsWith('.js')) return res.status(400).send('// invalid');
|
||||
res.type('application/javascript');
|
||||
res.sendFile(path.join(__dirname, 'src', 'editor', safe), (err) => {
|
||||
if (err && !res.headersSent) res.status(404).send('// editor module not found');
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -4,7 +4,10 @@
|
||||
"description": "Control module machineGroupControl",
|
||||
"main": "mgc.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",
|
||||
|
||||
96
src/combinatorics/pumpCombinations.js
Normal file
96
src/combinatorics/pumpCombinations.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// Pure subset/combination generators used by the optimizer.
|
||||
// All callable through `ctx` so this file stays free of class state.
|
||||
// `ctx` must provide:
|
||||
// - groupCurves: { groupFlow, groupPower } (from ../groupOps/groupCurves)
|
||||
// - logger (warn/debug)
|
||||
// - readChildMeasurement(machine, type, variant, position, canonicalUnit)
|
||||
// - POSITIONS, unitPolicy.canonical.flow
|
||||
|
||||
const EXCLUDED_STATES = new Set(['off', 'coolingdown', 'stopping', 'emergencystop']);
|
||||
|
||||
// Reduce demand by the flow that manually-driven operational machines
|
||||
// are already delivering. Returns the adjusted Qd (may be < 0).
|
||||
function checkSpecialCases(machines, Qd, ctx) {
|
||||
const { logger, readChildMeasurement, POSITIONS, unitPolicy } = ctx;
|
||||
const canonicalFlow = unitPolicy?.canonical?.flow;
|
||||
|
||||
Object.values(machines).forEach(machine => {
|
||||
const state = machine.state?.getCurrentState?.();
|
||||
const mode = machine.currentMode;
|
||||
|
||||
if (state !== 'operational') return;
|
||||
if (mode !== 'virtualControl' && mode !== 'fysicalControl') return;
|
||||
|
||||
const measuredFlow = readChildMeasurement
|
||||
? readChildMeasurement(machine, 'flow', 'measured', POSITIONS.DOWNSTREAM, canonicalFlow)
|
||||
: undefined;
|
||||
const predictedFlow = readChildMeasurement
|
||||
? readChildMeasurement(machine, 'flow', 'predicted', POSITIONS.DOWNSTREAM, canonicalFlow)
|
||||
: undefined;
|
||||
|
||||
let flow = 0;
|
||||
if (Number.isFinite(measuredFlow) && measuredFlow !== 0) {
|
||||
flow = measuredFlow;
|
||||
} else if (Number.isFinite(predictedFlow) && predictedFlow !== 0) {
|
||||
flow = predictedFlow;
|
||||
} else {
|
||||
// Unrecoverable: a machine is producing flow we can't quantify.
|
||||
// Caller decides whether to abort the dispatch tick.
|
||||
logger?.error?.(
|
||||
"Dont perform calculation at all seeing that there is a machine working but we dont know the flow its producing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Qd = Qd - flow;
|
||||
});
|
||||
return Qd;
|
||||
}
|
||||
|
||||
// Generate all non-empty machine subsets that can deliver Qd within powerCap.
|
||||
// Inputs that can't possibly contribute (off / coolingdown / mode-locked) are
|
||||
// excluded before the power set is built, so 2^N stays small in practice.
|
||||
function validPumpCombinations(machines, Qd, ctx, powerCap = Infinity) {
|
||||
const { groupCurves } = ctx;
|
||||
const groupFlow = groupCurves?.groupFlow;
|
||||
const groupPower = groupCurves?.groupPower;
|
||||
|
||||
Qd = checkSpecialCases(machines, Qd, ctx);
|
||||
|
||||
let subsets = [[]];
|
||||
Object.keys(machines).forEach(machineId => {
|
||||
const machine = machines[machineId];
|
||||
const state = machine.state?.getCurrentState?.();
|
||||
const validActionForMode =
|
||||
typeof machine.isValidActionForMode === 'function'
|
||||
? machine.isValidActionForMode('execsequence', 'auto')
|
||||
: true;
|
||||
|
||||
if (EXCLUDED_STATES.has(state) || !validActionForMode) return;
|
||||
|
||||
const newSubsets = subsets.map(set => [...set, machineId]);
|
||||
subsets = subsets.concat(newSubsets);
|
||||
});
|
||||
|
||||
return subsets.filter(subset => {
|
||||
if (subset.length === 0) return false;
|
||||
|
||||
const { maxFlow, minFlow, maxPower } = subset.reduce(
|
||||
(acc, machineId) => {
|
||||
const machine = machines[machineId];
|
||||
const f = groupFlow(machine);
|
||||
const p = groupPower(machine);
|
||||
return {
|
||||
maxFlow: acc.maxFlow + f.currentFxyYMax,
|
||||
minFlow: acc.minFlow + f.currentFxyYMin,
|
||||
maxPower: acc.maxPower + p.currentFxyYMax,
|
||||
};
|
||||
},
|
||||
{ maxFlow: 0, minFlow: 0, maxPower: 0 },
|
||||
);
|
||||
|
||||
return maxFlow >= Qd && minFlow <= Qd && maxPower <= powerCap;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { validPumpCombinations, checkSpecialCases, EXCLUDED_STATES };
|
||||
104
src/commands/handlers.js
Normal file
104
src/commands/handlers.js
Normal file
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
|
||||
// Handler functions for machineGroupControl commands. Each handler receives:
|
||||
// source: the domain (specificClass) instance — exposes setMode,
|
||||
// handleInput, childRegistrationUtils.registerChild, logger,
|
||||
// config.general.name.
|
||||
// msg: the Node-RED input message.
|
||||
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
|
||||
//
|
||||
// Pure functions: no module-level state. The registry already enforces the
|
||||
// typeof-check ladder; per-topic semantic validation lives here.
|
||||
|
||||
function _logger(source, ctx) {
|
||||
return ctx?.logger || source?.logger || null;
|
||||
}
|
||||
|
||||
// Gate one command against the mode-allowed action and source allow-lists.
|
||||
// Returns true if both gates pass (or if the source lacks the gate methods —
|
||||
// keeps backward compat with fakes/specifics that haven't adopted the pattern
|
||||
// yet). When a gate fails the source already warn-logs; we just bail out.
|
||||
function _gate(source, action, msg) {
|
||||
if (typeof source?.isValidActionForMode === 'function') {
|
||||
if (!source.isValidActionForMode(action, source.mode)) return false;
|
||||
}
|
||||
if (typeof source?.isValidSourceForMode === 'function') {
|
||||
const src = (typeof msg?.source === 'string' && msg.source) ? msg.source : 'parent';
|
||||
if (!source.isValidSourceForMode(src, source.mode)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
exports.setMode = (source, msg) => {
|
||||
// set.mode is a status-level operation — allowed in every mode by the
|
||||
// default schema (incl. maintenance). The gate still fires so an
|
||||
// unauthorised source is rejected even for mode switching.
|
||||
if (!_gate(source, 'statusCheck', msg)) return;
|
||||
source.setMode(msg.payload);
|
||||
};
|
||||
|
||||
exports.registerChild = (source, msg, ctx) => {
|
||||
if (!_gate(source, 'statusCheck', msg)) return;
|
||||
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);
|
||||
};
|
||||
|
||||
exports.setDemand = async (source, msg, ctx) => {
|
||||
const log = _logger(source, ctx);
|
||||
// Operator demand is self-describing: the unit on the message decides how
|
||||
// the value is interpreted. There is no persistent scaling state on MGC.
|
||||
//
|
||||
// payload = number → unit defaults to '%'
|
||||
// payload = { value, unit:'%' }→ percent of group capacity
|
||||
// payload = { value, unit:'m3/h' | 'l/s' | 'm3/s' | ... } → absolute flow
|
||||
// payload < 0 (any unit) → operator stop-all signal
|
||||
//
|
||||
// Unit resolution + canonical dispatch lives in source.setDemand. The
|
||||
// handler's job is payload parsing, mode gating, and the "done" reply.
|
||||
const p = msg?.payload;
|
||||
let rawValue;
|
||||
let unit;
|
||||
if (p !== null && typeof p === 'object') {
|
||||
rawValue = p.value;
|
||||
unit = (typeof p.unit === 'string' && p.unit.trim()) ? p.unit.trim() : '%';
|
||||
} else {
|
||||
rawValue = p;
|
||||
unit = '%';
|
||||
}
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isFinite(value)) {
|
||||
log?.error?.(`set.demand: invalid Qd value '${JSON.stringify(msg?.payload)}'`);
|
||||
return;
|
||||
}
|
||||
// Gate the demand against the current mode. Action kind depends on whether
|
||||
// this is a stop-all (negative) or a dispatch — the schema declares which
|
||||
// are accepted per mode (maintenance gets neither). Done after numeric
|
||||
// parse so an unparseable payload is still surfaced as an error, not a
|
||||
// silent mode-rejection.
|
||||
let action;
|
||||
if (value < 0) action = 'emergencyStop';
|
||||
else if (source?.mode === 'priorityControl') action = 'execSequentialControl';
|
||||
else action = 'execOptimalCombination';
|
||||
if (!_gate(source, action, msg)) return;
|
||||
try {
|
||||
await source.setDemand(value, unit);
|
||||
} catch (err) {
|
||||
log?.error?.(`set.demand: failed to process Qd: ${err && err.message}`);
|
||||
return;
|
||||
}
|
||||
// Reply on Port 0 with the configured node name as topic — preserves the
|
||||
// legacy "done" handshake some downstream flows rely on.
|
||||
if (typeof ctx?.send === 'function') {
|
||||
const reply = Object.assign({}, msg, {
|
||||
topic: source?.config?.general?.name,
|
||||
payload: 'done',
|
||||
});
|
||||
ctx.send(reply);
|
||||
}
|
||||
};
|
||||
38
src/commands/index.js
Normal file
38
src/commands/index.js
Normal file
@@ -0,0 +1,38 @@
|
||||
'use strict';
|
||||
|
||||
// machineGroupControl 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.
|
||||
|
||||
const handlers = require('./handlers');
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
topic: 'set.mode',
|
||||
aliases: ['setMode'],
|
||||
payloadSchema: { type: 'string' },
|
||||
description: 'Switch the operating mode. Allowed: `optimalControl`, `priorityControl`, `maintenance` (schema-validated in `machineGroupControl.json` → `mode.current`).',
|
||||
handler: handlers.setMode,
|
||||
},
|
||||
{
|
||||
topic: 'child.register',
|
||||
aliases: ['registerChild'],
|
||||
// payload is the Node-RED id (string) of the child node.
|
||||
payloadSchema: { type: 'string' },
|
||||
description: 'Register a child machine with this group.',
|
||||
handler: handlers.registerChild,
|
||||
},
|
||||
{
|
||||
topic: 'set.demand',
|
||||
aliases: ['Qd'],
|
||||
// payload is either a bare number (interpreted as %) or
|
||||
// { value: number, unit: '%' | 'm3/h' | 'l/s' | 'm3/s' | ... }.
|
||||
// No `units` descriptor — the handler resolves the unit explicitly so
|
||||
// commandRegistry._normaliseUnits doesn't pre-convert a percentage into
|
||||
// a flow rate. Negative value is the operator stop-all signal.
|
||||
payloadSchema: { type: 'any' },
|
||||
description: 'Operator demand setpoint. Bare number = %; {value, unit} for absolute flow units. Negative = stop all.',
|
||||
handler: handlers.setDemand,
|
||||
},
|
||||
];
|
||||
182
src/control/strategies.js
Normal file
182
src/control/strategies.js
Normal file
@@ -0,0 +1,182 @@
|
||||
'use strict';
|
||||
|
||||
// Priority-based control strategies for machineGroupControl.
|
||||
//
|
||||
// equalFlowControl: distribute demand equally across priority-ordered active
|
||||
// machines, falling back to start/stop the next priority when the current
|
||||
// active set can't deliver.
|
||||
//
|
||||
// Extracted from specificClass during the P4 refactor; the orchestrator
|
||||
// wires it in via the strategies map below. It depends on the same
|
||||
// group-curve helpers the optimizer uses, so allocation and power
|
||||
// evaluation stay on the equalised group operating point.
|
||||
|
||||
const { POSITIONS } = require('generalFunctions');
|
||||
const { groupFlow, groupCalcPower } = require('../groupOps/groupCurves');
|
||||
|
||||
function sortMachinesByPriority(machines, priorityList) {
|
||||
if (priorityList && Array.isArray(priorityList)) {
|
||||
return priorityList
|
||||
.filter(id => machines[id])
|
||||
.map(id => ({ id, machine: machines[id] }));
|
||||
}
|
||||
return Object.entries(machines)
|
||||
.map(([id, machine]) => ({ id, machine }))
|
||||
.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
function filterOutUnavailableMachines(list) {
|
||||
return list.filter(({ machine }) => {
|
||||
const state = machine.state.getCurrentState();
|
||||
const validActionForMode = machine.isValidActionForMode('execsequence', 'auto');
|
||||
return !(state === 'off' || state === 'coolingdown' || state === 'stopping'
|
||||
|| state === 'emergencystop' || !validActionForMode);
|
||||
});
|
||||
}
|
||||
|
||||
function capFlowDemand(Qd, dynamicTotals, logger) {
|
||||
if (Qd < dynamicTotals.flow.min && Qd > 0) {
|
||||
logger?.warn?.(`Flow demand ${Qd} below min ${dynamicTotals.flow.min}; capping.`);
|
||||
return dynamicTotals.flow.min;
|
||||
}
|
||||
if (Qd > dynamicTotals.flow.max) {
|
||||
logger?.warn?.(`Flow demand ${Qd} above max ${dynamicTotals.flow.max}; capping.`);
|
||||
return dynamicTotals.flow.max;
|
||||
}
|
||||
return Qd;
|
||||
}
|
||||
|
||||
// Pure distribution math: given the demand, group envelope, priority list, and
|
||||
// per-machine curve helpers, return the {machineId, flow} mapping plus running
|
||||
// totals. No side effects, no mgc reference — testable without an MGC fixture.
|
||||
//
|
||||
// Inputs:
|
||||
// machines: dict {id → machine} (machine objects need group-curve fields set)
|
||||
// Qd: demand in canonical m³/s
|
||||
// dynamicTotals: {flow: {min, max}} — envelope across ALL registered pumps
|
||||
// activeTotals: {flow: {min, max}} — envelope across currently-active pumps
|
||||
// priorityList: optional array of ids; null = default ordering
|
||||
// isMachineActive: (id) → boolean (state-aware predicate)
|
||||
// groupFlow: (machine) → {currentFxyYMin, currentFxyYMax}
|
||||
// groupCalcPower: (machine, flow) → number (W)
|
||||
// logger: { warn, error, … } or null
|
||||
//
|
||||
// Returns: { flowDistribution: [{machineId, flow}], totalFlow, totalPower, totalCog }
|
||||
function computeEqualFlowDistribution({
|
||||
machines, Qd, dynamicTotals, activeTotals, priorityList,
|
||||
isMachineActive, groupFlow, groupCalcPower, logger,
|
||||
}) {
|
||||
Qd = capFlowDemand(Qd, dynamicTotals, logger);
|
||||
|
||||
let machinesInPriorityOrder = sortMachinesByPriority(machines, priorityList);
|
||||
machinesInPriorityOrder = filterOutUnavailableMachines(machinesInPriorityOrder);
|
||||
|
||||
const flowDistribution = [];
|
||||
let totalFlow = 0;
|
||||
let totalPower = 0;
|
||||
// Equal-flow doesn't compute a meaningful cog — only BEP-Gravitation does.
|
||||
// Preserved at 0 for backwards-compat; pinned by a basic test so a future
|
||||
// change that introduces a fake non-zero value will fail loudly.
|
||||
const totalCog = 0;
|
||||
|
||||
switch (true) {
|
||||
case (Qd < activeTotals.flow.min && activeTotals.flow.min !== 0): {
|
||||
let availableFlow = activeTotals.flow.min;
|
||||
for (let i = machinesInPriorityOrder.length - 1; i >= 0 && availableFlow > Qd; i--) {
|
||||
const m = machinesInPriorityOrder[i];
|
||||
if (isMachineActive(m.id)) {
|
||||
flowDistribution.push({ machineId: m.id, flow: 0 });
|
||||
availableFlow -= groupFlow(m.machine).currentFxyYMin;
|
||||
}
|
||||
}
|
||||
const remaining = machinesInPriorityOrder.filter(({ id }) =>
|
||||
isMachineActive(id) && !flowDistribution.some(it => it.machineId === id));
|
||||
const distributedFlow = Qd / remaining.length;
|
||||
for (const m of remaining) {
|
||||
flowDistribution.push({ machineId: m.id, flow: distributedFlow });
|
||||
totalFlow += distributedFlow;
|
||||
totalPower += groupCalcPower(m.machine, distributedFlow);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case (Qd > activeTotals.flow.max): {
|
||||
let i = 1;
|
||||
while (totalFlow < Qd && i <= machinesInPriorityOrder.length) {
|
||||
Qd = Qd / i;
|
||||
if (groupFlow(machinesInPriorityOrder[i - 1].machine).currentFxyYMax >= Qd) {
|
||||
for (let i2 = 0; i2 < i; i2++) {
|
||||
if (!isMachineActive(machinesInPriorityOrder[i2].id)) {
|
||||
flowDistribution.push({ machineId: machinesInPriorityOrder[i2].id, flow: Qd });
|
||||
totalFlow += Qd;
|
||||
totalPower += groupCalcPower(machinesInPriorityOrder[i2].machine, Qd);
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const countActive = machinesInPriorityOrder.filter(({ id }) => isMachineActive(id)).length;
|
||||
Qd /= countActive;
|
||||
for (let i = 0; i < countActive; i++) {
|
||||
flowDistribution.push({ machineId: machinesInPriorityOrder[i].id, flow: Qd });
|
||||
totalFlow += Qd;
|
||||
totalPower += groupCalcPower(machinesInPriorityOrder[i].machine, Qd);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { flowDistribution, totalFlow, totalPower, totalCog };
|
||||
}
|
||||
|
||||
// Orchestrator: equalize the operating point, call the pure distribution math,
|
||||
// write outputs, dispatch children. The mgc reaches happen here, not in the
|
||||
// algorithm — see computeEqualFlowDistribution above for the part that's
|
||||
// testable in isolation.
|
||||
async function equalFlowControl(ctx, Qd, _powerCap = Infinity, priorityList = null) {
|
||||
const { mgc } = ctx;
|
||||
try {
|
||||
mgc.equalizePressure();
|
||||
const dynamicTotals = mgc.calcDynamicTotals();
|
||||
const activeTotals = mgc.totals.activeTotals();
|
||||
|
||||
const { flowDistribution, totalFlow, totalPower, totalCog } = computeEqualFlowDistribution({
|
||||
machines: mgc.machines,
|
||||
Qd, dynamicTotals, activeTotals, priorityList,
|
||||
isMachineActive: (id) => mgc.isMachineActive(id),
|
||||
groupFlow, groupCalcPower,
|
||||
logger: mgc.logger,
|
||||
});
|
||||
|
||||
const pUnit = mgc.unitPolicy.canonical.power;
|
||||
const fUnit = mgc.unitPolicy.canonical.flow;
|
||||
mgc.operatingPoint.writeOwn('power', 'predicted', POSITIONS.AT_EQUIPMENT, totalPower, pUnit);
|
||||
mgc.operatingPoint.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, totalFlow, fUnit);
|
||||
// Hydraulic efficiency η = (Q·ΔP)/P_shaft, same scale as child cogs.
|
||||
const dP = mgc.operatingPoint.headerDiffPa;
|
||||
if (Number.isFinite(dP) && dP > 0 && totalPower > 0) {
|
||||
mgc.measurements.type('efficiency').variant('predicted').position(POSITIONS.AT_EQUIPMENT)
|
||||
.value((totalFlow * dP) / totalPower);
|
||||
}
|
||||
mgc.measurements.type('Ncog').variant('predicted').position(POSITIONS.AT_EQUIPMENT).value(totalCog);
|
||||
|
||||
// Route the chosen distribution through the shared planner/executor
|
||||
// path. With planner.useRendezvous=true (the default) all pumps
|
||||
// reach their per-pump flow target at the same wall-clock instant;
|
||||
// with it false, every command fires at tick 0 — same effect as
|
||||
// the legacy Promise.all dispatch but with correct startup/shutdown
|
||||
// ordering (the planner emits execsequence BEFORE flowmovement for
|
||||
// idle pumps, where the legacy code emitted them in the opposite
|
||||
// order and relied on the pump's delayedMove queue to recover).
|
||||
await mgc._dispatchFlowDistribution(flowDistribution);
|
||||
} catch (err) {
|
||||
mgc.logger?.error?.(err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
equalFlowControl, computeEqualFlowDistribution,
|
||||
capFlowDemand, sortMachinesByPriority, filterOutUnavailableMachines,
|
||||
};
|
||||
53
src/dispatch/demandDispatcher.js
Normal file
53
src/dispatch/demandDispatcher.js
Normal file
@@ -0,0 +1,53 @@
|
||||
'use strict';
|
||||
|
||||
const { LatestWinsGate } = require('generalFunctions');
|
||||
|
||||
// Thin wrapper around LatestWinsGate for the MGC demand path. Replaces
|
||||
// the original `_dispatchInFlight` + `_delayedCall` pair in
|
||||
// specificClass.handleInput: a new demand arriving while a dispatch is
|
||||
// in flight overwrites any pending one, so the latest value always wins
|
||||
// and intermediates are dropped silently.
|
||||
|
||||
class DemandDispatcher {
|
||||
constructor(ctx = {}, runFn) {
|
||||
if (typeof runFn !== 'function') {
|
||||
throw new TypeError('DemandDispatcher requires a runFn');
|
||||
}
|
||||
this.ctx = ctx;
|
||||
this.logger = ctx.logger || null;
|
||||
this._runFn = runFn;
|
||||
this._gate = new LatestWinsGate(
|
||||
async (demand) => this._runFn(demand, this.ctx),
|
||||
{ logger: this.logger },
|
||||
);
|
||||
}
|
||||
|
||||
fire(demand) {
|
||||
this._gate.fire(demand);
|
||||
}
|
||||
|
||||
// Returns a promise that resolves when THIS demand's dispatch settles.
|
||||
// If superseded by a later fireAndWait while parked, the promise
|
||||
// resolves with the LatestWinsGate SUPERSEDED sentinel
|
||||
// ({ superseded: true }) — callers can branch on it without try/catch.
|
||||
fireAndWait(demand) {
|
||||
return this._gate.fireAndWait(demand);
|
||||
}
|
||||
|
||||
drain() {
|
||||
return this._gate.drain();
|
||||
}
|
||||
|
||||
// Cancels any parked pending value so it cannot run. The currently
|
||||
// in-flight dispatch (if any) still runs to completion. A parked
|
||||
// fireAndWait promise resolves with the SUPERSEDED sentinel.
|
||||
cancelPending() {
|
||||
if (this._gate._pending) this._gate._supersedePending();
|
||||
}
|
||||
|
||||
get inFlight() {
|
||||
return this._gate.size > 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DemandDispatcher;
|
||||
94
src/editor/compact-fields.js
Normal file
94
src/editor/compact-fields.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// compact-fields.js — MGC-only output-format icon picker.
|
||||
//
|
||||
// Logger toggle/level and physical-position visuals now live in the shared
|
||||
// generalFunctions/src/menu/iconHelpers.js (auto-injected by MenuManager), so
|
||||
// the only MGC-local visuals left are the two output-format dropdowns
|
||||
// (processOutputFormat, dbaseOutputFormat) — those fields aren't part of any
|
||||
// shared menu.
|
||||
|
||||
(function () {
|
||||
const editor = window.EVOLV?.nodes?.machineGroupControl?.editor;
|
||||
if (!editor) return;
|
||||
|
||||
const BLUE = '#1F4E79';
|
||||
const STEEL = '#607484';
|
||||
|
||||
// MGC-only SVGs (output formats only — logger/position SVGs come from
|
||||
// window.EVOLV.iconHelpers.SVG).
|
||||
const SVG = {
|
||||
process: `
|
||||
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect x="10" y="14" width="20" height="30" rx="2" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
|
||||
<rect x="50" y="14" width="20" height="30" rx="2" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
|
||||
<line x1="30" y1="29" x2="46" y2="29" stroke="${BLUE}" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M42 24 L48 29 L42 34" fill="none" stroke="${BLUE}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`,
|
||||
json: `
|
||||
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<g fill="none" stroke="${BLUE}" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M30 14 C22 16 22 26 27 29 C22 32 22 42 30 44"/>
|
||||
<path d="M50 14 C58 16 58 26 53 29 C58 32 58 42 50 44"/>
|
||||
</g>
|
||||
<g fill="${STEEL}">
|
||||
<circle cx="36" cy="29" r="2.2"/>
|
||||
<circle cx="44" cy="29" r="2.2"/>
|
||||
</g>
|
||||
</svg>`,
|
||||
csv: `
|
||||
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect x="12" y="12" width="56" height="34" rx="2" fill="#fff" stroke="${STEEL}" stroke-width="2.4"/>
|
||||
<line x1="12" y1="22" x2="68" y2="22" stroke="${STEEL}" stroke-width="2"/>
|
||||
<g stroke="${STEEL}" stroke-width="1.6">
|
||||
<line x1="12" y1="34" x2="68" y2="34"/>
|
||||
<line x1="31" y1="12" x2="31" y2="46"/>
|
||||
<line x1="49" y1="12" x2="49" y2="46"/>
|
||||
</g>
|
||||
</svg>`,
|
||||
influxdb: `
|
||||
<svg viewBox="0 0 80 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<ellipse cx="40" cy="15" rx="22" ry="6" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
|
||||
<path d="M18 15 V42 C18 46 28 49 40 49 C52 49 62 46 62 42 V15" fill="#f7fafc" stroke="${STEEL}" stroke-width="2.4"/>
|
||||
<path d="M18 28 C26 32 54 32 62 28" fill="none" stroke="${STEEL}" stroke-width="1.6" opacity="0.6"/>
|
||||
<path d="M22 39 L30 32 L38 41 L46 34 L54 38" fill="none" stroke="${BLUE}" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
const outputIcons = {
|
||||
process: SVG.process,
|
||||
json: SVG.json,
|
||||
csv: SVG.csv,
|
||||
influxdb: SVG.influxdb,
|
||||
frost: SVG.influxdb,
|
||||
};
|
||||
|
||||
const outputLabels = {
|
||||
process: 'Process',
|
||||
json: 'JSON',
|
||||
csv: 'CSV',
|
||||
influxdb: 'Influx',
|
||||
frost: 'FROST',
|
||||
};
|
||||
|
||||
function initOutputFormats() {
|
||||
const helpers = window.EVOLV?.iconHelpers;
|
||||
if (!helpers) return;
|
||||
|
||||
const processSelect = document.getElementById('node-input-processOutputFormat');
|
||||
const processHolder = document.getElementById('mgc-process-output-picker');
|
||||
if (processSelect && processHolder) {
|
||||
helpers.renderSelectPicker(processSelect, processHolder, outputIcons, outputLabels);
|
||||
}
|
||||
|
||||
const dbaseSelect = document.getElementById('node-input-dbaseOutputFormat');
|
||||
const dbaseHolder = document.getElementById('mgc-dbase-output-picker');
|
||||
if (dbaseSelect && dbaseHolder) {
|
||||
helpers.renderSelectPicker(dbaseSelect, dbaseHolder, outputIcons, outputLabels);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
initOutputFormats();
|
||||
}
|
||||
|
||||
editor.compactFields = { init };
|
||||
})();
|
||||
34
src/editor/index.js
Normal file
34
src/editor/index.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// machineGroupControl editor — namespace bootstrap.
|
||||
//
|
||||
// Attaches the editor's submodule registry to the shared
|
||||
// window.EVOLV.nodes.machineGroupControl namespace (same one the menuManager
|
||||
// and configManager endpoints populate). Each sibling module in this
|
||||
// directory (mode-cards.js, demand-contract.js, oneditprepare.js) registers
|
||||
// itself by writing additional members onto this namespace.
|
||||
//
|
||||
// Loaded first by mgc.html — must not depend on any other src/editor module.
|
||||
|
||||
(function () {
|
||||
const root = window.EVOLV = window.EVOLV || {};
|
||||
const nodes = root.nodes = root.nodes || {};
|
||||
const ns = nodes.machineGroupControl = nodes.machineGroupControl || {};
|
||||
const editor = ns.editor = ns.editor || {};
|
||||
|
||||
// Pub/sub for mode changes — mode-cards.js fires, anything that wants to
|
||||
// re-render on mode change subscribes. Keep it tiny; no third-party emitter.
|
||||
const modeListeners = [];
|
||||
editor.onModeChange = (cb) => { if (typeof cb === 'function') modeListeners.push(cb); };
|
||||
editor.emitModeChange = (newMode) => {
|
||||
for (const cb of modeListeners) {
|
||||
try { cb(newMode); } catch (e) { /* swallow — UI helper */ }
|
||||
}
|
||||
};
|
||||
|
||||
// Read the currently selected mode from the hidden input that mode-cards.js
|
||||
// keeps in sync with the active card. Falls back to optimalControl if the
|
||||
// input isn't on the page yet (race against oneditprepare).
|
||||
editor.getMode = () => {
|
||||
const el = document.getElementById('node-input-mode');
|
||||
return (el && el.value) || 'optimalControl';
|
||||
};
|
||||
})();
|
||||
139
src/editor/mode-cards.js
Normal file
139
src/editor/mode-cards.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// mode-cards.js — visual pickers for control-strategy modes and planner flags.
|
||||
//
|
||||
// Replaces the plain mode field with compact illustrated controls. The
|
||||
// original inputs stay in the DOM but are hidden — Node-RED reads their values
|
||||
// on save, exactly as before.
|
||||
//
|
||||
// SVGs are inline so the editor doesn't need to fetch additional assets.
|
||||
|
||||
(function () {
|
||||
const editor = window.EVOLV?.nodes?.machineGroupControl?.editor;
|
||||
if (!editor) return;
|
||||
|
||||
const MODES = [
|
||||
{
|
||||
value: 'optimalControl',
|
||||
ariaLabel: 'Optimal control',
|
||||
label: 'Most-efficient',
|
||||
svg: `
|
||||
<svg viewBox="0 0 120 72" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<line x1="12" y1="60" x2="112" y2="60" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<line x1="12" y1="60" x2="12" y2="10" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<path d="M 16 60 Q 62 -30 108 60" fill="none" stroke="#1E8449" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="62" y1="15" x2="62" y2="60" stroke="#1F4E79" stroke-dasharray="3 3" stroke-width="1.2"/>
|
||||
<circle cx="62" cy="15" r="5.5" fill="#1E8449" stroke="#fff" stroke-width="1.6"/>
|
||||
</svg>`,
|
||||
},
|
||||
{
|
||||
value: 'priorityControl',
|
||||
ariaLabel: 'Priority control',
|
||||
label: 'Priority',
|
||||
svg: `
|
||||
<svg viewBox="0 0 120 72" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<line x1="12" y1="60" x2="112" y2="60" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<line x1="12" y1="60" x2="12" y2="10" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<polyline points="14,54 38,54 38,40 62,40 62,26 86,26 86,14 110,14"
|
||||
fill="none" stroke="#1F4E79" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
</svg>`,
|
||||
},
|
||||
{
|
||||
value: 'maintenance',
|
||||
ariaLabel: 'Maintenance',
|
||||
label: 'Maintenance',
|
||||
svg: `<i class="fa fa-wrench" style="font-size:40px;color:#607484;" aria-hidden="true"></i>`,
|
||||
},
|
||||
];
|
||||
|
||||
const RENDEZVOUS_SVG = `
|
||||
<svg viewBox="0 0 120 72" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<line x1="12" y1="60" x2="112" y2="60" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<line x1="12" y1="60" x2="12" y2="10" stroke="#888" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<line x1="96" y1="12" x2="96" y2="60" stroke="#1F4E79" stroke-dasharray="3 3" stroke-width="1.4"/>
|
||||
<path d="M 18 52 C 38 50, 64 38, 96 20" fill="none" stroke="#1E8449" stroke-width="2.6" stroke-linecap="round"/>
|
||||
<path d="M 18 58 C 40 56, 64 42, 96 20" fill="none" stroke="#50a8d9" stroke-width="2.6" stroke-linecap="round"/>
|
||||
<path d="M 18 44 C 42 44, 66 34, 96 20" fill="none" stroke="#C0392B" stroke-width="2.6" stroke-linecap="round"/>
|
||||
<circle cx="96" cy="20" r="6" fill="#1F4E79" stroke="#fff" stroke-width="1.6"/>
|
||||
</svg>`;
|
||||
|
||||
// Render the three cards into the placeholder div. The hidden input stays
|
||||
// intact; the card click handler writes its value back so Node-RED's save
|
||||
// path is unchanged.
|
||||
function init(/* node */) {
|
||||
const placeholder = document.getElementById('mgc-mode-cards');
|
||||
const hidden = document.getElementById('node-input-mode');
|
||||
if (!placeholder || !hidden) return;
|
||||
|
||||
placeholder.innerHTML = MODES.map((m) => `
|
||||
<div class="mgc-mode-card" data-mode="${m.value}" role="radio" tabindex="0"
|
||||
aria-label="${m.ariaLabel}" aria-checked="false" title="${m.ariaLabel}">
|
||||
<div class="mgc-mode-card-svg">${m.svg}</div>
|
||||
<div class="mgc-mode-card-label">${m.label}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
const cards = Array.from(placeholder.querySelectorAll('.mgc-mode-card'));
|
||||
function syncHighlight() {
|
||||
const current = hidden.value || 'optimalControl';
|
||||
for (const c of cards) {
|
||||
const on = c.getAttribute('data-mode') === current;
|
||||
c.classList.toggle('mgc-mode-card-on', on);
|
||||
c.setAttribute('aria-checked', String(on));
|
||||
}
|
||||
}
|
||||
function pick(mode) {
|
||||
hidden.value = mode;
|
||||
// Fire change so any other listener bound to the input (Node-RED's
|
||||
// dirty-tracker, plus our pub/sub) sees the update.
|
||||
hidden.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
syncHighlight();
|
||||
editor.emitModeChange(mode);
|
||||
}
|
||||
for (const c of cards) {
|
||||
c.addEventListener('click', () => pick(c.getAttribute('data-mode')));
|
||||
c.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
pick(c.getAttribute('data-mode'));
|
||||
}
|
||||
});
|
||||
}
|
||||
syncHighlight();
|
||||
}
|
||||
|
||||
function initRendezvousToggle(/* node */) {
|
||||
const placeholder = document.getElementById('mgc-rendezvous-toggle');
|
||||
const checkbox = document.getElementById('node-input-useRendezvous');
|
||||
if (!placeholder || !checkbox) return;
|
||||
|
||||
placeholder.innerHTML = `
|
||||
<div class="mgc-toggle-card-svg">${RENDEZVOUS_SVG}</div>
|
||||
<div class="mgc-toggle-card-label">Inactive</div>
|
||||
`;
|
||||
const labelEl = placeholder.querySelector('.mgc-toggle-card-label');
|
||||
|
||||
function syncHighlight() {
|
||||
const on = checkbox.checked;
|
||||
placeholder.classList.toggle('mgc-toggle-card-on', on);
|
||||
placeholder.setAttribute('aria-checked', String(on));
|
||||
if (labelEl) labelEl.textContent = on ? 'Active' : 'Inactive';
|
||||
}
|
||||
function toggle() {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
syncHighlight();
|
||||
}
|
||||
|
||||
placeholder.addEventListener('click', toggle);
|
||||
placeholder.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
checkbox.addEventListener('change', syncHighlight);
|
||||
syncHighlight();
|
||||
}
|
||||
|
||||
editor.modeCards = { init };
|
||||
editor.rendezvousToggle = { init: initRendezvousToggle };
|
||||
})();
|
||||
22
src/editor/oneditprepare.js
Normal file
22
src/editor/oneditprepare.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// oneditprepare.js — initialise the editor's visual modules.
|
||||
//
|
||||
// Called from mgc.html's oneditprepare alongside the existing menuManager
|
||||
// initialiser (logger/position dropdowns). Each module is responsible for
|
||||
// its own placeholder; we just kick them off in dependency order.
|
||||
|
||||
(function () {
|
||||
const ns = window.EVOLV?.nodes?.machineGroupControl;
|
||||
if (!ns || !ns.editor) return;
|
||||
|
||||
ns.editor.initVisuals = function (node) {
|
||||
if (ns.editor.modeCards && typeof ns.editor.modeCards.init === 'function') {
|
||||
ns.editor.modeCards.init(node);
|
||||
}
|
||||
if (ns.editor.rendezvousToggle && typeof ns.editor.rendezvousToggle.init === 'function') {
|
||||
ns.editor.rendezvousToggle.init(node);
|
||||
}
|
||||
if (ns.editor.compactFields && typeof ns.editor.compactFields.init === 'function') {
|
||||
ns.editor.compactFields.init(node);
|
||||
}
|
||||
};
|
||||
})();
|
||||
96
src/efficiency/groupEfficiency.js
Normal file
96
src/efficiency/groupEfficiency.js
Normal file
@@ -0,0 +1,96 @@
|
||||
'use strict';
|
||||
|
||||
// Aggregates per-machine efficiency (cog) into group-level metrics and
|
||||
// computes distance-from-peak. Extracted verbatim from specificClass.js
|
||||
// (calcGroupEfficiency / calcDistanceFromPeak / calcRelativeDistanceFromPeak /
|
||||
// calcDistanceBEP) so the orchestrator can delegate without inheriting
|
||||
// the arithmetic.
|
||||
|
||||
class GroupEfficiency {
|
||||
constructor(ctx = {}) {
|
||||
this.ctx = ctx;
|
||||
this.logger = ctx.logger || null;
|
||||
this.interpolation = ctx.interpolation || null;
|
||||
this.measurements = ctx.measurements || null;
|
||||
this.machines = ctx.machines || null;
|
||||
}
|
||||
|
||||
// Average of per-machine cog plus the worst-performing machine's cog.
|
||||
// `maxEfficiency` is misleadingly named — it is in fact the MEAN cog
|
||||
// across all machines, treated as the group-level "peak" target.
|
||||
// Kept that way for behavioural parity with the original.
|
||||
calcGroupEfficiency(machines) {
|
||||
const target = machines || this.machines;
|
||||
let cumEfficiency = 0;
|
||||
let machineCount = 0;
|
||||
let lowestEfficiency = Infinity;
|
||||
|
||||
Object.entries(target || {}).forEach(([_id, machine]) => {
|
||||
cumEfficiency += machine.cog;
|
||||
if (machine.cog < lowestEfficiency) {
|
||||
lowestEfficiency = machine.cog;
|
||||
}
|
||||
machineCount++;
|
||||
});
|
||||
|
||||
const maxEfficiency = cumEfficiency / machineCount;
|
||||
const currentEfficiency = this._readCurrentEfficiency();
|
||||
|
||||
return { maxEfficiency, lowestEfficiency, currentEfficiency };
|
||||
}
|
||||
|
||||
calcDistanceFromPeak(currentEfficiency, peakEfficiency) {
|
||||
return Math.abs(currentEfficiency - peakEfficiency);
|
||||
}
|
||||
|
||||
// Maps current efficiency onto [0..1] across [maxEfficiency..minEfficiency].
|
||||
// Returns undefined for any case where the metric is meaningless:
|
||||
// - currentEfficiency missing
|
||||
// - the [max..min] band has collapsed (homogeneous pump group, OR float
|
||||
// noise so |max-min| < DEGENERATE_EPS).
|
||||
// Consumers must treat undefined as "no data" and display accordingly,
|
||||
// not as 0% / 100% — both readings would be misleading.
|
||||
calcRelativeDistanceFromPeak(currentEfficiency, maxEfficiency, minEfficiency) {
|
||||
const DEGENERATE_EPS = 1e-9; // η points are 0..1, so 1e-9 catches float noise.
|
||||
if (currentEfficiency == null) return undefined;
|
||||
if (!this.interpolation) return undefined;
|
||||
if (!Number.isFinite(maxEfficiency) || !Number.isFinite(minEfficiency)) return undefined;
|
||||
if (Math.abs(maxEfficiency - minEfficiency) < DEGENERATE_EPS) return undefined;
|
||||
return this.interpolation.interpolate_lin_single_point(
|
||||
currentEfficiency,
|
||||
maxEfficiency,
|
||||
minEfficiency,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
// Returns both abs + rel; orchestrator decides whether to mirror onto
|
||||
// its own this.absDistFromPeak / this.relDistFromPeak fields.
|
||||
calcDistanceBEP(currentEfficiency, maxEfficiency, minEfficiency) {
|
||||
const absDistFromPeak = this.calcDistanceFromPeak(currentEfficiency, maxEfficiency);
|
||||
const relDistFromPeak = this.calcRelativeDistanceFromPeak(
|
||||
currentEfficiency,
|
||||
maxEfficiency,
|
||||
minEfficiency,
|
||||
);
|
||||
return { absDistFromPeak, relDistFromPeak };
|
||||
}
|
||||
|
||||
// Pull the latest measured efficiency from the container if one was
|
||||
// provided. Optional convenience — orchestrator may read it directly.
|
||||
_readCurrentEfficiency() {
|
||||
if (!this.measurements) return null;
|
||||
try {
|
||||
return this.measurements
|
||||
.type('efficiency')
|
||||
.variant('predicted')
|
||||
.position('atequipment')
|
||||
.getCurrentValue();
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GroupEfficiency;
|
||||
27
src/groupOps/groupCurves.js
Normal file
27
src/groupOps/groupCurves.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// Group-scope read helpers for pump curves.
|
||||
//
|
||||
// Optimizers and totals evaluate each pump at the GROUP operating point
|
||||
// (set by GroupOperatingPoint.equalize), not the pump's individual sensor-
|
||||
// driven point. Each pump exposes a parallel "group*" predict object —
|
||||
// these helpers fall back to the individual predicts when the pump hasn't
|
||||
// been initialised for group operation yet (first tick after register).
|
||||
|
||||
function groupFlow(machine /*, ctx */) {
|
||||
return machine.groupPredictFlow ?? machine.predictFlow;
|
||||
}
|
||||
|
||||
function groupPower(machine /*, ctx */) {
|
||||
return machine.groupPredictPower ?? machine.predictPower;
|
||||
}
|
||||
|
||||
function groupNCog(machine /*, ctx */) {
|
||||
return machine.groupPredictFlow ? (machine.groupNCog ?? 0) : (machine.NCog ?? 0);
|
||||
}
|
||||
|
||||
function groupCalcPower(machine, flow /*, ctx */) {
|
||||
return typeof machine.groupCalcPower === 'function'
|
||||
? machine.groupCalcPower(flow)
|
||||
: machine.inputFlowCalcPower(flow);
|
||||
}
|
||||
|
||||
module.exports = { groupFlow, groupPower, groupNCog, groupCalcPower };
|
||||
100
src/groupOps/groupOperatingPoint.js
Normal file
100
src/groupOps/groupOperatingPoint.js
Normal file
@@ -0,0 +1,100 @@
|
||||
const { POSITIONS } = require('generalFunctions');
|
||||
|
||||
// Group-scope measurement read/write + header equalization.
|
||||
//
|
||||
// Pulled out of specificClass during the P4 refactor: the equalization
|
||||
// logic is the source of truth for the "one consistent header operating
|
||||
// point" that the optimizer and totals modules both depend on. Keeping it
|
||||
// in one place makes the order-of-operations explicit (read header, write
|
||||
// onto every machine's group-scope predicts).
|
||||
class GroupOperatingPoint {
|
||||
constructor(ctx = {}) {
|
||||
// ctx: { measurements, machines, unitPolicy, logger }
|
||||
// Late-binding via getters in the orchestrator works too — but
|
||||
// passing the live references avoids re-plumbing setters.
|
||||
this.ctx = ctx;
|
||||
// Last header differential pressure (Pa) computed by equalize().
|
||||
// Consumers (optimizer, strategies) read this to convert raw
|
||||
// flow/power to hydraulic efficiency η = (Q·ΔP)/P.
|
||||
this.headerDiffPa = 0;
|
||||
}
|
||||
|
||||
get measurements() { return this.ctx.measurements; }
|
||||
get machines() { return this.ctx.machines; }
|
||||
get unitPolicy() { return this.ctx.unitPolicy; }
|
||||
get logger() { return this.ctx.logger; }
|
||||
|
||||
readChild(machine, type, variant, position, unit = null) {
|
||||
return machine?.measurements
|
||||
?.type(type)
|
||||
?.variant(variant)
|
||||
?.position(position)
|
||||
?.getCurrentValue(unit || undefined);
|
||||
}
|
||||
|
||||
writeOwn(type, variant, position, value, unit = null, timestamp = Date.now()) {
|
||||
if (!Number.isFinite(value)) return;
|
||||
this.measurements
|
||||
.type(type)
|
||||
.variant(variant)
|
||||
.position(position)
|
||||
.value(value, timestamp, unit || undefined);
|
||||
}
|
||||
|
||||
// Force every machine's predict-curve interpolators to use the same
|
||||
// (header) differential pressure for MGC's optimization. See the
|
||||
// original _equalizeOperatingPoint commentary in specificClass for
|
||||
// the full rationale (header source order, fDimension fallback).
|
||||
equalize() {
|
||||
const machines = this.machines || {};
|
||||
if (Object.keys(machines).length === 0) return;
|
||||
|
||||
const pressureUnit = this.unitPolicy.canonical.pressure;
|
||||
const groupHeaderDown = this.measurements
|
||||
.type('pressure').variant('measured').position(POSITIONS.DOWNSTREAM)
|
||||
.getCurrentValue(pressureUnit);
|
||||
const groupHeaderUp = this.measurements
|
||||
.type('pressure').variant('measured').position(POSITIONS.UPSTREAM)
|
||||
.getCurrentValue(pressureUnit);
|
||||
|
||||
const childDown = [];
|
||||
const childUp = [];
|
||||
Object.values(machines).forEach(machine => {
|
||||
const d = this.readChild(machine, 'pressure', 'measured', POSITIONS.DOWNSTREAM, pressureUnit);
|
||||
const u = this.readChild(machine, 'pressure', 'measured', POSITIONS.UPSTREAM, pressureUnit);
|
||||
if (Number.isFinite(d) && d > 0) childDown.push(d);
|
||||
if (Number.isFinite(u) && u > 0) childUp.push(u);
|
||||
});
|
||||
|
||||
const downIsHeader = Number.isFinite(groupHeaderDown) && groupHeaderDown > 0;
|
||||
const upIsHeader = Number.isFinite(groupHeaderUp) && groupHeaderUp > 0;
|
||||
const headerDownstream = downIsHeader ? groupHeaderDown : (childDown.length ? Math.max(...childDown) : 0);
|
||||
const headerUpstream = upIsHeader ? groupHeaderUp : (childUp.length ? Math.min(...childUp) : 0);
|
||||
|
||||
const headerDiff = headerDownstream - headerUpstream;
|
||||
if (!Number.isFinite(headerDiff) || headerDiff <= 0) {
|
||||
this.logger?.debug?.(`Skipping equalization: invalid header diff ${headerDiff} (down=${headerDownstream}, up=${headerUpstream})`);
|
||||
return;
|
||||
}
|
||||
// Stash so downstream callers (optimizer, strategies) can compute
|
||||
// hydraulic efficiency without re-reading every machine's pressure.
|
||||
this.headerDiffPa = headerDiff;
|
||||
|
||||
this.logger?.debug?.(`Equalizing operating point: down=${headerDownstream}, up=${headerUpstream}, diff=${headerDiff}`);
|
||||
|
||||
Object.values(machines).forEach(machine => {
|
||||
if (typeof machine.setGroupOperatingPoint === 'function') {
|
||||
machine.setGroupOperatingPoint(headerDownstream, headerUpstream);
|
||||
} else {
|
||||
// Older rotatingMachine without the group API — direct
|
||||
// fDimension write keeps demos working while submodules
|
||||
// are rolled forward.
|
||||
if (machine.predictFlow) machine.predictFlow.fDimension = headerDiff;
|
||||
if (machine.predictPower) machine.predictPower.fDimension = headerDiff;
|
||||
if (machine.predictCtrl) machine.predictCtrl.fDimension = headerDiff;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GroupOperatingPoint;
|
||||
@@ -5,11 +5,13 @@ const Machine = require('../../rotatingMachine/src/specificClass');
|
||||
const Measurement = require('../../measurement/src/specificClass');
|
||||
const baseCurve = require('../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json');
|
||||
|
||||
const CONTROL_MODES = ['optimalcontrol', 'prioritycontrol', 'prioritypercentagecontrol'];
|
||||
// prioritypercentagecontrol mode and per-instance scaling state were
|
||||
// removed when set.demand became unit-self-describing — see
|
||||
// commands/handlers.js (bare number = %, {value, unit} = absolute).
|
||||
const CONTROL_MODES = ['optimalcontrol', 'prioritycontrol'];
|
||||
const MODE_LABELS = {
|
||||
optimalcontrol: 'OPT',
|
||||
prioritycontrol: 'PRIO',
|
||||
prioritypercentagecontrol: 'PERC'
|
||||
};
|
||||
|
||||
const stateConfig = {
|
||||
@@ -60,7 +62,6 @@ function createGroupConfig(name) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: `machinegroup-${name}` },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||
scaling: { current: 'normalized' },
|
||||
mode: { current: 'optimalcontrol' }
|
||||
};
|
||||
}
|
||||
@@ -185,7 +186,9 @@ async function driveModeToFlow({ mg, pt, mode, pressure, targetFlow, priorityOrd
|
||||
await sleep(15);
|
||||
|
||||
mg.setMode(mode);
|
||||
mg.setScaling('normalized'); // required for prioritypercentagecontrol, works for others too
|
||||
// setScaling is gone — handleInput now takes canonical m³/s directly. This
|
||||
// legacy diagnostic still works in % terms by sweeping demand 0..100 and
|
||||
// mapping each step to canonical before dispatch.
|
||||
|
||||
const dynamic = mg.calcDynamicTotals();
|
||||
const span = Math.max(dynamic.flow.max - dynamic.flow.min, 1);
|
||||
@@ -197,7 +200,10 @@ async function driveModeToFlow({ mg, pt, mode, pressure, targetFlow, priorityOrd
|
||||
let best = { demand, flow: 0, power: 0, efficiency: 0, error: Infinity };
|
||||
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
await mg.handleInput('parent', demand, Infinity, priorityOrder);
|
||||
// demand is a percent (0..100); convert to canonical m³/s for the
|
||||
// post-refactor handleInput signature.
|
||||
const canonical = dynamic.flow.min + (demand / 100) * (dynamic.flow.max - dynamic.flow.min);
|
||||
await mg.handleInput('parent', canonical, Infinity, priorityOrder);
|
||||
await sleep(30);
|
||||
|
||||
const totals = captureTotals(mg);
|
||||
|
||||
129
src/io/output.js
Normal file
129
src/io/output.js
Normal file
@@ -0,0 +1,129 @@
|
||||
'use strict';
|
||||
|
||||
// Output + status-badge composition for machineGroupControl. Kept off the
|
||||
// orchestrator so specificClass stays under the file-size budget. Both
|
||||
// functions take the live MGC instance and reach for the same public surface
|
||||
// the rest of the package already uses (measurements, dynamicTotals, mode).
|
||||
|
||||
const { statusBadge, POSITIONS } = require('generalFunctions');
|
||||
|
||||
function _outputUnitForType(unitPolicy, type) {
|
||||
switch (String(type || '').toLowerCase()) {
|
||||
case 'flow': return unitPolicy.output.flow;
|
||||
case 'power': return unitPolicy.output.power;
|
||||
case 'pressure': return unitPolicy.output.pressure;
|
||||
case 'temperature': return unitPolicy.output.temperature;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getOutput(mgc) {
|
||||
const out = {};
|
||||
const { measurements, unitPolicy, mode, scaling, absDistFromPeak, relDistFromPeak } = mgc;
|
||||
measurements.getTypes().forEach(type => {
|
||||
measurements.getVariants(type).forEach(variant => {
|
||||
const unit = _outputUnitForType(unitPolicy, type);
|
||||
const read = (pos) => measurements.type(type).variant(variant).position(pos).getCurrentValue(unit || undefined);
|
||||
const dn = read(POSITIONS.DOWNSTREAM);
|
||||
const at = read(POSITIONS.AT_EQUIPMENT);
|
||||
const up = read(POSITIONS.UPSTREAM);
|
||||
if (dn != null) out[`downstream_${variant}_${type}`] = dn;
|
||||
if (up != null) out[`upstream_${variant}_${type}`] = up;
|
||||
if (at != null) out[`atEquipment_${variant}_${type}`] = at;
|
||||
if (dn != null && up != null) {
|
||||
const diff = measurements.type(type).variant(variant)
|
||||
.difference({ from: POSITIONS.DOWNSTREAM, to: POSITIONS.UPSTREAM, unit });
|
||||
if (diff?.value != null) out[`differential_${variant}_${type}`] = diff.value;
|
||||
}
|
||||
});
|
||||
});
|
||||
out.mode = mode;
|
||||
out.scaling = scaling;
|
||||
out.absDistFromPeak = absDistFromPeak;
|
||||
out.relDistFromPeak = relDistFromPeak;
|
||||
|
||||
// System (header) differential pressure resolved by the last equalize.
|
||||
// Dashboards use this to compute head = ΔP / (ρ · g) for Q-H plots
|
||||
// and to scale the BEP indicators without re-reading every child.
|
||||
// Emitted in canonical Pa and in the configured output unit (mbar
|
||||
// by default) so the dashboard can pick whichever it prefers.
|
||||
const headerDiffPa = mgc.operatingPoint?.headerDiffPa;
|
||||
if (Number.isFinite(headerDiffPa) && headerDiffPa > 0) {
|
||||
out.headerDiffPa = headerDiffPa;
|
||||
const pUnit = unitPolicy.output.pressure;
|
||||
// 1 mbar = 100 Pa. Only convert when we recognise mbar; otherwise
|
||||
// leave the raw Pa to avoid a stale or silently wrong unit label.
|
||||
if (pUnit === 'mbar') out.headerDiffMbar = headerDiffPa / 100;
|
||||
}
|
||||
|
||||
// Group capacity + active-machine counts. Surfaced so dashboards can
|
||||
// show the same numbers the status badge does without subscribing to
|
||||
// every child node individually. Emitted in the output flow unit (m³/h)
|
||||
// so the dashed capacity envelope lands on the SAME axis as the predicted-
|
||||
// flow series — dynamicTotals is canonical m³/s, so convert here. (Both
|
||||
// telemetry consumers — the Grafana flow panel and the FlowFuse fanout —
|
||||
// assume m³/h; emitting raw m³/s made the capacity lines render as ~0.)
|
||||
const fUnit = unitPolicy.output.flow;
|
||||
const capMax = mgc.dynamicTotals?.flow?.max;
|
||||
const capMin = mgc.dynamicTotals?.flow?.min;
|
||||
out.flowCapacityMax = Number.isFinite(capMax)
|
||||
? unitPolicy.convert(capMax, 'm3/s', fUnit, 'MGC flow capacity max') : 0;
|
||||
out.flowCapacityMin = Number.isFinite(capMin)
|
||||
? unitPolicy.convert(capMin, 'm3/s', fUnit, 'MGC flow capacity min') : 0;
|
||||
|
||||
// Operator demand resolved by the last dispatch. Surfaced so the dashboard
|
||||
// can overlay "what was asked" against the achieved total flow:
|
||||
// - demandFlow: resolved flow setpoint (post-envelope-clamp) in the output
|
||||
// flow unit (m³/h), same scale as the total-flow series.
|
||||
// - demandPct: that setpoint as 0..100 % of the live capacity envelope
|
||||
// (flow.min..flow.max), so a % demand entered by the operator round-trips
|
||||
// regardless of whether they asked in % or absolute flow.
|
||||
// Omitted entirely before the first demand arrives (degraded state).
|
||||
if (mgc._lastDemand) {
|
||||
const clampedCanonical = mgc._lastDemand.clamped;
|
||||
out.demandFlow = unitPolicy.convert(clampedCanonical, 'm3/s', fUnit, 'MGC demand setpoint');
|
||||
const span = Number.isFinite(capMin) && Number.isFinite(capMax) ? capMax - capMin : 0;
|
||||
out.demandPct = span > 0
|
||||
? Math.max(0, Math.min(100, ((clampedCanonical - capMin) / span) * 100))
|
||||
: 0;
|
||||
}
|
||||
|
||||
out.machineCount = Object.keys(mgc.machines || {}).length;
|
||||
out.machineCountActive = Object.values(mgc.machines || {}).filter((m) => {
|
||||
const s = m?.state?.getCurrentState?.();
|
||||
const md = m?.currentMode;
|
||||
return s && s !== 'off' && s !== 'maintenance' && md !== 'maintenance';
|
||||
}).length;
|
||||
|
||||
// Group movement status: 'working' while any child is still ramping /
|
||||
// sequencing toward its dispatched setpoint, 'ready' once all have settled.
|
||||
// The dispatch gate holds non-urgent demand until 'ready'; surfacing it lets
|
||||
// a dashboard show why a fresh setpoint hasn't been applied yet.
|
||||
out.movementState = typeof mgc.getMovementState === 'function' ? mgc.getMovementState() : 'ready';
|
||||
return out;
|
||||
}
|
||||
|
||||
function getStatusBadge(mgc) {
|
||||
const totalFlow = mgc.measurements.type('flow').variant('predicted').position(POSITIONS.AT_EQUIPMENT)
|
||||
.getCurrentValue(mgc.unitPolicy.output.flow) ?? 0;
|
||||
const totalPower = mgc.measurements.type('power').variant('predicted').position(POSITIONS.AT_EQUIPMENT)
|
||||
.getCurrentValue(mgc.unitPolicy.output.power) ?? 0;
|
||||
const totalCapacity = mgc.dynamicTotals?.flow?.max ?? 0;
|
||||
const available = Object.values(mgc.machines).filter(m => {
|
||||
const s = m?.state?.getCurrentState?.();
|
||||
const md = m?.currentMode;
|
||||
return s && s !== 'off' && s !== 'maintenance' && md !== 'maintenance';
|
||||
});
|
||||
const machineCount = Object.keys(mgc.machines || {}).length;
|
||||
const scaling = String(mgc.scaling || '').toLowerCase() === 'absolute' ? 'abs' : 'norm';
|
||||
const parts = [
|
||||
mgc.mode || '?',
|
||||
scaling,
|
||||
`Q=${Math.round(totalFlow)}/${Math.round(totalCapacity)} m³/h`,
|
||||
`P=${Math.round(totalPower)} kW`,
|
||||
`${available.length}/${machineCount}x`,
|
||||
];
|
||||
return statusBadge.compose(parts, { fill: available.length > 0 ? 'green' : (machineCount > 0 ? 'yellow' : 'grey'), shape: 'dot' });
|
||||
}
|
||||
|
||||
module.exports = { getOutput, getStatusBadge };
|
||||
90
src/movement/machineProfile.js
Normal file
90
src/movement/machineProfile.js
Normal file
@@ -0,0 +1,90 @@
|
||||
'use strict';
|
||||
|
||||
// Builds a plain-object snapshot of a registered child machine for the
|
||||
// movement planner. Pure read — no contract changes to the parent/child
|
||||
// registration handshake, no mutation of the child.
|
||||
|
||||
function buildProfile(child) {
|
||||
if (!child) throw new TypeError('buildProfile: child is required');
|
||||
|
||||
const id = child?.config?.general?.id ?? null;
|
||||
const state = typeof child.state?.getCurrentState === 'function'
|
||||
? child.state.getCurrentState()
|
||||
: null;
|
||||
const position = typeof child.state?.getCurrentPosition === 'function'
|
||||
? child.state.getCurrentPosition()
|
||||
: null;
|
||||
|
||||
const mm = child.state?.movementManager;
|
||||
const minPosition = Number(mm?.minPosition);
|
||||
const maxPosition = Number(mm?.maxPosition);
|
||||
const velocityPctPerS = (() => {
|
||||
if (typeof mm?.getNormalizedSpeed === 'function' && Number.isFinite(maxPosition) && Number.isFinite(minPosition)) {
|
||||
return mm.getNormalizedSpeed() * (maxPosition - minPosition);
|
||||
}
|
||||
const s = Number(mm?.speed);
|
||||
return Number.isFinite(s) ? s : 0;
|
||||
})();
|
||||
|
||||
// Source of truth for ladder durations is the child state's config.time
|
||||
// (state.js stores the merged stateConfig there). Older fallbacks
|
||||
// (child.config.stateConfig, child.stateConfig) are kept for callers
|
||||
// that pre-populate them, but rotatingMachine doesn't — it stores
|
||||
// timings under state.config.time. Reading the wrong path is silent:
|
||||
// every duration defaults to 0, the planner thinks startup is
|
||||
// instantaneous, tStar collapses to the ramp time, and same-time
|
||||
// landing breaks.
|
||||
const t = child.state?.config?.time
|
||||
?? child.config?.stateConfig?.time
|
||||
?? child.stateConfig?.time
|
||||
?? {};
|
||||
const timings = {
|
||||
startingS: Number(t.starting) || 0,
|
||||
warmingupS: Number(t.warmingup) || 0,
|
||||
stoppingS: Number(t.stopping) || 0,
|
||||
coolingdownS: Number(t.coolingdown) || 0,
|
||||
};
|
||||
|
||||
const remainingTransitionS = typeof child.state?.stateManager?.getRemainingTransitionS === 'function'
|
||||
? child.state.stateManager.getRemainingTransitionS()
|
||||
: null;
|
||||
|
||||
const flowAt = (pos, pressure) => {
|
||||
if (typeof child.predictFlow?.evaluate === 'function') {
|
||||
return child.predictFlow.evaluate(pos, pressure);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Inverse curve: target flow (canonical m³/s, in the child's output unit
|
||||
// since predictCtrl was built from the same curve units) → control %.
|
||||
// Mirrors the conversion the pump performs in flowController on a
|
||||
// `flowmovement` command (rotatingMachine/src/flow/flowController.js:52).
|
||||
// Returns null when the child has no curve loaded so the scheduler can
|
||||
// fall back gracefully.
|
||||
const positionForFlow = (flow) => {
|
||||
if (!Number.isFinite(flow)) return null;
|
||||
if (typeof child.predictCtrl?.y !== 'function') return null;
|
||||
try {
|
||||
const v = child.predictCtrl.y(flow);
|
||||
return Number.isFinite(v) ? v : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
state,
|
||||
position,
|
||||
minPosition,
|
||||
maxPosition,
|
||||
velocityPctPerS,
|
||||
timings,
|
||||
remainingTransitionS,
|
||||
flowAt,
|
||||
positionForFlow,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildProfile };
|
||||
86
src/movement/moveTrajectory.js
Normal file
86
src/movement/moveTrajectory.js
Normal file
@@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
|
||||
// Per-machine time-parameterised plan. Pure: given a MachineProfile
|
||||
// snapshot and a target position, computes how long the move will take.
|
||||
//
|
||||
// Cases by profile.state:
|
||||
// idle / off startup ladder + ramp from min to target
|
||||
// operational |target − position| / velocity
|
||||
// accelerating |
|
||||
// decelerating post-abort residue, same as operational
|
||||
// starting remaining-in-starting + full warmup + ramp from min
|
||||
// warmingup remaining-in-warmingup + ramp from min
|
||||
// stopping | coolingdown non-interruptible deload; cannot contribute flow
|
||||
// in this dispatch — returns null so the scheduler
|
||||
// can exclude the machine from "up" candidates.
|
||||
//
|
||||
// Velocity of 0 returns Infinity (misconfigured speed) so the scheduler
|
||||
// can demote the machine without crashing.
|
||||
|
||||
const ACTIVE_OPERATIONAL = new Set(['operational', 'accelerating', 'decelerating']);
|
||||
const STARTUP_LADDER = new Set(['starting', 'warmingup']);
|
||||
const SHUTDOWN_LADDER = new Set(['stopping', 'coolingdown']);
|
||||
|
||||
class MoveTrajectory {
|
||||
constructor(profile, { targetPosition } = {}) {
|
||||
if (!profile || typeof profile !== 'object') {
|
||||
throw new TypeError('MoveTrajectory: profile is required');
|
||||
}
|
||||
if (!Number.isFinite(targetPosition)) {
|
||||
throw new TypeError('MoveTrajectory: targetPosition must be a finite number');
|
||||
}
|
||||
this.profile = profile;
|
||||
this.targetPosition = this._clampToBounds(targetPosition);
|
||||
}
|
||||
|
||||
_clampToBounds(p) {
|
||||
const { minPosition, maxPosition } = this.profile;
|
||||
if (Number.isFinite(minPosition) && p < minPosition) return minPosition;
|
||||
if (Number.isFinite(maxPosition) && p > maxPosition) return maxPosition;
|
||||
return p;
|
||||
}
|
||||
|
||||
// Seconds from "fire" until the machine is delivering flow at
|
||||
// targetPosition. Null when the machine is in a non-contributing
|
||||
// (shutting-down) state.
|
||||
etaToTargetS() {
|
||||
const p = this.profile;
|
||||
const v = p.velocityPctPerS;
|
||||
const target = this.targetPosition;
|
||||
|
||||
if (SHUTDOWN_LADDER.has(p.state)) return null;
|
||||
|
||||
if (!Number.isFinite(v) || v <= 0) return Infinity;
|
||||
|
||||
if (p.state === 'operational' || ACTIVE_OPERATIONAL.has(p.state)) {
|
||||
const dist = Math.abs(target - p.position);
|
||||
return dist / v;
|
||||
}
|
||||
|
||||
if (p.state === 'warmingup') {
|
||||
// Remaining warmup, then ramp from minPosition to target.
|
||||
// Ramp starts from minPosition because the pump is not moving
|
||||
// during warmup — position is held at min.
|
||||
const remW = p.remainingTransitionS ?? p.timings.warmingupS;
|
||||
const rampDist = Math.max(0, target - p.minPosition);
|
||||
return remW + rampDist / v;
|
||||
}
|
||||
|
||||
if (p.state === 'starting') {
|
||||
// Remaining-in-starting + full warmup duration + ramp from min.
|
||||
const remS = p.remainingTransitionS ?? p.timings.startingS;
|
||||
const rampDist = Math.max(0, target - p.minPosition);
|
||||
return remS + p.timings.warmingupS + rampDist / v;
|
||||
}
|
||||
|
||||
// idle / off / emergencystop / maintenance / any non-active state
|
||||
// not in the ladders: full startup sequence to operational, then ramp.
|
||||
const rampDist = Math.max(0, target - p.minPosition);
|
||||
return p.timings.startingS + p.timings.warmingupS + rampDist / v;
|
||||
}
|
||||
}
|
||||
|
||||
MoveTrajectory.SHUTDOWN_LADDER = SHUTDOWN_LADDER;
|
||||
MoveTrajectory.STARTUP_LADDER = STARTUP_LADDER;
|
||||
|
||||
module.exports = MoveTrajectory;
|
||||
121
src/movement/movementExecutor.js
Normal file
121
src/movement/movementExecutor.js
Normal file
@@ -0,0 +1,121 @@
|
||||
'use strict';
|
||||
|
||||
// Tick-driven executor for the schedule produced by movementScheduler.plan.
|
||||
//
|
||||
// - Holds the current schedule + a cursor that advances one per tick().
|
||||
// - Fires any unfired command whose fireAtTickN <= cursor.
|
||||
// - replan(newSchedule) replaces the schedule and resets the cursor —
|
||||
// already-fired commands stay fired (the pump's FSM is downstream and
|
||||
// handles their consequences; the executor never tries to "undo" a
|
||||
// fired startup, which keeps warmup/cooldown safety intact).
|
||||
// - fireCommand is injected for unit-testability — production wires it to
|
||||
// `machine.handleInput(...)`.
|
||||
|
||||
class MovementExecutor {
|
||||
constructor({ fireCommand, logger } = {}) {
|
||||
if (typeof fireCommand !== 'function') {
|
||||
throw new TypeError('MovementExecutor: fireCommand callback is required');
|
||||
}
|
||||
this._fireCommand = fireCommand;
|
||||
this._logger = logger || null;
|
||||
this._schedule = null;
|
||||
this._cursor = 0;
|
||||
this._firedIdx = new Set();
|
||||
// Wall-clock anchor for the active schedule. Each tick recomputes
|
||||
// a "virtual cursor" from elapsed time so the schedule survives a
|
||||
// blocking first tick (e.g. an awaited startup sequence that takes
|
||||
// multiple seconds to settle).
|
||||
this._dispatchT0 = null;
|
||||
}
|
||||
|
||||
// Replace the active schedule. Cursor starts at 0 (new dispatch is
|
||||
// anchored to "now"). The previous schedule's unfired commands are
|
||||
// dropped; already-fired commands are not retracted.
|
||||
replan(schedule) {
|
||||
this._schedule = schedule || { commands: [] };
|
||||
this._cursor = 0;
|
||||
this._firedIdx = new Set();
|
||||
this._dispatchT0 = Date.now();
|
||||
if (this._logger?.debug) {
|
||||
const cmds = this._schedule.commands || [];
|
||||
this._logger.debug(`MovementExecutor.replan: ${cmds.length} commands, tStar=${this._schedule.tStarS ?? '?'}s`);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance one tick. Returns a Promise resolving to the list of
|
||||
// commands fired this tick once their async work settles. Awaiting
|
||||
// the FIRST tick from within a dispatch is what gives the new move
|
||||
// priority over an in-flight shutdown sequence — fire-and-forget
|
||||
// gives the shutdown's for-loop a window to progress through state
|
||||
// transitions before the new move's residue handler claims the FSM.
|
||||
async tick() {
|
||||
// Virtual cursor = max(advanced cursor, elapsed wall-clock ticks).
|
||||
// If a previous tick blocked on a long await, elapsed time has
|
||||
// already passed and we should fire every command whose
|
||||
// fireAtTickN now lies in the past — not wait another N timer
|
||||
// cycles to catch up. tickS is stamped on the schedule by the
|
||||
// planner (defaults to 1 s).
|
||||
const tickS = Number.isFinite(this._schedule?.tickS) && this._schedule.tickS > 0
|
||||
? this._schedule.tickS
|
||||
: 1;
|
||||
const elapsedS = this._dispatchT0 != null ? (Date.now() - this._dispatchT0) / 1000 : 0;
|
||||
const wallTick = Math.floor(elapsedS / tickS);
|
||||
const virtCursor = Math.max(this._cursor, wallTick);
|
||||
|
||||
const fired = [];
|
||||
const cmds = this._schedule?.commands || [];
|
||||
for (let i = 0; i < cmds.length; i++) {
|
||||
if (this._firedIdx.has(i)) continue;
|
||||
const c = cmds[i];
|
||||
if (c.fireAtTickN <= virtCursor) {
|
||||
this._firedIdx.add(i);
|
||||
try {
|
||||
// Fire-and-forget. The synchronous prologue of
|
||||
// handleInput claims the latest-wins gate before
|
||||
// returning its promise — that's enough for race
|
||||
// favouring. AWAITing the returned promise here
|
||||
// would block the executor for the entire ladder +
|
||||
// ramp duration of a flowmovement-after-startup
|
||||
// (because the pump's delayedMove only resolves
|
||||
// when the ramp completes), preventing the
|
||||
// wall-clock timer from starting and dragging every
|
||||
// delayed command in the schedule forward by that
|
||||
// amount.
|
||||
const r = this._fireCommand(c);
|
||||
if (r && typeof r.then === 'function') {
|
||||
r.catch((e) => {
|
||||
if (this._logger?.error) {
|
||||
this._logger.error(`MovementExecutor: fireCommand rejected for ${c.machineId}/${c.action}: ${e?.message || e}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
fired.push(c);
|
||||
} catch (e) {
|
||||
if (this._logger?.error) {
|
||||
this._logger.error(`MovementExecutor: fireCommand failed for ${c.machineId}/${c.action}: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this._cursor = virtCursor + 1;
|
||||
return fired;
|
||||
}
|
||||
|
||||
// Telemetry — number of commands not yet fired.
|
||||
pending() {
|
||||
const cmds = this._schedule?.commands || [];
|
||||
return cmds.length - this._firedIdx.size;
|
||||
}
|
||||
|
||||
// Telemetry — current tick cursor.
|
||||
cursor() {
|
||||
return this._cursor;
|
||||
}
|
||||
|
||||
// Telemetry — the live schedule (read-only view).
|
||||
schedule() {
|
||||
return this._schedule;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MovementExecutor;
|
||||
243
src/movement/movementScheduler.js
Normal file
243
src/movement/movementScheduler.js
Normal file
@@ -0,0 +1,243 @@
|
||||
'use strict';
|
||||
|
||||
// Pure movement planner. Given a set of machine profile snapshots and the
|
||||
// optimizer's chosen flow combination, returns a tick-indexed schedule of
|
||||
// commands that minimises flow disruption during the transition.
|
||||
//
|
||||
// Algorithm — rendezvous-on-demand-at-current-pressure:
|
||||
//
|
||||
// 1. For each machine, classify the move it needs (startup, flow-move
|
||||
// up, flow-move down, shutdown, no-op) based on its current FSM state
|
||||
// and the optimizer's target flow for it.
|
||||
// 2. Compute eta_i (seconds-to-target-flow) per machine via
|
||||
// MoveTrajectory. Machines that can't contribute on this dispatch
|
||||
// (stopping / coolingdown / unknown) are skipped.
|
||||
// 3. Rendezvous time t* = max(eta_i over ALL non-noop moves). The
|
||||
// slowest move (typically a startup ladder + ramp) sets the deadline.
|
||||
// 4. Every command — including a startup's `execsequence` — is delayed by
|
||||
// (t* − eta_j) so its move FINISHES at t*. A startup is delayed as a
|
||||
// whole: its ladder begins at (t* − eta) and completes at (t* − rampS),
|
||||
// then the queued flowmovement (held in the pump's delayedMove) ramps to
|
||||
// finish at t*. The slowest mover (t* − eta == 0) fires immediately.
|
||||
// Delaying the ladder — rather than firing it at tick 0 — is what keeps a
|
||||
// faster-than-slowest startup from reaching `operational` early and
|
||||
// sitting at its MINIMUM flow before t* (calcFlow at min position is not
|
||||
// zero), which otherwise leaks ~min-flow into the group total ahead of
|
||||
// the rendezvous (the staging bump).
|
||||
//
|
||||
// Net effect: ALL pumps reach their per-pump flow target at the same
|
||||
// wall-clock instant t*. Sum-of-flows is monotonic during the transition
|
||||
// (no overshoot from a fast in-flight retarget arriving before the
|
||||
// startup pumps catch up).
|
||||
//
|
||||
// The pump's flow→position conversion (via predictCtrl.y) lives in the
|
||||
// profile so this module is pure: no Node-RED calls, no live child reads.
|
||||
|
||||
const MoveTrajectory = require('./moveTrajectory');
|
||||
|
||||
const ACTIVE_STATES = new Set(['operational', 'accelerating', 'decelerating']);
|
||||
const STARTUP_LADDER = new Set(['starting', 'warmingup']);
|
||||
const SHUTDOWN_LADDER = new Set(['stopping', 'coolingdown']);
|
||||
|
||||
// Tick cadence — MGC main loop is 1 Hz per .claude/rules tick convention.
|
||||
const DEFAULT_TICK_S = 1;
|
||||
|
||||
function isOn(state) {
|
||||
return ACTIVE_STATES.has(state) || STARTUP_LADDER.has(state);
|
||||
}
|
||||
|
||||
// Classify the action a machine needs. The optimizer's combination is a
|
||||
// canonical statement of "what flow should this machine deliver now."
|
||||
// `targetFlow == 0` (or absence from combination) means "this machine is
|
||||
// not part of the new combination."
|
||||
function classify(profile, targetFlow) {
|
||||
const isOff = !isOn(profile.state) && !SHUTDOWN_LADDER.has(profile.state);
|
||||
if (targetFlow > 0) {
|
||||
if (isOff) return 'startup';
|
||||
return 'flowmove'; // up or down depending on current vs target
|
||||
}
|
||||
// targetFlow <= 0
|
||||
if (ACTIVE_STATES.has(profile.state) || STARTUP_LADDER.has(profile.state)) {
|
||||
return 'shutdown';
|
||||
}
|
||||
return 'noop';
|
||||
}
|
||||
|
||||
// Direction in flow-space: increasing, decreasing, or unchanged. Drives
|
||||
// rendezvous: t* is the max eta over INCREASING moves; DECREASING moves
|
||||
// get delayed to land at t*.
|
||||
function directionOf(profile, targetFlow) {
|
||||
if (!isOn(profile.state)) return targetFlow > 0 ? 'increasing' : 'unchanged';
|
||||
const currentFlow = Number.isFinite(profile.flowAt?.(profile.position, profile._pressureForClassification))
|
||||
? profile.flowAt(profile.position, profile._pressureForClassification)
|
||||
: null;
|
||||
if (currentFlow == null) {
|
||||
// Without a current-flow read, assume increasing iff target > 0.
|
||||
return targetFlow > 0 ? 'increasing' : 'decreasing';
|
||||
}
|
||||
if (targetFlow > currentFlow) return 'increasing';
|
||||
if (targetFlow < currentFlow) return 'decreasing';
|
||||
return 'unchanged';
|
||||
}
|
||||
|
||||
// Plan the schedule.
|
||||
//
|
||||
// profiles — array from buildProfile(child)
|
||||
// combination — array of {machineId, flow} from optimizer
|
||||
// currentPressure — Pa, for flow→flow and flow→position conversions
|
||||
// options — { tickS?: 1, useRendezvous?: true }
|
||||
//
|
||||
// useRendezvous=false collapses the schedule to "all commands fire at
|
||||
// tick 0" — every pump moves at its own speed and lands at its own eta.
|
||||
// Used when the operator explicitly opts out of same-time landing.
|
||||
function plan(profiles, combination, currentPressure, options = {}) {
|
||||
const tickS = Number.isFinite(options.tickS) && options.tickS > 0 ? options.tickS : DEFAULT_TICK_S;
|
||||
const useRendezvous = options.useRendezvous !== false;
|
||||
const targets = new Map();
|
||||
for (const item of combination || []) {
|
||||
if (item && item.machineId != null) targets.set(String(item.machineId), Number(item.flow) || 0);
|
||||
}
|
||||
|
||||
// First pass: classify + compute eta per machine.
|
||||
const plans = [];
|
||||
for (const p of profiles) {
|
||||
const id = String(p.id);
|
||||
const targetFlow = targets.get(id) ?? 0;
|
||||
|
||||
// Stash pressure on a copy of the profile so directionOf can read it
|
||||
// without changing the public profile shape. Non-mutating: classify
|
||||
// only needs the value during this pass.
|
||||
const probeProfile = Object.assign({}, p, { _pressureForClassification: currentPressure });
|
||||
const action = classify(p, targetFlow);
|
||||
const direction = directionOf(probeProfile, targetFlow);
|
||||
|
||||
if (action === 'noop') {
|
||||
plans.push({ machineId: id, action, direction, eta: 0, targetFlow, skip: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert target flow to target position using the pump's inverse
|
||||
// curve (lives on the profile). Fallback: linear interpolation
|
||||
// across [min,max] using the curve domain we know.
|
||||
let targetPosition = null;
|
||||
if (action !== 'shutdown' && typeof p.positionForFlow === 'function') {
|
||||
targetPosition = p.positionForFlow(targetFlow);
|
||||
}
|
||||
if (targetPosition == null) {
|
||||
// Shutdown: target is the minimum position.
|
||||
targetPosition = action === 'shutdown' ? (Number.isFinite(p.minPosition) ? p.minPosition : 0) : p.position;
|
||||
}
|
||||
|
||||
let eta;
|
||||
// Per-pump ladder duration; used to gate the flowmovement so it
|
||||
// can't fire before warmup completes (the pump won't accept it).
|
||||
const ladderS = action === 'startup'
|
||||
? ((Number(p.timings?.startingS) || 0) + (Number(p.timings?.warmingupS) || 0))
|
||||
: 0;
|
||||
// Ramp-only portion of the eta. For startup this is eta − ladder.
|
||||
// For flow-move or shutdown the entire eta IS the ramp.
|
||||
let rampS = 0;
|
||||
|
||||
if (action === 'shutdown') {
|
||||
// Time for flow to reach zero = position ramp from current
|
||||
// position to minPosition. stoppingS / coolingdownS happen
|
||||
// AFTER flow is zero; they don't affect rendezvous.
|
||||
const v = Number(p.velocityPctPerS) > 0 ? p.velocityPctPerS : Infinity;
|
||||
const dist = Math.max(0, p.position - (p.minPosition ?? 0));
|
||||
eta = v === Infinity ? 0 : dist / v;
|
||||
rampS = eta;
|
||||
} else {
|
||||
const traj = new MoveTrajectory(p, { targetPosition });
|
||||
eta = traj.etaToTargetS();
|
||||
if (eta == null) eta = Infinity; // shouldn't happen for non-shutdown actions, but defensive
|
||||
rampS = Math.max(0, Number.isFinite(eta) ? eta - ladderS : 0);
|
||||
}
|
||||
|
||||
plans.push({ machineId: id, action, direction, eta, ladderS, rampS, targetFlow, targetPosition, skip: false });
|
||||
}
|
||||
|
||||
// Rendezvous: t* = max eta over ALL non-noop moves. Includes
|
||||
// increasing AND decreasing flow-moves so the slowest mover sets the
|
||||
// deadline for everyone. When useRendezvous=false, tStar is forced
|
||||
// to 0 so every command's delay collapses to 0 (legacy behaviour).
|
||||
const allEtas = plans
|
||||
.filter((q) => !q.skip && Number.isFinite(q.eta))
|
||||
.map((q) => q.eta);
|
||||
const tStar = useRendezvous && allEtas.length > 0 ? Math.max(...allEtas) : 0;
|
||||
|
||||
// Second pass: assign fireAtTickN. Every command is delayed so its
|
||||
// move finishes at t*; the lone exception is the startup ladder's
|
||||
// execsequence (the ladder must begin now because eta == ladder + ramp).
|
||||
const commands = [];
|
||||
for (const q of plans) {
|
||||
if (q.skip) continue;
|
||||
|
||||
// Delay-to-rendezvous: fire (t* − eta) seconds from now so the
|
||||
// move FINISHES at t*. Clamped to >= 0 (the eta == t* mover fires
|
||||
// immediately).
|
||||
const fireAtSDelayed = Math.max(0, tStar - q.eta);
|
||||
const fireAtTickNDelayed = Math.round(fireAtSDelayed / tickS);
|
||||
// Unchanged moves are no-ops; fire at 0 for simplicity (the pump
|
||||
// ignores them and we don't pollute the schedule with delays).
|
||||
const isUnchanged = q.direction === 'unchanged';
|
||||
|
||||
if (q.action === 'startup') {
|
||||
// Just-in-time start. Delay the ENTIRE startup — ladder AND ramp —
|
||||
// by (t* − eta), so the warmup ladder finishes (and the ramp
|
||||
// begins) at (t* − rampS) and the flow lands exactly at t*.
|
||||
//
|
||||
// The ladder duration can't be compressed, but it CAN be delayed.
|
||||
// Firing the execsequence at tick 0 (the old behaviour) made a
|
||||
// faster-than-slowest startup reach `operational` early and sit at
|
||||
// its minimum flow from warmup-end until its delayed ramp — leaking
|
||||
// ~min-flow into the group total before t* (the staging bump). For
|
||||
// the slowest pump (eta == t*) fireAtTickNDelayed is 0, so it still
|
||||
// fires immediately. The flowmovement fires on the same tick; the
|
||||
// pump holds it in delayedMove through the ladder, then ramps over
|
||||
// rampS to finish at t*.
|
||||
commands.push({
|
||||
machineId: q.machineId,
|
||||
action: 'execsequence',
|
||||
sequence: 'startup',
|
||||
fireAtTickN: fireAtTickNDelayed,
|
||||
eta: q.eta,
|
||||
});
|
||||
commands.push({
|
||||
machineId: q.machineId,
|
||||
action: 'flowmovement',
|
||||
flow: q.targetFlow,
|
||||
fireAtTickN: fireAtTickNDelayed,
|
||||
eta: q.eta,
|
||||
});
|
||||
} else if (q.action === 'flowmove') {
|
||||
commands.push({
|
||||
machineId: q.machineId,
|
||||
action: 'flowmovement',
|
||||
flow: q.targetFlow,
|
||||
// Unchanged moves are no-ops; fire immediately so we
|
||||
// don't park them behind a long startup ladder for no
|
||||
// reason. Up/down moves both delay so they land at t*.
|
||||
fireAtTickN: isUnchanged ? 0 : fireAtTickNDelayed,
|
||||
eta: q.eta,
|
||||
});
|
||||
} else if (q.action === 'shutdown') {
|
||||
commands.push({
|
||||
machineId: q.machineId,
|
||||
action: 'execsequence',
|
||||
sequence: 'shutdown',
|
||||
fireAtTickN: fireAtTickNDelayed,
|
||||
eta: q.eta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tStarS: tStar,
|
||||
tickS,
|
||||
commands,
|
||||
// Debugging telemetry — kept in the output so tests can introspect.
|
||||
_plans: plans,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { plan, DEFAULT_TICK_S };
|
||||
295
src/nodeClass.js
295
src/nodeClass.js
@@ -1,280 +1,29 @@
|
||||
const { outputUtils, configManager, convert } = require("generalFunctions");
|
||||
const Specific = require("./specificClass");
|
||||
'use strict';
|
||||
|
||||
class nodeClass {
|
||||
/**
|
||||
* Create a MeasurementNode.
|
||||
* @param {object} uiConfig - Node-RED node configuration.
|
||||
* @param {object} RED - Node-RED runtime API.
|
||||
* @param {object} nodeInstance - The Node-RED node instance.
|
||||
* @param {string} nameOfNode - The name of the node, used for
|
||||
*/
|
||||
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
|
||||
// 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
|
||||
const { BaseNodeAdapter } = require('generalFunctions');
|
||||
const MachineGroup = require('./specificClass');
|
||||
const commands = require('./commands');
|
||||
|
||||
// Load default & UI config
|
||||
this._loadConfig(uiConfig, this.node);
|
||||
// Event-driven: the domain emits 'output-changed' from handlePressureChange
|
||||
// (pump events) and from handleInput/turnOff. No tick loop needed.
|
||||
class nodeClass extends BaseNodeAdapter {
|
||||
static DomainClass = MachineGroup;
|
||||
static commands = commands;
|
||||
static tickInterval = null;
|
||||
static statusInterval = 1000;
|
||||
|
||||
// Instantiate core Measurement class
|
||||
this._setupSpecificClass();
|
||||
|
||||
// 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();
|
||||
this.defaultConfig = cfgMgr.getConfig(this.name);
|
||||
const flowUnit = this._resolveUnitOrFallback(uiConfig.unit, 'volumeFlowRate', 'm3/h', 'flow');
|
||||
|
||||
// Build config: base sections (no domain-specific config for group controller)
|
||||
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id);
|
||||
|
||||
// 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;
|
||||
buildDomainConfig(uiConfig = {}) {
|
||||
// Schema shape is mode.current / scaling.current (the schema nests
|
||||
// value + allowedActions/allowedSources under `current`). Editor field
|
||||
// names are flat — bridge here.
|
||||
const out = {};
|
||||
if (uiConfig.mode) out.mode = { current: uiConfig.mode };
|
||||
if (uiConfig.scaling) out.scaling = { current: uiConfig.scaling };
|
||||
if (uiConfig.useRendezvous !== undefined) {
|
||||
out.planner = { useRendezvous: uiConfig.useRendezvous };
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
_updateNodeStatus() {
|
||||
//console.log('Updating node status...');
|
||||
const mg = this.source;
|
||||
const mode = mg.mode;
|
||||
const scaling = mg.scaling;
|
||||
|
||||
// Add safety checks for measurements
|
||||
const totalFlow = mg.measurements
|
||||
?.type("flow")
|
||||
?.variant("predicted")
|
||||
?.position("atequipment")
|
||||
?.getCurrentValue(mg?.unitPolicy?.output?.flow || 'm3/h') || 0;
|
||||
|
||||
const totalPower = mg.measurements
|
||||
?.type("power")
|
||||
?.variant("predicted")
|
||||
?.position("atEquipment")
|
||||
?.getCurrentValue(mg?.unitPolicy?.output?.power || 'kW') || 0;
|
||||
|
||||
// Calculate total capacity based on available machines with safety checks
|
||||
const availableMachines = Object.values(mg.machines || {}).filter((machine) => {
|
||||
// Safety check: ensure machine and machine.state exist
|
||||
if (!machine || !machine.state || typeof machine.state.getCurrentState !== 'function') {
|
||||
mg.logger?.warn(`Machine missing or invalid: ${machine?.config?.general?.id || 'unknown'}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const state = machine.state.getCurrentState();
|
||||
const mode = machine.currentMode;
|
||||
return !(
|
||||
state === "off" ||
|
||||
state === "maintenance" ||
|
||||
mode === "maintenance"
|
||||
);
|
||||
});
|
||||
|
||||
const totalCapacity = Math.round((mg.dynamicTotals?.flow?.max || 0) * 1) / 1;
|
||||
|
||||
// Determine overall status based on available machines
|
||||
const status = availableMachines.length > 0
|
||||
? `${availableMachines.length} machine(s) connected`
|
||||
: "No machines";
|
||||
|
||||
let scalingSymbol = "";
|
||||
switch ((scaling || "").toLowerCase()) {
|
||||
case "absolute":
|
||||
scalingSymbol = "Ⓐ";
|
||||
break;
|
||||
case "normalized":
|
||||
scalingSymbol = "Ⓝ";
|
||||
break;
|
||||
default:
|
||||
scalingSymbol = mode || "";
|
||||
break;
|
||||
}
|
||||
|
||||
const text = ` ${mode || 'Unknown'} | ${scalingSymbol}: 💨=${Math.round(totalFlow)}/${totalCapacity} | ⚡=${Math.round(totalPower)} | ${status}`;
|
||||
|
||||
return {
|
||||
fill: availableMachines.length > 0 ? "green" : "red",
|
||||
shape: "dot",
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate the core logic and store as source.
|
||||
*/
|
||||
_setupSpecificClass() {
|
||||
this.source = new Specific(this.config);
|
||||
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() {
|
||||
this.source.emitter.on("mAbs", (val) => {
|
||||
this.node.status({
|
||||
fill: "green",
|
||||
shape: "dot",
|
||||
text: `${val} ${this.config.general.unit}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 to drive the Measurement class.
|
||||
*/
|
||||
_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() {
|
||||
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]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the node's input handler, routing control messages to the class.
|
||||
*/
|
||||
_attachInputHandler() {
|
||||
this.node.on(
|
||||
"input",
|
||||
async (msg, send, done) => {
|
||||
const mg = this.source;
|
||||
const RED = this.RED;
|
||||
try {
|
||||
switch (msg.topic) {
|
||||
case "registerChild": {
|
||||
const childId = msg.payload;
|
||||
const childObj = RED.nodes.getNode(childId);
|
||||
if (!childObj || !childObj.source) {
|
||||
mg.logger.warn(`registerChild skipped: missing child/source for id=${childId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
mg.logger.debug(`Registering child: ${childId}, found: ${!!childObj}, source: ${!!childObj?.source}`);
|
||||
|
||||
mg.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
|
||||
|
||||
mg.logger.debug(`Total machines after registration: ${Object.keys(mg.machines || {}).length}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "setMode": {
|
||||
const mode = msg.payload;
|
||||
mg.setMode(mode);
|
||||
break;
|
||||
}
|
||||
|
||||
case "setScaling": {
|
||||
const scaling = msg.payload;
|
||||
mg.setScaling(scaling);
|
||||
break;
|
||||
}
|
||||
|
||||
case "Qd": {
|
||||
const Qd = parseFloat(msg.payload);
|
||||
const sourceQd = "parent";
|
||||
if (isNaN(Qd)) {
|
||||
mg.logger.error(`Invalid demand value: ${msg.payload}`);
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await mg.handleInput(sourceQd, Qd);
|
||||
msg.topic = mg.config.general.name;
|
||||
msg.payload = "done";
|
||||
send(msg);
|
||||
} catch (error) {
|
||||
mg.logger.error(`Failed to process Qd: ${error.message}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
mg.logger.warn(`Unknown topic: ${msg.topic}`);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
mg.logger.error(`Input handler failure: ${error.message}`);
|
||||
}
|
||||
if (typeof done === 'function') done();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up timers and intervals when Node-RED stops the node.
|
||||
*/
|
||||
_attachCloseHandler() {
|
||||
this.node.on("close", (done) => {
|
||||
clearInterval(this._tickInterval);
|
||||
clearInterval(this._statusInterval);
|
||||
this.node.status({}); // clear node status badge
|
||||
if (typeof done === 'function') done();
|
||||
});
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = nodeClass; // Export the class for Node-RED to use
|
||||
module.exports = nodeClass;
|
||||
|
||||
188
src/optimizer/bepGravitation.js
Normal file
188
src/optimizer/bepGravitation.js
Normal file
@@ -0,0 +1,188 @@
|
||||
// BEP-gravitation optimizer: bias flow allocation toward each pump's BEP,
|
||||
// then refine via marginal-cost swaps. `ctx` shape matches bestCombination.js.
|
||||
|
||||
const MC_ITER_CAP = 50; // marginal-cost refinement iterations
|
||||
const MC_RELATIVE_EXIT = 0.001; // exit when the mc gap is < 0.1% of expensive.mc
|
||||
|
||||
// Estimate dP/dQ slopes around the BEP on the group operating point.
|
||||
// Returns finite numbers for everything; falls back to zero slopes if the
|
||||
// curve is flat or the machine has not been initialised.
|
||||
function estimateSlopesAtBEP(machine, Q_BEP, ctx, delta = 1.0) {
|
||||
const { groupCurves } = ctx;
|
||||
const { groupFlow, groupNCog, groupCalcPower } = groupCurves;
|
||||
|
||||
const minFlow = groupFlow(machine).currentFxyYMin;
|
||||
const maxFlow = groupFlow(machine).currentFxyYMax;
|
||||
const span = Math.max(0, maxFlow - minFlow);
|
||||
const normalizedCog = Math.max(0, Math.min(1, groupNCog(machine) || 0));
|
||||
const targetBEP = Q_BEP ?? (minFlow + span * normalizedCog);
|
||||
|
||||
const clampFlow = (flow) => Math.min(maxFlow, Math.max(minFlow, flow));
|
||||
const center = clampFlow(targetBEP);
|
||||
const deltaSafe = Math.max(delta, 0.01);
|
||||
const leftFlow = clampFlow(center - deltaSafe);
|
||||
const rightFlow = clampFlow(center + deltaSafe);
|
||||
|
||||
const powerAt = (flow) => groupCalcPower(machine, flow);
|
||||
const P_center = powerAt(center);
|
||||
const P_left = powerAt(leftFlow);
|
||||
const P_right = powerAt(rightFlow);
|
||||
const slopeLeft = (P_center - P_left) / Math.max(1e-6, center - leftFlow);
|
||||
const slopeRight = (P_right - P_center) / Math.max(1e-6, rightFlow - center);
|
||||
const alpha = Math.max(1e-6, (Math.abs(slopeLeft) + Math.abs(slopeRight)) / 2);
|
||||
|
||||
return { slopeLeft, slopeRight, alpha, Q_BEP: center, P_BEP: P_center };
|
||||
}
|
||||
|
||||
// Redistribute `delta` across pumps using slope-derived weights; flatter
|
||||
// curves attract more flow. Bounded: exits on zero progress or no capacity.
|
||||
function redistributeFlowBySlope(pumpInfos, flowDistribution, delta, directional = true) {
|
||||
const tolerance = 1e-3;
|
||||
let remaining = delta;
|
||||
const entryMap = new Map(flowDistribution.map(entry => [entry.machineId, entry]));
|
||||
|
||||
while (Math.abs(remaining) > tolerance) {
|
||||
const increasing = remaining > 0;
|
||||
const candidates = pumpInfos.map(info => {
|
||||
const entry = entryMap.get(info.id);
|
||||
if (!entry) return null;
|
||||
const capacity = increasing ? info.maxFlow - entry.flow : entry.flow - info.minFlow;
|
||||
if (capacity <= tolerance) return null;
|
||||
const slope = increasing
|
||||
? (directional ? info.slopes.slopeRight : info.slopes.alpha)
|
||||
: (directional ? info.slopes.slopeLeft : info.slopes.alpha);
|
||||
const weight = 1 / Math.max(1e-6, Math.abs(slope) || info.slopes.alpha || 1);
|
||||
return { entry, capacity, weight };
|
||||
}).filter(Boolean);
|
||||
|
||||
if (!candidates.length) break;
|
||||
const weightSum = candidates.reduce((sum, c) => sum + c.weight * c.capacity, 0);
|
||||
if (weightSum <= 0) break;
|
||||
|
||||
let progress = 0;
|
||||
candidates.forEach(candidate => {
|
||||
let share = (candidate.weight * candidate.capacity / weightSum) * Math.abs(remaining);
|
||||
share = Math.min(share, candidate.capacity);
|
||||
if (share <= 0) return;
|
||||
if (increasing) candidate.entry.flow += share;
|
||||
else candidate.entry.flow -= share;
|
||||
progress += share;
|
||||
});
|
||||
|
||||
if (progress <= tolerance) break;
|
||||
remaining += increasing ? -progress : progress;
|
||||
}
|
||||
}
|
||||
|
||||
function _marginalCostRefine(flowDistribution, pumpInfos, Qd, ctx) {
|
||||
const { groupCalcPower } = ctx.groupCurves;
|
||||
const mcDelta = Math.max(1e-6, (Qd / pumpInfos.length) * 0.005);
|
||||
|
||||
for (let iter = 0; iter < MC_ITER_CAP; iter++) {
|
||||
const mcEntries = flowDistribution.map(entry => {
|
||||
const info = pumpInfos.find(i => i.id === entry.machineId);
|
||||
const pNow = groupCalcPower(info.machine, entry.flow);
|
||||
const pUp = groupCalcPower(info.machine, Math.min(info.maxFlow, entry.flow + mcDelta));
|
||||
return { entry, info, mc: (pUp - pNow) / mcDelta };
|
||||
});
|
||||
|
||||
let expensive = null;
|
||||
let cheap = null;
|
||||
for (const e of mcEntries) {
|
||||
if (e.entry.flow > e.info.minFlow + mcDelta && (!expensive || e.mc > expensive.mc)) expensive = e;
|
||||
if (e.entry.flow < e.info.maxFlow - mcDelta && (!cheap || e.mc < cheap.mc)) cheap = e;
|
||||
}
|
||||
if (!expensive || !cheap || expensive === cheap) break;
|
||||
if (expensive.mc - cheap.mc < expensive.mc * MC_RELATIVE_EXIT) break;
|
||||
|
||||
const before = groupCalcPower(expensive.info.machine, expensive.entry.flow)
|
||||
+ groupCalcPower(cheap.info.machine, cheap.entry.flow);
|
||||
const after = groupCalcPower(expensive.info.machine, expensive.entry.flow - mcDelta)
|
||||
+ groupCalcPower(cheap.info.machine, cheap.entry.flow + mcDelta);
|
||||
if (after < before) {
|
||||
expensive.entry.flow -= mcDelta;
|
||||
cheap.entry.flow += mcDelta;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calcBestCombinationBEPGravitation(combinations, Qd, ctx, method = 'BEP-Gravitation-Directional') {
|
||||
const { machines, groupCurves } = ctx;
|
||||
const { groupFlow, groupNCog, groupCalcPower } = groupCurves;
|
||||
const directional = method === 'BEP-Gravitation-Directional';
|
||||
|
||||
let bestCombination = null;
|
||||
let bestPower = Infinity;
|
||||
let bestFlow = 0;
|
||||
let bestCog = 0;
|
||||
let bestDeviation = Infinity;
|
||||
|
||||
combinations.forEach(combination => {
|
||||
const pumpInfos = combination.map(machineId => {
|
||||
const machine = machines[machineId];
|
||||
const minFlow = groupFlow(machine).currentFxyYMin;
|
||||
const maxFlow = groupFlow(machine).currentFxyYMax;
|
||||
const span = Math.max(0, maxFlow - minFlow);
|
||||
const NCog = Math.max(0, Math.min(1, groupNCog(machine) || 0));
|
||||
const estimatedBEP = minFlow + span * NCog;
|
||||
const slopes = estimateSlopesAtBEP(machine, estimatedBEP, ctx);
|
||||
return { id: machineId, machine, minFlow, maxFlow, NCog, Q_BEP: slopes.Q_BEP, slopes };
|
||||
});
|
||||
|
||||
if (pumpInfos.length === 0) return;
|
||||
|
||||
const flowDistribution = pumpInfos.map(info => ({
|
||||
machineId: info.id,
|
||||
flow: Math.min(info.maxFlow, Math.max(info.minFlow, info.Q_BEP)),
|
||||
}));
|
||||
|
||||
let totalFlow = flowDistribution.reduce((s, e) => s + e.flow, 0);
|
||||
const delta = Qd - totalFlow;
|
||||
if (Math.abs(delta) > 1e-6) {
|
||||
redistributeFlowBySlope(pumpInfos, flowDistribution, delta, directional);
|
||||
}
|
||||
|
||||
flowDistribution.forEach(entry => {
|
||||
const info = pumpInfos.find(i => i.id === entry.machineId);
|
||||
entry.flow = Math.min(info.maxFlow, Math.max(info.minFlow, entry.flow));
|
||||
});
|
||||
|
||||
_marginalCostRefine(flowDistribution, pumpInfos, Qd, ctx);
|
||||
|
||||
let totalPower = 0;
|
||||
totalFlow = 0;
|
||||
flowDistribution.forEach(entry => {
|
||||
totalFlow += entry.flow;
|
||||
const info = pumpInfos.find(i => i.id === entry.machineId);
|
||||
totalPower += groupCalcPower(info.machine, entry.flow);
|
||||
});
|
||||
|
||||
const totalCog = pumpInfos.reduce((s, info) => s + info.NCog, 0);
|
||||
const deviation = pumpInfos.reduce((sum, info) => {
|
||||
const entry = flowDistribution.find(item => item.machineId === info.id);
|
||||
const deltaFlow = entry ? (entry.flow - info.Q_BEP) : 0;
|
||||
return sum + (deltaFlow * deltaFlow) * (info.slopes.alpha || 1);
|
||||
}, 0);
|
||||
|
||||
const shouldUpdate = totalPower < bestPower
|
||||
|| (totalPower === bestPower && deviation < bestDeviation);
|
||||
|
||||
if (shouldUpdate) {
|
||||
bestCombination = flowDistribution.map(e => ({ ...e }));
|
||||
bestPower = totalPower;
|
||||
bestFlow = totalFlow;
|
||||
bestCog = totalCog;
|
||||
bestDeviation = deviation;
|
||||
}
|
||||
});
|
||||
|
||||
return { bestCombination, bestPower, bestFlow, bestCog, bestDeviation, method };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calcBestCombinationBEPGravitation,
|
||||
estimateSlopesAtBEP,
|
||||
redistributeFlowBySlope,
|
||||
};
|
||||
88
src/optimizer/bestCombination.js
Normal file
88
src/optimizer/bestCombination.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// CoG-based combination optimizer.
|
||||
// Pure function: picks the combination whose CoG-weighted flow allocation
|
||||
// yields the lowest total power, clamped to each machine's curve envelope.
|
||||
//
|
||||
// `ctx` must provide:
|
||||
// - machines: machineId -> machine
|
||||
// - groupCurves: { groupFlow, groupNCog, groupCalcPower }
|
||||
// - logger (optional, for debug traces)
|
||||
|
||||
const ROUND_2 = 100;
|
||||
|
||||
function calcBestCombination(combinations, Qd, ctx) {
|
||||
const { machines, groupCurves, logger } = ctx;
|
||||
const { groupFlow, groupNCog, groupCalcPower } = groupCurves;
|
||||
|
||||
let bestCombination = null;
|
||||
let bestPower = Infinity;
|
||||
let bestFlow = 0;
|
||||
let bestCog = 0;
|
||||
|
||||
combinations.forEach(combination => {
|
||||
const totalCoG = combination.reduce((sum, id) => {
|
||||
return sum + Math.round((groupNCog(machines[id]) || 0) * ROUND_2) / ROUND_2;
|
||||
}, 0);
|
||||
|
||||
// CoG-weighted initial distribution; if all CoGs are 0, split evenly.
|
||||
let flowDistribution = combination.map(machineId => {
|
||||
const machine = machines[machineId];
|
||||
let flow;
|
||||
if (totalCoG === 0) {
|
||||
flow = Qd / combination.length;
|
||||
} else {
|
||||
flow = ((groupNCog(machine) || 0) / totalCoG) * Qd;
|
||||
logger?.debug?.(`Machine Normalized CoG-based distribution ${machineId} flow: ${flow}`);
|
||||
}
|
||||
return { machineId, flow };
|
||||
});
|
||||
|
||||
const clamped = flowDistribution.map(entry => {
|
||||
const machine = machines[entry.machineId];
|
||||
const min = groupFlow(machine).currentFxyYMin;
|
||||
const max = groupFlow(machine).currentFxyYMax;
|
||||
const clampedFlow = Math.min(max, Math.max(min, entry.flow));
|
||||
return { ...entry, flow: clampedFlow, min, max, desired: entry.flow };
|
||||
});
|
||||
|
||||
// Spill the unmet remainder once: distribute proportionally to each
|
||||
// machine's *desired* share, weighted toward those with headroom.
|
||||
let remainder = Qd - clamped.reduce((sum, entry) => sum + entry.flow, 0);
|
||||
if (Math.abs(remainder) > 1e-6) {
|
||||
const adjustable = clamped.filter(entry =>
|
||||
remainder > 0 ? entry.flow < entry.max : entry.flow > entry.min,
|
||||
);
|
||||
const weightSum = adjustable.reduce((s, e) => s + e.desired, 0) || adjustable.length;
|
||||
|
||||
adjustable.forEach(entry => {
|
||||
const weight = entry.desired / weightSum || 1 / adjustable.length;
|
||||
const delta = remainder * weight;
|
||||
const next = remainder > 0
|
||||
? Math.min(entry.max, entry.flow + delta)
|
||||
: Math.max(entry.min, entry.flow + delta);
|
||||
remainder -= (next - entry.flow);
|
||||
entry.flow = next;
|
||||
});
|
||||
}
|
||||
|
||||
flowDistribution = clamped;
|
||||
|
||||
let totalFlow = 0;
|
||||
let totalPower = 0;
|
||||
flowDistribution.forEach(({ machineId, flow }) => {
|
||||
totalFlow += flow;
|
||||
totalPower += groupCalcPower(machines[machineId], flow);
|
||||
});
|
||||
|
||||
if (totalPower < bestPower) {
|
||||
logger?.debug?.(`New best combination found: ${totalPower} < ${bestPower}`);
|
||||
bestPower = totalPower;
|
||||
bestFlow = totalFlow;
|
||||
bestCog = totalCoG;
|
||||
bestCombination = flowDistribution;
|
||||
}
|
||||
});
|
||||
|
||||
return { bestCombination, bestPower, bestFlow, bestCog };
|
||||
}
|
||||
|
||||
module.exports = { calcBestCombination };
|
||||
17
src/optimizer/index.js
Normal file
17
src/optimizer/index.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const cog = require('./bestCombination');
|
||||
const bep = require('./bepGravitation');
|
||||
|
||||
// Pick the optimizer module by config string.
|
||||
// Anything other than the two BEP variants falls back to CoG.
|
||||
function pickOptimizer(method) {
|
||||
if (method === 'BEP-Gravitation' || method === 'BEP-Gravitation-Directional') return bep;
|
||||
return cog;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
pickOptimizer,
|
||||
calcBestCombination: cog.calcBestCombination,
|
||||
calcBestCombinationBEPGravitation: bep.calcBestCombinationBEPGravitation,
|
||||
estimateSlopesAtBEP: bep.estimateSlopesAtBEP,
|
||||
redistributeFlowBySlope: bep.redistributeFlowBySlope,
|
||||
};
|
||||
2244
src/specificClass.js
2244
src/specificClass.js
File diff suppressed because it is too large
Load Diff
117
src/totals/totalsCalculator.js
Normal file
117
src/totals/totalsCalculator.js
Normal file
@@ -0,0 +1,117 @@
|
||||
const { POSITIONS } = require('generalFunctions');
|
||||
const { groupFlow, groupPower, groupNCog } = require('../groupOps/groupCurves');
|
||||
|
||||
// Aggregations across every machine in the group.
|
||||
//
|
||||
// calcAbsoluteTotals scans the full input-curve envelope (worst/best case
|
||||
// over the pump's entire pressure range). calcDynamicTotals reads the
|
||||
// current group operating point (after equalize). activeTotals only sums
|
||||
// machines that are operationally active right now.
|
||||
class TotalsCalculator {
|
||||
constructor(ctx = {}) {
|
||||
// ctx: { machines, unitPolicy, logger, operatingPoint, isMachineActive }
|
||||
// operatingPoint is a GroupOperatingPoint instance (for readChild).
|
||||
// isMachineActive is delegated back to the orchestrator so the
|
||||
// state-machine vocabulary lives in one place.
|
||||
this.ctx = ctx;
|
||||
this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }, NCog: 0 };
|
||||
this.absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
|
||||
}
|
||||
|
||||
get machines() { return this.ctx.machines || {}; }
|
||||
get unitPolicy() { return this.ctx.unitPolicy; }
|
||||
get logger() { return this.ctx.logger; }
|
||||
get operatingPoint() { return this.ctx.operatingPoint; }
|
||||
|
||||
isMachineActive(id) {
|
||||
if (typeof this.ctx.isMachineActive === 'function') return this.ctx.isMachineActive(id);
|
||||
const s = this.machines[id]?.state?.getCurrentState?.();
|
||||
return s === 'operational' || s === 'accelerating' || s === 'decelerating';
|
||||
}
|
||||
|
||||
calcAbsoluteTotals() {
|
||||
const out = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
|
||||
|
||||
Object.values(this.machines).forEach(machine => {
|
||||
const totals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } };
|
||||
Object.entries(machine.predictFlow.inputCurve).forEach(([pressure, xyCurve]) => {
|
||||
const minFlow = Math.min(...xyCurve.y);
|
||||
const maxFlow = Math.max(...xyCurve.y);
|
||||
const minPower = Math.min(...machine.predictPower.inputCurve[pressure].y);
|
||||
const maxPower = Math.max(...machine.predictPower.inputCurve[pressure].y);
|
||||
if (minFlow < totals.flow.min) totals.flow.min = minFlow;
|
||||
if (minPower < totals.power.min) totals.power.min = minPower;
|
||||
if (maxFlow > totals.flow.max) totals.flow.max = maxFlow;
|
||||
if (maxPower > totals.power.max) totals.power.max = maxPower;
|
||||
});
|
||||
if (totals.flow.min < out.flow.min) out.flow.min = totals.flow.min;
|
||||
if (totals.power.min < out.power.min) out.power.min = totals.power.min;
|
||||
out.flow.max += totals.flow.max;
|
||||
out.power.max += totals.power.max;
|
||||
});
|
||||
|
||||
// Empty-group + sentinel reset: Infinity / -Infinity are math
|
||||
// artefacts of the reducer's initial values; downstream code
|
||||
// expects clean zeros.
|
||||
if (out.flow.min === Infinity) { this.logger?.warn?.('Flow min Infinity — zeroing.'); out.flow.min = 0; }
|
||||
if (out.power.min === Infinity) { this.logger?.warn?.('Power min Infinity — zeroing.'); out.power.min = 0; }
|
||||
if (out.flow.max === -Infinity) { this.logger?.warn?.('Flow max -Infinity — zeroing.'); out.flow.max = 0; }
|
||||
if (out.power.max === -Infinity) { this.logger?.warn?.('Power max -Infinity — zeroing.'); out.power.max = 0; }
|
||||
|
||||
this.absoluteTotals = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
calcDynamicTotals() {
|
||||
const out = { flow: { min: Infinity, max: 0, act: 0 }, power: { min: Infinity, max: 0, act: 0 }, NCog: 0 };
|
||||
const fUnit = this.unitPolicy.canonical.flow;
|
||||
const pUnit = this.unitPolicy.canonical.power;
|
||||
|
||||
Object.values(this.machines).forEach(machine => {
|
||||
if (!machine.hasCurve) {
|
||||
this.logger?.error?.(`Machine ${machine.config?.general?.id} has no valid curve — skipping.`);
|
||||
return;
|
||||
}
|
||||
const gpf = groupFlow(machine);
|
||||
const gpp = groupPower(machine);
|
||||
|
||||
const minFlow = gpf.currentFxyYMin;
|
||||
const maxFlow = gpf.currentFxyYMax;
|
||||
const minPower = gpp.currentFxyYMin;
|
||||
const maxPower = gpp.currentFxyYMax;
|
||||
|
||||
const actFlow = this.operatingPoint?.readChild(machine, 'flow', 'predicted', POSITIONS.DOWNSTREAM, fUnit) || 0;
|
||||
const actPower = this.operatingPoint?.readChild(machine, 'power', 'predicted', POSITIONS.AT_EQUIPMENT, pUnit) || 0;
|
||||
|
||||
if (minFlow < out.flow.min) out.flow.min = minFlow;
|
||||
if (minPower < out.power.min) out.power.min = minPower;
|
||||
out.flow.max += maxFlow;
|
||||
out.power.max += maxPower;
|
||||
out.flow.act += actFlow;
|
||||
out.power.act += actPower;
|
||||
out.NCog += groupNCog(machine);
|
||||
});
|
||||
|
||||
this.dynamicTotals = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
activeTotals() {
|
||||
const out = { flow: { min: 0, max: 0 }, power: { min: 0, max: 0 }, countActiveMachines: 0 };
|
||||
|
||||
Object.entries(this.machines).forEach(([id, machine]) => {
|
||||
if (!this.isMachineActive(id)) return;
|
||||
const gpf = groupFlow(machine);
|
||||
const gpp = groupPower(machine);
|
||||
out.flow.min += gpf.currentFxyYMin;
|
||||
out.flow.max += gpf.currentFxyYMax;
|
||||
out.power.min += gpp.currentFxyYMin;
|
||||
out.power.max += gpp.currentFxyYMax;
|
||||
out.countActiveMachines += 1;
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TotalsCalculator;
|
||||
180
test/_output-manifest.md
Normal file
180
test/_output-manifest.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# machineGroupControl — Output Manifest
|
||||
|
||||
Per `.claude/rules/output-coverage.md`. Single source of truth for what MGC
|
||||
emits on Port 0/1/2, where the value comes from, and which test exercises it
|
||||
in populated AND degraded states.
|
||||
|
||||
**Convention for missing values:** keys are **absent** when the underlying
|
||||
source has not produced a value yet (pre-first-tick, no demand, no pressure).
|
||||
Once produced, a key may be **explicitly null/undefined** only in the
|
||||
documented degenerate cases below. The dashboard formatter must treat both
|
||||
absent and null/undefined as "no data" (display `'—'`) — see the
|
||||
`pct`/`num` helpers in `examples/02-Dashboard.json :: fn_status_split`.
|
||||
|
||||
---
|
||||
|
||||
## Port 0 — process data
|
||||
|
||||
Built by `src/io/output.js :: getOutput(mgc)`. Delta-compressed by
|
||||
`outputUtils.formatMsg(..., 'process')` — only changed keys appear in each emit.
|
||||
|
||||
### Static fields (always emitted once MGC has been initialised)
|
||||
|
||||
| Key | Source | Type / Range | Populated test | Degraded test |
|
||||
|---|---|---|---|---|
|
||||
| `mode` | `mgc.mode` (set via `set.mode` command; normalised by `specificClass.setMode`) | string ∈ {`optimalControl`, `priorityControl`, `maintenance`} (canonical camelCase) | commands.basic.test.js, ncog-distribution.integration.test.js | n/a — always set from constructor default |
|
||||
| `scaling` | `mgc.scaling` | string ∈ {`absolute`, `normalized`} or undefined | commands.basic.test.js | dashboard-fanout (undefined → raw-rows shows '—') |
|
||||
| `absDistFromPeak` | `groupEfficiency.calcDistanceFromPeak` (specificClass.js:132) | number ≥ 0 (η-points) | bep-distance-demand-sweep, group-bep-cascade, groupEfficiency.basic | groupEfficiency.basic test 7 (undefined when current = null) |
|
||||
| `relDistFromPeak` | `groupEfficiency.calcRelativeDistanceFromPeak` | number ∈ [0,1] **OR `undefined`** for degenerate (homogeneous pumps) | bep-distance-demand-sweep, group-bep-cascade | groupEfficiency.basic tests 5/6/7 (undefined cases), dashboard-fanout test 11 (undefined → '—' display) |
|
||||
| `flowCapacityMax` | `mgc.dynamicTotals.flow.max` (totalsCalculator), **converted to `unitPolicy.output.flow` (m³/h)** in output.js:62 | number m³/h ≥ 0; `0` when envelope unresolved (Infinity/NaN) | totalsCalculator.basic, dashboard-fanout (post-setup), demand-telemetry.basic | absent until first equalize; dashboard-fanout (state A); demand-telemetry (Infinity → 0) |
|
||||
| `flowCapacityMin` | `mgc.dynamicTotals.flow.min`, **converted to output flow unit (m³/h)** | number m³/h ≥ 0; `0` when unresolved | totalsCalculator.basic, demand-telemetry.basic | same as above |
|
||||
| `demandFlow` | `mgc._lastDemand.clamped` (set in `_runDispatch`, output.js:62) | number, canonical m³/s clamped to envelope, converted to `unitPolicy.output.flow` | demand-telemetry.basic (populated) | demand-telemetry.basic (absent before first demand); turnOff → 0 |
|
||||
| `demandPct` | derived `(clamped − flow.min)/(flow.max − flow.min)·100` (output.js:62) | number ∈ [0,100], `0` when capacity span ≤ 0 | demand-telemetry.basic (populated) | demand-telemetry.basic (absent before first demand) |
|
||||
| `machineCount` | `Object.keys(mgc.machines).length` | integer ≥ 0 | demand-cycle-walkthrough, ncog-distribution | n/a — always reflects current registration count |
|
||||
| `machineCountActive` | filtered count excluding `off`/`maintenance` states | integer ≥ 0 | demand-cycle-walkthrough, ncog-distribution | dashboard-fanout (state A: 0 active) |
|
||||
| `movementState` | `mgc.getMovementState()` (specificClass) — `'working'` while any child is ramping/sequencing or the executor has pending commands, else `'ready'` | string `'working'`\|`'ready'`, never null | movement-gate.basic (working: accelerating/warmingup/delayedMove/moveTimeLeft/executor-pending) | movement-gate.basic (ready: no machines, all settled) |
|
||||
|
||||
### Conditional pressure-header fields (emitted only when equalize resolved a positive ΔP)
|
||||
|
||||
| Key | Source | Type / Range | Populated test | Degraded test |
|
||||
|---|---|---|---|---|
|
||||
| `headerDiffPa` | `mgc.operatingPoint.headerDiffPa` (groupOperatingPoint.equalize) | number Pa > 0 | groupOperatingPoint.basic, dashboard-fanout (state B/C) | dashboard-fanout (state A — absent) |
|
||||
| `headerDiffMbar` | derived `headerDiffPa / 100` when `unitPolicy.output.pressure === 'mbar'` | number mbar > 0 | dashboard-fanout (state B/C) | absent when output pressure unit ≠ mbar — **not explicitly tested** |
|
||||
|
||||
### Dynamic measurement fields — pattern `{position}_{variant}_{type}`
|
||||
|
||||
Built by the loop at `io/output.js:23-39`. For each type×variant×position the
|
||||
container holds, one key is emitted **only if the value is non-null**.
|
||||
|
||||
Positions: `downstream`, `upstream`, `atEquipment`. Plus `differential_<variant>_<type>` when both `downstream` and `upstream` exist.
|
||||
|
||||
**Predicted measurements MGC writes itself (via writeOwn):**
|
||||
|
||||
| Key | Source (write site) | Type / Range | Populated test | Degraded test |
|
||||
|---|---|---|---|---|
|
||||
| `atEquipment_predicted_flow` | `handlePressureChange` (specificClass:153), `_optimalControl` (specificClass:214), `equalFlowControl` (control/strategies:118), `turnOffAllMachines` (specificClass:297) | number, canonical m³/s converted to `unitPolicy.output.flow` | bep-distance-demand-sweep, dashboard-fanout (state B/C), ncog-distribution | dashboard-fanout (state A: absent), turnoff-deadlock (post-shutdown = 0) |
|
||||
| `downstream_predicted_flow` | `handlePressureChange` (specificClass:156 — mirrors AT_EQUIPMENT for PS contract), `turnOffAllMachines` (specificClass:296) | same as above | implicit in bep-distance-demand-sweep getOutput | turnoff-deadlock (post-shutdown = 0) |
|
||||
| `atEquipment_predicted_power` | same call sites as flow (specificClass:157, 213; strategies:117; specificClass:298) | number, canonical W converted to `unitPolicy.output.power` | bep-distance-demand-sweep, dashboard-fanout, distribution-power-table | turnoff-deadlock (= 0) |
|
||||
| `atEquipment_predicted_efficiency` | `_optimalControl` (specificClass:221), `equalFlowControl` (strategies:122) — only when `dP > 0 && bestPower > 0` | number ∈ [0, 1] hydraulic η = (Q·ΔP)/P | bep-distance-demand-sweep, dashboard-fanout (state C) | **absent** when dP ≤ 0 or bestPower ≤ 0 — guarded but not explicitly tested |
|
||||
| `atEquipment_predicted_Ncog` | `_optimalControl` (specificClass:224), `equalFlowControl` (strategies:125) | number, range **0..N where N = active pumps** (SUM of per-pump NCog from `bepGravitation.js:162` totalCog) — NOT 0..1; see [[project-mgc-bep-metrics-semantics]] | ncog-distribution (9 tests), bep-distance-demand-sweep, dashboard-fanout (state C) | dashboard-fanout normalizes by `machineCountActive` for display — tests 6/7/8/9/10 |
|
||||
|
||||
**Measured pressures forwarded from children:**
|
||||
MGC subscribes to each registered measurement child (specificClass.js:91-104)
|
||||
and re-emits the child's reading on its own `MeasurementContainer`. If a
|
||||
pressure measurement child registers at position `downstream`, MGC will
|
||||
emit `downstream_measured_pressure` on Port 0 the next time `getOutput` runs.
|
||||
|
||||
| Key pattern | Source | Tests |
|
||||
|---|---|---|
|
||||
| `<position>_measured_<type>` | child measurement node forwarded via `MeasurementContainer.emitter` (specificClass:91-105) | indirect — group-bep-cascade.integration drives pressure events through registered children; not asserted as a named output key |
|
||||
| `differential_measured_pressure` | computed when both `downstream_measured_pressure` and `upstream_measured_pressure` exist (output.js:33-37) | indirect via dashboard-fanout (used by fn_qh_point for header ΔP fallback) |
|
||||
|
||||
---
|
||||
|
||||
## Port 1 — InfluxDB telemetry
|
||||
|
||||
Built by `outputUtils.formatMsg(..., 'influxdb')` — same `getOutput` source,
|
||||
different formatter. Emits the same key set as Port 0 with InfluxDB
|
||||
line-protocol tag/field discipline (cardinality rules per `.claude/rules/telemetry.md`).
|
||||
|
||||
| Concern | Status |
|
||||
|---|---|
|
||||
| Keys | Identical to Port 0; the influxdb formatter (`generalFunctions/src/helper/formatters/influxdbFormatter.js`) decides which become tags vs fields. |
|
||||
| Test coverage | **None.** No test file imports/asserts the influxdb formatter for MGC. Regression vector if a key is added/renamed without checking cardinality. Tracked. |
|
||||
|
||||
---
|
||||
|
||||
## Port 2 — registration / control plumbing
|
||||
|
||||
Emitted on startup by `BaseNodeAdapter` (one message per node).
|
||||
|
||||
| Topic | Payload shape | Source | Tests |
|
||||
|---|---|---|---|
|
||||
| `registerChild` | `{ id: node.id, positionVsParent: <string> }` | BaseNodeAdapter init — sends to upstream parent so it can subscribe to this node's measurements | structure-examples.integration, commands.basic.test.js test 5 (`child.register`) — receiver side |
|
||||
|
||||
---
|
||||
|
||||
## Events emitted on `mgc.source.measurements.emitter`
|
||||
|
||||
These are NOT Port 0/1/2 emissions — they're in-process events that downstream
|
||||
EVOLV nodes (e.g., pumpingStation) subscribe to via the parent-child handshake.
|
||||
Listed here for completeness; covered by `.claude/rules/telemetry.md` rather
|
||||
than this manifest.
|
||||
|
||||
- `flow.predicted.atequipment` — fired on every `writeOwn` to flow/predicted/AT_EQUIPMENT
|
||||
- `flow.predicted.downstream` — fired on every `writeOwn` to flow/predicted/DOWNSTREAM (the live aggregate the PS subscribes to)
|
||||
- `power.predicted.atequipment`
|
||||
- `efficiency.predicted.atequipment`
|
||||
- `Ncog.predicted.atequipment`
|
||||
- `<type>.measured.<position>` — re-emit of any registered measurement child
|
||||
|
||||
Documented in `CONTRACT.md`; tested indirectly via `group-bep-cascade.integration.test.js` and `ncog-distribution.integration.test.js`.
|
||||
|
||||
---
|
||||
|
||||
## Example flow fan-out — `examples/02-Dashboard.json :: fn_status_split` (outputs: 18)
|
||||
|
||||
Delta-caches Port 0 then fans one msg per dashboard widget. Charts return the
|
||||
whole msg as `null` (drop the output) when their source is missing — never
|
||||
`{ payload: null }`. All ports covered by `test/integration/dashboard-fanout.integration.test.js`.
|
||||
|
||||
| # | Target widget | Topic / payload | Populated | Degraded (missing source) |
|
||||
|---|---|---|---|---|
|
||||
| 0 | ui_txt_mode | string | ✔ State C | ✔ State A → mode string |
|
||||
| 1 | ui_txt_flow | `'… m³/h'` | ✔ | ✔ State A → `—` |
|
||||
| 2 | ui_txt_power | `'… kW'` | ✔ | ✔ → `—` |
|
||||
| 3 | ui_txt_capacity | `'min – max m³/h'` | ✔ State B | ✔ → `—` |
|
||||
| 4 | ui_txt_machines | `'nAct / nTot'` | ✔ | ✔ → `—` |
|
||||
| 5 | ui_txt_bep (rel%) | `'… %'` | ✔ | ✔ null/undefined → `—` |
|
||||
| 6 | ui_txt_eta | `'… %'` | ✔ | ✔ → `—` |
|
||||
| 7 | ui_txt_eta_peak | `'… %'` | ✔ | ✔ → `—` |
|
||||
| 8 | ui_txt_bep_abs | `'…'` (η pts, 3dp) | ✔ | ✔ → `—` |
|
||||
| 9 | ui_txt_ncog | `'… %'` (sum/nAct) | ✔ | ✔ nAct=0/missing → `—` |
|
||||
| 10 | ui_chart_flow | `{topic:'Flow', payload:number}` | ✔ | ✔ → null (drop) |
|
||||
| 11 | ui_chart_flow (capacity) | `{topic:'Capacity', …}` | ✔ | ✔ → null |
|
||||
| 12 | ui_chart_power | `{topic:'Power', …}` | ✔ | ✔ → null |
|
||||
| 13 | ui_chart_bep | `{topic:'BEP rel %', ×100}` | ✔ | ✔ → null |
|
||||
| 14 | ui_chart_eta | `{topic:'η (%)', ×100}` | ✔ | ✔ → null |
|
||||
| 15 | ui_tpl_raw | `[{key,value}]` rows | ✔ | ✔ |
|
||||
| 16 | ui_chart_qh (passthrough) | raw `msg.payload` | ✔ | ✔ |
|
||||
| 17 | ui_chart_mgc_pctcap | `{topic:'% of capacity', payload:flow/capMax×100}` | ✔ State C | ✔ State A → null (drop) |
|
||||
|
||||
## Example flow fan-out — `examples/02-Dashboard.json :: fn_chart_pump_a/b/c` (outputs: 2 each)
|
||||
|
||||
Each per-pump fan-out delta-caches the pump's Port 0 then emits two chart msgs.
|
||||
The ctrl output carries a **-1 OFF sentinel**: when the cached pump `state` is
|
||||
`off` / `idle` / `maintenance` the pump is not running, so it plots `-1` (below
|
||||
the 0–100 band) — a clear OFF rail distinct from a pump genuinely running at 0%.
|
||||
`ui_chart_pumps_ctrl` has `ymin: "-5"` so the sentinel is visible. Charts return
|
||||
the whole msg as `null` (drop the output) when their source is missing — never
|
||||
`{ payload: null }`. All ports covered by
|
||||
`test/integration/per-pump-ctrl-fanout.integration.test.js`.
|
||||
|
||||
| # | Target chart | Topic / payload | Populated | Degraded |
|
||||
|---|---|---|---|---|
|
||||
| 0 | ui_chart_per_pump_flow | `{topic:'Pump A/B/C', payload:flow m³/h}` | ✔ running state | ✔ no `flow.predicted.downstream.*` key → null (drop) |
|
||||
| 1 | ui_chart_pumps_ctrl | `{topic:'Pump A/B/C', payload:ctrl%}`, or `payload:-1` when state ∈ {off,idle,maintenance} | ✔ running → +ctrl; ✔ off/idle/maintenance → -1 | ✔ no state + ctrl missing/NaN/null → null (drop); ✔ ctrl-only delta keeps cached OFF state |
|
||||
|
||||
`fn_chart_total` (outputs: 1) feeds the same flow chart with the group total
|
||||
(`downstream_predicted_flow ?? atEquipment_predicted_flow`); returns `null` when
|
||||
both are absent.
|
||||
|
||||
## Coverage gaps (open items)
|
||||
|
||||
These are known holes flagged during the 2026-05-14 governance review; not yet
|
||||
fixed but documented so they don't regress silently.
|
||||
|
||||
1. **Port 1 (InfluxDB) has no dedicated tests.** Any rename of a Port 0 key
|
||||
should add an explicit Port 1 assertion to prevent silent cardinality
|
||||
regressions.
|
||||
2. **`headerDiffMbar` only emitted when `unitPolicy.output.pressure === 'mbar'`.**
|
||||
The fallback (non-mbar configurations) isn't explicitly tested.
|
||||
3. **`atEquipment_predicted_efficiency` absent-state isn't asserted.** The
|
||||
`dP > 0 && bestPower > 0` guard exists but no test pins the absence.
|
||||
4. **Forwarded measured measurements** (`<position>_measured_<type>`) aren't
|
||||
asserted as named output keys — only their underlying behaviour is exercised.
|
||||
5. **`scaling` undefined behaviour** — schema removed `scaling.current` for
|
||||
several modes; what MGC emits for those is implicit, not tested.
|
||||
|
||||
When any of these is closed, move the row up into the appropriate table and
|
||||
delete the entry here.
|
||||
110
test/basic/bepGravitation.basic.test.js
Normal file
110
test/basic/bepGravitation.basic.test.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
calcBestCombinationBEPGravitation,
|
||||
estimateSlopesAtBEP,
|
||||
redistributeFlowBySlope,
|
||||
} = require('../../src/optimizer/bepGravitation');
|
||||
const optimizerIndex = require('../../src/optimizer');
|
||||
|
||||
function makeMachine({ id, fMin = 0, fMax = 100, NCog = 0.5, costFn } = {}) {
|
||||
return {
|
||||
config: { general: { id } },
|
||||
NCog,
|
||||
predictFlow: { currentFxyYMin: fMin, currentFxyYMax: fMax },
|
||||
predictPower: { currentFxyYMin: 0, currentFxyYMax: fMax * 2 },
|
||||
// Default: convex cost so marginal-cost refinement has a clear winner.
|
||||
inputFlowCalcPower: costFn ?? ((f) => 0.001 * f * f + f),
|
||||
};
|
||||
}
|
||||
|
||||
function mkCtx(machines) {
|
||||
return {
|
||||
machines,
|
||||
groupCurves: {
|
||||
groupFlow: (m) => m.predictFlow,
|
||||
groupPower: (m) => m.predictPower,
|
||||
groupNCog: (m) => m.NCog ?? 0,
|
||||
groupCalcPower: (m, f) => m.inputFlowCalcPower(f),
|
||||
},
|
||||
logger: { debug: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('estimateSlopesAtBEP: returns finite slopes/alpha/Q_BEP/P_BEP for a typical machine', () => {
|
||||
const machine = makeMachine({ id: 'a', fMin: 10, fMax: 90, NCog: 0.5 });
|
||||
const ctx = mkCtx({ a: machine });
|
||||
const slopes = estimateSlopesAtBEP(machine, 50, ctx);
|
||||
assert.ok(Number.isFinite(slopes.slopeLeft));
|
||||
assert.ok(Number.isFinite(slopes.slopeRight));
|
||||
assert.ok(Number.isFinite(slopes.alpha));
|
||||
assert.ok(slopes.alpha > 0);
|
||||
assert.ok(Number.isFinite(slopes.Q_BEP));
|
||||
assert.equal(slopes.Q_BEP, 50);
|
||||
assert.ok(Number.isFinite(slopes.P_BEP));
|
||||
});
|
||||
|
||||
test('redistributeFlowBySlope: redistributes within capacity, never exceeding min/max', () => {
|
||||
const pumpInfos = [
|
||||
{ id: 'a', minFlow: 0, maxFlow: 50,
|
||||
slopes: { slopeLeft: 1, slopeRight: 1, alpha: 1 } },
|
||||
{ id: 'b', minFlow: 0, maxFlow: 50,
|
||||
slopes: { slopeLeft: 1, slopeRight: 1, alpha: 1 } },
|
||||
];
|
||||
const flowDist = [{ machineId: 'a', flow: 10 }, { machineId: 'b', flow: 10 }];
|
||||
redistributeFlowBySlope(pumpInfos, flowDist, 30); // add 30 across 2 pumps
|
||||
const total = flowDist.reduce((s, e) => s + e.flow, 0);
|
||||
assert.ok(Math.abs(total - 50) < 1e-2, `expected total ~50, got ${total}`);
|
||||
for (const e of flowDist) {
|
||||
assert.ok(e.flow <= 50 + 1e-6 && e.flow >= 0 - 1e-6);
|
||||
}
|
||||
});
|
||||
|
||||
test('marginal-cost refinement bounded (no infinite loop on a flat-curve scenario)', () => {
|
||||
// Flat cost everywhere -> marginal cost identical -> loop must exit cleanly.
|
||||
const machines = {
|
||||
a: makeMachine({ id: 'a', fMin: 0, fMax: 100, costFn: (f) => f }),
|
||||
b: makeMachine({ id: 'b', fMin: 0, fMax: 100, costFn: (f) => f }),
|
||||
};
|
||||
const ctx = mkCtx(machines);
|
||||
const start = Date.now();
|
||||
const res = calcBestCombinationBEPGravitation([['a', 'b']], 30, ctx);
|
||||
const elapsed = Date.now() - start;
|
||||
assert.ok(elapsed < 1000, `refinement should be fast, took ${elapsed}ms`);
|
||||
assert.ok(res.bestCombination);
|
||||
const total = res.bestCombination.reduce((s, e) => s + e.flow, 0);
|
||||
assert.ok(Math.abs(total - 30) < 1e-2, `total should be ~Qd, got ${total}`);
|
||||
});
|
||||
|
||||
test('method selection: directional uses slopeRight/slopeLeft; non-directional uses alpha', () => {
|
||||
// Asymmetric slopes so the two methods produce different allocations.
|
||||
const pumpInfos = [
|
||||
{ id: 'a', minFlow: 0, maxFlow: 100,
|
||||
slopes: { slopeLeft: 10, slopeRight: 0.1, alpha: 5 } },
|
||||
{ id: 'b', minFlow: 0, maxFlow: 100,
|
||||
slopes: { slopeLeft: 0.1, slopeRight: 10, alpha: 5 } },
|
||||
];
|
||||
const distDir = [{ machineId: 'a', flow: 10 }, { machineId: 'b', flow: 10 }];
|
||||
const distAlpha = [{ machineId: 'a', flow: 10 }, { machineId: 'b', flow: 10 }];
|
||||
|
||||
// Increase by 30 -> directional should prefer 'a' (shallow right slope).
|
||||
redistributeFlowBySlope(pumpInfos, distDir, 30, true);
|
||||
// Alpha mode: same slope-weight per pump -> roughly equal split.
|
||||
redistributeFlowBySlope(pumpInfos, distAlpha, 30, false);
|
||||
|
||||
const aDir = distDir.find(e => e.machineId === 'a').flow;
|
||||
const bDir = distDir.find(e => e.machineId === 'b').flow;
|
||||
const aAlpha = distAlpha.find(e => e.machineId === 'a').flow;
|
||||
const bAlpha = distAlpha.find(e => e.machineId === 'b').flow;
|
||||
|
||||
assert.ok(aDir > bDir, `directional should send more to a (got a=${aDir}, b=${bDir})`);
|
||||
assert.ok(Math.abs(aAlpha - bAlpha) < 1e-2, `alpha mode should split evenly (got a=${aAlpha}, b=${bAlpha})`);
|
||||
|
||||
// pickOptimizer wires the right module.
|
||||
assert.equal(optimizerIndex.pickOptimizer('BEP-Gravitation-Directional').calcBestCombinationBEPGravitation,
|
||||
calcBestCombinationBEPGravitation);
|
||||
assert.equal(optimizerIndex.pickOptimizer('BEP-Gravitation').calcBestCombinationBEPGravitation,
|
||||
calcBestCombinationBEPGravitation);
|
||||
assert.ok(optimizerIndex.pickOptimizer('CoG').calcBestCombination);
|
||||
});
|
||||
67
test/basic/bestCombination.basic.test.js
Normal file
67
test/basic/bestCombination.basic.test.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { calcBestCombination } = require('../../src/optimizer/bestCombination');
|
||||
|
||||
function makeMachine({ id, fMin = 0, fMax = 100, NCog = 0.5, costFn } = {}) {
|
||||
return {
|
||||
config: { general: { id } },
|
||||
NCog,
|
||||
predictFlow: { currentFxyYMin: fMin, currentFxyYMax: fMax },
|
||||
predictPower: { currentFxyYMin: 0, currentFxyYMax: fMax * 2 },
|
||||
// Power model: caller picks the cost function so we can shape who wins.
|
||||
inputFlowCalcPower: costFn ?? ((flow) => flow * 1.0),
|
||||
};
|
||||
}
|
||||
|
||||
function mkCtx(machines) {
|
||||
return {
|
||||
machines,
|
||||
groupCurves: {
|
||||
groupFlow: (m) => m.predictFlow,
|
||||
groupPower: (m) => m.predictPower,
|
||||
groupNCog: (m) => m.NCog ?? 0,
|
||||
groupCalcPower: (m, f) => m.inputFlowCalcPower(f),
|
||||
},
|
||||
logger: { debug: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('calcBestCombination: 1 machine in combination receives Qd clamped to its range', () => {
|
||||
const machines = { a: makeMachine({ id: 'a', fMin: 5, fMax: 60 }) };
|
||||
const ctx = mkCtx(machines);
|
||||
|
||||
const res = calcBestCombination([['a']], 40, ctx);
|
||||
assert.ok(res.bestCombination);
|
||||
assert.equal(res.bestCombination.length, 1);
|
||||
assert.equal(res.bestCombination[0].flow, 40);
|
||||
|
||||
// Above max — clamps to max.
|
||||
const high = calcBestCombination([['a']], 200, ctx);
|
||||
assert.equal(high.bestCombination[0].flow, 60);
|
||||
});
|
||||
|
||||
test('calcBestCombination: 2 machines with equal NCog split flow evenly', () => {
|
||||
const machines = {
|
||||
a: makeMachine({ id: 'a', NCog: 0.5, fMin: 0, fMax: 100 }),
|
||||
b: makeMachine({ id: 'b', NCog: 0.5, fMin: 0, fMax: 100 }),
|
||||
};
|
||||
const ctx = mkCtx(machines);
|
||||
const res = calcBestCombination([['a', 'b']], 40, ctx);
|
||||
const aFlow = res.bestCombination.find(e => e.machineId === 'a').flow;
|
||||
const bFlow = res.bestCombination.find(e => e.machineId === 'b').flow;
|
||||
assert.ok(Math.abs(aFlow - bFlow) < 1e-6, `expected even split, got a=${aFlow} b=${bFlow}`);
|
||||
assert.ok(Math.abs(aFlow + bFlow - 40) < 1e-6);
|
||||
});
|
||||
|
||||
test('calcBestCombination: returns combination with the lowest total power', () => {
|
||||
// Two combinations: [a] (expensive) vs [b] (cheap). Both can deliver Qd=20.
|
||||
const machines = {
|
||||
a: makeMachine({ id: 'a', fMin: 0, fMax: 100, costFn: (f) => f * 10 }),
|
||||
b: makeMachine({ id: 'b', fMin: 0, fMax: 100, costFn: (f) => f * 1 }),
|
||||
};
|
||||
const ctx = mkCtx(machines);
|
||||
const res = calcBestCombination([['a'], ['b']], 20, ctx);
|
||||
assert.equal(res.bestCombination[0].machineId, 'b');
|
||||
assert.equal(res.bestPower, 20);
|
||||
});
|
||||
358
test/basic/commands.basic.test.js
Normal file
358
test/basic/commands.basic.test.js
Normal file
@@ -0,0 +1,358 @@
|
||||
// Basic tests for the machineGroupControl 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 = 'mgc-1',
|
||||
handleInputResult = undefined,
|
||||
dt = { flow: { min: 0, max: 100 } },
|
||||
// Initial mode for the fake. Defaults to optimalControl so gates pass for
|
||||
// the historical tests; per-test override via the returned `source.mode = …`.
|
||||
mode = 'optimalControl',
|
||||
// Override the gate decisions. Default-true matches the no-gating world
|
||||
// tests assumed before this change; negative-path tests pass functions that
|
||||
// return false for specific actions / sources.
|
||||
isValidActionForMode = () => true,
|
||||
isValidSourceForMode = () => true,
|
||||
} = {}) {
|
||||
const calls = {
|
||||
setMode: [],
|
||||
handleInput: [],
|
||||
registerChild: [],
|
||||
turnOffAllMachines: 0,
|
||||
gateAction: [],
|
||||
gateSource: [],
|
||||
};
|
||||
const source = {
|
||||
logger: makeLogger(),
|
||||
config: { general: { name } },
|
||||
mode,
|
||||
setMode: (m) => { calls.setMode.push(m); /* keep fake.mode unchanged unless test does it */ },
|
||||
isValidActionForMode: (action, m) => {
|
||||
const ok = isValidActionForMode(action, m);
|
||||
calls.gateAction.push({ action, mode: m, ok });
|
||||
if (!ok) source.logger.warn(`action '${action}' not allowed in mode '${m}'`);
|
||||
return ok;
|
||||
},
|
||||
isValidSourceForMode: (src, m) => {
|
||||
const ok = isValidSourceForMode(src, m);
|
||||
calls.gateSource.push({ src, mode: m, ok });
|
||||
if (!ok) source.logger.warn(`source '${src}' not allowed in mode '${m}'`);
|
||||
return ok;
|
||||
},
|
||||
handleInput: async (src, demand) => {
|
||||
calls.handleInput.push({ src, demand });
|
||||
if (handleInputResult instanceof Error) throw handleInputResult;
|
||||
return handleInputResult;
|
||||
},
|
||||
// Mirror of the real specificClass.setDemand: resolves unit -> canonical
|
||||
// m³/s and forwards to handleInput. With dt.flow {min:0,max:100} the %
|
||||
// interpolation is identity, so a bare numeric demand round-trips through
|
||||
// handleInput unchanged — keeping the existing assertions stable.
|
||||
setDemand: async (value, unit = '%') => {
|
||||
const v = Number(value);
|
||||
if (!Number.isFinite(v)) return undefined;
|
||||
if (v < 0) { await source.turnOffAllMachines(); return undefined; }
|
||||
let canonical;
|
||||
if (unit === '%') {
|
||||
canonical = source.interpolation.interpolate_lin_single_point(
|
||||
v, 0, 100, dt.flow.min, dt.flow.max);
|
||||
} else {
|
||||
const { convert } = require('generalFunctions');
|
||||
canonical = convert(v).from(unit).to('m3/s');
|
||||
}
|
||||
return source.handleInput('parent', canonical);
|
||||
},
|
||||
// Retained for completeness — the mock setDemand uses these internally.
|
||||
calcDynamicTotals: () => dt,
|
||||
interpolation: {
|
||||
interpolate_lin_single_point: (x, ix, iy, ox, oy) => {
|
||||
if (iy === ix) return ox;
|
||||
return ox + ((x - ix) * (oy - ox)) / (iy - ix);
|
||||
},
|
||||
},
|
||||
turnOffAllMachines: async () => { calls.turnOffAllMachines += 1; },
|
||||
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: 'prioritycontrol' }, source, makeCtx());
|
||||
assert.deepEqual(calls.setMode, ['prioritycontrol']);
|
||||
|
||||
// bare-number demand → interpreted as % → interpolated against dt.flow.
|
||||
// Default test dt is {min:0,max:100} so % is identity.
|
||||
await reg.dispatch({ topic: 'set.demand', payload: '12.5' }, source, makeCtx());
|
||||
assert.equal(calls.handleInput.length, 1);
|
||||
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 12.5 });
|
||||
});
|
||||
|
||||
test('set.demand with explicit flow unit converts to canonical m³/s', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
await reg.dispatch({ topic: 'set.demand', payload: { value: 200, unit: 'm3/h' } }, source, makeCtx());
|
||||
assert.equal(calls.handleInput.length, 1);
|
||||
// 200 m³/h = 0.0555... m³/s
|
||||
assert.ok(Math.abs(calls.handleInput[0].demand - 0.05555555555555556) < 1e-9,
|
||||
`expected ~0.0556 m³/s, got ${calls.handleInput[0].demand}`);
|
||||
});
|
||||
|
||||
test('set.demand negative value triggers turnOffAllMachines and bypasses handleInput', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
await reg.dispatch({ topic: 'set.demand', payload: -1 }, source, makeCtx());
|
||||
assert.equal(calls.turnOffAllMachines, 1);
|
||||
assert.equal(calls.handleInput.length, 0);
|
||||
});
|
||||
|
||||
test('child.register canonical resolves child via RED.nodes.getNode', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const child = { id: 'child-1', source: { tag: 'child-domain' } };
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'child.register', payload: 'child-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('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: 'prioritycontrol' }, source, makeCtx({ logger: ctxLogger }));
|
||||
await reg.dispatch({ topic: 'setMode', payload: 'optimalcontrol' }, source, makeCtx({ logger: ctxLogger }));
|
||||
assert.deepEqual(calls.setMode, ['prioritycontrol', 'optimalcontrol']);
|
||||
let warns = ctxLogger.calls.warn.filter((m) => m.includes("'setMode' is deprecated"));
|
||||
assert.equal(warns.length, 1, 'setMode deprecation warning should log exactly once');
|
||||
|
||||
await reg.dispatch({ topic: 'Qd', payload: 5 }, source, makeCtx({ logger: ctxLogger }));
|
||||
warns = ctxLogger.calls.warn.filter((m) => m.includes("'Qd' is deprecated"));
|
||||
assert.equal(warns.length, 1);
|
||||
assert.equal(calls.handleInput.length, 1);
|
||||
|
||||
const child = { id: 'child-x', source: { tag: 'child-domain' } };
|
||||
await reg.dispatch(
|
||||
{ topic: 'registerChild', payload: 'child-x', positionVsParent: 'atEquipment' },
|
||||
source,
|
||||
makeCtx({ child, logger: ctxLogger })
|
||||
);
|
||||
warns = ctxLogger.calls.warn.filter((m) => m.includes("'registerChild' is deprecated"));
|
||||
assert.equal(warns.length, 1);
|
||||
assert.equal(calls.registerChild.length, 1);
|
||||
});
|
||||
|
||||
test('set.demand with non-numeric payload logs error and does not call handleInput', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.demand', payload: 'oops' }, source, makeCtx({ logger: ctxLogger }));
|
||||
assert.equal(calls.handleInput.length, 0);
|
||||
assert.ok(
|
||||
ctxLogger.calls.error.some((m) => m.includes('set.demand') && m.includes('oops')),
|
||||
`expected error about invalid Qd, got: ${JSON.stringify(ctxLogger.calls.error)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('set.demand on success calls ctx.send with reply { topic: config.general.name, payload: "done" }', async () => {
|
||||
const { source, calls } = makeSource({ name: 'mgc-A' });
|
||||
const sent = [];
|
||||
const ctx = makeCtx({ sendSpy: (m) => sent.push(m) });
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.demand', payload: 7.5 }, source, ctx);
|
||||
|
||||
assert.equal(calls.handleInput.length, 1);
|
||||
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 7.5 });
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].topic, 'mgc-A');
|
||||
assert.equal(sent[0].payload, 'done');
|
||||
});
|
||||
|
||||
test('child.register with unknown child 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)}`
|
||||
);
|
||||
});
|
||||
|
||||
// --- mode gate tests -------------------------------------------------------
|
||||
|
||||
test('gate: set.demand in maintenance mode is dropped (action not allowed)', async () => {
|
||||
// Mirror schema: maintenance allows only statusCheck. The dispatch action
|
||||
// for a positive demand under optimalControl/priorityControl is
|
||||
// execOptimalCombination / execSequentialControl — neither in maintenance.
|
||||
const { source, calls } = makeSource({
|
||||
mode: 'maintenance',
|
||||
isValidActionForMode: (action) => action === 'statusCheck',
|
||||
});
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.demand', payload: 50 }, source, makeCtx());
|
||||
assert.equal(calls.handleInput.length, 0, 'handleInput must not be invoked');
|
||||
assert.equal(calls.turnOffAllMachines, 0, 'turnOffAllMachines must not be invoked');
|
||||
assert.ok(
|
||||
source.logger.calls.warn.some((m) => m.includes('not allowed')),
|
||||
`expected warn about action not allowed in maintenance, got: ${JSON.stringify(source.logger.calls.warn)}`
|
||||
);
|
||||
});
|
||||
|
||||
test("gate: set.demand from msg.source 'physical' in maintenance is dropped (source not allowed)", async () => {
|
||||
// Maintenance accepts sources ['parent','GUI'] per schema. Physical/HMI is
|
||||
// rejected by the source gate even before we ask which action to perform.
|
||||
const { source, calls } = makeSource({
|
||||
mode: 'maintenance',
|
||||
isValidActionForMode: () => true, // pretend action is allowed; source gate must still reject
|
||||
isValidSourceForMode: (src) => src === 'parent' || src === 'GUI',
|
||||
});
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.demand', payload: 50, source: 'physical' }, source, makeCtx());
|
||||
assert.equal(calls.handleInput.length, 0);
|
||||
assert.equal(calls.turnOffAllMachines, 0);
|
||||
assert.ok(
|
||||
source.logger.calls.warn.some((m) => m.includes("'physical'") && m.includes('not allowed')),
|
||||
`expected warn about physical source not allowed, got: ${JSON.stringify(source.logger.calls.warn)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('gate: set.demand from msg.source GUI in optimalControl reaches handleInput', async () => {
|
||||
const { source, calls } = makeSource({
|
||||
mode: 'optimalControl',
|
||||
isValidActionForMode: (action) =>
|
||||
['statusCheck', 'execOptimalCombination', 'balanceLoad', 'emergencyStop'].includes(action),
|
||||
isValidSourceForMode: (src) => ['parent', 'GUI', 'physical', 'API'].includes(src),
|
||||
});
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.demand', payload: 25, source: 'GUI' }, source, makeCtx());
|
||||
assert.equal(calls.handleInput.length, 1);
|
||||
assert.deepEqual(calls.handleInput[0], { src: 'parent', demand: 25 });
|
||||
// Sanity check on the gate plumbing: both gates were consulted with the
|
||||
// expected (action, source, mode) tuple.
|
||||
assert.ok(calls.gateAction.some((g) => g.action === 'execOptimalCombination' && g.mode === 'optimalControl' && g.ok));
|
||||
assert.ok(calls.gateSource.some((g) => g.src === 'GUI' && g.mode === 'optimalControl' && g.ok));
|
||||
});
|
||||
|
||||
test('gate: emergencyStop (negative demand) gated by mode → maintenance blocks the stop-all', async () => {
|
||||
// A negative demand is the operator stop-all signal. The schema declares
|
||||
// emergencyStop in optimalControl/priorityControl but NOT in maintenance,
|
||||
// so this should be rejected too — maintenance is "monitor only", which
|
||||
// includes "no dispatch decisions, even shutdowns".
|
||||
const { source, calls } = makeSource({
|
||||
mode: 'maintenance',
|
||||
isValidActionForMode: (action) => action === 'statusCheck',
|
||||
});
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.demand', payload: -1 }, source, makeCtx());
|
||||
assert.equal(calls.turnOffAllMachines, 0, 'turnOff must be gated');
|
||||
assert.ok(
|
||||
source.logger.calls.warn.some((m) => m.includes('emergencyStop') && m.includes('not allowed')),
|
||||
`expected warn about emergencyStop not allowed, got: ${JSON.stringify(source.logger.calls.warn)}`
|
||||
);
|
||||
});
|
||||
|
||||
// --- mode-string normalisation (specificClass internals) --------------------
|
||||
|
||||
const { _normaliseMode, ALLOWED_MODES } = require('../../src/specificClass');
|
||||
|
||||
test('mode normalisation: camelCase pass-through, lowercase accepted, garbage rejected', () => {
|
||||
assert.equal(_normaliseMode('optimalControl'), 'optimalControl');
|
||||
assert.equal(_normaliseMode('optimalcontrol'), 'optimalControl');
|
||||
assert.equal(_normaliseMode('OPTIMALCONTROL'), 'optimalControl');
|
||||
assert.equal(_normaliseMode('priorityControl'), 'priorityControl');
|
||||
assert.equal(_normaliseMode('prioritycontrol'), 'priorityControl');
|
||||
assert.equal(_normaliseMode('maintenance'), 'maintenance');
|
||||
assert.equal(_normaliseMode('MAINTENANCE'), 'maintenance');
|
||||
assert.equal(_normaliseMode('wat'), null);
|
||||
assert.equal(_normaliseMode(''), null);
|
||||
assert.equal(_normaliseMode(null), null);
|
||||
assert.equal(_normaliseMode(undefined), null);
|
||||
assert.deepEqual(ALLOWED_MODES, ['optimalControl', 'priorityControl', 'maintenance']);
|
||||
});
|
||||
|
||||
// --- schema-shape regression -----------------------------------------------
|
||||
|
||||
test('schema regression: allowedSources keys are camelCase for all three modes', () => {
|
||||
// Read the JSON directly — generalFunctions' package.json `exports` map
|
||||
// doesn't expose the configs subpath, and we don't want to add it just for
|
||||
// a test. Path is repo-relative from this test file.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const schemaPath = path.resolve(__dirname, '../../../generalFunctions/src/configs/machineGroupControl.json');
|
||||
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
|
||||
const allowedSourcesSchema = schema.mode.allowedSources.rules.schema;
|
||||
assert.ok(allowedSourcesSchema.optimalControl, 'optimalControl key must exist on allowedSources');
|
||||
assert.ok(allowedSourcesSchema.priorityControl, 'priorityControl key must exist on allowedSources');
|
||||
assert.ok(allowedSourcesSchema.maintenance, 'maintenance key must exist on allowedSources');
|
||||
// Maintenance is monitor-only: parent + GUI permitted, physical/API rejected.
|
||||
const mDefaults = allowedSourcesSchema.maintenance.default;
|
||||
assert.ok(mDefaults.includes('parent'), `maintenance default should permit parent, got ${mDefaults}`);
|
||||
assert.ok(mDefaults.includes('GUI'), `maintenance default should permit GUI, got ${mDefaults}`);
|
||||
assert.ok(!mDefaults.includes('physical'), 'maintenance must NOT permit physical writes');
|
||||
assert.ok(!mDefaults.includes('API'), 'maintenance must NOT permit API writes');
|
||||
// Catch a regression to lowercase keys.
|
||||
assert.equal(allowedSourcesSchema.optimalcontrol, undefined, 'lowercase optimalcontrol key must NOT exist');
|
||||
assert.equal(allowedSourcesSchema.prioritycontrol, undefined, 'lowercase prioritycontrol key must NOT exist');
|
||||
});
|
||||
83
test/basic/demand-telemetry.basic.test.js
Normal file
83
test/basic/demand-telemetry.basic.test.js
Normal file
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { getOutput } = require('../../src/io/output.js');
|
||||
const MachineGroup = require('../../src/specificClass.js');
|
||||
|
||||
// Real declared unit policy so the m³/s → m³/h conversion is the production one.
|
||||
const unitPolicy = MachineGroup.unitPolicy;
|
||||
|
||||
// Minimal MGC stand-in exposing exactly the surface getOutput reads. The
|
||||
// measurement loop is short-circuited with an empty type list so the test
|
||||
// isolates the demand telemetry without needing curves / CoolProp.
|
||||
function mockMgc(overrides = {}) {
|
||||
return {
|
||||
measurements: { getTypes: () => [] },
|
||||
unitPolicy,
|
||||
mode: 'optimalControl',
|
||||
scaling: 'absolute',
|
||||
absDistFromPeak: 0,
|
||||
relDistFromPeak: 0,
|
||||
dynamicTotals: { flow: { min: 0.05, max: 0.25 } }, // m³/s
|
||||
machines: {},
|
||||
operatingPoint: {},
|
||||
_lastDemand: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('demandFlow + demandPct emitted once a demand is resolved', () => {
|
||||
// Demand resolved to 0.15 m³/s inside a 0.05..0.25 envelope → midpoint = 50%.
|
||||
const out = getOutput(mockMgc({ _lastDemand: { canonical: 0.15, clamped: 0.15 } }));
|
||||
|
||||
// m³/s → m³/h is ×3600. 0.15 m³/s = 540 m³/h.
|
||||
assert.equal(out.demandFlow, 540);
|
||||
assert.ok(Math.abs(out.demandPct - 50) < 1e-9, `expected ~50%, got ${out.demandPct}`);
|
||||
});
|
||||
|
||||
test('demandPct reflects the clamped setpoint, not the raw request', () => {
|
||||
// Operator asked for 0.40 m³/s but the envelope caps at 0.25 → 100%.
|
||||
const out = getOutput(mockMgc({ _lastDemand: { canonical: 0.40, clamped: 0.25 } }));
|
||||
assert.equal(out.demandFlow, 900); // 0.25 m³/s = 900 m³/h
|
||||
assert.equal(out.demandPct, 100);
|
||||
});
|
||||
|
||||
test('demandPct is 0 (never NaN) when the capacity span is zero', () => {
|
||||
const out = getOutput(mockMgc({
|
||||
dynamicTotals: { flow: { min: 0.1, max: 0.1 } },
|
||||
_lastDemand: { canonical: 0.1, clamped: 0.1 },
|
||||
}));
|
||||
assert.equal(out.demandPct, 0);
|
||||
assert.ok(Number.isFinite(out.demandFlow));
|
||||
});
|
||||
|
||||
test('turnOff demand (0) emits a zero setpoint, not absent', () => {
|
||||
const out = getOutput(mockMgc({ _lastDemand: { canonical: 0, clamped: 0 } }));
|
||||
assert.equal(out.demandFlow, 0);
|
||||
assert.equal(out.demandPct, 0);
|
||||
});
|
||||
|
||||
test('demand telemetry is absent before the first demand (degraded state)', () => {
|
||||
const out = getOutput(mockMgc({ _lastDemand: null }));
|
||||
assert.ok(!('demandFlow' in out), 'demandFlow must be absent pre-first-demand');
|
||||
assert.ok(!('demandPct' in out), 'demandPct must be absent pre-first-demand');
|
||||
// The always-on capacity fields are still present, converted to the output
|
||||
// flow unit (m³/h): 0.05 m³/s → 180, 0.25 m³/s → 900.
|
||||
assert.equal(out.flowCapacityMin, 180);
|
||||
assert.equal(out.flowCapacityMax, 900);
|
||||
});
|
||||
|
||||
test('flow capacity is emitted in the output unit (m³/h), matching the flow series', () => {
|
||||
const out = getOutput(mockMgc({ dynamicTotals: { flow: { min: 0.1, max: 0.3 } } }));
|
||||
assert.equal(out.flowCapacityMin, 360); // 0.1 m³/s × 3600
|
||||
assert.equal(out.flowCapacityMax, 1080); // 0.3 m³/s × 3600
|
||||
});
|
||||
|
||||
test('flow capacity falls back to 0 when the envelope is unresolved (Infinity)', () => {
|
||||
// Pre-first-equalize: dynamicTotals seeds min=Infinity, max=0.
|
||||
const out = getOutput(mockMgc({ dynamicTotals: { flow: { min: Infinity, max: 0 } } }));
|
||||
assert.equal(out.flowCapacityMin, 0);
|
||||
assert.equal(out.flowCapacityMax, 0);
|
||||
});
|
||||
140
test/basic/demandDispatcher.basic.test.js
Normal file
140
test/basic/demandDispatcher.basic.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const DemandDispatcher = require('../../src/dispatch/demandDispatcher.js');
|
||||
|
||||
const silentLogger = { warn() {}, error() {}, debug() {}, info() {} };
|
||||
|
||||
// Helper: a manually-resolvable promise so we can pin a dispatch in flight.
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('fire(50) triggers runFn with 50', async () => {
|
||||
const calls = [];
|
||||
const dispatcher = new DemandDispatcher(
|
||||
{ logger: silentLogger },
|
||||
async (demand) => { calls.push(demand); },
|
||||
);
|
||||
dispatcher.fire(50);
|
||||
await dispatcher.drain();
|
||||
assert.deepEqual(calls, [50]);
|
||||
});
|
||||
|
||||
test('two fires back-to-back during in-flight — only the second runs after first settles', async () => {
|
||||
const calls = [];
|
||||
const gates = [deferred()];
|
||||
const dispatcher = new DemandDispatcher(
|
||||
{ logger: silentLogger },
|
||||
async (demand) => {
|
||||
calls.push(demand);
|
||||
await gates[0].promise;
|
||||
},
|
||||
);
|
||||
|
||||
dispatcher.fire(10);
|
||||
// first invocation is now in flight (after a microtask)
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
dispatcher.fire(20);
|
||||
// 20 should be pending, not yet run.
|
||||
assert.deepEqual(calls, [10]);
|
||||
gates[0].resolve();
|
||||
await dispatcher.drain();
|
||||
assert.deepEqual(calls, [10, 20]);
|
||||
});
|
||||
|
||||
test('three rapid fires — only first + last run; middle dropped', async () => {
|
||||
const calls = [];
|
||||
const gate = deferred();
|
||||
const dispatcher = new DemandDispatcher(
|
||||
{ logger: silentLogger },
|
||||
async (demand) => {
|
||||
calls.push(demand);
|
||||
if (calls.length === 1) await gate.promise;
|
||||
},
|
||||
);
|
||||
|
||||
dispatcher.fire(1);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
dispatcher.fire(2);
|
||||
dispatcher.fire(3); // overwrites the pending 2
|
||||
|
||||
assert.deepEqual(calls, [1]);
|
||||
gate.resolve();
|
||||
await dispatcher.drain();
|
||||
assert.deepEqual(calls, [1, 3]);
|
||||
});
|
||||
|
||||
test('drain() resolves only when idle', async () => {
|
||||
const gate = deferred();
|
||||
let runs = 0;
|
||||
const dispatcher = new DemandDispatcher(
|
||||
{ logger: silentLogger },
|
||||
async () => { runs++; await gate.promise; },
|
||||
);
|
||||
|
||||
// drain() on an idle gate resolves immediately.
|
||||
await dispatcher.drain();
|
||||
|
||||
dispatcher.fire('a');
|
||||
let drained = false;
|
||||
const drainPromise = dispatcher.drain().then(() => { drained = true; });
|
||||
// Let a few microtasks run — drain must NOT be resolved while in flight.
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
assert.equal(drained, false);
|
||||
assert.equal(runs, 1);
|
||||
gate.resolve();
|
||||
await drainPromise;
|
||||
assert.equal(drained, true);
|
||||
});
|
||||
|
||||
test('error in runFn does not deadlock; subsequent fire still works', async () => {
|
||||
const calls = [];
|
||||
const dispatcher = new DemandDispatcher(
|
||||
{ logger: silentLogger },
|
||||
async (demand) => {
|
||||
calls.push(demand);
|
||||
if (demand === 'boom') throw new Error('boom');
|
||||
},
|
||||
);
|
||||
dispatcher.fire('boom');
|
||||
await dispatcher.drain();
|
||||
dispatcher.fire('ok');
|
||||
await dispatcher.drain();
|
||||
assert.deepEqual(calls, ['boom', 'ok']);
|
||||
});
|
||||
|
||||
test('inFlight getter reports correctly', async () => {
|
||||
const gate = deferred();
|
||||
const dispatcher = new DemandDispatcher(
|
||||
{ logger: silentLogger },
|
||||
async () => { await gate.promise; },
|
||||
);
|
||||
assert.equal(dispatcher.inFlight, false);
|
||||
dispatcher.fire(1);
|
||||
// Microtask scheduling — gate flips to inFlight after one tick.
|
||||
await Promise.resolve();
|
||||
assert.equal(dispatcher.inFlight, true);
|
||||
gate.resolve();
|
||||
await dispatcher.drain();
|
||||
assert.equal(dispatcher.inFlight, false);
|
||||
});
|
||||
|
||||
test('runFn receives the ctx supplied at construction', async () => {
|
||||
const seen = [];
|
||||
const ctx = { logger: silentLogger, marker: 'mgc-A' };
|
||||
const dispatcher = new DemandDispatcher(
|
||||
ctx,
|
||||
async (demand, runCtx) => { seen.push({ demand, marker: runCtx.marker }); },
|
||||
);
|
||||
dispatcher.fire(42);
|
||||
await dispatcher.drain();
|
||||
assert.deepEqual(seen, [{ demand: 42, marker: 'mgc-A' }]);
|
||||
});
|
||||
132
test/basic/equalFlowDistribution.basic.test.js
Normal file
132
test/basic/equalFlowDistribution.basic.test.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// Unit tests for the pure distribution math extracted out of equalFlowControl.
|
||||
// Decoupling target: the algorithm should be testable without a full MGC.
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { computeEqualFlowDistribution } = require('../../src/control/strategies.js');
|
||||
|
||||
// Tiny helpers to make synthetic machines. The pure function still calls
|
||||
// filterOutUnavailableMachines, which reads machine.state.getCurrentState()
|
||||
// and machine.isValidActionForMode() — stub both so the algorithm sees the
|
||||
// machine as available. groupFlow/groupCalcPower are injected.
|
||||
function mkMachine(id, capability = { min: 0.01, max: 0.10, power: (flow) => flow * 1000 }, state = 'operational') {
|
||||
return {
|
||||
id,
|
||||
machine: {
|
||||
__testCapability: capability,
|
||||
state: { getCurrentState: () => state },
|
||||
isValidActionForMode: () => true,
|
||||
},
|
||||
};
|
||||
}
|
||||
const dummyLogger = { warn() {}, error() {}, debug() {}, info() {} };
|
||||
|
||||
// Default injected helpers: read from the synthetic machine's __testCapability.
|
||||
const groupFlow = (m) => ({
|
||||
currentFxyYMin: m.__testCapability.min,
|
||||
currentFxyYMax: m.__testCapability.max,
|
||||
});
|
||||
const groupCalcPower = (m, flow) => m.__testCapability.power(flow);
|
||||
|
||||
function basicArgs(overrides = {}) {
|
||||
const m = { a: mkMachine('a').machine, b: mkMachine('b').machine, c: mkMachine('c').machine };
|
||||
return {
|
||||
machines: m, Qd: 0.06,
|
||||
dynamicTotals: { flow: { min: 0.01, max: 0.30 } },
|
||||
activeTotals: { flow: { min: 0.03, max: 0.30 } },
|
||||
priorityList: ['a', 'b', 'c'],
|
||||
isMachineActive: () => true,
|
||||
groupFlow, groupCalcPower, logger: dummyLogger,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('default case: distributes Qd equally across active machines', () => {
|
||||
const r = computeEqualFlowDistribution(basicArgs({ Qd: 0.06 }));
|
||||
// 3 active pumps, demand 0.06 → 0.02 per pump.
|
||||
assert.equal(r.flowDistribution.length, 3);
|
||||
for (const entry of r.flowDistribution) {
|
||||
assert.ok(Math.abs(entry.flow - 0.02) < 1e-12, `entry.flow=${entry.flow}`);
|
||||
}
|
||||
assert.ok(Math.abs(r.totalFlow - 0.06) < 1e-12);
|
||||
// power(flow) = flow * 1000 in the test capability → 0.02 * 1000 = 20 W per pump.
|
||||
assert.ok(Math.abs(r.totalPower - 60) < 1e-9);
|
||||
});
|
||||
|
||||
test('Qd above active capacity: starts additional priority machines until covered', () => {
|
||||
// Only one machine "active" to start with; demand exceeds its envelope.
|
||||
// Algorithm should bring more priority machines online via the high-demand branch.
|
||||
const active = new Set(['a']);
|
||||
const args = basicArgs({
|
||||
Qd: 0.18, // above any single pump's max (0.10)
|
||||
activeTotals: { flow: { min: 0.01, max: 0.10 } },
|
||||
isMachineActive: (id) => active.has(id),
|
||||
});
|
||||
const r = computeEqualFlowDistribution(args);
|
||||
// The algorithm reduces Qd iteratively (Qd /= i) until it fits per-pump max.
|
||||
// We don't assert exact splits — only that flowDistribution is non-empty
|
||||
// and totalFlow is finite, since the legacy algorithm is preserved as-is.
|
||||
assert.ok(r.flowDistribution.length >= 1);
|
||||
assert.ok(Number.isFinite(r.totalFlow));
|
||||
assert.ok(Number.isFinite(r.totalPower));
|
||||
});
|
||||
|
||||
test('Qd below active min flow: routes excess machines to flow=0 and redistributes', () => {
|
||||
// demand below active min — algorithm shuts off lowest-priority machine(s)
|
||||
// and redistributes Qd across the remainder.
|
||||
const args = basicArgs({
|
||||
Qd: 0.015,
|
||||
dynamicTotals: { flow: { min: 0.01, max: 0.30 } },
|
||||
activeTotals: { flow: { min: 0.03, max: 0.30 } }, // active min > Qd
|
||||
});
|
||||
const r = computeEqualFlowDistribution(args);
|
||||
const offCount = r.flowDistribution.filter(e => e.flow === 0).length;
|
||||
assert.ok(offCount >= 1, `expected ≥1 machine to be shut off, got distribution: ${JSON.stringify(r.flowDistribution)}`);
|
||||
const totalServed = r.flowDistribution.filter(e => e.flow > 0).reduce((s, e) => s + e.flow, 0);
|
||||
assert.ok(Math.abs(totalServed - 0.015) < 1e-12, `served flow ${totalServed} should equal Qd 0.015`);
|
||||
});
|
||||
|
||||
test('totalCog is always 0 for equalFlow — preserves legacy contract', () => {
|
||||
// The historical algorithm sets totalCog = 0 in this strategy (BEP-Gravitation
|
||||
// is the only optimizer that produces a meaningful per-combination cog).
|
||||
// Pinned here so a future "improvement" doesn't silently introduce a fake value.
|
||||
const r = computeEqualFlowDistribution(basicArgs());
|
||||
assert.equal(r.totalCog, 0);
|
||||
});
|
||||
|
||||
test('isMachineActive is consulted for COUNT but not for SELECTION (legacy quirk)', () => {
|
||||
// Pins pre-existing behaviour of the default branch: it counts how many
|
||||
// machines are active (countActive) to decide how to split Qd, but then
|
||||
// iterates the FIRST countActive machines in priority order — which may
|
||||
// include inactive ones. So 2 of 3 active + Qd within range → first 2 in
|
||||
// priorityList both get flow, regardless of which are actually active.
|
||||
//
|
||||
// This is a latent bug that pre-dates the strategies decoupling refactor.
|
||||
// Documenting it here so a future cleanup is a deliberate change with a
|
||||
// failing-then-passing test, not a silent semantic shift.
|
||||
const active = new Set(['a', 'c']);
|
||||
const r = computeEqualFlowDistribution(basicArgs({
|
||||
Qd: 0.06,
|
||||
isMachineActive: (id) => active.has(id),
|
||||
}));
|
||||
// Today: machinesInPriorityOrder[0]='a', [1]='b' → 'a' and 'b' both get 0.03.
|
||||
// 'c' (active but third in priority order) gets nothing.
|
||||
const aFlow = r.flowDistribution.find(e => e.machineId === 'a')?.flow;
|
||||
const bFlow = r.flowDistribution.find(e => e.machineId === 'b')?.flow;
|
||||
const cFlow = r.flowDistribution.find(e => e.machineId === 'c')?.flow;
|
||||
assert.equal(aFlow, 0.03, 'a (priority 0, active)');
|
||||
assert.equal(bFlow, 0.03, 'b (priority 1, INACTIVE — receives flow anyway, bug)');
|
||||
assert.equal(cFlow, undefined, 'c (priority 2, active — does NOT receive flow, bug)');
|
||||
});
|
||||
|
||||
test('priorityList controls iteration order', () => {
|
||||
// The order in flowDistribution should match priorityList — i.e., machine 'c'
|
||||
// appears before machine 'a' when priorityList = ['c', 'b', 'a'].
|
||||
const r = computeEqualFlowDistribution(basicArgs({
|
||||
priorityList: ['c', 'b', 'a'],
|
||||
}));
|
||||
assert.equal(r.flowDistribution[0].machineId, 'c');
|
||||
});
|
||||
66
test/basic/groupCurves.basic.test.js
Normal file
66
test/basic/groupCurves.basic.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { groupFlow, groupPower, groupNCog, groupCalcPower } = require('../../src/groupOps/groupCurves');
|
||||
|
||||
function predictView(min, max, current = (min + max) / 2) {
|
||||
return {
|
||||
currentF: current,
|
||||
currentFxyYMin: min,
|
||||
currentFxyYMax: max,
|
||||
};
|
||||
}
|
||||
|
||||
test('groupFlow returns the same shape as the original _groupFlow (groupPredictFlow preferred)', () => {
|
||||
const machine = {
|
||||
predictFlow: predictView(0, 1, 0.5),
|
||||
groupPredictFlow: predictView(0.1, 0.9, 0.4),
|
||||
};
|
||||
const v = groupFlow(machine);
|
||||
assert.equal(v, machine.groupPredictFlow);
|
||||
assert.equal(v.currentFxyYMin, 0.1);
|
||||
assert.equal(v.currentFxyYMax, 0.9);
|
||||
assert.equal(v.currentF, 0.4);
|
||||
});
|
||||
|
||||
test('groupFlow falls back to predictFlow when groupPredictFlow is absent', () => {
|
||||
const machine = { predictFlow: predictView(0, 1) };
|
||||
assert.equal(groupFlow(machine), machine.predictFlow);
|
||||
});
|
||||
|
||||
test('groupPower returns groupPredictPower when present, else predictPower', () => {
|
||||
const m1 = { predictPower: predictView(0, 100), groupPredictPower: predictView(10, 90) };
|
||||
assert.equal(groupPower(m1), m1.groupPredictPower);
|
||||
|
||||
const m2 = { predictPower: predictView(0, 100) };
|
||||
assert.equal(groupPower(m2), m2.predictPower);
|
||||
});
|
||||
|
||||
test('groupNCog returns the group value when groupPredictFlow is present', () => {
|
||||
const m = { groupPredictFlow: predictView(0, 1), groupNCog: 0.42, NCog: 0.99, predictFlow: predictView(0, 1) };
|
||||
assert.equal(groupNCog(m), 0.42);
|
||||
});
|
||||
|
||||
test('groupNCog falls back to NCog when no groupPredictFlow', () => {
|
||||
const m = { predictFlow: predictView(0, 1), NCog: 0.7 };
|
||||
assert.equal(groupNCog(m), 0.7);
|
||||
});
|
||||
|
||||
test('groupNCog defaults to 0 when neither is defined', () => {
|
||||
const m = { predictFlow: predictView(0, 1) };
|
||||
assert.equal(groupNCog(m), 0);
|
||||
});
|
||||
|
||||
test('groupCalcPower prefers machine.groupCalcPower', () => {
|
||||
let lastFlow = null;
|
||||
const m = {
|
||||
groupCalcPower(flow) { lastFlow = flow; return flow * 2; },
|
||||
inputFlowCalcPower(flow) { return flow * 999; },
|
||||
};
|
||||
assert.equal(groupCalcPower(m, 0.3), 0.6);
|
||||
assert.equal(lastFlow, 0.3);
|
||||
});
|
||||
|
||||
test('groupCalcPower falls back to inputFlowCalcPower when groupCalcPower missing', () => {
|
||||
const m = { inputFlowCalcPower(flow) { return flow + 1; } };
|
||||
assert.equal(groupCalcPower(m, 5), 6);
|
||||
});
|
||||
90
test/basic/groupEfficiency.basic.test.js
Normal file
90
test/basic/groupEfficiency.basic.test.js
Normal file
@@ -0,0 +1,90 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { interpolation } = require('generalFunctions');
|
||||
const GroupEfficiency = require('../../src/efficiency/groupEfficiency.js');
|
||||
|
||||
function makeMachines(cogs) {
|
||||
const out = {};
|
||||
cogs.forEach((cog, i) => { out[`m${i}`] = { cog }; });
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeGE(extra = {}) {
|
||||
return new GroupEfficiency({
|
||||
interpolation: new interpolation(),
|
||||
logger: { warn() {}, error() {}, debug() {}, info() {} },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
test('calcGroupEfficiency aggregates across 3 machines', () => {
|
||||
const ge = makeGE();
|
||||
const machines = makeMachines([0.9, 0.8, 0.7]);
|
||||
const { maxEfficiency, lowestEfficiency } = ge.calcGroupEfficiency(machines);
|
||||
assert.equal(lowestEfficiency, 0.7);
|
||||
// maxEfficiency in the original code is actually the MEAN cog.
|
||||
assert.ok(Math.abs(maxEfficiency - 0.8) < 1e-12);
|
||||
});
|
||||
|
||||
test('calcDistanceFromPeak returns |a - b|', () => {
|
||||
const ge = makeGE();
|
||||
assert.ok(Math.abs(ge.calcDistanceFromPeak(0.85, 0.92) - 0.07) < 1e-12);
|
||||
assert.ok(Math.abs(ge.calcDistanceFromPeak(0.92, 0.85) - 0.07) < 1e-12);
|
||||
});
|
||||
|
||||
test('calcRelativeDistanceFromPeak maps current onto [0..1]', () => {
|
||||
const ge = makeGE();
|
||||
// current=0.85, max=0.92, min=0.7 → maps 0.85 in [0.92..0.7] onto [0..1].
|
||||
// interpolate_lin_single_point treats first range as input domain:
|
||||
// 0.85 → ((0.85 - 0.92) / (0.7 - 0.92)) * (1 - 0) + 0 = 0.07/0.22 ≈ 0.3181818...
|
||||
const v = ge.calcRelativeDistanceFromPeak(0.85, 0.92, 0.7);
|
||||
const expected = (0.85 - 0.92) / (0.7 - 0.92);
|
||||
assert.ok(Math.abs(v - expected) < 1e-9, `got ${v} expected ${expected}`);
|
||||
});
|
||||
|
||||
test('calcDistanceBEP returns both abs + rel', () => {
|
||||
const ge = makeGE();
|
||||
const { absDistFromPeak, relDistFromPeak } = ge.calcDistanceBEP(0.85, 0.92, 0.7);
|
||||
assert.ok(Math.abs(absDistFromPeak - 0.07) < 1e-12);
|
||||
const expectedRel = (0.85 - 0.92) / (0.7 - 0.92);
|
||||
assert.ok(Math.abs(relDistFromPeak - expectedRel) < 1e-9);
|
||||
});
|
||||
|
||||
test('calcRelativeDistanceFromPeak returns undefined when max === min (degenerate)', () => {
|
||||
// For homogeneous pump groups (all cogs equal), the [max..min] band
|
||||
// collapses and the metric is mathematically undefined. Return undefined
|
||||
// so the dashboard displays "—" instead of a misleading 0% / 100%.
|
||||
const ge = makeGE();
|
||||
assert.equal(ge.calcRelativeDistanceFromPeak(0.85, 0.8, 0.8), undefined);
|
||||
});
|
||||
|
||||
test('calcRelativeDistanceFromPeak returns undefined when max ≈ min within epsilon', () => {
|
||||
// Float noise from identical pumps: max-min might be 1e-12 rather than 0.
|
||||
// Must still report undefined — the interpolation extrapolates wildly here.
|
||||
const ge = makeGE();
|
||||
assert.equal(ge.calcRelativeDistanceFromPeak(0.85, 0.211264, 0.211263999), undefined);
|
||||
});
|
||||
|
||||
test('calcRelativeDistanceFromPeak returns undefined when current is null', () => {
|
||||
const ge = makeGE();
|
||||
assert.equal(ge.calcRelativeDistanceFromPeak(null, 0.92, 0.7), undefined);
|
||||
});
|
||||
|
||||
test('calcDistanceBEP propagates undefined relDist for degenerate input', () => {
|
||||
// Regression: if currentEff is finite, absDist is still computed (it's
|
||||
// just |current - peak|), but relDist must be undefined for degenerate.
|
||||
const ge = makeGE();
|
||||
const { absDistFromPeak, relDistFromPeak } = ge.calcDistanceBEP(0.206, 0.211, 0.211);
|
||||
assert.ok(Math.abs(absDistFromPeak - 0.005) < 1e-9);
|
||||
assert.equal(relDistFromPeak, undefined);
|
||||
});
|
||||
|
||||
test('calcGroupEfficiency handles a single machine', () => {
|
||||
const ge = makeGE();
|
||||
const { maxEfficiency, lowestEfficiency } = ge.calcGroupEfficiency(makeMachines([0.77]));
|
||||
assert.equal(maxEfficiency, 0.77);
|
||||
assert.equal(lowestEfficiency, 0.77);
|
||||
});
|
||||
131
test/basic/groupOperatingPoint.basic.test.js
Normal file
131
test/basic/groupOperatingPoint.basic.test.js
Normal file
@@ -0,0 +1,131 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { MeasurementContainer, POSITIONS } = require('generalFunctions');
|
||||
const GroupOperatingPoint = require('../../src/groupOps/groupOperatingPoint');
|
||||
|
||||
const unitPolicy = {
|
||||
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
|
||||
output: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
|
||||
};
|
||||
|
||||
const silentLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
||||
|
||||
function makeContainer() {
|
||||
return new MeasurementContainer({
|
||||
defaultUnits: unitPolicy.output,
|
||||
preferredUnits: unitPolicy.output,
|
||||
canonicalUnits: unitPolicy.canonical,
|
||||
storeCanonical: true,
|
||||
autoConvert: true,
|
||||
});
|
||||
}
|
||||
|
||||
function makeMachine(id, pressures = {}) {
|
||||
// pressures: { down?: Pa, up?: Pa } — written into a real container
|
||||
const m = {
|
||||
config: { general: { id } },
|
||||
measurements: makeContainer(),
|
||||
setGroupOperatingPointCalls: [],
|
||||
setGroupOperatingPoint(down, up) {
|
||||
this.setGroupOperatingPointCalls.push({ down, up });
|
||||
},
|
||||
};
|
||||
const now = Date.now();
|
||||
if (pressures.down != null) {
|
||||
m.measurements.type('pressure').variant('measured').position(POSITIONS.DOWNSTREAM).value(pressures.down, now, 'Pa');
|
||||
}
|
||||
if (pressures.up != null) {
|
||||
m.measurements.type('pressure').variant('measured').position(POSITIONS.UPSTREAM).value(pressures.up, now, 'Pa');
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
test('readChild returns value in requested unit when present', () => {
|
||||
const machines = {};
|
||||
const m = makeMachine('m1', { down: 150000 });
|
||||
machines[m.config.general.id] = m;
|
||||
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines, unitPolicy, logger: silentLogger });
|
||||
|
||||
const v = gop.readChild(m, 'pressure', 'measured', POSITIONS.DOWNSTREAM, 'Pa');
|
||||
assert.equal(v, 150000);
|
||||
});
|
||||
|
||||
test('readChild returns null when measurement missing', () => {
|
||||
const m = makeMachine('m1');
|
||||
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines: { m1: m }, unitPolicy, logger: silentLogger });
|
||||
|
||||
const v = gop.readChild(m, 'pressure', 'measured', POSITIONS.UPSTREAM, 'Pa');
|
||||
assert.equal(v, null);
|
||||
});
|
||||
|
||||
test("writeOwn writes to the group's measurements container", () => {
|
||||
const ownC = makeContainer();
|
||||
const gop = new GroupOperatingPoint({ measurements: ownC, machines: {}, unitPolicy, logger: silentLogger });
|
||||
|
||||
gop.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, 0.1, 'm3/s');
|
||||
const v = ownC.type('flow').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue('m3/s');
|
||||
assert.equal(v, 0.1);
|
||||
});
|
||||
|
||||
test('writeOwn skips non-finite values', () => {
|
||||
const ownC = makeContainer();
|
||||
const gop = new GroupOperatingPoint({ measurements: ownC, machines: {}, unitPolicy, logger: silentLogger });
|
||||
|
||||
gop.writeOwn('flow', 'predicted', POSITIONS.AT_EQUIPMENT, NaN, 'm3/s');
|
||||
const v = ownC.type('flow').variant('predicted').position(POSITIONS.AT_EQUIPMENT).getCurrentValue('m3/s');
|
||||
assert.equal(v, null);
|
||||
});
|
||||
|
||||
test('equalize() pushes the worst-case header onto each machine when 3 pressures differ', () => {
|
||||
// No group header → max child downstream, min positive child upstream.
|
||||
// max(120k, 140k, 100k) = 140000, min(80k, 90k, 70k) = 70000.
|
||||
const machines = {
|
||||
a: makeMachine('a', { down: 120000, up: 80000 }),
|
||||
b: makeMachine('b', { down: 140000, up: 90000 }),
|
||||
c: makeMachine('c', { down: 100000, up: 70000 }),
|
||||
};
|
||||
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines, unitPolicy, logger: silentLogger });
|
||||
|
||||
gop.equalize();
|
||||
|
||||
for (const id of ['a', 'b', 'c']) {
|
||||
const last = machines[id].setGroupOperatingPointCalls.at(-1);
|
||||
assert.ok(last, `machine ${id} should have been called`);
|
||||
assert.equal(last.down, 140000);
|
||||
assert.equal(last.up, 70000);
|
||||
}
|
||||
});
|
||||
|
||||
test('equalize() is a no-op when there is no pressure data', () => {
|
||||
const machines = { a: makeMachine('a'), b: makeMachine('b') };
|
||||
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines, unitPolicy, logger: silentLogger });
|
||||
|
||||
gop.equalize();
|
||||
|
||||
assert.equal(machines.a.setGroupOperatingPointCalls.length, 0);
|
||||
assert.equal(machines.b.setGroupOperatingPointCalls.length, 0);
|
||||
});
|
||||
|
||||
test('equalize() is a no-op when machines map is empty', () => {
|
||||
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines: {}, unitPolicy, logger: silentLogger });
|
||||
assert.doesNotThrow(() => gop.equalize());
|
||||
});
|
||||
|
||||
test('equalize() falls back to direct fDimension when setGroupOperatingPoint is missing', () => {
|
||||
const m = {
|
||||
config: { general: { id: 'old' } },
|
||||
measurements: makeContainer(),
|
||||
predictFlow: { fDimension: 0 },
|
||||
predictPower: { fDimension: 0 },
|
||||
predictCtrl: { fDimension: 0 },
|
||||
};
|
||||
m.measurements.type('pressure').variant('measured').position(POSITIONS.DOWNSTREAM).value(200000, Date.now(), 'Pa');
|
||||
m.measurements.type('pressure').variant('measured').position(POSITIONS.UPSTREAM).value(100000, Date.now(), 'Pa');
|
||||
|
||||
const gop = new GroupOperatingPoint({ measurements: makeContainer(), machines: { old: m }, unitPolicy, logger: silentLogger });
|
||||
gop.equalize();
|
||||
|
||||
assert.equal(m.predictFlow.fDimension, 100000);
|
||||
assert.equal(m.predictPower.fDimension, 100000);
|
||||
assert.equal(m.predictCtrl.fDimension, 100000);
|
||||
});
|
||||
142
test/basic/moveTrajectory.basic.test.js
Normal file
142
test/basic/moveTrajectory.basic.test.js
Normal file
@@ -0,0 +1,142 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MoveTrajectory = require('../../src/movement/moveTrajectory');
|
||||
|
||||
// Reusable profile builder — keeps each test focused on the field(s) it cares
|
||||
// about. Anything not overridden is in a sane "operational at 0%" baseline.
|
||||
function makeProfile(over = {}) {
|
||||
return Object.assign({
|
||||
id: 'P1',
|
||||
state: 'operational',
|
||||
position: 0,
|
||||
minPosition: 0,
|
||||
maxPosition: 100,
|
||||
velocityPctPerS: 2,
|
||||
timings: { startingS: 10, warmingupS: 20, stoppingS: 5, coolingdownS: 15 },
|
||||
remainingTransitionS: null,
|
||||
flowAt: () => null,
|
||||
}, over);
|
||||
}
|
||||
|
||||
// TC1 — idle, full startup ladder + ramp from min.
|
||||
test('TC1 idle → target = startingS + warmingupS + (target−min)/velocity', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'idle' }), { targetPosition: 60 });
|
||||
assert.equal(t.etaToTargetS(), 10 + 20 + 60 / 2); // 60s
|
||||
});
|
||||
|
||||
// TC2 — operational up.
|
||||
test('TC2 operational up = |target−position|/velocity', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 40 }), { targetPosition: 60 });
|
||||
assert.equal(t.etaToTargetS(), 10);
|
||||
});
|
||||
|
||||
// TC3 — operational down. ETA is positive.
|
||||
test('TC3 operational down = |target−position|/velocity', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 80 }), { targetPosition: 30 });
|
||||
assert.equal(t.etaToTargetS(), 25);
|
||||
});
|
||||
|
||||
// TC4 — no-op.
|
||||
test('TC4 operational, target == position → 0s', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 50 }), { targetPosition: 50 });
|
||||
assert.equal(t.etaToTargetS(), 0);
|
||||
});
|
||||
|
||||
// TC5 — accelerating post-abort residue, same formula as operational.
|
||||
test('TC5 accelerating residue = operational formula', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'accelerating', position: 35 }), { targetPosition: 60 });
|
||||
assert.equal(t.etaToTargetS(), 12.5);
|
||||
});
|
||||
|
||||
// TC6 — decelerating residue.
|
||||
test('TC6 decelerating residue = operational formula', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'decelerating', position: 70 }), { targetPosition: 40 });
|
||||
assert.equal(t.etaToTargetS(), 15);
|
||||
});
|
||||
|
||||
// TC7 — warmingup, remaining time from stateManager.
|
||||
test('TC7 warmingup = remainingWarmupS + (target−min)/velocity', () => {
|
||||
const t = new MoveTrajectory(makeProfile({
|
||||
state: 'warmingup',
|
||||
position: 0,
|
||||
remainingTransitionS: 12,
|
||||
}), { targetPosition: 50 });
|
||||
assert.equal(t.etaToTargetS(), 12 + 50 / 2); // 37s
|
||||
});
|
||||
|
||||
// TC7b — warmingup but no remaining-time observation: falls back to full
|
||||
// configured warmup (worst-case). Kept for resilience when the state machine
|
||||
// pre-dates the getter.
|
||||
test('TC7b warmingup fallback to full warmingupS when no remaining provided', () => {
|
||||
const t = new MoveTrajectory(makeProfile({
|
||||
state: 'warmingup',
|
||||
position: 0,
|
||||
remainingTransitionS: null,
|
||||
}), { targetPosition: 50 });
|
||||
assert.equal(t.etaToTargetS(), 20 + 50 / 2); // 45s
|
||||
});
|
||||
|
||||
// TC8 — starting: remaining + full warmup + ramp.
|
||||
test('TC8 starting = remainingStartingS + warmingupS + (target−min)/velocity', () => {
|
||||
const t = new MoveTrajectory(makeProfile({
|
||||
state: 'starting',
|
||||
position: 0,
|
||||
remainingTransitionS: 8,
|
||||
}), { targetPosition: 50 });
|
||||
assert.equal(t.etaToTargetS(), 8 + 20 + 50 / 2); // 53s
|
||||
});
|
||||
|
||||
// TC8b — boundary: remaining hits 0 just before the setTimeout fires.
|
||||
test('TC8b starting with remainingTransitionS=0 still yields positive ETA', () => {
|
||||
const t = new MoveTrajectory(makeProfile({
|
||||
state: 'starting',
|
||||
position: 0,
|
||||
remainingTransitionS: 0,
|
||||
}), { targetPosition: 50 });
|
||||
assert.equal(t.etaToTargetS(), 0 + 20 + 50 / 2); // 45s
|
||||
});
|
||||
|
||||
// TC9 — shutdown ladder excluded: returns null so scheduler skips it.
|
||||
test('TC9a stopping → null', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'stopping', position: 30 }), { targetPosition: 0 });
|
||||
assert.equal(t.etaToTargetS(), null);
|
||||
});
|
||||
test('TC9b coolingdown → null', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'coolingdown', position: 0 }), { targetPosition: 0 });
|
||||
assert.equal(t.etaToTargetS(), null);
|
||||
});
|
||||
|
||||
// TC10 — target above max clamps; ETA uses clamped value.
|
||||
test('TC10 target above maxPosition clamps to max', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, maxPosition: 100 }), { targetPosition: 120 });
|
||||
assert.equal(t.targetPosition, 100);
|
||||
assert.equal(t.etaToTargetS(), 50);
|
||||
});
|
||||
|
||||
// TC11 — target below min clamps; ETA zero when already at min.
|
||||
test('TC11 target below min clamps to min; ETA = 0 when at min', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, minPosition: 0 }), { targetPosition: -5 });
|
||||
assert.equal(t.targetPosition, 0);
|
||||
assert.equal(t.etaToTargetS(), 0);
|
||||
});
|
||||
|
||||
// TC12 — zero velocity yields Infinity, not NaN or crash.
|
||||
test('TC12 zero velocity → Infinity', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'operational', position: 0, velocityPctPerS: 0 }), { targetPosition: 50 });
|
||||
assert.equal(t.etaToTargetS(), Infinity);
|
||||
});
|
||||
|
||||
// TC13 — non-finite target throws at construction (totality of etaToTargetS).
|
||||
test('TC13 non-finite target throws at construction', () => {
|
||||
assert.throws(() => new MoveTrajectory(makeProfile(), { targetPosition: NaN }), TypeError);
|
||||
assert.throws(() => new MoveTrajectory(makeProfile(), { targetPosition: undefined }), TypeError);
|
||||
});
|
||||
|
||||
// Extra: minPosition above 0 is honoured in ramp distance for startup cases.
|
||||
test('TC1b idle with minPosition=10 → ramp from 10, not 0', () => {
|
||||
const t = new MoveTrajectory(makeProfile({ state: 'idle', minPosition: 10 }), { targetPosition: 60 });
|
||||
assert.equal(t.etaToTargetS(), 10 + 20 + (60 - 10) / 2); // 55s
|
||||
});
|
||||
86
test/basic/movement-gate.basic.test.js
Normal file
86
test/basic/movement-gate.basic.test.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// Unit tests for the MGC movement state + rendezvous-lock helpers
|
||||
// (getMovementState / _isEmergencyDemand / _pressureEmergency). Exercised via
|
||||
// prototype.call with a
|
||||
// minimal fake `this` so no Node-RED runtime or full MachineGroup boot is
|
||||
// needed. See project rule .claude/rules/testing.md (basic = pure logic).
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
|
||||
function machine(state, { delayedMove = null, moveTimeLeft = 0 } = {}) {
|
||||
return { state: { getCurrentState: () => state, delayedMove, getMoveTimeLeft: () => moveTimeLeft } };
|
||||
}
|
||||
function movementStateOf(machines, pending = 0) {
|
||||
return MachineGroup.prototype.getMovementState.call({
|
||||
machines,
|
||||
movementExecutor: { pending: () => pending },
|
||||
});
|
||||
}
|
||||
|
||||
test('movementState: ready when no machines are registered', () => {
|
||||
assert.equal(movementStateOf({}), 'ready');
|
||||
});
|
||||
test('movementState: ready when every machine is settled and nothing is pending', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational'), b: machine('idle') }), 'ready');
|
||||
});
|
||||
test('movementState: working while a machine is mid-ramp', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational'), b: machine('accelerating') }), 'working');
|
||||
});
|
||||
test('movementState: working during a start/stop sequence step', () => {
|
||||
assert.equal(movementStateOf({ a: machine('warmingup') }), 'working');
|
||||
});
|
||||
test('movementState: working when a setpoint is queued (delayedMove)', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational', { delayedMove: 50 }) }), 'working');
|
||||
});
|
||||
test('movementState: working while move time remains', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational', { moveTimeLeft: 1.2 }) }), 'working');
|
||||
});
|
||||
test('movementState: working when the executor still has scheduled commands', () => {
|
||||
assert.equal(movementStateOf({ a: machine('operational') }, 2), 'working');
|
||||
});
|
||||
|
||||
// Rendezvous lock: only an EMERGENCY pre-empts an in-flight rendezvous; every
|
||||
// ordinary setpoint (any size, mode/priority change included) defers.
|
||||
function emergency(demandQ, { last = 10, emergency = false } = {}) {
|
||||
return MachineGroup.prototype._isEmergencyDemand.call({
|
||||
_lastDemand: last == null ? null : { canonical: last },
|
||||
}, demandQ, { emergency });
|
||||
}
|
||||
|
||||
test('emergency: a stop (≤0) always pre-empts', () => {
|
||||
assert.equal(emergency(0), true);
|
||||
assert.equal(emergency(-5), true);
|
||||
});
|
||||
test('emergency: the first demand (no prior) dispatches immediately', () => {
|
||||
assert.equal(emergency(50, { last: null }), true);
|
||||
});
|
||||
test('emergency: an explicit emergency flag pre-empts', () => {
|
||||
assert.equal(emergency(60, { last: 10, emergency: true }), true);
|
||||
});
|
||||
test('emergency: an ordinary same-mode step defers (large or small)', () => {
|
||||
assert.equal(emergency(12, { last: 10 }), false); // small nudge — defer
|
||||
assert.equal(emergency(60, { last: 10 }), false); // large step — also defers now
|
||||
});
|
||||
|
||||
// Pressure-excursion detector — inert until planner.emergencyPressurePa is set.
|
||||
function pressureEmergency({ thr, headerPa } = {}) {
|
||||
return MachineGroup.prototype._pressureEmergency.call({
|
||||
config: { planner: thr == null ? {} : { emergencyPressurePa: thr } },
|
||||
operatingPoint: { headerDiffPa: headerPa },
|
||||
});
|
||||
}
|
||||
|
||||
test('pressureEmergency: inert (false) when no threshold is configured', () => {
|
||||
assert.equal(pressureEmergency({ headerPa: 999999 }), false);
|
||||
});
|
||||
test('pressureEmergency: false when header is below the configured threshold', () => {
|
||||
assert.equal(pressureEmergency({ thr: 200000, headerPa: 150000 }), false);
|
||||
});
|
||||
test('pressureEmergency: true when header breaches the configured threshold', () => {
|
||||
assert.equal(pressureEmergency({ thr: 200000, headerPa: 210000 }), true);
|
||||
});
|
||||
test('pressureEmergency: false when header pressure is unknown', () => {
|
||||
assert.equal(pressureEmergency({ thr: 200000, headerPa: undefined }), false);
|
||||
});
|
||||
136
test/basic/movementExecutor.basic.test.js
Normal file
136
test/basic/movementExecutor.basic.test.js
Normal file
@@ -0,0 +1,136 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MovementExecutor = require('../../src/movement/movementExecutor');
|
||||
|
||||
function mkSchedule(commands, tStarS = 0, tickS = 1) {
|
||||
return { tStarS, tickS, commands };
|
||||
}
|
||||
|
||||
const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
||||
|
||||
test('executor: throws if fireCommand callback missing', () => {
|
||||
assert.throws(() => new MovementExecutor({}), TypeError);
|
||||
});
|
||||
|
||||
test('executor: fires commands whose fireAtTickN <= cursor', async () => {
|
||||
const fired = [];
|
||||
const ex = new MovementExecutor({
|
||||
fireCommand: (c) => fired.push(c),
|
||||
logger: noopLogger,
|
||||
});
|
||||
ex.replan(mkSchedule([
|
||||
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
||||
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 2, eta: 2 },
|
||||
{ machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 5, eta: 5 },
|
||||
]));
|
||||
let firedThisTick = await ex.tick();
|
||||
assert.equal(firedThisTick.length, 1);
|
||||
assert.equal(firedThisTick[0].machineId, 'A');
|
||||
firedThisTick = await ex.tick();
|
||||
assert.equal(firedThisTick.length, 0);
|
||||
firedThisTick = await ex.tick();
|
||||
assert.equal(firedThisTick.length, 1);
|
||||
assert.equal(firedThisTick[0].machineId, 'B');
|
||||
await ex.tick(); await ex.tick();
|
||||
firedThisTick = await ex.tick();
|
||||
assert.equal(firedThisTick.length, 1);
|
||||
assert.equal(firedThisTick[0].machineId, 'C');
|
||||
|
||||
assert.deepEqual(fired.map((c) => c.machineId), ['A', 'B', 'C']);
|
||||
assert.equal(ex.pending(), 0);
|
||||
});
|
||||
|
||||
test('executor: replan drops unfired commands and resets cursor', async () => {
|
||||
const fired = [];
|
||||
const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger });
|
||||
|
||||
ex.replan(mkSchedule([
|
||||
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
||||
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 10, eta: 10 },
|
||||
]));
|
||||
await ex.tick(); // A fires
|
||||
assert.deepEqual(fired, ['A']);
|
||||
assert.equal(ex.pending(), 1);
|
||||
|
||||
ex.replan(mkSchedule([
|
||||
{ machineId: 'X', action: 'flowmovement', flow: 80, fireAtTickN: 0, eta: 0 },
|
||||
{ machineId: 'Y', action: 'flowmovement', flow: 20, fireAtTickN: 3, eta: 3 },
|
||||
]));
|
||||
assert.equal(ex.cursor(), 0, 'cursor reset on replan');
|
||||
await ex.tick(); // X fires
|
||||
assert.deepEqual(fired, ['A', 'X']);
|
||||
await ex.tick(); await ex.tick(); await ex.tick();
|
||||
assert.ok(!fired.includes('B'), 'old B move was dropped by replan');
|
||||
assert.ok(fired.includes('Y'), 'new Y move fired after delay');
|
||||
});
|
||||
|
||||
test('executor: fires only once per command even across many ticks', async () => {
|
||||
const fired = [];
|
||||
const ex = new MovementExecutor({ fireCommand: (c) => fired.push(c.machineId), logger: noopLogger });
|
||||
ex.replan(mkSchedule([
|
||||
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
||||
]));
|
||||
for (let i = 0; i < 5; i++) await ex.tick();
|
||||
assert.deepEqual(fired, ['A']);
|
||||
});
|
||||
|
||||
test('executor: catches fireCommand errors and continues', async () => {
|
||||
const fired = [];
|
||||
const ex = new MovementExecutor({
|
||||
fireCommand: (c) => {
|
||||
if (c.machineId === 'B') throw new Error('boom');
|
||||
fired.push(c.machineId);
|
||||
},
|
||||
logger: noopLogger,
|
||||
});
|
||||
ex.replan(mkSchedule([
|
||||
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
||||
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 0, eta: 0 },
|
||||
{ machineId: 'C', action: 'flowmovement', flow: 30, fireAtTickN: 0, eta: 0 },
|
||||
]));
|
||||
await ex.tick();
|
||||
// B's error must not block A or C.
|
||||
assert.deepEqual(fired, ['A', 'C']);
|
||||
});
|
||||
|
||||
test('executor: empty / null schedule is safe to tick', async () => {
|
||||
const ex = new MovementExecutor({ fireCommand: () => {}, logger: noopLogger });
|
||||
assert.deepEqual(await ex.tick(), []);
|
||||
ex.replan({ commands: [] });
|
||||
assert.deepEqual(await ex.tick(), []);
|
||||
});
|
||||
|
||||
test('executor: tick fires commands synchronously and does NOT await their promises', async () => {
|
||||
// Contract: tick() returns as soon as every due fireCommand has been
|
||||
// invoked. It does NOT wait for the returned promises to resolve.
|
||||
// This matters because a flowmovement-after-startup resolves only
|
||||
// after the pump's entire ramp completes — awaiting it would freeze
|
||||
// the executor's wall-clock progression and drag every delayed
|
||||
// command in the schedule forward by that duration.
|
||||
const order = [];
|
||||
let resolveFire;
|
||||
const firePromise = new Promise((r) => { resolveFire = r; });
|
||||
const ex = new MovementExecutor({
|
||||
fireCommand: (c) => {
|
||||
order.push(`fire-start-${c.machineId}`);
|
||||
return firePromise.then(() => { order.push(`fire-end-${c.machineId}`); });
|
||||
},
|
||||
logger: noopLogger,
|
||||
});
|
||||
ex.replan(mkSchedule([
|
||||
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 0 },
|
||||
]));
|
||||
const tickPromise = ex.tick().then(() => order.push('tick-resolved'));
|
||||
// Wait one microtask cycle: tick should already have resolved even
|
||||
// though fire is still pending.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
assert.deepEqual(order, ['fire-start-A', 'tick-resolved'],
|
||||
'tick must resolve immediately after invoking fireCommand — not wait for its promise');
|
||||
resolveFire();
|
||||
await tickPromise;
|
||||
// The fire's tail runs in the background and lands after tick resolved.
|
||||
assert.deepEqual(order, ['fire-start-A', 'tick-resolved', 'fire-end-A']);
|
||||
});
|
||||
302
test/basic/movementScheduler.basic.test.js
Normal file
302
test/basic/movementScheduler.basic.test.js
Normal file
@@ -0,0 +1,302 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { plan } = require('../../src/movement/movementScheduler');
|
||||
|
||||
// Profile builder — same shape as buildProfile output. positionForFlow
|
||||
// approximates the inverse curve as a linear mapping over [min,max] for
|
||||
// flow ∈ [0, maxFlow], which is enough to test scheduler logic without
|
||||
// dragging real curve math in.
|
||||
function makeProfile(over = {}) {
|
||||
const defaults = {
|
||||
id: 'A',
|
||||
state: 'operational',
|
||||
position: 0,
|
||||
minPosition: 0,
|
||||
maxPosition: 100,
|
||||
velocityPctPerS: 2,
|
||||
timings: { startingS: 10, warmingupS: 20, stoppingS: 5, coolingdownS: 15 },
|
||||
remainingTransitionS: null,
|
||||
maxFlow: 100, // synthetic — for the test mapping below
|
||||
};
|
||||
const p = Object.assign(defaults, over);
|
||||
// Linear position-for-flow over [min,max].
|
||||
p.positionForFlow = (flow) => {
|
||||
if (!Number.isFinite(flow) || flow <= 0) return p.minPosition;
|
||||
return p.minPosition + (flow / p.maxFlow) * (p.maxPosition - p.minPosition);
|
||||
};
|
||||
// flowAt — inverse of the above.
|
||||
p.flowAt = (pos /*, pressure */) => {
|
||||
if (!Number.isFinite(pos)) return 0;
|
||||
if (p.maxPosition === p.minPosition) return 0;
|
||||
return ((pos - p.minPosition) / (p.maxPosition - p.minPosition)) * p.maxFlow;
|
||||
};
|
||||
return p;
|
||||
}
|
||||
|
||||
// Tick rounding helper — scheduler uses Math.round(eta/tickS).
|
||||
function tickRound(s, tickS = 1) { return Math.round(s / tickS); }
|
||||
|
||||
test('plan: idle → start a single pump (no other pumps online)', () => {
|
||||
const profiles = [makeProfile({ id: 'A', state: 'idle', position: 0 })];
|
||||
const combination = [{ machineId: 'A', flow: 60 }];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
// Two commands: execsequence(startup) + flowmovement(60). Both at tick 0.
|
||||
assert.equal(out.commands.length, 2);
|
||||
assert.equal(out.commands[0].action, 'execsequence');
|
||||
assert.equal(out.commands[0].sequence, 'startup');
|
||||
assert.equal(out.commands[0].fireAtTickN, 0);
|
||||
assert.equal(out.commands[1].action, 'flowmovement');
|
||||
assert.equal(out.commands[1].flow, 60);
|
||||
assert.equal(out.commands[1].fireAtTickN, 0);
|
||||
// tStar = full startup ladder + ramp from 0 to position-for-60 (= 60%).
|
||||
// = 10 + 20 + 60/2 = 60s.
|
||||
assert.equal(out.tStarS, 60);
|
||||
});
|
||||
|
||||
test('plan: operational up-move (no rendezvous partner)', () => {
|
||||
const profiles = [makeProfile({ id: 'A', state: 'operational', position: 40 })];
|
||||
// Currently delivering 40 (at maxFlow=100 → linear), targeting 60.
|
||||
const combination = [{ machineId: 'A', flow: 60 }];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
assert.equal(out.commands.length, 1);
|
||||
assert.equal(out.commands[0].action, 'flowmovement');
|
||||
assert.equal(out.commands[0].flow, 60);
|
||||
assert.equal(out.commands[0].fireAtTickN, 0);
|
||||
// eta = |60−40|/2 = 10s
|
||||
assert.equal(out.tStarS, 10);
|
||||
});
|
||||
|
||||
test('plan: rendezvous — startup pump + running pump that needs to shed load', () => {
|
||||
// A: starting from idle, target 60. eta = 10 + 20 + 60/2 = 60s.
|
||||
// B: operational at 80 (flow=80), target 40 (down). eta_B = 40/2 = 20s.
|
||||
// Expectation: A fires at tick 0; B fires at tick (60−20) = 40 so B
|
||||
// FINISHES at the same time A reaches its target.
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'idle', position: 0 }),
|
||||
makeProfile({ id: 'B', state: 'operational', position: 80 }),
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 60 },
|
||||
{ machineId: 'B', flow: 40 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
const cmdA_startup = out.commands.find((c) => c.machineId === 'A' && c.action === 'execsequence');
|
||||
const cmdA_flow = out.commands.find((c) => c.machineId === 'A' && c.action === 'flowmovement');
|
||||
const cmdB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
|
||||
assert.ok(cmdA_startup, 'A startup');
|
||||
assert.ok(cmdA_flow, 'A flowmovement (queued)');
|
||||
assert.ok(cmdB, 'B flowmovement');
|
||||
|
||||
assert.equal(cmdA_startup.fireAtTickN, 0);
|
||||
assert.equal(cmdA_flow.fireAtTickN, 0);
|
||||
// B delayed so it finishes at tStar=60 → fires at 60−20 = 40.
|
||||
assert.equal(cmdB.fireAtTickN, 40);
|
||||
assert.equal(out.tStarS, 60);
|
||||
});
|
||||
|
||||
test('plan: all machines moving down — all land at slowest mover\'s eta', () => {
|
||||
// Two operational pumps, both reducing flow. tStar = max eta over
|
||||
// ALL non-noop moves (not just increasing) so the slower pump
|
||||
// defines the rendezvous and the faster one is delayed to land
|
||||
// with it. Net effect: same-time landing in pure-down scenarios too,
|
||||
// sum-of-flows stays at the OLD setpoint until t* then drops cleanly.
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'operational', position: 80, velocityPctPerS: 2 }),
|
||||
makeProfile({ id: 'B', state: 'operational', position: 70, velocityPctPerS: 2 }),
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 40 }, // target position via inverse curve → 40 (identity makeProfile)
|
||||
{ machineId: 'B', flow: 30 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
// eta_A = |80-40|/2 = 20s, eta_B = |70-30|/2 = 20s → tStar = 20s.
|
||||
assert.equal(out.tStarS, 20);
|
||||
// Both pumps have eta == tStar so neither is delayed (fireAtTickN = 0).
|
||||
for (const c of out.commands) {
|
||||
assert.equal(c.fireAtTickN, 0, `${c.machineId} should fire at 0 when eta == tStar`);
|
||||
}
|
||||
});
|
||||
|
||||
test('plan: asymmetric down moves — faster one delayed to land with slower one', () => {
|
||||
// A and B both reduce flow but A's move is faster. The new
|
||||
// symmetric-rendezvous semantics delay the faster mover so both land
|
||||
// at tStar = max eta.
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'operational', position: 60, velocityPctPerS: 4 }), // fast
|
||||
makeProfile({ id: 'B', state: 'operational', position: 80, velocityPctPerS: 2 }), // slow
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 40 },
|
||||
{ machineId: 'B', flow: 40 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
// eta_A = |60-40|/4 = 5s, eta_B = |80-40|/2 = 20s → tStar = 20s.
|
||||
assert.equal(out.tStarS, 20);
|
||||
const cA = out.commands.find((c) => c.machineId === 'A');
|
||||
const cB = out.commands.find((c) => c.machineId === 'B');
|
||||
assert.equal(cA.fireAtTickN, 15, 'A (fast) delayed by tStar − eta_A = 20 − 5 = 15');
|
||||
assert.equal(cB.fireAtTickN, 0, 'B (slow) defines tStar — fires immediately');
|
||||
});
|
||||
|
||||
test('plan: shutdown — removed machine gets execsequence(shutdown)', () => {
|
||||
// A staying at flow 60, B getting shut down (target 0).
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'operational', position: 60 }),
|
||||
makeProfile({ id: 'B', state: 'operational', position: 50 }),
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 60 }, // unchanged
|
||||
{ machineId: 'B', flow: 0 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
const shutdownB = out.commands.find((c) => c.machineId === 'B' && c.action === 'execsequence' && c.sequence === 'shutdown');
|
||||
assert.ok(shutdownB, 'B shutdown command present');
|
||||
});
|
||||
|
||||
test('plan: noop — machine not in combination and already off does nothing', () => {
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'operational', position: 60 }),
|
||||
makeProfile({ id: 'B', state: 'idle', position: 0 }),
|
||||
];
|
||||
const combination = [{ machineId: 'A', flow: 60 }];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
const bAny = out.commands.find((c) => c.machineId === 'B');
|
||||
assert.equal(bAny, undefined, 'B should be omitted (no-op)');
|
||||
});
|
||||
|
||||
test('plan: rendezvous with three pumps — slowest startup sets the pace', () => {
|
||||
// A: idle → 50 (full startup, slow).
|
||||
// B: operational at 80 → 40 (down).
|
||||
// C: operational at 30 → 50 (up, fast).
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'idle', position: 0 }),
|
||||
makeProfile({ id: 'B', state: 'operational', position: 80 }),
|
||||
makeProfile({ id: 'C', state: 'operational', position: 30 }),
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 50 },
|
||||
{ machineId: 'B', flow: 40 },
|
||||
{ machineId: 'C', flow: 50 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
|
||||
// eta_A = 10 + 20 + 50/2 = 55s (startup ladder + ramp; defines tStar)
|
||||
// eta_B = |80-40|/2 = 20s (decreasing)
|
||||
// eta_C = |50-30|/2 = 10s (increasing)
|
||||
// tStar = max(55, 20, 10) = 55.
|
||||
assert.equal(out.tStarS, 55);
|
||||
|
||||
const cA = out.commands.find((c) => c.machineId === 'A' && c.action === 'execsequence');
|
||||
const cC = out.commands.find((c) => c.machineId === 'C' && c.action === 'flowmovement');
|
||||
const cB = out.commands.find((c) => c.machineId === 'B' && c.action === 'flowmovement');
|
||||
// A's startup must begin NOW; its delayed flowmovement lands at t*
|
||||
// by construction.
|
||||
assert.equal(cA.fireAtTickN, 0);
|
||||
// Symmetric rendezvous: BOTH B and C are delayed to land at t*.
|
||||
// C (up, fast) gets delayed by t* − eta_C = 45.
|
||||
// B (down, mid) gets delayed by t* − eta_B = 35.
|
||||
assert.equal(cC.fireAtTickN, 55 - 10, 'C delayed to land at tStar (same-time landing)');
|
||||
assert.equal(cB.fireAtTickN, 55 - 20, 'B delayed to land at tStar (same-time landing)');
|
||||
});
|
||||
|
||||
test('plan: mixed-speed multi-startup — fast pumps wait so all land at tStar together', () => {
|
||||
// Three idle pumps starting from min position. Different per-pump
|
||||
// velocities → different etas. Without the rampStart gating, each
|
||||
// pump's delayedMove would fire at warmup-end and ramp at its own
|
||||
// speed, so the FAST pump lands long before the SLOW one — visible
|
||||
// on the dashboard as staggered landing curves.
|
||||
//
|
||||
// Real-world reproducer: pumpingstation-complete-example with the
|
||||
// editor's Reaction Speed set to A=3 %/s, B=10 %/s, C=1 %/s.
|
||||
//
|
||||
// Velocities here mirror that ratio but scaled for unit-test
|
||||
// readability. Position range is [0,100] so rampDist = 100.
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'idle', position: 0, velocityPctPerS: 3 }),
|
||||
makeProfile({ id: 'B', state: 'idle', position: 0, velocityPctPerS: 10 }),
|
||||
makeProfile({ id: 'C', state: 'idle', position: 0, velocityPctPerS: 1 }),
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 100 },
|
||||
{ machineId: 'B', flow: 100 },
|
||||
{ machineId: 'C', flow: 100 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
|
||||
// Default ladder = starting(10) + warmingup(20) = 30 s.
|
||||
// ramp_A = 100/3 ≈ 33.33 s → eta_A ≈ 63.33 s
|
||||
// ramp_B = 100/10 = 10 s → eta_B = 40 s
|
||||
// ramp_C = 100/1 = 100 s → eta_C = 130 s
|
||||
// tStar = max(eta_A, eta_B, eta_C) = 130 s.
|
||||
assert.ok(Math.abs(out.tStarS - 130) < 0.01, `tStar should be 130; got ${out.tStarS}`);
|
||||
|
||||
// Just-in-time: the WHOLE startup (ladder + ramp) is delayed by (tStar −
|
||||
// eta), so both execsequence and flowmovement fire at the same delayed
|
||||
// tick. eta_A = 30 + 33.33 ≈ 63.33, eta_B = 40, eta_C = 130.
|
||||
// A: round(130 − 63.33) = 67
|
||||
// B: round(130 − 40) = 90
|
||||
// C: round(130 − 130) = 0 (slowest — defines tStar, fires now)
|
||||
const delays = { A: Math.round(130 - (30 + 100 / 3)), B: 90, C: 0 };
|
||||
for (const id of ['A', 'B', 'C']) {
|
||||
const exec = out.commands.find((c) => c.machineId === id && c.action === 'execsequence');
|
||||
const flow = out.commands.find((c) => c.machineId === id && c.action === 'flowmovement');
|
||||
assert.ok(exec, `${id} execsequence present`);
|
||||
assert.ok(flow, `${id} flowmovement present`);
|
||||
assert.equal(exec.fireAtTickN, delays[id], `${id} ladder delayed to land at tStar`);
|
||||
assert.equal(flow.fireAtTickN, delays[id], `${id} flowmovement fires with the ladder`);
|
||||
}
|
||||
|
||||
// Sanity: with the ladder delayed, each pump reaches `operational` only at
|
||||
// (delay + ladderS) and its ramp ends at the same wall-clock instant ≈ 130.
|
||||
// A: 67 + 30 (op) + 33.33 ≈ 130.33
|
||||
// B: 90 + 30 (op) + 10 = 130
|
||||
// C: 0 + 30 (op) + 100 = 130
|
||||
// No pump sits at `operational` (and minimum flow) before its ramp — that
|
||||
// early min-flow was the staging bump this just-in-time start removes.
|
||||
});
|
||||
|
||||
test('plan: zero-velocity machine is demoted (infinite eta) but does not crash', () => {
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'operational', position: 0, velocityPctPerS: 0 }),
|
||||
];
|
||||
const combination = [{ machineId: 'A', flow: 60 }];
|
||||
|
||||
const out = plan(profiles, combination, 100_000);
|
||||
// Eta is Infinity → filtered out of tStar computation (only finite etas count).
|
||||
// Command still scheduled; fireAtTickN remains 0 for increasing move.
|
||||
const c = out.commands.find((c) => c.action === 'flowmovement');
|
||||
assert.ok(c);
|
||||
assert.equal(c.fireAtTickN, 0);
|
||||
assert.equal(out.tStarS, 0); // no finite increasing eta → tStar collapses to 0
|
||||
});
|
||||
|
||||
test('plan: respects custom tickS option', () => {
|
||||
// Same as the rendezvous test but with tickS=5 → fireAt should be in
|
||||
// ticks-of-5-seconds, not seconds.
|
||||
const profiles = [
|
||||
makeProfile({ id: 'A', state: 'idle', position: 0 }),
|
||||
makeProfile({ id: 'B', state: 'operational', position: 80 }),
|
||||
];
|
||||
const combination = [
|
||||
{ machineId: 'A', flow: 60 },
|
||||
{ machineId: 'B', flow: 40 },
|
||||
];
|
||||
|
||||
const out = plan(profiles, combination, 100_000, { tickS: 5 });
|
||||
const cmdB = out.commands.find((c) => c.machineId === 'B');
|
||||
assert.equal(out.tStarS, 60);
|
||||
assert.equal(out.tickS, 5);
|
||||
assert.equal(cmdB.fireAtTickN, tickRound(60 - 20, 5)); // = 8
|
||||
});
|
||||
90
test/basic/pumpCombinations.basic.test.js
Normal file
90
test/basic/pumpCombinations.basic.test.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// Local stub for groupCurves — replace once ../groupOps/groupCurves lands.
|
||||
const groupCurves = {
|
||||
groupFlow: (m) => m.predictFlow,
|
||||
groupPower: (m) => m.predictPower,
|
||||
groupNCog: (m) => m.NCog ?? 0,
|
||||
groupCalcPower: (m, f) => m.inputFlowCalcPower(f),
|
||||
};
|
||||
|
||||
const { validPumpCombinations, checkSpecialCases } =
|
||||
require('../../src/combinatorics/pumpCombinations');
|
||||
|
||||
function makeMachine({ id, state = 'off', mode = 'auto',
|
||||
fMin = 0, fMax = 100, pMax = 100,
|
||||
NCog = 0.5, validAction = true } = {}) {
|
||||
return {
|
||||
config: { general: { id } },
|
||||
state: { getCurrentState: () => state },
|
||||
currentMode: mode,
|
||||
NCog,
|
||||
predictFlow: { currentFxyYMin: fMin, currentFxyYMax: fMax },
|
||||
predictPower: { currentFxyYMin: 0, currentFxyYMax: pMax },
|
||||
inputFlowCalcPower: (flow) => flow * 0.5,
|
||||
isValidActionForMode: () => validAction,
|
||||
};
|
||||
}
|
||||
|
||||
const POSITIONS = { DOWNSTREAM: 'downstream' };
|
||||
const baseCtx = (extra = {}) => ({
|
||||
groupCurves,
|
||||
logger: { warn: () => {}, debug: () => {}, error: () => {} },
|
||||
readChildMeasurement: () => undefined,
|
||||
POSITIONS,
|
||||
unitPolicy: { canonical: { flow: 'm3/s' } },
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('validPumpCombinations: 3 idle machines + Qd in range returns subsets that can deliver', () => {
|
||||
const machines = {
|
||||
a: makeMachine({ id: 'a', state: 'idle', fMin: 10, fMax: 50 }),
|
||||
b: makeMachine({ id: 'b', state: 'idle', fMin: 10, fMax: 50 }),
|
||||
c: makeMachine({ id: 'c', state: 'idle', fMin: 10, fMax: 50 }),
|
||||
};
|
||||
const combos = validPumpCombinations(machines, 40, baseCtx());
|
||||
assert.ok(combos.length > 0, 'expected at least one combination');
|
||||
// every combination must be able to deliver Qd
|
||||
for (const subset of combos) {
|
||||
const maxF = subset.reduce((s, id) => s + machines[id].predictFlow.currentFxyYMax, 0);
|
||||
const minF = subset.reduce((s, id) => s + machines[id].predictFlow.currentFxyYMin, 0);
|
||||
assert.ok(maxF >= 40);
|
||||
assert.ok(minF <= 40);
|
||||
}
|
||||
});
|
||||
|
||||
test('validPumpCombinations: excludes machines in off/coolingdown/stopping/emergencystop', () => {
|
||||
const machines = {
|
||||
a: makeMachine({ id: 'a', state: 'off', fMin: 10, fMax: 50 }),
|
||||
b: makeMachine({ id: 'b', state: 'coolingdown', fMin: 10, fMax: 50 }),
|
||||
c: makeMachine({ id: 'c', state: 'stopping', fMin: 10, fMax: 50 }),
|
||||
d: makeMachine({ id: 'd', state: 'emergencystop', fMin: 10, fMax: 50 }),
|
||||
e: makeMachine({ id: 'e', state: 'idle', fMin: 10, fMax: 50 }),
|
||||
};
|
||||
const combos = validPumpCombinations(machines, 30, baseCtx());
|
||||
// Only "e" can be in a combination
|
||||
for (const subset of combos) {
|
||||
for (const id of subset) assert.equal(id, 'e');
|
||||
}
|
||||
});
|
||||
|
||||
test('checkSpecialCases: reduces Qd by flow of manually controlled operational machines', () => {
|
||||
const machines = {
|
||||
a: makeMachine({ id: 'a', state: 'operational', mode: 'virtualControl' }),
|
||||
b: makeMachine({ id: 'b', state: 'idle' }),
|
||||
};
|
||||
const ctx = baseCtx({
|
||||
readChildMeasurement: (m, type, variant) => {
|
||||
if (m.config.general.id === 'a' && variant === 'measured') return 12;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const adjusted = checkSpecialCases(machines, 50, ctx);
|
||||
assert.equal(adjusted, 38);
|
||||
});
|
||||
|
||||
test('validPumpCombinations: no machines returns empty array', () => {
|
||||
const combos = validPumpCombinations({}, 10, baseCtx());
|
||||
assert.deepEqual(combos, []);
|
||||
});
|
||||
128
test/basic/totalsCalculator.basic.test.js
Normal file
128
test/basic/totalsCalculator.basic.test.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const TotalsCalculator = require('../../src/totals/totalsCalculator');
|
||||
|
||||
const unitPolicy = {
|
||||
canonical: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
|
||||
output: { pressure: 'Pa', flow: 'm3/s', power: 'W', temperature: 'K' },
|
||||
};
|
||||
const silent = { debug() {}, info() {}, warn() {}, error() {} };
|
||||
|
||||
function predictView(min, max) {
|
||||
return { currentF: (min + max) / 2, currentFxyYMin: min, currentFxyYMax: max };
|
||||
}
|
||||
|
||||
function makeMachine(id, opts = {}) {
|
||||
const {
|
||||
flowMin = 0.0, flowMax = 1.0,
|
||||
powerMin = 100, powerMax = 1000,
|
||||
state = 'operational',
|
||||
hasCurve = true,
|
||||
NCog = 0.5,
|
||||
// Input-curve envelope (for calcAbsoluteTotals): { [pressureKey]: { y: [...] } }
|
||||
inputCurve = null,
|
||||
actFlow = 0,
|
||||
actPower = 0,
|
||||
} = opts;
|
||||
|
||||
const fakeInput = inputCurve || {
|
||||
'50000': { y: [flowMin, (flowMin + flowMax) / 2, flowMax] },
|
||||
};
|
||||
const fakePower = inputCurve
|
||||
? Object.fromEntries(Object.keys(inputCurve).map(k => [k, { y: [powerMin, (powerMin + powerMax) / 2, powerMax] }]))
|
||||
: { '50000': { y: [powerMin, (powerMin + powerMax) / 2, powerMax] } };
|
||||
|
||||
return {
|
||||
config: { general: { id } },
|
||||
hasCurve,
|
||||
state: { getCurrentState: () => state },
|
||||
NCog,
|
||||
predictFlow: { inputCurve: fakeInput, ...predictView(flowMin, flowMax) },
|
||||
predictPower: { inputCurve: fakePower, ...predictView(powerMin, powerMax) },
|
||||
_actFlow: actFlow,
|
||||
_actPower: actPower,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeOperatingPoint(/* machines */) {
|
||||
return {
|
||||
readChild(machine, type, _variant, _position /*, _unit */) {
|
||||
if (type === 'flow') return machine._actFlow;
|
||||
if (type === 'power') return machine._actPower;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('calcAbsoluteTotals returns zeros when no machines', () => {
|
||||
const tc = new TotalsCalculator({ machines: {}, unitPolicy, logger: silent });
|
||||
const t = tc.calcAbsoluteTotals();
|
||||
assert.deepEqual(t, { flow: { min: 0, max: 0 }, power: { min: 0, max: 0 } });
|
||||
});
|
||||
|
||||
test('calcAbsoluteTotals scans curve envelope (sum of maxes, min of mins)', () => {
|
||||
const machines = {
|
||||
a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500 }),
|
||||
b: makeMachine('b', { flowMin: 0.2, flowMax: 0.8, powerMin: 200, powerMax: 700 }),
|
||||
};
|
||||
const tc = new TotalsCalculator({ machines, unitPolicy, logger: silent });
|
||||
const t = tc.calcAbsoluteTotals();
|
||||
assert.equal(t.flow.min, 0.1);
|
||||
assert.equal(t.power.min, 100);
|
||||
// max is summed across all machines
|
||||
assert.equal(t.flow.max, 0.5 + 0.8);
|
||||
assert.equal(t.power.max, 500 + 700);
|
||||
});
|
||||
|
||||
test('calcDynamicTotals sums across machines and skips machines with no valid curve', () => {
|
||||
const machines = {
|
||||
a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500, actFlow: 0.3, actPower: 300 }),
|
||||
b: makeMachine('b', { flowMin: 0.2, flowMax: 0.7, powerMin: 200, powerMax: 600, actFlow: 0.4, actPower: 400 }),
|
||||
skip: makeMachine('skip', { hasCurve: false }),
|
||||
};
|
||||
const tc = new TotalsCalculator({
|
||||
machines, unitPolicy, logger: silent,
|
||||
operatingPoint: fakeOperatingPoint(machines),
|
||||
});
|
||||
|
||||
const t = tc.calcDynamicTotals();
|
||||
|
||||
assert.equal(t.flow.min, 0.1);
|
||||
assert.equal(t.flow.max, 0.5 + 0.7);
|
||||
assert.equal(t.flow.act, 0.3 + 0.4);
|
||||
assert.equal(t.power.min, 100);
|
||||
assert.equal(t.power.max, 500 + 600);
|
||||
assert.equal(t.power.act, 300 + 400);
|
||||
assert.equal(t.NCog, machines.a.NCog + machines.b.NCog);
|
||||
});
|
||||
|
||||
test('activeTotals skips machines whose state is off or maintenance', () => {
|
||||
const machines = {
|
||||
a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500, state: 'operational' }),
|
||||
b: makeMachine('b', { flowMin: 0.2, flowMax: 0.7, powerMin: 200, powerMax: 600, state: 'off' }),
|
||||
c: makeMachine('c', { flowMin: 0.3, flowMax: 0.9, powerMin: 300, powerMax: 900, state: 'maintenance' }),
|
||||
d: makeMachine('d', { flowMin: 0.05, flowMax: 0.4, powerMin: 50, powerMax: 400, state: 'accelerating' }),
|
||||
};
|
||||
const tc = new TotalsCalculator({ machines, unitPolicy, logger: silent });
|
||||
|
||||
const t = tc.activeTotals();
|
||||
assert.equal(t.countActiveMachines, 2); // a + d
|
||||
assert.equal(t.flow.min, 0.1 + 0.05);
|
||||
assert.equal(t.flow.max, 0.5 + 0.4);
|
||||
assert.equal(t.power.min, 100 + 50);
|
||||
assert.equal(t.power.max, 500 + 400);
|
||||
});
|
||||
|
||||
test('activeTotals honours the injected isMachineActive override', () => {
|
||||
const machines = {
|
||||
a: makeMachine('a', { flowMin: 0.1, flowMax: 0.5, powerMin: 100, powerMax: 500, state: 'operational' }),
|
||||
b: makeMachine('b', { flowMin: 0.2, flowMax: 0.7, powerMin: 200, powerMax: 600, state: 'operational' }),
|
||||
};
|
||||
const tc = new TotalsCalculator({
|
||||
machines, unitPolicy, logger: silent,
|
||||
isMachineActive: (id) => id === 'b',
|
||||
});
|
||||
const t = tc.activeTotals();
|
||||
assert.equal(t.countActiveMachines, 1);
|
||||
assert.equal(t.flow.max, 0.7);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/basic.flow.json'), 'utf8'));
|
||||
const flow = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../examples/02-Dashboard.json'), 'utf8'));
|
||||
|
||||
test('basic example includes node type machineGroupControl', () => {
|
||||
const count = flow.filter((n) => n && n.type === 'machineGroupControl').length;
|
||||
|
||||
138
test/integration/bep-distance-demand-sweep.integration.test.js
Normal file
138
test/integration/bep-distance-demand-sweep.integration.test.js
Normal file
@@ -0,0 +1,138 @@
|
||||
// Empirical answer: does absDistFromPeak / relDistFromPeak move with demand?
|
||||
// Drives the live MGC + 3 identical pumps (same model as the dashboard demo)
|
||||
// across a demand sweep and records what each metric actually does. The test
|
||||
// asserts the expected qualitative shape, so any future change that
|
||||
// regresses BEP-distance sensitivity will fail loudly.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const RM = require('../../../rotatingMachine/src/specificClass');
|
||||
const MGC = require('../../src/specificClass');
|
||||
const { getOutput } = require('../../src/io/output');
|
||||
|
||||
const PUMP_MODEL = 'hidrostal-H05K-S03R';
|
||||
const HEADER_DP_MBAR = 1100;
|
||||
|
||||
// stateConfig.time = 0 for every transition so warmup/cooldown don't add real
|
||||
// seconds — without this the 4-demand sweep × 3 pumps takes >120s and the test
|
||||
// runner kills it.
|
||||
const INSTANT_STATE = {
|
||||
time: { starting: 0, warmingup: 0, operational: 0, accelerating: 0,
|
||||
decelerating: 0, stopping: 0, coolingdown: 0, idle: 0,
|
||||
maintenance: 0, emergencystop: 0, off: 0 },
|
||||
};
|
||||
|
||||
function mkPump(id) {
|
||||
return new RM({
|
||||
general: { id, name: id },
|
||||
asset: { model: PUMP_MODEL, unit: 'm3/h' },
|
||||
}, INSTANT_STATE);
|
||||
}
|
||||
|
||||
async function buildGroupWithPressure() {
|
||||
const mgc = new MGC({
|
||||
general: { id: 'mgc', name: 'mgc' },
|
||||
functionality: { mode: { current: 'optimalControl' }, positionVsParent: 'atEquipment' },
|
||||
});
|
||||
const pumps = ['A','B','C'].map(l => mkPump(`pump-${l}`));
|
||||
for (const p of pumps) {
|
||||
mgc.childRegistrationUtils?.registerChild?.(p, 'atEquipment');
|
||||
}
|
||||
for (const p of pumps) {
|
||||
p.updateMeasuredPressure(0, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'sim-up' });
|
||||
p.updateMeasuredPressure(HEADER_DP_MBAR, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'sim-dn' });
|
||||
}
|
||||
// Let pressure events propagate through the emitter chain.
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
return { mgc, pumps };
|
||||
}
|
||||
|
||||
// Settle to 'ready' between demands. The rendezvous lock defers a new setpoint
|
||||
// that arrives while the group is still 'working', so each sweep step must wait
|
||||
// for the previous move to land before issuing (and reading) the next.
|
||||
async function waitReady(mgc, timeoutMs = 6000) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
if (mgc.getMovementState?.() === 'ready') return true;
|
||||
try { await mgc.movementExecutor?.tick?.(); } catch { /* ignore */ }
|
||||
await new Promise(r => setTimeout(r, 40));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function sweepDemand(mgc, demands_m3h) {
|
||||
const rows = [];
|
||||
for (const Qd_m3h of demands_m3h) {
|
||||
const Qd = Qd_m3h / 3600; // m3/h → m3/s
|
||||
try { await mgc.handleInput('parent', Qd); }
|
||||
catch (e) { /* turnOff or no-combination paths are part of the contract */ }
|
||||
await waitReady(mgc);
|
||||
const out = getOutput(mgc);
|
||||
rows.push({
|
||||
demand: Qd_m3h,
|
||||
flow: out.atEquipment_predicted_flow,
|
||||
eta: out.atEquipment_predicted_efficiency,
|
||||
absDist: out.absDistFromPeak,
|
||||
relDist: out.relDistFromPeak,
|
||||
ncog: out.atEquipment_predicted_Ncog,
|
||||
nAct: out.machineCountActive,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
test('absDistFromPeak rises when demand pushes pumps off BEP', async () => {
|
||||
const { mgc } = await buildGroupWithPressure();
|
||||
// Sweep covers "comfortably within combined BEP" (low/mid) and "over the
|
||||
// group's BEP envelope, pumps must push" (high). For hidrostal-H05K-S03R
|
||||
// at 1100 mbar, single-pump max ≈ 230 m³/h, 3-pump max ≈ 680 m³/h. Demand
|
||||
// 600 m³/h forces each pump well past BEP.
|
||||
const rows = await sweepDemand(mgc, [100, 200, 300, 600]);
|
||||
|
||||
// Sanity: pumps actually accepted the demand and flow is rising.
|
||||
assert.ok(rows[3].flow > rows[0].flow + 100,
|
||||
`flow should rise with demand, got ${JSON.stringify(rows.map(r => r.flow))}`);
|
||||
|
||||
// absDist should be larger at over-capacity demand than at within-capacity.
|
||||
// Use a generous tolerance — the test asserts the QUALITATIVE shape, not
|
||||
// exact numbers (which depend on curve interpolation).
|
||||
const lowAbs = Math.min(rows[0].absDist, rows[1].absDist, rows[2].absDist);
|
||||
const highAbs = rows[3].absDist;
|
||||
assert.ok(highAbs > lowAbs + 0.005,
|
||||
`absDistFromPeak should be larger off-BEP than on-BEP. ` +
|
||||
`low (Qd∈{100,200,300}): min=${lowAbs}, high (Qd=600): ${highAbs}. ` +
|
||||
`Full rows: ${JSON.stringify(rows, null, 2)}`);
|
||||
});
|
||||
|
||||
test('absDistFromPeak ≈ 0 across the within-BEP demand range (working as designed)', async () => {
|
||||
const { mgc } = await buildGroupWithPressure();
|
||||
const rows = await sweepDemand(mgc, [100, 200, 300]);
|
||||
// The BEP-Gravitation optimizer is supposed to KEEP us at BEP for demands
|
||||
// the group can absorb at BEP. So absDist staying near zero across the
|
||||
// "easy" range is the correct outcome — NOT a bug. This test pins that
|
||||
// behaviour so any future "fix" that introduces drift here fails.
|
||||
for (const r of rows) {
|
||||
assert.ok(r.absDist != null && r.absDist < 0.02,
|
||||
`at demand ${r.demand} m³/h, absDist=${r.absDist} should be near zero ` +
|
||||
`(optimizer holds BEP); only off-BEP demand should produce noticeable drift`);
|
||||
}
|
||||
});
|
||||
|
||||
test('relDistFromPeak is structurally ill-defined for homogeneous pump groups', async () => {
|
||||
const { mgc } = await buildGroupWithPressure();
|
||||
const rows = await sweepDemand(mgc, [100, 200, 300, 600]);
|
||||
// 3 identical pumps → all cogs equal → max=mean=min in calcDistanceBEP.
|
||||
// The interpolation [max..min] → [0..1] collapses; the metric is
|
||||
// mathematically undefined here. Whatever value comes out is float-noise
|
||||
// dependent and MUST NOT be interpreted as "BEP distance percentage".
|
||||
// This test documents the limitation as a contract; it deliberately does
|
||||
// not assert a specific value — it asserts the metric does NOT move
|
||||
// monotonically with demand (which it shouldn't for identical pumps).
|
||||
const uniqueRel = new Set(rows.map(r => r.relDist));
|
||||
assert.ok(uniqueRel.size <= 2,
|
||||
`relDistFromPeak is expected to be effectively constant for identical pumps. ` +
|
||||
`Distinct values across sweep: ${[...uniqueRel].join(', ')}. ` +
|
||||
`If you want this metric to track demand, configure pumps with different ` +
|
||||
`peak η (different models or different curve scaling).`);
|
||||
});
|
||||
251
test/integration/dashboard-fanout.integration.test.js
Normal file
251
test/integration/dashboard-fanout.integration.test.js
Normal file
@@ -0,0 +1,251 @@
|
||||
// Output-coverage tests for examples/02-Dashboard.json :: fn_status_split.
|
||||
// Exercises every output port in three states (deploy / post-setup / post-demand)
|
||||
// AND verifies the per-port format contract that every downstream ui-* widget
|
||||
// or chart expects. Per .claude/rules/output-coverage.md.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const flow = JSON.parse(fs.readFileSync(
|
||||
path.resolve(__dirname, '../../examples/02-Dashboard.json'), 'utf8'));
|
||||
const fn = flow.find(n => n.id === 'fn_status_split');
|
||||
|
||||
function runFn(msgs) {
|
||||
let ctxStore = {};
|
||||
const context = {
|
||||
get: (k) => ctxStore[k],
|
||||
set: (k, v) => { ctxStore[k] = v; },
|
||||
};
|
||||
const fn_body = new Function('msg', 'context', fn.func);
|
||||
return msgs.map(msg => fn_body(msg, context));
|
||||
}
|
||||
|
||||
// Indices into the 18-output return array. Kept here as the manifest contract
|
||||
// for this function — every test below references these names, never raw ints.
|
||||
const PORT = {
|
||||
text_mode: 0, text_flow: 1, text_power: 2, text_capacity: 3,
|
||||
text_machines: 4, text_bep_rel: 5, text_eta: 6, text_eta_peak: 7,
|
||||
text_bep_abs: 8, text_ncog: 9,
|
||||
chart_flow: 10, chart_capacity: 11, chart_power: 12, chart_bep_rel: 13,
|
||||
chart_eta: 14,
|
||||
raw_rows: 15, raw_passthrough: 16,
|
||||
chart_pctcap: 17,
|
||||
};
|
||||
|
||||
const initialMsg = {
|
||||
payload: {
|
||||
mode: 'optimalControl', scaling: 'normalized',
|
||||
absDistFromPeak: 0, relDistFromPeak: 0,
|
||||
flowCapacityMax: 0, flowCapacityMin: 0,
|
||||
machineCount: 3, machineCountActive: 0,
|
||||
},
|
||||
};
|
||||
const postSetupMsg = {
|
||||
payload: {
|
||||
atEquipment_predicted_flow: 0, downstream_predicted_flow: 0,
|
||||
atEquipment_predicted_power: 0,
|
||||
flowCapacityMax: 450, flowCapacityMin: 0,
|
||||
machineCountActive: 0,
|
||||
headerDiffPa: 110000, headerDiffMbar: 1100,
|
||||
},
|
||||
};
|
||||
const postDemandMsg = {
|
||||
payload: {
|
||||
atEquipment_predicted_flow: 200,
|
||||
downstream_predicted_flow: 200,
|
||||
atEquipment_predicted_power: 11.4,
|
||||
atEquipment_predicted_efficiency: 0.62,
|
||||
// Ncog as MGC actually emits it: SUM of per-pump NCog values.
|
||||
// 2 pumps each at NCog=0.6 → sum=1.2; per-pump average should display as 60.0 %.
|
||||
atEquipment_predicted_Ncog: 1.2,
|
||||
absDistFromPeak: 0.05, relDistFromPeak: 0.08,
|
||||
flowCapacityMax: 450, machineCountActive: 2,
|
||||
},
|
||||
};
|
||||
|
||||
test('manifest: function has exactly 18 outputs and wires array matches', () => {
|
||||
assert.equal(fn.outputs, 18);
|
||||
assert.equal(fn.wires.length, 18);
|
||||
});
|
||||
|
||||
test('State A (deploy-time): no AT_EQUIPMENT keys → flow/power text show em-dash', () => {
|
||||
const [out] = runFn([initialMsg]);
|
||||
assert.equal(out[PORT.text_mode].payload, 'optimalControl');
|
||||
assert.equal(out[PORT.text_flow].payload, '—');
|
||||
assert.equal(out[PORT.text_power].payload, '—');
|
||||
assert.equal(out[PORT.text_ncog].payload, '—');
|
||||
assert.equal(out[PORT.text_eta].payload, '—');
|
||||
});
|
||||
|
||||
test('State A: charts with no source data emit null msg, never { payload: null }', () => {
|
||||
const [out] = runFn([initialMsg]);
|
||||
// Charts 10, 12, 14 have no source data in State A → must be null (drop msg).
|
||||
assert.equal(out[PORT.chart_flow], null, 'chart_flow must be null when flow missing');
|
||||
assert.equal(out[PORT.chart_power], null, 'chart_power must be null when power missing');
|
||||
assert.equal(out[PORT.chart_eta], null, 'chart_eta must be null when eta missing');
|
||||
// For every msg-emitting chart output: payload is never literally null.
|
||||
for (const idx of Object.values(PORT)) {
|
||||
if (out[idx] && Object.prototype.hasOwnProperty.call(out[idx], 'payload')) {
|
||||
assert.notEqual(out[idx].payload, null,
|
||||
`port ${idx} emitted { payload: null } — would crash ui-chart`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('State B (post-setup, no demand): flow/power = 0, eta missing', () => {
|
||||
const [, out] = runFn([initialMsg, postSetupMsg]);
|
||||
assert.equal(out[PORT.text_flow].payload, '0.0 m³/h');
|
||||
assert.equal(out[PORT.text_power].payload, '0.00 kW');
|
||||
assert.equal(out[PORT.text_capacity].payload, '0.0 – 450.0 m³/h');
|
||||
// η still missing → '—'
|
||||
assert.equal(out[PORT.text_eta].payload, '—');
|
||||
});
|
||||
|
||||
test('State C (post-demand): every text/chart output has real value', () => {
|
||||
const [, , out] = runFn([initialMsg, postSetupMsg, postDemandMsg]);
|
||||
assert.equal(out[PORT.text_flow].payload, '200.0 m³/h');
|
||||
assert.equal(out[PORT.text_power].payload, '11.40 kW');
|
||||
assert.equal(out[PORT.text_eta].payload, '62.0 %');
|
||||
// BEP abs gap: η-points dimensionless, 3 dp.
|
||||
assert.equal(out[PORT.text_bep_abs].payload, '0.050');
|
||||
// Charts have numeric payload.
|
||||
assert.equal(out[PORT.chart_flow].payload, 200);
|
||||
assert.equal(out[PORT.chart_power].payload, 11.4);
|
||||
assert.equal(out[PORT.chart_eta].payload, 62);
|
||||
// % of capacity = flow / flowCapacityMax × 100 = 200 / 450 × 100 ≈ 44.44.
|
||||
assert.equal(out[PORT.chart_pctcap].topic, '% of capacity');
|
||||
assert.ok(Math.abs(out[PORT.chart_pctcap].payload - (200 / 450) * 100) < 1e-6);
|
||||
});
|
||||
|
||||
test('% of capacity chart: drops msg when flow or capacity missing (no payload:null)', () => {
|
||||
// State A: no flow + flowCapacityMax=0 → pctCap undefined → chart() returns
|
||||
// null so the function node skips the output, never { payload: null }.
|
||||
const [out] = runFn([initialMsg]);
|
||||
assert.equal(out[PORT.chart_pctcap], null, 'chart_pctcap must drop msg when source missing');
|
||||
});
|
||||
|
||||
test('NCog formatter: SUM is normalized by machineCountActive before display', () => {
|
||||
// The fix under test. MGC emits Ncog as the SUM of per-pump NCog values
|
||||
// (range 0..N), so a raw pct() would display 120% for 2 pumps at 0.6 each.
|
||||
// The formatter must divide by machineCountActive first.
|
||||
const [, , out] = runFn([initialMsg, postSetupMsg, postDemandMsg]);
|
||||
// 2 pumps × 0.6 each = sum 1.2, mean 0.6, displayed "60.0 %".
|
||||
assert.equal(out[PORT.text_ncog].payload, '60.0 %');
|
||||
});
|
||||
|
||||
test('NCog formatter: ncogSum=0 with active pumps → 0.0 %, not em-dash', () => {
|
||||
const msg = { payload: { ...postSetupMsg.payload,
|
||||
atEquipment_predicted_Ncog: 0, machineCountActive: 3 } };
|
||||
const [out] = runFn([msg]);
|
||||
// Today this is exactly what the live MGC emits (per-pump groupNCog=0
|
||||
// for the hidrostal-H05K-S03R curve at 110 kPa). The dashboard must show
|
||||
// a clean "0.0 %" — not "—" — because we DO have data, it's just zero.
|
||||
assert.equal(out[PORT.text_ncog].payload, '0.0 %');
|
||||
});
|
||||
|
||||
test('NCog formatter: ncogSum present but machineCountActive = 0 → em-dash (no /0)', () => {
|
||||
const msg = { payload: { atEquipment_predicted_Ncog: 1.5, machineCountActive: 0 } };
|
||||
const [out] = runFn([msg]);
|
||||
assert.equal(out[PORT.text_ncog].payload, '—');
|
||||
});
|
||||
|
||||
test('NCog formatter: ncogSum present but machineCountActive missing → em-dash', () => {
|
||||
const msg = { payload: { atEquipment_predicted_Ncog: 1.5 /* no nAct */ } };
|
||||
const [out] = runFn([msg]);
|
||||
assert.equal(out[PORT.text_ncog].payload, '—');
|
||||
});
|
||||
|
||||
test('NCog formatter: 3 pumps each at NCog=0.5 (sum 1.5) → 50.0 %, not 150 %', () => {
|
||||
// Regression test for the bug class — the formatter was displaying sum × 100,
|
||||
// so 1.5 became "150.0 %". Verify the normalization sticks.
|
||||
const msg = { payload: {
|
||||
atEquipment_predicted_Ncog: 1.5,
|
||||
machineCountActive: 3,
|
||||
} };
|
||||
const [out] = runFn([msg]);
|
||||
assert.equal(out[PORT.text_ncog].payload, '50.0 %');
|
||||
});
|
||||
|
||||
test('BEP rel%: undefined bepRel → "—" (degenerate homogeneous-pump case)', () => {
|
||||
// After today's groupEfficiency fix, MGC emits relDistFromPeak=undefined when
|
||||
// pumps are identical. The dashboard text formatter must display "—" — NOT
|
||||
// "0.0 %" via the +null === 0 trap.
|
||||
const msg = { payload: { mode: 'optimalControl', relDistFromPeak: undefined } };
|
||||
const [out] = runFn([msg]);
|
||||
assert.equal(out[PORT.text_bep_rel].payload, '—');
|
||||
});
|
||||
|
||||
test('BEP rel%: null bepRel → "—" (defensive against null emission)', () => {
|
||||
// Same trap as the NCog fix: +null === 0 → pct() would return "0.0 %".
|
||||
const msg = { payload: { relDistFromPeak: null } };
|
||||
const [out] = runFn([msg]);
|
||||
assert.equal(out[PORT.text_bep_rel].payload, '—');
|
||||
});
|
||||
|
||||
test('BEP rel% chart: drops msg when bepRel is null/undefined (no payload:null)', () => {
|
||||
const msg = { payload: { relDistFromPeak: undefined } };
|
||||
const [out] = runFn([msg]);
|
||||
assert.equal(out[PORT.chart_bep_rel], null, 'chart must drop msg when bepRel missing');
|
||||
});
|
||||
|
||||
// ── fn_qh_fanout: Q-H curve → chart points ────────────────────────────
|
||||
const fnQH = flow.find(n => n.id === 'fn_qh_fanout');
|
||||
|
||||
function runFanout(payload) {
|
||||
const fn_body = new Function('msg', fnQH.func);
|
||||
return fn_body({ payload });
|
||||
}
|
||||
|
||||
test('Q-H fanout: trims trailing flat-Q tail so chart axis doesn\'t blow up', () => {
|
||||
// Synthetic input mimics buildQHCurve at low ctrl%: useful range followed by
|
||||
// a horizontal tail (Q clamped to env minimum across high H).
|
||||
const points = [
|
||||
{ Q: 100, H: 7 }, { Q: 80, H: 10 }, { Q: 50, H: 15 },
|
||||
{ Q: 20, H: 20 }, { Q: 9.5, H: 24 }, { Q: 9.5, H: 28 },
|
||||
{ Q: 9.5, H: 32 }, { Q: 9.5, H: 36 }, { Q: 9.5, H: 40 },
|
||||
];
|
||||
const [out] = runFanout({ points });
|
||||
const curvePoints = out.filter(m => m.topic === 'Curve' && m.payload);
|
||||
// The 5 tail points at Q=9.5 should collapse to (at most) one — the first
|
||||
// one to mark the curve's tail entry, not all five.
|
||||
const tailPoints = curvePoints.filter(p => p.payload.Q === 9.5 || p.payload.x === 9.5);
|
||||
assert.ok(tailPoints.length <= 1,
|
||||
`expected ≤1 flat-tail point, got ${tailPoints.length}: ${JSON.stringify(curvePoints)}`);
|
||||
});
|
||||
|
||||
test('Q-H fanout: still emits the rising portion of the curve unchanged', () => {
|
||||
const points = [
|
||||
{ Q: 100, H: 7 }, { Q: 80, H: 10 }, { Q: 50, H: 15 }, { Q: 20, H: 20 },
|
||||
{ Q: 9.5, H: 24 }, { Q: 9.5, H: 28 }, // flat tail
|
||||
];
|
||||
const [out] = runFanout({ points });
|
||||
const curvePoints = out.filter(m => m.topic === 'Curve' && m.payload);
|
||||
const rising = curvePoints.filter(p => p.payload.x > 10);
|
||||
assert.equal(rising.length, 4, `expected 4 rising points, got ${rising.length}`);
|
||||
// First rising point preserves Q=100, H=7.
|
||||
assert.equal(rising[0].payload.x, 100);
|
||||
assert.equal(rising[0].payload.y, 7);
|
||||
});
|
||||
|
||||
test('Q-H fanout: empty/error input → null msg', () => {
|
||||
assert.equal(runFanout({ error: 'no curve', points: [] }), null);
|
||||
assert.equal(runFanout({ points: [] }), null);
|
||||
});
|
||||
|
||||
test('contract: no output ever emits { payload: null } for any of the three states', () => {
|
||||
// The original η-null bug. Re-asserted across all three states because a
|
||||
// regression here crashes the FlowFuse ui-chart with TypeError on .y.
|
||||
const states = runFn([initialMsg, postSetupMsg, postDemandMsg]);
|
||||
for (let s = 0; s < states.length; s++) {
|
||||
const out = states[s];
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const msg = out[i];
|
||||
if (msg && Object.prototype.hasOwnProperty.call(msg, 'payload')) {
|
||||
assert.notEqual(msg.payload, null,
|
||||
`state ${s} port ${i} → { payload: null } would crash ui-chart`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -49,7 +49,7 @@ function machineConfig(id) {
|
||||
return {
|
||||
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
|
||||
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
@@ -67,8 +67,10 @@ function groupConfig() {
|
||||
return {
|
||||
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||
scaling: { current: 'normalized' }, // demand expressed as 0..100 %
|
||||
mode: { current: 'optimalcontrol' }, // production mode
|
||||
// No scaling config: post-refactor MGC has no scaling state. handleInput
|
||||
// takes canonical m³/s. Test converts pct → m³/s before dispatch (mirrors
|
||||
// what the set.demand handler does for bare-number payloads).
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,24 +161,33 @@ test(`MGC demand-cycle walkthrough — head=${HEAD_MBAR} mbar, ${N_PUMPS} pumps,
|
||||
console.log(`MGC station envelope at head ${HEAD_MBAR} mbar (${N_PUMPS} pumps):`);
|
||||
console.log(` per-pump: ${perPumpMin_m3h.toFixed(1)} .. ${perPumpMax_m3h.toFixed(1)} m³/h`);
|
||||
console.log(` station: ${flowMin_m3h.toFixed(1)} .. ${flowMax_m3h.toFixed(1)} m³/h`);
|
||||
console.log(` scaling=normalized: 0% → ${flowMin_m3h.toFixed(1)} m³/h, 100% → ${flowMax_m3h.toFixed(1)} m³/h`);
|
||||
console.log(` (demand ≤ 0% turns ALL pumps off — see MGC handleInput)`);
|
||||
console.log(` 0% → ${flowMin_m3h.toFixed(1)} m³/h, 100% → ${flowMax_m3h.toFixed(1)} m³/h`);
|
||||
console.log(` (demand < 0 turns ALL pumps off; 0 = minimum-control floor)`);
|
||||
console.log('');
|
||||
printHeader(pumps);
|
||||
|
||||
// Build demand sweep: 0..100% up, then 100..0% down.
|
||||
// Build demand sweep: 0..100% up, then 100..0% down, then -1 (all-off sentinel).
|
||||
const upSteps = [];
|
||||
for (let pct = 0; pct <= 100 + 1e-9; pct += STEP_PERCENT) upSteps.push(Math.min(pct, 100));
|
||||
const downSteps = upSteps.slice(0, -1).reverse(); // skip the duplicate 100
|
||||
const sequence = [...upSteps, ...downSteps];
|
||||
const sequence = [...upSteps, ...downSteps, -1];
|
||||
|
||||
let stuckSeen = 0;
|
||||
for (const pct of sequence) {
|
||||
await mgc.handleInput('parent', pct);
|
||||
// Post-refactor handleInput takes canonical m³/s; the percent → m³/s
|
||||
// mapping the set.demand handler does is replicated here in test.
|
||||
if (pct < 0) {
|
||||
await mgc.turnOffAllMachines();
|
||||
} else {
|
||||
const flowMin_m3s = flowMin_m3h / 3600;
|
||||
const flowMax_m3s = flowMax_m3h / 3600;
|
||||
const canonical = flowMin_m3s + (pct / 100) * (flowMax_m3s - flowMin_m3s);
|
||||
await mgc.handleInput('parent', canonical);
|
||||
}
|
||||
await sleep(DWELL_MS);
|
||||
|
||||
// Mirror MGC's normalized→absolute mapping for the printed Qd column.
|
||||
const demandQout_m3h = pct <= 0
|
||||
// pct < 0 → all off (Qd = 0); pct >= 0 → linear interpolation across [min, max].
|
||||
const demandQout_m3h = pct < 0
|
||||
? 0
|
||||
: (flowMax_m3h - flowMin_m3h) * (pct / 100) + flowMin_m3h;
|
||||
|
||||
@@ -194,11 +205,11 @@ test(`MGC demand-cycle walkthrough — head=${HEAD_MBAR} mbar, ${N_PUMPS} pumps,
|
||||
if (s.state === 'accelerating' || s.state === 'decelerating') stuckSeen += 1;
|
||||
}
|
||||
|
||||
if (pct === 0) {
|
||||
// Demand 0% must turn ALL pumps off (or to a non-running state).
|
||||
if (pct < 0) {
|
||||
// Strict negative demand turns ALL pumps off (the explicit "all off" signal).
|
||||
for (const s of snaps) {
|
||||
assert.ok(['idle', 'off', 'stopping', 'coolingdown'].includes(s.state),
|
||||
`demand 0% but pump still in '${s.state}' (totalQ=${totalQ.toFixed(2)})`);
|
||||
`demand ${pct}% but pump still in '${s.state}' (totalQ=${totalQ.toFixed(2)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ function machineConfig(id, model) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: id, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal', model, supplier: 'hidrostal' },
|
||||
asset: { model, unit: 'm3/h' },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
@@ -44,7 +44,7 @@ function groupConfig() {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: 'station' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||
scaling: { current: 'absolute' },
|
||||
// No scaling field — handleInput always takes canonical m³/s post-refactor.
|
||||
mode: { current: 'optimalcontrol' }
|
||||
};
|
||||
}
|
||||
@@ -139,7 +139,6 @@ test('machineGroupControl vs naive baselines — real curves, verified flow', as
|
||||
|
||||
// Run machineGroupControl optimalControl with absolute scaling
|
||||
mg.setMode('optimalcontrol');
|
||||
mg.setScaling('absolute');
|
||||
mg.calcAbsoluteTotals();
|
||||
mg.calcDynamicTotals();
|
||||
await mg.handleInput('parent', Qd);
|
||||
@@ -196,7 +195,6 @@ test('machineGroupControl vs naive baselines — real curves, verified flow', as
|
||||
injectPressure(m);
|
||||
}
|
||||
mg.setMode('optimalcontrol');
|
||||
mg.setScaling('absolute');
|
||||
mg.calcAbsoluteTotals();
|
||||
mg.calcDynamicTotals();
|
||||
await mg.handleInput('parent', Qd);
|
||||
|
||||
93
test/integration/group-bep-cascade.integration.test.js
Normal file
93
test/integration/group-bep-cascade.integration.test.js
Normal file
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||||
const baseCurve = require('../../../generalFunctions/datasets/assetData/curves/hidrostal-H05K-S03R.json');
|
||||
|
||||
/**
|
||||
* After fixing rotatingMachine + MGC to use hydraulic efficiency
|
||||
* (η = Q·ΔP / P_shaft) instead of raw flow/power, every BEP-related output
|
||||
* on MGC should be in the dimensionless 0..1 range and respond to demand
|
||||
* changes. This check ties the whole chain together:
|
||||
* - per-machine cog updates after equalize
|
||||
* - group efficiency measurement is hydraulic (matches scale of cogs)
|
||||
* - calcDistanceBEP(eff, mean(cog), min(cog)) is non-degenerate
|
||||
*/
|
||||
|
||||
const stateConfig = {
|
||||
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
|
||||
movement: { speed: 1200, mode: 'staticspeed', maxSpeed: 1800 },
|
||||
};
|
||||
|
||||
function machineConfig(id, label) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: label, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
|
||||
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 groupConfig() {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: 'TestGroup' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
async function setupGroupWithTwoPumps() {
|
||||
const m1 = new Machine(machineConfig(1, 'pump-1'), stateConfig);
|
||||
const m2 = new Machine(machineConfig(2, 'pump-2'), stateConfig);
|
||||
m1.config.asset.machineCurve = baseCurve;
|
||||
m2.config.asset.machineCurve = baseCurve;
|
||||
await m1.handleInput('parent', 'execSequence', 'startup');
|
||||
await m2.handleInput('parent', 'execSequence', 'startup');
|
||||
|
||||
const mgc = new MachineGroup(groupConfig(), stateConfig);
|
||||
// Mutate the existing machines object — replacing the reference would
|
||||
// strand operatingPoint/totals/efficiency on the original empty bag.
|
||||
mgc.machines[1] = m1;
|
||||
mgc.machines[2] = m2;
|
||||
// Set header (system) pressure differential: 800/1200 mbar => 400 mbar = 40 kPa
|
||||
mgc.measurements.type('pressure').variant('measured').position('upstream').value(80000, Date.now(), 'Pa');
|
||||
mgc.measurements.type('pressure').variant('measured').position('downstream').value(120000, Date.now(), 'Pa');
|
||||
mgc.operatingPoint.equalize();
|
||||
return { mgc, m1, m2 };
|
||||
}
|
||||
|
||||
test('after equalize, each child cog is a dimensionless 0..1 hydraulic efficiency', async () => {
|
||||
const { m1, m2 } = await setupGroupWithTwoPumps();
|
||||
// Trigger updatePosition by setting ctrl explicitly
|
||||
m1.updatePosition();
|
||||
m2.updatePosition();
|
||||
for (const m of [m1, m2]) {
|
||||
assert.ok(Number.isFinite(m.cog), `cog must be finite, got ${m.cog}`);
|
||||
assert.ok(m.cog >= 0 && m.cog <= 1.0,
|
||||
`cog must be a 0..1 hydraulic efficiency, got ${m.cog}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('operatingPoint.headerDiffPa is set by equalize and matches measured differential', async () => {
|
||||
const { mgc, m1 } = await setupGroupWithTwoPumps();
|
||||
// Equalize reads from host measurements; falls back to children when
|
||||
// header is missing. Either path should produce headerDiffPa > 0.
|
||||
// headerDiff must equal the measured differential (40 kPa) once any
|
||||
// pressure source is populated.
|
||||
assert.equal(mgc.operatingPoint.headerDiffPa, 40000,
|
||||
`headerDiffPa should equal downstream-upstream = 40000 Pa, got ${mgc.operatingPoint.headerDiffPa}`);
|
||||
// Sanity: the host's child reference is still consumable for diagnostics.
|
||||
void m1.measurements;
|
||||
});
|
||||
@@ -39,7 +39,7 @@ function machineConfig(id) {
|
||||
return {
|
||||
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
|
||||
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
@@ -57,11 +57,20 @@ function groupConfig() {
|
||||
return {
|
||||
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||
scaling: { current: 'normalized' },
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
// Post-refactor handleInput takes canonical m³/s. This helper mirrors what
|
||||
// the set.demand handler does for a bare-number (percent) payload, so test
|
||||
// scenarios that previously sent `mgc.handleInput('parent', pctToCanonical(mgc, 100))` (= 100 %)
|
||||
// keep their intent.
|
||||
function pctToCanonical(mgc, pct) {
|
||||
if (pct < 0) return -1;
|
||||
const dt = mgc.calcDynamicTotals();
|
||||
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
|
||||
}
|
||||
|
||||
function buildGroup({ withPressure = true } = {}) {
|
||||
const mgc = new MachineGroup(groupConfig());
|
||||
const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`);
|
||||
@@ -137,7 +146,7 @@ test('Scenario 1 — single-shot 100% demand to idle pumps', async () => {
|
||||
console.log(`\n[Scenario 1] head=${HEAD_MBAR_DOWN} mbar, time.starting=${stateConfig.time.starting}s, time.warmingup=${stateConfig.time.warmingup}s`);
|
||||
printSnapshots('before handleInput', pumps);
|
||||
|
||||
await mgc.handleInput('parent', 100);
|
||||
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
|
||||
printSnapshots('immediately after handleInput returns', pumps);
|
||||
|
||||
// Wait for full startup (3s) + movement (~0.5s) + slack
|
||||
@@ -159,16 +168,16 @@ test('Scenario 2 — rapid 100% retargeting during startup window', async () =>
|
||||
// mid-flight, parking it in 'accelerating'/'decelerating'.
|
||||
|
||||
const { mgc, pumps } = buildGroup();
|
||||
console.log(`\n[Scenario 2] firing mgc.handleInput('parent', 100) every 200ms for 5s`);
|
||||
console.log(`\n[Scenario 2] firing mgc.handleInput('parent', pctToCanonical(mgc, 100)) every 200ms for 5s`);
|
||||
printSnapshots('before any handleInput', pumps);
|
||||
|
||||
// First call (kicks off startup); not awaited so retargets can layer on.
|
||||
mgc.handleInput('parent', 100).catch(e => console.log(`first call rejected: ${e.message}`));
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(e => console.log(`first call rejected: ${e.message}`));
|
||||
|
||||
// Spam additional retargets every 200ms for 5s — covers the 3s startup
|
||||
// window with 25 extra retargeting calls.
|
||||
const interval = setInterval(() => {
|
||||
mgc.handleInput('parent', 100).catch(e => console.log(`retarget rejected: ${e.message}`));
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(e => console.log(`retarget rejected: ${e.message}`));
|
||||
}, 200);
|
||||
await sleep(5000);
|
||||
clearInterval(interval);
|
||||
@@ -199,7 +208,7 @@ test('Scenario 3 — pumps with NO pressure measurements injected', async () =>
|
||||
console.log(`\n[Scenario 3] no pressure injected. per-pump curve envelope: ${minQ.toFixed(1)} .. ${maxQ.toFixed(1)} m³/h, station: ${(dyn.flow.min*3600).toFixed(1)} .. ${(dyn.flow.max*3600).toFixed(1)} m³/h`);
|
||||
printSnapshots('before handleInput', pumps);
|
||||
|
||||
await mgc.handleInput('parent', 100);
|
||||
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
|
||||
await sleep(6000);
|
||||
printSnapshots('after 6s settle (no pressure)', pumps);
|
||||
|
||||
@@ -228,7 +237,7 @@ test('Scenario 5 — full up/down/up cycle through shutdown', async () => {
|
||||
printSnapshots('before any handleInput', pumps);
|
||||
|
||||
// Phase 1: drive up to 100% from idle.
|
||||
await mgc.handleInput('parent', 100);
|
||||
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
|
||||
await sleep(5000); // full startup + ramp
|
||||
printSnapshots('after settle at 100%', pumps);
|
||||
for (const p of pumps) {
|
||||
@@ -236,12 +245,14 @@ test('Scenario 5 — full up/down/up cycle through shutdown', async () => {
|
||||
`Phase 1: pump ${p.config.general.id} not operational at 100% (got ${p.state.getCurrentState()})`);
|
||||
}
|
||||
|
||||
// Phase 2: demand drops to 0% — pumps begin shutdown sequence.
|
||||
// FIRE-AND-FORGET: handleInput(0) awaits turnOffAllMachines which
|
||||
// Phase 2: demand drops below 0 — pumps begin shutdown sequence. Use a
|
||||
// strictly-negative percent because 0% now means "minimum-control"
|
||||
// (interpolates to dt.flow.min), not shutdown.
|
||||
// FIRE-AND-FORGET: handleInput(-1) awaits turnOffAllMachines which
|
||||
// awaits the full per-pump shutdown sequence. We need the next 100%
|
||||
// demand to arrive WHILE pumps are still in stopping/coolingdown,
|
||||
// not after they've reached idle.
|
||||
mgc.handleInput('parent', 0).catch(e => console.log(`0% rejected: ${e.message}`));
|
||||
mgc.turnOffAllMachines().catch(e => console.log(`-1% rejected: ${e.message}`));
|
||||
// Wait briefly so the shutdown sequence enters but does NOT complete.
|
||||
// shutdown=['stopping','coolingdown','idle'] with stopping=1s,
|
||||
// coolingdown=2s. 500ms puts us solidly inside 'stopping'.
|
||||
@@ -252,7 +263,7 @@ test('Scenario 5 — full up/down/up cycle through shutdown', async () => {
|
||||
console.log(` states mid-shutdown: ${midShutdownStates.join(', ')}`);
|
||||
|
||||
// Phase 3: demand returns to 100% while pumps are mid-shutdown.
|
||||
await mgc.handleInput('parent', 100);
|
||||
await mgc.handleInput('parent', pctToCanonical(mgc, 100));
|
||||
// Generous: full coolingdown remaining + full startup + ramp.
|
||||
await sleep(8000);
|
||||
printSnapshots('after re-engage to 100%', pumps);
|
||||
@@ -279,7 +290,7 @@ test('Scenario 6 — full up sweep then full down sweep', async () => {
|
||||
|
||||
console.log(' --- up sweep ---');
|
||||
for (const pct of upSteps) {
|
||||
mgc.handleInput('parent', pct).catch(e => console.log(`up ${pct}% rejected: ${e.message}`));
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`up ${pct}% rejected: ${e.message}`));
|
||||
await sleep(600);
|
||||
const snaps = pumps.map(snapshot);
|
||||
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
|
||||
@@ -291,7 +302,7 @@ test('Scenario 6 — full up sweep then full down sweep', async () => {
|
||||
|
||||
console.log(' --- down sweep ---');
|
||||
for (const pct of downSteps) {
|
||||
mgc.handleInput('parent', pct).catch(e => console.log(`down ${pct}% rejected: ${e.message}`));
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`down ${pct}% rejected: ${e.message}`));
|
||||
await sleep(600);
|
||||
const snaps = pumps.map(snapshot);
|
||||
const totalQ = snaps.reduce((s, x) => s + x.flow, 0);
|
||||
@@ -340,7 +351,7 @@ test('Scenario 4 — varying demand during startup (combo flips)', async () => {
|
||||
|
||||
for (const pct of sequence) {
|
||||
console.log(` → demand ${pct}%`);
|
||||
mgc.handleInput('parent', pct).catch(e => console.log(`call ${pct}% rejected: ${e.message}`));
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(e => console.log(`call ${pct}% rejected: ${e.message}`));
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,19 @@ const baseCurve = require('../../../generalFunctions/datasets/assetData/curves/h
|
||||
|
||||
/* ---- helpers ---- */
|
||||
|
||||
// Settle the group to 'ready'. The rendezvous lock defers a setpoint arriving
|
||||
// while the group is still 'working', so a full-MGC test must wait for each
|
||||
// move to land before reading steady state or issuing the next demand.
|
||||
async function waitReady(mgc, timeoutMs = 6000) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
if (mgc.getMovementState?.() === 'ready') return true;
|
||||
try { await mgc.movementExecutor?.tick?.(); } catch { /* ignore */ }
|
||||
await new Promise(r => setTimeout(r, 40));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); }
|
||||
|
||||
function distortSeries(series, scale = 1, tilt = 0) {
|
||||
@@ -54,7 +67,7 @@ function createMachineConfig(id, label) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: label, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
|
||||
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
@@ -72,7 +85,6 @@ function createGroupConfig(name) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller' },
|
||||
scaling: { current: 'normalized' },
|
||||
mode: { current: 'optimalcontrol' }
|
||||
};
|
||||
}
|
||||
@@ -407,10 +419,15 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump
|
||||
await m.handleInput('parent', 'execSequence', 'startup');
|
||||
}
|
||||
|
||||
// Run optimalControl
|
||||
// Run optimalControl. handleInput takes canonical m³/s post-refactor —
|
||||
// mirror the set.demand handler's percent → canonical mapping inline.
|
||||
mg.setMode('optimalcontrol');
|
||||
mg.setScaling('normalized');
|
||||
await mg.handleInput('parent', 50, Infinity);
|
||||
function pctCanonical(mgc, pct) {
|
||||
const dt = mgc.calcDynamicTotals();
|
||||
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
|
||||
}
|
||||
await mg.handleInput('parent', pctCanonical(mg, 50), Infinity);
|
||||
await waitReady(mg); // rendezvous lock — let the move land before reading steady state
|
||||
const optPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
const optFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
|
||||
@@ -419,10 +436,12 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump
|
||||
await m.handleInput('parent', 'execSequence', 'shutdown');
|
||||
await m.handleInput('parent', 'execSequence', 'startup');
|
||||
}
|
||||
await waitReady(mg); // ensure the group is settled so the next demand isn't deferred
|
||||
|
||||
// Run priorityControl
|
||||
mg.setMode('prioritycontrol');
|
||||
await mg.handleInput('parent', 50, Infinity, ['eff', 'std', 'weak']);
|
||||
await mg.handleInput('parent', pctCanonical(mg, 50), Infinity, ['eff', 'std', 'weak']);
|
||||
await waitReady(mg);
|
||||
const prioPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
const prioFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ function machineConfig(id) {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: id, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
|
||||
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
@@ -46,7 +46,6 @@ function groupConfig() {
|
||||
return {
|
||||
general: { logging: { enabled: false, logLevel: 'error' }, name: 'mgc', id: 'mgc' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||
scaling: { current: 'absolute' }, // talk to MGC in m³/h directly
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
117
test/integration/per-pump-ctrl-fanout.integration.test.js
Normal file
117
test/integration/per-pump-ctrl-fanout.integration.test.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// Output-coverage tests for examples/02-Dashboard.json :: fn_chart_pump_a/b/c.
|
||||
// These per-pump fan-out functions feed two charts:
|
||||
// output 0 → ui_chart_per_pump_flow (topic = 'Pump A/B/C', payload = flow m³/h)
|
||||
// output 1 → ui_chart_pumps_ctrl (topic = 'Pump A/B/C', payload = ctrl %)
|
||||
// The ctrl output carries a -1 OFF sentinel: when the pump is off / idle /
|
||||
// maintenance it is not running, so we plot -1 (below the 0–100 band) to give
|
||||
// the chart a clear OFF rail distinct from a pump genuinely running at 0%.
|
||||
// Every output is exercised in populated AND degraded states per
|
||||
// .claude/rules/output-coverage.md.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const flow = JSON.parse(fs.readFileSync(
|
||||
path.resolve(__dirname, '../../examples/02-Dashboard.json'), 'utf8'));
|
||||
|
||||
const PUMPS = [
|
||||
{ id: 'fn_chart_pump_a', topic: 'Pump A' },
|
||||
{ id: 'fn_chart_pump_b', topic: 'Pump B' },
|
||||
{ id: 'fn_chart_pump_c', topic: 'Pump C' },
|
||||
];
|
||||
|
||||
const FLOW = 0; // output index → ui_chart_per_pump_flow
|
||||
const CTRL = 1; // output index → ui_chart_pumps_ctrl
|
||||
|
||||
// Each fan-out caches Port 0 deltas in context('c'). Build a fresh runner per
|
||||
// test so state never leaks between cases.
|
||||
function makeRunner(node) {
|
||||
let store = {};
|
||||
const context = { get: (k) => store[k], set: (k, v) => { store[k] = v; } };
|
||||
const body = new Function('msg', 'context', node.func);
|
||||
return (payload) => body({ payload }, context);
|
||||
}
|
||||
|
||||
// A populated downstream-flow key uses the 4-segment MeasurementContainer
|
||||
// convention the function matches with find('flow.predicted.downstream.').
|
||||
const flowKey = (id) => `flow.predicted.downstream.${id}`;
|
||||
|
||||
test('every per-pump fan-out has exactly 2 outputs wired to flow + ctrl charts', () => {
|
||||
for (const { id } of PUMPS) {
|
||||
const node = flow.find(n => n.id === id);
|
||||
assert.ok(node, `${id} present in flow`);
|
||||
assert.equal(node.outputs, 2, `${id} outputs`);
|
||||
assert.equal(node.wires.length, 2, `${id} wires`);
|
||||
assert.deepEqual(node.wires[FLOW], ['ui_chart_per_pump_flow'], `${id} flow wire`);
|
||||
assert.deepEqual(node.wires[CTRL], ['ui_chart_pumps_ctrl'], `${id} ctrl wire`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ui_chart_pumps_ctrl ymin is -5 so the OFF sentinel (-1) is visible', () => {
|
||||
const chart = flow.find(n => n.id === 'ui_chart_pumps_ctrl');
|
||||
assert.ok(chart, 'ui_chart_pumps_ctrl present');
|
||||
assert.equal(chart.ymin, '-5');
|
||||
assert.equal(chart.ymax, '100');
|
||||
});
|
||||
|
||||
for (const { id, topic } of PUMPS) {
|
||||
test(`${id}: populated running state → flow + ctrl carry real numbers`, () => {
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
const out = run({ [flowKey(id)]: 478 / 3, ctrl: 72, state: 'operational' });
|
||||
assert.deepEqual(out[FLOW], { topic, payload: 478 / 3 });
|
||||
assert.deepEqual(out[CTRL], { topic, payload: 72 });
|
||||
});
|
||||
|
||||
for (const offState of ['off', 'idle', 'maintenance']) {
|
||||
test(`${id}: state '${offState}' → ctrl emits -1 sentinel (even if ctrl% is 0/stale)`, () => {
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
// ctrl stale at 0 (or any residual) must be overridden by the sentinel.
|
||||
const out = run({ [flowKey(id)]: 0, ctrl: 0, state: offState });
|
||||
assert.deepEqual(out[CTRL], { topic, payload: -1 });
|
||||
});
|
||||
}
|
||||
|
||||
test(`${id}: degraded — no state, ctrl missing → ctrl output is null (drop, never payload:null)`, () => {
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
const out = run({ [flowKey(id)]: 50 });
|
||||
assert.equal(out[CTRL], null, 'ctrl must drop when no state and no ctrl');
|
||||
// flow still present.
|
||||
assert.deepEqual(out[FLOW], { topic, payload: 50 });
|
||||
});
|
||||
|
||||
test(`${id}: degraded — no flow key → flow output is null (drop)`, () => {
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
const out = run({ ctrl: 40, state: 'operational' });
|
||||
assert.equal(out[FLOW], null, 'flow must drop when source key missing');
|
||||
assert.deepEqual(out[CTRL], { topic, payload: 40 });
|
||||
});
|
||||
|
||||
test(`${id}: pre-first-tick — empty payload → both outputs null, no payload:null`, () => {
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
const out = run({});
|
||||
assert.equal(out[FLOW], null);
|
||||
assert.equal(out[CTRL], null);
|
||||
for (const m of out) {
|
||||
if (m && Object.prototype.hasOwnProperty.call(m, 'payload')) {
|
||||
assert.notEqual(m.payload, null, `${id} emitted { payload: null }`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`${id}: running ctrl with NaN/null ctrl value → ctrl drops (no payload:null)`, () => {
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
assert.equal(run({ [flowKey(id)]: 10, ctrl: null, state: 'operational' })[CTRL], null);
|
||||
assert.equal(run({ [flowKey(id)]: 10, ctrl: NaN, state: 'operational' })[CTRL], null);
|
||||
});
|
||||
|
||||
test(`${id}: delta-cache holds last state so a ctrl-only delta still rails OFF`, () => {
|
||||
// Realistic: pump first reports state:'off', then a later tick carries only
|
||||
// a ctrl delta (no state). The cached 'off' must keep the sentinel engaged.
|
||||
const run = makeRunner(flow.find(n => n.id === id));
|
||||
run({ state: 'off', ctrl: 0 });
|
||||
const out = run({ ctrl: 5 }); // ctrl-only delta; cached state still 'off'
|
||||
assert.deepEqual(out[CTRL], { topic, payload: -1 });
|
||||
});
|
||||
}
|
||||
254
test/integration/planner-convergence.integration.test.js
Normal file
254
test/integration/planner-convergence.integration.test.js
Normal file
@@ -0,0 +1,254 @@
|
||||
// MGC planner — real-time CONVERGENCE diagnostic.
|
||||
//
|
||||
// Where planner-rendezvous.integration.test.js intercepts _fireCommand to
|
||||
// only assert schedule SHAPE, this test lets the executor REALLY run on
|
||||
// real pumps with non-zero startup/warmup times, and asks two questions:
|
||||
//
|
||||
// (a) does sum-of-pump-flows converge to the demand setpoint?
|
||||
// (b) do all pumps reach their individual flow target at roughly the
|
||||
// same wall-clock instant (the rendezvous)?
|
||||
//
|
||||
// Realistic scenario: ONE pump already operational, TWO pumps idle. A new
|
||||
// demand requires (i) the two idle pumps to start (slow, ~3.5s) AND (ii)
|
||||
// the running pump to retarget. Per the planner code, only flow-DECREASING
|
||||
// moves get delayed to land at t*; flow-INCREASING moves on running pumps
|
||||
// fire at tick 0 and land at their own eta. So the running pump's landing
|
||||
// time should NOT match the two idle pumps unless its target equals its
|
||||
// current flow (an unusual coincidence). This test surfaces that.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||||
|
||||
const HEAD_MBAR_UP = 0;
|
||||
const HEAD_MBAR_DOWN = 1100;
|
||||
const N_PUMPS = 3;
|
||||
|
||||
const LOG_DEBUG = process.env.LOG_DEBUG === '1';
|
||||
const logCfg = { enabled: LOG_DEBUG, logLevel: LOG_DEBUG ? 'debug' : 'error' };
|
||||
|
||||
const stateConfig = {
|
||||
general: { logging: logCfg },
|
||||
state: { current: 'idle' },
|
||||
movement: { mode: 'staticspeed', speed: 200, maxSpeed: 200, interval: 50 },
|
||||
// REAL ladder times — this is the whole point of the test.
|
||||
time: { starting: 1, warmingup: 2, stopping: 1, coolingdown: 2 },
|
||||
};
|
||||
|
||||
function machineConfig(id) {
|
||||
return {
|
||||
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
|
||||
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 groupConfig() {
|
||||
return {
|
||||
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
function pctToCanonical(mgc, pct) {
|
||||
if (pct < 0) return -1;
|
||||
const dt = mgc.calcDynamicTotals();
|
||||
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
|
||||
}
|
||||
|
||||
const NON_RUNNING = new Set(['idle', 'off', 'stopping', 'coolingdown', 'emergencystop']);
|
||||
function pumpFlow_m3h(pump) {
|
||||
const state = pump.state.getCurrentState();
|
||||
if (NON_RUNNING.has(state)) return 0;
|
||||
return Number(pump.predictFlow?.outputY ?? 0) * 3600;
|
||||
}
|
||||
|
||||
function buildGroup() {
|
||||
const mgc = new MachineGroup(groupConfig());
|
||||
const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`);
|
||||
const pumps = ids.map((id) => new Machine(machineConfig(id), stateConfig));
|
||||
for (const m of pumps) {
|
||||
m.updateMeasuredPressure(HEAD_MBAR_UP, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
|
||||
m.updateMeasuredPressure(HEAD_MBAR_DOWN, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
|
||||
mgc.childRegistrationUtils.registerChild(m, 'downstream');
|
||||
}
|
||||
mgc.calcAbsoluteTotals();
|
||||
mgc.calcDynamicTotals();
|
||||
return { mgc, pumps };
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Sample per-pump flow at fixed intervals and return a trajectory: an array
|
||||
// of {tMs, perPump:[...], sum}.
|
||||
async function sampleFlows(pumps, durationMs, intervalMs = 200) {
|
||||
const t0 = Date.now();
|
||||
const out = [];
|
||||
while (Date.now() - t0 < durationMs) {
|
||||
const perPump = pumps.map(pumpFlow_m3h);
|
||||
out.push({ tMs: Date.now() - t0, perPump, sum: perPump.reduce((a, b) => a + b, 0) });
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Find the wall-clock instant (in ms from t0) at which a given series
|
||||
// REACHES and STAYS within `tol` of `target` for the rest of the run. If
|
||||
// never reached, returns null.
|
||||
function arrivalTimeMs(series, target, tol) {
|
||||
for (let i = 0; i < series.length; i++) {
|
||||
const v = series[i];
|
||||
if (Math.abs(v - target) <= tol) {
|
||||
// require it to stay close
|
||||
let stayed = true;
|
||||
for (let j = i + 1; j < series.length; j++) {
|
||||
if (Math.abs(series[j] - target) > tol * 1.5) { stayed = false; break; }
|
||||
}
|
||||
if (stayed) return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function printTrace(label, traj, demand_m3h) {
|
||||
console.log(`\n${label} (demand=${demand_m3h.toFixed(1)} m³/h)`);
|
||||
const head = [' t(s)'.padStart(7), 'pump_a'.padStart(8), 'pump_b'.padStart(8), 'pump_c'.padStart(8), 'Σ m³/h'.padStart(8), 'err'.padStart(7)];
|
||||
console.log(head.join(' '));
|
||||
console.log('─'.repeat(head.join(' ').length));
|
||||
for (const s of traj) {
|
||||
const err = s.sum - demand_m3h;
|
||||
console.log([
|
||||
(s.tMs / 1000).toFixed(2).padStart(7),
|
||||
s.perPump[0].toFixed(1).padStart(8),
|
||||
s.perPump[1].toFixed(1).padStart(8),
|
||||
s.perPump[2].toFixed(1).padStart(8),
|
||||
s.sum.toFixed(1).padStart(8),
|
||||
err.toFixed(1).padStart(7),
|
||||
].join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
// ── The diagnostic ──────────────────────────────────────────────────────
|
||||
|
||||
test('planner-convergence: mixed-state dispatch — sum reaches demand AND lands together', async () => {
|
||||
const { mgc, pumps } = buildGroup();
|
||||
const dyn = mgc.calcDynamicTotals();
|
||||
const flowMin_m3h = dyn.flow.min * 3600;
|
||||
const flowMax_m3h = dyn.flow.max * 3600;
|
||||
console.log(`\nStation envelope at head ${HEAD_MBAR_DOWN} mbar (${N_PUMPS} pumps): ${flowMin_m3h.toFixed(1)} .. ${flowMax_m3h.toFixed(1)} m³/h`);
|
||||
|
||||
// Phase 1: bring pump_a (only) to operational at a low setpoint via a
|
||||
// direct child command. This bypasses the optimizer and gives us a
|
||||
// deterministic mixed state: 1 running, 2 idle. We then drive a global
|
||||
// demand to ramp up — the planner must coordinate one in-flight retarget
|
||||
// with two startups.
|
||||
const pumpA = pumps[0];
|
||||
await pumpA.handleInput('parent', 'execsequence', 'startup');
|
||||
// wait for warmup to complete
|
||||
for (let i = 0; i < 200 && pumpA.state.getCurrentState() !== 'operational'; i++) await sleep(50);
|
||||
assert.equal(pumpA.state.getCurrentState(), 'operational', 'pre-condition: pump_a should be operational');
|
||||
|
||||
// Put pump_a at ~30% of its per-pump flow range. This guarantees the
|
||||
// optimizer's later combination will want pump_a to MOVE (either up to
|
||||
// share work with the new pumps, or down to balance them) — either
|
||||
// direction surfaces a rendezvous concern.
|
||||
const sample = pumpA.groupPredictFlow ?? pumpA.predictFlow;
|
||||
const perPumpMin_m3h = sample.currentFxyYMin * 3600;
|
||||
const perPumpMax_m3h = sample.currentFxyYMax * 3600;
|
||||
const initialFlow_m3h = perPumpMin_m3h + 0.30 * (perPumpMax_m3h - perPumpMin_m3h);
|
||||
await pumpA.handleInput('parent', 'flowmovement', initialFlow_m3h);
|
||||
await sleep(500); // let pump_a settle
|
||||
|
||||
const initialSnap = pumps.map((p) => ({ state: p.state.getCurrentState(), q: pumpFlow_m3h(p) }));
|
||||
console.log('\nInitial state (1 running, 2 idle):');
|
||||
for (let i = 0; i < pumps.length; i++) {
|
||||
console.log(` ${pumps[i].config.general.id}: ${initialSnap[i].state.padEnd(13)} Q=${initialSnap[i].q.toFixed(1)} m³/h`);
|
||||
}
|
||||
assert.equal(initialSnap[0].state, 'operational', 'pump_a operational at start');
|
||||
assert.equal(initialSnap[1].state, 'idle', 'pump_b idle at start');
|
||||
assert.equal(initialSnap[2].state, 'idle', 'pump_c idle at start');
|
||||
|
||||
// Phase 2: drive 90% demand — needs all 3 pumps.
|
||||
const demandPct = 90;
|
||||
const demand_m3s = pctToCanonical(mgc, demandPct);
|
||||
const demand_m3h = demand_m3s * 3600;
|
||||
console.log(`\nDispatching ${demandPct}% → ${demand_m3h.toFixed(1)} m³/h demand…`);
|
||||
|
||||
// Fire-and-don't-wait so we can sample DURING the move.
|
||||
mgc.handleInput('parent', demand_m3s).catch(() => {});
|
||||
|
||||
// Give the dispatcher a microtask + tick to plan, then dump the
|
||||
// schedule so we can see WHAT the planner produced (vs. what the
|
||||
// executor actually does).
|
||||
await sleep(60);
|
||||
const sched = mgc.movementExecutor.schedule();
|
||||
console.log(`\nPlanner schedule (tStar=${sched?.tStarS?.toFixed(2)}s, ${sched?.commands?.length} cmds):`);
|
||||
for (const c of (sched?.commands || [])) {
|
||||
console.log(` ${c.machineId.padEnd(8)} ${c.action.padEnd(13)} ${c.sequence ?? ('flow=' + (c.flow?.toFixed(1) ?? 'n/a')).padEnd(12)} fireAtTickN=${c.fireAtTickN} eta=${c.eta?.toFixed(2)}s`);
|
||||
}
|
||||
|
||||
// Sample for 8 seconds at 200 ms — long enough for tStar ≈ 3.5 s + ramp.
|
||||
const traj = await sampleFlows(pumps, 8000, 200);
|
||||
|
||||
printTrace('Per-pump flow trajectory', traj, demand_m3h);
|
||||
|
||||
// ── Question (a): does sum-of-flows converge to demand? ────────────
|
||||
const finalSum = traj[traj.length - 1].sum;
|
||||
const tolAbs = demand_m3h * 0.05; // 5% tolerance
|
||||
console.log(`\nFinal ΣQ = ${finalSum.toFixed(1)} m³/h vs demand ${demand_m3h.toFixed(1)} m³/h (tol ±${tolAbs.toFixed(1)})`);
|
||||
assert.ok(
|
||||
Math.abs(finalSum - demand_m3h) <= tolAbs,
|
||||
`(a) CONVERGENCE FAILED: final ΣQ=${finalSum.toFixed(1)} m³/h, demand=${demand_m3h.toFixed(1)} m³/h, err=${(finalSum - demand_m3h).toFixed(1)} m³/h (>${tolAbs.toFixed(1)})`,
|
||||
);
|
||||
|
||||
// ── Question (b): same-time landing? ───────────────────────────────
|
||||
//
|
||||
// For each pump, find when its flow first reached a stable value (its
|
||||
// own steady-state target). Compare the spread across the three pumps:
|
||||
// if they "land together", all arrival indices are within ~1 sample.
|
||||
const sampleTargets = pumps.map((_, i) => {
|
||||
// Use the LAST sample's flow as that pump's actual landing value.
|
||||
// We're measuring "when did this pump stop moving" not "did it hit
|
||||
// some externally-specified target" — that's what same-time-landing
|
||||
// is about.
|
||||
return traj[traj.length - 1].perPump[i];
|
||||
});
|
||||
const arrivalIdx = pumps.map((_, i) => {
|
||||
const series = traj.map((s) => s.perPump[i]);
|
||||
const tgt = sampleTargets[i];
|
||||
const tol = Math.max(2.0, Math.abs(tgt) * 0.05); // 5% or 2 m³/h, whichever larger
|
||||
return arrivalTimeMs(series, tgt, tol);
|
||||
});
|
||||
console.log('\nArrival index per pump (sample # where flow stabilises within 5%):');
|
||||
for (let i = 0; i < pumps.length; i++) {
|
||||
const idx = arrivalIdx[i];
|
||||
const t = idx == null ? 'NEVER' : `${(traj[idx].tMs / 1000).toFixed(2)} s`;
|
||||
console.log(` ${pumps[i].config.general.id}: idx=${idx}, t=${t}, finalQ=${sampleTargets[i].toFixed(1)} m³/h`);
|
||||
}
|
||||
const validIdx = arrivalIdx.filter((x) => x != null);
|
||||
assert.equal(validIdx.length, N_PUMPS, '(b) one or more pumps never landed on a stable flow');
|
||||
|
||||
const spreadSamples = Math.max(...validIdx) - Math.min(...validIdx);
|
||||
const spreadMs = spreadSamples * 200;
|
||||
console.log(`Same-time-landing spread: ${spreadSamples} samples = ${spreadMs} ms`);
|
||||
// Loose bound: within 1.5 s. A bigger spread means the schedule did
|
||||
// NOT bring the pumps to their setpoints together.
|
||||
assert.ok(
|
||||
spreadMs <= 1500,
|
||||
`(b) SAME-TIME LANDING FAILED: pumps landed ${spreadMs} ms apart (>1500 ms tolerance). ` +
|
||||
`This means flow-INCREASING moves on running pumps land BEFORE startup pumps reach operational.`,
|
||||
);
|
||||
});
|
||||
210
test/integration/planner-rendezvous.integration.test.js
Normal file
210
test/integration/planner-rendezvous.integration.test.js
Normal file
@@ -0,0 +1,210 @@
|
||||
// MGC + planner end-to-end integration. Proves the timing-aware
|
||||
// rendezvous schedule actually fires on real rotatingMachine objects
|
||||
// (not just the abstract scheduler unit tests).
|
||||
//
|
||||
// Layout mirrors idle-startup-deadlock.integration.test.js: three real
|
||||
// pump objects, a real MGC, registration via childRegistrationUtils. The
|
||||
// difference: instead of asserting end-state, we tap into the executor's
|
||||
// schedule + intercept fireCommand to record exact ordering.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const MachineGroup = require('../../src/specificClass');
|
||||
const Machine = require('../../../rotatingMachine/src/specificClass');
|
||||
|
||||
const HEAD_MBAR_UP = 0;
|
||||
const HEAD_MBAR_DOWN = 1100;
|
||||
const N_PUMPS = 3;
|
||||
|
||||
const LOG_DEBUG = process.env.LOG_DEBUG === '1';
|
||||
const logCfg = { enabled: LOG_DEBUG, logLevel: LOG_DEBUG ? 'debug' : 'error' };
|
||||
|
||||
const stateConfig = {
|
||||
general: { logging: logCfg },
|
||||
state: { current: 'idle' },
|
||||
movement: { mode: 'staticspeed', speed: 200, maxSpeed: 200, interval: 50 },
|
||||
time: { starting: 1, warmingup: 2, stopping: 1, coolingdown: 2 },
|
||||
};
|
||||
|
||||
function machineConfig(id) {
|
||||
return {
|
||||
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
|
||||
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 groupConfig() {
|
||||
return {
|
||||
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
|
||||
function pctToCanonical(mgc, pct) {
|
||||
if (pct < 0) return -1;
|
||||
const dt = mgc.calcDynamicTotals();
|
||||
return mgc.interpolation.interpolate_lin_single_point(pct, 0, 100, dt.flow.min, dt.flow.max);
|
||||
}
|
||||
|
||||
function buildGroup() {
|
||||
const mgc = new MachineGroup(groupConfig());
|
||||
const ids = Array.from({ length: N_PUMPS }, (_, i) => `pump_${String.fromCharCode(97 + i)}`);
|
||||
const pumps = ids.map((id) => new Machine(machineConfig(id), stateConfig));
|
||||
for (const m of pumps) {
|
||||
m.updateMeasuredPressure(HEAD_MBAR_UP, 'upstream', { timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: `up-${m.config.general.id}` });
|
||||
m.updateMeasuredPressure(HEAD_MBAR_DOWN, 'downstream', { timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: `dn-${m.config.general.id}` });
|
||||
mgc.childRegistrationUtils.registerChild(m, 'downstream');
|
||||
}
|
||||
mgc.calcAbsoluteTotals();
|
||||
mgc.calcDynamicTotals();
|
||||
return { mgc, pumps };
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Wrap the MGC's executor.fireCommand so we record every command in
|
||||
// timing order. Replaces the actual fireCommand so the test stays
|
||||
// hermetic (pumps don't actually move — we just verify the SCHEDULE).
|
||||
function tapExecutor(mgc) {
|
||||
const log = [];
|
||||
const originalFire = mgc.movementExecutor._fireCommand;
|
||||
mgc.movementExecutor._fireCommand = (cmd) => {
|
||||
log.push({ ...cmd, firedAtMs: Date.now() });
|
||||
// Still call the original so the FSM moves and the test stays realistic.
|
||||
try { originalFire(cmd); } catch (_) { /* ignore */ }
|
||||
};
|
||||
return log;
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('planner-integration: idle group → demand brings up all 3 pumps in lockstep', async () => {
|
||||
const { mgc, pumps } = buildGroup();
|
||||
const log = tapExecutor(mgc);
|
||||
|
||||
// 100% demand from idle → optimizer picks a 3-pump combination.
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(() => {});
|
||||
// Wait one tick so the executor's setInterval-driven follow-up ticks
|
||||
// (if any) have a chance to fire. Three-pump symmetric startup has
|
||||
// identical etas → tStar = max(eta) = eta itself → all commands at
|
||||
// fireAtTickN=0 → all fire synchronously.
|
||||
await sleep(50);
|
||||
|
||||
const startupCmds = log.filter((c) => c.action === 'execsequence' && c.sequence === 'startup');
|
||||
const flowCmds = log.filter((c) => c.action === 'flowmovement');
|
||||
|
||||
assert.equal(startupCmds.length, N_PUMPS, 'one startup per pump');
|
||||
assert.equal(flowCmds.length, N_PUMPS, 'one flowmovement per pump (queued via delayedMove)');
|
||||
// All startups must be fired in the same tick — i.e. roughly the same
|
||||
// wall-clock instant (within a few ms).
|
||||
const spread = Math.max(...startupCmds.map((c) => c.firedAtMs)) - Math.min(...startupCmds.map((c) => c.firedAtMs));
|
||||
assert.ok(spread < 50, `startup spread too wide: ${spread}ms`);
|
||||
});
|
||||
|
||||
test('planner-integration: rendezvous — startup pump fires immediately, retarget on running pump is delayed', async () => {
|
||||
// Bring up two pumps first; then change demand so the third pump
|
||||
// starts AND the two existing pumps shed load. The two running pumps'
|
||||
// flowmovement should be delayed so they land at the rendezvous time
|
||||
// matching the third pump's startup completion.
|
||||
|
||||
const { mgc, pumps } = buildGroup();
|
||||
|
||||
// Phase 1: low demand so optimizer picks a sub-set of pumps and at
|
||||
// least one stays idle. We try a few decreasing values until we find
|
||||
// one that leaves an idle pump (optimizer's combination choice is
|
||||
// sensitive to curve/pressure, hard to predict precisely).
|
||||
let idlePumpFound = false;
|
||||
for (const pct of [30, 20, 10, 5, 1]) {
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, pct)).catch(() => {});
|
||||
await sleep(4500);
|
||||
const states0 = pumps.map((p) => p.state.getCurrentState());
|
||||
if (states0.includes('idle')) { idlePumpFound = true; break; }
|
||||
}
|
||||
if (!idlePumpFound) {
|
||||
const finalStates = pumps.map((p) => p.state.getCurrentState());
|
||||
console.log(` (skipping) optimizer always picked all 3 pumps even at low demand: ${finalStates.join(',')}`);
|
||||
return; // optimizer behaviour denies us the scenario — not a failure of the planner.
|
||||
}
|
||||
|
||||
// Start tapping AFTER the first ramp settles — we only care about
|
||||
// the schedule from the next dispatch.
|
||||
const log = tapExecutor(mgc);
|
||||
|
||||
// Phase 2: drive to 100%. Now optimizer wants all 3 pumps. The idle
|
||||
// pump needs full startup; existing pumps adjust their flow.
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(() => {});
|
||||
// Wait long enough for the executor's wall-clock ticks to fire
|
||||
// delayed commands. tStar can be up to startingS + warmingupS + ramp
|
||||
// = 1 + 2 + 0.5 = 3.5s.
|
||||
await sleep(5000);
|
||||
|
||||
const startupCmds = log.filter((c) => c.action === 'execsequence' && c.sequence === 'startup');
|
||||
const flowCmds = log.filter((c) => c.action === 'flowmovement');
|
||||
|
||||
// We expect: at least one startup (for the idle pump) AND flow
|
||||
// adjustments on the running pumps. The exact split depends on
|
||||
// optimizer behaviour, so assert loosely.
|
||||
assert.ok(startupCmds.length >= 1, 'at least one startup expected for the idle pump');
|
||||
assert.ok(flowCmds.length >= 1, 'at least one flowmovement expected');
|
||||
|
||||
// The schedule snapshot stored on the executor should record a
|
||||
// positive tStar (rendezvous time).
|
||||
const lastSchedule = mgc.movementExecutor.schedule();
|
||||
assert.ok(lastSchedule, 'executor schedule should be set');
|
||||
// The schedule should have at least one increasing eta (the startup),
|
||||
// which sets tStar > 0.
|
||||
assert.ok(lastSchedule.tStarS > 0, `tStar should be > 0 when a startup is in the plan; got ${lastSchedule.tStarS}`);
|
||||
|
||||
// If any flowmovement on an EXISTING (then-operational) pump was a
|
||||
// down-move, its fireAtTickN should be > 0 (delayed). Find any such
|
||||
// command in the schedule.
|
||||
const delayedDownMoves = lastSchedule.commands.filter((c) => c.action === 'flowmovement' && c.fireAtTickN > 0);
|
||||
// Note: this assertion is "expected on most runs" rather than
|
||||
// "guaranteed every time" — depends on whether the optimizer picks a
|
||||
// combination that requires existing pumps to reduce. We assert the
|
||||
// schedule SHAPE (positive tStar) and accept that delayed-down moves
|
||||
// are common-but-not-mandatory.
|
||||
if (delayedDownMoves.length === 0) {
|
||||
// Surface a debug print if the run didn't exercise delayed moves —
|
||||
// helps when reading test logs to know what happened.
|
||||
console.log(' (planner-integration) note: no delayed down-moves this run — combination may have been all-up.');
|
||||
}
|
||||
});
|
||||
|
||||
test('planner-integration: replan drops unfired commands when a new demand arrives', async () => {
|
||||
const { mgc, pumps } = buildGroup();
|
||||
const log = tapExecutor(mgc);
|
||||
|
||||
// First demand: 100% from idle. tStar will be ~3.5s; all startup
|
||||
// cmds fire at tick 0 (synchronous), but if there were any delayed
|
||||
// down-moves, they'd be in the schedule.
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, 100)).catch(() => {});
|
||||
await sleep(100);
|
||||
const firstSnapshot = mgc.movementExecutor.schedule().commands.length;
|
||||
|
||||
// Immediately fire a second demand: 50%. Replan happens; some unfired
|
||||
// commands from the first schedule get dropped.
|
||||
mgc.handleInput('parent', pctToCanonical(mgc, 50)).catch(() => {});
|
||||
await sleep(100);
|
||||
|
||||
// Schedule was replaced.
|
||||
const secondSnapshot = mgc.movementExecutor.schedule();
|
||||
assert.ok(secondSnapshot, 'executor schedule replaced after replan');
|
||||
// Cursor reset to a low value (≤ a couple of ticks from the replan).
|
||||
assert.ok(mgc.movementExecutor.cursor() <= 2, `cursor should reset on replan; got ${mgc.movementExecutor.cursor()}`);
|
||||
// Sanity: replan didn't blow up the executor.
|
||||
assert.ok(firstSnapshot > 0, 'first dispatch should have queued at least one command');
|
||||
});
|
||||
@@ -9,14 +9,16 @@ function loadJson(file) {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
|
||||
}
|
||||
|
||||
const FLOW_FILES = ['01-Basic.json', '02-Dashboard.json'];
|
||||
|
||||
test('examples package exists for machineGroupControl', () => {
|
||||
for (const file of ['README.md', 'basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
|
||||
for (const file of ['README.md', ...FLOW_FILES]) {
|
||||
assert.equal(fs.existsSync(path.join(dir, file)), true, file + ' missing');
|
||||
}
|
||||
});
|
||||
|
||||
test('example flows are parseable arrays for machineGroupControl', () => {
|
||||
for (const file of ['basic.flow.json', 'integration.flow.json', 'edge.flow.json']) {
|
||||
for (const file of FLOW_FILES) {
|
||||
const parsed = loadJson(file);
|
||||
assert.equal(Array.isArray(parsed), true);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ function machineConfig(id) {
|
||||
return {
|
||||
general: { logging: logCfg, name: id, id, unit: 'm3/h' },
|
||||
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
|
||||
asset: { category: 'pump', type: 'centrifugal', model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
|
||||
asset: { model: 'hidrostal-H05K-S03R', unit: 'm3/h' },
|
||||
mode: {
|
||||
current: 'auto',
|
||||
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
|
||||
@@ -62,7 +62,6 @@ function groupConfig() {
|
||||
return {
|
||||
general: { logging: logCfg, name: 'mgc', id: 'mgc' },
|
||||
functionality: { softwareType: 'machinegroup', role: 'groupcontroller', positionVsParent: 'atEquipment' },
|
||||
scaling: { current: 'normalized' },
|
||||
mode: { current: 'optimalcontrol' },
|
||||
};
|
||||
}
|
||||
@@ -116,16 +115,27 @@ test('repeated turnOffAllMachines reaches idle (serializes concurrent shutdowns)
|
||||
'delayedMove must be cleared after shutdown');
|
||||
});
|
||||
|
||||
test('turnOffAllMachines clears MGC._delayedCall to cancel any deferred dispatch', async () => {
|
||||
test('turnOffAllMachines cancels any parked demand so it cannot re-engage pumps', async () => {
|
||||
// PS sends a 1% keep-alive while MGC is mid-dispatch. MGC parks it in
|
||||
// _delayedCall. PS then crosses stopLevel and calls turnOffAllMachines.
|
||||
// Without clearing _delayedCall, MGC's finally block fires the parked
|
||||
// 1% call AFTER the shutdown — re-engaging the pump.
|
||||
// its demand dispatcher's latest-wins slot. PS then crosses stopLevel
|
||||
// and calls turnOffAllMachines. Without cancelPending(), the parked
|
||||
// 1% call would fire AFTER the shutdown — re-engaging the pump.
|
||||
const { mgc } = buildGroup();
|
||||
mgc._delayedCall = { source: 'parent', demand: 1, powerCap: Infinity, priorityList: null };
|
||||
const gate = mgc._demandDispatcher._gate;
|
||||
// Pin a fake in-flight dispatch then park a pending call behind it.
|
||||
gate._inFlight = true;
|
||||
const parked = mgc.handleInput('parent', 1, Infinity, null);
|
||||
|
||||
await mgc.turnOffAllMachines();
|
||||
|
||||
assert.equal(mgc._delayedCall, null,
|
||||
'turnOff must cancel any deferred dispatch so it cannot re-engage pumps post-shutdown');
|
||||
// Re-open the gate: the in-flight pin is artificial. Awaiting the
|
||||
// parked promise must yield the SUPERSEDED sentinel (i.e. it was
|
||||
// cancelled, not run).
|
||||
const res = await parked;
|
||||
assert.ok(res && res.superseded === true,
|
||||
'parked demand must resolve as superseded after turnOffAllMachines cancels it');
|
||||
// Idle now — pending slot must be clear.
|
||||
assert.equal(gate._pending, null,
|
||||
'turnOff must cancel any parked demand so it cannot re-engage pumps post-shutdown');
|
||||
gate._inFlight = false;
|
||||
});
|
||||
|
||||
148
wiki/Home.md
Normal file
148
wiki/Home.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# machineGroupControl
|
||||
|
||||
  
|
||||
|
||||
A `machineGroupControl` (MGC) coordinates two or more `rotatingMachine` children that share a common header. It accepts an operator demand setpoint, enumerates the valid pump combinations against the group's live flow/power envelope, picks the best operating point (BEP-Gravitation by default), and schedules per-machine flow setpoints + start/stop commands with **timing-aware rendezvous** so the running aggregate stays close to demand during transitions.
|
||||
|
||||
---
|
||||
|
||||
## At a glance
|
||||
|
||||
| Thing | Value |
|
||||
|:---|:---|
|
||||
| What it represents | A pump group sharing one suction + one discharge header |
|
||||
| S88 level | Unit |
|
||||
| Use it when | You have 2 + pumps that can substitute for each other on the same header and you want efficient load-sharing |
|
||||
| Don't use it for | A single pump (wire `rotatingMachine` directly), valves (use `valveGroupControl`), or pumps living behind independent headers |
|
||||
| Children it accepts | `machine` (rotatingMachine), `measurement` (pressure / others) |
|
||||
| Parent it talks to | `pumpingStation` (typical), or any node that issues `set.demand` |
|
||||
|
||||
---
|
||||
|
||||
## How it fits
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
parent[pumpingStation<br/>Process Cell]:::pc -->|set.demand| mgc[machineGroupControl<br/>Unit]:::unit
|
||||
header[measurement<br/>header pressure]:::ctrl -.measured.-> mgc
|
||||
mgc -->|flowmovement / execsequence| m_a[rotatingMachine A]:::equip
|
||||
mgc -->|flowmovement / execsequence| m_b[rotatingMachine B]:::equip
|
||||
mgc -->|flowmovement / execsequence| m_c[rotatingMachine C]:::equip
|
||||
mgc -->|child.register| parent
|
||||
m_a -->|child.register| mgc
|
||||
m_b -->|child.register| mgc
|
||||
m_c -->|child.register| mgc
|
||||
classDef pc fill:#0c99d9,color:#fff
|
||||
classDef unit fill:#50a8d9,color:#000
|
||||
classDef equip fill:#86bbdd,color:#000
|
||||
classDef ctrl fill:#a9daee,color:#000
|
||||
```
|
||||
|
||||
S88 colours are anchored in `.claude/rules/node-red-flow-layout.md`.
|
||||
|
||||
---
|
||||
|
||||
## Try it — 3-minute demo
|
||||
|
||||
Import the basic example flow, deploy, and watch three pumps come online together when demand rises.
|
||||
|
||||
```bash
|
||||
curl -X POST -H 'Content-Type: application/json' \
|
||||
--data @nodes/machineGroupControl/examples/01-Basic.json \
|
||||
http://localhost:1880/flow
|
||||
```
|
||||
|
||||
What to click in the dashboard after deploy:
|
||||
|
||||
1. The Setup group auto-fires `virtualControl` + `cmd.startup` on each child pump after ~1.5 s.
|
||||
2. `set.demand = 50` (bare number = percent of group capacity) → MGC picks the best 1- or 2-pump combination by BEP-Gravitation.
|
||||
3. `set.demand = { value: 80, unit: "m3/h" }` → absolute-flow setpoint.
|
||||
4. `set.mode = priorityControl` → equal-flow distribution by priority order.
|
||||
5. `set.demand = -1` → operator stop-all; `turnOffAllMachines` cancels any pending dispatch and shuts every active pump down.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **GIF needed.** Demo recording of demand 50 % → 100 % → -1 with the live status panel. Save as `wiki/_partial-gifs/machineGroupControl/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
|
||||
|
||||
---
|
||||
|
||||
## The three things you'll send
|
||||
|
||||
`set.demand` is **unit-self-describing** — the payload itself decides how the value is interpreted. There is no persistent `scaling` state on the orchestrator.
|
||||
|
||||
| Topic | Aliases | Payload | What it does |
|
||||
|:---|:---|:---|:---|
|
||||
| `set.mode` | `setMode` | `"optimalControl"` \| `"priorityControl"` \| `"maintenance"` | Switches dispatch strategy. `maintenance` is monitoring-only. |
|
||||
| `set.demand` | `Qd` | bare number = %; `{value, unit}` for absolute units (`m3/h`, `l/s`, `m3/s`, …); negative = stop all | Operator demand setpoint. Resolves to canonical m³/s before dispatch. |
|
||||
| `child.register` | `registerChild` | child node id (string) | Manually register a child (Port 2 wiring does this automatically in most flows). |
|
||||
|
||||
---
|
||||
|
||||
## What you'll see come out
|
||||
|
||||
Sample Port 0 message (delta-compressed — only changed fields each tick):
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "machineGroupControl#MGC1",
|
||||
"payload": {
|
||||
"mode": "optimalControl",
|
||||
"atEquipment_predicted_flow": 42.5,
|
||||
"downstream_predicted_flow": 42.5,
|
||||
"atEquipment_predicted_power": 18.0,
|
||||
"headerDiffPa": 145000,
|
||||
"headerDiffMbar": 1450,
|
||||
"flowCapacityMax": 90,
|
||||
"flowCapacityMin": 6,
|
||||
"machineCount": 3,
|
||||
"machineCountActive": 2,
|
||||
"absDistFromPeak": 0.02,
|
||||
"relDistFromPeak": 0.10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
|:---|:---|
|
||||
| `mode` | Current dispatch mode. |
|
||||
| `atEquipment_predicted_flow` / `_power` | Group aggregate at the pump shafts. The optimizer writes intent here; `handlePressureChange` keeps it in sync with the live totals. |
|
||||
| `downstream_predicted_flow` | Live aggregate mirrored onto DOWNSTREAM — pumpingStation parents subscribe here. |
|
||||
| `headerDiffPa` / `headerDiffMbar` | Last header differential the equalizer resolved. Dashboards use it for Q-H plots without re-reading every child. |
|
||||
| `flowCapacityMax` / `flowCapacityMin` | The group's dynamic envelope at the current header pressure. Defines where `set.demand` (as %) maps to. |
|
||||
| `machineCount` / `machineCountActive` | All registered children, and how many are in a state other than `off` / `maintenance`. |
|
||||
| `absDistFromPeak` / `relDistFromPeak` | Distance from group BEP. `relDistFromPeak` is `undefined` when the η spread collapses (homogeneous pump group). |
|
||||
|
||||
The key shape is `<position>_<variant>_<type>` — the inverse of `rotatingMachine`'s `<type>.<variant>.<position>.<childId>` key shape, because MGC's output is the group aggregate, not a per-child snapshot.
|
||||
|
||||
---
|
||||
|
||||
## The new bit — the movement planner
|
||||
|
||||
When MGC computes a new optimal combination it doesn't fan the commands out instantly. It builds a **schedule** that times each command so the running aggregate stays close to demand during the transition.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
demand[set.demand] --> dispatch[_runDispatch<br/>latest-wins]
|
||||
dispatch --> abort[abortActiveMovements]
|
||||
abort --> opt[optimizer.calcBestCombination*]
|
||||
opt --> profiles[buildProfile<br/>x children]
|
||||
profiles --> plan[movementScheduler.plan<br/>rendezvous t* = max(eta_i)]
|
||||
plan --> exec[movementExecutor.replan<br/>+ await tick()]
|
||||
exec --> kids[rotatingMachine x N<br/>flowmovement / execsequence]
|
||||
```
|
||||
|
||||
The planner classifies each pump's required move (`startup` / `flowmove` / `shutdown` / `noop`), computes an ETA per move via `MoveTrajectory`, sets the rendezvous time `t* = max(eta_i)` over flow-INCREASING moves, and delays flow-DECREASING moves so they FINISH at `t*`. Net effect: the sum of flows tracks the demand smoothly during the transition; on overshoot the header pressure rises and self-corrects.
|
||||
|
||||
This path is exercised in `optimalControl` mode. `priorityControl` mode still uses the legacy direct-dispatch path (`control.equalFlowControl`) — the planner has not been wired through there yet.
|
||||
|
||||
---
|
||||
|
||||
## Need more?
|
||||
|
||||
| Page | What you'll find |
|
||||
|:---|:---|
|
||||
| [Reference — Contracts](Reference-Contracts) | Topic registry, config schema, child registration filters |
|
||||
| [Reference — Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals, output ports |
|
||||
| [Reference — Examples](Reference-Examples) | Shipped flows, debug recipes |
|
||||
| [Reference — Limitations](Reference-Limitations) | When not to use, known issues, open questions |
|
||||
|
||||
[EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home) · [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) · [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
|
||||
261
wiki/Reference-Architecture.md
Normal file
261
wiki/Reference-Architecture.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Reference — Architecture
|
||||
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> Code structure for `machineGroupControl`: the three-tier sandwich, the `src/` layout, the dispatch lifecycle, the movement planner that fans commands out, and the output-port pipeline. Everything here is reproducible from `src/`. For an intuitive overview, return to [Home](Home).
|
||||
|
||||
---
|
||||
|
||||
## Three-tier code layout
|
||||
|
||||
```
|
||||
nodes/machineGroupControl/
|
||||
|
|
||||
+-- mgc.js entry: RED.nodes.registerType('machineGroupControl', NodeClass)
|
||||
|
|
||||
+-- src/
|
||||
| nodeClass.js extends BaseNodeAdapter (Node-RED bridge)
|
||||
| specificClass.js extends BaseDomain (orchestration only)
|
||||
| |
|
||||
| +-- commands/
|
||||
| | index.js topic descriptors
|
||||
| | handlers.js pure handler functions (unit-self-describing set.demand)
|
||||
| |
|
||||
| +-- groupOps/
|
||||
| | groupOperatingPoint.js header equalisation + child read helpers
|
||||
| | groupCurves.js per-machine curve adapters used by optimizer + strategies
|
||||
| |
|
||||
| +-- totals/
|
||||
| | totalsCalculator.js absolute, dynamic, and active envelopes
|
||||
| |
|
||||
| +-- combinatorics/
|
||||
| | pumpCombinations.js enumerate valid pump subsets that can deliver Qd
|
||||
| |
|
||||
| +-- optimizer/
|
||||
| | index.js selector (CoG vs BEP-Gravitation variants)
|
||||
| | bestCombination.js N-CoG optimizer
|
||||
| | bepGravitation.js BEP-Gravitation (+ Directional variant)
|
||||
| |
|
||||
| +-- efficiency/
|
||||
| | groupEfficiency.js group η, BEP distance (abs + relative)
|
||||
| |
|
||||
| +-- control/
|
||||
| | strategies.js equalFlowControl (priority mode legacy direct dispatch)
|
||||
| |
|
||||
| +-- dispatch/
|
||||
| | demandDispatcher.js thin wrapper over LatestWinsGate.fireAndWait
|
||||
| |
|
||||
| +-- movement/
|
||||
| | machineProfile.js pure snapshot of a registered child for the planner
|
||||
| | moveTrajectory.js per-pump ETA-to-target math
|
||||
| | movementScheduler.js rendezvous planner (pure)
|
||||
| | movementExecutor.js tick-driven, async-aware command firer
|
||||
| |
|
||||
| +-- io/
|
||||
| output.js getOutput() shape + status badge
|
||||
```
|
||||
|
||||
### Tier responsibilities
|
||||
|
||||
| Tier | File | What it owns | Touches `RED.*` |
|
||||
|:---|:---|:---|:---:|
|
||||
| entry | `mgc.js` | Type registration | Yes |
|
||||
| nodeClass | `src/nodeClass.js` | Input routing, output ports, status badge polling (`statusInterval=1000`). No tick loop — event-driven. | Yes |
|
||||
| specificClass | `src/specificClass.js` | Wire concern modules in `configure()`; route demand through `DemandDispatcher`; pick mode in `_runDispatch`; own the planner's wall-clock driver. | No |
|
||||
|
||||
`specificClass` is stitching. All real work lives in the concern modules: pure math in `combinatorics/`, `optimizer/`, `efficiency/`, `movement/{moveTrajectory,movementScheduler}`; live-state-touching in `groupOps/`, `totals/`, `control/`, `dispatch/`, `movement/movementExecutor`.
|
||||
|
||||
---
|
||||
|
||||
## The dispatch lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant parent as pumpingStation / UI
|
||||
participant gate as DemandDispatcher (LatestWinsGate)
|
||||
participant disp as _runDispatch
|
||||
participant abort as abortActiveMovements
|
||||
participant opt as optimizer
|
||||
participant plan as movementScheduler
|
||||
participant exec as movementExecutor
|
||||
participant kids as rotatingMachine[]
|
||||
|
||||
parent->>gate: handleInput(Qd)
|
||||
Note over gate: latest-wins:<br/>parked demand is dropped if a fresher one arrives
|
||||
gate->>disp: payload.demand = canonical m³/s
|
||||
disp->>abort: abortActiveMovements('new demand')
|
||||
disp->>disp: calcDynamicTotals + clamp Qd to envelope
|
||||
alt mode = optimalControl
|
||||
disp->>opt: pickOptimizer(method).calcBestCombination*
|
||||
opt-->>disp: bestCombination + bestFlow / bestPower / bestCog
|
||||
disp->>plan: plan(profiles, combination, headerDiffPa)
|
||||
plan-->>disp: schedule {tStarS, tickS, commands[]}
|
||||
disp->>exec: replan(schedule)
|
||||
disp->>exec: await tick() (FIRST tick, synchronous race-favouring)
|
||||
Note over exec: setInterval(1000ms) drives further ticks<br/>auto-stops when pending() == 0
|
||||
else mode = priorityControl
|
||||
disp->>disp: control.equalFlowControl(ctx, Qd, powerCap, priorityList)
|
||||
Note over disp: Legacy direct fan-out:<br/>await Promise.all(...handleInput...)
|
||||
end
|
||||
exec->>kids: flowmovement / execsequence (per scheduled tick)
|
||||
disp->>disp: handlePressureChange-style refresh<br/>notifyOutputChanged
|
||||
```
|
||||
|
||||
Key facts the diagram pins down:
|
||||
|
||||
| Fact | Why it matters |
|
||||
|:---|:---|
|
||||
| Demand serialisation is **latest-wins**, not FIFO | A burst of demand updates collapses to a single dispatch. Parked demands resolve with `{ superseded: true }` so callers can branch on it. |
|
||||
| `abortActiveMovements` only aborts pumps in `accelerating` / `decelerating` | Warmup / cooldown are protected at the pump's FSM; aborting them is silently ignored there. |
|
||||
| `_runDispatch` **awaits the first executor tick** | Synchronous first-tick fire gives the new move's residue-handler priority over an in-flight shutdown sequence's for-loop. Fire-and-forget would lose the race in real wall-clock conditions. |
|
||||
| The 1 Hz `setInterval` only runs while `executor.pending() > 0` | Idle MGCs don't burn a forever-on timer. |
|
||||
| Negative demand goes straight to `turnOffAllMachines` | And `turnOffAllMachines` calls `dispatcher.cancelPending` so a parked positive demand can't re-engage pumps post-shutdown. |
|
||||
| `priorityControl` uses the legacy direct-dispatch path | The planner is not (yet) wired through `equalFlowControl`. See [Reference — Limitations](Reference-Limitations). |
|
||||
|
||||
---
|
||||
|
||||
## The movement planner
|
||||
|
||||
The planner is the new architectural layer between the optimizer and the children. It exists so that when MGC re-balances during transitions, the running aggregate flow stays close to demand instead of dipping while one pump warms up and another keeps spinning.
|
||||
|
||||
### 1. `buildProfile(child)` — pure read
|
||||
|
||||
A plain-object snapshot of a registered child machine. Returns:
|
||||
|
||||
| Field | Source | Notes |
|
||||
|:---|:---|:---|
|
||||
| `id` | `child.config.general.id` | |
|
||||
| `state` | `child.state.getCurrentState()` | One of `idle`, `starting`, `warmingup`, `operational`, `accelerating`, `decelerating`, `stopping`, `coolingdown`, `off`, `emergencystop`, `maintenance`. |
|
||||
| `position` | `child.state.getCurrentPosition()` | Control % (`0..100`). |
|
||||
| `minPosition` / `maxPosition` | `child.state.movementManager` | |
|
||||
| `velocityPctPerS` | `movementManager.getNormalizedSpeed() × range` | Movement ramp rate in position-units / second. |
|
||||
| `timings` | `child.config.stateConfig.time` | `{startingS, warmingupS, stoppingS, coolingdownS}` — the configured durations the FSM spends in each timed state. |
|
||||
| `remainingTransitionS` | `child.state.stateManager.getRemainingTransitionS()` | Wall-clock-aware remaining seconds in the current timed state. 0 for untimed states. |
|
||||
| `flowAt(pos, pressure)` | `child.predictFlow.evaluate` | Forward curve (position → flow). |
|
||||
| `positionForFlow(flow)` | `child.predictCtrl.y` | Inverse curve (flow → control %); mirrors what `flowController` does on a `flowmovement` command. |
|
||||
|
||||
No contract changes — MGC already holds the live child reference (`this.machines[id]`); the profile is just a read of that.
|
||||
|
||||
### 2. `MoveTrajectory` — per-pump ETA math
|
||||
|
||||
Given a profile and a `targetPosition`, `etaToTargetS()` returns seconds-to-target-flow:
|
||||
|
||||
| Current state | ETA |
|
||||
|:---|:---|
|
||||
| `idle` / `off` / `emergencystop` / `maintenance` | `startingS + warmingupS + (target − minPosition) / velocity` |
|
||||
| `operational` / `accelerating` / `decelerating` (post-abort residue) | `\|target − position\| / velocity` |
|
||||
| `warmingup` | `remainingTransitionS + (target − minPosition) / velocity` |
|
||||
| `starting` | `remainingTransitionS + warmingupS + (target − minPosition) / velocity` |
|
||||
| `stopping` / `coolingdown` | `null` — pump cannot contribute on this dispatch |
|
||||
|
||||
Velocity of 0 returns `Infinity` so the scheduler can demote the machine without crashing. Targets are clamped to `[minPosition, maxPosition]` at construction.
|
||||
|
||||
### 3. `movementScheduler.plan` — rendezvous
|
||||
|
||||
Pure function. Inputs: `(profiles[], combination, currentPressurePa, { tickS = 1 })`. Output:
|
||||
|
||||
```js
|
||||
{
|
||||
tStarS: 60, // rendezvous time in seconds
|
||||
tickS: 1, // tick cadence
|
||||
commands: [
|
||||
{ machineId: 'A', action: 'execsequence', sequence: 'startup', fireAtTickN: 0, eta: 60 },
|
||||
{ machineId: 'A', action: 'flowmovement', flow: 60, fireAtTickN: 0, eta: 60 },
|
||||
{ machineId: 'B', action: 'flowmovement', flow: 40, fireAtTickN: 40, eta: 20 },
|
||||
{ machineId: 'C', action: 'execsequence', sequence: 'shutdown', fireAtTickN: 55, eta: 5 }
|
||||
],
|
||||
_plans: [...] // per-machine classification + eta + direction; useful in tests
|
||||
}
|
||||
```
|
||||
|
||||
Algorithm:
|
||||
|
||||
1. **Classify** each machine's move against the optimizer's target flow:
|
||||
- `targetFlow > 0` and pump off → `startup`
|
||||
- `targetFlow > 0` and pump on (any active or startup-ladder state) → `flowmove`
|
||||
- `targetFlow <= 0` and pump on → `shutdown`
|
||||
- Otherwise → `noop`
|
||||
2. **Direction**: compare target flow against the pump's current flow (via `profile.flowAt`). Increasing, decreasing, or unchanged.
|
||||
3. **ETA**: `MoveTrajectory.etaToTargetS()` (or, for shutdowns, the position-ramp time to `minPosition`).
|
||||
4. **Rendezvous**: `t* = max(eta_i)` over flow-INCREASING moves.
|
||||
5. **Schedule**: increasing / unchanged moves fire at `fireAtTickN = 0`; decreasing moves fire at `fireAtTickN = round((t* − eta_j) / tickS)` so they finish at `t*`.
|
||||
|
||||
Net behaviour: during a transition the flow sum tracks demand smoothly. On overshoot, header pressure rises and individual pumps deliver less — a self-correcting undershoot. On undershoot, demand simply lands a few ticks later than ideal.
|
||||
|
||||
### 4. `MovementExecutor` — tick-driven, async-aware
|
||||
|
||||
Holds the active schedule plus a cursor (`_cursor`) that advances one per `tick()`. Each tick fires every unfired command whose `fireAtTickN <= cursor` via an injected `fireCommand` callback. The callback returns a Promise (in production, the `machine.handleInput(...)` promise); `tick()` awaits all of those before resolving.
|
||||
|
||||
`replan(newSchedule)` replaces the schedule and resets the cursor to 0. Already-fired commands stay fired — the pump's FSM downstream owns their consequences; the executor never tries to "undo" a fired startup (which keeps warmup / cooldown safety intact).
|
||||
|
||||
Wall-clock driver lives on the MGC itself (`_ensureExecutorTimer`): a `setInterval(1000)` that calls `tick()` and clears itself when `pending() === 0`. `unref()` keeps the timer from blocking Node-RED shutdown.
|
||||
|
||||
### 5. The cooperating FSM change (in `rotatingMachine`)
|
||||
|
||||
For the planner to be robust, the pump's `executeSequence` honours a **sequence-abort token** that MGC's external aborts advance. Without this, an in-flight shutdown's for-loop would race against the new dispatch's residue handler and could win — transitioning `operational → stopping → coolingdown → idle` even after the new move took the FSM operational.
|
||||
|
||||
See the rotatingMachine wiki's [Architecture — FSM section](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Reference-Architecture#fsm) for the full mechanism. Summary:
|
||||
|
||||
- `state.abortCurrentMovement(reason, { returnToOperational: false })` — the default form, used by MGC's `abortActiveMovements` — increments `state.sequenceAbortToken`.
|
||||
- `executeSequence` captures the token at entry and re-checks it before every state transition in its for-loop. A mismatch exits the loop early with a `Sequence '<name>' interrupted ... by external abort` warning.
|
||||
- Sequence-internal aborts (`returnToOperational: true`, used when a fresher shutdown pre-empts its own setpoint ramp) do NOT advance the token. So the shutdown's own ramp-down to zero is interruptible without terminating the shutdown sequence itself.
|
||||
|
||||
---
|
||||
|
||||
## Output ports
|
||||
|
||||
| Port | Carries | Sample shape |
|
||||
|:---|:---|:---|
|
||||
| 0 (process) | Delta-compressed state snapshot — group aggregates, header diff, BEP distance, machine counts | `{topic, payload: {mode, atEquipment_predicted_flow, headerDiffPa, machineCountActive, ...}}` |
|
||||
| 1 (telemetry) | InfluxDB line-protocol payload (same fields as Port 0) | `machineGroupControl,id=MGC1 atEquipment_predicted_flow=42.5,... ` |
|
||||
| 2 (register / control) | `child.register` upward at init | `{topic: 'child.register', payload: {ref, softwareType, config}}` |
|
||||
|
||||
Port-0 key shape is **`<position>_<variant>_<type>`** — group aggregates only. Per-pump series live on each `rotatingMachine`'s Port 0 (with the inverted `<type>.<variant>.<position>.<childId>` shape). Subscribe per-child if you need per-pump trends on a dashboard.
|
||||
|
||||
See [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) for the full InfluxDB layout.
|
||||
|
||||
---
|
||||
|
||||
## Event sources
|
||||
|
||||
| Source | Where it fires | What it triggers |
|
||||
|:---|:---|:---|
|
||||
| `setInterval(_executorIntervalMs = 1000)` | Driven by `_ensureExecutorTimer` after a successful `optimalControl` plan | `movementExecutor.tick()` |
|
||||
| `setInterval(statusInterval = 1000)` | `BaseNodeAdapter` | Status badge re-render |
|
||||
| Inbound `msg.topic` | Node-RED input wire | `commandRegistry` dispatch to `set.mode` / `set.demand` / `child.register` |
|
||||
| Child measurement event | `child.measurements.emitter` after a measurement landed | `handlePressureChange()` (for pressure) or value mirror (for everything else) |
|
||||
| Child prediction event | `child.emitter` "flow.predicted.downstream" | `handlePressureChange()` |
|
||||
| `child.register` from a pump | Port 2 of the pump | `onRegister('machine', ...)` — stores ref in `this.machines[id]` |
|
||||
|
||||
MGC has **no per-second tick of its own**. It's purely event-driven plus the planner's optional wall-clock executor.
|
||||
|
||||
---
|
||||
|
||||
## Where to start reading
|
||||
|
||||
| If you're changing... | Read first |
|
||||
|:---|:---|
|
||||
| The dispatch flow, latest-wins semantics, mode switch | `src/specificClass.js` `_runDispatch` (lines 318–349) |
|
||||
| Topic registration, payload validation | `src/commands/index.js` + `src/commands/handlers.js` |
|
||||
| Optimizer selection / scoring | `src/optimizer/index.js`, `bepGravitation.js`, `bestCombination.js` |
|
||||
| Header-pressure equalisation | `src/groupOps/groupOperatingPoint.js` `equalize()` |
|
||||
| Combination enumeration | `src/combinatorics/pumpCombinations.js` |
|
||||
| Per-pump ETA, rendezvous math | `src/movement/moveTrajectory.js`, `movementScheduler.js` |
|
||||
| Wall-clock tick wiring | `src/specificClass.js` `_ensureExecutorTimer` (lines 290–301) |
|
||||
| Output shape, status badge | `src/io/output.js` |
|
||||
| Priority-mode equal-flow distribution | `src/control/strategies.js` |
|
||||
|
||||
---
|
||||
|
||||
## Related pages
|
||||
|
||||
| Page | Why |
|
||||
|:---|:---|
|
||||
| [Home](Home) | Intuitive overview |
|
||||
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
||||
| [Reference — Limitations](Reference-Limitations) | Known issues and open questions |
|
||||
| [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home) | The child node: FSM, prediction, drift |
|
||||
| [EVOLV — Architecture](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Architecture) | Platform-wide three-tier pattern |
|
||||
196
wiki/Reference-Contracts.md
Normal file
196
wiki/Reference-Contracts.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Reference — Contracts
|
||||
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> Full topic contract, configuration schema, and child-registration filters for `machineGroupControl`. Source of truth: `src/commands/index.js`, `src/specificClass.js` `configure()`, and the schema at `generalFunctions/src/configs/machineGroupControl.json`.
|
||||
>
|
||||
> For an intuitive overview, return to the [Home](Home).
|
||||
|
||||
---
|
||||
|
||||
## Topic contract
|
||||
|
||||
The MGC accepts three canonical topics. `set.demand` is the only one with semantic content; the other two are simple state changes.
|
||||
|
||||
| Canonical topic | Aliases | Payload | Unit handling | Effect |
|
||||
|:---|:---|:---|:---|:---|
|
||||
| `set.mode` | `setMode` | `string` (`"optimalControl"` \| `"priorityControl"` \| `"maintenance"`) | — | Switch the dispatch strategy. `maintenance` is monitoring-only — the dispatch switch warns and skips. |
|
||||
| `set.demand` | `Qd` | bare number, OR `{value: number, unit: string}` | self-describing (see below) | Operator demand setpoint. Resolves to canonical m³/s, then enters the latest-wins gate. Negative value = stop all (any unit). |
|
||||
| `child.register` | `registerChild` | `string` (Node-RED node id) | — | Register a child machine manually. Port 2 wiring does this automatically in normal flows. |
|
||||
|
||||
### `set.demand` — unit-self-describing semantics
|
||||
|
||||
`src/commands/handlers.js` `setDemand`. The payload itself decides the meaning:
|
||||
|
||||
| Payload form | Interpretation |
|
||||
|:---|:---|
|
||||
| `42` (bare number) | 42 %. Mapped through `interpolation.interpolate_lin_single_point(value, 0, 100, dt.flow.min, dt.flow.max)` to a canonical m³/s, clamped to the dynamic envelope. |
|
||||
| `{value: 42, unit: '%'}` | Same as above — explicit-percent form. |
|
||||
| `{value: 80, unit: 'm3/h'}` (or `l/s` / `m3/s` / …) | Absolute flow. Converted via `convert(value).from(unit).to('m3/s')`. |
|
||||
| `42` or `{value: …, unit: 'm3/h'}` with `value < 0` | Triggers `turnOffAllMachines()` regardless of unit. |
|
||||
| Anything else (`NaN`, missing) | Logged at error level; dispatch is skipped. |
|
||||
|
||||
There is **no persistent `scaling` state** on the orchestrator. Each `set.demand` carries its own unit context; callers can switch between absolute and percent at will.
|
||||
|
||||
After a successful dispatch the handler replies on the input port with `{topic: <node.name>, payload: 'done'}` — the legacy "done" handshake some downstream flows still rely on.
|
||||
|
||||
---
|
||||
|
||||
## Data model — `getOutput()` shape
|
||||
|
||||
Composed each tick by `src/io/output.js` `getOutput()` and emitted via `outputUtils.formatMsg` on Port 0. Delta-compressed: consumers see only the keys that changed.
|
||||
|
||||
### Per-measurement keys
|
||||
|
||||
For every `(type, variant)` MeasurementContainer pair, the formatter emits **up to four keys** — one per position plus a differential when both upstream and downstream are present:
|
||||
|
||||
```
|
||||
<position>_<variant>_<type>
|
||||
```
|
||||
|
||||
Examples (with `variant=predicted`, `type=flow`):
|
||||
|
||||
| Key | Source |
|
||||
|:---|:---|
|
||||
| `downstream_predicted_flow` | Group aggregate at the discharge side. |
|
||||
| `atEquipment_predicted_flow` | Optimizer intent (what the controller's solving for). |
|
||||
| `upstream_predicted_flow` | Group suction-side aggregate (when populated). |
|
||||
| `differential_predicted_flow` | `downstream − upstream` when both legs read. |
|
||||
|
||||
Same shape for `pressure`, `power`, `temperature`, `efficiency`, `Ncog`. Output units are taken from the unit policy (`flow=m3/h`, `pressure=mbar`, `power=kW`, `temperature=°C`).
|
||||
|
||||
### Scalar group keys
|
||||
|
||||
| Key | Type | Source | Notes |
|
||||
|:---|:---|:---|:---|
|
||||
| `mode` | string | `mgc.mode` | Current dispatch mode. |
|
||||
| `scaling` | (legacy) | `mgc.scaling` | Always `undefined` in the current code — the orchestrator no longer carries a scaling field. Kept in the formatter for now; will be removed. |
|
||||
| `absDistFromPeak` | number | `mgc.efficiency.calcDistanceBEP` | Absolute η distance to the group "peak" (mean of per-pump cogs). |
|
||||
| `relDistFromPeak` | number \| undefined | same | Normalised 0..1; `undefined` when the η spread collapses (homogeneous pump group). |
|
||||
| `headerDiffPa` | number | `mgc.operatingPoint.headerDiffPa` | Last header differential the equaliser resolved. Pa. |
|
||||
| `headerDiffMbar` | number | derived | Only emitted when `output.pressure === 'mbar'`. |
|
||||
| `flowCapacityMax` / `flowCapacityMin` | number | `mgc.dynamicTotals.flow.{max,min}` | The group's current envelope at the active header pressure. |
|
||||
| `machineCount` | number | `Object.keys(mgc.machines).length` | All registered children. |
|
||||
| `machineCountActive` | number | derived | Children whose state ≠ `off` / `maintenance` and currentMode ≠ `maintenance`. |
|
||||
|
||||
### Status badge
|
||||
|
||||
`src/io/output.js` `getStatusBadge()` composes:
|
||||
|
||||
```
|
||||
<mode> · <scaling-abbrev> · Q=<flow>/<capacity> m³/h · P=<power> kW · <active>/<count>x
|
||||
```
|
||||
|
||||
Fill colour: `green` when any pump is available, `yellow` when machines are registered but all are off/maintenance, `grey` when no pumps are registered.
|
||||
|
||||
---
|
||||
|
||||
## Configuration schema — editor form to config keys
|
||||
|
||||
Source of truth: `generalFunctions/src/configs/machineGroupControl.json` plus `nodeClass.buildDomainConfig`.
|
||||
|
||||
### General (`config.general`)
|
||||
|
||||
| Form field | Config key | Default | Notes |
|
||||
|:---|:---|:---|:---|
|
||||
| Name | `general.name` | `Machine Group Configuration` | Human-readable label. |
|
||||
| (auto-assigned) | `general.id` | `null` | Node-RED node id; assigned at deploy. |
|
||||
| Default unit | `general.unit` | `m3/h` | Surfaces as the unit-policy output for `flow`. |
|
||||
| Enable logging | `general.logging.enabled` | `true` | Master logger switch. |
|
||||
| Log level | `general.logging.logLevel` | `info` | `debug` / `info` / `warn` / `error`. |
|
||||
|
||||
### Functionality (`config.functionality`)
|
||||
|
||||
| Form field | Config key | Default | Notes |
|
||||
|:---|:---|:---|:---|
|
||||
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | One of `atEquipment` / `upstream` / `downstream`. Used in the child-register payload. |
|
||||
| (hidden) | `functionality.softwareType` | `machinegroupcontrol` | Constant. |
|
||||
| (hidden) | `functionality.role` | `GroupController` | Constant. |
|
||||
| Distance offset | `functionality.distance` | `null` | Optional spatial offset; populated from the editor when `hasDistance` is enabled. |
|
||||
| Distance unit | `functionality.distanceUnit` | `m` | |
|
||||
| Distance description | `functionality.distanceDescription` | `""` | Free-text. |
|
||||
|
||||
### Output (`config.output`)
|
||||
|
||||
| Form field | Config key | Default | Range | Notes |
|
||||
|:---|:---|:---|:---|:---|
|
||||
| Process Output | `output.process` | `process` | `process` / `json` / `csv` | Port-0 formatter. |
|
||||
| Database Output | `output.dbase` | `influxdb` | `influxdb` / `json` / `csv` | Port-1 formatter. |
|
||||
|
||||
### Mode (`config.mode`)
|
||||
|
||||
| Form field | Config key | Default | Range | Where used |
|
||||
|:---|:---|:---|:---|:---|
|
||||
| Control mode | `mode.current` | `optimalControl` | `optimalControl` / `priorityControl` / `maintenance` | dispatch switch in `_runDispatch`; mode-source/-action gates in `commands/handlers.js`. |
|
||||
| (defaults) | `mode.allowedActions.optimalControl` | `[statusCheck, execOptimalCombination, balanceLoad, emergencyStop]` | — | Enforced at command-handler entry via `specificClass.isValidActionForMode`. |
|
||||
| (defaults) | `mode.allowedActions.priorityControl` | `[statusCheck, execSequentialControl, balanceLoad, emergencyStop]` | — | Same. |
|
||||
| (defaults) | `mode.allowedActions.maintenance` | `[statusCheck]` | — | Same — dispatch/emergencyStop are dropped with a warn log. |
|
||||
| (defaults) | `mode.allowedSources.optimalControl` | `["parent","GUI","physical","API"]` | — | Enforced via `specificClass.isValidSourceForMode`. |
|
||||
| (defaults) | `mode.allowedSources.priorityControl` | `["parent","GUI","physical","API"]` | — | Same. |
|
||||
| (defaults) | `mode.allowedSources.maintenance` | `["parent","GUI"]` | — | Physical/HMI and API writes dropped in maintenance — monitoring only. |
|
||||
|
||||
> [!NOTE]
|
||||
> `mode.current` is normalised at write time by `specificClass.setMode`: legacy lowercase inputs (`optimalcontrol`, `prioritycontrol`) are accepted and stored as the canonical camelCase. The `_runDispatch` switch then lowercases for its comparison — both forms reach the correct branch. Garbage modes (e.g. `'wat'`) are rejected with a warn log and the previous mode is preserved.
|
||||
>
|
||||
> Selecting `maintenance` no longer reaches `_runDispatch` at all in normal operation: the mode-action gate at `commands/handlers.js` drops the incoming `set.demand` before the dispatcher sees it. Status messages (`set.mode`, `child.register`) continue to flow.
|
||||
|
||||
### Unit policy
|
||||
|
||||
Source: `src/specificClass.js` lines 33–37.
|
||||
|
||||
| Quantity | Canonical (internal) | Output (rendered) | Required-unit |
|
||||
|:---|:---|:---|:---:|
|
||||
| Flow | `m3/s` | `m3/h` | ✓ |
|
||||
| Pressure | `Pa` | `mbar` | ✓ |
|
||||
| Power | `W` | `kW` | ✓ |
|
||||
| Temperature | `K` | `°C` | ✓ |
|
||||
|
||||
`requireUnitForTypes` means MeasurementContainer rejects writes without an explicit unit for these types — protects against accidentally writing raw numbers in the wrong scale.
|
||||
|
||||
---
|
||||
|
||||
## Child registration
|
||||
|
||||
Source: `src/specificClass.js` `configure()` lines 92–118.
|
||||
|
||||
| softwareType | Filter / subscribed events | Side-effect |
|
||||
|:---|:---|:---|
|
||||
| `machine` | `onRegister` stores the child in `this.machines[id]`. Subscribes to `pressure.measured.downstream`, `pressure.measured.differential`, and `flow.predicted.downstream` from the child's emitter. | Every event calls `handlePressureChange()` — equalises the header, recomputes dynamic totals, refreshes group η, fires `notifyOutputChanged()`. |
|
||||
| `measurement` | `onRegister` reads `asset.type` and `positionVsParent`, subscribes to `<type>.measured.<position>` on the child's measurement emitter. | Mirrors the value into MGC's own MeasurementContainer; pressure values additionally trigger `handlePressureChange()`. |
|
||||
|
||||
A child whose `asset.type` or `positionVsParent` is missing is logged at warn and skipped (not registered).
|
||||
|
||||
There is **no filter on `machinegroup` / `pumpingstation` children** — MGC is a leaf controller; it parents pumps but doesn't accept fellow aggregators.
|
||||
|
||||
---
|
||||
|
||||
## Header-pressure equalisation
|
||||
|
||||
Source: `src/groupOps/groupOperatingPoint.js` `equalize()`.
|
||||
|
||||
MGC ensures every registered child uses the **same** header differential pressure when computing predicted flow / power. Algorithm:
|
||||
|
||||
1. Read MGC's own group-scope pressure (downstream and upstream) from its MeasurementContainer.
|
||||
2. Read each child's measured pressure (downstream / upstream).
|
||||
3. Pick:
|
||||
- `headerDownstream` = group reading if positive, else `max` across children.
|
||||
- `headerUpstream` = group reading if positive, else `min` across children.
|
||||
4. If the differential is non-positive, skip the equalisation (debug log).
|
||||
5. Stash the diff on `this.headerDiffPa` (used by `getOutput` and by every η computation).
|
||||
6. Push the diff onto each child's `predictFlow.fDimension` / `predictPower.fDimension` / `predictCtrl.fDimension` — preferred path is `child.setGroupOperatingPoint(downstream, upstream)`, which lets the child re-build its `groupPredict*` interpolators. Older children fall back to a direct `fDimension` write.
|
||||
|
||||
The equaliser is called from `handlePressureChange` (on every child pressure / predicted-flow event) and from the start of `_optimalControl`.
|
||||
|
||||
---
|
||||
|
||||
## Related pages
|
||||
|
||||
| Page | Why |
|
||||
|:---|:---|
|
||||
| [Home](Home) | Intuitive overview |
|
||||
| [Reference — Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals |
|
||||
| [Reference — Examples](Reference-Examples) | Shipped flows |
|
||||
| [Reference — Limitations](Reference-Limitations) | Known issues and open questions |
|
||||
| [EVOLV — Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions) | Platform-wide topic rules |
|
||||
| [EVOLV — Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry) | Port 0 / 1 / 2 InfluxDB layout |
|
||||
155
wiki/Reference-Examples.md
Normal file
155
wiki/Reference-Examples.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Reference — Examples
|
||||
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> Every example flow shipped under `nodes/machineGroupControl/examples/`, plus how to load them, what they show, and the debug recipes that go with them. Live source: `nodes/machineGroupControl/examples/`.
|
||||
|
||||
---
|
||||
|
||||
## Shipped examples
|
||||
|
||||
| File | Tier | What it shows |
|
||||
|:---|:---:|:---|
|
||||
| `examples/01-Basic.json` | 1 | One MGC + three `rotatingMachine` pumps driven by inject buttons. A Setup group once-fires `virtualControl` + `cmd.startup` on all three pumps; mode / demand are then driven by buttons. |
|
||||
| `examples/02-Dashboard.json` | 2 | Same command surface driven by a FlowFuse Dashboard 2.0 page — mode buttons, demand slider, live status rows (mode / total flow / total power / capacity / active machines / BEP %), trend charts, and a raw-output table. |
|
||||
|
||||
MGC is not a standalone node — it needs at least one `rotatingMachine` child to dispatch to. Both flows ship three child pumps.
|
||||
|
||||
---
|
||||
|
||||
## Loading a flow
|
||||
|
||||
### Via the editor
|
||||
|
||||
1. Open the Node-RED editor at `http://localhost:1880`.
|
||||
2. Menu → Import.
|
||||
3. Drag-and-drop the JSON file, or paste its contents.
|
||||
4. Click Deploy.
|
||||
|
||||
### Via the Admin API
|
||||
|
||||
```bash
|
||||
curl -X POST -H 'Content-Type: application/json' \
|
||||
--data @nodes/machineGroupControl/examples/01-Basic.json \
|
||||
http://localhost:1880/flows
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 01 — Basic standalone
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Screenshot needed.** Capture of the basic flow in the editor. Save as `wiki/_partial-screenshots/machineGroupControl/01-basic-flow.png`. Replace this callout with the image link.
|
||||
|
||||
### Nodes on the tab
|
||||
|
||||
| Type | Purpose |
|
||||
|:---|:---|
|
||||
| `comment` | Tab header / instructions / driver-group labels |
|
||||
| `inject` | Setup auto-injects (virtualControl + cmd.startup per pump), mode buttons, demand-by-percent buttons, demand-by-absolute-unit buttons, stop-all button |
|
||||
| `machineGroupControl` | The unit under test |
|
||||
| `rotatingMachine` × 3 | Children A / B / C (each with its own simulated pressure pair) |
|
||||
| `debug` | Port 0 (process), Port 1 (telemetry), Port 2 (registration) per node |
|
||||
|
||||
### What to do after deploy
|
||||
|
||||
1. Wait ~1.5 s. The Setup group auto-fires `virtualControl` + `cmd.startup` on all three pumps.
|
||||
2. Click `set.demand = 50` (bare number = percent). MGC selects the best combination via BEP-Gravitation, plans a rendezvous, and dispatches `flowmovement` to the selected pumps.
|
||||
3. Click `set.demand = 100`. The optimizer probably engages a third pump; the planner schedules its `execsequence(startup)` at tick 0 and delays the running pumps' down-moves so they all hit their new targets together at `t*`.
|
||||
4. Click `set.mode = priorityControl`. Subsequent demands route through `equalFlowControl` — equal-flow per active pump in priority order. (Planner is bypassed in this mode — see [Limitations](Reference-Limitations).)
|
||||
5. Click `set.demand = {value: 80, unit: 'm3/h'}` (or use the absolute-unit button). Same path, but the percent-mapping step is skipped — the value lands on the gate as canonical m³/s directly.
|
||||
6. Click `set.demand = -1`. `turnOffAllMachines` runs: cancels any parked demand, sends `execsequence: 'shutdown'` to every active pump.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **GIF needed.** Demo of steps 1–6 with the live status panel. Save as `wiki/_partial-gifs/machineGroupControl/01-basic-demo.gif`, target ≤ 1 MB after `gifsicle -O3 --lossy=80`.
|
||||
|
||||
---
|
||||
|
||||
## Example 02 — Dashboard
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Screenshots needed.** Two captures from `02-Dashboard.json`:
|
||||
> 1. The editor tab (left controls column + MGC + 3 pumps + dashboard widget cluster on the right).
|
||||
> 2. The rendered dashboard at `http://localhost:1880/dashboard/mgc-basic`.
|
||||
>
|
||||
> Save as `wiki/_partial-screenshots/machineGroupControl/02-dashboard-editor.png` and `03-dashboard-rendered.png`. Replace this callout with both image links.
|
||||
|
||||
### What it adds vs Example 01
|
||||
|
||||
| Addition | Why |
|
||||
|:---|:---|
|
||||
| FlowFuse `ui-base` + `ui-theme` + `ui-page` setup | One dashboard page hosting four widget groups |
|
||||
| `ui-button` cluster (Controls) | Mode buttons, `Initialize pumps`, `Stop all` |
|
||||
| `ui-slider` (Demand) | Drag-to-set demand; passes through the same canonical `set.demand` topic the injects use |
|
||||
| `ui-text` cluster (Status) | Mode / total flow / total power / capacity / active machines / BEP % rows |
|
||||
| `ui-chart` × N (Trends) | Flow, power, BEP trends over time |
|
||||
| `ui-template` (Raw output) | Full key/value table of the latest Port 0 payload |
|
||||
| Fan-out function | Caches last-known values so delta-only Port 0 updates never blank a status row, and forwards numeric values to charts |
|
||||
|
||||
The dashboard buttons fire the **same canonical `msg.topic`** as the inject nodes in Example 01 — there is no separate dashboard command surface to learn.
|
||||
|
||||
Required: `@flowfuse/node-red-dashboard` (Dashboard 2.0) installed in the Node-RED instance.
|
||||
|
||||
### What to do after deploy
|
||||
|
||||
1. Open `http://localhost:1880/dashboard/mgc-basic`.
|
||||
2. The page auto-initialises the pumps; the `Initialize pumps` button re-runs the setup manually.
|
||||
3. Drag the **Demand** slider. The Status row's `total flow` and `BEP %` react; the trend charts plot the transition.
|
||||
4. Switch modes. The mode row in Status reflects the change immediately.
|
||||
5. Inspect the **Raw output** table for the full Port-0 surface — `headerDiffPa`, `flowCapacityMax`, `machineCountActive`, `relDistFromPeak`, …
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **GIF needed.** Capture clicking through demand 30 % → 80 % → -1 with the trends reacting. 30–45 s is enough.
|
||||
>
|
||||
> Save as `wiki/_partial-gifs/machineGroupControl/02-dashboard-demo.gif`. Replace this callout with the image link.
|
||||
|
||||
---
|
||||
|
||||
## Docker compose snippet
|
||||
|
||||
To bring up Node-RED + InfluxDB with EVOLV nodes pre-loaded:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (extract)
|
||||
services:
|
||||
nodered:
|
||||
build: ./docker/nodered
|
||||
ports: ['1880:1880']
|
||||
volumes:
|
||||
- ./docker/nodered/data:/data/evolv
|
||||
influxdb:
|
||||
image: influxdb:2.7
|
||||
ports: ['8086:8086']
|
||||
```
|
||||
|
||||
Full file: [EVOLV/docker-compose.yml](https://gitea.wbd-rd.nl/RnD/EVOLV/src/branch/development/docker-compose.yml).
|
||||
|
||||
---
|
||||
|
||||
## Debug recipes
|
||||
|
||||
| Symptom | First thing to check | Where to look |
|
||||
|:---|:---|:---|
|
||||
| `mode is not a valid mode` warns every dispatch | `mode.current` is `maintenance` (or a typo). Reset to `optimalControl` or `priorityControl`. | `_runDispatch` switch. |
|
||||
| `No valid combination found (empty set)` | Demand outside the dynamic envelope, OR every child filtered out (state in `off / coolingdown / stopping / emergencystop` or `auto`-mode rejects the action). | `validPumpCombinations` + state of each child. |
|
||||
| Group flow stuck at zero after `set.demand` | Pumps never reached an active state — check per-pump startup logs. | Each pump's `state` on its Port 0. |
|
||||
| Pump warmingup, but then drops back to idle when demand keeps changing | Pre-2026-05-15 race condition: shutdown's for-loop barged through after a residue-handler operational transition. The fix is the `sequenceAbortToken` mechanism in rotatingMachine's FSM. Verify the rotatingMachine submodule is at `394a972` or newer. | rotatingMachine `state/sequenceController.js`. |
|
||||
| Header pressure not equalising | Pressure children must register with `asset.type='pressure'` and a matching `positionVsParent`. Pure-numeric pressures with no unit are rejected by MeasurementContainer. | `operatingPoint.equalize`. |
|
||||
| Optimiser picks unexpected combination | Verify `optimization.method` — default is `BEP-Gravitation-Directional`. Per-method scoring lives in `optimizer/`. | `optimizer/{bestCombination, bepGravitation}.js`. |
|
||||
| Status badge shows `scaling=norm` even after a unit-tagged demand | Badge cosmetic only — the `scaling` field is a legacy artifact and currently always reads `norm`. The dispatch path is unit-self-describing. | `io/output.js` `getStatusBadge`. |
|
||||
| Per-pump flow / power trends missing | MGC only emits group aggregates on Port 0. Subscribe to each `rotatingMachine`'s Port 0 if you need per-pump series. | `io/output.js` `getOutput`. |
|
||||
|
||||
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors.
|
||||
|
||||
---
|
||||
|
||||
## Related pages
|
||||
|
||||
| Page | Why |
|
||||
|:---|:---|
|
||||
| [Home](Home) | Intuitive overview |
|
||||
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||
| [Reference — Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals |
|
||||
| [Reference — Limitations](Reference-Limitations) | Known issues and open questions |
|
||||
| [EVOLV — Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns) | Where this node fits in a larger plant |
|
||||
128
wiki/Reference-Limitations.md
Normal file
128
wiki/Reference-Limitations.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Reference — Limitations
|
||||
|
||||

|
||||
|
||||
> [!NOTE]
|
||||
> What `machineGroupControl` does not do, current rough edges, and open questions. The planner-decline question is tracked as Gitea issue `RnD/machineGroupControl#1`; other open items live in `.agents/improvements/IMPROVEMENTS_BACKLOG.md` in the superproject.
|
||||
|
||||
---
|
||||
|
||||
## When you would not use this node
|
||||
|
||||
| Scenario | Use instead |
|
||||
|:---|:---|
|
||||
| A single pump | Wire `rotatingMachine` directly under your parent. MGC's combinatorics + totals add no value below N=2. |
|
||||
| Valves (no curve, no FSM-driven motor) | `valveGroupControl`. MGC's optimizer assumes a flow-vs-pressure characteristic. |
|
||||
| Pumps behind independent headers | Multiple MGCs (one per header), each parented to its own logical aggregator. The equaliser assumes a shared discharge / suction pressure. |
|
||||
| Curve-less assets | Without a curve, `optimalControl` excludes the machine from every combination; the dispatch loop falls into the empty-set branch and warns each tick. |
|
||||
| Mixed compressor + pump groups | The optimizer is curve-agnostic in principle, but the η = (Q·ΔP)/P_shaft identity used in `_optimalControl` assumes an incompressible-flow head. Use separate MGCs per phase. |
|
||||
|
||||
---
|
||||
|
||||
## Known limitations
|
||||
|
||||
### `maintenance` mode is in the schema but not in the dispatch switch
|
||||
|
||||
`config.mode.current` accepts `maintenance` as a valid value (per the schema enum), but `_runDispatch`'s mode switch only handles `optimalcontrol` and `prioritycontrol`. Picking `maintenance` will log `'maintenance' is not a valid mode.` on every demand. Treated as schema-vs-code drift, not a runtime bug.
|
||||
|
||||
### `priorityControl` bypasses the movement planner
|
||||
|
||||
`equalFlowControl` (the priority-mode strategy) still uses the legacy direct-dispatch path:
|
||||
|
||||
```js
|
||||
await Promise.all(flowDistribution.map(async ({ machineId, flow }) => {
|
||||
if (flow > 0) {
|
||||
await machine.handleInput('parent', 'flowmovement', ...);
|
||||
if (currentState === 'idle') await machine.handleInput('parent', 'execsequence', 'startup');
|
||||
} else { ... shutdown ... }
|
||||
}));
|
||||
```
|
||||
|
||||
The planner is only wired through `optimalControl`. Consequence: priority-mode transitions can show a flow dip while one pump warms up and another keeps spinning. Tracked for a future pass; the planner's API is mode-agnostic so the surgery is straightforward when priorities allow.
|
||||
|
||||
### `mgc.scaling` is undefined
|
||||
|
||||
The orchestrator no longer carries a `scaling` field — `set.demand` is unit-self-describing per message. The `io/output.js` formatter still references `mgc.scaling`, which always reads `undefined`. The status-badge cosmetically displays `norm`. This is a leftover artifact of the pre-refactor design; harmless, scheduled for removal.
|
||||
|
||||
### Group efficiency naming — `maxEfficiency` is the **mean**, not the peak
|
||||
|
||||
`GroupEfficiency.calcGroupEfficiency` returns `{ maxEfficiency, lowestEfficiency }`. `maxEfficiency` is the **mean cog** across all machines, not the maximum. The name is preserved for behavioural parity with the pre-refactor code; callers using it as "the peak" will over-estimate the BEP target. Tracked — rename is a follow-up.
|
||||
|
||||
### `calcAbsoluteTotals` implicit pressure coupling
|
||||
|
||||
`TotalsCalculator.calcAbsoluteTotals` iterates a machine's `predictFlow.inputCurve` and re-indexes the SAME pressure key into `predictPower.inputCurve`. If the two curves were sampled at different pressures the lookup is `undefined` and the call throws. Mitigation deferred to the rotatingMachine curveLoader pass (P5).
|
||||
|
||||
### Power-cap parameter has no canonical topic
|
||||
|
||||
`handleInput(source, demand, powerCap)` accepts a `powerCap` argument and threads it to `validPumpCombinations`, but there is no `set.power-cap` topic in `commands/index.js`. Only programmatic callers can set it. Tracked.
|
||||
|
||||
### Per-pump fan-out not on Port 0
|
||||
|
||||
MGC's Port 0 carries the group aggregate only (`atEquipment_predicted_flow`, `headerDiffPa`, etc.). If you want per-pump trends on a dashboard you must wire each `rotatingMachine`'s Port 0 separately. By design — the alternative would put N × M fields on the MGC payload.
|
||||
|
||||
### Curve-less members silently drop out
|
||||
|
||||
`combinatorics/pumpCombinations.validPumpCombinations` filters by FSM state and mode but not by curve presence. A machine with `predictFlow === null` (because its curve loader failed at startup) has `currentFxyYMin / Max = 0`, so its contribution to subset envelopes is zero. It can still appear in subsets — the optimizer just gives it zero flow. The drop-out is silent; the only signal is the curve-loader's error log at startup.
|
||||
|
||||
---
|
||||
|
||||
## Open questions (tracked)
|
||||
|
||||
| Question | Where it lives |
|
||||
|:---|:---|
|
||||
| Should the planner ever decline a combination when the slowest startup exceeds an SLA on demand spikes? | [machineGroupControl#1](https://gitea.wbd-rd.nl/RnD/machineGroupControl/issues/1) |
|
||||
| Wire the movement planner through `priorityControl` | Internal — not yet ticketed |
|
||||
| Remove the `mgc.scaling` artifact + the `scaling` badge field | Internal |
|
||||
| Rename `maxEfficiency` → `meanGroupCog` in `GroupEfficiency` | Internal |
|
||||
| Decline-and-fall-back vs always-commit on planner level | Same as the Gitea issue above |
|
||||
|
||||
---
|
||||
|
||||
## Migration notes
|
||||
|
||||
### From pre-planner
|
||||
|
||||
The MGC's `_optimalControl` used to fan commands out inline (lines 226–239 in `26e92b5^`):
|
||||
|
||||
```js
|
||||
await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => {
|
||||
if (flow > 0) {
|
||||
await machine.handleInput('parent', 'flowmovement', ...);
|
||||
if (state === 'idle') await machine.handleInput('parent', 'execsequence', 'startup');
|
||||
} else if (ACTIVE_STATES.has(state)) {
|
||||
await machine.handleInput('parent', 'execsequence', 'shutdown');
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
That code is gone. The new path: build profiles → `scheduler.plan` → `executor.replan` → `await executor.tick()` (synchronous first tick) → `setInterval(1000)` for the rest. The flow / power numbers and the optimizer's pick are unchanged; only the **timing** of the per-pump commands changed.
|
||||
|
||||
If your test fixture relied on commands firing inline during `_runDispatch`, the new behaviour fires `fireAtTickN=0` commands synchronously inside the first `await executor.tick()` and later ones on the wall-clock interval. Tests that asserted exact timing should use the `executor.schedule()` introspection getter.
|
||||
|
||||
### From pre-unit-self-describing demand
|
||||
|
||||
The old `set.scaling` topic and its persistent `scaling.current` config field have been removed. Each `set.demand` now carries its own unit context:
|
||||
|
||||
| Pre | Post |
|
||||
|:---|:---|
|
||||
| `set.scaling = "absolute"`; `set.demand = 80` | `set.demand = {value: 80, unit: "m3/h"}` |
|
||||
| `set.scaling = "normalized"`; `set.demand = 50` | `set.demand = 50` (bare number = %) |
|
||||
| `set.scaling = "absolute"`; `set.demand = 0.022` (m³/s) | `set.demand = {value: 0.022, unit: "m3/s"}` |
|
||||
|
||||
Old flows that still send `set.scaling` will silently ignore it; the topic is no longer registered.
|
||||
|
||||
### From `prioritypercentagecontrol`
|
||||
|
||||
The mode `prioritypercentagecontrol` was retired with the unit-self-describing refactor. Use `priorityControl` with absolute-unit `set.demand` payloads, or `optimalControl` with the same.
|
||||
|
||||
---
|
||||
|
||||
## Related pages
|
||||
|
||||
| Page | Why |
|
||||
|:---|:---|
|
||||
| [Home](Home) | Intuitive overview |
|
||||
| [Reference — Contracts](Reference-Contracts) | Topic + config + child filters |
|
||||
| [Reference — Architecture](Reference-Architecture) | Code map, dispatch lifecycle, planner internals |
|
||||
| [Reference — Examples](Reference-Examples) | Shipped flows + debug recipes |
|
||||
| [rotatingMachine — Limitations](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Reference-Limitations) | The child's own limitations (drift, multi-parent, virtual-child stale data) |
|
||||
19
wiki/_Sidebar.md
Normal file
19
wiki/_Sidebar.md
Normal file
@@ -0,0 +1,19 @@
|
||||
### machineGroupControl
|
||||
|
||||
- [Home](Home)
|
||||
|
||||
**Reference**
|
||||
|
||||
- [Contracts](Reference-Contracts)
|
||||
- [Architecture](Reference-Architecture)
|
||||
- [Examples](Reference-Examples)
|
||||
- [Limitations](Reference-Limitations)
|
||||
|
||||
**Related**
|
||||
|
||||
- [EVOLV master wiki](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Home)
|
||||
- [rotatingMachine wiki](https://gitea.wbd-rd.nl/RnD/rotatingMachine/wiki/Home)
|
||||
- [pumpingStation wiki](https://gitea.wbd-rd.nl/RnD/pumpingStation/wiki/Home)
|
||||
- [Topology Patterns](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topology-Patterns)
|
||||
- [Topic Conventions](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Topic-Conventions)
|
||||
- [Telemetry](https://gitea.wbd-rd.nl/RnD/EVOLV/wiki/Telemetry)
|
||||
Reference in New Issue
Block a user