Waterschap Brabantse Delta · R&D-lab · uitleg
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.
RnD/EVOLV, submodule
machineGroupControl @ d11d749afeff (verantwoording onderaan).rotatingMachine: toestandsmachine en
pompcurves) hebben een eigen artifact op het EVOLV-spoor.bestand:regel), zodat elke uitspraak controleerbaar is.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.
demand van een
operator of van de parent-node: een percentage van de groepscapaciteit óf een absolute flow
(m³/h, l/min, m³/s).In EVOLV-termen: MGC is een S88-Unit en de
parent van zijn pompen. De pompen zijn zijn children — rotatingMachine-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.
Drie dingen om te onthouden uit dit diagram:
machine-children in
this.machines[id]; measurement-children (bv. een druktransmitter op de
header) worden gespiegeld in de eigen metingcontainer.
specificClass.js:141-167machine.handleInput('parent', 'execsequence'|'flowmovement', …).
specificClass.js:468-486; handlers via RED.nodes.getNode'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-506141 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 });
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):
[
{
"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"
]
]
}
]
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.
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).
Let op: 0 % en 0 m³/h zijn niet hetzelfde. De eenheid bepaalt wat er bij lage waarden gebeurt:
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)emergencyStop en gaat rechtstreeks naar turnOffAllMachines, nog vóór
eenheids-omrekening.
commands/handlers.js:90; specificClass.js:536-539530 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
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:
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).
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;
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.
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 }
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.
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-13583 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}
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).
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.
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 }
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.
execsequence-toestemming in mode auto.
pumpCombinations.js:9, :61-73Binnen 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.
| Symbool | Betekenis | Eenheid | Herkomst |
|---|---|---|---|
| QBEP,i | geschat best-efficiency-punt van pomp i (waar de pomp het zuinigst draait) | m³/s | bepGravitation.js:136-137 |
| Qmin,i, spani | minimumflow en envelope-breedte (max−min) op het groepsbedrijfspunt | m³/s | bepGravitation.js:133-135 |
| NCog̃i | positie van het zwaartepunt van de curve, genormaliseerd op [0..1] | — | bepGravitation.js:136 |
| slopei | helling dP/dQ rond het BEP (eindige differenties, richtingsafhankelijk) | W·s/m³ | bepGravitation.js:16-43 |
| wi | herverdelingsgewicht. 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 ruilstap | m³/(W·s) | bepGravitation.js:47-83 |
| Δ | tekort t.o.v. de som van de BEP-punten, herverdeeld met wi | m³/s | bepGravitation.js:150-153 |
| mcΔ | stapgrootte van de verfijning (schuift flow van de duurste naar de goedkoopste marginale m³) | m³/s | bepGravitation.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:
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
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.
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).
bepGravitation.js +
bestCombination.js).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 });
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.
| Symbool | Betekenis | Eenheid | Herkomst |
|---|---|---|---|
| t* | gezamenlijk eindmoment (max over alle echte moves) | s | movementScheduler.js:159-166 |
| etai | duur van de manoeuvre van pomp i; hangt af van de beginstatus: draaiend → alleen ramp; opwarmend → rest-opwarmtijd + ramp; uit → volledige startladder + ramp; stoppend → null | s | moveTrajectory.js:46-80 |
| delayi | wachttijd vóór het afvuren, zodat de move op t* eindigt | s | movementScheduler.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.
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².
movementScheduler.js).useRendezvous), gepland als vervolg.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 }
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 });
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.
| Symbool | Betekenis | Eenheid | Herkomst |
|---|---|---|---|
| ΔPheader | headerdrukverschil; alleen toegepast als eindig en >0 | Pa | groupOperatingPoint.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, Pas | groepsflow en asvermogen | m³/s, W | specificClass.js:424-432 |
De code kent drie totaalbegrippen — het onderscheid bepaalt de takgrenzen in sectie 6:
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:
min(max, max(min, per)))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 }
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 }
MGC heeft drie uitgangen. Conventie voor alle drie: een ontbrekende meting
betekent key afwezig in het bericht, nooit null.
| key | betekenis |
|---|---|
| altijd aanwezig | |
| mode | actieve mode (sectie 5) |
| flowCapacityMax / flowCapacityMin | dynamische groepsenvelope (sectie 10) |
| machineCount / machineCountActive | aantal geregistreerde / draaiende pompen |
| movementState | ready of working (sectie 4/9) |
| absDistFromPeak / relDistFromPeak | afstand van de groep tot het efficiëntie-optimum |
| na de eerste demand | |
| demandFlow / demandPct | laatst gevraagde setpoint, als flow en als % van de envelope |
meetafhankelijk — key volgt het patroon {positie}_{variant}_{type} | |
| atEquipment_predicted_flow | voorspelde groepsflow |
| atEquipment_predicted_power | voorspeld groepsvermogen |
| atEquipment_predicted_Ncog | genormaliseerd curve-zwaartepunt van de groep |
| atEquipment_predicted_efficiency | η uit sectie 10 — alleen bij ΔP > 0 én P > 0 |
| downstream_predicted_flow | voorspelde outflow (leest de parent als outflow-signaal) |
| headerDiffPa / headerDiffMbar | headerdrukverschil, indien bekend |
| alleen in rotationControl | |
| rotationIndex / rotationsCompleted / leadMachine | rotatiestand (sectie 7) |
Exact dezelfde keyset als poort 0, maar in
InfluxDB-vorm via formatMsg(raw, cfg, 'influxdb'); omzetbaar naar
frost / json / csv.
Alleen de registratie omhoog (sectie 1):
{ topic:'child.register', payload:<node.id>, positionVsParent, distance }.
nodes/generalFunctions/src/configs/machineGroupControl.json.| Sleutel | Default | Betekenis |
|---|---|---|
general.name | "Machine Group Configuration" | naam; wordt msg.topic van poort 0 |
general.unit | m3/h | default meeteenheid (output) |
mode.current | optimalControl | besturingsstrategie (sectie 5) |
planner.useRendezvous | true | samen klaar op t* (sectie 9); false = alles vuurt op tick 0 |
planner.emergencyPressurePa | null (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.
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.)
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.
https://lab.wbd-rd.nl/rnd-lab/projects/evolv-machinegroupcontrolCONTRACT.md en test/_output-manifest.md in de
node-repo (RnD/machineGroupControl).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.
config.optimization?.method wordt gelezen (specificClass.js:405) maar
er is geen optimization-sectie in het schema en geen editorveld — in de praktijk
draait altijd BEP-Gravitation-Directional. aanname:
mogelijk een bewust nog-niet-ontsloten optie._lastDispatchedMode en _lastPriorityKey worden gezet
(:587-588) maar nergens in src/ gelezen — dode/toekomstige state; alleen
_lastPriorityList wordt door de emergency-redispatch gebruikt (:294).calcGroupEfficiency deelt cumEfficiency/machineCount zonder guard →
NaN bij 0 machines (src/efficiency/groupEfficiency.js:36); de rel-dist-guards vangen
dit stroomafwaarts als "geen data" af, maar de NaN zelf niet.maintenance heeft geen dispatch-tak; directe handleInput-aanroepers
(bv. een parent-node) passeren de commandgating ongegate en eindigen in de default-tak met een
warn (specificClass.js:618).maxEfficiency is — bewust behouden, misleidende naam — het gemiddelde van de
child-cogs; relDistFromPeak is undefined bij een gedegenereerde band
(src/efficiency/groupEfficiency.js:19-66).checkSpecialCases kan bewust Qd < 0 opleveren; de subsetfilter geeft
dan een lege set → warn "No valid combination found"
(pumpCombinations.js:12; specificClass.js:400-403).headerDiffMbar-fallback en de afwezig-status van
atEquipment_predicted_efficiency zijn niet gepind
(test/_output-manifest.md:169-189).mgc.{js,html} moeten
machineGroupControl.{js,html} worden; de Node-RED type-id is al het volle
'machineGroupControl' en moet bij hernoemen behouden blijven
(EVOLV CLAUDE.md:46-54).buildDomainConfig brugt nog uiConfig.scaling
(src/nodeClass.js:21) terwijl de editor geen scaling-veld meer heeft — restant van
vóór de unit-zelfbeschrijvende demand (test/_output-manifest.md:183-187).RnD/EVOLV
(super-repo 74c4089, 2026-07-03) — submodule machineGroupControl @
d11d749afeff, plus het feitendossier van deze analyse. Analysedatum 2026-07-17.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.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.setGroupOperatingPoint heeft; sectie 10 beschrijft nu die stand. De snippet toont
bewust nog de geanalyseerde revisie d11d749, waar het pad bestond.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.