Compare commits
11 Commits
main
...
26e92b54f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26e92b54f7 | ||
|
|
d238270530 | ||
|
|
4cb9c5084c | ||
|
|
05de4ee29a | ||
|
|
7d19fc1db0 | ||
|
|
3ee1939b0a | ||
|
|
31324ae82d | ||
|
|
0e8cab5d3f | ||
|
|
045a941ab4 | ||
|
|
bb2f3bea82 | ||
|
|
619b1311d2 |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# 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
|
||||
71
CONTRACT.md
Normal file
71
CONTRACT.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 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 `prioritycontrol`, `optimalcontrol`, `dynamiccontrol`, … | Switches the control strategy via `source.setMode(payload)`. |
|
||||
| `set.scaling` | `setScaling` | `string` — one of `absolute`, `normalized` | Sets the demand-scaling convention via `source.setScaling(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"
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
1750
examples/02-Dashboard.json
Normal file
1750
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":[]}
|
||||
]
|
||||
21
mgc.html
21
mgc.html
@@ -21,6 +21,9 @@
|
||||
processOutputFormat: { value: "process" },
|
||||
dbaseOutputFormat: { value: "influxdb" },
|
||||
|
||||
// Control strategy
|
||||
mode: { value: "optimalControl" }, // optimalControl | priorityControl | maintenance
|
||||
|
||||
//define asset properties
|
||||
uuid: { value: "" },
|
||||
supplier: { value: "" },
|
||||
@@ -40,7 +43,7 @@
|
||||
distance: { value: 0 },
|
||||
distanceUnit: { value: "m" },
|
||||
distanceDescription: { value: "" }
|
||||
|
||||
|
||||
},
|
||||
inputs:1,
|
||||
outputs:3,
|
||||
@@ -84,6 +87,22 @@
|
||||
|
||||
<script type="text/html" data-template-name="machineGroupControl">
|
||||
|
||||
<h3>Control strategy</h3>
|
||||
<div class="form-row">
|
||||
<label for="node-input-mode"><i class="fa fa-cogs"></i> Mode</label>
|
||||
<select id="node-input-mode" style="width:60%;">
|
||||
<option value="optimalControl">optimalControl — pick the best valid pump combination by BEP-gravitation / NCog</option>
|
||||
<option value="priorityControl">priorityControl — sequential equal-flow control by priority list</option>
|
||||
<option value="maintenance">maintenance — monitoring only, no dispatch</option>
|
||||
</select>
|
||||
</div>
|
||||
<p style="margin-top:8px;color:#666;font-size:11px;">
|
||||
Demand is self-describing per <code>set.demand</code> message: a bare number is
|
||||
treated as % of group capacity; <code>{value, unit}</code> with a flow unit
|
||||
(<code>m3/h</code>, <code>l/s</code>, <code>m3/s</code>, …) is dispatched
|
||||
in absolute terms. Negative value stops all pumps.
|
||||
</p>
|
||||
|
||||
<h3>Output Formats</h3>
|
||||
<div class="form-row">
|
||||
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||
|
||||
@@ -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 };
|
||||
101
src/commands/handlers.js
Normal file
101
src/commands/handlers.js
Normal file
@@ -0,0 +1,101 @@
|
||||
'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.
|
||||
|
||||
const { convert } = require('generalFunctions');
|
||||
|
||||
function _logger(source, ctx) {
|
||||
return ctx?.logger || source?.logger || null;
|
||||
}
|
||||
|
||||
exports.setMode = (source, msg) => {
|
||||
source.setMode(msg.payload);
|
||||
};
|
||||
|
||||
exports.registerChild = (source, msg, ctx) => {
|
||||
const log = _logger(source, ctx);
|
||||
const childId = msg.payload;
|
||||
const childObj = ctx?.RED?.nodes?.getNode?.(childId);
|
||||
if (!childObj || !childObj.source) {
|
||||
log?.warn?.(`registerChild: child '${childId}' not found or has no .source`);
|
||||
return;
|
||||
}
|
||||
source.childRegistrationUtils.registerChild(childObj.source, msg.positionVsParent);
|
||||
};
|
||||
|
||||
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
|
||||
//
|
||||
// The handler is the only place that resolves units. _runDispatch sees a
|
||||
// single canonical m³/s number and never branches on scaling.
|
||||
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;
|
||||
}
|
||||
// Negative is the operator's "stop all" signal regardless of unit.
|
||||
if (value < 0) {
|
||||
try {
|
||||
await source.turnOffAllMachines();
|
||||
} catch (err) {
|
||||
log?.error?.(`set.demand: turnOffAllMachines failed: ${err && err.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Resolve to canonical m³/s.
|
||||
let canonicalDemand;
|
||||
if (unit === '%') {
|
||||
const dt = source.calcDynamicTotals();
|
||||
// Linear interpolation: 0 % → dt.flow.min, 100 % → dt.flow.max. The
|
||||
// interpolation helper also clamps so 110 % can't run pumps past max.
|
||||
canonicalDemand = source.interpolation.interpolate_lin_single_point(
|
||||
value, 0, 100, dt.flow.min, dt.flow.max);
|
||||
} else {
|
||||
try {
|
||||
canonicalDemand = convert(value).from(unit).to('m3/s');
|
||||
} catch (err) {
|
||||
log?.error?.(`set.demand: cannot convert ${value} ${unit} → m3/s: ${err && err.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await source.handleInput('parent', canonicalDemand);
|
||||
} 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 machine group between auto / manual modes.',
|
||||
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,
|
||||
},
|
||||
];
|
||||
185
src/control/strategies.js
Normal file
185
src/control/strategies.js
Normal file
@@ -0,0 +1,185 @@
|
||||
'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);
|
||||
|
||||
await Promise.all(flowDistribution.map(async ({ machineId, flow }) => {
|
||||
const machine = mgc.machines[machineId];
|
||||
const currentState = machine.state.getCurrentState();
|
||||
if (flow > 0) {
|
||||
await machine.handleInput('parent', 'flowmovement', mgc._canonicalToOutputFlow(flow));
|
||||
if (currentState === 'idle') {
|
||||
await machine.handleInput('parent', 'execsequence', 'startup');
|
||||
}
|
||||
} else if (currentState === 'operational' || currentState === 'accelerating' || currentState === 'decelerating') {
|
||||
await machine.handleInput('parent', 'execsequence', 'shutdown');
|
||||
}
|
||||
}));
|
||||
} 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;
|
||||
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);
|
||||
|
||||
96
src/io/output.js
Normal file
96
src/io/output.js
Normal file
@@ -0,0 +1,96 @@
|
||||
'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.
|
||||
out.flowCapacityMax = mgc.dynamicTotals?.flow?.max ?? 0;
|
||||
out.flowCapacityMin = mgc.dynamicTotals?.flow?.min ?? 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;
|
||||
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 };
|
||||
294
src/nodeClass.js
294
src/nodeClass.js
@@ -1,280 +1,26 @@
|
||||
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;
|
||||
}
|
||||
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();
|
||||
});
|
||||
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 };
|
||||
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,
|
||||
};
|
||||
1994
src/specificClass.js
1994
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;
|
||||
130
test/_output-manifest.md
Normal file
130
test/_output-manifest.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# 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) | string ∈ {`optimalcontrol`, `prioritycontrol`, …} | 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) | number m³/s ≥ 0 | totalsCalculator.basic, dashboard-fanout (post-setup) | absent until first equalize; dashboard-fanout (state A) |
|
||||
| `flowCapacityMin` | `mgc.dynamicTotals.flow.min` | number m³/s ≥ 0 | totalsCalculator.basic | same as above |
|
||||
| `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) |
|
||||
|
||||
### 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`.
|
||||
|
||||
---
|
||||
|
||||
## 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);
|
||||
});
|
||||
194
test/basic/commands.basic.test.js
Normal file
194
test/basic/commands.basic.test.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// 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 } } } = {}) {
|
||||
const calls = {
|
||||
setMode: [],
|
||||
handleInput: [],
|
||||
registerChild: [],
|
||||
turnOffAllMachines: 0,
|
||||
};
|
||||
const source = {
|
||||
logger: makeLogger(),
|
||||
config: { general: { name } },
|
||||
setMode: (m) => calls.setMode.push(m),
|
||||
handleInput: async (src, demand) => {
|
||||
calls.handleInput.push({ src, demand });
|
||||
if (handleInputResult instanceof Error) throw handleInputResult;
|
||||
return handleInputResult;
|
||||
},
|
||||
// Used by set.demand handler when unit is %: needs dt.flow + interpolation.
|
||||
// With min=0, max=100, the linear interpolation is identity so a bare
|
||||
// numeric demand round-trips through handleInput unchanged.
|
||||
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)}`
|
||||
);
|
||||
});
|
||||
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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
125
test/integration/bep-distance-demand-sweep.integration.test.js
Normal file
125
test/integration/bep-distance-demand-sweep.integration.test.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// 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 };
|
||||
}
|
||||
|
||||
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 new Promise(r => setTimeout(r, 30));
|
||||
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).`);
|
||||
});
|
||||
240
test/integration/dashboard-fanout.integration.test.js
Normal file
240
test/integration/dashboard-fanout.integration.test.js
Normal file
@@ -0,0 +1,240 @@
|
||||
// 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 17-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,
|
||||
};
|
||||
|
||||
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 17 outputs and wires array matches', () => {
|
||||
assert.equal(fn.outputs, 17);
|
||||
assert.equal(fn.wires.length, 17);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,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 +72,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 +406,14 @@ 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);
|
||||
const optPower = mg.measurements.type('power').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
const optFlow = mg.measurements.type('flow').variant('predicted').position('atequipment').getCurrentValue() || 0;
|
||||
|
||||
@@ -422,7 +425,7 @@ test('full MGC optimalControl uses ≤ power than priorityControl for mixed pump
|
||||
|
||||
// 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']);
|
||||
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' },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
284
wiki/Home.md
Normal file
284
wiki/Home.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# machineGroupControl
|
||||
|
||||
> **Reflects code as of `7d19fc1` · regenerated `2026-05-11` via `npm run wiki:all`**
|
||||
> If this banner is stale, the page may be out of date. Treat as informative, not authoritative.
|
||||
|
||||
## 1. What this node is
|
||||
|
||||
**machineGroupControl (MGC)** is an S88 Unit orchestrator that coordinates multiple `rotatingMachine` children sharing a common header. It receives a demand setpoint, evaluates valid pump combinations against the group's totals and curves, picks the best operating point (BEP-Gravitation or NCog), and dispatches per-machine flow setpoints + start/stop commands.
|
||||
|
||||
## 2. Position in the platform
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
parent[pumpingStation<br/>Process Cell]:::pc -->|set.demand| mgc[machineGroupControl<br/>Unit]:::unit
|
||||
header[measurement<br/>header pressure]:::ctrl -.data.-> 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: Process Cell `#0c99d9`, Unit `#50a8d9`, Equipment `#86bbdd`, Control Module `#a9daee`. Source of truth: `.claude/rules/node-red-flow-layout.md`.
|
||||
|
||||
## 3. Capability matrix
|
||||
|
||||
| Capability | Status | Notes |
|
||||
|---|---|---|
|
||||
| Aggregate group flow / power totals | ✅ | `TotalsCalculator` — absolute and dynamic. |
|
||||
| Valid-combination enumeration | ✅ | `combinatorics/pumpCombinations`. |
|
||||
| Best-combination optimiser (BEP-Gravitation) | ✅ | Directional or symmetric variant. |
|
||||
| Best-combination optimiser (NCog) | ✅ | Normalised cost-of-goods score. |
|
||||
| Priority / equal-flow control | ✅ | `mode='prioritycontrol'`. |
|
||||
| Priority percentage control | ✅ | Requires `scaling='normalized'`. |
|
||||
| Optimal control | ✅ | `mode='optimalcontrol'`. |
|
||||
| Group efficiency + BEP distance | ✅ | `GroupEfficiency`. |
|
||||
| Header-pressure equalisation | ✅ | `operatingPoint.equalize()`. |
|
||||
| Demand serialisation (latest-wins) | ✅ | `DemandDispatcher` / `LatestWinsGate.fireAndWait`. |
|
||||
| Forced shutdown on `Qd ≤ 0` | ✅ | `turnOffAllMachines()`. |
|
||||
|
||||
## 4. Code map
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph nodeRED["nodeClass.js — adapter (BaseNodeAdapter)"]
|
||||
nc["buildDomainConfig()<br/>static DomainClass, commands"]
|
||||
end
|
||||
subgraph domain["specificClass.js — orchestrator (BaseDomain)"]
|
||||
sc["MachineGroup.configure()<br/>ChildRouter rules<br/>handleInput() dispatch gate"]
|
||||
end
|
||||
subgraph concerns["src/ concern modules"]
|
||||
groupOps["groupOps/<br/>GroupOperatingPoint + curves"]
|
||||
totals["totals/<br/>TotalsCalculator"]
|
||||
combi["combinatorics/<br/>validPumpCombinations"]
|
||||
opt["optimizer/<br/>BEP-Grav / NCog selectors"]
|
||||
efficiency["efficiency/<br/>GroupEfficiency + BEP dist"]
|
||||
ctrl["control/<br/>strategies (equalFlow / prioPct)"]
|
||||
dispatch["dispatch/<br/>DemandDispatcher (LatestWinsGate)"]
|
||||
io["io/<br/>output + status"]
|
||||
commands["commands/<br/>topic registry + handlers"]
|
||||
end
|
||||
nc --> sc
|
||||
sc --> groupOps
|
||||
sc --> totals
|
||||
sc --> combi
|
||||
sc --> opt
|
||||
sc --> efficiency
|
||||
sc --> ctrl
|
||||
sc --> dispatch
|
||||
sc --> io
|
||||
nc --> commands
|
||||
```
|
||||
|
||||
| Module | Owns | Read first if you're changing… |
|
||||
|---|---|---|
|
||||
| `groupOps/` | Group operating point + child read helpers | Header pressure handling, child measurement plumbing. |
|
||||
| `totals/` | Absolute + dynamic flow/power totals | Demand clamping, totals math. |
|
||||
| `combinatorics/` | Enumeration of valid pump subsets | Which combinations are considered eligible. |
|
||||
| `optimizer/` | Best-combination selectors | Optimiser selection method, scoring math. |
|
||||
| `efficiency/` | Group efficiency, BEP distance | BEP gravitation tuning, peak math. |
|
||||
| `control/strategies.js` | Per-mode dispatch (priority, prioPct) | Mode behaviour, priorityList usage. |
|
||||
| `dispatch/` | `DemandDispatcher` wrapping `LatestWinsGate.fireAndWait` | Demand serialisation, mid-flight overrides. |
|
||||
| `commands/` | Input-topic registry and handlers | New input topics, payload validation. |
|
||||
| `io/` | `getOutput`, `getStatusBadge` | Output shape, dashboard badge. |
|
||||
|
||||
## 5. Topic contract
|
||||
|
||||
> **Auto-generated** from `src/commands/index.js`. Do NOT hand-edit between the markers. Re-run `npm run wiki:contract`.
|
||||
|
||||
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||
|
||||
| Canonical topic | Aliases | Payload | Unit | Effect |
|
||||
|---|---|---|---|---|
|
||||
| `set.mode` | `setMode` | `string` | — | Switch the machine group between auto / manual modes. |
|
||||
| `set.scaling` | `setScaling` | `string` | — | Select the group scaling strategy. |
|
||||
| `child.register` | `registerChild` | `string` | — | Register a child machine with this group. |
|
||||
| `set.demand` | `Qd` | `any` | `volumeFlowRate` (default `m3/h`) | Operator demand setpoint dispatched to the child machines. |
|
||||
|
||||
<!-- END AUTOGEN: topic-contract -->
|
||||
|
||||
## 6. Child registration
|
||||
|
||||
`ChildRouter` declarations in `specificClass.js → configure()`.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph kids["accepted children (softwareType)"]
|
||||
mach["machine<br/>(rotatingMachine)"]:::equip
|
||||
m["measurement<br/>(header pressure)"]:::ctrl
|
||||
end
|
||||
mach -->|"pressure.measured.downstream<br/>pressure.measured.differential<br/>flow.predicted.downstream"| eq[operatingPoint.equalize<br/>+ totals refresh]
|
||||
m -->|"<type>.measured.<position>"| mirror[mirror into own<br/>MeasurementContainer]
|
||||
mirror -->|"if type === 'pressure'"| eq
|
||||
eq --> emit[notifyOutputChanged]
|
||||
classDef equip fill:#86bbdd,color:#000
|
||||
classDef ctrl fill:#a9daee,color:#000
|
||||
```
|
||||
|
||||
| softwareType | filter / subscribed events | Side-effect |
|
||||
|---|---|---|
|
||||
| `machine` | onRegister stores in `this.machines[id]`; subscribes to `pressure.measured.downstream`, `pressure.measured.differential`, `flow.predicted.downstream` | `handlePressureChange()` — equalise + recompute totals + recompute group efficiency. |
|
||||
| `measurement` | onRegister attaches listener for `<asset.type>.measured.<positionVsParent>` | Mirror value into MGC's own MeasurementContainer; pressure also triggers `handlePressureChange()`. |
|
||||
|
||||
## 7. Lifecycle — what one event does
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant parent as pumpingStation
|
||||
participant mgc as MGC
|
||||
participant op as GroupOperatingPoint
|
||||
participant tot as TotalsCalculator
|
||||
participant opt as optimizer
|
||||
participant kids as rotatingMachine[]
|
||||
|
||||
parent->>mgc: set.demand (Qd)
|
||||
Note over mgc: dispatch gate — latest-wins
|
||||
mgc->>mgc: abortActiveMovements('new demand')
|
||||
mgc->>tot: calcDynamicTotals()
|
||||
mgc->>mgc: clamp Qd to [minFlow, maxFlow]
|
||||
alt mode=optimalcontrol
|
||||
mgc->>mgc: validPumpCombinations(Qd)
|
||||
mgc->>opt: pick best (BEP-Grav | NCog)
|
||||
opt-->>mgc: bestCombination + bestFlow/Power
|
||||
mgc->>kids: flowmovement (per-pump flow)
|
||||
mgc->>kids: execsequence (startup / shutdown)
|
||||
else mode=prioritycontrol
|
||||
mgc->>mgc: equalFlowControl(Qd, powerCap, priorityList)
|
||||
end
|
||||
mgc->>op: writeOwn flow/power predicted (AT_EQUIPMENT + DOWNSTREAM)
|
||||
mgc->>mgc: notifyOutputChanged()
|
||||
```
|
||||
|
||||
## 8. Data model — `getOutput()`
|
||||
|
||||
What lands on Port 0. Composed in `io/output.js → getOutput(this)` and delta-compressed by `outputUtils.formatMsg`.
|
||||
|
||||
<!-- BEGIN AUTOGEN: data-model -->
|
||||
|
||||
| Key | Type | Unit | Sample |
|
||||
|---|---|---|---|
|
||||
| `absDistFromPeak` | number | — | `0` |
|
||||
| `mode` | string | — | `"optimalcontrol"` |
|
||||
| `relDistFromPeak` | number | — | `0` |
|
||||
| `scaling` | string | — | `"normalized"` |
|
||||
|
||||
<!-- END AUTOGEN: data-model -->
|
||||
|
||||
**Concrete sample** (excerpt — see live test output for the canonical shape):
|
||||
|
||||
~~~json
|
||||
{
|
||||
"mode": "optimalcontrol",
|
||||
"scaling": "normalized",
|
||||
"atEquipment_predicted_flow": 42.5,
|
||||
"downstream_predicted_flow": 42.5,
|
||||
"atEquipment_predicted_power": 18.0,
|
||||
"atEquipment_predicted_efficiency": 0.65,
|
||||
"atEquipment_predicted_Ncog": 1.23,
|
||||
"absDistFromPeak": 0.02,
|
||||
"relDistFromPeak": 0.10
|
||||
}
|
||||
~~~
|
||||
|
||||
Key format from `io/output.js`: `<position>_<variant>_<type>` (e.g. `atEquipment_predicted_flow`). Output units: flow in `m3/h`, power in `kW`, pressure in `mbar`.
|
||||
|
||||
## 9. Configuration — editor form ↔ config keys
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph editor["Node-RED editor form"]
|
||||
f1[Control mode dropdown]
|
||||
f2[Scaling dropdown]
|
||||
f3[Optimisation method]
|
||||
f4[Output unit (flow)]
|
||||
f5[Position vs parent]
|
||||
f6[Allowed sources / actions per mode]
|
||||
end
|
||||
subgraph cfg["Domain config slice"]
|
||||
c1[mode.current]
|
||||
c2[scaling.current]
|
||||
c3[optimization.method]
|
||||
c4[general.unit]
|
||||
c5[functionality.positionVsParent]
|
||||
c6[mode.allowedSources<br/>mode.allowedActions]
|
||||
end
|
||||
f1 --> c1
|
||||
f2 --> c2
|
||||
f3 --> c3
|
||||
f4 --> c4
|
||||
f5 --> c5
|
||||
f6 --> c6
|
||||
```
|
||||
|
||||
| Form field | Config key | Default | Range | Where used |
|
||||
|---|---|---|---|---|
|
||||
| Control mode | `mode.current` | `optimalControl` | enum (`prioritycontrol`, `prioritypercentagecontrol`, `optimalcontrol`) | dispatch switch in `_runDispatch` |
|
||||
| Scaling | `scaling.current` | `normalized` | enum (`absolute`, `normalized`) | demand mapping in `_runDispatch` |
|
||||
| Optimisation method | `optimization.method` | `BEP-Gravitation-Directional` | enum (`NCog`, `BEP-Gravitation`, `BEP-Gravitation-Directional`) | `_optimalControl` selector |
|
||||
| Output unit (flow) | `general.unit` | `m3/h` | unit string | unit policy `output.flow` |
|
||||
| Position vs parent | `functionality.positionVsParent` | `atEquipment` | enum | event suffix for parent subscription |
|
||||
|
||||
## 10. State chart
|
||||
|
||||
MGC is **event-driven and stateless** with respect to operating modes — there is no FSM. The closest thing to "state" is the dispatch gate. Diagram for that single state vector:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> idle_disp: configure()
|
||||
idle_disp --> dispatching: handleInput(Qd)
|
||||
dispatching --> idle_disp: dispatch complete
|
||||
dispatching --> dispatching: handleInput(Qd) — deferred and re-fired on completion
|
||||
dispatching --> turning_off: Qd <= 0
|
||||
turning_off --> idle_disp: all machines acknowledged shutdown
|
||||
```
|
||||
|
||||
While `dispatching`, additional `handleInput` calls are absorbed by `DemandDispatcher` (latest-wins). A superseded call resolves with `{ superseded: true }`. `turnOffAllMachines()` calls `cancelPending()` so turn-off is always the final intent.
|
||||
|
||||
## 11. Examples
|
||||
|
||||
| Tier | File | What it shows |
|
||||
|---|---|---|
|
||||
| 1 | `examples/01-Basic.json` | One MGC + three `rotatingMachine` pumps driven by inject buttons. Setup auto-fires `virtualControl` + `cmd.startup` on all three pumps; numbered driver groups for mode / scaling / demand. |
|
||||
| 2 | `examples/02-Dashboard.json` | Same command surface driven by a FlowFuse Dashboard 2.0 page — Mode + Scaling buttons, Demand slider, live Status rows (mode / scaling / total flow / total power / capacity / active machines / BEP %), three trend charts, and a raw-output table. |
|
||||
|
||||
See [`examples/README.md`](https://gitea.wbd-rd.nl/RnD/machineGroupControl/src/branch/development/examples/README.md) for the canonical command surface table and step-by-step "what to try" recipes.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Screenshots needed.** Capture both flows in the editor + the rendered dashboard. Save under `wiki/_partial-screenshots/machineGroupControl/` as `01-basic-flow.png`, `02-dashboard-editor.png`, `03-dashboard-rendered.png`. Replace this callout with the image links.
|
||||
|
||||
## 12. Debug recipes
|
||||
|
||||
| Symptom | First thing to check | Where to look |
|
||||
|---|---|---|
|
||||
| No combination selected | Demand outside `[dynamicTotals.flow.min, max]` — clamped on entry; `_optimalControl` returns early if combinations empty. | `validPumpCombinations` + warn log. |
|
||||
| Group flow stuck at zero | Machines never reach an `ACTIVE_STATE` — check per-pump startup logs. | `isMachineActive`. |
|
||||
| Priority-percentage mode warns and exits | Mode requires `scaling='normalized'`. Set both. | `_runDispatch` switch. |
|
||||
| Stale flow setpoints on chained calls | Dispatch gate may have superseded intermediate calls — callers should check `result.superseded`. | `DemandDispatcher` / `LatestWinsGate`. |
|
||||
| Header pressure not equalising | Pressure children must register with `asset.type='pressure'` and a matching position. | `operatingPoint.equalize`. |
|
||||
| Optimiser picks unexpected combo | Verify `optimization.method` and per-method scoring (NCog vs BEP-Grav). | `optimizer/`. |
|
||||
|
||||
> Never ship `enableLog: 'debug'` in a demo — fills the container log within seconds and obscures real errors.
|
||||
|
||||
## 13. When you would NOT use this node
|
||||
|
||||
- Don't use MGC for a **single pump** — wire `rotatingMachine` directly. MGC's combinatorics + totals add no value below N=2.
|
||||
- Don't use MGC for **valves** — use `valveGroupControl`. MGC's optimiser assumes a flow-vs-pressure characteristic curve.
|
||||
- Don't use MGC when the pumps live behind **independent headers** — combinations assume a shared discharge / suction pressure.
|
||||
|
||||
## 14. Known limitations / current issues
|
||||
|
||||
| # | Issue | Tracked in |
|
||||
|---|---|---|
|
||||
| 1 | `optimalControl` requires every machine to expose a curve — null-curve members silently exclude themselves from combinations. | `combinatorics/pumpCombinations`. |
|
||||
| 2 | Mid-flight setpoint overrides on `accelerating` / `decelerating` rely on `abortActiveMovements` per dispatch — a sequence with no awaitable `abortMovement` will warn but proceed. | `abortActiveMovements`. |
|
||||
| 3 | Power-cap parameter exposed but not surfaced as a topic input — only programmatic via `handleInput(source, demand, powerCap)`. | `commands/index.js` — no canonical topic. |
|
||||
| 4 | Per-pump fan-out for dashboard charts (per-machine flow / power series) not surfaced from MGC's Port 0 — only group aggregates appear. Subscribe to each rotatingMachine's Port 0 if you need per-pump trends. | `io/output.js` aggregates only. |
|
||||
| 5 | **`maxEfficiency` naming bug** — `GroupEfficiency.calcGroupEfficiency` returns `{ maxEfficiency, lowestEfficiency }` but `maxEfficiency` is actually the **mean cog** across all machines (not the maximum). The name is deliberately preserved for behavioural parity; callers using it as "the peak" will over-estimate the BEP target. | `efficiency/groupEfficiency.js` comment + `OPEN_QUESTIONS.md` 2026-05-10. |
|
||||
| 6 | **`calcAbsoluteTotals` implicit pressure-key coupling** — iterates `machine.predictFlow.inputCurve` and re-uses the same pressure key to index `machine.predictPower.inputCurve[pressure]`. If the two curves were sampled at different pressures the lookup is `undefined` and the call throws. Enforcement or defensive skip deferred to P5 (rotatingMachine curveLoader). | `totals/totalsCalculator.js` + `OPEN_QUESTIONS.md` 2026-05-10. |
|
||||
Reference in New Issue
Block a user