Waterschap Brabantse Delta · R&D-lab · uitleg

EVOLV · machineGroupControl

eigenaar R&D-lab code geanalyseerd 2026-07-17 versie 3.4 concept
machineGroupControl (MGC) bestuurt een groep pompen als één geheel: er komt een procesvraag binnen (“lever 60 %” of “lever 75 m³/h”) en de MGC-node bepaalt welke pompen draaien, hoeveel elk levert, en wanneer ze starten of stoppen. Deze pagina legt uit hoe de code dat doet — stap voor stap, met interactieve figuren die de echte formules simuleren.
voorbeeld — gebruikt in alle figuren

Om de code concreet te maken rekent deze hele pagina met dezelfde fictieve opstelling: drie gelijke pompen, elk regelbaar tussen 8 en 40 m³/h. De groep kan dus 8 tot 120 m³/h leveren. De operator vraagt 60 %. De getallen zijn verzonnen; de formules, volgorde en klemregels zijn exact die van de code.

pump1
envelope 8–40 m³/h
draait, 30 m³/h
pump2
envelope 8–40 m³/h
uit (startbaar)
pump3
envelope 8–40 m³/h
uit (startbaar)
groep
8–120 m³/h
vraag: 60 % → 75,2 m³/h

1Wat doet deze node, en waar hangt hij?

ontvangt
Eén demand van een operator of van de parent-node: een percentage van de groepscapaciteit óf een absolute flow (m³/h, l/min, m³/s).
beslist
Per mode een andere verdeelstrategie: gelijk verdelen op prioriteit, met roterende voorrang, of energie-optimaal (secties 5–8).
stuurt
Elke pomp een eigen flowsetpoint en start/stop-opdracht, zo gepland dat alle pompen op hetzelfde moment op hun nieuwe punt zitten (sectie 9).

In EVOLV-termen: MGC is een S88-Unit en de parent van zijn pompen. De pompen zijn zijn childrenrotatingMachine-nodes die zich bij hem aanmelden. MGC is zelf weer een child van bijvoorbeeld een pumpingStation. Het diagram toont die koppeling voor de voorbeeldopstelling, en welke van de drie soorten verkeer over welke route loopt.

pumpingStation parent (upstream) machineGroupControl deze node · S88 Unit pump1 rotatingMachine · child pump2 rotatingMachine · child pump3 rotatingMachine · child p2 registratie p0/p1 → proces / Influx besturing · in-process in-process events
registratie · via draad (poort 2) telemetrie · via draad (poort 0/1) besturing · in-process (object-referenties) in-process events (emitter)

Drie dingen om te onthouden uit dit diagram:

registratie via draad
Een child meldt zich één keer aan over de draad (poort 2 omhoog). MGC bewaart machine-children in this.machines[id]; measurement-children (bv. een druktransmitter op de header) worden gespiegeld in de eigen metingcontainer. specificClass.js:141-167
besturing in-process
De opdrachten aan de pompen gaan niet over draden maar rechtstreeks van object naar object: machine.handleInput('parent', 'execsequence'|'flowmovement', …). specificClass.js:468-486; handlers via RED.nodes.getNode
event-driven output
Telemetrie verschijnt op het 'output-changed'-event, niet op een vaste klok. Er is wél een 1 s-timer, maar die drijft alleen de bewegingsplanner en stopt zichzelf zodra niets meer loopt. src/nodeClass.js:12; specificClass.js:119, :490-506
src/specificClass.js:141-167child-registratie: machines + metingen abonneren
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
141        this.router
142            .onRegister('machine', (child) => {
143                const id = child.config.general.id;
144                if (this.machines[id]) {
145                    this.logger.warn(`Machine ${id} is already registered.`);
146                    return;
147                }
148                this.machines[id] = child;
149            })
150            .onMeasurement('machine', { type: 'pressure', position: POSITIONS.DOWNSTREAM },   () => this.handlePressureChange())
151            .onMeasurement('machine', { type: 'pressure', position: 'differential' },          () => this.handlePressureChange())
152            .onPrediction('machine',  { type: 'flow',     position: POSITIONS.DOWNSTREAM },   () => this.handlePressureChange())
153            .onRegister('measurement', (child) => {
154                const position = child.config?.functionality?.positionVsParent || child.config?.general?.positionVsParent;
155                const measurementType = child.config?.asset?.type;
156                if (!measurementType || !position) {
157                    this.logger.warn(`Measurement child ${child.config?.general?.id} missing asset.type or positionVsParent — skipping`);
158                    return;
159                }
160                const eventName = `${measurementType}.measured.${String(position).toLowerCase()}`;
161                child.measurements.emitter.on(eventName, (eventData = {}) => {
162                    this.measurements
163                        .type(measurementType).variant('measured').position(position)
164                        .value(eventData.value, eventData.timestamp, eventData.unit);
165                    if (measurementType === 'pressure') this.handlePressureChange();
166                });
167            });
src/specificClass.js:468-486besturing rechtstreeks naar de children (in-process)
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
468    _fireSchedulerCommand(cmd) {
469        const machine = this.machines[cmd.machineId];
470        if (!machine) {
471            this.logger?.warn?.(`Scheduler fired ${cmd.action} for unknown machine ${cmd.machineId}`);
472            return undefined;
473        }
474        const handle = typeof machine.handleInput === 'function' ? machine.handleInput.bind(machine) : null;
475        if (!handle) return undefined;
476        if (cmd.action === 'execsequence') {
477            return Promise.resolve(handle('parent', 'execsequence', cmd.sequence))
478                .catch((e) => this.logger?.error?.(`execsequence ${cmd.sequence} on ${cmd.machineId} failed: ${e?.message || e}`));
479        }
480        if (cmd.action === 'flowmovement') {
481            const outFlow = this._canonicalToOutputFlow(cmd.flow);
482            return Promise.resolve(handle('parent', 'flowmovement', outFlow))
483                .catch((e) => this.logger?.error?.(`flowmovement on ${cmd.machineId} failed: ${e?.message || e}`));
484        }
485        return undefined;
486    }

Zo ziet die koppeling er in een echte Node-RED-flow uit — dit voorbeeld uit de node-repo is direct importeerbaar (menu ▸ Import ▸ plakken):

examples/01-Basic.jsonimporteerbare Node-RED-voorbeeldflow (MGC + children)
importeren: Node-RED-menu ▸ Import ▸ plakken · revisie d11d749 actuele flow op git ↗ uitgebreider: 02-Dashboard ↗
[
    {
        "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": "mode = optimalControl",
        "props": [
            {
                "p": "command",
                "v": "{\"mode\": {\"value\": \"optimalControl\"}}",
                "vt": "json"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": "",
        "x": 870,
        "y": 60,
        "wires": [
            [
                "mgc_basic_node"
            ]
        ]
    },
    {
        "id": "inj_mode_priority",
        "type": "inject",
        "z": "tab_mgc_basic",
        "g": "grp_drv_mode",
        "name": "mode = priorityControl",
        "props": [
            {
                "p": "command",
                "v": "{\"mode\": {\"value\": \"priorityControl\"}}",
                "vt": "json"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": "",
        "x": 870,
        "y": 100,
        "wires": [
            [
                "mgc_basic_node"
            ]
        ]
    }
]

2Van demand naar pompsetpoints — 9 stappen

Dit is de kern van de node: het pad dat één demand door de code aflegt. Klik een stap (of gebruik ◀ ▶ / pijltjestoetsen). Rechts: wat de stap doet, wat er in het voorbeeld gebeurt (amber = fictieve getallen), en waar het in de code staat.

3Probeer het zelf — het I/O-contract

Kies links een commando en stel de velden in. Het middenpaneel toont het exacte Node-RED-bericht (kopieerbaar, direct injecteerbaar). Klik Injecteer ▸ en zie rechts wat er uit de drie poorten komt — live doorgerekend op de voorbeeldopstelling (groep 8–120 m³/h).

Commandoinput · 1 poort
Bericht
Poortenoutput · 3 poorten

Let op: 0 % en 0 m³/h zijn niet hetzelfde. De eenheid bepaalt wat er bij lage waarden gebeurt:

demand 0 %
→ min-flow, blijft draaien
Procent wordt lineair afgebeeld op de groepsenvelope: 0 % = flow-min (8 m³/h in het voorbeeld), 100 % = flow-max. 0 % is dus geen stop. specificClass.js:541-544
demand 0 m³/h (elke flow-eenheid)
→ alle pompen uit
Na omrekening is de vraag ≤ 0 en schakelt de dispatch alles netjes af (turnOffAllMachines). Tussen 0 en flow-min klemt de code de vraag omhoog naar flow-min: een draaiende groep zakt nooit onder zijn minimum. specificClass.js:565 (stop), :594-596 (klem omhoog)
demand < 0 (elke eenheid)
→ stop-all, vóór alles
Negatief is het afgesproken stopsignaal: het commando wordt gegate als emergencyStop en gaat rechtstreeks naar turnOffAllMachines, nog vóór eenheids-omrekening. commands/handlers.js:90; specificClass.js:536-539
src/specificClass.js:530-566eenheids-resolutie: %, flow-eenheden, 0 en negatief
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
530    async setDemand(value, unit = '%') {
531        const v = Number(value);
532        if (!Number.isFinite(v)) {
533            this.logger?.error?.(`setDemand: invalid value '${value}'`);
534            return undefined;
535        }
536        if (v < 0) {
537            await this.turnOffAllMachines();
538            return undefined;
539        }
540        let canonical;
541        if (unit === '%') {
542            const dt = this.calcDynamicTotals();
543            canonical = this.interpolation.interpolate_lin_single_point(
544                v, 0, 100, dt.flow.min, dt.flow.max);
545        } else {
546            try {
547                canonical = this.unitPolicy.convert(v, unit, 'm3/s', 'setDemand absolute flow');
548            } catch (err) {
549                this.logger?.error?.(`setDemand: cannot convert ${v} ${unit} -> m3/s: ${err?.message || err}`);
550                return undefined;
551            }
552        }
553        return this.handleInput('parent', canonical);
554    }
555
556    async _runDispatch(source, demand, powerCap, priorityList, opts = {}) {
557        const demandQ = parseFloat(demand);
558        if (!Number.isFinite(demandQ)) {
559            this.logger.error(`Invalid flow demand input: ${demand}.`);
560            return;
561        }
562        // Demand is canonical m³/s (the handler has already resolved units).
563        // The handler routes negatives directly to turnOffAllMachines, but
564        // keep a defensive check in case turnOff-state arrives some other way.
565        if (demandQ <= 0) { await this.turnOffAllMachines(); return; }
566

4Snel achter elkaar sturen — latest wins

Een verdeling uitrekenen en uitvoeren kost tijd. Wat als er intussen nieuwe demands binnenkomen? De code serialiseert ze met een latest-wins-regel: er is precies één wachtplek. Het bericht dat daar ligt wordt door elk nieuwer bericht vervangen; de vervangen afzender krijgt {superseded: true} terug en weet zo dat zijn waarde nooit is uitgevoerd. Drie demands kort na elkaar:

t₀ t₁ t₂ t₃ · ① klaar t₄ · ③ klaar ① 50 % komt op t₀ draait direct — gate was vrij ② 80 % komt op t₁ wacht (plek 1) vervangen door ③ → resolvet {superseded: true} ③ 30 % komt op t₂ wacht (plek 1) draait zodra ① klaar is

Netto-effect: ① en ③ worden uitgevoerd, ② verdwijnt stilletjes — precies wat je wilt bij een operator die aan een slider draait. Dezelfde regel zit nog een laag dieper: is de groep fysiek nog aan het bewegen (movementState 'working'), dan parkeert de dispatch de demand in _pendingDemand (ook latest wins) en voert hem uit zodra de groep ready meldt. Alleen een noodgeval — stop, allereerste demand, of een drukexcursie boven emergencyPressurePa — mag er doorheen (stap 3 in sectie 2).

generalFunctions LatestWinsGate; specificClass.js:102-108 (gate), :575-583 (rendezvous-lock); dispatch/demandDispatcher.js:29-46
src/dispatch/demandDispatcher.js:1-53latest-wins gate rond de dispatch (hele module)
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
1'use strict';
2
3const { LatestWinsGate } = require('generalFunctions');
4
5// Thin wrapper around LatestWinsGate for the MGC demand path. Replaces
6// the original `_dispatchInFlight` + `_delayedCall` pair in
7// specificClass.handleInput: a new demand arriving while a dispatch is
8// in flight overwrites any pending one, so the latest value always wins
9// and intermediates are dropped silently.
10
11class DemandDispatcher {
12    constructor(ctx = {}, runFn) {
13        if (typeof runFn !== 'function') {
14            throw new TypeError('DemandDispatcher requires a runFn');
15        }
16        this.ctx = ctx;
17        this.logger = ctx.logger || null;
18        this._runFn = runFn;
19        this._gate = new LatestWinsGate(
20            async (demand) => this._runFn(demand, this.ctx),
21            { logger: this.logger },
22        );
23    }
24
25    fire(demand) {
26        this._gate.fire(demand);
27    }
28
29    // Returns a promise that resolves when THIS demand's dispatch settles.
30    // If superseded by a later fireAndWait while parked, the promise
31    // resolves with the LatestWinsGate SUPERSEDED sentinel
32    // ({ superseded: true }) — callers can branch on it without try/catch.
33    fireAndWait(demand) {
34        return this._gate.fireAndWait(demand);
35    }
36
37    drain() {
38        return this._gate.drain();
39    }
40
41    // Cancels any parked pending value so it cannot run. The currently
42    // in-flight dispatch (if any) still runs to completion. A parked
43    // fireAndWait promise resolves with the SUPERSEDED sentinel.
44    cancelPending() {
45        if (this._gate._pending) this._gate._supersedePending();
46    }
47
48    get inFlight() {
49        return this._gate.size > 0;
50    }
51}
52
53module.exports = DemandDispatcher;

5De vier modes

De mode bepaalt welke verdeelstrategie stap 6–7 gebruikt, en daarnaast wélke commando's van wélke bron (parent / GUI / fysiek / API) geaccepteerd worden.

optimalControl
Zoekt de energiezuinigste pompcombinatie en verdeling (sectie 8). Default-mode.
priorityControl
Verdeelt de vraag gelijk over de pompen, in vaste prioriteitsvolgorde (sectie 6).
rotationControl
Zelfde verdeling als priority, maar de voorrangsvolgorde roteert bij elke volledige stop (sectie 7) — gelijkmatige slijtage.
maintenance
Alleen statusuitvraag, alleen vanaf parent/GUI. Heeft geen dispatch-tak: een demand die er toch doorheen komt eindigt in een warn.
specificClass.js:608-619 (dispatch-switch); configs/machineGroupControl.json:187-267 (actie- en brongating per mode)
src/specificClass.js:608-619de dispatch-switch per mode
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
608        switch (String(this.mode || '').toLowerCase()) {
609            case 'prioritycontrol':           await control.equalFlowControl(ctx, demandQout, powerCap, priorityList); break;
610            // Duty rotation: dispatch sequentially (same as priorityControl) but
611            // with a lead order rotated by _rotationIndex. A live (>0) dispatch
612            // arms the rotation so the next demand→0 hands the lead to the next
613            // pump. An explicit priorityList from the caller still wins (lets an
614            // operator override the rotated order for one dispatch).
615            case 'rotationcontrol':           this._rotationArmed = true;
616                                              await control.equalFlowControl(ctx, demandQout, powerCap, priorityList || this._rotationOrderedIds()); break;
617            case 'optimalcontrol':            await this._optimalControl(demandQout, powerCap); break;
618            default: this.logger.warn(`${this.mode} is not a valid mode.`);
619        }

6equalFlowControl — drie takken

De verdeler achter priorityControl en rotationControl. Hij vergelijkt de vraag met wat de nu-draaiende pompen aankunnen en kiest één van drie takken: pompen afschakelen, pompen bijschakelen, of gewoon gelijk verdelen. Schuif de vraag en zie de tak wisselen. Startpunt van dit figuur: pump1 en pump2 draaien (samen 16–80 m³/h), pump3 staat uit.

50 m³/h
tak 1 · Qd < 16 (min v.d. draaiende set)
Te weinig vraag voor twee pompen: schakel draaiende pompen met de laagste prioriteit uit tot de vraag past; de rest verdeelt gelijk. strategies.js:83-101
tak 2 · Qd > 80 (max v.d. draaiende set)
Te veel vraag: breng pompen in prioriteitsvolgorde online tot de som van hun maxima de vraag dekt; verdeel dan gelijk, geklemd op ieders envelope. strategies.js:102-127
tak 3 · past precies
Verdeel de vraag gelijk over de draaiende pompen. strategies.js:128-144

Twee oude fouten in deze functie zijn bewust hersteld en met tests vastgepind: tak 2 deelde de vraag cumulatief (per-pomp flow zakte naar Qd/(i!)) en gaf flow alleen aan niet-draaiende pompen; de oude default-tak verdeelde over de eerste N prioriteitsposities in plaats van over de werkelijk draaiende pompen.

test/basic/equalFlowDistribution.basic.test.js; strategies.js:102-135
src/control/strategies.js:83-148equalFlowControl: de drie takken
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
83        case (Qd < activeTotals.flow.min && activeTotals.flow.min !== 0): {
84            let availableFlow = activeTotals.flow.min;
85            for (let i = machinesInPriorityOrder.length - 1; i >= 0 && availableFlow > Qd; i--) {
86                const m = machinesInPriorityOrder[i];
87                if (isMachineActive(m.id)) {
88                    flowDistribution.push({ machineId: m.id, flow: 0 });
89                    availableFlow -= groupFlow(m.machine).currentFxyYMin;
90                }
91            }
92            const remaining = machinesInPriorityOrder.filter(({ id }) =>
93                isMachineActive(id) && !flowDistribution.some(it => it.machineId === id));
94            const distributedFlow = Qd / remaining.length;
95            for (const m of remaining) {
96                flowDistribution.push({ machineId: m.id, flow: distributedFlow });
97                totalFlow += distributedFlow;
98                totalPower += groupCalcPower(m.machine, distributedFlow);
99            }
100            break;
101        }
102        case (Qd > activeTotals.flow.max): {
103            // Demand exceeds what the active set can deliver: bring machines
104            // online in priority order until their combined max covers Qd,
105            // then split equally, clamped to each pump's envelope. (The
106            // legacy loop compounded `Qd /= i` across iterations — per-pump
107            // flow shrank to Qd/(i!) instead of Qd/i — and assigned flow only
108            // to INACTIVE pumps, leaving running pumps without a setpoint.
109            // Fixed deliberately; the old behaviour was pinned as a known
110            // quirk in equalFlowDistribution.basic.test.js.)
111            const selected = [];
112            let cap = 0;
113            for (const m of machinesInPriorityOrder) {
114                selected.push(m);
115                cap += groupFlow(m.machine).currentFxyYMax;
116                if (cap >= Qd) break;
117            }
118            const per = selected.length > 0 ? Qd / selected.length : 0;
119            for (const m of selected) {
120                const env = groupFlow(m.machine);
121                const flow = Math.min(env.currentFxyYMax, Math.max(env.currentFxyYMin, per));
122                flowDistribution.push({ machineId: m.id, flow });
123                totalFlow += flow;
124                totalPower += groupCalcPower(m.machine, flow);
125            }
126            break;
127        }
128        default: {
129            // Qd lies inside the active set's envelope: split equally across
130            // the machines that are actually ACTIVE, in priority order. (The
131            // legacy code counted the active machines but then handed flow to
132            // the FIRST countActive entries of the priority list — which
133            // could include inactive pumps while skipping active ones. Fixed
134            // deliberately; old behaviour was pinned as a latent bug in
135            // equalFlowDistribution.basic.test.js.)
136            const activeMachines = machinesInPriorityOrder.filter(({ id }) => isMachineActive(id));
137            const per = activeMachines.length > 0 ? Qd / activeMachines.length : 0;
138            for (const m of activeMachines) {
139                flowDistribution.push({ machineId: m.id, flow: per });
140                totalFlow += per;
141                totalPower += groupCalcPower(m.machine, per);
142            }
143            break;
144        }
145    }
146
147    return { flowDistribution, totalFlow, totalPower, totalCog };
148}

7Duty rotation — de voorrang schuift door

In rotationControl is de prioriteitslijst de vaste machine-id-volgorde, geroteerd zodat de huidige lead vooraan staat. Het doorschuiven is edge-armed: een demand > 0 zet de rotatie op scherp, en pas de éérstvolgende demand → 0 (volledige stop) schuift de lead één plek op. Tien keer achter elkaar “0” sturen roteert dus maar één keer. Probeer het:

De tijdlijn hierboven groeit mee met elke gebeurtenis: een blauwe kaart is een échte rotatie (de groep stopte volledig terwijl de rotatie op scherp stond), een grijze kaart deed niets. Zo is te zien dat tien keer “0” sturen maar één keer roteert.

Dezelfde regel als grafiek, over een fictief etmaal-deel: boven de vraag die de groep binnenkrijgt, als gekleurde band eronder wélke pomp op dat moment de lead heeft. De lead verspringt alleen op een volledige stop die volgt op draaien (▼ rotatie); een tweede stop direct erna doet niets (× genegeerd).

Demand-verloop (fictief) met de lead-pomp als band. Elke ▼ is een échte rotatie (stop na draaien), de × is een herhaalde stop die géén rotatie geeft — het doorschuiven is edge-armed. Na drie echte stops is de cirkel rond (rotationsCompleted = 1).

Een expliciete priorityList van de aanroeper overschrijft de geroteerde volgorde voor die ene dispatch. rotationsCompleted telt volledige rondes; rotationIndex, rotationsCompleted en leadMachine verschijnen alléén in rotationControl-telemetrie.

specificClass.js:215-221 (_rotationOrderedIds), :229-240 (_advanceRotation), :615-616, :631-633; io/output.js:107-112
src/specificClass.js:215-240rotatievolgorde + edge-armed doorschuiven
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
215    _rotationOrderedIds() {
216        const ids = Object.keys(this.machines).sort((a, b) => String(a).localeCompare(String(b)));
217        if (ids.length === 0) return null;
218        const n = ids.length;
219        const lead = ((this._rotationIndex % n) + n) % n;
220        return ids.slice(lead).concat(ids.slice(0, lead));
221    }
222
223    // The current lead machine id (or null when no machines registered).
224    leadMachineId() {
225        const ids = this._rotationOrderedIds();
226        return ids ? ids[0] : null;
227    }
228
229    // Advance the duty-rotation lead by one (round-robin) on a running→0 edge.
230    // Wrapping past the last machine counts one completed full rotation. No-op
231    // when ≤1 machine is registered (nothing to rotate between).
232    _advanceRotation() {
233        this._rotationArmed = false;
234        const n = Object.keys(this.machines).length;
235        if (n <= 1) return;
236        const next = (this._rotationIndex + 1) % n;
237        if (next <= (this._rotationIndex % n)) this._rotationsCompleted += 1;
238        this._rotationIndex = next;
239        this.logger.info(`rotationControl: demand→0 — lead advanced to index ${this._rotationIndex} (${this.leadMachineId()}); rotations completed: ${this._rotationsCompleted}.`);
240    }

8optimalControl — eerst trechteren, dan optimaliseren

De optimizer bekijkt niet élke denkbare verdeling: eerst wordt het aantal kandidaat-combinaties in drie stappen teruggebracht, daarna verdeelt de optimizer de flow binnen elke overgebleven combinatie en wint de combinatie met het laagste totaalvermogen.

alle combinaties
Alle niet-lege deelverzamelingen van de pompen (2ⁿ−1; bij 3 pompen: 7). pumpCombinations.js:60-73
uitsluiten
Pompen die niet kunnen meedoen gaan er vooraf uit: status off/coolingdown/stopping/emergencystop, of geen execsequence-toestemming in mode auto. pumpCombinations.js:9, :61-73
filteren
Alleen combinaties die de vraag fysiek aankunnen: Σmax ≥ Qd, Σmin ≤ Qd en Σvermogen ≤ powerCap. Handmatig draaiende pompen worden eerst van de vraag afgetrokken — dat kan Qd < 0 maken → lege set → warn. pumpCombinations.js:13-48, :75-93; specificClass.js:400-403

Binnen elke combinatie verdeelt BEP-Gravitation-Directional de flow in drie stappen. De figuur toont twee fictieve pompen: A met een vlakke en B met een steile vermogenscurve P(Q). Klik de stappen; de markers verspringen.

QBEP,i= Qmin,i+ spani·NCog~i wi= 1|slopei| Δ=Qd iQBEP,i mcΔ=max(106, 0,005·Qdn)
SymboolBetekenisEenheidHerkomst
QBEP,igeschat best-efficiency-punt van pomp i (waar de pomp het zuinigst draait)m³/sbepGravitation.js:136-137
Qmin,i, spaniminimumflow en envelope-breedte (max−min) op het groepsbedrijfspuntm³/sbepGravitation.js:133-135
NCog̃ipositie van het zwaartepunt van de curve, genormaliseerd op [0..1]bepGravitation.js:136
slopeihelling dP/dQ rond het BEP (eindige differenties, richtingsafhankelijk)W·s/m³bepGravitation.js:16-43
wiherverdelingsgewicht. Waarom vlakker = meer: de helling |slope| is wat een extra m³ aan extra vermogen kost; bij een vlakke curve is dat bijna niets, dus daar is het tekort het goedkoopst onder te brengen. w = 1/|slope| codeert precies dat — de verfijning in stap 3 controleert het daarna nog per ruilstapm³/(W·s)bepGravitation.js:47-83
Δtekort t.o.v. de som van de BEP-punten, herverdeeld met wim³/sbepGravitation.js:150-153
mcΔstapgrootte van de verfijning (schuift flow van de duurste naar de goedkoopste marginale m³)m³/sbepGravitation.js:87

De verfijning stopt bij een relatieve gap < 0,1 %, bij een ruil die het totaalvermogen niet meer verlaagt, of na 50 iteraties. Daarna wint over alle combinaties de laagste ΣP; bij gelijkspel de kleinste gewogen afwijking van de BEP-punten (Σ(ΔQi²·αi)). De code van precies deze twee stukken:

src/optimizer/bepGravitation.js:84-129marginale-kostenverfijning (mcΔ-ruilstappen)
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
84
85function _marginalCostRefine(flowDistribution, pumpInfos, Qd, ctx) {
86    const { groupCalcPower } = ctx.groupCurves;
87    const mcDelta = Math.max(1e-6, (Qd / pumpInfos.length) * 0.005);
88
89    for (let iter = 0; iter < MC_ITER_CAP; iter++) {
90        const mcEntries = flowDistribution.map(entry => {
91            const info = pumpInfos.find(i => i.id === entry.machineId);
92            const pNow = groupCalcPower(info.machine, entry.flow);
93            const pUp = groupCalcPower(info.machine, Math.min(info.maxFlow, entry.flow + mcDelta));
94            return { entry, info, mc: (pUp - pNow) / mcDelta };
95        });
96
97        let expensive = null;
98        let cheap = null;
99        for (const e of mcEntries) {
100            if (e.entry.flow > e.info.minFlow + mcDelta && (!expensive || e.mc > expensive.mc)) expensive = e;
101            if (e.entry.flow < e.info.maxFlow - mcDelta && (!cheap || e.mc < cheap.mc)) cheap = e;
102        }
103        if (!expensive || !cheap || expensive === cheap) break;
104        if (expensive.mc - cheap.mc < expensive.mc * MC_RELATIVE_EXIT) break;
105
106        const before = groupCalcPower(expensive.info.machine, expensive.entry.flow)
107                     + groupCalcPower(cheap.info.machine, cheap.entry.flow);
108        const after = groupCalcPower(expensive.info.machine, expensive.entry.flow - mcDelta)
109                    + groupCalcPower(cheap.info.machine, cheap.entry.flow + mcDelta);
110        if (after < before) {
111            expensive.entry.flow -= mcDelta;
112            cheap.entry.flow += mcDelta;
113        } else {
114            break;
115        }
116    }
117}
118
119function calcBestCombinationBEPGravitation(combinations, Qd, ctx, method = 'BEP-Gravitation-Directional') {
120    const { machines, groupCurves } = ctx;
121    const { groupFlow, groupNCog, groupCalcPower } = groupCurves;
122    const directional = method === 'BEP-Gravitation-Directional';
123
124    let bestCombination = null;
125    let bestPower = Infinity;
126    let bestFlow = 0;
127    let bestCog = 0;
128    let bestDeviation = Infinity;
129
src/optimizer/bestCombination.js:60-88winnaar: laagste ΣP over alle combinaties
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
60                    ? Math.min(entry.max, entry.flow + delta)
61                    : Math.max(entry.min, entry.flow + delta);
62                remainder -= (next - entry.flow);
63                entry.flow = next;
64            });
65        }
66
67        flowDistribution = clamped;
68
69        let totalFlow = 0;
70        let totalPower = 0;
71        flowDistribution.forEach(({ machineId, flow }) => {
72            totalFlow += flow;
73            totalPower += groupCalcPower(machines[machineId], flow);
74        });
75
76        if (totalPower < bestPower) {
77            logger?.debug?.(`New best combination found: ${totalPower} < ${bestPower}`);
78            bestPower = totalPower;
79            bestFlow = totalFlow;
80            bestCog = totalCoG;
81            bestCombination = flowDistribution;
82        }
83    });
84
85    return { bestCombination, bestPower, bestFlow, bestCog };
86}
87
88module.exports = { calcBestCombination };

Kanttekening: optimization.method is uit de config leesbaar maar heeft geen schema- of editorveld — in de praktijk draait dus altijd BEP-Gravitation-Directional.

bepGravitation.js:4-5, :104-115, :170-186; specificClass.js:405

De zoektocht over álle combinaties, iteratie voor iteratie

Hierboven zag je de verfijning bínnen één combinatie. Dit figuur toont het hele speelveld: elke lijn is een levensvatbare pompcombinatie en elke stap op de x-as is één verfijnings-iteratie (ruilstap mcΔ). En hier zie je wat de verfijning wáárd is: bij de start loopt {p1, p3} voorop, maar {p1, p2, p3} haalt hem al verfijnend in en wint (★) — wie na de herverdeling zou stoppen, koos de verkeerde combinatie. Combinaties van één pomp vallen vooraf af (Σmax 40 < 75,2); {p1,p2} en {p2,p3} eindigen ruim boven beeld. De hele zoektocht draait bij élke dispatch opnieuw, op het actuele groepsbedrijfspunt (druk) van dat moment — de curves P(Q) schuiven met de druk mee (sectie 10). Klik ▶ om de iteraties als tijdstappen af te spelen.

Eerlijk over dit voorbeeld: de curves zijn didactisch geconstrueerd — bewust sterk uiteenlopende pompen — zodat het inhalen goed zichtbaar is. Bij drie identieke, gezonde pompen ligt de winnaar meestal al na de herverdeling vast en is de verfijning de laatste procenten. Het kruis-effect is wél reëel zodra de curves uiteenlopen (slijtage, verschillende waaiers, een pomp ver van zijn BEP) — precies de situaties waarvoor de optimizer bestaat. De praktijk-onderbouwing staat niet in dit figuur maar in het 2022-CRC-experiment (+26,0 % gemiddeld voordeel t.o.v. slimme cascade; zie Herkomst onderaan).

Totaalvermogen ΣP per combinatie per verfijnings-iteratie, voorbeeldvraag 75,2 m³/h; iteratie 0 = na BEP-start + herverdeling. De as is ingezoomd op de kop-groep; {p1,p2} (≈9,3 kW) en {p2,p3} (≈12,3 kW) liggen daarbuiten. Pompcurves fictief; algoritme exact (bepGravitation.js + bestCombination.js).
src/combinatorics/pumpCombinations.js:60-93combinaties opsommen, uitsluiten en filteren
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
60    let subsets = [[]];
61    Object.keys(machines).forEach(machineId => {
62        const machine = machines[machineId];
63        const state = machine.state?.getCurrentState?.();
64        const validActionForMode =
65            typeof machine.isValidActionForMode === 'function'
66                ? machine.isValidActionForMode('execsequence', 'auto')
67                : true;
68
69        if (EXCLUDED_STATES.has(state) || !validActionForMode) return;
70
71        const newSubsets = subsets.map(set => [...set, machineId]);
72        subsets = subsets.concat(newSubsets);
73    });
74
75    return subsets.filter(subset => {
76        if (subset.length === 0) return false;
77
78        const { maxFlow, minFlow, maxPower } = subset.reduce(
79            (acc, machineId) => {
80                const machine = machines[machineId];
81                const f = groupFlow(machine);
82                const p = groupPower(machine);
83                return {
84                    maxFlow: acc.maxFlow + f.currentFxyYMax,
85                    minFlow: acc.minFlow + f.currentFxyYMin,
86                    maxPower: acc.maxPower + p.currentFxyYMax,
87                };
88            },
89            { maxFlow: 0, minFlow: 0, maxPower: 0 },
90        );
91
92        return maxFlow >= Qd && minFlow <= Qd && maxPower <= powerCap;
93    });

9Rendezvous — alle pompen samen klaar op t*

De gekozen verdeling gaat door de bewegingsplanner. Die rekent per pomp uit hoe lang zijn manoeuvre duurt (eta), neemt het maximum als gezamenlijk eindmoment t* = max(eta), en geeft snellere pompen een wachttijd delay = t* − eta, zodat iedereen op hetzelfde moment op zijn nieuwe punt zit. In het voorbeeld: pump1 hoeft alleen te rampen (eta 12 s), pump2 moet vanuit stilstand starten en opwarmen (eta 63 s) → t* = 63 s, pump1 wacht 51 s.

delay (wachten) starten + opwarmen flow-ramp
t*= maxietai delayi= t*etai
SymboolBetekenisEenheidHerkomst
t*gezamenlijk eindmoment (max over alle echte moves)smovementScheduler.js:159-166
etaiduur van de manoeuvre van pomp i; hangt af van de beginstatus: draaiend → alleen ramp; opwarmend → rest-opwarmtijd + ramp; uit → volledige startladder + ramp; stoppend → nullsmoveTrajectory.js:46-80
delayiwachttijd vóór het afvuren, zodat de move op t* eindigtsmovementScheduler.js:178-211

Waarom de wachttijd vóóraan zit: een startende pomp wordt als geheel uitgesteld (zijn startladder begint pas op t* − eta). Zou hij meteen op t=0 starten, dan draait hij na het opwarmen alvast op minimumflow en lekt zo ongevraagd debiet in het groepstotaal vóór t*. Bij useRendezvous = false (legacy) vuurt alles op t=0 en landt elke pomp op zijn eigen eta. Stoppen hoeft niet te wachten: de stop-eta telt alleen de ramp omlaag naar minimum — uitloop en afkoeling komen ná flow 0. De uitvoerder tikt elke seconde met een virtuele cursor (haalt gemiste ticks in) en vuurt fire-and-forget; alleen de allereerste tick wordt afgewacht om een race met een lopende shutdown te winnen.

movementScheduler.js:141-148, :184-211; movementExecutor.js:51-102; specificClass.js:452-456

Wat levert dit het proces op? — het mechanisme, doorgerekend op de systeemdruk

Het scenario hieronder is het lastigste geval: pump1 draait vlak tegen zijn maximum (38 m³/h) en de vraag stijgt naar 46 → de verdeling wordt 2 × 23, dus pump1 moet omlaag terwijl pump2 koud bijkomt. Zonder rendezvous rampt pump1 meteen af en hangt de groep ruim een halve minuut op 23 m³/h vóór pump2 er überhaupt is — de systeemdruk zakt diep weg. Mét rendezvous blijft pump1 hoog tot de start van pump2 er bijna is; er resteert alleen de korte, kleine overdekking die fysiek onvermijdelijk is omdat een startende pomp minimaal zijn min-flow (8) meedrukt terwijl de ander nog terugregelt. De onderste grafiek vertaalt beide flowverlopen naar systeemdruk via een fictieve systeemkarakteristiek p = p₀ + k·Q².

Groepsflow bij vraagstap 38 → 46 m³/h (verdeling 2×23), mét en zónder rendezvous. Tijden en envelopes fictief; plannerlogica exact (movementScheduler.js).
Dezelfde runs vertaald naar systeemdruk (fictieve karakteristiek p = 1,0 + 0,0004·Q² bar). Het mechanisme — kleiner en korter drukdal — volgt deterministisch uit de planner; hoevéél rustiger het in de echte installatie is (echte leidingdynamiek, regelaars, terugslagkleppen) meten we in de pilot-meetanalyse (historian-drukdata mét/zónder useRendezvous), gepland als vervolg.
src/movement/moveTrajectory.js:46-80eta per pomp: ramp / opwarmen / startladder
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
46    etaToTargetS() {
47        const p = this.profile;
48        const v = p.velocityPctPerS;
49        const target = this.targetPosition;
50
51        if (SHUTDOWN_LADDER.has(p.state)) return null;
52
53        if (!Number.isFinite(v) || v <= 0) return Infinity;
54
55        if (p.state === 'operational' || ACTIVE_OPERATIONAL.has(p.state)) {
56            const dist = Math.abs(target - p.position);
57            return dist / v;
58        }
59
60        if (p.state === 'warmingup') {
61            // Remaining warmup, then ramp from minPosition to target.
62            // Ramp starts from minPosition because the pump is not moving
63            // during warmup — position is held at min.
64            const remW = p.remainingTransitionS ?? p.timings.warmingupS;
65            const rampDist = Math.max(0, target - p.minPosition);
66            return remW + rampDist / v;
67        }
68
69        if (p.state === 'starting') {
70            // Remaining-in-starting + full warmup duration + ramp from min.
71            const remS = p.remainingTransitionS ?? p.timings.startingS;
72            const rampDist = Math.max(0, target - p.minPosition);
73            return remS + p.timings.warmingupS + rampDist / v;
74        }
75
76        // idle / off / emergencystop / maintenance / any non-active state
77        // not in the ladders: full startup sequence to operational, then ramp.
78        const rampDist = Math.max(0, target - p.minPosition);
79        return p.timings.startingS + p.timings.warmingupS + rampDist / v;
80    }
src/movement/movementScheduler.js:159-211t* = max(eta) en delay = t* − eta
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
159    // Rendezvous: t* = max eta over ALL non-noop moves. Includes
160    // increasing AND decreasing flow-moves so the slowest mover sets the
161    // deadline for everyone. When useRendezvous=false, tStar is forced
162    // to 0 so every command's delay collapses to 0 (legacy behaviour).
163    const allEtas = plans
164        .filter((q) => !q.skip && Number.isFinite(q.eta))
165        .map((q) => q.eta);
166    const tStar = useRendezvous && allEtas.length > 0 ? Math.max(...allEtas) : 0;
167
168    // Second pass: assign fireAtTickN. Every command is delayed so its
169    // move finishes at t*; the lone exception is the startup ladder's
170    // execsequence (the ladder must begin now because eta == ladder + ramp).
171    const commands = [];
172    for (const q of plans) {
173        if (q.skip) continue;
174
175        // Delay-to-rendezvous: fire (t* − eta) seconds from now so the
176        // move FINISHES at t*. Clamped to >= 0 (the eta == t* mover fires
177        // immediately).
178        const fireAtSDelayed = Math.max(0, tStar - q.eta);
179        const fireAtTickNDelayed = Math.round(fireAtSDelayed / tickS);
180        // Unchanged moves are no-ops; fire at 0 for simplicity (the pump
181        // ignores them and we don't pollute the schedule with delays).
182        const isUnchanged = q.direction === 'unchanged';
183
184        if (q.action === 'startup') {
185            // Just-in-time start. Delay the ENTIRE startup — ladder AND ramp —
186            // by (t* − eta), so the warmup ladder finishes (and the ramp
187            // begins) at (t* − rampS) and the flow lands exactly at t*.
188            //
189            // The ladder duration can't be compressed, but it CAN be delayed.
190            // Firing the execsequence at tick 0 (the old behaviour) made a
191            // faster-than-slowest startup reach `operational` early and sit at
192            // its minimum flow from warmup-end until its delayed ramp — leaking
193            // ~min-flow into the group total before t* (the staging bump). For
194            // the slowest pump (eta == t*) fireAtTickNDelayed is 0, so it still
195            // fires immediately. The flowmovement fires on the same tick; the
196            // pump holds it in delayedMove through the ladder, then ramps over
197            // rampS to finish at t*.
198            commands.push({
199                machineId: q.machineId,
200                action: 'execsequence',
201                sequence: 'startup',
202                fireAtTickN: fireAtTickNDelayed,
203                eta: q.eta,
204            });
205            commands.push({
206                machineId: q.machineId,
207                action: 'flowmovement',
208                flow: q.targetFlow,
209                fireAtTickN: fireAtTickNDelayed,
210                eta: q.eta,
211            });

10Groepsbedrijfspunt & totalen

Vóór elke verdeling — en bij elke drukverandering — zet MGC alle pompen in hetzelfde druk-referentiekader. Dat is geen administratie maar een stabiliteitsmaatregel. Elke pomp heeft eigen druksensoren, en een pomp die stílstaat meet een veel lagere persdruk dan de draaiende pompen — fictief: pump1 uit → 20 mbar, pump2 draait → 400 mbar. Zou de optimizer elke pomp op zijn eigen meetpunt beoordelen, dan lijkt de stilstaande pomp altijd de zuinigste (bij lage ΔP kost een m³ nauwelijks kW): hij wordt gekozen, start, ziet meteen de échte headerdruk — en het voordeel verdampt, waarna de keuze terugklapt. Om die instabiliteit te voorkomen worden álle pompcurves vóór elke vergelijking vastgepind op één gezamenlijke ΔP: benedenstrooms de hoogste druk (de eigen groepsmeting op de header als die er is en > 0, anders het maximum over de child-metingen), bovenstrooms de laagste — het ruimste, conservatiefste kader, zodat een nog-te-starten pomp wordt beoordeeld op de druk die hij wérkelijk gaat zien, nooit op zijn stilstand-meting. Levert dat geen geldige ΔP > 0 op, dan wordt de tik overgeslagen en blijft het vorige kader staan.

De optimizer en de totalen lezen elke pomp daarna uitsluitend via de group-predicts op dit gedeelde punt (groupCurves.js), nooit op het individuele sensorpunt. Elke pomp krijgt het punt via machine.setGroupOperatingPoint(down, up). Het oude compatibiliteitspad dat voor rotatingMachines zónder die API het ΔP rechtstreeks in de voorspeller-interpolatoren schreef (fDimension — nog zichtbaar in de snippet onderaan, regels 85-95 van de geanalyseerde revisie) is inmiddels verwijderd (node-repo PR #15/#16): elke rotatingMachine in gebruik heeft de group-API (zowel main als de door EVOLV gepinde submodule-commit), en een machine zónder de API wordt sindsdien luid overgeslagen met een warning in plaats van stil gepatcht.

ΔPheader= PdownPup η= Q·ΔPheader Pas
SymboolBetekenisEenheidHerkomst
ΔPheaderheaderdrukverschil; alleen toegepast als eindig en >0PagroupOperatingPoint.js:74-81
ηhydraulische groepsefficiëntie (zelfde schaal als de child-cog); alleen geschreven bij ΔP > 0 én P > 0— (0..1)specificClass.js:424-432
Q, Pasgroepsflow en asvermogenm³/s, WspecificClass.js:424-432

De code kent drie totaalbegrippen — het onderscheid bepaalt de takgrenzen in sectie 6:

absolute
Wat de groep óóit kan: volledige curve-envelope over alle drukken; max = som van de pomp-maxima. totalsCalculator.js:32-70
dynamic
Wat de groep nu kan, op het huidige bedrijfspunt: min = kleinste pomp-min (8), max = som van de maxima (120). Hierop klemt stap 5 en interpoleert het %-demand. totalsCalculator.js:72-104
active
Wat de draaiende pompen nu kunnen (voorbeeld sectie 6: 16–80). Hierop kiest equalFlow zijn tak. totalsCalculator.js:106-121
groupOperatingPoint.js:48-97; aanroepen: specificClass.js:388 (vóór elke optimalisatie), :267 (bij elke drukverandering); consumptie: groupCurves.js:1-27

Wat als de trappen niet overlappen? — envelope-gaps

De drie totaalbegrippen hierboven verbergen een aanname: dat de bereiken van n en n+1 draaiende pompen op elkaar aansluiten. Bij de voorbeeldpompen (8–40, verhouding 5:1) is dat ruim het geval. Maar bij pompen met een krappe envelope — zeg 30–40 m³/h — vallen er gaten: één pomp dekt 30–40, twee dekken 60–80, en vragen in 40–60 of 80–90 zijn door geen enkele combinatie exact te leveren. Wat de code dan doet verschilt per mode:

Dekking per aantal draaiende pompen, voor een ruime (8–40) en een krappe (30–40) pompenvelope. Bij de krappe pomp vallen de vragen 40–60 en 80–90 in een gat (rood): geen combinatie kan ze exact leveren. Vuistregel: trappen sluiten aan zolang n·max ≥ (n+1)·min, oftewel max/min ≥ (n+1)/n.
priority / rotation (equalFlow)
Tak 2 schakelt bij tot Σmax ≥ vraag en klemt elke pomp op zijn envelope — bij een gat-vraag (50) klemt elke pomp op zijn minimum en levert de groep Σmin (60): een bewuste overdekking, de “trap” springt omhoog. strategies.js:102-127 (klem min(max, max(min, per)))
optimalControl
De combinatiefilter eist Σmin ≤ Qd ≤ Σmax — in een gat bestaat er geen geldige combinatie: warn “No valid combination found” en de lopende verdeling blijft staan. Geen overdekking, maar ook geen nieuwe verdeling. pumpCombinations.js:75-93; specificClass.js:400-403
les voor de pompkeuze
Wil je élke vraag kunnen leveren, kies dan pompen met max/min ≥ 2 (dan sluit elke trap aan vanaf n=1) — of accepteer expliciet het trap-gedrag per mode hierboven. Onder het groeps-minimum klemt de code de vraag sowieso omhoog (sectie 3). strategies.js:37-44 (capFlowDemand)
src/groupOps/groupOperatingPoint.js:48-97groepsbedrijfspunt naar alle pompen
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
48    equalize() {
49        const machines = this.machines || {};
50        if (Object.keys(machines).length === 0) return;
51
52        const pressureUnit = this.unitPolicy.canonical.pressure;
53        const groupHeaderDown = this.measurements
54            .type('pressure').variant('measured').position(POSITIONS.DOWNSTREAM)
55            .getCurrentValue(pressureUnit);
56        const groupHeaderUp = this.measurements
57            .type('pressure').variant('measured').position(POSITIONS.UPSTREAM)
58            .getCurrentValue(pressureUnit);
59
60        const childDown = [];
61        const childUp = [];
62        Object.values(machines).forEach(machine => {
63            const d = this.readChild(machine, 'pressure', 'measured', POSITIONS.DOWNSTREAM, pressureUnit);
64            const u = this.readChild(machine, 'pressure', 'measured', POSITIONS.UPSTREAM, pressureUnit);
65            if (Number.isFinite(d) && d > 0) childDown.push(d);
66            if (Number.isFinite(u) && u > 0) childUp.push(u);
67        });
68
69        const downIsHeader = Number.isFinite(groupHeaderDown) && groupHeaderDown > 0;
70        const upIsHeader   = Number.isFinite(groupHeaderUp)   && groupHeaderUp   > 0;
71        const headerDownstream = downIsHeader ? groupHeaderDown : (childDown.length ? Math.max(...childDown) : 0);
72        const headerUpstream   = upIsHeader   ? groupHeaderUp   : (childUp.length   ? Math.min(...childUp)   : 0);
73
74        const headerDiff = headerDownstream - headerUpstream;
75        if (!Number.isFinite(headerDiff) || headerDiff <= 0) {
76            this.logger?.debug?.(`Skipping equalization: invalid header diff ${headerDiff} (down=${headerDownstream}, up=${headerUpstream})`);
77            return;
78        }
79        // Stash so downstream callers (optimizer, strategies) can compute
80        // hydraulic efficiency without re-reading every machine's pressure.
81        this.headerDiffPa = headerDiff;
82
83        this.logger?.debug?.(`Equalizing operating point: down=${headerDownstream}, up=${headerUpstream}, diff=${headerDiff}`);
84
85        Object.values(machines).forEach(machine => {
86            if (typeof machine.setGroupOperatingPoint === 'function') {
87                machine.setGroupOperatingPoint(headerDownstream, headerUpstream);
88            } else {
89                // Older rotatingMachine without the group API — direct
90                // fDimension write keeps demos working while submodules
91                // are rolled forward.
92                if (machine.predictFlow)  machine.predictFlow.fDimension  = headerDiff;
93                if (machine.predictPower) machine.predictPower.fDimension = headerDiff;
94                if (machine.predictCtrl)  machine.predictCtrl.fDimension  = headerDiff;
95            }
96        });
97    }
src/totals/totalsCalculator.js:72-121dynamic- en active-totalen
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
72    calcDynamicTotals() {
73        const out = { flow: { min: Infinity, max: 0, act: 0 }, power: { min: Infinity, max: 0, act: 0 }, NCog: 0 };
74        const fUnit = this.unitPolicy.canonical.flow;
75        const pUnit = this.unitPolicy.canonical.power;
76
77        Object.values(this.machines).forEach(machine => {
78            if (!machine.hasCurve) {
79                this.logger?.error?.(`Machine ${machine.config?.general?.id} has no valid curve — skipping.`);
80                return;
81            }
82            const gpf = groupFlow(machine);
83            const gpp = groupPower(machine);
84
85            const minFlow  = gpf.currentFxyYMin;
86            const maxFlow  = gpf.currentFxyYMax;
87            const minPower = gpp.currentFxyYMin;
88            const maxPower = gpp.currentFxyYMax;
89
90            const actFlow  = this.operatingPoint?.readChild(machine, 'flow',  'predicted', POSITIONS.DOWNSTREAM,   fUnit) || 0;
91            const actPower = this.operatingPoint?.readChild(machine, 'power', 'predicted', POSITIONS.AT_EQUIPMENT, pUnit) || 0;
92
93            if (minFlow  < out.flow.min)  out.flow.min  = minFlow;
94            if (minPower < out.power.min) out.power.min = minPower;
95            out.flow.max   += maxFlow;
96            out.power.max  += maxPower;
97            out.flow.act   += actFlow;
98            out.power.act  += actPower;
99            out.NCog       += groupNCog(machine);
100        });
101
102        this.dynamicTotals = out;
103        return out;
104    }
105
106    activeTotals() {
107        const out = { flow: { min: 0, max: 0 }, power: { min: 0, max: 0 }, countActiveMachines: 0 };
108
109        Object.entries(this.machines).forEach(([id, machine]) => {
110            if (!this.isMachineActive(id)) return;
111            const gpf = groupFlow(machine);
112            const gpp = groupPower(machine);
113            out.flow.min  += gpf.currentFxyYMin;
114            out.flow.max  += gpf.currentFxyYMax;
115            out.power.min += gpp.currentFxyYMin;
116            out.power.max += gpp.currentFxyYMax;
117            out.countActiveMachines += 1;
118        });
119
120        return out;
121    }

11Poort-referentie & configuratie

MGC heeft drie uitgangen. Conventie voor alle drie: een ontbrekende meting betekent key afwezig in het bericht, nooit null.

0process bij elk 'output-changed'-event · delta-gecomprimeerd
keybetekenis
altijd aanwezig
modeactieve mode (sectie 5)
flowCapacityMax / flowCapacityMindynamische groepsenvelope (sectie 10)
machineCount / machineCountActiveaantal geregistreerde / draaiende pompen
movementStateready of working (sectie 4/9)
absDistFromPeak / relDistFromPeakafstand van de groep tot het efficiëntie-optimum
na de eerste demand
demandFlow / demandPctlaatst gevraagde setpoint, als flow en als % van de envelope
meetafhankelijk — key volgt het patroon {positie}_{variant}_{type}
atEquipment_predicted_flowvoorspelde groepsflow
atEquipment_predicted_powervoorspeld groepsvermogen
atEquipment_predicted_Ncoggenormaliseerd curve-zwaartepunt van de groep
atEquipment_predicted_efficiencyη uit sectie 10 — alleen bij ΔP > 0 én P > 0
downstream_predicted_flowvoorspelde outflow (leest de parent als outflow-signaal)
headerDiffPa / headerDiffMbarheaderdrukverschil, indien bekend
alleen in rotationControl
rotationIndex / rotationsCompleted / leadMachinerotatiestand (sectie 7)
src/io/output.js:20-114; test/_output-manifest.md:7-12 (afwezig-vs-null-conventie)
1dbase zelfde moment als poort 0

Exact dezelfde keyset als poort 0, maar in InfluxDB-vorm via formatMsg(raw, cfg, 'influxdb'); omzetbaar naar frost / json / csv.

2parent eenmalig, bij startup

Alleen de registratie omhoog (sectie 1): { topic:'child.register', payload:<node.id>, positionVsParent, distance }.

BaseNodeAdapter.js:117-131; mgc.html:98 (poortlabels ["process","dbase","parent"])
Belangrijkste configuratievelden; het volledige schema staat in nodes/generalFunctions/src/configs/machineGroupControl.json.
SleutelDefaultBetekenis
general.name"Machine Group Configuration"naam; wordt msg.topic van poort 0
general.unitm3/hdefault meeteenheid (output)
mode.currentoptimalControlbesturingsstrategie (sectie 5)
planner.useRendezvoustruesamen klaar op t* (sectie 9); false = alles vuurt op tick 0
planner.emergencyPressurePanull (inert)drukdrempel (Pa) waarboven een demand de rendezvous-lock mag passeren

Statusbadge in de Node-RED-editor (ververst elke seconde): "<mode> | Q=<actueel>/<capaciteit> m³/h | P=<kW> kW | <beschikbaar>/<totaal>x" — groen bij ≥1 beschikbare pomp, geel bij pompen-maar-geen-beschikbare, grijs bij leeg.

src/io/output.js:116-140
src/io/output.js:20-64poort 0/1: keyset opbouwen (afwezig ≠ null)
echte code · revisie d11d749 (geanalyseerde stand) actuele code op git ↗
20function getOutput(mgc) {
21    const out = {};
22    const { measurements, unitPolicy, mode, absDistFromPeak, relDistFromPeak } = mgc;
23    measurements.getTypes().forEach(type => {
24        measurements.getVariants(type).forEach(variant => {
25            const unit = _outputUnitForType(unitPolicy, type);
26            const read = (pos) => measurements.type(type).variant(variant).position(pos).getCurrentValue(unit || undefined);
27            const dn = read(POSITIONS.DOWNSTREAM);
28            const at = read(POSITIONS.AT_EQUIPMENT);
29            const up = read(POSITIONS.UPSTREAM);
30            if (dn != null) out[`downstream_${variant}_${type}`] = dn;
31            if (up != null) out[`upstream_${variant}_${type}`] = up;
32            if (at != null) out[`atEquipment_${variant}_${type}`] = at;
33            if (dn != null && up != null) {
34                const diff = measurements.type(type).variant(variant)
35                    .difference({ from: POSITIONS.DOWNSTREAM, to: POSITIONS.UPSTREAM, unit });
36                if (diff?.value != null) out[`differential_${variant}_${type}`] = diff.value;
37            }
38        });
39    });
40    out.mode = mode;
41    out.absDistFromPeak = absDistFromPeak;
42    out.relDistFromPeak = relDistFromPeak;
43
44    // System (header) differential pressure resolved by the last equalize.
45    // Dashboards use this to compute head = ΔP / (ρ · g) for Q-H plots
46    // and to scale the BEP indicators without re-reading every child.
47    // Emitted in canonical Pa and in the configured output unit (mbar
48    // by default) so the dashboard can pick whichever it prefers.
49    const headerDiffPa = mgc.operatingPoint?.headerDiffPa;
50    if (Number.isFinite(headerDiffPa) && headerDiffPa > 0) {
51        out.headerDiffPa = headerDiffPa;
52        const pUnit = unitPolicy.output.pressure;
53        // 1 mbar = 100 Pa. Only convert when we recognise mbar; otherwise
54        // leave the raw Pa to avoid a stale or silently wrong unit label.
55        if (pUnit === 'mbar') out.headerDiffMbar = headerDiffPa / 100;
56    }
57
58    // Group capacity + active-machine counts. Surfaced so dashboards can
59    // show the same numbers the status badge does without subscribing to
60    // every child node individually. Emitted in the output flow unit (m³/h)
61    // so the dashed capacity envelope lands on the SAME axis as the predicted-
62    // flow series — dynamicTotals is canonical m³/s, so convert here. (Both
63    // telemetry consumers — the Grafana flow panel and the FlowFuse fanout —
64    // assume m³/h; emitting raw m³/s made the capacity lines render as ~0.)

Herkomst: CRC© (2022)

Het besturingsconcept achter deze node — de drie wetten (vraag ≥ stabiliteit ≥ rendement), combinatie-enumeratie op het momentane bedrijfspunt en de marginale-kostenverfijning — is in 2022 bedacht en beschreven door R. de Ren als CRC© (“Carbon Reducing Controller”), destijds ongepubliceerd en door de auteur open-sourced bij indiensttreding bij WBD. De volledige lijn concept → code:

Deze links openen in de HELIX-reader — daar kun je, net als op deze pagina, passages selecteren en er reviewnotities op achterlaten.

Verder lezen

ABijlage A — aandachtspunten uit de code-analyse

Interne reviewnotities bij de geanalyseerde revisie — geen onderdeel van de functionele uitleg. De hoofdtekst (secties 1–11) staat op zichzelf; wie dit document extern deelt kan deze bijlage weglaten. Observaties d.d. 2026-07-17; runtime-gedrag is niet geverifieerd.

Bron statische code-analyse van de werkkopie RnD/EVOLV (super-repo 74c4089, 2026-07-03) — submodule machineGroupControl @ d11d749afeff, plus het feitendossier van deze analyse. Analysedatum 2026-07-17.
Methode statische code-analyse (geen runtime-verificatie); elk blok draagt een bronverwijzing (bestand:regel). De interactieve figuren zijn herimplementaties van de genoemde functies, vooraf met node doorgerekend; envelopes en tijden in de voorbeelden zijn fictief, de formules en klemregels exact.
Versie 3.0 herschreven 2026-07-21 n.a.v. review-notities: structuur, taal en poortreferentie; kinderen→children; voorbeeld expliciet gelabeld; nieuw: latest-wins tijdlijn en het onderscheid 0 % (min-flow) vs 0 m³/h (stop) — dat laatste corrigeert de v2.2-console, die 0 m³/h ten onrechte naar 8 m³/h klemde.
Versie 3.1 herzien 2026-07-23 n.a.v. reviewronde 2 (R. de Ren): titel ingekort; “procesvraag”-formulering; echte code per sectie en per stap openklapbaar (gepind op d11d749, met git-links); Node-RED-voorbeeldflow importeerbaar in sectie 1; w=1/|slope| toegelicht; duty-rotation kreeg een gebeurtenis-tijdlijn + scenario-afspeler; optimizer kreeg een combinatie-convergentiefiguur en een afspeelbaar zoekpad; rendezvous kreeg een impact-simulatie (hypothese, pilot-toets als vervolg); aandachtspunten verplaatst naar bijlage A (intern); CRC-links openen in de reader.
Versie 3.2 herzien 2026-07-23 n.a.v. reviewronde 3 (R. de Ren): groepsbedrijfspunt geherformuleerd (ΔP-referentiekader, bronvolgorde, skip-gedrag) en het voorspellers-compatibiliteitspad geduid (P4-refactor); rendezvous-simulatie omgebouwd naar het worst-case-scenario (pomp bijschakelen tegen max) mét systeemdruk-vertaling; convergentie- voorbeeld vervangen door een casus waarin de koploper pas op het eind wordt ingehaald; ΣP-teller op het zoekpad; verfijnings/winnaar-snippets direct bij de stopcriteria; duty rotation als tijd-grafiek; nieuwe uitleg envelope-gaps (gedrag per mode + pompkeuze-vuistregel).
Versie 3.4 2026-07-23: het fDimension-compatibiliteitspad is uit de code verwijderd (PR #15 comments + PR #16 verwijdering, beide gemerged; suite 187/187 groen) — geverifieerd dat élke rotatingMachine in gebruik setGroupOperatingPoint heeft; sectie 10 beschrijft nu die stand. De snippet toont bewust nog de geanalyseerde revisie d11d749, waar het pad bestond.
Versie 3.3 herzien 2026-07-23 n.a.v. feedback: sectie 10 leidt nu met de wáárom van de druk-equalisatie (stilstaande pomp meet lokaal te laag → zou op eigen sensorpunt altijd “winnen” → klapperende combinatiekeuze; daarom hoogste benedenstroomse/laagste bovenstroomse meting als gezamenlijk kader) — code-gedrag geverifieerd op de actuele main (identiek aan d11d749), rationale ook als comment in de code vastgelegd (node-repo PR #15); convergentievoorbeeld expliciet gelabeld als didactisch geconstrueerd, met de condities waarin het effect echt optreedt en verwijzing naar de 2022-meting.
Beperking beschrijft de geanalyseerde revisie; gedrag op andere branches of deployments kan afwijken.
Contact R&D-lab · lab.wbd-rd.nl