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

@@ -481,26 +481,10 @@ def build_process_tab():
"── MGC ── (orchestrates the 3 pumps via optimalcontrol)",
"Receives Qd from cmd:demand link-in. Distributes flow across pumps."
))
nodes.append(link_in(
"lin_demand_to_mgc", TAB_PROCESS, LANE_X[0], y_mgc + 60,
CH_DEMAND,
source_out_ids=[f"lout_demand_drivers", f"lout_demand_dash"],
downstream=["demand_fanout_mgc_ps"],
))
# Single fanout: one demand value → MGC (Qd) + PS (q_in).
# Skips when v <= 0 to avoid auto-shutdown.
nodes.append(function_node(
"demand_fanout_mgc_ps", TAB_PROCESS, LANE_X[1] + 220, y_mgc + 60,
"demand → MGC + PS",
"const v = Number(msg.payload);\n"
"if (!Number.isFinite(v) || v <= 0) return null;\n"
"// MGC accepts Qd in m3/h directly when scaling=absolute.\n"
"const qd = { topic: 'Qd', payload: v };\n"
"// PS accepts q_in in m3/s (canonical) via the 'unit' field.\n"
"const qin = { topic: 'q_in', payload: v / 3600, unit: 'm3/s' };\n"
"return [qd, qin];",
outputs=2, wires=[[MGC_ID], [PS_ID]],
))
# MGC no longer receives direct Qd from the dashboard — PS drives it
# via level-based control or manual Qd forwarding. The demand_fanout
# has been replaced by: sinus → q_in → PS (levelbased), and
# slider → Qd → PS (manual mode only).
nodes.append({
"id": MGC_ID, "type": "machineGroupControl", "z": TAB_PROCESS,
"name": "MGC — Pump Group",
@@ -552,8 +536,34 @@ def build_process_tab():
# ---------------- PS ----------------
y_ps = 100 + 4 * SECTION_GAP
nodes.append(comment("c_ps", TAB_PROCESS, LANE_X[2], y_ps,
"── Pumping Station ── (basin model, manual control mode)",
"Receives q_in from demand fanout. Emits formatted basin state."
"── Pumping Station ── (basin model, levelbased control)",
"Receives q_in (simulated inflow) from Demo Drivers tab.\n"
"Level-based control starts/stops pumps via MGC when level crosses start/stop thresholds."
))
# link-in for simulated inflow from Drivers tab
nodes.append(link_in(
"lin_qin_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 40,
"cmd:q_in", source_out_ids=["lout_qin_drivers"],
downstream=[PS_ID],
))
# link-in for manual Qd demand from Dashboard slider (only effective in manual mode)
nodes.append(link_in(
"lin_qd_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 80,
"cmd:Qd", source_out_ids=["lout_demand_dash"],
downstream=["qd_to_ps_wrap"],
))
nodes.append(function_node(
"qd_to_ps_wrap", TAB_PROCESS, LANE_X[1], y_ps + 80,
"wrap slider → PS Qd",
"msg.topic = 'Qd';\n"
"return msg;",
outputs=1, wires=[[PS_ID]],
))
# link-in for PS mode toggle from Dashboard
nodes.append(link_in(
"lin_ps_mode_at_ps", TAB_PROCESS, LANE_X[0], y_ps + 120,
"cmd:ps-mode", source_out_ids=["lout_ps_mode_dash"],
downstream=[PS_ID],
))
nodes.append({
"id": PS_ID, "type": "pumpingStation", "z": TAB_PROCESS,
@@ -566,13 +576,19 @@ def build_process_tab():
"hasDistance": False, "distance": 0, "distanceUnit": "m",
"distanceDescription": "",
"processOutputFormat": "process", "dbaseOutputFormat": "influxdb",
# PS in manual mode + safeties off — see top-level README for why.
"controlMode": "manual",
"basinVolume": 50, "basinHeight": 4,
"enableDryRunProtection": False,
"enableOverfillProtection": False,
"dryRunThresholdPercent": 0,
"overfillThresholdPercent": 100,
# PS in levelbased mode sinus inflow fills the basin, pumps start
# when level > startLevel, stop when level < stopLevel.
"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,
# Volume-based safeties ON, time-based OFF (time guard fires too
# aggressively with the sinus demo's small basin + high peak inflow).
"enableDryRunProtection": True,
"enableOverfillProtection": True,
"dryRunThresholdPercent": 5,
"overfillThresholdPercent": 95,
"timeleftToFullOrEmptyThresholdSeconds": 0,
"x": LANE_X[3], "y": y_ps + 80,
"wires": [
@@ -721,49 +737,39 @@ def build_ui_tab():
"── Process Demand ──", ""))
nodes.append(ui_slider(
"ui_demand_slider", TAB_UI, LANE_X[0], y + 40, g_demand,
"Process demand slider", "Process Demand (m³/h)",
0, 300, 5.0, "manualDemand",
"Manual demand (manual mode only)", "Manual demand (m³/h) — active in manual mode only",
0, 100, 5.0, "manualDemand",
wires=["lout_demand_dash"]
))
nodes.append(link_out(
"lout_demand_dash", TAB_UI, LANE_X[1], y + 40,
CH_DEMAND, target_in_ids=["lin_demand_to_mgc", "lin_demand_to_text"]
))
nodes.append(ui_switch(
"ui_random_toggle", TAB_UI, LANE_X[0], y + 100, g_demand,
"Random demand", "Random demand generator (auto)",
on_value="on", off_value="off", topic="randomToggle",
wires=["lout_random_dash"]
))
nodes.append(link_out(
"lout_random_dash", TAB_UI, LANE_X[1], y + 100,
CH_RANDOM_TOGGLE, target_in_ids=["lin_random_to_drivers"]
"cmd:Qd", target_in_ids=["lin_qd_at_ps"]
))
nodes.append(ui_text(
"ui_demand_text", TAB_UI, LANE_X[3], y + 40, g_demand,
"Current demand", "Current demand", "{{msg.payload}} m³/h"
))
# Echo the demand back for the text widget
nodes.append(link_in(
"lin_demand_to_text", TAB_UI, LANE_X[2], y + 40,
CH_DEMAND, source_out_ids=["lout_demand_dash", "lout_demand_drivers"],
downstream=["ui_demand_text"]
"Manual demand (active in manual mode)", "Manual demand",
"{{msg.payload}} m³/h"
))
# The slider value routes to PS as Qd — only effective in manual mode.
# Route is: slider → link-out cmd:Qd → process tab link-in → PS.
# ===== SECTION: Mode + Station Buttons =====
y = 320
nodes.append(comment("c_ui_station", TAB_UI, LANE_X[2], y,
"── Mode + Station-wide buttons ──", ""))
# Mode toggle now drives the PUMPING STATION mode (levelbased ↔ manual)
# instead of per-pump setMode. In levelbased mode, PS drives pumps
# automatically. In manual mode, the demand slider takes over.
nodes.append(ui_switch(
"ui_mode_toggle", TAB_UI, LANE_X[0], y + 40, g_station,
"Auto/Manual mode",
"Mode (Auto = MGC orchestrates · Manual = dashboard per-pump)",
on_value="auto", off_value="virtualControl", topic="setMode",
wires=["lout_mode_dash"]
"Station mode",
"Station mode (Auto = level-based control · Manual = slider demand)",
on_value="levelbased", off_value="manual", topic="changemode",
wires=["lout_ps_mode_dash"]
))
nodes.append(link_out(
"lout_mode_dash", TAB_UI, LANE_X[1], y + 40,
CH_MODE, target_in_ids=["lin_mode"]
"lout_ps_mode_dash", TAB_UI, LANE_X[1], y + 40,
"cmd:ps-mode", target_in_ids=["lin_ps_mode_at_ps"]
))
for k, (text, payload, color, icon, lout_id, channel) in enumerate([
@@ -1052,53 +1058,52 @@ def build_drivers_tab():
"id": TAB_DRIVERS, "type": "tab",
"label": "🎛️ 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\n"
"In production, delete this tab — real inflow comes from "
"upstream measurement sensors.",
})
nodes.append(comment("c_drv_title", TAB_DRIVERS, LANE_X[2], 20,
"🎛️ DEMO DRIVERS — auto stimulus only",
"Removable: in production, replace this tab with the real demand source."
"🎛️ DEMO DRIVERS — simulated basin inflow",
"Sinus generator → q_in to pumpingStation. Basin fills → level-based\n"
"control starts pumps → basin drains → pumps stop → cycle repeats."
))
# Random toggle state holder (set by the dashboard switch)
# Sinus inflow generator: produces a flow value (m³/s) that
# simulates incoming wastewater. Period ~120s so the fill/drain
# cycle is visible on the dashboard. Amplitude scaled so 3 pumps
# can handle the peak.
# Q_in = base + amplitude * (1 + sin(2π t / period)) / 2
# base = 0.005 m³/s (~18 m³/h) — always some inflow
# amplitude = 0.03 m³/s (~108 m³/h peak)
# period = 120 s
y = 100
nodes.append(comment("c_drv_state", TAB_DRIVERS, LANE_X[2], y,
"── Random toggle state ──", ""))
nodes.append(link_in(
"lin_random_to_drivers", TAB_DRIVERS, LANE_X[0], y + 40,
CH_RANDOM_TOGGLE, source_out_ids=["lout_random_dash"],
downstream=["random_state"]
nodes.append(comment("c_drv_sinus", TAB_DRIVERS, LANE_X[2], y,
"── Sinusoidal inflow generator ──",
"Produces a smooth inflow curve (m³/s) and sends to pumpingStation\n"
"via the cmd:q_in link channel. Period = 120s."
))
nodes.append(function_node(
"random_state", TAB_DRIVERS, LANE_X[1], y + 40,
"store random on/off",
"flow.set('randomOn', msg.payload === 'on');\n"
"return null;",
outputs=1, wires=[[]]
))
# Random demand generator: every 3s pick a value in [40, 240] m³/h
y = 250
nodes.append(comment("c_drv_random", TAB_DRIVERS, LANE_X[2], y,
"── Random demand generator ── (every 3 s)", ""))
nodes.append(inject(
"rand_tick", TAB_DRIVERS, LANE_X[0], y + 40,
"tick (random demand)",
topic="randomTick", payload="", payload_type="date",
repeat="3", wires=["random_demand_fn"]
"sinus_tick", TAB_DRIVERS, LANE_X[0], y + 40,
"tick (1s inflow)",
topic="sinusTick", payload="", payload_type="date",
repeat="1", wires=["sinus_fn"]
))
nodes.append(function_node(
"random_demand_fn", TAB_DRIVERS, LANE_X[1] + 220, y + 40,
"random demand",
"if (!flow.get('randomOn')) return null;\n"
"const v = Math.round(40 + Math.random() * 200);\n"
"return { topic: 'manualDemand', payload: v };",
outputs=1, wires=[["lout_demand_drivers"]]
"sinus_fn", TAB_DRIVERS, LANE_X[1] + 220, y + 40,
"sinus inflow (m³/s)",
"const base = 0.005; // m³/s (~18 m³/h always)\n"
"const amplitude = 0.03; // m³/s (~108 m³/h peak)\n"
"const period = 120; // seconds per full cycle\n"
"const t = Date.now() / 1000; // seconds since epoch\n"
"const q = base + amplitude * (1 + Math.sin(2 * Math.PI * t / period)) / 2;\n"
"return { topic: 'q_in', payload: q, unit: 'm3/s', timestamp: Date.now() };",
outputs=1, wires=[["lout_qin_drivers"]]
))
nodes.append(link_out(
"lout_demand_drivers", TAB_DRIVERS, LANE_X[3], y + 40,
CH_DEMAND, target_in_ids=["lin_demand_to_mgc", "lin_demand_to_text"]
"lout_qin_drivers", TAB_DRIVERS, LANE_X[3], y + 40,
"cmd:q_in", target_in_ids=["lin_qin_at_ps"]
))
return nodes
@@ -1126,8 +1131,8 @@ def build_setup_tab():
y = 100
nodes.append(inject(
"setup_mgc_scaling", TAB_SETUP, LANE_X[0], y,
"MGC scaling = absolute",
topic="setScaling", payload="absolute", payload_type="str",
"MGC scaling = normalized",
topic="setScaling", payload="normalized", payload_type="str",
once=True, once_delay="1.5",
wires=["lout_setup_to_mgc"]
))
@@ -1170,18 +1175,8 @@ def build_setup_tab():
CH_STATION_START, target_in_ids=["lin_station_start"]
))
y = 450
nodes.append(inject(
"setup_random_on", TAB_SETUP, LANE_X[0], y,
"auto-enable random demand",
topic="randomToggle", payload="on", payload_type="str",
once=True, once_delay="5",
wires=["lout_setup_random"]
))
nodes.append(link_out(
"lout_setup_random", TAB_SETUP, LANE_X[1], y,
CH_RANDOM_TOGGLE, target_in_ids=["lin_random_to_drivers"]
))
# (Random demand removed — sinus inflow drives the demo automatically.
# No explicit "random on" inject needed.)
return nodes