P9.3 + examples: fresh 3-tier flows + pilot wiki Home.md
examples/ (new — was empty except standalone-demo.js):
01-Basic.json 14 nodes, inject + dashboard, no parent
02-Integration.json 32 nodes, 2 tabs, measurement + MGC + 2 pumps,
link-out/link-in channels per node-red-flow-layout.md
03-Dashboard.json 63 nodes, 3 tabs (process + UI + setup),
FlowFuse charts + sliders, trend-split pattern
README.md load instructions
tools/build-examples.js regenerator
All canonical topic names only (set.*, cmd.*, data.*, child.*). No
legacy aliases. Every ui-* widget has x/y. Every chart has the full
mandatory key set from node-red-flow-layout.md §4.
wiki/Home.md (new) — pilot page for the 14-section visual-first template.
Sections 5 (topic-contract) + 9 (data-model) are auto-generated via the
new npm run wiki:* scripts; everything else hand-written following
.claude/refactor/WIKI_TEMPLATE.md.
package.json — added wiki:contract / wiki:datamodel / wiki:all scripts
wired to ../generalFunctions/scripts/wikiGen.js.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:50:45 +02:00
#!/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 = # 0 c99d9 , Unit = # 50 a8d9 ,
* Equipment Module = # 86 bbdd , 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 || { } ;
2026-05-29 18:47:19 +02:00
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' } ) ;
P9.3 + examples: fresh 3-tier flows + pilot wiki Home.md
examples/ (new — was empty except standalone-demo.js):
01-Basic.json 14 nodes, inject + dashboard, no parent
02-Integration.json 32 nodes, 2 tabs, measurement + MGC + 2 pumps,
link-out/link-in channels per node-red-flow-layout.md
03-Dashboard.json 63 nodes, 3 tabs (process + UI + setup),
FlowFuse charts + sliders, trend-split pattern
README.md load instructions
tools/build-examples.js regenerator
All canonical topic names only (set.*, cmd.*, data.*, child.*). No
legacy aliases. Every ui-* widget has x/y. Every chart has the full
mandatory key set from node-red-flow-layout.md §4.
wiki/Home.md (new) — pilot page for the 14-section visual-first template.
Sections 5 (topic-contract) + 9 (data-model) are auto-generated via the
new npm run wiki:* scripts; everything else hand-written following
.claude/refactor/WIKI_TEMPLATE.md.
package.json — added wiki:contract / wiki:datamodel / wiki:all scripts
wired to ../generalFunctions/scripts/wikiGen.js.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:50:45 +02:00
return {
id , type : 'inject' , z , name ,
2026-05-29 18:47:19 +02:00
props ,
P9.3 + examples: fresh 3-tier flows + pilot wiki Home.md
examples/ (new — was empty except standalone-demo.js):
01-Basic.json 14 nodes, inject + dashboard, no parent
02-Integration.json 32 nodes, 2 tabs, measurement + MGC + 2 pumps,
link-out/link-in channels per node-red-flow-layout.md
03-Dashboard.json 63 nodes, 3 tabs (process + UI + setup),
FlowFuse charts + sliders, trend-split pattern
README.md load instructions
tools/build-examples.js regenerator
All canonical topic names only (set.*, cmd.*, data.*, child.*). No
legacy aliases. Every ui-* widget has x/y. Every chart has the full
mandatory key set from node-red-flow-layout.md §4.
wiki/Home.md (new) — pilot page for the 14-section visual-first template.
Sections 5 (topic-contract) + 9 (data-model) are auto-generated via the
new npm run wiki:* scripts; everything else hand-written following
.claude/refactor/WIKI_TEMPLATE.md.
package.json — added wiki:contract / wiki:datamodel / wiki:all scripts
wired to ../generalFunctions/scripts/wikiGen.js.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:50:45 +02:00
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 } ;
}
/ * B u i l d a f u l l y - s p e c i f i e d p u m p i n g S t a t i o n n o d e . E v e r y c o n f i g f i e l d i s s e t
* 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' ] ) ;
2026-05-29 18:47:19 +02:00
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' } ) ;
P9.3 + examples: fresh 3-tier flows + pilot wiki Home.md
examples/ (new — was empty except standalone-demo.js):
01-Basic.json 14 nodes, inject + dashboard, no parent
02-Integration.json 32 nodes, 2 tabs, measurement + MGC + 2 pumps,
link-out/link-in channels per node-red-flow-layout.md
03-Dashboard.json 63 nodes, 3 tabs (process + UI + setup),
FlowFuse charts + sliders, trend-split pattern
README.md load instructions
tools/build-examples.js regenerator
All canonical topic names only (set.*, cmd.*, data.*, child.*). No
legacy aliases. Every ui-* widget has x/y. Every chart has the full
mandatory key set from node-red-flow-layout.md §4.
wiki/Home.md (new) — pilot page for the 14-section visual-first template.
Sections 5 (topic-contract) + 9 (data-model) are auto-generated via the
new npm run wiki:* scripts; everything else hand-written following
.claude/refactor/WIKI_TEMPLATE.md.
package.json — added wiki:contract / wiki:datamodel / wiki:all scripts
wired to ../generalFunctions/scripts/wikiGen.js.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:50:45 +02:00
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' } ) ;
2026-05-29 18:47:19 +02:00
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' } ) ;
P9.3 + examples: fresh 3-tier flows + pilot wiki Home.md
examples/ (new — was empty except standalone-demo.js):
01-Basic.json 14 nodes, inject + dashboard, no parent
02-Integration.json 32 nodes, 2 tabs, measurement + MGC + 2 pumps,
link-out/link-in channels per node-red-flow-layout.md
03-Dashboard.json 63 nodes, 3 tabs (process + UI + setup),
FlowFuse charts + sliders, trend-split pattern
README.md load instructions
tools/build-examples.js regenerator
All canonical topic names only (set.*, cmd.*, data.*, child.*). No
legacy aliases. Every ui-* widget has x/y. Every chart has the full
mandatory key set from node-red-flow-layout.md §4.
wiki/Home.md (new) — pilot page for the 14-section visual-first template.
Sections 5 (topic-contract) + 9 (data-model) are auto-generated via the
new npm run wiki:* scripts; everything else hand-written following
.claude/refactor/WIKI_TEMPLATE.md.
package.json — added wiki:contract / wiki:datamodel / wiki:all scripts
wired to ../generalFunctions/scripts/wikiGen.js.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:50:45 +02:00
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 ) ;
/ * L 6 t r e n d - s p l i t f n : o n e o u t p u t p e r c h a r t + o n e o u t p u t f o r t h e s t a t u s t e x t w i d g e t s .
* 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' ,
2026-05-29 18:47:19 +02:00
LANE _X [ 0 ] , 220 , [ 'ps_dash_lout_setup_inflow' ] , { once : true , onceDelay : '1.0' , unit : 'm3/h' } ) ) ;
P9.3 + examples: fresh 3-tier flows + pilot wiki Home.md
examples/ (new — was empty except standalone-demo.js):
01-Basic.json 14 nodes, inject + dashboard, no parent
02-Integration.json 32 nodes, 2 tabs, measurement + MGC + 2 pumps,
link-out/link-in channels per node-red-flow-layout.md
03-Dashboard.json 63 nodes, 3 tabs (process + UI + setup),
FlowFuse charts + sliders, trend-split pattern
README.md load instructions
tools/build-examples.js regenerator
All canonical topic names only (set.*, cmd.*, data.*, child.*). No
legacy aliases. Every ui-* widget has x/y. Every chart has the full
mandatory key set from node-red-flow-layout.md §4.
wiki/Home.md (new) — pilot page for the 14-section visual-first template.
Sections 5 (topic-contract) + 9 (data-model) are auto-generated via the
new npm run wiki:* scripts; everything else hand-written following
.claude/refactor/WIKI_TEMPLATE.md.
package.json — added wiki:contract / wiki:datamodel / wiki:all scripts
wired to ../generalFunctions/scripts/wikiGen.js.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:50:45 +02:00
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 ( ) ;