#!/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 || {}; return { id, type: 'inject', z, name, props: [ { p: 'topic', vt: 'str' }, { p: 'payload', v: String(payload), vt: payloadType }, ], 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']); const injectDemand = inject('ps_basic_inj_demand', Z, 'set.demand = 40 %', 'set.demand', '40', 'num', 200, 300, ['ps_basic_node']); const injectCalVol = inject('ps_basic_inj_calvol', Z, 'calibrate volume 25 m3','cmd.calibrate.volume','25','num', 220, 360, ['ps_basic_node']); const injectCalLvl = inject('ps_basic_inj_callvl', Z, 'calibrate level 1.5 m','cmd.calibrate.level','1.5','num', 220, 400, ['ps_basic_node']); 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' }); 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' })); 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();