Files
EVOLV/scripts/transform-flow-step4.js
znetsixe 6a6c04d34b Migrate to new Gitea instance (gitea.wbd-rd.nl)
- Update all submodule URLs from gitea.centraal.wbd-rd.nl to gitea.wbd-rd.nl
- Add settler as proper submodule in .gitmodules
- Add agent skills, function anchors, decisions, and improvements
- Add Docker configuration and scripts
- Add manuals and third_party docs
- Update .gitignore with secrets and build artifacts
- Remove stale .tgz build artifact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:07:04 +01:00

614 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* Step 4: Manual Controls per PS Detail Page
* - Creates 3 PS detail pages (/ps-west, /ps-north, /ps-south) with control groups
* - Adds control widgets: mode switches, pump speed sliders
* - Format functions to convert dashboard inputs to process node messages
* - Link-in/out routing between dashboard tab and PS tabs
* - Per-PS monitoring charts on detail pages
*/
const fs = require('fs');
const path = require('path');
const FLOW_PATH = path.join(__dirname, '..', 'docker', 'demo-flow.json');
const flow = JSON.parse(fs.readFileSync(FLOW_PATH, 'utf8'));
const byId = (id) => flow.find(n => n.id === id);
// =============================================
// Helper to create a standard set of controls for a PS
// =============================================
function createPSDetailPage(config) {
const {
psKey, // 'west', 'north', 'south'
psLabel, // 'PS West', 'PS North', 'PS South'
pagePath, // '/ps-west'
pageOrder, // 2, 3, 4
psNodeId, // 'demo_ps_west'
pumps, // [{id: 'demo_pump_w1', label: 'W1'}, ...]
controlModes, // ['levelbased','flowbased','manual']
defaultMode, // 'levelbased'
maxFlow, // 300
basinHeight, // 4
tabId, // 'demo_tab_ps_west'
} = config;
const prefix = `demo_ctrl_${psKey}`;
const nodes = [];
// === Page ===
nodes.push({
id: `demo_ui_page_ps_${psKey}_detail`,
type: "ui-page",
name: `${psLabel} Detail`,
ui: "demo_ui_base",
path: pagePath,
icon: "water_drop",
layout: "grid",
theme: "demo_ui_theme",
breakpoints: [{ name: "Default", px: "0", cols: "12" }],
order: pageOrder,
className: ""
});
// === Groups ===
nodes.push(
{
id: `${prefix}_grp_controls`,
type: "ui-group",
name: `${psLabel} Controls`,
page: `demo_ui_page_ps_${psKey}_detail`,
width: "6",
height: "1",
order: 1,
showTitle: true,
className: ""
},
{
id: `${prefix}_grp_monitoring`,
type: "ui-group",
name: `${psLabel} Monitoring`,
page: `demo_ui_page_ps_${psKey}_detail`,
width: "6",
height: "1",
order: 2,
showTitle: true,
className: ""
},
{
id: `${prefix}_grp_charts`,
type: "ui-group",
name: `${psLabel} Trends`,
page: `demo_ui_page_ps_${psKey}_detail`,
width: "12",
height: "1",
order: 3,
showTitle: true,
className: ""
}
);
// === PS Mode button group ===
const modeOptions = controlModes.map(m => ({
label: m === 'levelbased' ? 'Level' : m === 'flowbased' ? 'Flow' : m.charAt(0).toUpperCase() + m.slice(1),
value: m,
valueType: "str"
}));
nodes.push({
id: `${prefix}_mode`,
type: "ui-button-group",
z: "demo_tab_dashboard",
group: `${prefix}_grp_controls`,
name: `${psLabel} Mode`,
label: "Station Mode",
tooltip: "",
order: 1,
width: "6",
height: "1",
passthru: false,
options: modeOptions,
x: 120, y: 100 + pageOrder * 300,
wires: [[`${prefix}_fn_mode`]]
});
// Format: PS mode → setMode message
nodes.push({
id: `${prefix}_fn_mode`,
type: "function",
z: "demo_tab_dashboard",
name: `Fmt ${psLabel} Mode`,
func: `msg.topic = 'setMode';\nmsg.payload = msg.payload;\nreturn msg;`,
outputs: 1,
x: 320, y: 100 + pageOrder * 300,
wires: [[`${prefix}_link_cmd_out`]]
});
// === Manual Flow slider ===
nodes.push({
id: `${prefix}_flow`,
type: "ui-slider",
z: "demo_tab_dashboard",
group: `${prefix}_grp_controls`,
name: `${psLabel} Flow`,
label: "Manual Flow (m\u00b3/h)",
tooltip: "",
order: 2,
width: "6",
height: "1",
passthru: false,
outs: "end",
min: 0,
max: maxFlow,
step: 1,
x: 120, y: 140 + pageOrder * 300,
wires: [[`${prefix}_fn_flow`]]
});
// Format: flow slider → q_in message
nodes.push({
id: `${prefix}_fn_flow`,
type: "function",
z: "demo_tab_dashboard",
name: `Fmt ${psLabel} Flow`,
func: `msg.topic = 'q_in';\nmsg.payload = { value: Number(msg.payload), unit: 'm3/h' };\nreturn msg;`,
outputs: 1,
x: 320, y: 140 + pageOrder * 300,
wires: [[`${prefix}_link_cmd_out`]]
});
// === Pump controls ===
pumps.forEach((pump, pIdx) => {
const yOff = 180 + pageOrder * 300 + pIdx * 80;
// Pump mode button group
nodes.push({
id: `${prefix}_pump_${pump.label.toLowerCase()}_mode`,
type: "ui-button-group",
z: "demo_tab_dashboard",
group: `${prefix}_grp_controls`,
name: `${pump.label} Mode`,
label: `${pump.label} Mode`,
tooltip: "",
order: 3 + pIdx * 2,
width: "3",
height: "1",
passthru: false,
options: [
{ label: "Auto", value: "auto", valueType: "str" },
{ label: "Virtual", value: "virtualControl", valueType: "str" },
{ label: "Physical", value: "fysicalControl", valueType: "str" }
],
x: 120, y: yOff,
wires: [[`${prefix}_fn_pump_${pump.label.toLowerCase()}_mode`]]
});
// Format: pump mode
nodes.push({
id: `${prefix}_fn_pump_${pump.label.toLowerCase()}_mode`,
type: "function",
z: "demo_tab_dashboard",
name: `Fmt ${pump.label} Mode`,
func: `msg.topic = 'setMode';\nmsg.payload = msg.payload;\nmsg._targetNode = '${pump.id}';\nreturn msg;`,
outputs: 1,
x: 320, y: yOff,
wires: [[`${prefix}_link_pump_${pump.label.toLowerCase()}_out`]]
});
// Pump speed slider
nodes.push({
id: `${prefix}_pump_${pump.label.toLowerCase()}_speed`,
type: "ui-slider",
z: "demo_tab_dashboard",
group: `${prefix}_grp_controls`,
name: `${pump.label} Speed`,
label: `${pump.label} Speed (%)`,
tooltip: "",
order: 4 + pIdx * 2,
width: "3",
height: "1",
passthru: false,
outs: "end",
min: 0,
max: 100,
step: 1,
x: 120, y: yOff + 40,
wires: [[`${prefix}_fn_pump_${pump.label.toLowerCase()}_speed`]]
});
// Format: pump speed → execMovement
nodes.push({
id: `${prefix}_fn_pump_${pump.label.toLowerCase()}_speed`,
type: "function",
z: "demo_tab_dashboard",
name: `Fmt ${pump.label} Speed`,
func: `msg.topic = 'execMovement';\nmsg.payload = { source: 'dashboard', action: 'setpoint', setpoint: Number(msg.payload) };\nmsg._targetNode = '${pump.id}';\nreturn msg;`,
outputs: 1,
x: 320, y: yOff + 40,
wires: [[`${prefix}_link_pump_${pump.label.toLowerCase()}_out`]]
});
// Link-out for pump commands (dashboard → PS tab)
nodes.push({
id: `${prefix}_link_pump_${pump.label.toLowerCase()}_out`,
type: "link out",
z: "demo_tab_dashboard",
name: `${pump.label} Cmd`,
mode: "link",
links: [`${prefix}_link_pump_${pump.label.toLowerCase()}_in`],
x: 520, y: yOff + 20
});
// Link-in on PS tab
nodes.push({
id: `${prefix}_link_pump_${pump.label.toLowerCase()}_in`,
type: "link in",
z: tabId,
name: `${pump.label} Cmd`,
links: [`${prefix}_link_pump_${pump.label.toLowerCase()}_out`],
x: 120, y: 540 + pIdx * 60,
wires: [[pump.id]]
});
});
// === PS command link-out (dashboard → PS tab) ===
nodes.push({
id: `${prefix}_link_cmd_out`,
type: "link out",
z: "demo_tab_dashboard",
name: `${psLabel} Cmd`,
mode: "link",
links: [`${prefix}_link_cmd_in`],
x: 520, y: 120 + pageOrder * 300
});
// Link-in on PS tab for PS-level commands
nodes.push({
id: `${prefix}_link_cmd_in`,
type: "link in",
z: tabId,
name: `${psLabel} Cmd`,
links: [`${prefix}_link_cmd_out`],
x: 120, y: 480,
wires: [[psNodeId]]
});
// === Monitoring widgets on detail page ===
// Re-use existing data from the PS parse functions on dashboard tab
// Create a link-in to receive PS data and parse for detail page
nodes.push({
id: `${prefix}_link_detail_data_out`,
type: "link out",
z: tabId,
name: `${psLabel} Detail`,
mode: "link",
links: [`${prefix}_link_detail_data_in`],
x: 1080, y: 400
});
// Add to PS node wires[0]
const psNode = byId(psNodeId);
if (psNode && psNode.wires && psNode.wires[0]) {
psNode.wires[0].push(`${prefix}_link_detail_data_out`);
}
nodes.push({
id: `${prefix}_link_detail_data_in`,
type: "link in",
z: "demo_tab_dashboard",
name: `${psLabel} Detail`,
links: [`${prefix}_link_detail_data_out`],
x: 75, y: 50 + pageOrder * 300,
wires: [[`${prefix}_fn_detail_parse`]]
});
// Parse function for detail monitoring
nodes.push({
id: `${prefix}_fn_detail_parse`,
type: "function",
z: "demo_tab_dashboard",
name: `Parse ${psLabel} Detail`,
func: `const p = msg.payload || {};
const cache = context.get('c') || {};
const keys = Object.keys(p);
const pick = (prefixes) => { for (const pfx of prefixes) { const k = keys.find(k => k.startsWith(pfx)); if (k) { const v = Number(p[k]); if (Number.isFinite(v)) return v; } } return null; };
const level = pick(['level.predicted.atequipment','level.measured.atequipment']);
const volume = pick(['volume.predicted.atequipment']);
const netFlow = pick(['netFlowRate.predicted.atequipment']);
const fillPct = pick(['volumePercent.predicted.atequipment']);
const direction = p.direction || cache.direction || '?';
if (level !== null) cache.level = level;
if (volume !== null) cache.volume = volume;
if (netFlow !== null) cache.netFlow = netFlow;
if (fillPct !== null) cache.fillPct = fillPct;
cache.direction = direction;
context.set('c', cache);
const now = Date.now();
const dirArrow = cache.direction === 'filling' ? '\\u2191' : cache.direction === 'emptying' ? '\\u2193' : '\\u2014';
const status = [
dirArrow + ' ' + (cache.direction || ''),
cache.netFlow !== undefined ? Math.abs(cache.netFlow).toFixed(0) + ' m\\u00b3/h' : '',
].filter(s => s.trim()).join(' | ');
return [
cache.level !== undefined ? {topic:'${psLabel} Level', payload: cache.level, timestamp: now} : null,
cache.netFlow !== undefined ? {topic:'${psLabel} Flow', payload: cache.netFlow, timestamp: now} : null,
{topic:'${psLabel} Status', payload: status},
cache.fillPct !== undefined ? {payload: Number(cache.fillPct.toFixed(1))} : null,
cache.level !== undefined ? {payload: Number(cache.level.toFixed(2))} : null
];`,
outputs: 5,
x: 280, y: 50 + pageOrder * 300,
wires: [
[`${prefix}_chart_level`],
[`${prefix}_chart_flow`],
[`${prefix}_text_status`],
[`${prefix}_gauge_fill`],
[`${prefix}_gauge_tank`]
]
});
// Level chart
nodes.push({
id: `${prefix}_chart_level`,
type: "ui-chart",
z: "demo_tab_dashboard",
group: `${prefix}_grp_charts`,
name: `${psLabel} Level`,
label: "Basin Level (m)",
order: 1,
width: "6",
height: "5",
chartType: "line",
category: "topic",
categoryType: "msg",
xAxisType: "time",
yAxisLabel: "m",
removeOlder: "10",
removeOlderUnit: "60",
action: "append",
pointShape: "false",
pointRadius: 0,
interpolation: "linear",
showLegend: true,
xAxisProperty: "",
xAxisPropertyType: "timestamp",
yAxisProperty: "payload",
yAxisPropertyType: "msg",
colors: ["#0094ce", "#FF7F0E", "#2CA02C"],
textColor: ["#aaaaaa"],
textColorDefault: false,
gridColor: ["#333333"],
gridColorDefault: false,
x: 510, y: 30 + pageOrder * 300,
wires: []
});
// Flow chart
nodes.push({
id: `${prefix}_chart_flow`,
type: "ui-chart",
z: "demo_tab_dashboard",
group: `${prefix}_grp_charts`,
name: `${psLabel} Flow`,
label: "Net Flow (m\u00b3/h)",
order: 2,
width: "6",
height: "5",
chartType: "line",
category: "topic",
categoryType: "msg",
xAxisType: "time",
yAxisLabel: "m\u00b3/h",
removeOlder: "10",
removeOlderUnit: "60",
action: "append",
pointShape: "false",
pointRadius: 0,
interpolation: "linear",
showLegend: true,
xAxisProperty: "",
xAxisPropertyType: "timestamp",
yAxisProperty: "payload",
yAxisPropertyType: "msg",
colors: ["#4fc3f7", "#FF7F0E", "#2CA02C"],
textColor: ["#aaaaaa"],
textColorDefault: false,
gridColor: ["#333333"],
gridColorDefault: false,
x: 510, y: 60 + pageOrder * 300,
wires: []
});
// Status text
nodes.push({
id: `${prefix}_text_status`,
type: "ui-text",
z: "demo_tab_dashboard",
group: `${prefix}_grp_monitoring`,
name: `${psLabel} Status`,
label: "Status",
order: 1,
width: "6",
height: "1",
format: "{{msg.payload}}",
layout: "row-spread",
x: 510, y: 80 + pageOrder * 300,
wires: []
});
// Fill % gauge
nodes.push({
id: `${prefix}_gauge_fill`,
type: "ui-gauge",
z: "demo_tab_dashboard",
group: `${prefix}_grp_monitoring`,
name: `${psLabel} Fill`,
gtype: "gauge-34",
gstyle: "Rounded",
title: "Fill",
units: "%",
prefix: "",
suffix: "%",
min: 0,
max: 100,
segments: [
{ color: "#f44336", from: 0 },
{ color: "#ff9800", from: 10 },
{ color: "#4caf50", from: 25 },
{ color: "#ff9800", from: 75 },
{ color: "#f44336", from: 90 }
],
width: 3,
height: 3,
order: 2,
className: "",
x: 700, y: 80 + pageOrder * 300,
wires: []
});
// Tank gauge
nodes.push({
id: `${prefix}_gauge_tank`,
type: "ui-gauge",
z: "demo_tab_dashboard",
group: `${prefix}_grp_monitoring`,
name: `${psLabel} Tank`,
gtype: "gauge-tank",
gstyle: "Rounded",
title: "Level",
units: "m",
prefix: "",
suffix: "m",
min: 0,
max: basinHeight,
segments: [
{ color: "#f44336", from: 0 },
{ color: "#ff9800", from: basinHeight * 0.08 },
{ color: "#2196f3", from: basinHeight * 0.25 },
{ color: "#ff9800", from: basinHeight * 0.62 },
{ color: "#f44336", from: basinHeight * 0.8 }
],
width: 3,
height: 4,
order: 3,
className: "",
x: 700, y: 40 + pageOrder * 300,
wires: []
});
return nodes;
}
// =============================================
// Create detail pages for each PS
// =============================================
const westNodes = createPSDetailPage({
psKey: 'west',
psLabel: 'PS West',
pagePath: '/ps-west',
pageOrder: 2,
psNodeId: 'demo_ps_west',
pumps: [
{ id: 'demo_pump_w1', label: 'W1' },
{ id: 'demo_pump_w2', label: 'W2' }
],
controlModes: ['levelbased', 'flowbased', 'manual'],
defaultMode: 'levelbased',
maxFlow: 300,
basinHeight: 4,
tabId: 'demo_tab_ps_west',
});
const northNodes = createPSDetailPage({
psKey: 'north',
psLabel: 'PS North',
pagePath: '/ps-north',
pageOrder: 3,
psNodeId: 'demo_ps_north',
pumps: [
{ id: 'demo_pump_n1', label: 'N1' }
],
controlModes: ['levelbased', 'flowbased', 'manual'],
defaultMode: 'flowbased',
maxFlow: 200,
basinHeight: 3,
tabId: 'demo_tab_ps_north',
});
const southNodes = createPSDetailPage({
psKey: 'south',
psLabel: 'PS South',
pagePath: '/ps-south',
pageOrder: 4,
psNodeId: 'demo_ps_south',
pumps: [
{ id: 'demo_pump_s1', label: 'S1' }
],
controlModes: ['levelbased', 'flowbased', 'manual'],
defaultMode: 'manual',
maxFlow: 100,
basinHeight: 2.5,
tabId: 'demo_tab_ps_south',
});
flow.push(...westNodes, ...northNodes, ...southNodes);
// =============================================
// Validate
// =============================================
const allIds = new Set(flow.map(n => n.id));
let issues = 0;
// Check for duplicate IDs
const idCounts = {};
flow.forEach(n => { idCounts[n.id] = (idCounts[n.id] || 0) + 1; });
for (const [id, count] of Object.entries(idCounts)) {
if (count > 1) { console.warn(`DUPLICATE ID: ${id} (${count} instances)`); issues++; }
}
for (const n of flow) {
if (!n.wires) continue;
for (const port of n.wires) {
for (const target of port) {
if (!allIds.has(target)) {
console.warn(`BROKEN WIRE: ${n.id}${target}`);
issues++;
}
}
}
if (n.type === 'link out' && n.links) {
for (const lt of n.links) {
if (!allIds.has(lt)) { console.warn(`BROKEN LINK OUT: ${n.id}${lt}`); issues++; }
}
}
if (n.type === 'link in' && n.links) {
for (const ls of n.links) {
if (!allIds.has(ls)) { console.warn(`BROKEN LINK IN: ${n.id}${ls}`); issues++; }
}
}
}
if (issues === 0) console.log('All references valid ✓');
else console.log(`Found ${issues} issues`);
// Count nodes per tab
const tabCounts = {};
for (const n of flow) {
if (n.z) tabCounts[n.z] = (tabCounts[n.z] || 0) + 1;
}
console.log('Nodes per tab:', JSON.stringify(tabCounts, null, 2));
console.log('Total nodes:', flow.length);
// Count new nodes added
const newNodeCount = westNodes.length + northNodes.length + southNodes.length;
console.log(`Added ${newNodeCount} new nodes (${westNodes.length} west + ${northNodes.length} north + ${southNodes.length} south)`);
// Write
fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n');
console.log(`Wrote ${FLOW_PATH}`);