wip: sinus-driven pumping station demo + PS levelbased control to MGC
Some checks failed
CI / lint-and-test (push) Has been cancelled

Architecture change: demo is now driven by a sinusoidal inflow into the
pumping station basin, rather than a random demand generator. The basin
fills from the sinus, and PS's levelbased control should start/stop
pumps via MGC when level crosses start/stop thresholds.

Changes:
- Demo Drivers tab: sinus generator (period 120s, base 0.005 + amp 0.03
  m³/s) replaces the random demand. Sends q_in to PS via link channel.
- PS config: levelbased mode, 10 m³ basin, startLevel 1.2 m / stopLevel
  0.6 m. Volume-based safeties on, time-based off.
- MGC scaling = normalized (was absolute) so PS's percent-based level
  control maps correctly.
- Dashboard mode toggle now drives PS mode (levelbased ↔ manual) instead
  of per-pump setMode. Slider sends Qd to PS (only effective in manual).
- PS code (committed separately): _controlLevelBased now calls
  _applyMachineGroupLevelControl + new Qd topic + forwardDemandToChildren.

KNOWN ISSUE: Basin fills correctly (visible on dashboard), but pumps
don't start when level exceeds startLevel. Likely cause: _pickVariant
for 'level' in _controlLevelBased may not be resolving the predicted
level correctly, or the safetyController is interfering despite
time-threshold being 0. Needs source-level tracing of the PS tick →
_safetyController → _controlLogic → _controlLevelBased path with
logging enabled. To be debugged in the next session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-14 08:42:22 +02:00
parent bc8138c3dc
commit 0cbd6a4077
3 changed files with 224 additions and 347 deletions

View File

@@ -689,45 +689,6 @@
"y": 700,
"wires": []
},
{
"id": "lin_demand_to_mgc",
"type": "link in",
"z": "tab_process",
"name": "cmd:demand",
"links": [
"lout_demand_drivers",
"lout_demand_dash"
],
"x": 120,
"y": 760,
"wires": [
[
"demand_fanout_mgc_ps"
]
]
},
{
"id": "demand_fanout_mgc_ps",
"type": "function",
"z": "tab_process",
"name": "demand \u2192 MGC + PS",
"func": "const v = Number(msg.payload);\nif (!Number.isFinite(v) || v <= 0) return null;\n// MGC accepts Qd in m3/h directly when scaling=absolute.\nconst qd = { topic: 'Qd', payload: v };\n// PS accepts q_in in m3/s (canonical) via the 'unit' field.\nconst qin = { topic: 'q_in', payload: v / 3600, unit: 'm3/s' };\nreturn [qd, qin];",
"outputs": 2,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 600,
"y": 760,
"wires": [
[
"mgc_pumps"
],
[
"ps_basin"
]
]
},
{
"id": "mgc_pumps",
"type": "machineGroupControl",
@@ -797,12 +758,79 @@
"id": "c_ps",
"type": "comment",
"z": "tab_process",
"name": "\u2500\u2500 Pumping Station \u2500\u2500 (basin model, manual control mode)",
"info": "Receives q_in from demand fanout. Emits formatted basin state.",
"name": "\u2500\u2500 Pumping Station \u2500\u2500 (basin model, levelbased control)",
"info": "Receives q_in (simulated inflow) from Demo Drivers tab.\nLevel-based control starts/stops pumps via MGC when level crosses start/stop thresholds.",
"x": 640,
"y": 900,
"wires": []
},
{
"id": "lin_qin_at_ps",
"type": "link in",
"z": "tab_process",
"name": "cmd:q_in",
"links": [
"lout_qin_drivers"
],
"x": 120,
"y": 940,
"wires": [
[
"ps_basin"
]
]
},
{
"id": "lin_qd_at_ps",
"type": "link in",
"z": "tab_process",
"name": "cmd:Qd",
"links": [
"lout_demand_dash"
],
"x": 120,
"y": 980,
"wires": [
[
"qd_to_ps_wrap"
]
]
},
{
"id": "qd_to_ps_wrap",
"type": "function",
"z": "tab_process",
"name": "wrap slider \u2192 PS Qd",
"func": "msg.topic = 'Qd';\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 380,
"y": 980,
"wires": [
[
"ps_basin"
]
]
},
{
"id": "lin_ps_mode_at_ps",
"type": "link in",
"z": "tab_process",
"name": "cmd:ps-mode",
"links": [
"lout_ps_mode_dash"
],
"x": 120,
"y": 1020,
"wires": [
[
"ps_basin"
]
]
},
{
"id": "ps_basin",
"type": "pumpingStation",
@@ -824,13 +852,20 @@
"distanceDescription": "",
"processOutputFormat": "process",
"dbaseOutputFormat": "influxdb",
"controlMode": "manual",
"basinVolume": 50,
"basinHeight": 4,
"enableDryRunProtection": false,
"enableOverfillProtection": false,
"dryRunThresholdPercent": 0,
"overfillThresholdPercent": 100,
"controlMode": "levelbased",
"basinVolume": 10,
"basinHeight": 3,
"startLevel": 1.2,
"stopLevel": 0.6,
"minFlowLevel": 0.6,
"maxFlowLevel": 2.8,
"heightInlet": 2.5,
"heightOutlet": 0.2,
"heightOverflow": 2.8,
"enableDryRunProtection": true,
"enableOverfillProtection": true,
"dryRunThresholdPercent": 5,
"overfillThresholdPercent": 95,
"timeleftToFullOrEmptyThresholdSeconds": 0,
"x": 900,
"y": 980,
@@ -1361,8 +1396,8 @@
"type": "ui-slider",
"z": "tab_ui",
"group": "ui_grp_demand",
"name": "Process demand slider",
"label": "Process Demand (m\u00b3/h)",
"name": "Manual demand (manual mode only)",
"label": "Manual demand (m\u00b3/h) \u2014 active in manual mode only",
"tooltip": "",
"order": 1,
"width": "0",
@@ -1372,7 +1407,7 @@
"topic": "manualDemand",
"topicType": "str",
"min": "0",
"max": "300",
"max": "100",
"step": "5.0",
"showLabel": true,
"showValue": true,
@@ -1393,63 +1428,15 @@
"id": "lout_demand_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:demand",
"name": "cmd:Qd",
"mode": "link",
"links": [
"lin_demand_to_mgc",
"lin_demand_to_text"
"lin_qd_at_ps"
],
"x": 380,
"y": 140,
"wires": []
},
{
"id": "ui_random_toggle",
"type": "ui-switch",
"z": "tab_ui",
"group": "ui_grp_demand",
"name": "Random demand",
"label": "Random demand generator (auto)",
"tooltip": "",
"order": 1,
"width": "0",
"height": "0",
"passthru": true,
"decouple": "false",
"topic": "randomToggle",
"topicType": "str",
"style": "",
"className": "",
"evaluate": "true",
"onvalue": "on",
"onvalueType": "str",
"onicon": "auto_mode",
"oncolor": "#0f52a5",
"offvalue": "off",
"offvalueType": "str",
"officon": "back_hand",
"offcolor": "#888888",
"x": 120,
"y": 200,
"wires": [
[
"lout_random_dash"
]
]
},
{
"id": "lout_random_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:randomToggle",
"mode": "link",
"links": [
"lin_random_to_drivers"
],
"x": 380,
"y": 200,
"wires": []
},
{
"id": "ui_demand_text",
"type": "ui-text",
@@ -1458,8 +1445,8 @@
"order": 1,
"width": "0",
"height": "0",
"name": "Current demand",
"label": "Current demand",
"name": "Manual demand (active in manual mode)",
"label": "Manual demand",
"format": "{{msg.payload}} m\u00b3/h",
"layout": "row-left",
"style": false,
@@ -1470,23 +1457,6 @@
"y": 140,
"wires": []
},
{
"id": "lin_demand_to_text",
"type": "link in",
"z": "tab_ui",
"name": "cmd:demand",
"links": [
"lout_demand_dash",
"lout_demand_drivers"
],
"x": 640,
"y": 140,
"wires": [
[
"ui_demand_text"
]
]
},
{
"id": "c_ui_station",
"type": "comment",
@@ -1502,24 +1472,24 @@
"type": "ui-switch",
"z": "tab_ui",
"group": "ui_grp_station",
"name": "Auto/Manual mode",
"label": "Mode (Auto = MGC orchestrates \u00b7 Manual = dashboard per-pump)",
"name": "Station mode",
"label": "Station mode (Auto = level-based control \u00b7 Manual = slider demand)",
"tooltip": "",
"order": 1,
"width": "0",
"height": "0",
"passthru": true,
"decouple": "false",
"topic": "setMode",
"topic": "changemode",
"topicType": "str",
"style": "",
"className": "",
"evaluate": "true",
"onvalue": "auto",
"onvalue": "levelbased",
"onvalueType": "str",
"onicon": "auto_mode",
"oncolor": "#0f52a5",
"offvalue": "virtualControl",
"offvalue": "manual",
"offvalueType": "str",
"officon": "back_hand",
"offcolor": "#888888",
@@ -1527,18 +1497,18 @@
"y": 360,
"wires": [
[
"lout_mode_dash"
"lout_ps_mode_dash"
]
]
},
{
"id": "lout_mode_dash",
"id": "lout_ps_mode_dash",
"type": "link out",
"z": "tab_ui",
"name": "cmd:mode",
"name": "cmd:ps-mode",
"mode": "link",
"links": [
"lin_mode"
"lin_ps_mode_at_ps"
],
"x": 380,
"y": 360,
@@ -3346,76 +3316,33 @@
"type": "tab",
"label": "\ud83c\udf9b\ufe0f Demo Drivers",
"disabled": false,
"info": "Auto stimulus for the demo. Random demand generator + state holder for the dashboard's randomToggle switch. In production, delete this tab and feed cmd:demand from your real demand source."
"info": "Simulated inflow for the demo. A slow sinusoid generates inflow into the pumping station basin, which then drives the level-based pump control automatically.\n\nIn production, delete this tab \u2014 real inflow comes from upstream measurement sensors."
},
{
"id": "c_drv_title",
"type": "comment",
"z": "tab_drivers",
"name": "\ud83c\udf9b\ufe0f DEMO DRIVERS \u2014 auto stimulus only",
"info": "Removable: in production, replace this tab with the real demand source.",
"name": "\ud83c\udf9b\ufe0f DEMO DRIVERS \u2014 simulated basin inflow",
"info": "Sinus generator \u2192 q_in to pumpingStation. Basin fills \u2192 level-based\ncontrol starts pumps \u2192 basin drains \u2192 pumps stop \u2192 cycle repeats.",
"x": 640,
"y": 20,
"wires": []
},
{
"id": "c_drv_state",
"id": "c_drv_sinus",
"type": "comment",
"z": "tab_drivers",
"name": "\u2500\u2500 Random toggle state \u2500\u2500",
"info": "",
"name": "\u2500\u2500 Sinusoidal inflow generator \u2500\u2500",
"info": "Produces a smooth inflow curve (m\u00b3/s) and sends to pumpingStation\nvia the cmd:q_in link channel. Period = 120s.",
"x": 640,
"y": 100,
"wires": []
},
{
"id": "lin_random_to_drivers",
"type": "link in",
"z": "tab_drivers",
"name": "cmd:randomToggle",
"links": [
"lout_random_dash"
],
"x": 120,
"y": 140,
"wires": [
[
"random_state"
]
]
},
{
"id": "random_state",
"type": "function",
"z": "tab_drivers",
"name": "store random on/off",
"func": "flow.set('randomOn', msg.payload === 'on');\nreturn null;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 380,
"y": 140,
"wires": [
[]
]
},
{
"id": "c_drv_random",
"type": "comment",
"z": "tab_drivers",
"name": "\u2500\u2500 Random demand generator \u2500\u2500 (every 3 s)",
"info": "",
"x": 640,
"y": 250,
"wires": []
},
{
"id": "rand_tick",
"id": "sinus_tick",
"type": "inject",
"z": "tab_drivers",
"name": "tick (random demand)",
"name": "tick (1s inflow)",
"props": [
{
"p": "topic",
@@ -3427,52 +3354,51 @@
"vt": "date"
}
],
"topic": "randomTick",
"topic": "sinusTick",
"payload": "",
"payloadType": "date",
"repeat": "3",
"repeat": "1",
"crontab": "",
"once": false,
"onceDelay": "0.5",
"x": 120,
"y": 290,
"y": 140,
"wires": [
[
"random_demand_fn"
"sinus_fn"
]
]
},
{
"id": "random_demand_fn",
"id": "sinus_fn",
"type": "function",
"z": "tab_drivers",
"name": "random demand",
"func": "if (!flow.get('randomOn')) return null;\nconst v = Math.round(40 + Math.random() * 200);\nreturn { topic: 'manualDemand', payload: v };",
"name": "sinus inflow (m\u00b3/s)",
"func": "const base = 0.005; // m\u00b3/s (~18 m\u00b3/h always)\nconst amplitude = 0.03; // m\u00b3/s (~108 m\u00b3/h peak)\nconst period = 120; // seconds per full cycle\nconst t = Date.now() / 1000; // seconds since epoch\nconst q = base + amplitude * (1 + Math.sin(2 * Math.PI * t / period)) / 2;\nreturn { topic: 'q_in', payload: q, unit: 'm3/s', timestamp: Date.now() };",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 600,
"y": 290,
"y": 140,
"wires": [
[
"lout_demand_drivers"
"lout_qin_drivers"
]
]
},
{
"id": "lout_demand_drivers",
"id": "lout_qin_drivers",
"type": "link out",
"z": "tab_drivers",
"name": "cmd:demand",
"name": "cmd:q_in",
"mode": "link",
"links": [
"lin_demand_to_mgc",
"lin_demand_to_text"
"lin_qin_at_ps"
],
"x": 900,
"y": 290,
"y": 140,
"wires": []
},
{
@@ -3496,7 +3422,7 @@
"id": "setup_mgc_scaling",
"type": "inject",
"z": "tab_setup",
"name": "MGC scaling = absolute",
"name": "MGC scaling = normalized",
"props": [
{
"p": "topic",
@@ -3504,12 +3430,12 @@
},
{
"p": "payload",
"v": "absolute",
"v": "normalized",
"vt": "str"
}
],
"topic": "setScaling",
"payload": "absolute",
"payload": "normalized",
"payloadType": "str",
"repeat": "",
"crontab": "",
@@ -3654,49 +3580,5 @@
"x": 380,
"y": 350,
"wires": []
},
{
"id": "setup_random_on",
"type": "inject",
"z": "tab_setup",
"name": "auto-enable random demand",
"props": [
{
"p": "topic",
"vt": "str"
},
{
"p": "payload",
"v": "on",
"vt": "str"
}
],
"topic": "randomToggle",
"payload": "on",
"payloadType": "str",
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "5",
"x": 120,
"y": 450,
"wires": [
[
"lout_setup_random"
]
]
},
{
"id": "lout_setup_random",
"type": "link out",
"z": "tab_setup",
"name": "cmd:randomToggle",
"mode": "link",
"links": [
"lin_random_to_drivers"
],
"x": 380,
"y": 450,
"wires": []
}
]