-
-
-
-
-
-
-
-`,
- templateScope: "local",
- className: "",
- x: 510, y: 960,
- wires: [[]]
-});
-
-// =============================================
-// 3c. KPI gauges on overview
-// =============================================
-
-// Total Influent Flow gauge
-flow.push({
- id: "demo_gauge_overview_flow",
- type: "ui-gauge",
- z: "demo_tab_dashboard",
- group: "demo_ui_grp_overview_kpi",
- name: "Total Influent Flow",
- gtype: "gauge-34",
- gstyle: "Rounded",
- title: "Influent Flow",
- units: "m\u00b3/h",
- prefix: "",
- suffix: "m\u00b3/h",
- min: 0,
- max: 500,
- segments: [
- { color: "#2196f3", from: 0 },
- { color: "#4caf50", from: 50 },
- { color: "#ff9800", from: 350 },
- { color: "#f44336", from: 450 }
- ],
- width: 3,
- height: 4,
- order: 1,
- className: "",
- x: 510, y: 1020,
- wires: []
-});
-
-// Reactor DO gauge
-flow.push({
- id: "demo_gauge_overview_do",
- type: "ui-gauge",
- z: "demo_tab_dashboard",
- group: "demo_ui_grp_overview_kpi",
- name: "Reactor DO",
- gtype: "gauge-34",
- gstyle: "Rounded",
- title: "Reactor DO",
- units: "mg/L",
- prefix: "",
- suffix: "mg/L",
- min: 0,
- max: 10,
- segments: [
- { color: "#f44336", from: 0 },
- { color: "#ff9800", from: 1 },
- { color: "#4caf50", from: 2 },
- { color: "#ff9800", from: 6 },
- { color: "#f44336", from: 8 }
- ],
- width: 3,
- height: 4,
- order: 2,
- className: "",
- x: 510, y: 1060,
- wires: []
-});
-
-// Effluent TSS gauge
-flow.push({
- id: "demo_gauge_overview_tss",
- type: "ui-gauge",
- z: "demo_tab_dashboard",
- group: "demo_ui_grp_overview_kpi",
- name: "Effluent TSS",
- gtype: "gauge-34",
- gstyle: "Rounded",
- title: "Effluent TSS",
- units: "mg/L",
- prefix: "",
- suffix: "mg/L",
- min: 0,
- max: 50,
- segments: [
- { color: "#4caf50", from: 0 },
- { color: "#ff9800", from: 25 },
- { color: "#f44336", from: 40 }
- ],
- width: 3,
- height: 4,
- order: 3,
- className: "",
- x: 510, y: 1100,
- wires: []
-});
-
-// Effluent NH4 gauge
-flow.push({
- id: "demo_gauge_overview_nh4",
- type: "ui-gauge",
- z: "demo_tab_dashboard",
- group: "demo_ui_grp_overview_kpi",
- name: "Effluent NH4",
- gtype: "gauge-34",
- gstyle: "Rounded",
- title: "Effluent NH4",
- units: "mg/L",
- prefix: "",
- suffix: "mg/L",
- min: 0,
- max: 20,
- segments: [
- { color: "#4caf50", from: 0 },
- { color: "#ff9800", from: 5 },
- { color: "#f44336", from: 10 }
- ],
- width: 3,
- height: 4,
- order: 4,
- className: "",
- x: 510, y: 1140,
- wires: []
-});
-
-// =============================================
-// 3d. Reorder all page navigation
-// =============================================
-const pageOrders = {
- "demo_ui_page_overview": 0,
- "demo_ui_page_influent": 1,
- "demo_ui_page_treatment": 5,
- "demo_ui_page_telemetry": 6,
-};
-
-for (const [pageId, order] of Object.entries(pageOrders)) {
- const page = byId(pageId);
- if (page) page.order = order;
-}
-
-// =============================================
-// Feed chain vis and KPIs from merge + reactor + effluent
-// We need to also wire the overview_template to receive reactor/eff data
-// The parse functions already wire to the template and gauges separately
-// But the template needs ALL data sources - let's connect reactor and eff parsers to it too
-// =============================================
-
-// Actually, the template needs multiple inputs. Let's connect reactor and eff parse outputs too.
-// Modify overview reactor parse to also send to template
-const reactorParse = byId("demo_fn_overview_reactor_parse");
-// Currently wires to demo_gauge_overview_do. Add template as well.
-reactorParse.func = `const p = msg.payload || {};
-if (!p.C || !Array.isArray(p.C)) return null;
-
-flow.set('overview_reactor', p);
-
-// Output 1: DO gauge, Output 2: to chain template
-const doVal = Math.round(p.C[0]*100)/100;
-return [
- { topic: 'Reactor DO', payload: doVal },
- { topic: 'Reactor DO', payload: doVal }
-];`;
-reactorParse.outputs = 2;
-reactorParse.wires = [["demo_gauge_overview_do"], ["demo_overview_template"]];
-
-// Same for effluent parse - add template output
-const effParse = byId("demo_fn_overview_eff_parse");
-effParse.func = `const p = msg.payload || {};
-const topic = msg.topic || '';
-const val = Number(p.mAbs);
-if (!Number.isFinite(val)) return null;
-
-const rounded = Math.round(val*100)/100;
-
-// Route to appropriate gauge + template based on measurement type
-if (topic.includes('TSS') || topic.includes('tss')) {
- return [{ topic: 'Effluent TSS', payload: rounded }, null, { topic: 'Effluent TSS', payload: rounded }];
-}
-if (topic.includes('NH4') || topic.includes('ammonium')) {
- return [null, { topic: 'Effluent NH4', payload: rounded }, { topic: 'Effluent NH4', payload: rounded }];
-}
-return [null, null, null];`;
-effParse.outputs = 3;
-effParse.wires = [["demo_gauge_overview_tss"], ["demo_gauge_overview_nh4"], ["demo_overview_template"]];
-
-// =============================================
-// Validate
-// =============================================
-const allIds = new Set(flow.map(n => n.id));
-let issues = 0;
-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 ✓');
-console.log('Total nodes:', flow.length);
-
-// Write
-fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n');
-console.log(`Wrote ${FLOW_PATH}`);
diff --git a/scripts/transform-flow-step4.js b/scripts/transform-flow-step4.js
deleted file mode 100644
index f44c4eb..0000000
--- a/scripts/transform-flow-step4.js
+++ /dev/null
@@ -1,613 +0,0 @@
-#!/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}`);
diff --git a/scripts/update-demo-flow.js b/scripts/update-demo-flow.js
deleted file mode 100644
index 39a9ae5..0000000
--- a/scripts/update-demo-flow.js
+++ /dev/null
@@ -1,279 +0,0 @@
-#!/usr/bin/env node
-/**
- * Script to update docker/demo-flow.json with Fixes 2-5 from the plan.
- * Run from project root: node scripts/update-demo-flow.js
- */
-const fs = require('fs');
-const path = require('path');
-
-const flowPath = path.join(__dirname, '..', 'docker', 'demo-flow.json');
-const flow = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
-
-// === Fix 2: Enable simulator on 9 measurement nodes ===
-const simMeasIds = [
- 'demo_meas_flow', 'demo_meas_do', 'demo_meas_nh4',
- 'demo_meas_ft_n1', 'demo_meas_eff_flow', 'demo_meas_eff_do',
- 'demo_meas_eff_nh4', 'demo_meas_eff_no3', 'demo_meas_eff_tss'
-];
-simMeasIds.forEach(id => {
- const node = flow.find(n => n.id === id);
- if (node) {
- node.simulator = true;
- console.log('Enabled simulator on', id);
- } else {
- console.error('NOT FOUND:', id);
- }
-});
-
-// === Fix 2: Remove 18 inject+function sim pairs ===
-const removeSimIds = [
- 'demo_inj_meas_flow', 'demo_fn_sim_flow',
- 'demo_inj_meas_do', 'demo_fn_sim_do',
- 'demo_inj_meas_nh4', 'demo_fn_sim_nh4',
- 'demo_inj_ft_n1', 'demo_fn_sim_ft_n1',
- 'demo_inj_eff_flow', 'demo_fn_sim_eff_flow',
- 'demo_inj_eff_do', 'demo_fn_sim_eff_do',
- 'demo_inj_eff_nh4', 'demo_fn_sim_eff_nh4',
- 'demo_inj_eff_no3', 'demo_fn_sim_eff_no3',
- 'demo_inj_eff_tss', 'demo_fn_sim_eff_tss'
-];
-
-// === Fix 5: Remove manual pump startup/setpoint injectors ===
-const removeManualIds = [
- 'demo_inj_w1_startup', 'demo_inj_w1_setpoint',
- 'demo_inj_w2_startup', 'demo_inj_w2_setpoint',
- 'demo_inj_n1_startup',
- 'demo_inj_s1_startup'
-];
-
-const allRemoveIds = new Set([...removeSimIds, ...removeManualIds]);
-const before = flow.length;
-const filtered = flow.filter(n => !allRemoveIds.has(n.id));
-console.log(`Removed ${before - filtered.length} nodes (expected 24)`);
-
-// Remove wires to removed nodes from remaining nodes
-filtered.forEach(n => {
- if (n.wires && Array.isArray(n.wires)) {
- n.wires = n.wires.map(wireGroup => {
- if (Array.isArray(wireGroup)) {
- return wireGroup.filter(w => !allRemoveIds.has(w));
- }
- return wireGroup;
- });
- }
-});
-
-// === Fix 3 (demo part): Add speedUpFactor to reactor ===
-const reactor = filtered.find(n => n.id === 'demo_reactor');
-if (reactor) {
- reactor.speedUpFactor = 1;
- console.log('Added speedUpFactor=1 to reactor');
-}
-
-// === Fix 4: Add pressure measurement nodes ===
-const maxY = Math.max(...filtered.filter(n => n.z === 'demo_tab_wwtp').map(n => n.y || 0));
-
-const ptBaseConfig = {
- scaling: true,
- i_offset: 0,
- smooth_method: 'mean',
- count: 3,
- category: 'sensor',
- assetType: 'pressure',
- enableLog: false,
- logLevel: 'error',
- positionIcon: '',
- hasDistance: false
-};
-
-// Function to extract level from PS output and convert to hydrostatic pressure
-const levelExtractFunc = [
- '// Extract basin level from PS output and convert to hydrostatic pressure (mbar)',
- '// P = rho * g * h, rho=1000 kg/m3, g=9.81 m/s2',
- 'const p = msg.payload || {};',
- 'const keys = Object.keys(p);',
- 'const levelKey = keys.find(k => k.startsWith("level.predicted.atequipment") || k.startsWith("level.measured.atequipment"));',
- 'if (!levelKey) return null;',
- 'const h = Number(p[levelKey]);',
- 'if (!Number.isFinite(h)) return null;',
- 'msg.topic = "measurement";',
- 'msg.payload = Math.round(h * 98.1 * 10) / 10; // mbar',
- 'return msg;'
-].join('\n');
-
-const newNodes = [
- // Comment
- {
- id: 'demo_comment_pressure',
- type: 'comment',
- z: 'demo_tab_wwtp',
- name: '=== PRESSURE MEASUREMENTS (per pumping station) ===',
- info: '',
- x: 320,
- y: maxY + 40
- },
-
- // --- PS West upstream PT ---
- {
- id: 'demo_fn_level_to_pressure_w',
- type: 'function',
- z: 'demo_tab_wwtp',
- name: 'Level\u2192Pressure (West)',
- func: levelExtractFunc,
- outputs: 1,
- x: 370,
- y: maxY + 80,
- wires: [['demo_meas_pt_w_up']]
- },
- {
- id: 'demo_meas_pt_w_up',
- type: 'measurement',
- z: 'demo_tab_wwtp',
- name: 'PT-W-UP (West Upstream)',
- ...ptBaseConfig,
- i_min: 0, i_max: 5000, o_min: 0, o_max: 5000,
- simulator: false,
- uuid: 'pt-w-up-001',
- supplier: 'Endress+Hauser',
- model: 'Cerabar-PMC51',
- unit: 'mbar',
- assetTagNumber: 'PT-W-UP',
- positionVsParent: 'upstream',
- x: 580,
- y: maxY + 80,
- wires: [['demo_link_process_out'], ['demo_link_influx_out'], ['demo_pump_w1', 'demo_pump_w2']]
- },
- // PS West downstream PT (simulated)
- {
- id: 'demo_meas_pt_w_down',
- type: 'measurement',
- z: 'demo_tab_wwtp',
- name: 'PT-W-DN (West Downstream)',
- ...ptBaseConfig,
- i_min: 0, i_max: 5000, o_min: 0, o_max: 5000,
- simulator: true,
- uuid: 'pt-w-dn-001',
- supplier: 'Endress+Hauser',
- model: 'Cerabar-PMC51',
- unit: 'mbar',
- assetTagNumber: 'PT-W-DN',
- positionVsParent: 'downstream',
- x: 580,
- y: maxY + 140,
- wires: [['demo_link_process_out'], ['demo_link_influx_out'], ['demo_pump_w1', 'demo_pump_w2']]
- },
-
- // --- PS North upstream PT ---
- {
- id: 'demo_fn_level_to_pressure_n',
- type: 'function',
- z: 'demo_tab_wwtp',
- name: 'Level\u2192Pressure (North)',
- func: levelExtractFunc,
- outputs: 1,
- x: 370,
- y: maxY + 220,
- wires: [['demo_meas_pt_n_up']]
- },
- {
- id: 'demo_meas_pt_n_up',
- type: 'measurement',
- z: 'demo_tab_wwtp',
- name: 'PT-N-UP (North Upstream)',
- ...ptBaseConfig,
- i_min: 0, i_max: 5000, o_min: 0, o_max: 5000,
- simulator: false,
- uuid: 'pt-n-up-001',
- supplier: 'Endress+Hauser',
- model: 'Cerabar-PMC51',
- unit: 'mbar',
- assetTagNumber: 'PT-N-UP',
- positionVsParent: 'upstream',
- x: 580,
- y: maxY + 220,
- wires: [['demo_link_process_out'], ['demo_link_influx_out'], ['demo_pump_n1']]
- },
- {
- id: 'demo_meas_pt_n_down',
- type: 'measurement',
- z: 'demo_tab_wwtp',
- name: 'PT-N-DN (North Downstream)',
- ...ptBaseConfig,
- i_min: 0, i_max: 5000, o_min: 0, o_max: 5000,
- simulator: true,
- uuid: 'pt-n-dn-001',
- supplier: 'Endress+Hauser',
- model: 'Cerabar-PMC51',
- unit: 'mbar',
- assetTagNumber: 'PT-N-DN',
- positionVsParent: 'downstream',
- x: 580,
- y: maxY + 280,
- wires: [['demo_link_process_out'], ['demo_link_influx_out'], ['demo_pump_n1']]
- },
-
- // --- PS South upstream PT ---
- {
- id: 'demo_fn_level_to_pressure_s',
- type: 'function',
- z: 'demo_tab_wwtp',
- name: 'Level\u2192Pressure (South)',
- func: levelExtractFunc,
- outputs: 1,
- x: 370,
- y: maxY + 360,
- wires: [['demo_meas_pt_s_up']]
- },
- {
- id: 'demo_meas_pt_s_up',
- type: 'measurement',
- z: 'demo_tab_wwtp',
- name: 'PT-S-UP (South Upstream)',
- ...ptBaseConfig,
- i_min: 0, i_max: 5000, o_min: 0, o_max: 5000,
- simulator: false,
- uuid: 'pt-s-up-001',
- supplier: 'Endress+Hauser',
- model: 'Cerabar-PMC51',
- unit: 'mbar',
- assetTagNumber: 'PT-S-UP',
- positionVsParent: 'upstream',
- x: 580,
- y: maxY + 360,
- wires: [['demo_link_process_out'], ['demo_link_influx_out'], ['demo_pump_s1']]
- },
- {
- id: 'demo_meas_pt_s_down',
- type: 'measurement',
- z: 'demo_tab_wwtp',
- name: 'PT-S-DN (South Downstream)',
- ...ptBaseConfig,
- i_min: 0, i_max: 5000, o_min: 0, o_max: 5000,
- simulator: true,
- uuid: 'pt-s-dn-001',
- supplier: 'Endress+Hauser',
- model: 'Cerabar-PMC51',
- unit: 'mbar',
- assetTagNumber: 'PT-S-DN',
- positionVsParent: 'downstream',
- x: 580,
- y: maxY + 420,
- wires: [['demo_link_process_out'], ['demo_link_influx_out'], ['demo_pump_s1']]
- }
-];
-
-// Wire PS output port 0 to the level-to-pressure function nodes
-const psWest = filtered.find(n => n.id === 'demo_ps_west');
-const psNorth = filtered.find(n => n.id === 'demo_ps_north');
-const psSouth = filtered.find(n => n.id === 'demo_ps_south');
-
-if (psWest && psWest.wires[0]) psWest.wires[0].push('demo_fn_level_to_pressure_w');
-if (psNorth && psNorth.wires[0]) psNorth.wires[0].push('demo_fn_level_to_pressure_n');
-if (psSouth && psSouth.wires[0]) psSouth.wires[0].push('demo_fn_level_to_pressure_s');
-
-// Combine and write
-const result = [...filtered, ...newNodes];
-console.log(`Final flow has ${result.length} nodes`);
-
-fs.writeFileSync(flowPath, JSON.stringify(result, null, 2) + '\n');
-console.log('Done! Written to docker/demo-flow.json');
diff --git a/test/e2e/flows.json b/test/e2e/flows.json
deleted file mode 100644
index a99a20a..0000000
--- a/test/e2e/flows.json
+++ /dev/null
@@ -1,440 +0,0 @@
-[
- {
- "id": "e2e-flow-tab",
- "type": "tab",
- "label": "E2E Test Flow",
- "disabled": false,
- "info": "End-to-end test flow that verifies EVOLV nodes load, accept input, and produce output."
- },
- {
- "id": "inject-trigger",
- "type": "inject",
- "z": "e2e-flow-tab",
- "name": "Trigger once on start",
- "props": [
- { "p": "payload" },
- { "p": "topic", "vt": "str" }
- ],
- "repeat": "",
- "crontab": "",
- "once": true,
- "onceDelay": "3",
- "topic": "e2e-test",
- "payload": "",
- "payloadType": "date",
- "x": 160,
- "y": 80,
- "wires": [["build-measurement-msg"]]
- },
- {
- "id": "build-measurement-msg",
- "type": "function",
- "z": "e2e-flow-tab",
- "name": "Build measurement input",
- "func": "// Simulate an analog sensor reading sent to the measurement node.\n// The measurement node expects a numeric payload on topic 'analogInput'.\nmsg.payload = 4.2 + Math.random() * 15.8; // 4-20 mA range\nmsg.topic = 'analogInput';\nnode.status({ fill: 'green', shape: 'dot', text: 'sent ' + msg.payload.toFixed(2) });\nreturn msg;",
- "outputs": 1,
- "timeout": "",
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 380,
- "y": 80,
- "wires": [["measurement-e2e-node"]]
- },
- {
- "id": "measurement-e2e-node",
- "type": "measurement",
- "z": "e2e-flow-tab",
- "name": "E2E-Level-Sensor",
- "scaling": true,
- "i_min": 4,
- "i_max": 20,
- "i_offset": 0,
- "o_min": 0,
- "o_max": 5,
- "simulator": false,
- "smooth_method": "",
- "count": "10",
- "uuid": "",
- "supplier": "e2e-test",
- "category": "level",
- "assetType": "sensor",
- "model": "e2e-virtual",
- "unit": "m",
- "enableLog": false,
- "logLevel": "error",
- "positionVsParent": "upstream",
- "positionIcon": "",
- "hasDistance": false,
- "distance": 0,
- "distanceUnit": "m",
- "distanceDescription": "",
- "x": 600,
- "y": 80,
- "wires": [
- ["debug-process"],
- ["debug-dbase"],
- ["debug-parent"]
- ]
- },
- {
- "id": "debug-process",
- "type": "debug",
- "z": "e2e-flow-tab",
- "name": "Process Output",
- "active": true,
- "tosidebar": true,
- "console": true,
- "tostatus": true,
- "complete": "true",
- "targetType": "full",
- "statusVal": "",
- "statusType": "auto",
- "x": 830,
- "y": 40,
- "wires": []
- },
- {
- "id": "debug-dbase",
- "type": "debug",
- "z": "e2e-flow-tab",
- "name": "Database Output",
- "active": true,
- "tosidebar": true,
- "console": true,
- "tostatus": true,
- "complete": "true",
- "targetType": "full",
- "statusVal": "",
- "statusType": "auto",
- "x": 840,
- "y": 80,
- "wires": []
- },
- {
- "id": "debug-parent",
- "type": "debug",
- "z": "e2e-flow-tab",
- "name": "Parent Output",
- "active": true,
- "tosidebar": true,
- "console": true,
- "tostatus": true,
- "complete": "true",
- "targetType": "full",
- "statusVal": "",
- "statusType": "auto",
- "x": 830,
- "y": 120,
- "wires": []
- },
- {
- "id": "inject-periodic",
- "type": "inject",
- "z": "e2e-flow-tab",
- "name": "Periodic (5s)",
- "props": [
- { "p": "payload" },
- { "p": "topic", "vt": "str" }
- ],
- "repeat": "5",
- "crontab": "",
- "once": true,
- "onceDelay": "6",
- "topic": "e2e-heartbeat",
- "payload": "",
- "payloadType": "date",
- "x": 160,
- "y": 200,
- "wires": [["heartbeat-func"]]
- },
- {
- "id": "heartbeat-func",
- "type": "function",
- "z": "e2e-flow-tab",
- "name": "Heartbeat check",
- "func": "// Verify the EVOLV measurement node is running by querying its presence\nmsg.payload = {\n check: 'heartbeat',\n timestamp: Date.now(),\n nodeCount: global.get('_e2e_msg_count') || 0\n};\n// Increment message counter\nlet count = global.get('_e2e_msg_count') || 0;\nglobal.set('_e2e_msg_count', count + 1);\nnode.status({ fill: 'blue', shape: 'ring', text: 'beat #' + (count+1) });\nreturn msg;",
- "outputs": 1,
- "timeout": "",
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 380,
- "y": 200,
- "wires": [["debug-heartbeat"]]
- },
- {
- "id": "debug-heartbeat",
- "type": "debug",
- "z": "e2e-flow-tab",
- "name": "Heartbeat Debug",
- "active": true,
- "tosidebar": true,
- "console": true,
- "tostatus": false,
- "complete": "payload",
- "targetType": "msg",
- "statusVal": "",
- "statusType": "auto",
- "x": 600,
- "y": 200,
- "wires": []
- },
- {
- "id": "inject-monster-prediction",
- "type": "inject",
- "z": "e2e-flow-tab",
- "name": "Monster prediction",
- "props": [
- { "p": "payload" },
- { "p": "topic", "vt": "str" }
- ],
- "repeat": "",
- "crontab": "",
- "once": true,
- "onceDelay": "4",
- "topic": "model_prediction",
- "payload": "120",
- "payloadType": "num",
- "x": 150,
- "y": 320,
- "wires": [["evolv-monster"]]
- },
- {
- "id": "inject-monster-flow",
- "type": "inject",
- "z": "e2e-flow-tab",
- "name": "Monster flow",
- "props": [
- { "p": "payload" },
- { "p": "topic", "vt": "str" }
- ],
- "repeat": "3",
- "crontab": "",
- "once": true,
- "onceDelay": "5",
- "topic": "i_flow",
- "payload": "3600",
- "payloadType": "num",
- "x": 140,
- "y": 360,
- "wires": [["evolv-monster"]]
- },
- {
- "id": "inject-monster-start",
- "type": "inject",
- "z": "e2e-flow-tab",
- "name": "Monster start",
- "props": [
- { "p": "payload" },
- { "p": "topic", "vt": "str" }
- ],
- "repeat": "",
- "crontab": "",
- "once": true,
- "onceDelay": "6",
- "topic": "start",
- "payload": "",
- "payloadType": "date",
- "x": 140,
- "y": 400,
- "wires": [["evolv-monster"]]
- },
- {
- "id": "evolv-monster",
- "type": "monster",
- "z": "e2e-flow-tab",
- "name": "E2E-Monster",
- "samplingtime": 1,
- "minvolume": 5,
- "maxweight": 23,
- "emptyWeightBucket": 3,
- "aquon_sample_name": "112100",
- "supplier": "e2e-test",
- "subType": "samplingCabinet",
- "model": "e2e-virtual",
- "unit": "m3/h",
- "enableLog": false,
- "logLevel": "error",
- "x": 390,
- "y": 360,
- "wires": [
- ["debug-monster-process"],
- ["debug-monster-dbase"],
- [],
- []
- ]
- },
- {
- "id": "debug-monster-process",
- "type": "debug",
- "z": "e2e-flow-tab",
- "name": "Monster Process Output",
- "active": true,
- "tosidebar": true,
- "console": true,
- "tostatus": true,
- "complete": "true",
- "targetType": "full",
- "statusVal": "",
- "statusType": "auto",
- "x": 660,
- "y": 340,
- "wires": []
- },
- {
- "id": "debug-monster-dbase",
- "type": "debug",
- "z": "e2e-flow-tab",
- "name": "Monster Database Output",
- "active": true,
- "tosidebar": true,
- "console": true,
- "tostatus": true,
- "complete": "true",
- "targetType": "full",
- "statusVal": "",
- "statusType": "auto",
- "x": 670,
- "y": 380,
- "wires": []
- },
- {
- "id": "inject-dashboardapi-register",
- "type": "inject",
- "z": "e2e-flow-tab",
- "name": "DashboardAPI register child",
- "props": [
- { "p": "payload" },
- { "p": "topic", "vt": "str" }
- ],
- "repeat": "",
- "crontab": "",
- "once": true,
- "onceDelay": "12",
- "payload": "",
- "payloadType": "date",
- "x": 160,
- "y": 500,
- "wires": [["build-dashboardapi-msg"]]
- },
- {
- "id": "build-dashboardapi-msg",
- "type": "function",
- "z": "e2e-flow-tab",
- "name": "Build dashboardapi input",
- "func": "msg.topic = 'registerChild';\nmsg.payload = {\n config: {\n general: {\n name: 'E2E-Level-Sensor'\n },\n functionality: {\n softwareType: 'measurement'\n }\n }\n};\nreturn msg;",
- "outputs": 1,
- "timeout": "",
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 400,
- "y": 500,
- "wires": [["dashboardapi-e2e"]]
- },
- {
- "id": "dashboardapi-e2e",
- "type": "dashboardapi",
- "z": "e2e-flow-tab",
- "name": "E2E-DashboardAPI",
- "logLevel": "error",
- "enableLog": false,
- "host": "grafana",
- "port": "3000",
- "bearerToken": "",
- "x": 660,
- "y": 500,
- "wires": [["debug-dashboardapi-output"]]
- },
- {
- "id": "debug-dashboardapi-output",
- "type": "debug",
- "z": "e2e-flow-tab",
- "name": "DashboardAPI Output",
- "active": true,
- "tosidebar": true,
- "console": true,
- "tostatus": true,
- "complete": "true",
- "targetType": "full",
- "statusVal": "",
- "statusType": "auto",
- "x": 920,
- "y": 500,
- "wires": []
- },
- {
- "id": "inject-diffuser-flow",
- "type": "inject",
- "z": "e2e-flow-tab",
- "name": "Diffuser airflow",
- "props": [
- { "p": "payload" },
- { "p": "topic", "vt": "str" }
- ],
- "repeat": "",
- "crontab": "",
- "once": true,
- "onceDelay": "9",
- "topic": "air_flow",
- "payload": "24",
- "payloadType": "num",
- "x": 150,
- "y": 620,
- "wires": [["diffuser-e2e"]]
- },
- {
- "id": "diffuser-e2e",
- "type": "diffuser",
- "z": "e2e-flow-tab",
- "name": "E2E-Diffuser",
- "number": 1,
- "i_elements": 4,
- "i_diff_density": 2.4,
- "i_m_water": 4.5,
- "alfaf": 0.7,
- "enableLog": false,
- "logLevel": "error",
- "x": 390,
- "y": 620,
- "wires": [["debug-diffuser-process"], ["debug-diffuser-dbase"], []]
- },
- {
- "id": "debug-diffuser-process",
- "type": "debug",
- "z": "e2e-flow-tab",
- "name": "Diffuser Process Output",
- "active": true,
- "tosidebar": true,
- "console": true,
- "tostatus": true,
- "complete": "true",
- "targetType": "full",
- "statusVal": "",
- "statusType": "auto",
- "x": 670,
- "y": 600,
- "wires": []
- },
- {
- "id": "debug-diffuser-dbase",
- "type": "debug",
- "z": "e2e-flow-tab",
- "name": "Diffuser Database Output",
- "active": true,
- "tosidebar": true,
- "console": true,
- "tostatus": true,
- "complete": "true",
- "targetType": "full",
- "statusVal": "",
- "statusType": "auto",
- "x": 680,
- "y": 640,
- "wires": []
- }
-]
diff --git a/test/e2e/run-e2e.sh b/test/e2e/run-e2e.sh
deleted file mode 100755
index c15d8a5..0000000
--- a/test/e2e/run-e2e.sh
+++ /dev/null
@@ -1,213 +0,0 @@
-#!/usr/bin/env bash
-#
-# End-to-end test runner for EVOLV Node-RED stack.
-# Starts Node-RED + InfluxDB + Grafana via Docker Compose,
-# verifies that EVOLV nodes are registered in the palette,
-# and tears down the stack on exit.
-#
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
-COMPOSE_FILE="$PROJECT_ROOT/docker-compose.e2e.yml"
-
-NODERED_URL="http://localhost:1880"
-MAX_WAIT=120 # seconds to wait for Node-RED to become healthy
-GRAFANA_URL="http://localhost:3000/api/health"
-MAX_GRAFANA_WAIT=60
-LOG_WAIT=20
-
-# EVOLV node types that must appear in the palette (from package.json node-red.nodes)
-EXPECTED_NODES=(
- "dashboardapi"
- "diffuser"
- "machineGroupControl"
- "measurement"
- "monster"
- "pumpingstation"
- "reactor"
- "rotatingMachine"
- "settler"
- "valve"
- "valveGroupControl"
-)
-
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-NC='\033[0m' # No Color
-
-log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
-log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
-log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
-
-wait_for_log_pattern() {
- local pattern="$1"
- local description="$2"
- local required="${3:-false}"
- local elapsed=0
- local logs=""
-
- while [ $elapsed -lt $LOG_WAIT ]; do
- logs=$(run_compose logs nodered 2>&1)
- if echo "$logs" | grep -q "$pattern"; then
- log_info " [PASS] $description"
- return 0
- fi
- sleep 2
- elapsed=$((elapsed + 2))
- done
-
- if [ "$required" = true ]; then
- log_error " [FAIL] $description not detected in logs"
- FAILURES=$((FAILURES + 1))
- else
- log_warn " [WARN] $description not detected in logs"
- fi
- return 1
-}
-
-# Determine docker compose command (handle permission via sg docker if needed)
-USE_SG_DOCKER=false
-if ! docker info >/dev/null 2>&1; then
- if sg docker -c "docker info" >/dev/null 2>&1; then
- USE_SG_DOCKER=true
- log_info "Using sg docker for Docker access"
- else
- log_error "Docker is not accessible. Please ensure Docker is running and you have permissions."
- exit 1
- fi
-fi
-
-run_compose() {
- if [ "$USE_SG_DOCKER" = true ]; then
- local cmd="docker compose -f $(printf '%q' "$COMPOSE_FILE")"
- local arg
- for arg in "$@"; do
- cmd+=" $(printf '%q' "$arg")"
- done
- sg docker -c "$cmd"
- else
- docker compose -f "$COMPOSE_FILE" "$@"
- fi
-}
-
-cleanup() {
- log_info "Tearing down E2E stack..."
- run_compose down --volumes --remove-orphans 2>/dev/null || true
-}
-
-# Always clean up on exit
-trap cleanup EXIT
-
-# --- Step 1: Build and start the stack ---
-log_info "Building and starting E2E stack..."
-run_compose up -d --build
-
-# --- Step 2: Wait for Node-RED to be healthy ---
-log_info "Waiting for Node-RED to become healthy (max ${MAX_WAIT}s)..."
-elapsed=0
-while [ $elapsed -lt $MAX_WAIT ]; do
- if curl -sf "$NODERED_URL/" >/dev/null 2>&1; then
- log_info "Node-RED is up after ${elapsed}s"
- break
- fi
- sleep 2
- elapsed=$((elapsed + 2))
-done
-
-if [ $elapsed -ge $MAX_WAIT ]; then
- log_error "Node-RED did not become healthy within ${MAX_WAIT}s"
- log_error "Container logs:"
- run_compose logs nodered
- exit 1
-fi
-
-# Give Node-RED a few extra seconds to finish loading all nodes and editor metadata
-sleep 8
-
-# --- Step 3: Verify EVOLV nodes are registered in the palette ---
-log_info "Querying Node-RED for registered nodes..."
-NODES_RESPONSE=$(curl -sf "$NODERED_URL/nodes" 2>&1) || {
- log_error "Failed to query Node-RED /nodes endpoint"
- exit 1
-}
-
-FAILURES=0
-PALETTE_MISSES=0
-for node_type in "${EXPECTED_NODES[@]}"; do
- if echo "$NODES_RESPONSE" | grep -qi "$node_type"; then
- log_info " [PASS] Node type '$node_type' found in palette"
- else
- log_warn " [WARN] Node type '$node_type' not found in /nodes response"
- PALETTE_MISSES=$((PALETTE_MISSES + 1))
- fi
-done
-
-# --- Step 4: Verify flows are deployed ---
-log_info "Checking deployed flows..."
-FLOWS_RESPONSE=$(curl -sf "$NODERED_URL/flows" 2>&1) || {
- log_error "Failed to query Node-RED /flows endpoint"
- exit 1
-}
-
-if echo "$FLOWS_RESPONSE" | grep -q "e2e-flow-tab"; then
- log_info " [PASS] E2E test flow is deployed"
-else
- log_warn " [WARN] E2E test flow not found in deployed flows (may need manual deploy)"
-fi
-
-# --- Step 5: Verify InfluxDB is reachable ---
-log_info "Checking InfluxDB health..."
-INFLUX_HEALTH=$(curl -sf "http://localhost:8086/health" 2>&1) || {
- log_error "Failed to reach InfluxDB health endpoint"
- FAILURES=$((FAILURES + 1))
-}
-
-if echo "$INFLUX_HEALTH" | grep -q '"status":"pass"'; then
- log_info " [PASS] InfluxDB is healthy"
-else
- log_error " [FAIL] InfluxDB health check failed"
- FAILURES=$((FAILURES + 1))
-fi
-
-# --- Step 5b: Verify Grafana is reachable ---
-log_info "Checking Grafana health..."
-GRAFANA_HEALTH=""
-elapsed=0
-while [ $elapsed -lt $MAX_GRAFANA_WAIT ]; do
- GRAFANA_HEALTH=$(curl -sf "$GRAFANA_URL" 2>&1) && break
- sleep 2
- elapsed=$((elapsed + 2))
-done
-
-if echo "$GRAFANA_HEALTH" | grep -Eq '"database"[[:space:]]*:[[:space:]]*"ok"'; then
- log_info " [PASS] Grafana is healthy"
-else
- log_error " [FAIL] Grafana health check failed"
- FAILURES=$((FAILURES + 1))
-fi
-
-# --- Step 5c: Verify EVOLV measurement node produced output ---
-log_info "Checking EVOLV measurement node output in container logs..."
-wait_for_log_pattern "Database Output" "EVOLV measurement node produced database output" true || true
-wait_for_log_pattern "Process Output" "EVOLV measurement node produced process output" true || true
-wait_for_log_pattern "Monster Process Output" "EVOLV monster node produced process output" true || true
-wait_for_log_pattern "Monster Database Output" "EVOLV monster node produced database output" true || true
-wait_for_log_pattern "Diffuser Process Output" "EVOLV diffuser node produced process output" true || true
-wait_for_log_pattern "Diffuser Database Output" "EVOLV diffuser node produced database output" true || true
-wait_for_log_pattern "DashboardAPI Output" "EVOLV dashboardapi node produced create output" true || true
-
-# --- Step 6: Summary ---
-echo ""
-if [ $FAILURES -eq 0 ]; then
- log_info "========================================="
- log_info " E2E tests PASSED - all checks green"
- log_info "========================================="
- exit 0
-else
- log_error "========================================="
- log_error " E2E tests FAILED - $FAILURES check(s) failed"
- log_error "========================================="
- exit 1
-fi
diff --git a/third_party/docs/README.md b/third_party/docs/README.md
deleted file mode 100644
index 80e4b89..0000000
--- a/third_party/docs/README.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# EVOLV Scientific & Technical Reference Library
-
-## Purpose
-
-This directory contains curated reference documents for EVOLV's domain-specialist agents. These summaries distill authoritative sources into actionable knowledge that agents should consult **before making scientific or engineering claims**.
-
-## How Agents Should Use This
-
-1. **Before making domain claims**: Read the relevant reference doc to verify your reasoning
-2. **Cite sources**: When referencing scientific facts, point to the specific reference doc and its cited sources
-3. **Acknowledge uncertainty**: If the reference docs don't cover a topic, say so rather than guessing
-4. **Cross-reference with skills**: Combine these references with `.agents/skills/` SKILL.md files for implementation context
-
-## Index
-
-| File | Domain | Used By Agents |
-|------|--------|---------------|
-| [`asm-models.md`](asm-models.md) | Activated Sludge Models (ASM1-ASM3) | biological-process-engineer |
-| [`settling-models.md`](settling-models.md) | Sludge Settling & Clarifier Models | biological-process-engineer |
-| [`pump-affinity-laws.md`](pump-affinity-laws.md) | Pump Affinity Laws & Curve Theory | mechanical-process-engineer |
-| [`pid-control-theory.md`](pid-control-theory.md) | PID Control for Process Applications | mechanical-process-engineer, node-red-runtime |
-| [`signal-processing-sensors.md`](signal-processing-sensors.md) | Sensor Signal Conditioning | instrumentation-measurement |
-| [`wastewater-compliance-nl.md`](wastewater-compliance-nl.md) | Dutch Wastewater Regulations | commissioning-compliance, biological-process-engineer |
-| [`influxdb-schema-design.md`](influxdb-schema-design.md) | InfluxDB Time-Series Best Practices | telemetry-database |
-| [`ot-security-iec62443.md`](ot-security-iec62443.md) | OT Security Standards | ot-security-integration |
-
-## Sources Directory
-
-The `sources/` subdirectory is for placing actual PDFs of scientific papers, standards, and technical manuals. Agents should prefer these curated summaries but can reference originals when available.
-
-## Validation Status
-
-All reference documents have been validated against authoritative sources including:
-- IWA Scientific and Technical Reports (ASM models)
-- Peer-reviewed publications (Takacs 1991, Vesilind, Burger-Diehl)
-- Engineering Toolbox (pump affinity laws)
-- ISA publications (Astrom & Hagglund PID control)
-- IEC standards (61298, 62443)
-- EU Directive 91/271/EEC (wastewater compliance)
-- InfluxDB official documentation (schema design)
diff --git a/third_party/docs/sources/.gitkeep b/third_party/docs/sources/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/third_party/docs/asm-models.md b/wiki/concepts/asm-models.md
similarity index 100%
rename from third_party/docs/asm-models.md
rename to wiki/concepts/asm-models.md
diff --git a/third_party/docs/influxdb-schema-design.md b/wiki/concepts/influxdb-schema-design.md
similarity index 100%
rename from third_party/docs/influxdb-schema-design.md
rename to wiki/concepts/influxdb-schema-design.md
diff --git a/third_party/docs/ot-security-iec62443.md b/wiki/concepts/ot-security-iec62443.md
similarity index 100%
rename from third_party/docs/ot-security-iec62443.md
rename to wiki/concepts/ot-security-iec62443.md
diff --git a/third_party/docs/pid-control-theory.md b/wiki/concepts/pid-control-theory.md
similarity index 100%
rename from third_party/docs/pid-control-theory.md
rename to wiki/concepts/pid-control-theory.md
diff --git a/third_party/docs/pump-affinity-laws.md b/wiki/concepts/pump-affinity-laws.md
similarity index 100%
rename from third_party/docs/pump-affinity-laws.md
rename to wiki/concepts/pump-affinity-laws.md
diff --git a/third_party/docs/settling-models.md b/wiki/concepts/settling-models.md
similarity index 100%
rename from third_party/docs/settling-models.md
rename to wiki/concepts/settling-models.md
diff --git a/third_party/docs/signal-processing-sensors.md b/wiki/concepts/signal-processing-sensors.md
similarity index 100%
rename from third_party/docs/signal-processing-sensors.md
rename to wiki/concepts/signal-processing-sensors.md
diff --git a/third_party/docs/sources/README.md b/wiki/concepts/sources-readme.md
similarity index 100%
rename from third_party/docs/sources/README.md
rename to wiki/concepts/sources-readme.md
diff --git a/third_party/docs/wastewater-compliance-nl.md b/wiki/concepts/wastewater-compliance-nl.md
similarity index 100%
rename from third_party/docs/wastewater-compliance-nl.md
rename to wiki/concepts/wastewater-compliance-nl.md
diff --git a/wiki/index.md b/wiki/index.md
index f850e3c..0386407 100644
--- a/wiki/index.md
+++ b/wiki/index.md
@@ -20,6 +20,14 @@ updated: 2026-04-07
## Core Concepts
- [generalFunctions API](concepts/generalfunctions-api.md) — logger, MeasurementContainer, configManager, etc.
+- [Pump Affinity Laws](concepts/pump-affinity-laws.md) — Q ∝ N, H ∝ N², P ∝ N³
+- [ASM Models](concepts/asm-models.md) — activated sludge model kinetics
+- [PID Control Theory](concepts/pid-control-theory.md) — proportional-integral-derivative control
+- [Settling Models](concepts/settling-models.md) — secondary clarifier sludge settling
+- [Signal Processing for Sensors](concepts/signal-processing-sensors.md) — sensor conditioning
+- [InfluxDB Schema Design](concepts/influxdb-schema-design.md) — telemetry data model
+- [OT Security (IEC 62443)](concepts/ot-security-iec62443.md) — industrial security standard
+- [Wastewater Compliance NL](concepts/wastewater-compliance-nl.md) — Dutch regulatory requirements
## Findings
- [BEP-Gravitation Proof](findings/bep-gravitation-proof.md) — within 0.1% of brute-force optimum (proven)
@@ -28,21 +36,18 @@ updated: 2026-04-07
- [Pump Switching Stability](findings/pump-switching-stability.md) — 1-2 transitions, no hysteresis (proven)
- [Open Issues (2026-03)](findings/open-issues-2026-03.md) — diffuser, monster refactor, ML relocation, etc.
+## Manuals
+- [FlowFuse Dashboard Layout](manuals/node-red/flowfuse-dashboard-layout-manual.md)
+- [FlowFuse Widget Catalog](manuals/node-red/flowfuse-widgets-catalog.md)
+- [Node-RED Function Patterns](manuals/node-red/function-node-patterns.md)
+- [Node-RED Runtime](manuals/node-red/runtime-node-js.md)
+- [Messages and Editor Structure](manuals/node-red/messages-and-editor-structure.md)
+
## Sessions
- [2026-04-07: Production Hardening](sessions/2026-04-07-production-hardening.md) — rotatingMachine + machineGroupControl
## Other Documentation (outside wiki)
- `CLAUDE.md` — Claude Code project guide (root)
-- `AGENTS.md` — agent routing table, orchestrator policy (root, used by `.claude/agents/`)
+- `.agents/AGENTS.md` — agent routing table, orchestrator policy
- `.agents/` — skills, decisions, function-anchors, improvements
- `.claude/` — Claude Code agents and rules
-- `manuals/node-red/` — FlowFuse dashboard and Node-RED reference docs
-
-## Not Yet Documented
-- Parent-child registration protocol (Port 2 handshake)
-- Prediction health scoring algorithm (confidence 0-1)
-- MeasurementContainer internals (chainable API, delta compression)
-- PID controller implementation
-- reactor / settler / monster / measurement / valve nodes
-- pumpingStation node (uses rotatingMachine children)
-- InfluxDB telemetry format (Port 1)
diff --git a/manuals/README.md b/wiki/manuals/README.md
similarity index 100%
rename from manuals/README.md
rename to wiki/manuals/README.md
diff --git a/manuals/node-red/INDEX.md b/wiki/manuals/node-red/INDEX.md
similarity index 100%
rename from manuals/node-red/INDEX.md
rename to wiki/manuals/node-red/INDEX.md
diff --git a/manuals/node-red/flowfuse-dashboard-layout-manual.md b/wiki/manuals/node-red/flowfuse-dashboard-layout-manual.md
similarity index 100%
rename from manuals/node-red/flowfuse-dashboard-layout-manual.md
rename to wiki/manuals/node-red/flowfuse-dashboard-layout-manual.md
diff --git a/manuals/node-red/flowfuse-ui-button-manual.md b/wiki/manuals/node-red/flowfuse-ui-button-manual.md
similarity index 100%
rename from manuals/node-red/flowfuse-ui-button-manual.md
rename to wiki/manuals/node-red/flowfuse-ui-button-manual.md
diff --git a/manuals/node-red/flowfuse-ui-chart-manual.md b/wiki/manuals/node-red/flowfuse-ui-chart-manual.md
similarity index 100%
rename from manuals/node-red/flowfuse-ui-chart-manual.md
rename to wiki/manuals/node-red/flowfuse-ui-chart-manual.md
diff --git a/manuals/node-red/flowfuse-ui-config-manual.md b/wiki/manuals/node-red/flowfuse-ui-config-manual.md
similarity index 100%
rename from manuals/node-red/flowfuse-ui-config-manual.md
rename to wiki/manuals/node-red/flowfuse-ui-config-manual.md
diff --git a/manuals/node-red/flowfuse-ui-gauge-manual.md b/wiki/manuals/node-red/flowfuse-ui-gauge-manual.md
similarity index 100%
rename from manuals/node-red/flowfuse-ui-gauge-manual.md
rename to wiki/manuals/node-red/flowfuse-ui-gauge-manual.md
diff --git a/manuals/node-red/flowfuse-ui-template-manual.md b/wiki/manuals/node-red/flowfuse-ui-template-manual.md
similarity index 100%
rename from manuals/node-red/flowfuse-ui-template-manual.md
rename to wiki/manuals/node-red/flowfuse-ui-template-manual.md
diff --git a/manuals/node-red/flowfuse-ui-text-manual.md b/wiki/manuals/node-red/flowfuse-ui-text-manual.md
similarity index 100%
rename from manuals/node-red/flowfuse-ui-text-manual.md
rename to wiki/manuals/node-red/flowfuse-ui-text-manual.md
diff --git a/manuals/node-red/flowfuse-widgets-catalog.md b/wiki/manuals/node-red/flowfuse-widgets-catalog.md
similarity index 100%
rename from manuals/node-red/flowfuse-widgets-catalog.md
rename to wiki/manuals/node-red/flowfuse-widgets-catalog.md
diff --git a/manuals/node-red/function-node-patterns.md b/wiki/manuals/node-red/function-node-patterns.md
similarity index 100%
rename from manuals/node-red/function-node-patterns.md
rename to wiki/manuals/node-red/function-node-patterns.md
diff --git a/manuals/node-red/messages-and-editor-structure.md b/wiki/manuals/node-red/messages-and-editor-structure.md
similarity index 100%
rename from manuals/node-red/messages-and-editor-structure.md
rename to wiki/manuals/node-red/messages-and-editor-structure.md
diff --git a/manuals/node-red/runtime-node-js.md b/wiki/manuals/node-red/runtime-node-js.md
similarity index 100%
rename from manuals/node-red/runtime-node-js.md
rename to wiki/manuals/node-red/runtime-node-js.md