diff --git a/examples/README.md b/examples/README.md index 4e1e480..0913d46 100644 --- a/examples/README.md +++ b/examples/README.md @@ -79,8 +79,12 @@ These flows follow the EVOLV layout rule set in - **Group boxes** wrap each parent + its direct children, coloured by the parent's S88 level. -## Regenerating +## Maintaining -The current example JSON files are hand-maintained. If you re-introduce a -generator, regenerate `01-Basic.json` and `02-Dashboard.json` from it -rather than editing the JSON directly. +These example flows are **hand-authored one-offs** — edit the JSON directly. +There is intentionally no generator: examples are illustrative, not produced in +bulk. Validate any change with `flow-lint`: + +```bash +node ../../../tools/flow-lint/bin/flow-lint.js 01-Basic.json 02-Dashboard.json +``` diff --git a/tools/build-examples.js b/tools/build-examples.js deleted file mode 100644 index a009206..0000000 --- a/tools/build-examples.js +++ /dev/null @@ -1,955 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -/** - * build-examples.js — regenerate the three example flows for pumpingStation. - * - * Source of truth for the Tier 1/2/3 example flows under examples/. - * Follows EVOLV/.claude/rules/node-red-flow-layout.md: - * - Lane positions L0..L7 = [120, 360, 600, 840, 1080, 1320, 1560, 1800] - * - S88 colours per Node-RED group (Process Cell = #0c99d9, Unit = #50a8d9, - * Equipment Module = #86bbdd, Control Module = #a9daee, neutral = #dddddd) - * - Cross-tab wiring via named link out/link in channels (cmd:* / evt:* / setup:*) - * - ui-chart objects carry every mandatory key (interpolation, yAxisProperty, - * xAxisPropertyType, action, removeOlder*, colors, etc.) — omitting any - * causes FlowFuse to render the chart blank with no error. - * - * Only canonical pumpingStation topic names are used (per CONTRACT.md): - * set.mode, set.inflow, set.demand, cmd.calibrate.volume, cmd.calibrate.level. - * - * Run from repo root or any cwd: - * node nodes/pumpingStation/tools/build-examples.js - */ - -const fs = require('fs'); -const path = require('path'); - -const OUT_DIR = path.join(__dirname, '..', 'examples'); - -/* ------------------------------------------------------------------ */ -/* Layout constants */ -/* ------------------------------------------------------------------ */ - -const LANE_X = [120, 360, 600, 840, 1080, 1320, 1560, 1800]; -const S88 = { - AR: '#0f52a5', - PC: '#0c99d9', - UN: '#50a8d9', - EM: '#86bbdd', - CM: '#a9daee', - neutral: '#dddddd', -}; - -const CHART_COLORS = [ - '#0095FF', '#FF0000', '#FF7F0E', '#2CA02C', '#A347E1', - '#D62728', '#FF9896', '#9467BD', '#C5B0D5', -]; - -/* ------------------------------------------------------------------ */ -/* Helpers */ -/* ------------------------------------------------------------------ */ - -function tab(id, label, info) { - return { id, type: 'tab', label, disabled: false, info: info || '' }; -} - -function comment(id, z, name, x, y) { - return { id, type: 'comment', z, name, info: '', x, y, wires: [] }; -} - -function linkOut(id, z, name, x, y, links) { - return { id, type: 'link out', z, name, mode: 'link', links: links || [], x, y, wires: [] }; -} - -function linkIn(id, z, name, x, y, links, downstream) { - return { id, type: 'link in', z, name, links: links || [], x, y, wires: [downstream || []] }; -} - -function inject(id, z, name, topic, payload, payloadType, x, y, wires, opts) { - const o = opts || {}; - const props = [ - { p: 'topic', vt: 'str' }, - { p: 'payload', v: String(payload), vt: payloadType }, - ]; - // Command envelope: declare the unit alongside the value so the example - // documents what unit the number is in (the registry converts to the - // descriptor unit). origin = control authority (parent|GUI|fysical). - if (o.unit) props.push({ p: 'unit', v: o.unit, vt: 'str' }); - if (o.origin) props.push({ p: 'origin', v: o.origin, vt: 'str' }); - return { - id, type: 'inject', z, name, - props, - topic, - repeat: o.repeat || '', - crontab: '', - once: !!o.once, - onceDelay: o.onceDelay || '', - x, y, - wires: [wires || []], - }; -} - -function fn(id, z, name, code, x, y, wires, outputs) { - return { - id, type: 'function', z, name, - func: code, - outputs: outputs || 1, - noerr: 0, - initialize: '', - finalize: '', - libs: [], - x, y, - wires: wires || [[]], - }; -} - -function debugNode(id, z, name, x, y, complete, targetType, active) { - return { - id, type: 'debug', z, name, - active: active !== false, - tosidebar: true, - console: false, - tostatus: false, - complete: complete || 'payload', - targetType: targetType || 'msg', - x, y, wires: [], - }; -} - -function group(id, z, name, color, nodes, bbox) { - return { - id, type: 'group', z, name, - style: { label: true, stroke: '#000000', fill: color, 'fill-opacity': '0.10' }, - nodes, - x: bbox.x, y: bbox.y, w: bbox.w, h: bbox.h, - }; -} - -function bboxOf(nodeList, ids, pad) { - const p = pad == null ? 20 : pad; - const ns = nodeList.filter((n) => ids.includes(n.id)); - const xs = ns.map((n) => n.x || 0); - const ys = ns.map((n) => n.y || 0); - const minX = Math.min(...xs) - p; - const minY = Math.min(...ys) - p - 20; - const w = Math.max(...xs) - Math.min(...xs) + 200 + 2 * p; - const h = Math.max(...ys) - Math.min(...ys) + 60 + 2 * p; - return { x: minX, y: minY, w, h }; -} - -/* Build a fully-specified pumpingStation node. Every config field is set - * explicitly per rule §9 (no schema-default reliance for operational - * parameters). 50 m³ basin, 3.5 m height, inflow at 3 m, outflow at 0.2 m, - * overflow at 3.2 m. Level thresholds chosen so levelbased control activates - * mid-tank and saturates near overflow. - */ -function pumpingStationNode(id, z, name, x, y, wires) { - return { - id, type: 'pumpingStation', z, name, - simulator: false, - basinVolume: 50, - basinHeight: 3.5, - inflowLevel: 3.0, - outflowLevel: 0.2, - overflowLevel: 3.2, - defaultFluid: 'wastewater', - inletPipeDiameter: 0.3, - outletPipeDiameter: 0.3, - pipelineLength: 80, - maxDischargeHead: 24, - staticHead: 12, - maxInflowRate: 200, - temperatureReferenceDegC: 15, - timeleftToFullOrEmptyThresholdSeconds: 0, - enableDryRunProtection: true, - enableOverfillProtection: true, - dryRunThresholdPercent: 2, - overfillThresholdPercent: 98, - minHeightBasedOn: 'outlet', - processOutputFormat: 'process', - dbaseOutputFormat: 'influxdb', - refHeight: 'NAP', - basinBottomRef: 1, - uuid: 'example-ps-001', - supplier: 'WBD-RD', - category: 'station', - assetType: 'pumpingstation', - model: 'demo-50m3', - unit: 'm3/h', - enableLog: true, - logLevel: 'info', - positionVsParent: 'atEquipment', - positionIcon: '', - hasDistance: false, - distance: '', - distanceUnit: 'm', - distanceDescription: '', - controlMode: 'levelbased', - startLevel: 1.2, - minLevel: 0.4, - maxLevel: 2.8, - flowSetpoint: null, - flowDeadband: null, - x, y, - wires: wires || [[], [], []], - }; -} - -function measurementLevelNode(id, z, name, x, y, wires) { - return { - id, type: 'measurement', z, name, - mode: 'analog', - channels: '[]', - scaling: false, - i_min: 0, i_max: 0, i_offset: 0, - o_min: 0, o_max: 1, - simulator: true, - smooth_method: 'mean', - count: 5, - processOutputFormat: 'process', - dbaseOutputFormat: 'influxdb', - uuid: 'example-level-001', - supplier: 'vega', - category: 'sensor', - assetType: 'level', - model: 'VEGAPULS-31', - unit: 'm', - assetTagNumber: 'LT-001', - enableLog: false, - logLevel: 'error', - positionVsParent: 'atEquipment', - positionIcon: '', - hasDistance: false, - distance: 0, - distanceUnit: 'm', - distanceDescription: '', - x, y, - wires: wires || [[], [], []], - }; -} - -function machineGroupControlNode(id, z, name, x, y, wires) { - return { - id, type: 'machineGroupControl', z, name, - enableLog: true, - logLevel: 'info', - positionVsParent: 'atEquipment', - positionIcon: '', - hasDistance: false, - distance: '', - distanceUnit: 'm', - x, y, - wires: wires || [[], [], []], - }; -} - -function rotatingMachineNode(id, z, name, uuid, x, y, wires) { - return { - id, type: 'rotatingMachine', z, name, - speed: '1', - startup: '2', warmup: '1', shutdown: '2', cooldown: '1', - movementMode: 'staticspeed', - machineCurve: '', - uuid, - supplier: 'hidrostal', - category: 'pump', - assetType: 'pump-centrifugal', - model: 'hidrostal-H05K-S03R', - unit: 'm3/h', - curvePressureUnit: 'mbar', - curveFlowUnit: 'm3/h', - curvePowerUnit: 'kW', - curveControlUnit: '%', - enableLog: false, - logLevel: 'error', - positionVsParent: 'atEquipment', - positionIcon: '', - hasDistance: false, - distance: '', - distanceUnit: 'm', - distanceDescription: '', - x, y, - wires: wires || [[], [], []], - }; -} - -/* FlowFuse ui-chart with every required key (per layout rule §4). */ -function uiChart(id, z, group, name, label, order, yAxisLabel, x, y, color) { - return { - id, type: 'ui-chart', z, group, name, label, - order, width: 12, height: 6, - chartType: 'line', - category: 'topic', - categoryType: 'msg', - xAxisLabel: 'time', - xAxisType: 'time', - xAxisProperty: '', - xAxisPropertyType: 'timestamp', - xAxisFormat: '', - xAxisFormatType: 'auto', - yAxisLabel, - yAxisProperty: 'payload', - yAxisPropertyType: 'msg', - xmin: '', xmax: '', ymin: '', ymax: '', - bins: 10, - action: 'append', - stackSeries: false, - pointShape: 'circle', - pointRadius: 4, - interpolation: 'linear', - showLegend: true, - className: '', - removeOlder: '15', - removeOlderUnit: '60', - removeOlderPoints: '200', - colors: color ? [color, ...CHART_COLORS.slice(1)] : CHART_COLORS, - textColor: ['#666666'], - textColorDefault: true, - gridColor: ['#e5e5e5'], - gridColorDefault: true, - x, y, wires: [], - }; -} - -function uiText(id, z, group, name, label, order, x, y, format) { - return { - id, type: 'ui-text', z, group, name, label, - order, width: 4, height: 1, - format: format || '{{msg.payload}}', - layout: 'row-spread', - x, y, wires: [], - }; -} - -function uiSlider(id, z, group, name, label, order, x, y, topic, min, max, step) { - return { - id, type: 'ui-slider', z, group, name, label, - order, width: 6, height: 1, - passthru: true, - outs: 'end', - topic, - topicType: 'str', - min, max, step, - icon: '', - thumbLabel: 'always', - showValue: true, - className: '', - x, y, wires: [[]], - }; -} - -function uiDropdown(id, z, group, name, label, order, x, y, topic, options, wires) { - return { - id, type: 'ui-dropdown', z, group, name, label, - order, width: 6, height: 1, - passthru: true, - multiple: false, - options: options.map((o) => ({ label: o, value: o, type: 'str' })), - payload: '', - topic, - topicType: 'str', - x, y, - wires: [wires || []], - }; -} - -function uiBase(id) { - return { - id, type: 'ui-base', - name: 'EVOLV Demo', - path: '/dashboard', - appIcon: '', - includeClientData: true, - acceptsClientConfig: ['ui-notification', 'ui-control'], - showPathInSidebar: false, - headerContent: 'page', - navigationStyle: 'default', - titleBarStyle: 'default', - }; -} - -function uiTheme(id) { - return { - id, type: 'ui-theme', - name: 'EVOLV Theme', - colors: { - surface: '#ffffff', primary: '#0c99d9', bgPage: '#eeeeee', - groupBg: '#ffffff', groupOutline: '#cccccc', - }, - sizes: { - density: 'default', pagePadding: '14px', groupGap: '14px', - groupBorderRadius: '6px', widgetGap: '12px', - }, - }; -} - -function uiPage(id, base, theme, name, path, order) { - return { - id, type: 'ui-page', name, ui: base, path, - icon: 'water', - layout: 'grid', theme, - breakpoints: [{ name: 'Default', px: '0', cols: '12' }], - order, className: '', - }; -} - -function uiGroup(id, page, name, width, height, order) { - return { - id, type: 'ui-group', name, page, width, height, order, - showTitle: true, className: '', - }; -} - -/* ------------------------------------------------------------------ */ -/* Tier 1 — 01-Basic.json */ -/* ------------------------------------------------------------------ */ - -function buildBasic() { - const Z = 'ps_basic_tab'; - const nodes = []; - - nodes.push(tab(Z, 'PumpingStation - Basic', - 'Tier 1: single pumpingStation node driven by inject nodes only. ' + - 'Demonstrates the canonical Phase-2 topic API: set.mode, set.inflow, set.demand.')); - - nodes.push(comment('ps_basic_title', Z, - 'PumpingStation - Basic\n' + - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' + - 'A 50 m³ basin (3.5 m tall, inflow at 3.0 m, outflow at 0.2 m,\n' + - 'overflow at 3.2 m). controlMode = levelbased, manual demand allowed\n' + - 'only when set.mode = manual.\n\n' + - 'HOW TO USE:\n' + - ' 1. Deploy the flow.\n' + - ' 2. Click "set.mode = manual" so set.demand is honoured.\n' + - ' 3. Click "set.inflow = 60 m3/h" to push wastewater into the basin.\n' + - ' 4. Watch the basin fill on Port 0 (level, volume, percControl rise).\n' + - ' 5. Click "calibrate volume 25 m3" to jump straight to half-full.\n\n' + - 'Aliases (changemode, q_in, Qd, …) still work but log a deprecation\n' + - 'warning - fresh flows use the canonical names.', 600, 40)); - - // Lane 0: link-in placeholders (none for Tier 1 - all inputs are local). - // Lane 2..3: inject nodes (we keep them in lane 1 for proximity). - const injectMode = inject('ps_basic_inj_mode', Z, 'set.mode = manual', 'set.mode', 'manual', 'str', 200, 160, ['ps_basic_node']); - const injectModeLvl = inject('ps_basic_inj_mode_lvl',Z, 'set.mode = levelbased','set.mode', 'levelbased', 'str', 220, 200, ['ps_basic_node']); - const injectInflow = inject('ps_basic_inj_inflow', Z, 'set.inflow = 60 m3/h', 'set.inflow', '60', 'num', 200, 260, ['ps_basic_node'], { unit: 'm3/h' }); - const injectDemand = inject('ps_basic_inj_demand', Z, 'set.demand = 40 m3/h', 'set.demand', '40', 'num', 200, 300, ['ps_basic_node'], { unit: 'm3/h' }); - const injectCalVol = inject('ps_basic_inj_calvol', Z, 'calibrate volume 25 m3','cmd.calibrate.volume','25','num', 220, 360, ['ps_basic_node'], { unit: 'm3' }); - const injectCalLvl = inject('ps_basic_inj_callvl', Z, 'calibrate level 1.5 m','cmd.calibrate.level','1.5','num', 220, 400, ['ps_basic_node'], { unit: 'm' }); - nodes.push(injectMode, injectModeLvl, injectInflow, injectDemand, injectCalVol, injectCalLvl); - - // Lane 5 (PC): the pumpingStation itself. - const ps = pumpingStationNode('ps_basic_node', Z, 'Pumping Station', LANE_X[5], 300, - [['ps_basic_format'], ['ps_basic_dbg_influx'], ['ps_basic_dbg_parent']]); - nodes.push(ps); - - // Lane 6: format/merge function for Port 0. - const formatFn = fn('ps_basic_format', Z, 'Merge deltas + format', - "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" + - "const cache = context.get('c') || {};\n" + - "Object.assign(cache, p);\n" + - "context.set('c', cache);\n" + - "function pick(prefix) {\n" + - " for (const k of Object.keys(cache)) if (k === prefix || k.indexOf(prefix + '.') === 0) {\n" + - " const v = Number(cache[k]); if (Number.isFinite(v)) return v;\n" + - " } return null;\n" + - "}\n" + - "const vol = pick('volume.predicted.atequipment');\n" + - "const lvl = pick('level.predicted.atequipment');\n" + - "const flIn = pick('flow.predicted.in');\n" + - "msg.payload = {\n" + - " state: cache.state || 'unknown',\n" + - " controlMode: cache.controlMode || cache.mode || 'n/a',\n" + - " direction: cache.direction || 'n/a',\n" + - " percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a',\n" + - " volume: vol != null ? vol.toFixed(2) + ' m3' : 'n/a',\n" + - " volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1) + ' %' : 'n/a',\n" + - " level: lvl != null ? lvl.toFixed(3) + ' m' : 'n/a',\n" + - " inflow: flIn != null ? (flIn * 3600).toFixed(1) + ' m3/h' : 'n/a',\n" + - " timeToFull: cache.timeToFull != null ? Number(cache.timeToFull).toFixed(0) + ' s' : 'n/a',\n" + - " timeToEmpty: cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a'\n" + - "};\nreturn msg;", - LANE_X[6], 280, [['ps_basic_dbg_process']]); - nodes.push(formatFn); - - // Lane 7: debug taps. - nodes.push(debugNode('ps_basic_dbg_process', Z, 'Port 0: Process', LANE_X[7], 240, 'payload', 'msg', true)); - nodes.push(debugNode('ps_basic_dbg_influx', Z, 'Port 1: InfluxDB', LANE_X[7], 320, 'true', 'full', false)); - nodes.push(debugNode('ps_basic_dbg_parent', Z, 'Port 2: Parent reg', LANE_X[7], 380, 'true', 'full', true)); - - // Wrap the station + its formatter in a Process Cell group box. - const psGroupIds = ['ps_basic_node', 'ps_basic_format']; - nodes.push(group('grp_ps_basic', Z, 'Pumping Station (PC)', S88.PC, psGroupIds, - bboxOf(nodes, psGroupIds, 30))); - - return nodes; -} - -/* ------------------------------------------------------------------ */ -/* Tier 2 — 02-Integration.json */ -/* ------------------------------------------------------------------ */ - -function buildIntegration() { - const TAB_PROC = 'ps_int_proc'; - const TAB_SETUP = 'ps_int_setup'; - const nodes = []; - - nodes.push(tab(TAB_PROC, 'Process Plant', - 'Tier 2: pumpingStation + measurement child + machineGroupControl parent with two rotatingMachine pumps. ' + - 'Demonstrates Phase-2 parent/child handshakes and the canonical set.mode/set.inflow/set.demand topics.')); - nodes.push(tab(TAB_SETUP, 'Setup', - 'Deploy-time once-true injects that initialise control modes on the EVOLV nodes.')); - - /* ---------- Process Plant tab ---------------------------------- */ - - nodes.push(comment('ps_int_title', TAB_PROC, - 'PumpingStation - Integration\n' + - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' + - 'L0 link-ins | L2 level sensor (CM) | L3 pumps (EM) | L4 MGC (UN) | L5 station (PC).\n' + - 'Pumps register with MGC via Port 2; MGC and the level sensor register with the station via Port 2.\n' + - 'Cross-tab channels: setup:* drive once-true initialisation from the Setup tab.', 600, 40)); - - /* Link-ins on L0 receive from the Setup tab. */ - const linInMode = linkIn('lin_setup_mode', TAB_PROC, 'setup:to-ps-mode', LANE_X[0], 500, [], ['ps_int_station']); - const linInInflow = linkIn('lin_setup_inflow', TAB_PROC, 'setup:to-ps-inflow', LANE_X[0], 560, [], ['ps_int_station']); - const linInMgcMode = linkIn('lin_setup_mgcmode', TAB_PROC, 'setup:to-mgc-mode', LANE_X[0], 360, [], ['ps_int_mgc']); - nodes.push(linInMode, linInInflow, linInMgcMode); - - /* L2: level measurement (Control Module). */ - const levelMeas = measurementLevelNode('meas_level', TAB_PROC, 'Basin level sensor', - LANE_X[2], 700, [['ps_int_dbg_level'], [], ['ps_int_station']]); - nodes.push(levelMeas); - // Simulator measurement injector for the level sensor (push a varying level so PS sees something). - const levelInj = inject('ps_int_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num', LANE_X[0], 700, ['meas_level']); - nodes.push(levelInj); - - /* L3: two rotatingMachine pumps (Equipment Module). */ - const pumpA = rotatingMachineNode('pump_a', TAB_PROC, 'Pump A', 'example-pump-a', - LANE_X[3], 320, [['ps_int_dbg_pa'], [], ['ps_int_mgc']]); - const pumpB = rotatingMachineNode('pump_b', TAB_PROC, 'Pump B', 'example-pump-b', - LANE_X[3], 400, [['ps_int_dbg_pb'], [], ['ps_int_mgc']]); - nodes.push(pumpA, pumpB); - - /* L4: MGC (Unit). */ - const mgc = machineGroupControlNode('ps_int_mgc', TAB_PROC, 'Pump Group', - LANE_X[4], 360, [['ps_int_dbg_mgc'], [], ['ps_int_station']]); - nodes.push(mgc); - - /* L5: pumpingStation (Process Cell). */ - const station = pumpingStationNode('ps_int_station', TAB_PROC, 'Pumping Station', - LANE_X[5], 520, [['ps_int_format'], ['ps_int_dbg_influx'], []]); - nodes.push(station); - - /* L6: formatter for the station's Port 0. */ - const formatFn = fn('ps_int_format', TAB_PROC, 'Merge deltas + format', - "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" + - "const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" + - "function pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\n" + - "const vol=pick('volume.predicted.atequipment'), lvl=pick('level.predicted.atequipment'), flIn=pick('flow.predicted.in'), flOut=pick('flow.predicted.out');\n" + - "msg.payload = {\n" + - " state: cache.state || 'unknown',\n" + - " controlMode: cache.controlMode || cache.mode || 'n/a',\n" + - " direction: cache.direction || 'n/a',\n" + - " percControl: cache.percControl != null ? Number(cache.percControl).toFixed(1)+' %' : 'n/a',\n" + - " volume: vol != null ? vol.toFixed(2)+' m3' : 'n/a',\n" + - " volumePercent: cache.volumePercent != null ? Number(cache.volumePercent).toFixed(1)+' %' : 'n/a',\n" + - " level: lvl != null ? lvl.toFixed(3)+' m' : 'n/a',\n" + - " inflow: flIn != null ? (flIn*3600).toFixed(1)+' m3/h' : 'n/a',\n" + - " outflow: flOut != null ? (flOut*3600).toFixed(1)+' m3/h' : 'n/a',\n" + - " childCount: cache.childCount != null ? cache.childCount : 'n/a'\n" + - "};\nreturn msg;", - LANE_X[6], 520, [['ps_int_dbg_process']]); - nodes.push(formatFn); - - /* L7: debug taps for the various ports. */ - nodes.push(debugNode('ps_int_dbg_process', TAB_PROC, 'PS Port 0: Process', LANE_X[7], 480, 'payload', 'msg', true)); - nodes.push(debugNode('ps_int_dbg_influx', TAB_PROC, 'PS Port 1: InfluxDB', LANE_X[7], 540, 'true', 'full', false)); - nodes.push(debugNode('ps_int_dbg_mgc', TAB_PROC, 'MGC Port 0', LANE_X[7], 360, 'payload', 'msg', true)); - nodes.push(debugNode('ps_int_dbg_pa', TAB_PROC, 'Pump A Port 0', LANE_X[7], 320, 'payload', 'msg', false)); - nodes.push(debugNode('ps_int_dbg_pb', TAB_PROC, 'Pump B Port 0', LANE_X[7], 400, 'payload', 'msg', false)); - nodes.push(debugNode('ps_int_dbg_level', TAB_PROC, 'Level Port 0', LANE_X[7], 700, 'payload', 'msg', false)); - - /* Group boxes. */ - const pumpAIds = ['pump_a', 'ps_int_dbg_pa']; - const pumpBIds = ['pump_b', 'ps_int_dbg_pb']; - const mgcIds = ['ps_int_mgc', 'ps_int_dbg_mgc', 'lin_setup_mgcmode']; - const stationIds = ['ps_int_station', 'ps_int_format', 'ps_int_dbg_process', 'ps_int_dbg_influx', 'lin_setup_mode', 'lin_setup_inflow']; - const levelIds = ['meas_level', 'ps_int_inj_level', 'ps_int_dbg_level']; - nodes.push(group('grp_pumpa', TAB_PROC, 'Pump A (EM)', S88.EM, pumpAIds, bboxOf(nodes, pumpAIds, 25))); - nodes.push(group('grp_pumpb', TAB_PROC, 'Pump B (EM)', S88.EM, pumpBIds, bboxOf(nodes, pumpBIds, 25))); - nodes.push(group('grp_mgc', TAB_PROC, 'Pump Group MGC (UN)', S88.UN, mgcIds, bboxOf(nodes, mgcIds, 25))); - nodes.push(group('grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, stationIds, bboxOf(nodes, stationIds, 25))); - nodes.push(group('grp_level', TAB_PROC, 'Level Sensor (CM)', S88.CM, levelIds, bboxOf(nodes, levelIds, 25))); - - /* ---------- Setup tab ----------------------------------------- */ - - nodes.push(comment('setup_title', TAB_SETUP, - 'Deploy-time setup\n' + - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' + - 'Fires once after each deploy: pushes the canonical set.mode / set.inflow /\n' + - 'set.demand topics across cross-tab channels into the Process Plant tab.', - LANE_X[2], 40)); - - const setMode = inject('setup_inj_mode', TAB_SETUP, 'set.mode = levelbased', 'set.mode', 'levelbased', 'str', LANE_X[0], 160, ['lout_setup_mode'], { once: true, onceDelay: '0.5' }); - const setMgc = inject('setup_inj_mgcmode', TAB_SETUP, 'MGC set.mode = auto', 'set.mode', 'auto', 'str', LANE_X[0], 220, ['lout_setup_mgcmode'],{ once: true, onceDelay: '0.5' }); - const setInflow = inject('setup_inj_inflow', TAB_SETUP, 'seed inflow 60 m3/h', 'set.inflow', '60', 'num', LANE_X[0], 280, ['lout_setup_inflow'], { once: true, onceDelay: '1.0', unit: 'm3/h' }); - nodes.push(setMode, setMgc, setInflow); - - const loutMode = linkOut('lout_setup_mode', TAB_SETUP, 'setup:to-ps-mode', LANE_X[7], 160, ['lin_setup_mode']); - const loutMgcMode = linkOut('lout_setup_mgcmode', TAB_SETUP, 'setup:to-mgc-mode', LANE_X[7], 220, ['lin_setup_mgcmode']); - const loutInflow = linkOut('lout_setup_inflow', TAB_SETUP, 'setup:to-ps-inflow', LANE_X[7], 280, ['lin_setup_inflow']); - nodes.push(loutMode, loutMgcMode, loutInflow); - - // Setup tab group. - const setupIds = ['setup_inj_mode', 'setup_inj_mgcmode', 'setup_inj_inflow', - 'lout_setup_mode', 'lout_setup_mgcmode', 'lout_setup_inflow']; - nodes.push(group('grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25))); - - return nodes; -} - -/* ------------------------------------------------------------------ */ -/* Tier 3 — 03-Dashboard.json */ -/* ------------------------------------------------------------------ */ - -function buildDashboard() { - const TAB_PROC = 'ps_dash_proc'; - const TAB_UI = 'ps_dash_ui'; - const TAB_SETUP = 'ps_dash_setup'; - const nodes = []; - - nodes.push(tab(TAB_PROC, 'Process Plant', - 'Tier 3: full station with measurement + MGC + 2 pumps, formatted for live dashboard.')); - nodes.push(tab(TAB_UI, 'Dashboard UI', - 'FlowFuse dashboard 2.0: 3 charts (flow / level / volumePercent), text widgets and 2 sliders.')); - nodes.push(tab(TAB_SETUP, 'Setup', - 'Once-true injects: initial mode + initial inflow seed.')); - - /* ---------- FlowFuse dashboard scaffolding -------------------- */ - - nodes.push(uiBase('ps_dash_base')); - nodes.push(uiTheme('ps_dash_theme')); - nodes.push(uiPage('ps_dash_page', 'ps_dash_base', 'ps_dash_theme', 'PumpingStation Demo', '/pumping-station', 1)); - nodes.push(uiGroup('ps_dash_grp_ctrl', 'ps_dash_page', 'Controls', 6, 1, 1)); - nodes.push(uiGroup('ps_dash_grp_status', 'ps_dash_page', 'Operator Status', 6, 1, 2)); - nodes.push(uiGroup('ps_dash_grp_trend', 'ps_dash_page', 'Live Trends', 12, 1, 3)); - - /* ---------- Process Plant tab --------------------------------- */ - - nodes.push(comment('ps_dash_proc_title', TAB_PROC, - 'Process Plant\n━━━━━━━━━━━━━━━━━\nFull station with parent (MGC) and 2 pump children.\n' + - 'Events go to Dashboard UI through evt:ps; commands come back through cmd:ps-mode and cmd:ps-demand.', - 600, 40)); - - /* L0 link-ins: setup + dashboard commands. */ - const linModeProc = linkIn('lin_proc_mode', TAB_PROC, 'cmd:ps-mode', LANE_X[0], 480, [], ['ps_dash_station']); - const linDemandProc = linkIn('lin_proc_demand', TAB_PROC, 'cmd:ps-demand', LANE_X[0], 540, [], ['ps_dash_station']); - const linSetupMode = linkIn('lin_proc_setupmode', TAB_PROC, 'setup:to-ps-mode', LANE_X[0], 420, [], ['ps_dash_station']); - const linSetupInflow= linkIn('lin_proc_setupinflow', TAB_PROC, 'setup:to-ps-inflow',LANE_X[0], 600, [], ['ps_dash_station']); - nodes.push(linModeProc, linDemandProc, linSetupMode, linSetupInflow); - - /* L2 level sensor with simulator. */ - const levelMeas = measurementLevelNode('ps_dash_meas_level', TAB_PROC, 'Basin level sensor', - LANE_X[2], 700, [[], [], ['ps_dash_station']]); - nodes.push(levelMeas); - nodes.push(inject('ps_dash_inj_level', TAB_PROC, 'sim level 1.6 m', 'measurement', '1.6', 'num', - LANE_X[0], 700, ['ps_dash_meas_level'])); - - /* L3 pumps. */ - const pumpA = rotatingMachineNode('ps_dash_pump_a', TAB_PROC, 'Pump A', 'example-pump-a', - LANE_X[3], 320, [[], [], ['ps_dash_mgc']]); - const pumpB = rotatingMachineNode('ps_dash_pump_b', TAB_PROC, 'Pump B', 'example-pump-b', - LANE_X[3], 400, [[], [], ['ps_dash_mgc']]); - nodes.push(pumpA, pumpB); - - /* L4 MGC. */ - const mgc = machineGroupControlNode('ps_dash_mgc', TAB_PROC, 'Pump Group', - LANE_X[4], 360, [[], [], ['ps_dash_station']]); - nodes.push(mgc); - - /* L5 pumpingStation. */ - const station = pumpingStationNode('ps_dash_station', TAB_PROC, 'Pumping Station', - LANE_X[5], 520, [['ps_dash_trend_split'], [], []]); - nodes.push(station); - - /* L6 trend-split fn: one output per chart + one output for the status text widgets. - * Outputs: - * 0 -> chart_flow ({topic: 'Inflow', payload: m3/h}, {topic: 'Outflow', payload: m3/h}) - * 1 -> chart_level ({topic: 'Level', payload: m}) - * 2 -> chart_volpct ({topic: 'Volume%', payload: %}) - * 3 -> text_status (compact state string) - * 4 -> text_perc (percControl) - * 5 -> text_direction (direction) - * 6 -> text_timetoempty(timeToEmpty) - */ - const trendCode = - "const p = (msg && msg.payload && typeof msg.payload === 'object') ? msg.payload : {};\n" + - "const cache = context.get('c') || {}; Object.assign(cache, p); context.set('c', cache);\n" + - "function pick(prefix){ for (const k of Object.keys(cache)) if (k===prefix||k.indexOf(prefix+'.')===0){ const v=Number(cache[k]); if(Number.isFinite(v)) return v;} return null; }\n" + - "const flowIn = pick('flow.predicted.in');\n" + - "const flowOut = pick('flow.predicted.out');\n" + - "const level = pick('level.predicted.atequipment');\n" + - "const volPct = Number(cache.volumePercent);\n" + - "const ts = Date.now();\n" + - "const flowMsgs = [];\n" + - "if (flowIn != null) flowMsgs.push({ topic: 'Inflow', payload: flowIn * 3600, timestamp: ts });\n" + - "if (flowOut != null) flowMsgs.push({ topic: 'Outflow', payload: flowOut * 3600, timestamp: ts });\n" + - "const flowOut1 = flowMsgs.length ? flowMsgs : null;\n" + - "const levelOut = level != null ? { topic: 'Level', payload: level, timestamp: ts } : null;\n" + - "const volOut = Number.isFinite(volPct) ? { topic: 'Volume%', payload: volPct, timestamp: ts } : null;\n" + - "const stateStr = `state=${cache.state||'?'} | mode=${cache.controlMode||cache.mode||'?'}`;\n" + - "const percStr = cache.percControl != null ? Number(cache.percControl).toFixed(1) + ' %' : 'n/a';\n" + - "const dirStr = cache.direction || 'n/a';\n" + - "const tEmpty = cache.timeToEmpty != null ? Number(cache.timeToEmpty).toFixed(0) + ' s' : 'n/a';\n" + - "return [\n" + - " flowOut1,\n" + - " levelOut,\n" + - " volOut,\n" + - " { payload: stateStr },\n" + - " { payload: percStr },\n" + - " { payload: dirStr },\n" + - " { payload: tEmpty }\n" + - "];"; - const trendSplit = fn('ps_dash_trend_split', TAB_PROC, 'Trend split + status', trendCode, - LANE_X[6], 520, - [ - ['lout_evt_flow'], - ['lout_evt_level'], - ['lout_evt_volpct'], - ['lout_evt_state'], - ['lout_evt_perc'], - ['lout_evt_dir'], - ['lout_evt_tempty'], - ], 7); - nodes.push(trendSplit); - - /* L7 link-outs into the Dashboard UI tab. */ - const loutFlow = linkOut('lout_evt_flow', TAB_PROC, 'evt:flow', LANE_X[7], 420, ['lin_ui_flow']); - const loutLevel = linkOut('lout_evt_level', TAB_PROC, 'evt:level', LANE_X[7], 460, ['lin_ui_level']); - const loutVolPct = linkOut('lout_evt_volpct', TAB_PROC, 'evt:volpct', LANE_X[7], 500, ['lin_ui_volpct']); - const loutState = linkOut('lout_evt_state', TAB_PROC, 'evt:state', LANE_X[7], 540, ['lin_ui_state']); - const loutPerc = linkOut('lout_evt_perc', TAB_PROC, 'evt:perc', LANE_X[7], 580, ['lin_ui_perc']); - const loutDir = linkOut('lout_evt_dir', TAB_PROC, 'evt:dir', LANE_X[7], 620, ['lin_ui_dir']); - const loutTempty = linkOut('lout_evt_tempty', TAB_PROC, 'evt:tempty', LANE_X[7], 660, ['lin_ui_tempty']); - nodes.push(loutFlow, loutLevel, loutVolPct, loutState, loutPerc, loutDir, loutTempty); - - /* Process tab groups. */ - const procStationIds = ['ps_dash_station', 'ps_dash_trend_split', - 'lin_proc_mode', 'lin_proc_demand', 'lin_proc_setupmode', 'lin_proc_setupinflow', - 'lout_evt_flow', 'lout_evt_level', 'lout_evt_volpct', 'lout_evt_state', 'lout_evt_perc', 'lout_evt_dir', 'lout_evt_tempty']; - const procPumpAIds = ['ps_dash_pump_a']; - const procPumpBIds = ['ps_dash_pump_b']; - const procMgcIds = ['ps_dash_mgc']; - const procLevelIds = ['ps_dash_meas_level', 'ps_dash_inj_level']; - nodes.push(group('ps_dash_grp_station', TAB_PROC, 'Pumping Station (PC)', S88.PC, procStationIds, bboxOf(nodes, procStationIds, 25))); - nodes.push(group('ps_dash_grp_pa', TAB_PROC, 'Pump A (EM)', S88.EM, procPumpAIds, bboxOf(nodes, procPumpAIds, 25))); - nodes.push(group('ps_dash_grp_pb', TAB_PROC, 'Pump B (EM)', S88.EM, procPumpBIds, bboxOf(nodes, procPumpBIds, 25))); - nodes.push(group('ps_dash_grp_mgc', TAB_PROC, 'Pump Group MGC (UN)', S88.UN, procMgcIds, bboxOf(nodes, procMgcIds, 25))); - nodes.push(group('ps_dash_grp_level', TAB_PROC, 'Level Sensor (CM)', S88.CM, procLevelIds, bboxOf(nodes, procLevelIds, 25))); - - /* ---------- Dashboard UI tab ---------------------------------- */ - - nodes.push(comment('ps_dash_ui_title', TAB_UI, - 'Dashboard UI\n━━━━━━━━━━━━━━━\nLink-ins on L0 receive evt:* from Process Plant.\n' + - 'Sliders on L2 emit cmd:* back to Process Plant.\n' + - 'Charts use the trend-split pattern: one chart per metric, series labelled by msg.topic.', - 600, 40)); - - /* L0 link-ins from the process side. */ - nodes.push(linkIn('lin_ui_flow', TAB_UI, 'evt:flow', LANE_X[0], 220, [], ['ui_chart_flow'])); - nodes.push(linkIn('lin_ui_level', TAB_UI, 'evt:level', LANE_X[0], 320, [], ['ui_chart_level'])); - nodes.push(linkIn('lin_ui_volpct', TAB_UI, 'evt:volpct', LANE_X[0], 420, [], ['ui_chart_volpct'])); - nodes.push(linkIn('lin_ui_state', TAB_UI, 'evt:state', LANE_X[0], 520, [], ['ui_text_state'])); - nodes.push(linkIn('lin_ui_perc', TAB_UI, 'evt:perc', LANE_X[0], 560, [], ['ui_text_perc'])); - nodes.push(linkIn('lin_ui_dir', TAB_UI, 'evt:dir', LANE_X[0], 600, [], ['ui_text_dir'])); - nodes.push(linkIn('lin_ui_tempty', TAB_UI, 'evt:tempty', LANE_X[0], 640, [], ['ui_text_tempty'])); - - /* L4 charts and text widgets. */ - nodes.push(uiChart('ui_chart_flow', TAB_UI, 'ps_dash_grp_trend', 'Flow trend', 'Flow (m³/h)', 1, 'm³/h', LANE_X[4], 220)); - nodes.push(uiChart('ui_chart_level', TAB_UI, 'ps_dash_grp_trend', 'Level trend', 'Level (m)', 2, 'm', LANE_X[4], 320)); - nodes.push(uiChart('ui_chart_volpct', TAB_UI, 'ps_dash_grp_trend', 'Volume %', 'Volume (%)', 3, '%', LANE_X[4], 420)); - nodes.push(uiText( 'ui_text_state', TAB_UI, 'ps_dash_grp_status','State', 'Station state',1, LANE_X[4], 520)); - nodes.push(uiText( 'ui_text_perc', TAB_UI, 'ps_dash_grp_status','percControl', 'Control %', 2, LANE_X[4], 560)); - nodes.push(uiText( 'ui_text_dir', TAB_UI, 'ps_dash_grp_status','direction', 'Direction', 3, LANE_X[4], 600)); - nodes.push(uiText( 'ui_text_tempty', TAB_UI, 'ps_dash_grp_status','timeToEmpty', 'Time to empty',4, LANE_X[4], 640)); - - /* L2 controls: dropdown for mode + slider for demand. */ - const modeDropdown = uiDropdown('ui_dd_mode', TAB_UI, 'ps_dash_grp_ctrl', - 'Mode', 'Control mode', 1, LANE_X[2], 160, 'set.mode', - ['manual', 'levelbased', 'flowbased', 'none'], ['ui_wrap_mode']); - const demandSlider = uiSlider('ui_sl_demand', TAB_UI, 'ps_dash_grp_ctrl', - 'Demand', 'Manual demand (m³/h)', 2, LANE_X[2], 220, 'set.demand', 0, 200, 5); - nodes.push(modeDropdown, demandSlider); - // Slider wires need explicit wiring (uiSlider helper leaves wires empty so we set them post-creation). - demandSlider.wires = [['ui_wrap_demand']]; - - /* L4 wrappers: enforce the canonical topic on the outgoing msg. */ - const wrapMode = fn('ui_wrap_mode', TAB_UI, 'topic=set.mode', - "msg.topic = 'set.mode';\nmsg.payload = String(msg.payload || 'manual');\nreturn msg;", - LANE_X[4], 160, [['lout_cmd_mode']]); - const wrapDemand = fn('ui_wrap_demand', TAB_UI, 'topic=set.demand', - "msg.topic = 'set.demand';\nmsg.payload = Number(msg.payload);\nreturn Number.isFinite(msg.payload) ? msg : null;", - LANE_X[4], 220, [['lout_cmd_demand']]); - nodes.push(wrapMode, wrapDemand); - - /* L7 link-outs to the process plant. */ - nodes.push(linkOut('lout_cmd_mode', TAB_UI, 'cmd:ps-mode', LANE_X[7], 160, ['lin_proc_mode'])); - nodes.push(linkOut('lout_cmd_demand', TAB_UI, 'cmd:ps-demand', LANE_X[7], 220, ['lin_proc_demand'])); - - /* UI tab groups (mirror the dashboard groups). */ - const uiCtrlIds = ['ui_dd_mode', 'ui_sl_demand', 'ui_wrap_mode', 'ui_wrap_demand', - 'lout_cmd_mode', 'lout_cmd_demand']; - const uiStatusIds = ['ui_text_state', 'ui_text_perc', 'ui_text_dir', 'ui_text_tempty', - 'lin_ui_state', 'lin_ui_perc', 'lin_ui_dir', 'lin_ui_tempty']; - const uiTrendIds = ['ui_chart_flow', 'ui_chart_level', 'ui_chart_volpct', - 'lin_ui_flow', 'lin_ui_level', 'lin_ui_volpct']; - nodes.push(group('grp_ui_ctrl', TAB_UI, 'Controls (PC)', S88.PC, uiCtrlIds, bboxOf(nodes, uiCtrlIds, 25))); - nodes.push(group('grp_ui_status', TAB_UI, 'Operator status (PC)', S88.PC, uiStatusIds, bboxOf(nodes, uiStatusIds, 25))); - nodes.push(group('grp_ui_trend', TAB_UI, 'Live trends (PC)', S88.PC, uiTrendIds, bboxOf(nodes, uiTrendIds, 25))); - - /* ---------- Setup tab ----------------------------------------- */ - - nodes.push(comment('ps_dash_setup_title', TAB_SETUP, 'Deploy-time setup\n━━━━━━━━━━━━━━━━━━━\n' + - 'Initialises set.mode = levelbased and seeds an inflow at deploy time.', - LANE_X[2], 40)); - - nodes.push(inject('ps_dash_setup_mode', TAB_SETUP, 'set.mode = levelbased', 'set.mode', 'levelbased', 'str', - LANE_X[0], 160, ['ps_dash_lout_setup_mode'], { once: true, onceDelay: '0.5' })); - nodes.push(inject('ps_dash_setup_inflow', TAB_SETUP, 'seed inflow 60 m3/h', 'set.inflow', '60', 'num', - LANE_X[0], 220, ['ps_dash_lout_setup_inflow'], { once: true, onceDelay: '1.0', unit: 'm3/h' })); - - nodes.push(linkOut('ps_dash_lout_setup_mode', TAB_SETUP, 'setup:to-ps-mode', LANE_X[7], 160, ['lin_proc_setupmode'])); - nodes.push(linkOut('ps_dash_lout_setup_inflow', TAB_SETUP, 'setup:to-ps-inflow', LANE_X[7], 220, ['lin_proc_setupinflow'])); - - const setupIds = ['ps_dash_setup_mode', 'ps_dash_setup_inflow', - 'ps_dash_lout_setup_mode', 'ps_dash_lout_setup_inflow']; - nodes.push(group('ps_dash_grp_setup', TAB_SETUP, 'Deploy-time setup', S88.neutral, setupIds, bboxOf(nodes, setupIds, 25))); - - return nodes; -} - -/* ------------------------------------------------------------------ */ -/* README */ -/* ------------------------------------------------------------------ */ - -const README = `# pumpingStation - Example Flows - -Three Node-RED flows demonstrating the Phase-2 pumpingStation node on the -canonical topic API (\`set.mode\`, \`set.inflow\`, \`set.demand\`, -\`cmd.calibrate.volume\`, \`cmd.calibrate.level\`). Legacy aliases -(\`changemode\`, \`q_in\`, \`Qd\`, \`calibratePredictedVolume\`, -\`calibratePredictedLevel\`, \`registerChild\`) still work but log a -one-time deprecation warning; these fresh flows use the canonical names only. - -## Files - -| File | Tier | Tabs | Purpose | -|---|---|---|---| -| \`01-Basic.json\` | 1 | Process Plant | Single pumpingStation driven by inject nodes - no parent, no dashboard. | -| \`02-Integration.json\` | 2 | Process Plant + Setup | Adds a \`measurement\` level child and a \`machineGroupControl\` parent with two \`rotatingMachine\` pumps. Demonstrates the Phase-2 parent/child handshake. | -| \`03-Dashboard.json\` | 3 | Process Plant + Dashboard UI + Setup | Tier 2 plumbing plus a FlowFuse Dashboard 2.0 page with 3 charts (flow / level / volume %), text widgets, and 2 controls (mode dropdown + demand slider). | - -## Prerequisites - -- Node-RED with the EVOLV package installed (so the \`pumpingStation\`, - \`measurement\`, \`machineGroupControl\`, and \`rotatingMachine\` node - types are registered). -- For \`03-Dashboard.json\`: \`@flowfuse/node-red-dashboard\` (Dashboard 2.0). - -## How to load - -\`\`\`bash -# Drop a file into a running Node-RED instance using its Admin API. -curl -X POST -H 'Content-Type: application/json' \\ - --data @nodes/pumpingStation/examples/01-Basic.json \\ - http://localhost:1880/flows -\`\`\` - -Or in the editor: **Menu -> Import -> select file -> Import**. The flows -import into their own tabs and can be deployed immediately. - -## 01-Basic - what to try - -1. Deploy. -2. Inject \`set.mode = manual\`. -3. Inject \`set.inflow = 60 m3/h\` - the basin starts filling. Watch the - formatted Port 0 payload in the debug sidebar. -4. Inject \`set.demand = 40 %\` - in manual mode this would feed any - registered children; here there are no pump children so it is logged - and shown on Port 0. -5. Inject \`cmd.calibrate.volume = 25 m3\` to jump the predicted-volume - integrator to half-full. - -## 02-Integration - what to try - -1. Deploy. The Setup tab fires \`set.mode = levelbased\` to the station - and \`set.mode = auto\` to the MGC. -2. The two pumps register with the MGC via Port 2; the MGC and the level - sensor register with the station via Port 2. Watch the registration - debug taps to confirm. -3. The level inject pushes a 1.6 m measurement so the station sees a - non-zero starting level. Setup also seeds \`set.inflow = 60 m3/h\`. -4. The station's \`controlMode = levelbased\` then drives the MGC, which - dispatches to Pump A / Pump B. - -## 03-Dashboard - what to try - -1. Deploy. -2. Open the dashboard at \`http://localhost:1880/dashboard/page/pumping-station\`. -3. Use the **Control mode** dropdown to switch between \`manual\`, - \`levelbased\`, \`flowbased\`, \`none\`. -4. In manual mode, drag the **Manual demand** slider - the demand cascades - to the MGC and on to the pumps. -5. The three charts (flow, level, volume %) plot live data; the four text - widgets show state, percControl, direction, and time-to-empty. - -## Layout conventions - -These flows follow the EVOLV layout rule set in -\`.claude/rules/node-red-flow-layout.md\`: - -- Tabs split by **concern**: Process Plant (EVOLV nodes) / Dashboard UI - (\`ui-*\` widgets) / Setup (once-true injects). -- Cross-tab wiring via **named link out / link in channels**: - \`setup:to-ps-mode\`, \`setup:to-ps-inflow\`, \`setup:to-mgc-mode\`, - \`cmd:ps-mode\`, \`cmd:ps-demand\`, \`evt:flow\`, \`evt:level\`, - \`evt:volpct\`, \`evt:state\`, \`evt:perc\`, \`evt:dir\`, \`evt:tempty\`. -- **Lane positions** L0-L7 = \`[120, 360, 600, 840, 1080, 1320, 1560, 1800]\`, - driven by each node's S88 level (Process Cell on L5, Unit on L4, - Equipment on L3, Control Module on L2). -- **Group boxes** wrap each parent + its direct children, coloured by the - parent's S88 level. - -## Regenerating - -These flows are generated from \`tools/build-examples.js\`. Edit the -generator, never the JSON, then: - -\`\`\`bash -node nodes/pumpingStation/tools/build-examples.js -\`\`\` - -The script writes \`01-Basic.json\`, \`02-Integration.json\`, and -\`03-Dashboard.json\` into this directory. -`; - -/* ------------------------------------------------------------------ */ -/* Main */ -/* ------------------------------------------------------------------ */ - -function writeFlow(filename, builder) { - const flow = builder(); - const dest = path.join(OUT_DIR, filename); - fs.writeFileSync(dest, JSON.stringify(flow, null, 2) + '\n', 'utf8'); - console.log(`wrote ${dest} (${flow.length} nodes)`); -} - -function main() { - if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true }); - writeFlow('01-Basic.json', buildBasic); - writeFlow('02-Integration.json', buildIntegration); - writeFlow('03-Dashboard.json', buildDashboard); - fs.writeFileSync(path.join(OUT_DIR, 'README.md'), README, 'utf8'); - console.log(`wrote ${path.join(OUT_DIR, 'README.md')}`); -} - -main();