From 53b55d81c33a768730d09b472db1a1df086a2129 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Tue, 14 Apr 2026 11:00:27 +0200 Subject: [PATCH] fix: fully configure PS basin + add node-completeness rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Basin undersized (10m³) for sinus peak (126 m³/h) → overflow → 122%. Now 30 m³ with 4m height, all PS fields set. New rule: always configure every field of every node. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/rules/node-red-flow-layout.md | 15 +++++- .../build_flow.py | 52 +++++++++++++------ .../pumpingstation-3pumps-dashboard/flow.json | 51 +++++++++++------- 3 files changed, 82 insertions(+), 36 deletions(-) diff --git a/.claude/rules/node-red-flow-layout.md b/.claude/rules/node-red-flow-layout.md index 273f1cf..f5cc71f 100644 --- a/.claude/rules/node-red-flow-layout.md +++ b/.claude/rules/node-red-flow-layout.md @@ -240,7 +240,20 @@ If you only fill the top-level fields, `payload_type=json` is silently treated a Both ends store the paired ids in `links`. The `name` is cosmetic (label only) — Node-RED routes by id. Multiple emitters can target one receiver; one emitter can target multiple receivers. -## 9. Verifying the layout +## 9. Node configuration completeness — ALWAYS set every field + +When placing an EVOLV node in a flow (demo or production), configure **every config field** the node's schema defines — don't rely on schema defaults for operational parameters. Schema defaults exist to make the validator happy, not to represent a realistic plant. + +**Why this matters:** A pumpingStation with `basinVolume: 10` but default `heightOverflow: 2.5` and default `heightOutlet: 0.2` creates an internally inconsistent basin where the fill % exceeds 100%, safety guards fire at wrong thresholds, and the demo looks broken. Every field interacts with every other field. + +**The rule:** +1. Read the node's config schema (`generalFunctions/src/configs/.json`) before writing the flow. +2. For each section (basin, hydraulics, control, safety, scaling, smoothing, …), set EVERY field explicitly in the flow JSON — even if you'd pick the same value as the default. +3. Add a comment in the flow generator per section explaining WHY you chose each value (e.g. "basin sized so sinus peak takes 6 min to fill from startLevel to overflow"). +4. Cross-check computed values: `surfaceArea = volume / height`, `maxVolOverflow = heightOverflow × surfaceArea`, gauge `max` = basin `height`, fill % denominator = `volume` (not overflow volume). +5. If a gauge or chart references a config value (basin height, maxVol), derive it from the same source — never hardcode a number that was computed elsewhere. + +## 10. Verifying the layout Before declaring a flow done: diff --git a/examples/pumpingstation-3pumps-dashboard/build_flow.py b/examples/pumpingstation-3pumps-dashboard/build_flow.py index 022e92c..e0fa6fc 100644 --- a/examples/pumpingstation-3pumps-dashboard/build_flow.py +++ b/examples/pumpingstation-3pumps-dashboard/build_flow.py @@ -576,15 +576,37 @@ def build_process_tab(): "hasDistance": False, "distance": 0, "distanceUnit": "m", "distanceDescription": "", "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", - # PS in levelbased mode — sinus inflow fills the basin, pumps start - # when level > startLevel, stop when level < stopLevel. + # === FULLY CONFIGURED PS — every field explicitly set === + # Rule: ALWAYS configure ALL node fields. Defaults are for + # schema validation, not for realistic operation. + # + # Basin geometry: 30 m³, 4 m tall → surfaceArea = 7.5 m² + # Sized so peak sinus inflow (0.035 m³/s = 126 m³/h) takes + # ~6 min to fill from startLevel to overflow → pumps have time. "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). + "basinVolume": 30, + "basinHeight": 4, + "heightInlet": 3.5, + "heightOutlet": 0.3, + "heightOverflow": 3.8, + "inletPipeDiameter": 0.3, + "outletPipeDiameter": 0.3, + # Level-based control thresholds + "startLevel": 2.0, # pumps ON above 2.0 m (50% of height) + "stopLevel": 1.0, # pumps OFF below 1.0 m (25% of height) + "minFlowLevel": 1.0, # 0% pump demand at this level + "maxFlowLevel": 3.5, # 100% pump demand at this level + # Hydraulics + "refHeight": "NAP", + "minHeightBasedOn": "outlet", + "basinBottomRef": 0, + "staticHead": 12, + "maxDischargeHead": 24, + "pipelineLength": 80, + "defaultFluid": "wastewater", + "temperatureReferenceDegC": 15, + "maxInflowRate": 200, + # Safety guards "enableDryRunProtection": True, "enableOverfillProtection": True, "dryRunThresholdPercent": 5, @@ -613,7 +635,7 @@ def build_process_tab(): "const qOut = find('flow.measured.downstream.') || find('flow.measured.out.');\n" "// Compute derived metrics\n" "// Basin capacity = basinVolume (config). Don't hardcode — read it once.\n" - "if (!context.get('maxVol')) context.set('maxVol', 10.0); // basinVolume from PS config\n" + "if (!context.get('maxVol')) context.set('maxVol', 30.0); // basinVolume from PS config\n" "const maxVol = context.get('maxVol');\n" "const fillPct = vol != null ? Math.min(100, Math.max(0, Math.round(Number(vol) / maxVol * 100))) : null;\n" "const netM3h = (c.netFlow != null) ? Number(c.netFlow) * 3600 : null;\n" @@ -1094,11 +1116,11 @@ def build_ui_tab(): # ===== Basin charts + gauges (fill %, level, net flow) ===== # Gauge segment definitions (reused for both pages) TANK_SEGMENTS = [ - {"color": "#f44336", "from": 0}, # red: below stopLevel - {"color": "#ff9800", "from": 0.6}, # orange: between stop and start - {"color": "#2196f3", "from": 1.2}, # blue: normal operating - {"color": "#ff9800", "from": 2.5}, # orange: approaching overflow - {"color": "#f44336", "from": 2.8}, # red: overflow zone + {"color": "#f44336", "from": 0}, # red: below stopLevel (1.0 m) + {"color": "#ff9800", "from": 1.0}, # orange: between stop and start + {"color": "#2196f3", "from": 2.0}, # blue: normal operating (startLevel) + {"color": "#ff9800", "from": 3.5}, # orange: approaching overflow + {"color": "#f44336", "from": 3.8}, # red: overflow zone (heightOverflow) ] FILL_SEGMENTS = [ {"color": "#f44336", "from": 0}, @@ -1131,7 +1153,7 @@ def build_ui_tab(): "gtype": "gauge-tank", "gstyle": "Rounded", "title": "Level", "units": "m", "prefix": "", "suffix": " m", - "min": 0, "max": 3, + "min": 0, "max": 4, "segments": TANK_SEGMENTS, "width": 2, "height": 5, "order": 2, "icon": "", "sizeGauge": 20, "sizeGap": 2, "sizeSegments": 10, diff --git a/examples/pumpingstation-3pumps-dashboard/flow.json b/examples/pumpingstation-3pumps-dashboard/flow.json index 6734e4d..420f0d3 100644 --- a/examples/pumpingstation-3pumps-dashboard/flow.json +++ b/examples/pumpingstation-3pumps-dashboard/flow.json @@ -853,15 +853,26 @@ "processOutputFormat": "process", "dbaseOutputFormat": "influxdb", "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, + "basinVolume": 30, + "basinHeight": 4, + "heightInlet": 3.5, + "heightOutlet": 0.3, + "heightOverflow": 3.8, + "inletPipeDiameter": 0.3, + "outletPipeDiameter": 0.3, + "startLevel": 2.0, + "stopLevel": 1.0, + "minFlowLevel": 1.0, + "maxFlowLevel": 3.5, + "refHeight": "NAP", + "minHeightBasedOn": "outlet", + "basinBottomRef": 0, + "staticHead": 12, + "maxDischargeHead": 24, + "pipelineLength": 80, + "defaultFluid": "wastewater", + "temperatureReferenceDegC": 15, + "maxInflowRate": 200, "enableDryRunProtection": true, "enableOverfillProtection": true, "dryRunThresholdPercent": 5, @@ -881,7 +892,7 @@ "type": "function", "z": "tab_process", "name": "format PS port 0", - "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst lvl = find('level.predicted.');\nconst vol = find('volume.predicted.');\nconst qIn = find('flow.measured.upstream.') || find('flow.measured.in.');\nconst qOut = find('flow.measured.downstream.') || find('flow.measured.out.');\n// Compute derived metrics\nconst maxVol = 9.33; // must match basinVolume * basinHeight / basinHeight = basinVolume / surfaceArea * height\nconst fillPct = vol != null ? Math.round(Number(vol) / maxVol * 100) : null;\nconst netM3h = (c.netFlow != null) ? Number(c.netFlow) * 3600 : null;\nconst seconds = (c.seconds != null && Number.isFinite(Number(c.seconds))) ? Number(c.seconds) : null;\nconst timeStr = seconds != null ? (seconds > 60 ? Math.round(seconds/60) + ' min' : Math.round(seconds) + ' s') : 'n/a';\nmsg.payload = {\n direction: c.direction || 'steady',\n level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n volume: vol != null ? Number(vol).toFixed(1) + ' m\u00b3' : 'n/a',\n fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n netFlow: netM3h != null ? netM3h.toFixed(0) + ' m\u00b3/h' : 'n/a',\n timeLeft: timeStr,\n qIn: qIn != null ? (Number(qIn) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n // Numerics for trends\n levelNum: lvl != null ? Number(lvl) : null,\n volumeNum: vol != null ? Number(vol) : null,\n fillPctNum: fillPct,\n netFlowNum: netM3h,\n};\nreturn msg;", + "func": "const p = msg.payload || {};\nconst c = context.get('c') || {};\nObject.assign(c, p);\ncontext.set('c', c);\nfunction find(prefix) {\n for (const k in c) { if (k.indexOf(prefix) === 0) return c[k]; }\n return null;\n}\nconst lvl = find('level.predicted.');\nconst vol = find('volume.predicted.');\nconst qIn = find('flow.measured.upstream.') || find('flow.measured.in.');\nconst qOut = find('flow.measured.downstream.') || find('flow.measured.out.');\n// Compute derived metrics\n// Basin capacity = basinVolume (config). Don't hardcode \u2014 read it once.\nif (!context.get('maxVol')) context.set('maxVol', 30.0); // basinVolume from PS config\nconst maxVol = context.get('maxVol');\nconst fillPct = vol != null ? Math.min(100, Math.max(0, Math.round(Number(vol) / maxVol * 100))) : null;\nconst netM3h = (c.netFlow != null) ? Number(c.netFlow) * 3600 : null;\nconst seconds = (c.seconds != null && Number.isFinite(Number(c.seconds))) ? Number(c.seconds) : null;\nconst timeStr = seconds != null ? (seconds > 60 ? Math.round(seconds/60) + ' min' : Math.round(seconds) + ' s') : 'n/a';\nmsg.payload = {\n direction: c.direction || 'steady',\n level: lvl != null ? Number(lvl).toFixed(2) + ' m' : 'n/a',\n volume: vol != null ? Number(vol).toFixed(1) + ' m\u00b3' : 'n/a',\n fillPct: fillPct != null ? fillPct + '%' : 'n/a',\n netFlow: netM3h != null ? netM3h.toFixed(0) + ' m\u00b3/h' : 'n/a',\n timeLeft: timeStr,\n qIn: qIn != null ? (Number(qIn) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n qOut: qOut != null ? (Number(qOut) * 3600).toFixed(0) + ' m\u00b3/h' : 'n/a',\n // Numerics for trends\n levelNum: lvl != null ? Number(lvl) : null,\n volumeNum: vol != null ? Number(vol) : null,\n fillPctNum: fillPct,\n netFlowNum: netM3h,\n};\nreturn msg;", "outputs": 1, "noerr": 0, "initialize": "", @@ -3476,7 +3487,7 @@ "prefix": "", "suffix": " m", "min": 0, - "max": 3, + "max": 4, "segments": [ { "color": "#f44336", @@ -3484,19 +3495,19 @@ }, { "color": "#ff9800", - "from": 0.6 + "from": 1.0 }, { "color": "#2196f3", - "from": 1.2 + "from": 2.0 }, { "color": "#ff9800", - "from": 2.5 + "from": 3.5 }, { "color": "#f44336", - "from": 2.8 + "from": 3.8 } ], "width": 2, @@ -3632,7 +3643,7 @@ "prefix": "", "suffix": " m", "min": 0, - "max": 3, + "max": 4, "segments": [ { "color": "#f44336", @@ -3640,19 +3651,19 @@ }, { "color": "#ff9800", - "from": 0.6 + "from": 1.0 }, { "color": "#2196f3", - "from": 1.2 + "from": 2.0 }, { "color": "#ff9800", - "from": 2.5 + "from": 3.5 }, { "color": "#f44336", - "from": 2.8 + "from": 3.8 } ], "width": 2,