chore: clean up superproject structure
Some checks failed
CI / lint-and-test (push) Has been cancelled

Move content to correct locations:
- AGENTS.md → .agents/AGENTS.md (with orchestrator reference update)
- third_party/docs/ (8 reference docs) → wiki/concepts/
- manuals/ (12 Node-RED docs) → wiki/manuals/

Delete 23 unreferenced one-off scripts from scripts/ (keeping 5 active).
Delete stale Dockerfile.e2e, docker-compose.e2e.yml, test/e2e/.
Remove empty third_party/ directory.

Root is now: README, CLAUDE.md, LICENSE, package.json, Makefile,
Dockerfile, docker-compose.yml, docker/, scripts/ (5), nodes/, wiki/,
plus dotfiles (.agents, .claude, .gitea).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-07 18:01:04 +02:00
parent bac6c620b1
commit 48f790d123
55 changed files with 21 additions and 5670 deletions

View File

@@ -2,7 +2,7 @@
## Context
- Task/request: Adapt EVOLV agents/skills using Harness Engineering patterns and set owner-controlled operating defaults.
- Impacted files/contracts: `AGENTS.md`, `.agents/skills/*/SKILL.md`, `.agents/skills/*/agents/openai.yaml`, decision-log policy.
- Impacted files/contracts: `.agents/AGENTS.md`, `.agents/skills/*/SKILL.md`, `.agents/skills/*/agents/openai.yaml`, decision-log policy.
- Why a decision is required now: New harness workflow needs explicit defaults for compatibility, safety bias, and governance discipline.
## Options
@@ -30,9 +30,9 @@
- Data/operations impact: Decision traceability improves cross-turn consistency and auditability.
## Implementation Notes
- Required code/doc updates: Set defaults in `AGENTS.md` and orchestrator skill instructions; keep decision-log template active.
- Required code/doc updates: Set defaults in `.agents/AGENTS.md` and orchestrator skill instructions; keep decision-log template active.
- Validation evidence required: Presence of defaults in policy docs and this decision artifact under `.agents/decisions/`.
## Rollback / Migration
- Rollback strategy: Update defaults in `AGENTS.md` and orchestrator SKILL; create a superseding decision log entry.
- Rollback strategy: Update defaults in `.agents/AGENTS.md` and orchestrator SKILL; create a superseding decision log entry.
- Migration/deprecation plan: For any future hard-break preference, require explicit migration plan and effective date in a new decision entry.

View File

@@ -42,7 +42,7 @@ You are the EVOLV orchestrator agent. You decompose complex tasks, route to spec
## Reference Files
- `.agents/skills/evolv-orchestrator/SKILL.md` — Full orchestration protocol
- `AGENTS.md` — Agent invocation policy, routing table, decision governance
- `.agents/AGENTS.md` — Agent invocation policy, routing table, decision governance
- `.agents/decisions/` — Decision log directory
- `.agents/improvements/IMPROVEMENTS_BACKLOG.md` — Deferred improvements
@@ -52,4 +52,4 @@ You are the EVOLV orchestrator agent. You decompose complex tasks, route to spec
- Owner-approved defaults: compatibility=controlled, safety=availability-first
## Reasoning Difficulty: Medium-High
This agent handles multi-domain task decomposition, cross-cutting impact analysis, and decision governance enforcement. The primary challenge is correctly mapping changes across node boundaries — a single modification can cascade through parent-child relationships, shared contracts, and InfluxDB semantics. When uncertain about cross-domain impact, consult `.agents/skills/evolv-orchestrator/SKILL.md` and `AGENTS.md` before routing to specialist agents.
This agent handles multi-domain task decomposition, cross-cutting impact analysis, and decision governance enforcement. The primary challenge is correctly mapping changes across node boundaries — a single modification can cascade through parent-child relationships, shared contracts, and InfluxDB semantics. When uncertain about cross-domain impact, consult `.agents/skills/evolv-orchestrator/SKILL.md` and `.agents/AGENTS.md` before routing to specialist agents.

View File

@@ -1,29 +0,0 @@
FROM nodered/node-red:latest
# Switch to root for setup
USER root
# Copy EVOLV directly into where Node-RED looks for custom nodes
COPY package.json /data/node_modules/EVOLV/package.json
COPY nodes/ /data/node_modules/EVOLV/nodes/
# Rewrite generalFunctions dependency to local file path (no-op if already local)
RUN sed -i 's|"generalFunctions": "git+https://[^"]*"|"generalFunctions": "file:./nodes/generalFunctions"|' \
/data/node_modules/EVOLV/package.json
# Fix ownership for node-red user
RUN chown -R node-red:root /data
USER node-red
# Install EVOLV's own dependencies inside the EVOLV package directory
WORKDIR /data/node_modules/EVOLV
RUN npm install --ignore-scripts --production
# Copy test flows into Node-RED data directory
COPY --chown=node-red:root test/e2e/flows.json /data/flows.json
# Reset workdir to Node-RED default
WORKDIR /usr/src/node-red
EXPOSE 1880

View File

@@ -1,49 +0,0 @@
services:
influxdb:
image: influxdb:2.7
environment:
- DOCKER_INFLUXDB_INIT_MODE=setup
- DOCKER_INFLUXDB_INIT_USERNAME=admin
- DOCKER_INFLUXDB_INIT_PASSWORD=adminpassword
- DOCKER_INFLUXDB_INIT_ORG=evolv
- DOCKER_INFLUXDB_INIT_BUCKET=evolv
- DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=evolv-e2e-token
ports:
- "8086:8086"
healthcheck:
test: ["CMD", "influx", "ping"]
interval: 5s
timeout: 5s
retries: 5
nodered:
build:
context: .
dockerfile: Dockerfile.e2e
ports:
- "1880:1880"
depends_on:
influxdb:
condition: service_healthy
environment:
- INFLUXDB_URL=http://influxdb:8086
- INFLUXDB_TOKEN=evolv-e2e-token
- INFLUXDB_ORG=evolv
- INFLUXDB_BUCKET=evolv
volumes:
- ./test/e2e/flows.json:/data/flows.json
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:1880/"]
interval: 5s
timeout: 5s
retries: 10
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_AUTH_ANONYMOUS_ENABLED=true
depends_on:
- influxdb

View File

@@ -1,114 +0,0 @@
#!/usr/bin/env node
/**
* Add monitoring/debug nodes to the demo flow for process visibility.
* Adds a function node per PS that logs volume, level, flow rate every 10 ticks.
* Also adds a status debug node for the overall system.
*/
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'));
// Remove existing monitoring nodes
const monitorIds = flow.filter(n => n.id && n.id.startsWith('demo_mon_')).map(n => n.id);
if (monitorIds.length > 0) {
console.log('Removing existing monitoring nodes:', monitorIds);
for (const id of monitorIds) {
const idx = flow.findIndex(n => n.id === id);
if (idx !== -1) flow.splice(idx, 1);
}
// Also remove from wires
flow.forEach(n => {
if (n.wires) {
n.wires = n.wires.map(portWires =>
Array.isArray(portWires) ? portWires.filter(w => !monitorIds.includes(w)) : portWires
);
}
});
}
// Add monitoring function nodes for each PS
const monitors = [
{
id: 'demo_mon_west',
name: 'Monitor PS West',
ps: 'demo_ps_west',
x: 800, y: 50,
},
{
id: 'demo_mon_north',
name: 'Monitor PS North',
ps: 'demo_ps_north',
x: 800, y: 100,
},
{
id: 'demo_mon_south',
name: 'Monitor PS South',
ps: 'demo_ps_south',
x: 800, y: 150,
},
];
// Each PS sends process data on port 0. Wire monitoring nodes to PS port 0.
monitors.forEach(mon => {
// Function node that extracts key metrics and logs them periodically
const fnNode = {
id: mon.id,
type: 'function',
z: 'demo_tab_wwtp',
name: mon.name,
func: `// Extract key metrics from PS process output
const p = msg.payload || {};
// Keys have .default suffix in PS output format
const vol = p["volume.predicted.atequipment.default"];
const level = p["level.predicted.atequipment.default"];
const netFlow = p["netFlowRate.predicted.atequipment.default"];
const volPct = p["volumePercent.predicted.atequipment.default"];
// Only log when we have volume data
if (vol !== null && vol !== undefined) {
const ctx = context.get("tickCount") || 0;
context.set("tickCount", ctx + 1);
// Log every 10 ticks
if (ctx % 10 === 0) {
const fmt = (v, dec) => typeof v === "number" ? v.toFixed(dec) : String(v);
const parts = ["vol=" + fmt(vol, 1) + "m3"];
if (level !== null && level !== undefined) parts.push("lvl=" + fmt(level, 3) + "m");
if (volPct !== null && volPct !== undefined) parts.push("fill=" + fmt(volPct, 1) + "%");
if (netFlow !== null && netFlow !== undefined) parts.push("net=" + fmt(netFlow, 1) + "m3/h");
node.warn(parts.join(" | "));
}
}
return msg;`,
outputs: 1,
timeout: '',
noerr: 0,
initialize: '',
finalize: '',
libs: [],
x: mon.x,
y: mon.y,
wires: [[]],
};
flow.push(fnNode);
// Wire PS port 0 to this monitor (append to existing wires)
const psNode = flow.find(n => n.id === mon.ps);
if (psNode && psNode.wires && psNode.wires[0]) {
if (!psNode.wires[0].includes(mon.id)) {
psNode.wires[0].push(mon.id);
}
}
console.log(`Added ${mon.id}: ${mon.name} → wired to ${mon.ps} port 0`);
});
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n');
console.log(`\nDone. ${monitors.length} monitoring nodes added.`);

View File

@@ -1,138 +0,0 @@
#!/usr/bin/env node
/**
* Comprehensive runtime analysis of the WWTP demo flow.
* Captures process debug output, pumping station state, measurements,
* and analyzes filling/draining behavior over time.
*/
const http = require('http');
const NR_URL = 'http://localhost:1880';
function fetchJSON(url) {
return new Promise((resolve, reject) => {
http.get(url, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks))); }
catch (e) { reject(new Error('Parse error from ' + url + ': ' + e.message)); }
});
}).on('error', reject);
});
}
// Inject a debug-capture subflow to intercept process messages
async function injectDebugCapture() {
const flows = await fetchJSON(NR_URL + '/flows');
// Find all nodes on WWTP tab
const wwtp = flows.filter(n => n.z === 'demo_tab_wwtp');
console.log('=== WWTP Node Inventory ===');
const byType = {};
wwtp.forEach(n => {
if (!byType[n.type]) byType[n.type] = [];
byType[n.type].push(n);
});
Object.entries(byType).sort().forEach(([type, nodes]) => {
console.log(type + ' (' + nodes.length + '):');
nodes.forEach(n => {
const extra = [];
if (n.simulator) extra.push('sim=ON');
if (n.model) extra.push('model=' + n.model);
if (n.basinVolume) extra.push('basin=' + n.basinVolume + 'm3');
if (n.basinHeight) extra.push('h=' + n.basinHeight + 'm');
if (n.positionVsParent) extra.push('pos=' + n.positionVsParent);
if (n.control) extra.push('ctrl=' + JSON.stringify(n.control));
console.log(' ' + n.id + ' "' + (n.name || '') + '" ' + (extra.length ? '[' + extra.join(', ') + ']' : ''));
});
});
// Analyze pumping station configurations
console.log('\n=== Pumping Station Configs ===');
const pss = wwtp.filter(n => n.type === 'pumpingStation');
pss.forEach(ps => {
console.log('\n' + ps.id + ' "' + ps.name + '"');
console.log(' Basin: vol=' + ps.basinVolume + 'm3, h=' + ps.basinHeight + 'm');
console.log(' Inlet: h=' + ps.heightInlet + 'm, Outlet: h=' + ps.heightOutlet + 'm');
console.log(' Simulator: ' + ps.simulator);
console.log(' Control mode: ' + (ps.controlMode || 'not set'));
// Check q_in inject wiring
const qinInject = wwtp.find(n => n.id === 'demo_inj_' + ps.id.replace('demo_ps_', '') + '_flow');
if (qinInject) {
console.log(' q_in inject: repeat=' + qinInject.repeat + 's, wired to ' + JSON.stringify(qinInject.wires));
}
// Check what's wired to this PS (port 2 = parent registration)
const children = wwtp.filter(n => {
if (!n.wires) return false;
return n.wires.some(portWires => portWires && portWires.includes(ps.id));
});
console.log(' Children wired to it: ' + children.map(c => c.id + '(' + c.type + ')').join(', '));
});
// Analyze inject timers
console.log('\n=== Active Inject Timers ===');
const injects = wwtp.filter(n => n.type === 'inject');
injects.forEach(inj => {
const targets = (inj.wires || []).flat();
console.log(inj.id + ' "' + (inj.name || '') + '"');
console.log(' topic=' + inj.topic + ' payload=' + inj.payload);
console.log(' once=' + inj.once + ' repeat=' + (inj.repeat || 'none'));
console.log(' → ' + targets.join(', '));
});
// Analyze q_in function nodes
console.log('\n=== q_in Flow Simulation Functions ===');
const fnNodes = wwtp.filter(n => n.type === 'function' && n.name && n.name.includes('Flow'));
fnNodes.forEach(fn => {
console.log(fn.id + ' "' + fn.name + '"');
console.log(' func: ' + (fn.func || '').substring(0, 200));
const targets = (fn.wires || []).flat();
console.log(' → ' + targets.join(', '));
});
// Analyze measurement nodes
console.log('\n=== Measurement Nodes ===');
const meas = wwtp.filter(n => n.type === 'measurement');
meas.forEach(m => {
console.log(m.id + ' "' + (m.name || '') + '"');
console.log(' type=' + m.assetType + ' sim=' + m.simulator + ' range=[' + m.o_min + ',' + m.o_max + '] unit=' + m.unit);
console.log(' pos=' + (m.positionVsParent || 'none'));
// Check port 2 wiring (parent registration)
const port2 = m.wires && m.wires[2] ? m.wires[2] : [];
console.log(' port2→ ' + (port2.length ? port2.join(', ') : 'none'));
});
// Analyze rotating machines
console.log('\n=== Rotating Machine Nodes ===');
const machines = wwtp.filter(n => n.type === 'rotatingMachine');
machines.forEach(m => {
console.log(m.id + ' "' + (m.name || '') + '"');
console.log(' model=' + m.model + ' mode=' + m.movementMode);
console.log(' pos=' + m.positionVsParent + ' supplier=' + m.supplier);
console.log(' speed=' + m.speed + ' startup=' + m.startup + ' shutdown=' + m.shutdown);
const port2 = m.wires && m.wires[2] ? m.wires[2] : [];
console.log(' port2→ ' + (port2.length ? port2.join(', ') : 'none'));
});
// Check wiring integrity
console.log('\n=== Wiring Analysis ===');
pss.forEach(ps => {
const psPort0 = ps.wires && ps.wires[0] ? ps.wires[0] : [];
const psPort1 = ps.wires && ps.wires[1] ? ps.wires[1] : [];
const psPort2 = ps.wires && ps.wires[2] ? ps.wires[2] : [];
console.log(ps.id + ' wiring:');
console.log(' port0 (process): ' + psPort0.join(', '));
console.log(' port1 (influx): ' + psPort1.join(', '));
console.log(' port2 (parent): ' + psPort2.join(', '));
});
}
injectDebugCapture().catch(err => {
console.error('Analysis failed:', err);
process.exit(1);
});

View File

@@ -1,145 +0,0 @@
#!/usr/bin/env node
/**
* Capture live process data from Node-RED WebSocket debug sidebar.
* Collects samples over a time window and analyzes trends.
*/
const http = require('http');
const NR_URL = 'http://localhost:1880';
const CAPTURE_SECONDS = 30;
// Alternative: poll the Node-RED comms endpoint
// But let's use a simpler approach - inject a temporary catch-all debug and read context
async function fetchJSON(url) {
return new Promise((resolve, reject) => {
http.get(url, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks))); }
catch (e) { reject(new Error('Parse: ' + e.message)); }
});
}).on('error', reject);
});
}
async function postJSON(url, data) {
return new Promise((resolve, reject) => {
const body = JSON.stringify(data);
const parsed = new URL(url);
const req = http.request({
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
}, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString();
try { resolve(JSON.parse(text)); } catch { resolve(text); }
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
(async () => {
console.log('=== Capturing Process Data (' + CAPTURE_SECONDS + 's) ===\n');
// Use Node-RED inject API to trigger debug output
// Instead, let's read node context which stores the current state
// Get flows to find node IDs
const flows = await fetchJSON(NR_URL + '/flows');
const wwtp = flows.filter(n => n.z === 'demo_tab_wwtp');
// Pumping stations store state in node context
const pss = wwtp.filter(n => n.type === 'pumpingStation');
const pumps = wwtp.filter(n => n.type === 'rotatingMachine');
const samples = [];
const startTime = Date.now();
console.log('Sampling every 3 seconds for ' + CAPTURE_SECONDS + 's...\n');
for (let i = 0; i < Math.ceil(CAPTURE_SECONDS / 3); i++) {
const t = Date.now();
const elapsed = ((t - startTime) / 1000).toFixed(1);
// Read PS context data via Node-RED context API
const sample = { t: elapsed, stations: {} };
for (const ps of pss) {
try {
const ctx = await fetchJSON(NR_URL + '/context/node/' + ps.id + '?store=default');
sample.stations[ps.id] = ctx;
} catch (e) {
sample.stations[ps.id] = { error: e.message };
}
}
for (const pump of pumps) {
try {
const ctx = await fetchJSON(NR_URL + '/context/node/' + pump.id + '?store=default');
sample.stations[pump.id] = ctx;
} catch (e) {
sample.stations[pump.id] = { error: e.message };
}
}
samples.push(sample);
// Print summary for this sample
console.log('--- Sample at t=' + elapsed + 's ---');
for (const ps of pss) {
const ctx = sample.stations[ps.id];
if (ctx && ctx.data) {
console.log(ps.name + ':');
// Print all context keys
Object.entries(ctx.data).forEach(([key, val]) => {
if (typeof val === 'object') {
console.log(' ' + key + ': ' + JSON.stringify(val).substring(0, 200));
} else {
console.log(' ' + key + ': ' + val);
}
});
} else {
console.log(ps.name + ': ' + JSON.stringify(ctx).substring(0, 200));
}
}
for (const pump of pumps) {
const ctx = sample.stations[pump.id];
if (ctx && ctx.data && Object.keys(ctx.data).length > 0) {
console.log(pump.name + ':');
Object.entries(ctx.data).forEach(([key, val]) => {
if (typeof val === 'object') {
console.log(' ' + key + ': ' + JSON.stringify(val).substring(0, 200));
} else {
console.log(' ' + key + ': ' + val);
}
});
}
}
console.log('');
if (i < Math.ceil(CAPTURE_SECONDS / 3) - 1) {
await new Promise(r => setTimeout(r, 3000));
}
}
console.log('\n=== Summary ===');
console.log('Collected ' + samples.length + ' samples over ' + CAPTURE_SECONDS + 's');
})().catch(err => {
console.error('Capture failed:', err);
process.exit(1);
});

View File

@@ -1,109 +0,0 @@
#!/usr/bin/env node
/**
* Verify asset selection fields are correct in deployed flow.
* Checks that supplier/assetType/model/unit values match asset data IDs
* so the editor dropdowns will pre-select correctly.
*/
const http = require('http');
const NR_URL = 'http://localhost:1880';
async function fetchJSON(url) {
return new Promise((resolve, reject) => {
http.get(url, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks))); }
catch (e) { reject(new Error(`Parse error: ${e.message}`)); }
});
}).on('error', reject);
});
}
(async () => {
const flows = await fetchJSON(`${NR_URL}/flows`);
const errors = [];
console.log('=== Pump Asset Selection Checks ===');
const pumps = flows.filter(n => n.type === 'rotatingMachine' && n.z === 'demo_tab_wwtp');
pumps.forEach(p => {
const checks = [
{ field: 'supplier', expected: 'hidrostal', actual: p.supplier },
{ field: 'assetType', expected: 'pump-centrifugal', actual: p.assetType },
{ field: 'category', expected: 'machine', actual: p.category },
];
checks.forEach(c => {
if (c.actual === c.expected) {
console.log(` PASS: ${p.id} ${c.field} = "${c.actual}"`);
} else {
console.log(` FAIL: ${p.id} ${c.field} = "${c.actual}" (expected "${c.expected}")`);
errors.push(`${p.id}.${c.field}`);
}
});
// Model should be one of the known models
const validModels = ['hidrostal-H05K-S03R', 'hidrostal-C5-D03R-SHN1'];
if (validModels.includes(p.model)) {
console.log(` PASS: ${p.id} model = "${p.model}"`);
} else {
console.log(` FAIL: ${p.id} model = "${p.model}" (expected one of ${validModels})`);
errors.push(`${p.id}.model`);
}
});
console.log('\n=== Measurement Asset Selection Checks ===');
const measurements = flows.filter(n => n.type === 'measurement' && n.z === 'demo_tab_wwtp');
// Valid supplier→type→model combinations from measurement.json
const validSuppliers = {
'Endress+Hauser': {
types: ['flow', 'pressure', 'level'],
models: { flow: ['Promag-W400', 'Promag-W300'], pressure: ['Cerabar-PMC51', 'Cerabar-PMC71'], level: ['Levelflex-FMP50'] }
},
'Hach': {
types: ['dissolved-oxygen', 'ammonium', 'nitrate', 'tss'],
models: { 'dissolved-oxygen': ['LDO2'], ammonium: ['Amtax-sc'], nitrate: ['Nitratax-sc'], tss: ['Solitax-sc'] }
},
'vega': {
types: ['temperature', 'pressure', 'flow', 'level', 'oxygen'],
models: {} // not checking Vega models for now
}
};
measurements.forEach(m => {
const supplierData = validSuppliers[m.supplier];
if (!supplierData) {
console.log(` FAIL: ${m.id} supplier "${m.supplier}" not in asset data`);
errors.push(`${m.id}.supplier`);
return;
}
console.log(` PASS: ${m.id} supplier = "${m.supplier}"`);
if (!supplierData.types.includes(m.assetType)) {
console.log(` FAIL: ${m.id} assetType "${m.assetType}" not in ${m.supplier} types`);
errors.push(`${m.id}.assetType`);
} else {
console.log(` PASS: ${m.id} assetType = "${m.assetType}"`);
}
const validModels = supplierData.models[m.assetType] || [];
if (validModels.length > 0 && !validModels.includes(m.model)) {
console.log(` FAIL: ${m.id} model "${m.model}" not in ${m.supplier}/${m.assetType} models`);
errors.push(`${m.id}.model`);
} else if (m.model) {
console.log(` PASS: ${m.id} model = "${m.model}"`);
}
});
console.log('\n=== RESULT ===');
if (errors.length === 0) {
console.log('ALL ASSET SELECTION CHECKS PASSED');
} else {
console.log(`${errors.length} FAILURE(S):`, errors.join(', '));
process.exit(1);
}
})().catch(err => {
console.error('Check failed:', err.message);
process.exit(1);
});

View File

@@ -1,142 +0,0 @@
#!/usr/bin/env node
/**
* Check the deployed Node-RED flow for correctness after changes.
*/
const http = require('http');
function fetch(url) {
return new Promise((resolve, reject) => {
http.get(url, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => resolve(JSON.parse(Buffer.concat(chunks))));
}).on('error', reject);
});
}
(async () => {
let errors = 0;
// 1. Check deployed flow structure
console.log('=== Checking deployed flow structure ===');
const flow = await fetch('http://localhost:1880/flows');
console.log('Total deployed nodes:', flow.length);
// Check MGC exists
const mgc = flow.find(n => n.id === 'demo_mgc_west');
if (mgc) {
console.log('PASS: MGC West exists, position:', mgc.positionVsParent);
} else {
console.log('FAIL: MGC West missing from deployed flow');
errors++;
}
// Check reactor speedUpFactor
const reactor = flow.find(n => n.id === 'demo_reactor');
if (reactor && reactor.speedUpFactor === 1) {
console.log('PASS: Reactor speedUpFactor = 1');
} else {
console.log('FAIL: Reactor speedUpFactor =', reactor?.speedUpFactor);
errors++;
}
// Check sim mode on measurements
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'
];
let simOk = 0;
simMeasIds.forEach(id => {
const n = flow.find(x => x.id === id);
if (n && n.simulator === true) simOk++;
else { console.log('FAIL: simulator not true on', id); errors++; }
});
console.log(`PASS: ${simOk}/9 measurement nodes have simulator=true`);
// Check pressure nodes exist
const ptIds = ['demo_meas_pt_w_up','demo_meas_pt_w_down','demo_meas_pt_n_up','demo_meas_pt_n_down','demo_meas_pt_s_up','demo_meas_pt_s_down'];
let ptOk = 0;
ptIds.forEach(id => {
const n = flow.find(x => x.id === id);
if (n && n.type === 'measurement') ptOk++;
else { console.log('FAIL: pressure node missing:', id); errors++; }
});
console.log(`PASS: ${ptOk}/6 pressure measurement nodes present`);
// Check removed nodes are gone
const removedIds = [
'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',
'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 stillPresent = removedIds.filter(id => flow.find(x => x.id === id));
if (stillPresent.length === 0) {
console.log('PASS: All 24 removed nodes are gone');
} else {
console.log('FAIL: These removed nodes are still present:', stillPresent);
errors++;
}
// Check kept nodes still exist
const keptIds = [
'demo_inj_west_flow', 'demo_fn_west_flow_sim',
'demo_inj_north_flow', 'demo_fn_north_flow_sim',
'demo_inj_south_flow', 'demo_fn_south_flow_sim',
'demo_inj_w1_mode', 'demo_inj_w2_mode', 'demo_inj_n1_mode', 'demo_inj_s1_mode',
'demo_inj_west_mode', 'demo_inj_north_mode', 'demo_inj_south_mode'
];
const keptMissing = keptIds.filter(id => !flow.find(x => x.id === id));
if (keptMissing.length === 0) {
console.log('PASS: All kept nodes still present');
} else {
console.log('FAIL: These nodes should exist but are missing:', keptMissing);
errors++;
}
// Check wiring: W1/W2 register to MGC, MGC registers to PS West
const w1 = flow.find(n => n.id === 'demo_pump_w1');
const w2 = flow.find(n => n.id === 'demo_pump_w2');
if (w1 && w1.wires[2] && w1.wires[2].includes('demo_mgc_west')) {
console.log('PASS: W1 port 2 wired to MGC');
} else {
console.log('FAIL: W1 port 2 not wired to MGC, got:', w1?.wires?.[2]);
errors++;
}
if (w2 && w2.wires[2] && w2.wires[2].includes('demo_mgc_west')) {
console.log('PASS: W2 port 2 wired to MGC');
} else {
console.log('FAIL: W2 port 2 not wired to MGC, got:', w2?.wires?.[2]);
errors++;
}
if (mgc && mgc.wires[2] && mgc.wires[2].includes('demo_ps_west')) {
console.log('PASS: MGC port 2 wired to PS West');
} else {
console.log('FAIL: MGC port 2 not wired to PS West');
errors++;
}
// Check PS outputs wire to level-to-pressure functions
const psWest = flow.find(n => n.id === 'demo_ps_west');
if (psWest && psWest.wires[0] && psWest.wires[0].includes('demo_fn_level_to_pressure_w')) {
console.log('PASS: PS West port 0 wired to level-to-pressure function');
} else {
console.log('FAIL: PS West port 0 missing level-to-pressure wire');
errors++;
}
console.log('\n=== RESULT ===');
if (errors === 0) {
console.log('ALL CHECKS PASSED');
} else {
console.log(`${errors} FAILURE(S)`);
process.exit(1);
}
})().catch(err => {
console.error('Failed to connect to Node-RED:', err.message);
process.exit(1);
});

View File

@@ -1,78 +0,0 @@
#!/usr/bin/env node
/**
* Runtime smoke test: connect to Node-RED WebSocket debug and verify
* that key nodes are producing output within a timeout period.
*/
const http = require('http');
const TIMEOUT_MS = 15000;
const NR_URL = 'http://localhost:1880';
async function fetchJSON(url) {
return new Promise((resolve, reject) => {
http.get(url, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks))); }
catch (e) { reject(new Error(`Parse error from ${url}: ${e.message}`)); }
});
}).on('error', reject);
});
}
(async () => {
const errors = [];
// REST-based checks: verify Node-RED is healthy
console.log('=== Runtime Health Checks ===');
try {
const settings = await fetchJSON(`${NR_URL}/settings`);
console.log('PASS: Node-RED is responding, version:', settings.editorTheme ? 'custom' : 'default');
} catch (e) {
console.log('FAIL: Node-RED not responding:', e.message);
errors.push('Node-RED not responding');
}
// Check that flows are loaded
try {
const flows = await fetchJSON(`${NR_URL}/flows`);
const wwtp = flows.filter(n => n.z === 'demo_tab_wwtp');
if (wwtp.length > 50) {
console.log(`PASS: ${wwtp.length} nodes loaded on WWTP tab`);
} else {
console.log(`FAIL: Only ${wwtp.length} nodes on WWTP tab (expected >50)`);
errors.push('Too few nodes');
}
} catch (e) {
console.log('FAIL: Cannot read flows:', e.message);
errors.push('Cannot read flows');
}
// Check inject nodes are running (they have repeat timers)
try {
const flows = await fetchJSON(`${NR_URL}/flows`);
const injects = flows.filter(n => n.type === 'inject' && n.repeat && n.z === 'demo_tab_wwtp');
console.log(`PASS: ${injects.length} inject nodes with timers on WWTP tab`);
// Verify the q_in inject nodes are still there
const qinInjects = injects.filter(n => n.id.includes('_flow') || n.id.includes('_tick'));
console.log(`PASS: ${qinInjects.length} q_in/tick inject timers active`);
} catch (e) {
console.log('FAIL: Cannot check inject nodes:', e.message);
errors.push('Cannot check inject nodes');
}
console.log('\n=== RESULT ===');
if (errors.length === 0) {
console.log('ALL RUNTIME CHECKS PASSED');
} else {
console.log(`${errors.length} FAILURE(S):`, errors.join(', '));
process.exit(1);
}
})().catch(err => {
console.error('Runtime check failed:', err.message);
process.exit(1);
});

View File

@@ -1,294 +0,0 @@
#!/usr/bin/env node
/**
* Comprehensive WWTP Demo Test Suite
*
* Tests:
* 1. Deploy succeeds
* 2. All nodes healthy (no errors)
* 3. PS volumes above safety threshold after calibration
* 4. q_in flowing to all PSs (volume rising)
* 5. Measurement simulators producing values
* 6. MGC pressure handling working
* 7. No persistent safety triggers
* 8. Level-based control (PS West) stays idle at low level
* 9. Flow-based control (PS North) responds to flow
* 10. PS output format correct
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const NR_URL = 'http://localhost:1880';
const FLOW_FILE = path.join(__dirname, '..', 'docker', 'demo-flow.json');
let passed = 0;
let failed = 0;
let warnings = 0;
function test(name, condition, detail) {
if (condition) {
console.log(` ✅ PASS: ${name}${detail ? ' — ' + detail : ''}`);
passed++;
} else {
console.log(` ❌ FAIL: ${name}${detail ? ' — ' + detail : ''}`);
failed++;
}
}
function warn(name, detail) {
console.log(` ⚠️ WARN: ${name}${detail ? ' — ' + detail : ''}`);
warnings++;
}
function httpReq(method, urlPath, body) {
return new Promise((resolve, reject) => {
const parsed = new URL(NR_URL + urlPath);
const opts = {
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname,
method,
headers: { 'Content-Type': 'application/json', 'Node-RED-Deployment-Type': 'full' },
};
if (body) opts.headers['Content-Length'] = Buffer.byteLength(JSON.stringify(body));
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }));
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
function getLogs(since) {
try {
return execSync(`docker logs evolv-nodered --since ${since} 2>&1`, {
encoding: 'utf8', timeout: 5000,
});
} catch (e) { return ''; }
}
function fetchJSON(url) {
return new Promise((resolve, reject) => {
http.get(url, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks))); }
catch (e) { reject(e); }
});
}).on('error', reject);
});
}
(async () => {
console.log('═══════════════════════════════════════');
console.log(' WWTP Demo Flow — Comprehensive Test');
console.log('═══════════════════════════════════════\n');
// ==========================================================
console.log('1. DEPLOYMENT');
console.log('─────────────');
const flow = JSON.parse(fs.readFileSync(FLOW_FILE, 'utf8'));
test('Flow file loads', flow.length > 0, `${flow.length} nodes`);
const deployTime = new Date().toISOString();
const res = await httpReq('POST', '/flows', flow);
test('Deploy succeeds', res.status === 204 || res.status === 200, `HTTP ${res.status}`);
// Wait for init + calibration
console.log(' Waiting 5s for initialization...');
await new Promise((r) => setTimeout(r, 5000));
// Check for errors in logs
const initLogs = getLogs(deployTime);
const initErrors = initLogs.split('\n').filter((l) => l.includes('[ERROR]') || l.includes('Error'));
test('No initialization errors', initErrors.length === 0,
initErrors.length > 0 ? initErrors.slice(0, 3).join('; ') : 'clean');
// ==========================================================
console.log('\n2. NODE INVENTORY');
console.log('─────────────────');
const flows = await fetchJSON(NR_URL + '/flows');
const processTabs = ['demo_tab_wwtp', 'demo_tab_ps_west', 'demo_tab_ps_north', 'demo_tab_ps_south', 'demo_tab_treatment'];
const wwtp = flows.filter((n) => processTabs.includes(n.z));
const byType = {};
wwtp.forEach((n) => {
if (!n.type || n.type === 'tab' || n.type === 'comment') return;
byType[n.type] = (byType[n.type] || 0) + 1;
});
test('Has pumping stations', (byType['pumpingStation'] || 0) === 3, `${byType['pumpingStation'] || 0} PS nodes`);
test('Has rotating machines', (byType['rotatingMachine'] || 0) === 5, `${byType['rotatingMachine'] || 0} pumps`);
test('Has measurements', (byType['measurement'] || 0) >= 15, `${byType['measurement'] || 0} measurement nodes`);
test('Has reactor', (byType['reactor'] || 0) === 1, `${byType['reactor'] || 0} reactor`);
test('Has machineGroupControl', (byType['machineGroupControl'] || 0) >= 1, `${byType['machineGroupControl'] || 0} MGC`);
test('Has inject nodes', (byType['inject'] || 0) >= 10, `${byType['inject'] || 0} injects`);
console.log(` Node types: ${JSON.stringify(byType)}`);
// ==========================================================
console.log('\n3. PS CONFIGURATION');
console.log('───────────────────');
const pss = flows.filter((n) => n.type === 'pumpingStation');
pss.forEach((ps) => {
const vol = Number(ps.basinVolume);
const h = Number(ps.basinHeight);
const hOut = Number(ps.heightOutlet);
const sa = vol / h;
const minVol = hOut * sa;
test(`${ps.name} basin config valid`, vol > 0 && h > 0 && hOut >= 0, `vol=${vol} h=${h} hOut=${hOut}`);
test(`${ps.name} has safety enabled`, ps.enableDryRunProtection === true || ps.enableDryRunProtection === 'true');
});
// Check calibration nodes exist
const calibNodes = flows.filter((n) => n.id && n.id.startsWith('demo_inj_calib_'));
test('Calibration inject nodes exist', calibNodes.length === 3, `${calibNodes.length} calibration nodes`);
// ==========================================================
console.log('\n4. MEASUREMENT SIMULATORS');
console.log('─────────────────────────');
const measurements = flows.filter((n) => n.type === 'measurement' && processTabs.includes(n.z));
const simEnabled = measurements.filter((n) => n.simulator === true || n.simulator === 'true');
test('Measurement simulators enabled', simEnabled.length >= 10, `${simEnabled.length} of ${measurements.length} have sim=true`);
// List measurement nodes
measurements.forEach((m) => {
const sim = m.simulator === true || m.simulator === 'true';
const range = `[${m.o_min}-${m.o_max}] ${m.unit}`;
if (!sim && !m.id.includes('level') && !m.id.includes('pt_')) {
warn(`${m.name || m.id} sim=${sim}`, `range ${range}`);
}
});
// ==========================================================
console.log('\n5. PUMP CONFIGURATION');
console.log('─────────────────────');
const pumps = flows.filter((n) => n.type === 'rotatingMachine' && processTabs.includes(n.z));
pumps.forEach((p) => {
test(`${p.name} has model`, !!p.model, p.model);
test(`${p.name} supplier lowercase`, p.supplier === 'hidrostal', `supplier="${p.supplier}"`);
});
// ==========================================================
console.log('\n6. PRESSURE MEASUREMENTS');
console.log('────────────────────────');
const pts = flows.filter((n) => n.type === 'measurement' && n.id && n.id.includes('_pt_'));
test('6 pressure transmitters', pts.length === 6, `found ${pts.length}`);
pts.forEach((pt) => {
const range = `${pt.o_min}-${pt.o_max} ${pt.unit}`;
const sim = pt.simulator === true || pt.simulator === 'true';
const pos = pt.positionVsParent;
test(`${pt.name} valid`, pt.assetType === 'pressure', `pos=${pos} sim=${sim} range=${range}`);
// Check reasonable pressure ranges (not 0-5000)
if (pos === 'downstream' || pos === 'Downstream') {
test(`${pt.name} realistic range`, Number(pt.o_max) <= 2000, `o_max=${pt.o_max} (should be <=2000)`);
}
});
// ==========================================================
console.log('\n7. RUNTIME BEHAVIOR (30s observation)');
console.log('─────────────────────────────────────');
const obsStart = new Date().toISOString();
// Wait 30 seconds and observe
console.log(' Observing for 30 seconds...');
await new Promise((r) => setTimeout(r, 30000));
const obsLogs = getLogs(obsStart);
const obsLines = obsLogs.split('\n');
// Count message types
const safetyLines = obsLines.filter((l) => l.includes('Safe guard'));
const errorLines = obsLines.filter((l) => l.includes('[ERROR]'));
const monitorLines = obsLines.filter((l) => l.includes('[function:Monitor'));
test('No safety triggers in 30s', safetyLines.length === 0, `${safetyLines.length} triggers`);
test('No errors in 30s', errorLines.length === 0,
errorLines.length > 0 ? errorLines[0].substring(0, 100) : 'clean');
test('Monitor nodes producing data', monitorLines.length > 0, `${monitorLines.length} monitor lines`);
// Parse monitoring data
if (monitorLines.length > 0) {
console.log('\n Monitor data:');
monitorLines.forEach((l) => {
const clean = l.replace(/^\[WARN\] -> /, ' ');
console.log(' ' + clean.trim().substring(0, 150));
});
// Check volume per PS
const psVolumes = {};
monitorLines.forEach((l) => {
const psMatch = l.match(/Monitor (PS \w+)/);
const volMatch = l.match(/vol=([\d.]+)m3/);
if (psMatch && volMatch) {
const ps = psMatch[1];
if (!psVolumes[ps]) psVolumes[ps] = [];
psVolumes[ps].push(parseFloat(volMatch[1]));
}
});
Object.entries(psVolumes).forEach(([ps, vols]) => {
const first = vols[0];
const last = vols[vols.length - 1];
test(`${ps} volume above 0`, first > 0, `vol=${first.toFixed(1)} m3`);
test(`${ps} volume reasonable`, first < 1000, `vol=${first.toFixed(1)} m3`);
if (vols.length >= 2) {
const trend = last - first;
test(`${ps} volume stable/rising`, trend >= -0.5, `${first.toFixed(1)}${last.toFixed(1)} m3 (${trend >= 0 ? '+' : ''}${trend.toFixed(2)})`);
}
});
} else {
warn('No monitor data', 'monitoring function nodes may not have fired yet');
}
// ==========================================================
console.log('\n8. WIRING INTEGRITY');
console.log('───────────────────');
// Check all PS have q_in inject
pss.forEach((ps) => {
const qinFn = flows.find((n) => n.wires && n.wires.flat && n.wires.flat().includes(ps.id) && n.type === 'function');
test(`${ps.name} has q_in source`, !!qinFn, qinFn ? qinFn.name : 'none');
});
// Check all pumps have pressure measurements (RAS pump has flow sensor instead)
pumps.forEach((p) => {
const childSensors = flows.filter((n) => n.type === 'measurement' && n.wires && n.wires[2] && n.wires[2].includes(p.id));
const isRAS = p.id === 'demo_pump_ras';
const minSensors = isRAS ? 1 : 2;
test(`${p.name} has ${isRAS ? 'sensors' : 'pressure PTs'}`, childSensors.length >= minSensors,
`${childSensors.length} ${isRAS ? 'sensors' : 'PTs'} (${childSensors.map((pt) => pt.positionVsParent).join(', ')})`);
});
// ==========================================================
console.log('\n═══════════════════════════════════════');
console.log(` Results: ${passed} passed, ${failed} failed, ${warnings} warnings`);
console.log('═══════════════════════════════════════');
if (failed > 0) {
console.log('\n ❌ SOME TESTS FAILED');
process.exit(1);
} else if (warnings > 0) {
console.log('\n ⚠️ ALL TESTS PASSED (with warnings)');
} else {
console.log('\n ✅ ALL TESTS PASSED');
}
})().catch((err) => {
console.error('Test suite failed:', err);
process.exit(1);
});

View File

@@ -1,217 +0,0 @@
#!/usr/bin/env node
/**
* Deploy the demo flow fresh and trace the first 60 seconds of behavior.
* Captures: container logs, PS volume evolution, flow events.
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const NR_URL = 'http://localhost:1880';
const FLOW_FILE = path.join(__dirname, '..', 'docker', 'demo-flow.json');
const TRACE_SECONDS = 45;
function httpReq(method, urlPath, body) {
return new Promise((resolve, reject) => {
const parsed = new URL(NR_URL + urlPath);
const opts = {
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname,
method,
headers: {
'Content-Type': 'application/json',
'Node-RED-Deployment-Type': 'full',
},
};
if (body) {
const buf = Buffer.from(JSON.stringify(body));
opts.headers['Content-Length'] = buf.length;
}
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString();
resolve({ status: res.statusCode, body: text });
});
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
function getLogs(since) {
try {
// Get ALL logs since our timestamp
const cmd = `docker logs evolv-nodered --since ${since} 2>&1`;
return execSync(cmd, { encoding: 'utf8', timeout: 5000 });
} catch (e) {
return 'Log error: ' + e.message;
}
}
(async () => {
console.log('=== Deploy & Trace ===');
console.log('Loading flow from', FLOW_FILE);
const flow = JSON.parse(fs.readFileSync(FLOW_FILE, 'utf8'));
console.log(`Flow has ${flow.length} nodes`);
// Deploy
const deployTime = new Date().toISOString();
console.log(`\nDeploying at ${deployTime}...`);
const res = await httpReq('POST', '/flows', flow);
console.log(`Deploy response: ${res.status}`);
if (res.status !== 204 && res.status !== 200) {
console.error('Deploy failed:', res.body);
process.exit(1);
}
// Wait 3 seconds for initial setup
console.log('Waiting 3s for init...\n');
await new Promise((r) => setTimeout(r, 3000));
// Trace loop
const traceStart = Date.now();
const volumeHistory = [];
let lastLogPos = 0;
for (let i = 0; i < Math.ceil(TRACE_SECONDS / 3); i++) {
const elapsed = ((Date.now() - traceStart) / 1000).toFixed(1);
// Get new logs since deploy
const logs = getLogs(deployTime);
const newLines = logs.split('\n').slice(lastLogPos);
lastLogPos = logs.split('\n').length;
// Parse interesting log lines
const safeGuards = [];
const pressureChanges = [];
const modeChanges = [];
const stateChanges = [];
const other = [];
newLines.forEach((line) => {
if (!line.trim()) return;
const volMatch = line.match(/vol=([-\d.]+) m3.*remainingTime=([\w.]+)/);
if (volMatch) {
safeGuards.push({ vol: parseFloat(volMatch[1]), remaining: volMatch[2] });
return;
}
if (line.includes('Pressure change detected')) {
pressureChanges.push(1);
return;
}
if (line.includes('Mode changed') || line.includes('setMode') || line.includes('Control mode')) {
modeChanges.push(line.trim().substring(0, 200));
return;
}
if (line.includes('machine state') || line.includes('State:') || line.includes('startup') || line.includes('shutdown')) {
stateChanges.push(line.trim().substring(0, 200));
return;
}
if (line.includes('q_in') || line.includes('netflow') || line.includes('Volume') ||
line.includes('Height') || line.includes('Level') || line.includes('Controllevel')) {
other.push(line.trim().substring(0, 200));
return;
}
});
console.log(`--- t=${elapsed}s ---`);
if (safeGuards.length > 0) {
const latest = safeGuards[safeGuards.length - 1];
const first = safeGuards[0];
console.log(` SAFETY: ${safeGuards.length} triggers, vol: ${first.vol}${latest.vol} m3, remaining: ${latest.remaining}s`);
volumeHistory.push({ t: parseFloat(elapsed), vol: latest.vol });
} else {
console.log(' SAFETY: none (good)');
}
if (pressureChanges.length > 0) {
console.log(` PRESSURE: ${pressureChanges.length} changes`);
}
if (modeChanges.length > 0) {
modeChanges.forEach((m) => console.log(` MODE: ${m}`));
}
if (stateChanges.length > 0) {
stateChanges.slice(-5).forEach((s) => console.log(` STATE: ${s}`));
}
if (other.length > 0) {
other.slice(-5).forEach((o) => console.log(` INFO: ${o}`));
}
console.log('');
await new Promise((r) => setTimeout(r, 3000));
}
// Final analysis
console.log('\n=== Volume Trajectory ===');
volumeHistory.forEach((v) => {
const bar = '#'.repeat(Math.max(0, Math.round(v.vol / 2)));
console.log(` t=${String(v.t).padStart(5)}s: ${String(v.vol.toFixed(2)).padStart(8)} m3 ${bar}`);
});
if (volumeHistory.length >= 2) {
const first = volumeHistory[0];
const last = volumeHistory[volumeHistory.length - 1];
const dt = last.t - first.t;
const dv = last.vol - first.vol;
const rate = dt > 0 ? (dv / dt * 3600).toFixed(1) : 'N/A';
console.log(`\n Rate: ${rate} m3/h (${dv > 0 ? 'FILLING' : 'DRAINING'})`);
}
// Get ALL logs for comprehensive analysis
console.log('\n=== Full Log Analysis ===');
const allLogs = getLogs(deployTime);
const allLines = allLogs.split('\n');
// Count different message types
const counts = { safety: 0, pressure: 0, mode: 0, state: 0, error: 0, warn: 0, flow: 0 };
allLines.forEach((l) => {
if (l.includes('Safe guard')) counts.safety++;
if (l.includes('Pressure change')) counts.pressure++;
if (l.includes('Mode') || l.includes('mode')) counts.mode++;
if (l.includes('startup') || l.includes('shutdown') || l.includes('machine state')) counts.state++;
if (l.includes('[ERROR]') || l.includes('Error')) counts.error++;
if (l.includes('[WARN]')) counts.warn++;
if (l.includes('netflow') || l.includes('q_in') || l.includes('flow')) counts.flow++;
});
console.log('Message counts:', JSON.stringify(counts, null, 2));
// Print errors
const errors = allLines.filter((l) => l.includes('[ERROR]') || l.includes('Error'));
if (errors.length > 0) {
console.log('\nErrors:');
errors.slice(0, 20).forEach((e) => console.log(' ' + e.trim().substring(0, 200)));
}
// Print first few non-pressure, non-safety lines
console.log('\nKey events (first 30):');
let keyCount = 0;
allLines.forEach((l) => {
if (keyCount >= 30) return;
if (l.includes('Pressure change detected')) return;
if (l.includes('Safe guard triggered')) return;
if (!l.trim()) return;
console.log(' ' + l.trim().substring(0, 200));
keyCount++;
});
})().catch((err) => {
console.error('Failed:', err);
process.exit(1);
});

View File

@@ -1,36 +0,0 @@
#!/usr/bin/env node
/**
* Fix asset selection in demo-flow.json so editor dropdowns correctly
* pre-select the configured supplier/type/model when a node is opened.
*
* Issues fixed:
* 1. Pump nodes: supplier "Hidrostal" → "hidrostal" (matches machine.json id)
* 2. demo_meas_flow: assetType "flow-electromagnetic" → "flow" (matches measurement.json type id)
*/
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'));
let changes = 0;
flow.forEach(node => {
// Fix 1: Pump supplier id mismatch
if (node.type === 'rotatingMachine' && node.supplier === 'Hidrostal') {
node.supplier = 'hidrostal';
changes++;
console.log(`Fixed pump ${node.id}: supplier "Hidrostal" → "hidrostal"`);
}
// Fix 2: Standardize flow measurement assetType
if (node.type === 'measurement' && node.assetType === 'flow-electromagnetic') {
node.assetType = 'flow';
changes++;
console.log(`Fixed ${node.id}: assetType "flow-electromagnetic" → "flow"`);
}
});
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n');
console.log(`\nDone. ${changes} node(s) updated.`);

View File

@@ -1,243 +0,0 @@
#!/usr/bin/env node
/**
* Fix display issues:
* 1. Set positionIcon on all nodes based on positionVsParent
* 2. Switch reactor from CSTR to PFR with proper length/resolution
* 3. Add missing default fields to all dashboard widgets (gauges, sliders, button-groups)
*/
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);
// =============================================
// FIX 1: positionIcon on all process nodes
// =============================================
// Icon mapping from physicalPosition.js
const positionIconMap = {
'upstream': '→',
'atEquipment': '⊥',
'downstream': '←',
};
let iconFixed = 0;
for (const node of flow) {
if (node.positionVsParent !== undefined && node.positionVsParent !== '') {
const icon = positionIconMap[node.positionVsParent];
if (icon && node.positionIcon !== icon) {
node.positionIcon = icon;
iconFixed++;
}
}
// Also ensure positionIcon has a fallback if positionVsParent is set
if (node.positionVsParent && !node.positionIcon) {
node.positionIcon = positionIconMap[node.positionVsParent] || '⊥';
iconFixed++;
}
}
console.log(`Fixed positionIcon on ${iconFixed} nodes`);
// =============================================
// FIX 2: Switch reactor from CSTR to PFR
// =============================================
const reactor = byId('demo_reactor');
if (reactor) {
reactor.reactor_type = 'PFR';
reactor.length = 50; // 50m plug flow reactor
reactor.resolution_L = 10; // 10 slices for spatial resolution
reactor.alpha = 0; // Danckwerts BC (dispersive flow, more realistic)
console.log(`Switched reactor to PFR: length=${reactor.length}m, resolution=${reactor.resolution_L} slices`);
// Update influent measurements with positions along the reactor
// FT-001 at inlet (position 0), DO-001 at 1/3, NH4-001 at 2/3
const measFlow = byId('demo_meas_flow');
if (measFlow) {
measFlow.hasDistance = true;
measFlow.distance = 0; // at inlet
measFlow.distanceUnit = 'm';
measFlow.distanceDescription = 'reactor inlet';
measFlow.positionVsParent = 'upstream';
measFlow.positionIcon = '→';
console.log(' FT-001 positioned at reactor inlet (0m)');
}
const measDo = byId('demo_meas_do');
if (measDo) {
measDo.hasDistance = true;
measDo.distance = 15; // 15m along the reactor (30% of length)
measDo.distanceUnit = 'm';
measDo.distanceDescription = 'aeration zone';
measDo.positionVsParent = 'atEquipment';
measDo.positionIcon = '⊥';
console.log(' DO-001 positioned at 15m (aeration zone)');
}
const measNh4 = byId('demo_meas_nh4');
if (measNh4) {
measNh4.hasDistance = true;
measNh4.distance = 35; // 35m along the reactor (70% of length)
measNh4.distanceUnit = 'm';
measNh4.distanceDescription = 'post-aeration zone';
measNh4.positionVsParent = 'atEquipment';
measNh4.positionIcon = '⊥';
console.log(' NH4-001 positioned at 35m (post-aeration zone)');
}
}
// =============================================
// FIX 3: Add missing defaults to dashboard widgets
// =============================================
// --- ui-gauge: add missing fields ---
const gaugeDefaults = {
value: 'payload',
valueType: 'msg',
sizeThickness: 16,
sizeGap: 4,
sizeKeyThickness: 8,
styleRounded: true,
styleGlow: false,
alwaysShowTitle: false,
floatingTitlePosition: 'top-left',
icon: '',
};
let gaugeFixed = 0;
for (const node of flow) {
if (node.type !== 'ui-gauge') continue;
let changed = false;
for (const [key, defaultVal] of Object.entries(gaugeDefaults)) {
if (node[key] === undefined) {
node[key] = defaultVal;
changed = true;
}
}
// Ensure className exists
if (node.className === undefined) node.className = '';
// Ensure outputs (gauges have 1 output in newer versions)
if (changed) gaugeFixed++;
}
console.log(`Fixed ${gaugeFixed} ui-gauge nodes with missing defaults`);
// --- ui-button-group: add missing fields ---
const buttonGroupDefaults = {
rounded: true,
useThemeColors: true,
topic: 'topic',
topicType: 'msg',
className: '',
};
let bgFixed = 0;
for (const node of flow) {
if (node.type !== 'ui-button-group') continue;
let changed = false;
for (const [key, defaultVal] of Object.entries(buttonGroupDefaults)) {
if (node[key] === undefined) {
node[key] = defaultVal;
changed = true;
}
}
// Ensure options have valueType
if (node.options && Array.isArray(node.options)) {
for (const opt of node.options) {
if (!opt.valueType) opt.valueType = 'str';
}
}
if (changed) bgFixed++;
}
console.log(`Fixed ${bgFixed} ui-button-group nodes with missing defaults`);
// --- ui-slider: add missing fields ---
const sliderDefaults = {
topic: 'topic',
topicType: 'msg',
thumbLabel: true,
showTicks: 'always',
className: '',
iconPrepend: '',
iconAppend: '',
color: '',
colorTrack: '',
colorThumb: '',
showTextField: false,
};
let sliderFixed = 0;
for (const node of flow) {
if (node.type !== 'ui-slider') continue;
let changed = false;
for (const [key, defaultVal] of Object.entries(sliderDefaults)) {
if (node[key] === undefined) {
node[key] = defaultVal;
changed = true;
}
}
if (changed) sliderFixed++;
}
console.log(`Fixed ${sliderFixed} ui-slider nodes with missing defaults`);
// --- ui-chart: add missing fields ---
const chartDefaults = {
className: '',
};
let chartFixed = 0;
for (const node of flow) {
if (node.type !== 'ui-chart') continue;
let changed = false;
for (const [key, defaultVal] of Object.entries(chartDefaults)) {
if (node[key] === undefined) {
node[key] = defaultVal;
changed = true;
}
}
if (changed) chartFixed++;
}
console.log(`Fixed ${chartFixed} ui-chart nodes with missing defaults`);
// --- ui-template: add missing fields ---
for (const node of flow) {
if (node.type !== 'ui-template') continue;
if (node.templateScope === undefined) node.templateScope = 'local';
if (node.className === undefined) node.className = '';
}
// --- ui-text: add missing fields ---
for (const node of flow) {
if (node.type !== 'ui-text') continue;
if (node.className === undefined) node.className = '';
}
// =============================================
// 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 (issues === 0) console.log('All wire references valid ✓');
// List all nodes with positionIcon to verify
console.log('\nNodes with positionIcon:');
for (const n of flow) {
if (n.positionIcon) {
console.log(` ${n.positionIcon} ${n.name || n.id} (${n.positionVsParent})`);
}
}
// Write
fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n');
console.log(`\nWrote ${FLOW_PATH} (${flow.length} nodes)`);

View File

@@ -1,154 +0,0 @@
#!/usr/bin/env node
/**
* Fix layout of demo-flow.json so nodes are nicely grouped and don't overlap.
*
* Layout structure (on demo_tab_wwtp):
*
* Row 1 (y=40-300): PS West section (comment, mode injects, pumps, MGC, PS, q_in sim)
* Row 2 (y=340-500): PS North section
* Row 3 (y=520-680): PS South section
* Row 4 (y=720-920): Biological Treatment (measurements, reactor, settler, monster)
* Row 5 (y=960-1120): Pressure Measurements section
* Row 6 (y=1140-1440): Effluent measurements
* Row 7 (y=1460+): Telemetry & Dashboard API
*
* Column layout:
* x=140: Inject nodes (left)
* x=370: Function nodes
* x=580: Intermediate nodes (measurements feeding other nodes)
* x=700: Main equipment nodes (PS, pumps, measurement nodes)
* x=935: Link out nodes
* x=1050+: Right side (reactor, settler, telemetry)
*/
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'));
function setPos(id, x, y) {
const node = flow.find(n => n.id === id);
if (node) {
node.x = x;
node.y = y;
} else {
console.warn('Layout: node not found:', id);
}
}
// === PS West section (y: 40-300) ===
setPos('demo_comment_ps', 340, 40);
// Mode + q_in injects (left column)
setPos('demo_inj_w1_mode', 140, 80);
setPos('demo_inj_w2_mode', 140, 260);
setPos('demo_inj_west_mode', 140, 160);
setPos('demo_inj_west_flow', 140, 200);
// q_in function node
setPos('demo_fn_west_flow_sim', 370, 200);
// MGC sits between PS and pumps
setPos('demo_pump_w1', 700, 100);
setPos('demo_mgc_west', 700, 180);
setPos('demo_pump_w2', 700, 260);
setPos('demo_ps_west', 940, 180);
// === PS North section (y: 340-500) ===
setPos('demo_comment_ps_north', 330, 340);
setPos('demo_inj_n1_mode', 140, 380);
setPos('demo_inj_north_mode', 140, 420);
setPos('demo_inj_north_flow', 140, 460);
setPos('demo_fn_north_flow_sim', 370, 460);
// North outflow measurement
setPos('demo_comment_north_outflow', 200, 500);
setPos('demo_meas_ft_n1', 580, 500);
setPos('demo_pump_n1', 700, 400);
setPos('demo_ps_north', 940, 440);
// === PS South section (y: 540-680) ===
setPos('demo_comment_ps_south', 320, 540);
setPos('demo_inj_s1_mode', 140, 580);
setPos('demo_inj_south_mode', 140, 620);
setPos('demo_inj_south_flow', 140, 660);
setPos('demo_fn_south_flow_sim', 370, 660);
setPos('demo_pump_s1', 700, 580);
setPos('demo_ps_south', 940, 620);
// === Biological Treatment (y: 720-920) ===
setPos('demo_comment_treatment', 200, 720);
setPos('demo_meas_flow', 700, 760);
setPos('demo_meas_do', 700, 820);
setPos('demo_meas_nh4', 700, 880);
setPos('demo_reactor', 1100, 820);
setPos('demo_inj_reactor_tick', 900, 760);
setPos('demo_settler', 1100, 920);
setPos('demo_monster', 1100, 1000);
setPos('demo_inj_monster_flow', 850, 1000);
setPos('demo_fn_monster_flow', 930, 1040);
// === Pressure Measurements (y: 960-1120) — new section ===
setPos('demo_comment_pressure', 320, 960);
// West pressure (grouped together)
setPos('demo_fn_level_to_pressure_w', 370, 1000);
setPos('demo_meas_pt_w_up', 580, 1000);
setPos('demo_meas_pt_w_down', 580, 1040);
// North pressure
setPos('demo_fn_level_to_pressure_n', 370, 1080);
setPos('demo_meas_pt_n_up', 580, 1080);
setPos('demo_meas_pt_n_down', 580, 1120);
// South pressure
setPos('demo_fn_level_to_pressure_s', 370, 1160);
setPos('demo_meas_pt_s_up', 580, 1160);
setPos('demo_meas_pt_s_down', 580, 1200);
// === Effluent Measurements (y: 1240-1520) ===
setPos('demo_comment_effluent_meas', 300, 1240);
setPos('demo_meas_eff_flow', 700, 1280);
setPos('demo_meas_eff_do', 700, 1340);
setPos('demo_meas_eff_nh4', 700, 1400);
setPos('demo_meas_eff_no3', 700, 1460);
setPos('demo_meas_eff_tss', 700, 1520);
// === Telemetry section (right side, y: 40-240) ===
setPos('demo_comment_telemetry', 1300, 40);
setPos('demo_link_influx_out', 1135, 500);
setPos('demo_link_influx_in', 1175, 100);
setPos('demo_fn_influx_convert', 1350, 100);
setPos('demo_http_influx', 1560, 100);
setPos('demo_fn_influx_count', 1740, 100);
// Process debug
setPos('demo_comment_process_out', 1300, 160);
setPos('demo_link_process_out', 1135, 540);
setPos('demo_link_process_in', 1175, 200);
setPos('demo_dbg_process', 1360, 200);
setPos('demo_dbg_registration', 1370, 240);
// Dashboard link outs
setPos('demo_link_ps_west_dash', 1135, 160);
setPos('demo_link_ps_north_dash', 1135, 420);
setPos('demo_link_ps_south_dash', 1135, 600);
setPos('demo_link_reactor_dash', 1300, 820);
setPos('demo_link_meas_dash', 1135, 860);
setPos('demo_link_eff_meas_dash', 1135, 1300);
// Dashboard API
setPos('demo_dashapi', 1100, 1100);
setPos('demo_inj_dashapi', 850, 1100);
setPos('demo_http_grafana', 1300, 1100);
setPos('demo_dbg_grafana', 1500, 1100);
// InfluxDB status link
setPos('demo_link_influx_status_out', 1940, 100);
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n');
console.log('Layout fixed. Deploying...');

View File

@@ -1,103 +0,0 @@
#!/usr/bin/env node
/**
* Add initial volume calibration inject nodes to the demo flow.
*
* Problem: All 3 pumping stations start with initial volume = minVol,
* which is below the dryRun safety threshold. This causes the safety
* guard to trigger immediately on every tick, preventing normal control.
*
* Fix: Add inject nodes that fire once at deploy, sending
* calibratePredictedVolume to each PS with a reasonable starting volume.
*
* PS West: 500m3 basin, startLevel=2.5m → start at 200m3 (level 1.6m)
* Below startLevel, pumps stay off. q_in fills basin naturally.
* PS North: 200m3 basin, flowbased → start at 100m3 (50% fill)
* PS South: 100m3 basin, manual → start at 50m3 (50% fill)
*/
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'));
// Check if calibration nodes already exist
const existingCalib = flow.filter(n => n.id && n.id.startsWith('demo_inj_calib_'));
if (existingCalib.length > 0) {
console.log('Calibration nodes already exist:', existingCalib.map(n => n.id));
console.log('Removing existing calibration nodes first...');
for (const node of existingCalib) {
const idx = flow.findIndex(n => n.id === node.id);
if (idx !== -1) flow.splice(idx, 1);
}
}
// Find the WWTP tab for positioning
const wwtpTab = flow.find(n => n.id === 'demo_tab_wwtp');
if (!wwtpTab) {
console.error('WWTP tab not found!');
process.exit(1);
}
// Calibration configs: { ps_id, name, volume, x, y }
const calibrations = [
{
id: 'demo_inj_calib_west',
name: 'Cal: PS West → 200m3',
target: 'demo_ps_west',
volume: 200,
x: 100, y: 50,
},
{
id: 'demo_inj_calib_north',
name: 'Cal: PS North → 100m3',
target: 'demo_ps_north',
volume: 100,
x: 100, y: 100,
},
{
id: 'demo_inj_calib_south',
name: 'Cal: PS South → 50m3',
target: 'demo_ps_south',
volume: 50,
x: 100, y: 150,
},
];
let added = 0;
calibrations.forEach(cal => {
const injectNode = {
id: cal.id,
type: 'inject',
z: 'demo_tab_wwtp',
name: cal.name,
props: [
{
p: 'payload',
vt: 'num',
},
{
p: 'topic',
vt: 'str',
},
],
repeat: '',
crontab: '',
once: true,
onceDelay: '0.5',
topic: 'calibratePredictedVolume',
payload: String(cal.volume),
payloadType: 'num',
x: cal.x,
y: cal.y,
wires: [[cal.target]],
};
flow.push(injectNode);
added++;
console.log(`Added ${cal.id}: ${cal.name}${cal.target} (${cal.volume} m3)`);
});
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n');
console.log(`\nDone. ${added} calibration node(s) added.`);

View File

@@ -1,25 +0,0 @@
const fs = require("fs");
const flowPath = "docker/demo-flow.json";
const flow = JSON.parse(fs.readFileSync(flowPath, "utf8"));
let newFlow = flow.filter(n => n.id !== "demo_dbg_reactor_inspect");
const reactor = newFlow.find(n => n.id === "demo_reactor");
reactor.wires[0] = reactor.wires[0].filter(id => id !== "demo_dbg_reactor_inspect");
reactor.kla = 70;
newFlow.push({
id: "demo_dbg_reactor_inspect",
type: "function",
z: "demo_tab_treatment",
name: "Reactor State Inspector",
func: 'if (msg.topic !== "GridProfile") return null;\nconst p = msg.payload;\nif (!p || !p.grid) return null;\nconst now = Date.now();\nif (global.get("lastInspect") && now - global.get("lastInspect") < 5000) return null;\nglobal.set("lastInspect", now);\nconst profile = p.grid.map((row, i) => "cell" + i + "(" + (i*p.d_x).toFixed(0) + "m): NH4=" + row[3].toFixed(2) + " DO=" + row[0].toFixed(2));\nnode.warn("GRID: " + profile.join(" | "));\nreturn null;',
outputs: 1,
x: 840,
y: 320,
wires: [[]]
});
reactor.wires[0].push("demo_dbg_reactor_inspect");
fs.writeFileSync(flowPath, JSON.stringify(newFlow, null, 2) + "\n");
console.log("kla:", reactor.kla, "X_A_init:", reactor.X_A_init);

View File

@@ -1,72 +0,0 @@
#!/usr/bin/env node
/**
* Fix downstream pressure simulator ranges and add a monitoring debug node.
*
* Problems found:
* 1. Downstream pressure simulator range 0-5000 mbar is unrealistic.
* Real WWTP system backpressure: 800-1500 mbar (0.8-1.5 bar).
* The pump curve operates in 700-3900 mbar. With upstream ~300 mbar
* (hydrostatic from 3m basin) and downstream at 5000 mbar, the
* pressure differential pushes the curve to extreme predictions.
*
* 2. No way to see runtime state visually. We'll leave visual monitoring
* to the Grafana/dashboard layer, but fix the root cause here.
*
* Fix: Set downstream pressure simulators to realistic ranges:
* - West: o_min=800, o_max=1500, i_min=800, i_max=1500
* - North: o_min=600, o_max=1200, i_min=600, i_max=1200
* - South: o_min=500, o_max=1000, i_min=500, i_max=1000
*
* This keeps pressure differential in ~500-1200 mbar range,
* well within the pump curve (700-3900 mbar).
*/
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'));
let changes = 0;
// Fix downstream pressure simulator ranges
const pressureFixes = {
'demo_meas_pt_w_down': { i_min: 800, i_max: 1500, o_min: 800, o_max: 1500 },
'demo_meas_pt_n_down': { i_min: 600, i_max: 1200, o_min: 600, o_max: 1200 },
'demo_meas_pt_s_down': { i_min: 500, i_max: 1000, o_min: 500, o_max: 1000 },
};
flow.forEach(node => {
const fix = pressureFixes[node.id];
if (fix) {
const old = { i_min: node.i_min, i_max: node.i_max, o_min: node.o_min, o_max: node.o_max };
Object.assign(node, fix);
console.log(`Fixed ${node.id} "${node.name}":`);
console.log(` Was: i=[${old.i_min},${old.i_max}] o=[${old.o_min},${old.o_max}]`);
console.log(` Now: i=[${fix.i_min},${fix.i_max}] o=[${fix.o_min},${fix.o_max}]`);
changes++;
}
});
// Also fix upstream pressure ranges to match realistic hydrostatic range
// Basin level 0-4m → hydrostatic 0-392 mbar → use 0-500 mbar range
const upstreamFixes = {
'demo_meas_pt_w_up': { i_min: 0, i_max: 500, o_min: 0, o_max: 500 },
'demo_meas_pt_n_up': { i_min: 0, i_max: 400, o_min: 0, o_max: 400 },
'demo_meas_pt_s_up': { i_min: 0, i_max: 300, o_min: 0, o_max: 300 },
};
flow.forEach(node => {
const fix = upstreamFixes[node.id];
if (fix) {
const old = { i_min: node.i_min, i_max: node.i_max, o_min: node.o_min, o_max: node.o_max };
Object.assign(node, fix);
console.log(`Fixed ${node.id} "${node.name}":`);
console.log(` Was: i=[${old.i_min},${old.i_max}] o=[${old.o_min},${old.o_max}]`);
console.log(` Now: i=[${fix.i_min},${fix.i_max}] o=[${fix.o_min},${fix.o_max}]`);
changes++;
}
});
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n');
console.log(`\nDone. ${changes} node(s) updated.`);

View File

@@ -1,142 +0,0 @@
#!/usr/bin/env node
/**
* Monitor WWTP system health and process state.
* Captures PS volume, flow rates, pump states, and control actions.
*/
const http = require('http');
const { execSync } = require('child_process');
const NR_URL = 'http://localhost:1880';
const SAMPLE_INTERVAL = 5000;
const NUM_SAMPLES = 20; // 100 seconds
function getLogs(lines = 50) {
try {
return execSync('docker logs evolv-nodered --tail ' + lines + ' 2>&1', {
encoding: 'utf8', timeout: 5000,
});
} catch (e) { return ''; }
}
function parseLogs(logs) {
const result = { safety: [], pressure: 0, control: [], state: [], errors: [], flow: [] };
logs.split('\n').forEach(line => {
if (!line.trim()) return;
const volMatch = line.match(/vol=([-\d.]+) m3.*remainingTime=([\w.]+)/);
if (volMatch) {
result.safety.push({ vol: parseFloat(volMatch[1]), remaining: volMatch[2] });
return;
}
if (line.includes('Pressure change detected')) { result.pressure++; return; }
if (line.includes('Controllevel') || line.includes('flowbased') || line.includes('control applying')) {
result.control.push(line.trim().substring(0, 200));
return;
}
if (line.includes('startup') || line.includes('shutdown') || line.includes('machine state') ||
line.includes('Handling input') || line.includes('execSequence') || line.includes('execsequence')) {
result.state.push(line.trim().substring(0, 200));
return;
}
if (line.includes('[ERROR]') || line.includes('Error')) {
result.errors.push(line.trim().substring(0, 200));
return;
}
if (line.includes('netflow') || line.includes('Height') || line.includes('flow')) {
result.flow.push(line.trim().substring(0, 200));
}
});
return result;
}
(async () => {
console.log('=== WWTP Health Monitor ===');
console.log(`Sampling every ${SAMPLE_INTERVAL/1000}s for ${NUM_SAMPLES * SAMPLE_INTERVAL / 1000}s\n`);
const history = [];
for (let i = 0; i < NUM_SAMPLES; i++) {
const elapsed = (i * SAMPLE_INTERVAL / 1000).toFixed(0);
const logs = getLogs(40);
const parsed = parseLogs(logs);
console.log(`--- Sample ${i+1}/${NUM_SAMPLES} (t=${elapsed}s) ---`);
// Safety status
if (parsed.safety.length > 0) {
const latest = parsed.safety[parsed.safety.length - 1];
console.log(` ⚠️ SAFETY: ${parsed.safety.length} triggers, vol=${latest.vol} m3`);
} else {
console.log(' ✅ SAFETY: OK');
}
// Pressure changes
if (parsed.pressure > 0) {
console.log(` 📊 PRESSURE: ${parsed.pressure} changes (sim active)`);
}
// Control actions
if (parsed.control.length > 0) {
parsed.control.slice(-3).forEach(c => console.log(` 🎛️ CONTROL: ${c}`));
}
// State changes
if (parsed.state.length > 0) {
parsed.state.slice(-3).forEach(s => console.log(` 🔄 STATE: ${s}`));
}
// Flow info
if (parsed.flow.length > 0) {
parsed.flow.slice(-2).forEach(f => console.log(` 💧 FLOW: ${f}`));
}
// Errors
if (parsed.errors.length > 0) {
parsed.errors.forEach(e => console.log(` ❌ ERROR: ${e}`));
}
history.push({
t: parseInt(elapsed),
safety: parsed.safety.length,
pressure: parsed.pressure,
control: parsed.control.length,
state: parsed.state.length,
errors: parsed.errors.length,
});
console.log('');
if (i < NUM_SAMPLES - 1) {
await new Promise(r => setTimeout(r, SAMPLE_INTERVAL));
}
}
// Summary
console.log('\n=== Health Summary ===');
const totalSafety = history.reduce((a, h) => a + h.safety, 0);
const totalErrors = history.reduce((a, h) => a + h.errors, 0);
const totalControl = history.reduce((a, h) => a + h.control, 0);
const totalState = history.reduce((a, h) => a + h.state, 0);
console.log(`Safety triggers: ${totalSafety} ${totalSafety === 0 ? '✅' : '⚠️'}`);
console.log(`Errors: ${totalErrors} ${totalErrors === 0 ? '✅' : '❌'}`);
console.log(`Control actions: ${totalControl}`);
console.log(`State changes: ${totalState}`);
if (totalSafety === 0 && totalErrors === 0) {
console.log('\n🟢 SYSTEM HEALTHY');
} else if (totalErrors > 0) {
console.log('\n🔴 ERRORS DETECTED');
} else {
console.log('\n🟡 SAFETY ACTIVE (may be normal during startup)');
}
})().catch(err => {
console.error('Monitor failed:', err);
process.exit(1);
});

View File

@@ -1,158 +0,0 @@
#!/usr/bin/env node
/**
* Monitor WWTP runtime via Node-RED debug WebSocket and container logs.
* Captures process data every few seconds and displays trends.
*/
const http = require('http');
const { execSync } = require('child_process');
const NR_URL = 'http://localhost:1880';
const SAMPLE_INTERVAL = 5000; // ms
const NUM_SAMPLES = 12; // 60 seconds total
function fetchJSON(url) {
return new Promise((resolve, reject) => {
http.get(url, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks))); }
catch (e) { reject(new Error('Parse: ' + e.message)); }
});
}).on('error', reject);
});
}
function getRecentLogs(lines = 50) {
try {
return execSync('docker logs evolv-nodered --tail ' + lines + ' 2>&1', {
encoding: 'utf8',
timeout: 5000,
});
} catch (e) {
return 'Failed to get logs: ' + e.message;
}
}
function parseSafeGuardLogs(logs) {
const lines = logs.split('\n');
const safeGuards = [];
const pressures = [];
const others = [];
lines.forEach(line => {
const volMatch = line.match(/Safe guard triggered: vol=([-\d.]+) m3/);
if (volMatch) {
safeGuards.push(parseFloat(volMatch[1]));
}
const pressMatch = line.match(/New f =([\d.]+) is constrained/);
if (pressMatch) {
pressures.push(parseFloat(pressMatch[1]));
}
if (line.includes('_controlLevelBased') || line.includes('Mode changed') ||
line.includes('execSequence') || line.includes('startup') ||
line.includes('shutdown') || line.includes('setMode')) {
others.push(line.trim().substring(0, 200));
}
});
return { safeGuards, pressures, others };
}
(async () => {
console.log('=== WWTP Runtime Monitor ===');
console.log('Capturing ' + NUM_SAMPLES + ' samples at ' + (SAMPLE_INTERVAL/1000) + 's intervals\n');
// Wait for nodes to initialize after deploy
console.log('Waiting 10s for nodes to initialize...\n');
await new Promise(r => setTimeout(r, 10000));
for (let i = 0; i < NUM_SAMPLES; i++) {
const elapsed = (i * SAMPLE_INTERVAL / 1000 + 10).toFixed(0);
console.log('--- Sample ' + (i+1) + '/' + NUM_SAMPLES + ' (t=' + elapsed + 's after deploy) ---');
// Capture container logs (last 30 lines since last sample)
const logs = getRecentLogs(30);
const parsed = parseSafeGuardLogs(logs);
if (parsed.safeGuards.length > 0) {
const latest = parsed.safeGuards[parsed.safeGuards.length - 1];
const trend = parsed.safeGuards.length > 1
? (parsed.safeGuards[parsed.safeGuards.length-1] - parsed.safeGuards[0] > 0 ? 'RISING' : 'FALLING')
: 'STABLE';
console.log(' SAFETY: vol=' + latest.toFixed(2) + ' m3 (' + parsed.safeGuards.length + ' triggers, ' + trend + ')');
} else {
console.log(' SAFETY: No safe guard triggers (GOOD)');
}
if (parsed.pressures.length > 0) {
const avg = parsed.pressures.reduce((a,b) => a+b, 0) / parsed.pressures.length;
console.log(' PRESSURE CLAMP: avg f=' + avg.toFixed(0) + ' (' + parsed.pressures.length + ' warnings)');
} else {
console.log(' PRESSURE: No interpolation warnings (GOOD)');
}
if (parsed.others.length > 0) {
console.log(' CONTROL: ' + parsed.others.slice(-3).join('\n '));
}
// Check if there are state change or mode messages
const logLines = logs.split('\n');
const stateChanges = logLines.filter(l =>
l.includes('machine state') || l.includes('State:') ||
l.includes('draining') || l.includes('filling') ||
l.includes('q_in') || l.includes('netFlow')
);
if (stateChanges.length > 0) {
console.log(' STATE: ' + stateChanges.slice(-3).map(s => s.trim().substring(0, 150)).join('\n '));
}
console.log('');
if (i < NUM_SAMPLES - 1) {
await new Promise(r => setTimeout(r, SAMPLE_INTERVAL));
}
}
// Final log dump
console.log('\n=== Final Log Analysis (last 200 lines) ===');
const finalLogs = getRecentLogs(200);
const finalParsed = parseSafeGuardLogs(finalLogs);
console.log('Safe guard triggers: ' + finalParsed.safeGuards.length);
if (finalParsed.safeGuards.length > 0) {
console.log(' First vol: ' + finalParsed.safeGuards[0].toFixed(2) + ' m3');
console.log(' Last vol: ' + finalParsed.safeGuards[finalParsed.safeGuards.length-1].toFixed(2) + ' m3');
const delta = finalParsed.safeGuards[finalParsed.safeGuards.length-1] - finalParsed.safeGuards[0];
console.log(' Delta: ' + (delta > 0 ? '+' : '') + delta.toFixed(2) + ' m3 (' + (delta > 0 ? 'RECOVERING' : 'STILL DRAINING') + ')');
}
console.log('Pressure clamp warnings: ' + finalParsed.pressures.length);
if (finalParsed.pressures.length > 0) {
const min = Math.min(...finalParsed.pressures);
const max = Math.max(...finalParsed.pressures);
console.log(' Range: ' + min.toFixed(0) + ' - ' + max.toFixed(0));
}
console.log('\nControl events: ' + finalParsed.others.length);
finalParsed.others.slice(-10).forEach(l => console.log(' ' + l));
// Overall assessment
console.log('\n=== ASSESSMENT ===');
if (finalParsed.safeGuards.length === 0 && finalParsed.pressures.length === 0) {
console.log('HEALTHY: No safety triggers, no pressure warnings');
} else if (finalParsed.safeGuards.length > 0) {
const trend = finalParsed.safeGuards[finalParsed.safeGuards.length-1] - finalParsed.safeGuards[0];
if (trend > 0) {
console.log('RECOVERING: Volume rising but still negative');
} else {
console.log('CRITICAL: Volume still dropping - control issue persists');
}
} else if (finalParsed.pressures.length > 0) {
console.log('WARNING: Pressure values exceeding curve bounds');
}
})().catch(err => {
console.error('Monitor failed:', err);
process.exit(1);
});

View File

@@ -1,184 +0,0 @@
#!/usr/bin/env node
/**
* Patch demo-flow.json:
* 1. Fix NH4 chart — remove demo_link_meas_dash from new NH4 nodes
* 2. Update parse function — use "NH4 @ Xm" label format
* 3. Reorganize entire treatment tab — logical left-to-right layout
*/
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'));
const find = (id) => flow.find(n => n.id === id);
// ============================================================
// 1. FIX NH4 CHART WIRING
// Remove demo_link_meas_dash from the 4 new NH4 nodes.
// They should only go to process link + NH4 profile link.
// ============================================================
const newNh4Ids = ['demo_meas_nh4_in', 'demo_meas_nh4_a', 'demo_meas_nh4_b', 'demo_meas_nh4_c'];
for (const id of newNh4Ids) {
const n = find(id);
if (n) {
n.wires[0] = n.wires[0].filter(w => w !== 'demo_link_meas_dash');
console.log(` ${id} Port 0 wires: ${JSON.stringify(n.wires[0])}`);
}
}
console.log('1. Fixed: removed demo_link_meas_dash from new NH4 nodes');
// ============================================================
// 2. UPDATE PARSE FUNCTION — "NH4 @ Xm" format
// Also make it generic: read distance from payload metadata
// if available, fall back to topic matching.
// ============================================================
const parseFn = find('demo_fn_nh4_profile_parse');
if (parseFn) {
parseFn.func = `const p = msg.payload || {};
const topic = msg.topic || '';
const now = Date.now();
const val = Number(p.mAbs);
if (!Number.isFinite(val)) return null;
// Build label from distance metadata if available, else match by tag
const dist = p.distance;
const tag = p.assetTagNumber || topic;
let label;
if (dist !== undefined && dist !== null) {
label = 'NH4 @ ' + dist + 'm';
} else if (tag.includes('NH4-IN')) label = 'NH4 @ 0m';
else if (tag.includes('NH4-A')) label = 'NH4 @ 10m';
else if (tag.includes('NH4-B')) label = 'NH4 @ 25m';
else if (tag.includes('NH4-001')) label = 'NH4 @ 35m';
else if (tag.includes('NH4-C')) label = 'NH4 @ 45m';
else label = 'NH4 @ ?m';
return { topic: label, payload: Math.round(val * 100) / 100, timestamp: now };`;
console.log('2. Updated NH4 profile parse function to "NH4 @ Xm" format');
}
// ============================================================
// 3. REORGANIZE TREATMENT TAB LAYOUT
//
// Logical left-to-right process flow:
//
// Col 1 (x=80): Comments / section headers
// Col 2 (x=200): Injects (reactor tick, monster flow)
// Col 3 (x=420): Inlet measurements (flow, DO, NH4 profile)
// Col 4 (x=640): Link outs (meas dash, NH4 profile dash)
// Col 5 (x=820): Reactor
// Col 6 (x=1060): Settler
// Col 7 (x=1280): Effluent measurements
// Col 8 (x=1500): Effluent link outs
//
// Row zones (y):
// Row A (y=40): Section comment
// Row B (y=100-440): Main process: reactor measurements → reactor → settler
// Row C (y=500-700): Effluent measurements (downstream of settler)
// Row D (y=760-900): RAS recycle loop (below main flow)
// Row E (y=960-1120): Merge collection / influent composition
//
// ============================================================
const layout = {
// ── SECTION COMMENT ──
'demo_comment_treatment': { x: 80, y: 40 },
// ── INJECTS ──
'demo_inj_reactor_tick': { x: 200, y: 120 },
'demo_inj_monster_flow': { x: 200, y: 560 },
// ── INLET MEASUREMENTS (column, spaced 60px) ──
'demo_meas_flow': { x: 420, y: 100 }, // FT-001 flow
'demo_meas_do': { x: 420, y: 160 }, // DO-001
'demo_meas_nh4_in': { x: 420, y: 220 }, // NH4-IN 0m
'demo_meas_nh4_a': { x: 420, y: 280 }, // NH4-A 10m
'demo_meas_nh4': { x: 420, y: 340 }, // NH4-001 35m (existing, keep between A & B for distance order — wait, 25m < 35m)
'demo_meas_nh4_b': { x: 420, y: 400 }, // NH4-B 25m
'demo_meas_nh4_c': { x: 420, y: 460 }, // NH4-C 45m
// ── LINK OUTS (from measurements) ──
'demo_link_meas_dash': { x: 640, y: 130 },
'demo_link_nh4_profile_dash': { x: 640, y: 340 },
// ── REACTOR ──
'demo_reactor': { x: 820, y: 220 },
// ── REACTOR LINK OUTS ──
'demo_link_reactor_dash': { x: 1020, y: 180 },
'demo_link_overview_reactor_out': { x: 1020, y: 220 },
// ── SETTLER ──
'demo_settler': { x: 1060, y: 320 },
// ── SHARED LINK OUTS (process + influx) ──
'demo_link_influx_out_treatment': { x: 1020, y: 260 },
'demo_link_process_out_treatment': { x: 1020, y: 300 },
// ── EFFLUENT SECTION ──
'demo_comment_effluent_meas': { x: 80, y: 520 },
'demo_meas_eff_flow': { x: 1280, y: 320 },
'demo_meas_eff_do': { x: 1280, y: 380 },
'demo_meas_eff_nh4': { x: 1280, y: 440 },
'demo_meas_eff_no3': { x: 1280, y: 500 },
'demo_meas_eff_tss': { x: 1280, y: 560 },
'demo_link_eff_meas_dash': { x: 1500, y: 440 },
'demo_link_overview_eff_out': { x: 1500, y: 500 },
// ── MONSTER (downstream of settler, parallel to effluent meas) ──
'demo_monster': { x: 1060, y: 440 },
'demo_fn_monster_flow': { x: 400, y: 560 },
// ── RAS RECYCLE LOOP (below main process) ──
'demo_fn_ras_filter': { x: 1060, y: 760 },
'demo_pump_ras': { x: 1280, y: 760 },
'demo_meas_ft_ras': { x: 1500, y: 760 },
'demo_inj_ras_mode': { x: 1280, y: 820 },
'demo_inj_ras_speed': { x: 1280, y: 880 },
'demo_comment_pressure': { x: 80, y: 740 },
// ── MERGE COLLECTION (bottom section) ──
'demo_comment_merge': { x: 80, y: 960 },
'demo_link_merge_west_in': { x: 100, y: 1000 },
'demo_link_merge_north_in': { x: 100, y: 1060 },
'demo_link_merge_south_in': { x: 100, y: 1120 },
'demo_fn_tag_west': { x: 300, y: 1000 },
'demo_fn_tag_north': { x: 300, y: 1060 },
'demo_fn_tag_south': { x: 300, y: 1120 },
'demo_fn_merge_collect': { x: 520, y: 1060 },
'demo_link_merge_dash': { x: 720, y: 1020 },
'demo_fn_influent_compose': { x: 720, y: 1100 },
};
// Sort NH4 measurements by distance for visual order
// NH4-IN=0m, NH4-A=10m, NH4-B=25m, NH4-001=35m, NH4-C=45m
// Adjust y to be in distance order:
layout['demo_meas_nh4_in'] = { x: 420, y: 220 }; // 0m
layout['demo_meas_nh4_a'] = { x: 420, y: 280 }; // 10m
layout['demo_meas_nh4_b'] = { x: 420, y: 340 }; // 25m
layout['demo_meas_nh4'] = { x: 420, y: 400 }; // 35m
layout['demo_meas_nh4_c'] = { x: 420, y: 460 }; // 45m
let moved = 0;
for (const [id, pos] of Object.entries(layout)) {
const n = find(id);
if (n) {
n.x = pos.x;
n.y = pos.y;
moved++;
} else {
console.warn(` WARN: node ${id} not found`);
}
}
console.log(`3. Repositioned ${moved} nodes on treatment tab`);
// ============================================================
// WRITE OUTPUT
// ============================================================
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n', 'utf8');
console.log(`\nDone. Wrote ${flow.length} nodes to ${flowPath}`);

View File

@@ -1,455 +0,0 @@
#!/usr/bin/env node
/**
* Patch demo-flow.json:
* Phase A: Add 4 NH4 measurement nodes + ui-group + ui-chart
* Phase B: Add influent composer function node + wire merge collector
* Phase C: Fix biomass init on reactor
* Phase D: Add RAS pump, flow sensor, 2 injects, filter function + wiring
*/
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'));
// Helper: find node by id
const findNode = (id) => flow.find(n => n.id === id);
// ============================================================
// PHASE A: Add 4 NH4 measurement nodes + ui-group + ui-chart
// ============================================================
const nh4Measurements = [
{
id: 'demo_meas_nh4_in',
name: 'NH4-IN (Ammonium Inlet)',
uuid: 'nh4-in-001',
assetTagNumber: 'NH4-IN',
distance: 0,
distanceDescription: 'reactor inlet',
y: 280
},
{
id: 'demo_meas_nh4_a',
name: 'NH4-A (Early Aeration)',
uuid: 'nh4-a-001',
assetTagNumber: 'NH4-A',
distance: 10,
distanceDescription: 'early aeration zone',
y: 320
},
{
id: 'demo_meas_nh4_b',
name: 'NH4-B (Mid-Reactor)',
uuid: 'nh4-b-001',
assetTagNumber: 'NH4-B',
distance: 25,
distanceDescription: 'mid-reactor',
y: 360
},
{
id: 'demo_meas_nh4_c',
name: 'NH4-C (Near Outlet)',
uuid: 'nh4-c-001',
assetTagNumber: 'NH4-C',
distance: 45,
distanceDescription: 'near outlet',
y: 400
}
];
for (const m of nh4Measurements) {
flow.push({
id: m.id,
type: 'measurement',
z: 'demo_tab_treatment',
name: m.name,
scaling: true,
i_min: 0,
i_max: 50,
i_offset: 0,
o_min: 0,
o_max: 50,
smooth_method: 'mean',
count: 3,
simulator: true,
uuid: m.uuid,
supplier: 'Hach',
category: 'sensor',
assetType: 'ammonium',
model: 'Amtax-sc',
unit: 'mg/L',
assetTagNumber: m.assetTagNumber,
enableLog: false,
logLevel: 'error',
positionVsParent: 'atEquipment',
x: 400,
y: m.y,
wires: [
['demo_link_meas_dash', 'demo_link_process_out_treatment'],
['demo_link_influx_out_treatment'],
['demo_reactor']
],
positionIcon: '⊥',
hasDistance: true,
distance: m.distance,
distanceUnit: 'm',
distanceDescription: m.distanceDescription
});
}
// NH4 profile ui-group
flow.push({
id: 'demo_ui_grp_nh4_profile',
type: 'ui-group',
name: 'NH4 Profile Along Reactor',
page: 'demo_ui_page_treatment',
width: '6',
height: '1',
order: 6,
showTitle: true,
className: ''
});
// NH4 profile chart
flow.push({
id: 'demo_chart_nh4_profile',
type: 'ui-chart',
z: 'demo_tab_dashboard',
group: 'demo_ui_grp_nh4_profile',
name: 'NH4 Profile',
label: 'NH4 Along Reactor (mg/L)',
order: 1,
width: '6',
height: '5',
chartType: 'line',
category: 'topic',
categoryType: 'msg',
xAxisType: 'time',
yAxisLabel: 'mg/L',
removeOlder: '10',
removeOlderUnit: '60',
action: 'append',
pointShape: 'false',
pointRadius: 0,
interpolation: 'linear',
x: 510,
y: 1060,
wires: [],
showLegend: true,
xAxisProperty: '',
xAxisPropertyType: 'timestamp',
yAxisProperty: 'payload',
yAxisPropertyType: 'msg',
colors: [
'#0094ce',
'#FF7F0E',
'#2CA02C',
'#D62728',
'#A347E1',
'#D62728',
'#FF9896',
'#9467BD',
'#C5B0D5'
],
textColor: ['#aaaaaa'],
textColorDefault: false,
gridColor: ['#333333'],
gridColorDefault: false,
className: ''
});
// Link out + link in for NH4 profile chart
flow.push({
id: 'demo_link_nh4_profile_dash',
type: 'link out',
z: 'demo_tab_treatment',
name: '→ NH4 Profile Dashboard',
mode: 'link',
links: ['demo_link_nh4_profile_dash_in'],
x: 620,
y: 340
});
flow.push({
id: 'demo_link_nh4_profile_dash_in',
type: 'link in',
z: 'demo_tab_dashboard',
name: '← NH4 Profile',
links: ['demo_link_nh4_profile_dash'],
x: 75,
y: 1060,
wires: [['demo_fn_nh4_profile_parse']]
});
// Parse function for NH4 profile chart
flow.push({
id: 'demo_fn_nh4_profile_parse',
type: 'function',
z: 'demo_tab_dashboard',
name: 'Parse NH4 Profile',
func: `const p = msg.payload || {};
const topic = msg.topic || '';
const now = Date.now();
const val = Number(p.mAbs);
if (!Number.isFinite(val)) return null;
let label = topic;
if (topic.includes('NH4-IN')) label = 'NH4-IN (0m)';
else if (topic.includes('NH4-A')) label = 'NH4-A (10m)';
else if (topic.includes('NH4-B')) label = 'NH4-B (25m)';
else if (topic.includes('NH4-001')) label = 'NH4-001 (35m)';
else if (topic.includes('NH4-C')) label = 'NH4-C (45m)';
return { topic: label, payload: Math.round(val * 100) / 100, timestamp: now };`,
outputs: 1,
x: 280,
y: 1060,
wires: [['demo_chart_nh4_profile']]
});
// Wire existing NH4-001 and new NH4 measurements to the profile link out
const existingNh4 = findNode('demo_meas_nh4');
if (existingNh4) {
if (!existingNh4.wires[0].includes('demo_link_nh4_profile_dash')) {
existingNh4.wires[0].push('demo_link_nh4_profile_dash');
}
}
for (const m of nh4Measurements) {
const node = findNode(m.id);
if (node && !node.wires[0].includes('demo_link_nh4_profile_dash')) {
node.wires[0].push('demo_link_nh4_profile_dash');
}
}
console.log('Phase A: Added 4 NH4 measurements + ui-group + chart + wiring');
// ============================================================
// PHASE B: Add influent composer + wire merge collector
// ============================================================
flow.push({
id: 'demo_fn_influent_compose',
type: 'function',
z: 'demo_tab_treatment',
name: 'Influent Composer',
func: `// Convert merge collector output to Fluent messages for reactor
// ASM3: [S_O, S_I, S_S, S_NH, S_N2, S_NO, S_HCO, X_I, X_S, X_H, X_STO, X_A, X_TS]
const p = msg.payload || {};
const MUNICIPAL = [0.5, 30, 200, 40, 0, 0, 5, 25, 150, 30, 0, 0, 200];
const INDUSTRIAL = [0.5, 40, 300, 25, 0, 0, 4, 30, 100, 20, 0, 0, 150];
const RESIDENTIAL = [0.5, 25, 180, 45, 0, 0, 5, 20, 130, 25, 0, 0, 175];
const Fw = (p.west?.netFlow || 0) * 24; // m3/h -> m3/d
const Fn = (p.north?.netFlow || 0) * 24;
const Fs = (p.south?.netFlow || 0) * 24;
const msgs = [];
if (Fw > 0) msgs.push({ topic: 'Fluent', payload: { inlet: 0, F: Fw, C: MUNICIPAL }});
if (Fn > 0) msgs.push({ topic: 'Fluent', payload: { inlet: 1, F: Fn, C: INDUSTRIAL }});
if (Fs > 0) msgs.push({ topic: 'Fluent', payload: { inlet: 2, F: Fs, C: RESIDENTIAL }});
return [msgs];`,
outputs: 1,
x: 480,
y: 1040,
wires: [['demo_reactor']]
});
// Wire merge collector → influent composer (add to existing wires)
const mergeCollect = findNode('demo_fn_merge_collect');
if (mergeCollect) {
if (!mergeCollect.wires[0].includes('demo_fn_influent_compose')) {
mergeCollect.wires[0].push('demo_fn_influent_compose');
}
console.log('Phase B: Wired merge collector → influent composer → reactor');
} else {
console.error('Phase B: ERROR — demo_fn_merge_collect not found!');
}
// ============================================================
// PHASE C: Fix biomass initialization
// ============================================================
const reactor = findNode('demo_reactor');
if (reactor) {
reactor.X_A_init = 300;
reactor.X_H_init = 1500;
reactor.X_TS_init = 2500;
reactor.S_HCO_init = 8;
console.log('Phase C: Updated reactor biomass init values');
} else {
console.error('Phase C: ERROR — demo_reactor not found!');
}
// ============================================================
// PHASE D: Return Activated Sludge
// ============================================================
// D1: RAS pump
flow.push({
id: 'demo_pump_ras',
type: 'rotatingMachine',
z: 'demo_tab_treatment',
name: 'RAS Pump',
speed: '1',
startup: '5',
warmup: '3',
shutdown: '4',
cooldown: '2',
movementMode: 'dynspeed',
machineCurve: '',
uuid: 'pump-ras-001',
supplier: 'hidrostal',
category: 'machine',
assetType: 'pump-centrifugal',
model: 'hidrostal-RAS',
unit: 'm3/h',
enableLog: true,
logLevel: 'info',
positionVsParent: 'downstream',
positionIcon: '←',
hasDistance: false,
distance: 0,
distanceUnit: 'm',
distanceDescription: '',
x: 1000,
y: 380,
wires: [
['demo_link_process_out_treatment'],
['demo_link_influx_out_treatment'],
['demo_settler']
],
curveFlowUnit: 'l/s',
curvePressureUnit: 'mbar',
curvePowerUnit: 'kW'
});
// D2: RAS flow sensor
flow.push({
id: 'demo_meas_ft_ras',
type: 'measurement',
z: 'demo_tab_treatment',
name: 'FT-RAS (RAS Flow)',
scaling: true,
i_min: 20,
i_max: 80,
i_offset: 0,
o_min: 20,
o_max: 80,
smooth_method: 'mean',
count: 3,
simulator: true,
uuid: 'ft-ras-001',
supplier: 'Endress+Hauser',
category: 'sensor',
assetType: 'flow',
model: 'Promag-W400',
unit: 'm3/h',
assetTagNumber: 'FT-RAS',
enableLog: false,
logLevel: 'error',
positionVsParent: 'atEquipment',
positionIcon: '⊥',
hasDistance: false,
distance: 0,
distanceUnit: 'm',
distanceDescription: '',
x: 1200,
y: 380,
wires: [
['demo_link_process_out_treatment'],
['demo_link_influx_out_treatment'],
['demo_pump_ras']
]
});
// D3: Inject to set pump mode
flow.push({
id: 'demo_inj_ras_mode',
type: 'inject',
z: 'demo_tab_treatment',
name: 'RAS → virtualControl',
props: [
{ p: 'topic', vt: 'str' },
{ p: 'payload', vt: 'str' }
],
topic: 'setMode',
payload: 'virtualControl',
payloadType: 'str',
once: true,
onceDelay: '3',
x: 1000,
y: 440,
wires: [['demo_pump_ras']],
repeatType: 'none',
crontab: '',
repeat: ''
});
// D3: Inject to set pump speed
flow.push({
id: 'demo_inj_ras_speed',
type: 'inject',
z: 'demo_tab_treatment',
name: 'RAS speed → 50%',
props: [
{ p: 'topic', vt: 'str' },
{ p: 'payload', vt: 'json' }
],
topic: 'execMovement',
payload: '{"source":"auto","action":"setpoint","setpoint":50}',
payloadType: 'json',
once: true,
onceDelay: '4',
x: 1000,
y: 480,
wires: [['demo_pump_ras']],
repeatType: 'none',
crontab: '',
repeat: ''
});
// D4: RAS filter function
flow.push({
id: 'demo_fn_ras_filter',
type: 'function',
z: 'demo_tab_treatment',
name: 'RAS Filter',
func: `// Only pass RAS (inlet 2) from settler to reactor as inlet 3
if (msg.topic === 'Fluent' && msg.payload && msg.payload.inlet === 2) {
msg.payload.inlet = 3; // reactor inlet 3 = RAS
return msg;
}
return null;`,
outputs: 1,
x: 1000,
y: 320,
wires: [['demo_reactor']]
});
// D5: Wire settler Port 0 → RAS filter
const settler = findNode('demo_settler');
if (settler) {
if (!settler.wires[0].includes('demo_fn_ras_filter')) {
settler.wires[0].push('demo_fn_ras_filter');
}
console.log('Phase D: Wired settler → RAS filter → reactor');
} else {
console.error('Phase D: ERROR — demo_settler not found!');
}
// D5: Update reactor n_inlets: 3 → 4
if (reactor) {
reactor.n_inlets = 4;
console.log('Phase D: Updated reactor n_inlets to 4');
}
console.log('Phase D: Added RAS pump, flow sensor, 2 injects, filter function');
// ============================================================
// WRITE OUTPUT
// ============================================================
fs.writeFileSync(flowPath, JSON.stringify(flow, null, 2) + '\n', 'utf8');
console.log(`\nDone. Wrote ${flow.length} nodes to ${flowPath}`);

View File

@@ -1,380 +0,0 @@
#!/usr/bin/env node
/**
* Step 1: Tab Restructure + Per-tab link-outs
* - Creates 4 new tabs (PS West, PS North, PS South, Treatment)
* - Renames WWTP tab to "Telemetry / InfluxDB"
* - Moves nodes to their new tabs
* - Creates per-tab link-out nodes for influx + process
* - Rewires nodes to use local link-outs
* - Recalculates coordinates for clean layout
*/
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);
// =============================================
// 1a. Create 4 new tabs
// =============================================
flow.push(
{ id: "demo_tab_ps_west", type: "tab", label: "PS West", disabled: false, info: "Pumping Station West (Urban Catchment - 2 pumps, Level-based)" },
{ id: "demo_tab_ps_north", type: "tab", label: "PS North", disabled: false, info: "Pumping Station North (Industrial - 1 pump, Flow-based)" },
{ id: "demo_tab_ps_south", type: "tab", label: "PS South", disabled: false, info: "Pumping Station South (Residential - 1 pump, Manual)" },
{ id: "demo_tab_treatment", type: "tab", label: "Biological Treatment", disabled: false, info: "Merge point, Reactor, Settler, Effluent Measurements" }
);
// =============================================
// 1b. Rename existing WWTP tab
// =============================================
const wwtpTab = byId("demo_tab_wwtp");
wwtpTab.label = "Telemetry / InfluxDB";
wwtpTab.info = "InfluxDB write chain, process debug, Grafana dashboard API, shared infrastructure";
// =============================================
// 1c. Move nodes to new tabs
// =============================================
const moveMap = {
// PS West tab
"demo_comment_ps": "demo_tab_ps_west",
"demo_ps_west": "demo_tab_ps_west",
"demo_pump_w1": "demo_tab_ps_west",
"demo_pump_w2": "demo_tab_ps_west",
"demo_mgc_west": "demo_tab_ps_west",
"demo_inj_west_mode": "demo_tab_ps_west",
"demo_inj_west_flow": "demo_tab_ps_west",
"demo_fn_west_flow_sim": "demo_tab_ps_west",
"demo_inj_w1_mode": "demo_tab_ps_west",
"demo_inj_w2_mode": "demo_tab_ps_west",
"demo_inj_calib_west": "demo_tab_ps_west",
"demo_fn_level_to_pressure_w": "demo_tab_ps_west",
"demo_meas_pt_w_up": "demo_tab_ps_west",
"demo_meas_pt_w_down": "demo_tab_ps_west",
"demo_mon_west": "demo_tab_ps_west",
"demo_link_ps_west_dash": "demo_tab_ps_west",
// PS North tab
"demo_comment_ps_north": "demo_tab_ps_north",
"demo_ps_north": "demo_tab_ps_north",
"demo_pump_n1": "demo_tab_ps_north",
"demo_inj_north_mode": "demo_tab_ps_north",
"demo_inj_north_flow": "demo_tab_ps_north",
"demo_fn_north_flow_sim": "demo_tab_ps_north",
"demo_inj_n1_mode": "demo_tab_ps_north",
"demo_inj_calib_north": "demo_tab_ps_north",
"demo_comment_north_outflow": "demo_tab_ps_north",
"demo_meas_ft_n1": "demo_tab_ps_north",
"demo_fn_level_to_pressure_n": "demo_tab_ps_north",
"demo_meas_pt_n_up": "demo_tab_ps_north",
"demo_meas_pt_n_down": "demo_tab_ps_north",
"demo_mon_north": "demo_tab_ps_north",
"demo_link_ps_north_dash": "demo_tab_ps_north",
// PS South tab
"demo_comment_ps_south": "demo_tab_ps_south",
"demo_ps_south": "demo_tab_ps_south",
"demo_pump_s1": "demo_tab_ps_south",
"demo_inj_south_mode": "demo_tab_ps_south",
"demo_inj_south_flow": "demo_tab_ps_south",
"demo_fn_south_flow_sim": "demo_tab_ps_south",
"demo_inj_s1_mode": "demo_tab_ps_south",
"demo_inj_calib_south": "demo_tab_ps_south",
"demo_fn_level_to_pressure_s": "demo_tab_ps_south",
"demo_meas_pt_s_up": "demo_tab_ps_south",
"demo_meas_pt_s_down": "demo_tab_ps_south",
"demo_mon_south": "demo_tab_ps_south",
"demo_link_ps_south_dash": "demo_tab_ps_south",
// Treatment tab
"demo_comment_treatment": "demo_tab_treatment",
"demo_meas_flow": "demo_tab_treatment",
"demo_meas_do": "demo_tab_treatment",
"demo_meas_nh4": "demo_tab_treatment",
"demo_reactor": "demo_tab_treatment",
"demo_inj_reactor_tick": "demo_tab_treatment",
"demo_settler": "demo_tab_treatment",
"demo_monster": "demo_tab_treatment",
"demo_inj_monster_flow": "demo_tab_treatment",
"demo_fn_monster_flow": "demo_tab_treatment",
"demo_comment_effluent_meas": "demo_tab_treatment",
"demo_meas_eff_flow": "demo_tab_treatment",
"demo_meas_eff_do": "demo_tab_treatment",
"demo_meas_eff_nh4": "demo_tab_treatment",
"demo_meas_eff_no3": "demo_tab_treatment",
"demo_meas_eff_tss": "demo_tab_treatment",
"demo_comment_pressure": "demo_tab_treatment",
"demo_link_reactor_dash": "demo_tab_treatment",
"demo_link_meas_dash": "demo_tab_treatment",
"demo_link_eff_meas_dash": "demo_tab_treatment"
};
for (const [nodeId, tabId] of Object.entries(moveMap)) {
const node = byId(nodeId);
if (node) {
node.z = tabId;
} else {
console.warn(`WARNING: Node ${nodeId} not found for move`);
}
}
// =============================================
// 1c-coords. Recalculate coordinates per tab
// =============================================
// PS West layout (2 pumps + MGC)
const psWestCoords = {
"demo_comment_ps": { x: 340, y: 40 },
"demo_inj_calib_west": { x: 120, y: 80 },
"demo_inj_w1_mode": { x: 120, y: 120 },
"demo_inj_west_mode": { x: 120, y: 200 },
"demo_inj_west_flow": { x: 120, y: 240 },
"demo_inj_w2_mode": { x: 120, y: 320 },
"demo_fn_west_flow_sim": { x: 360, y: 240 },
"demo_pump_w1": { x: 600, y: 120 },
"demo_pump_w2": { x: 600, y: 320 },
"demo_mgc_west": { x: 600, y: 220 },
"demo_ps_west": { x: 860, y: 220 },
"demo_fn_level_to_pressure_w": { x: 360, y: 420 },
"demo_meas_pt_w_up": { x: 560, y: 420 },
"demo_meas_pt_w_down": { x: 560, y: 480 },
"demo_mon_west": { x: 1080, y: 160 },
"demo_link_ps_west_dash": { x: 1080, y: 220 },
};
// PS North layout (1 pump, no MGC)
const psNorthCoords = {
"demo_comment_ps_north": { x: 340, y: 40 },
"demo_inj_calib_north": { x: 120, y: 80 },
"demo_inj_n1_mode": { x: 120, y: 120 },
"demo_inj_north_mode": { x: 120, y: 200 },
"demo_inj_north_flow": { x: 120, y: 240 },
"demo_fn_north_flow_sim": { x: 360, y: 240 },
"demo_pump_n1": { x: 600, y: 120 },
"demo_ps_north": { x: 860, y: 200 },
"demo_comment_north_outflow":{ x: 200, y: 320 },
"demo_meas_ft_n1": { x: 560, y: 340 },
"demo_fn_level_to_pressure_n":{ x: 360, y: 420 },
"demo_meas_pt_n_up": { x: 560, y: 420 },
"demo_meas_pt_n_down": { x: 560, y: 480 },
"demo_mon_north": { x: 1080, y: 140 },
"demo_link_ps_north_dash": { x: 1080, y: 200 },
};
// PS South layout (1 pump, no MGC)
const psSouthCoords = {
"demo_comment_ps_south": { x: 340, y: 40 },
"demo_inj_calib_south": { x: 120, y: 80 },
"demo_inj_s1_mode": { x: 120, y: 120 },
"demo_inj_south_mode": { x: 120, y: 200 },
"demo_inj_south_flow": { x: 120, y: 240 },
"demo_fn_south_flow_sim": { x: 360, y: 240 },
"demo_pump_s1": { x: 600, y: 120 },
"demo_ps_south": { x: 860, y: 200 },
"demo_fn_level_to_pressure_s":{ x: 360, y: 380 },
"demo_meas_pt_s_up": { x: 560, y: 380 },
"demo_meas_pt_s_down": { x: 560, y: 440 },
"demo_mon_south": { x: 1080, y: 140 },
"demo_link_ps_south_dash": { x: 1080, y: 200 },
};
// Treatment layout
const treatmentCoords = {
"demo_comment_treatment": { x: 200, y: 40 },
"demo_meas_flow": { x: 400, y: 120 },
"demo_meas_do": { x: 400, y: 180 },
"demo_meas_nh4": { x: 400, y: 240 },
"demo_inj_reactor_tick": { x: 600, y: 80 },
"demo_reactor": { x: 800, y: 180 },
"demo_settler": { x: 800, y: 320 },
"demo_monster": { x: 800, y: 420 },
"demo_inj_monster_flow": { x: 560, y: 420 },
"demo_fn_monster_flow": { x: 660, y: 460 },
"demo_comment_effluent_meas":{ x: 200, y: 520 },
"demo_meas_eff_flow": { x: 400, y: 560 },
"demo_meas_eff_do": { x: 400, y: 620 },
"demo_meas_eff_nh4": { x: 400, y: 680 },
"demo_meas_eff_no3": { x: 400, y: 740 },
"demo_meas_eff_tss": { x: 400, y: 800 },
"demo_comment_pressure": { x: 200, y: 860 },
"demo_link_reactor_dash": { x: 1020, y: 180 },
"demo_link_meas_dash": { x: 620, y: 180 },
"demo_link_eff_meas_dash": { x: 620, y: 620 },
};
// Apply coordinates
for (const [nodeId, coords] of Object.entries({...psWestCoords, ...psNorthCoords, ...psSouthCoords, ...treatmentCoords})) {
const node = byId(nodeId);
if (node) {
node.x = coords.x;
node.y = coords.y;
}
}
// =============================================
// 1d. Create per-tab link-out nodes
// =============================================
// Determine which tab each moved node belongs to
const tabForNode = {};
for (const n of flow) {
if (n.z) tabForNode[n.id] = n.z;
}
// Map from tab → influx link-out ID
const influxLinkOutMap = {
"demo_tab_ps_west": "demo_link_influx_out_west",
"demo_tab_ps_north": "demo_link_influx_out_north",
"demo_tab_ps_south": "demo_link_influx_out_south",
"demo_tab_treatment": "demo_link_influx_out_treatment",
};
// Map from tab → process link-out ID
const processLinkOutMap = {
"demo_tab_ps_west": "demo_link_process_out_west",
"demo_tab_ps_north": "demo_link_process_out_north",
"demo_tab_ps_south": "demo_link_process_out_south",
"demo_tab_treatment": "demo_link_process_out_treatment",
};
// Link-out node positions per tab
const linkOutPositions = {
"demo_tab_ps_west": { influx: { x: 1080, y: 280 }, process: { x: 1080, y: 320 } },
"demo_tab_ps_north": { influx: { x: 1080, y: 260 }, process: { x: 1080, y: 300 } },
"demo_tab_ps_south": { influx: { x: 1080, y: 260 }, process: { x: 1080, y: 300 } },
"demo_tab_treatment": { influx: { x: 1020, y: 280 }, process: { x: 1020, y: 320 } },
};
// Create influx link-out nodes
for (const [tabId, nodeId] of Object.entries(influxLinkOutMap)) {
const pos = linkOutPositions[tabId].influx;
flow.push({
id: nodeId,
type: "link out",
z: tabId,
name: "→ InfluxDB",
mode: "link",
links: ["demo_link_influx_in"],
x: pos.x,
y: pos.y
});
}
// Create process link-out nodes
for (const [tabId, nodeId] of Object.entries(processLinkOutMap)) {
const pos = linkOutPositions[tabId].process;
flow.push({
id: nodeId,
type: "link out",
z: tabId,
name: "→ Process debug",
mode: "link",
links: ["demo_link_process_in"],
x: pos.x,
y: pos.y
});
}
// =============================================
// 1d-rewire. Rewire nodes to use local link-outs
// =============================================
// For every node that references "demo_link_influx_out" or "demo_link_process_out"
// in its wires, replace with the per-tab version
for (const node of flow) {
if (!node.wires || !node.z) continue;
const tab = node.z;
const localInflux = influxLinkOutMap[tab];
const localProcess = processLinkOutMap[tab];
for (let portIdx = 0; portIdx < node.wires.length; portIdx++) {
for (let wireIdx = 0; wireIdx < node.wires[portIdx].length; wireIdx++) {
if (node.wires[portIdx][wireIdx] === "demo_link_influx_out" && localInflux) {
node.wires[portIdx][wireIdx] = localInflux;
}
if (node.wires[portIdx][wireIdx] === "demo_link_process_out" && localProcess) {
node.wires[portIdx][wireIdx] = localProcess;
}
}
}
}
// Update the link-in nodes to reference all new link-out IDs
const influxIn = byId("demo_link_influx_in");
influxIn.links = Object.values(influxLinkOutMap);
// Also keep the old one if any nodes on the telemetry tab still reference it
// (the dashapi, telemetry nodes that stayed on demo_tab_wwtp)
influxIn.links.push("demo_link_influx_out");
const processIn = byId("demo_link_process_in");
processIn.links = Object.values(processLinkOutMap);
processIn.links.push("demo_link_process_out");
// Keep old link-out nodes on telemetry tab (they may still be needed
// by nodes that remain there, like dashapi)
// Update their links arrays too
const oldInfluxOut = byId("demo_link_influx_out");
if (oldInfluxOut) {
oldInfluxOut.links = ["demo_link_influx_in"];
// Move to bottom of telemetry tab
oldInfluxOut.x = 1135;
oldInfluxOut.y = 500;
}
const oldProcessOut = byId("demo_link_process_out");
if (oldProcessOut) {
oldProcessOut.links = ["demo_link_process_in"];
oldProcessOut.x = 1135;
oldProcessOut.y = 540;
}
// =============================================
// Validate
// =============================================
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);
// Check for broken wire references
const allIds = new Set(flow.map(n => n.id));
let brokenWires = 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}`);
brokenWires++;
}
}
}
}
if (brokenWires === 0) console.log('All wire references valid ✓');
// Check link-in/link-out pairing
for (const n of flow) {
if (n.type === 'link out' && n.links) {
for (const linkTarget of n.links) {
if (!allIds.has(linkTarget)) {
console.warn(`BROKEN LINK: ${n.id} links to missing ${linkTarget}`);
}
}
}
if (n.type === 'link in' && n.links) {
for (const linkSource of n.links) {
if (!allIds.has(linkSource)) {
console.warn(`BROKEN LINK: ${n.id} expects link from missing ${linkSource}`);
}
}
}
}
// Write
fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n');
console.log(`\nWrote ${FLOW_PATH} (${flow.length} nodes)`);

View File

@@ -1,219 +0,0 @@
#!/usr/bin/env node
/**
* Step 2: Merge Collection Point
* - Adds link-out from each PS tab to merge on treatment tab
* - Creates link-in, tag, collect, and dashboard link-out nodes on treatment
* - Wires PS outputs through merge to feed reactor
*/
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);
// =============================================
// 2a. Link-out nodes on each PS tab
// =============================================
flow.push(
{
id: "demo_link_merge_west_out",
type: "link out",
z: "demo_tab_ps_west",
name: "→ Merge (West)",
mode: "link",
links: ["demo_link_merge_west_in"],
x: 1080, y: 360
},
{
id: "demo_link_merge_north_out",
type: "link out",
z: "demo_tab_ps_north",
name: "→ Merge (North)",
mode: "link",
links: ["demo_link_merge_north_in"],
x: 1080, y: 340
},
{
id: "demo_link_merge_south_out",
type: "link out",
z: "demo_tab_ps_south",
name: "→ Merge (South)",
mode: "link",
links: ["demo_link_merge_south_in"],
x: 1080, y: 340
}
);
// Add merge link-outs to each PS node's wires[0]
const psWest = byId("demo_ps_west");
psWest.wires[0].push("demo_link_merge_west_out");
const psNorth = byId("demo_ps_north");
psNorth.wires[0].push("demo_link_merge_north_out");
const psSouth = byId("demo_ps_south");
psSouth.wires[0].push("demo_link_merge_south_out");
// =============================================
// 2b. Merge nodes on Treatment tab
// =============================================
// Link-in nodes
flow.push(
{
id: "demo_link_merge_west_in",
type: "link in",
z: "demo_tab_treatment",
name: "← PS West",
links: ["demo_link_merge_west_out"],
x: 100, y: 920,
wires: [["demo_fn_tag_west"]]
},
{
id: "demo_link_merge_north_in",
type: "link in",
z: "demo_tab_treatment",
name: "← PS North",
links: ["demo_link_merge_north_out"],
x: 100, y: 980,
wires: [["demo_fn_tag_north"]]
},
{
id: "demo_link_merge_south_in",
type: "link in",
z: "demo_tab_treatment",
name: "← PS South",
links: ["demo_link_merge_south_out"],
x: 100, y: 1040,
wires: [["demo_fn_tag_south"]]
}
);
// Tag functions
flow.push(
{
id: "demo_fn_tag_west",
type: "function",
z: "demo_tab_treatment",
name: "Tag: west",
func: "msg._psSource = 'west';\nreturn msg;",
outputs: 1,
x: 280, y: 920,
wires: [["demo_fn_merge_collect"]]
},
{
id: "demo_fn_tag_north",
type: "function",
z: "demo_tab_treatment",
name: "Tag: north",
func: "msg._psSource = 'north';\nreturn msg;",
outputs: 1,
x: 280, y: 980,
wires: [["demo_fn_merge_collect"]]
},
{
id: "demo_fn_tag_south",
type: "function",
z: "demo_tab_treatment",
name: "Tag: south",
func: "msg._psSource = 'south';\nreturn msg;",
outputs: 1,
x: 280, y: 1040,
wires: [["demo_fn_merge_collect"]]
}
);
// Merge collect function
flow.push({
id: "demo_fn_merge_collect",
type: "function",
z: "demo_tab_treatment",
name: "Merge Collector",
func: `// Cache each PS output by _psSource tag, compute totals
const p = msg.payload || {};
const ps = msg._psSource;
const cache = flow.get('merge_cache') || { west: {}, north: {}, south: {} };
const keys = Object.keys(p);
const pick = (prefix) => { const k = keys.find(k => k.startsWith(prefix)); return k ? Number(p[k]) : null; };
if (ps && cache[ps]) {
const nf = pick('netFlowRate.predicted'); if (nf !== null) cache[ps].netFlow = nf;
const fp = pick('volumePercent.predicted'); if (fp !== null) cache[ps].fillPct = fp;
cache[ps].direction = p.direction || cache[ps].direction;
cache[ps].ts = Date.now();
}
flow.set('merge_cache', cache);
const totalFlow = (cache.west.netFlow||0) + (cache.north.netFlow||0) + (cache.south.netFlow||0);
const avgFill = ((cache.west.fillPct||0) + (cache.north.fillPct||0) + (cache.south.fillPct||0)) / 3;
return {
topic: 'merge_combined_influent',
payload: { totalInfluentFlow: +totalFlow.toFixed(1), avgFillPercent: +avgFill.toFixed(1),
west: cache.west, north: cache.north, south: cache.south }
};`,
outputs: 1,
x: 480, y: 980,
wires: [["demo_link_merge_dash"]]
});
// Dashboard link-out for merge data
flow.push({
id: "demo_link_merge_dash",
type: "link out",
z: "demo_tab_treatment",
name: "→ Merge Dashboard",
mode: "link",
links: ["demo_link_merge_dash_in"],
x: 680, y: 980
});
// Create a comment for the merge section
flow.push({
id: "demo_comment_merge",
type: "comment",
z: "demo_tab_treatment",
name: "=== MERGE COLLECTION POINT ===",
info: "Combines output from all 3 pumping stations",
x: 200, y: 880
});
// =============================================
// Validate
// =============================================
const allIds = new Set(flow.map(n => n.id));
let brokenWires = 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}`);
brokenWires++;
}
}
}
}
for (const n of flow) {
if (n.type === 'link out' && n.links) {
for (const lt of n.links) {
if (!allIds.has(lt)) console.warn(`BROKEN LINK: ${n.id} links to missing ${lt}`);
}
}
if (n.type === 'link in' && n.links) {
for (const ls of n.links) {
if (!allIds.has(ls)) console.warn(`BROKEN LINK: ${n.id} expects link from missing ${ls}`);
}
}
}
if (brokenWires === 0) console.log('All wire references valid ✓');
console.log('Total nodes:', flow.length);
// Write
fs.writeFileSync(FLOW_PATH, JSON.stringify(flow, null, 2) + '\n');
console.log(`Wrote ${FLOW_PATH}`);

View File

@@ -1,583 +0,0 @@
#!/usr/bin/env node
/**
* Step 3: Overview Dashboard Page + KPI Gauges
* - Creates overview page with chain visualization
* - Adds KPI gauges (Total Flow, DO, TSS, NH4)
* - Link-in nodes to feed overview from merge + reactor + effluent data
* - Reorders all page navigation
*/
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);
// =============================================
// 3a. New config nodes
// =============================================
// Overview page
flow.push({
id: "demo_ui_page_overview",
type: "ui-page",
name: "Plant Overview",
ui: "demo_ui_base",
path: "/overview",
icon: "dashboard",
layout: "grid",
theme: "demo_ui_theme",
breakpoints: [{ name: "Default", px: "0", cols: "12" }],
order: 0,
className: ""
});
// Overview groups
flow.push(
{
id: "demo_ui_grp_overview_chain",
type: "ui-group",
name: "Process Chain",
page: "demo_ui_page_overview",
width: "12",
height: "1",
order: 1,
showTitle: true,
className: ""
},
{
id: "demo_ui_grp_overview_kpi",
type: "ui-group",
name: "Key Indicators",
page: "demo_ui_page_overview",
width: "12",
height: "1",
order: 2,
showTitle: true,
className: ""
}
);
// =============================================
// 3b. Chain visualization - link-in nodes on dashboard tab
// =============================================
// Link-in for merge data (this is what step 2's demo_link_merge_dash links to)
flow.push({
id: "demo_link_merge_dash_in",
type: "link in",
z: "demo_tab_dashboard",
name: "← Merge Data",
links: ["demo_link_merge_dash"],
x: 75, y: 960,
wires: [["demo_fn_overview_parse"]]
});
// We also need reactor and effluent data for the overview.
// Create link-out nodes on treatment tab for overview data
flow.push(
{
id: "demo_link_overview_reactor_out",
type: "link out",
z: "demo_tab_treatment",
name: "→ Overview (Reactor)",
mode: "link",
links: ["demo_link_overview_reactor_in"],
x: 1020, y: 220
},
{
id: "demo_link_overview_reactor_in",
type: "link in",
z: "demo_tab_dashboard",
name: "← Reactor (Overview)",
links: ["demo_link_overview_reactor_out"],
x: 75, y: 1020,
wires: [["demo_fn_overview_reactor_parse"]]
}
);
// Add overview reactor link-out to reactor's wires[0]
const reactor = byId("demo_reactor");
reactor.wires[0].push("demo_link_overview_reactor_out");
// Effluent measurements link for overview KPIs
flow.push(
{
id: "demo_link_overview_eff_out",
type: "link out",
z: "demo_tab_treatment",
name: "→ Overview (Effluent)",
mode: "link",
links: ["demo_link_overview_eff_in"],
x: 620, y: 660
},
{
id: "demo_link_overview_eff_in",
type: "link in",
z: "demo_tab_dashboard",
name: "← Effluent (Overview)",
links: ["demo_link_overview_eff_out"],
x: 75, y: 1080,
wires: [["demo_fn_overview_eff_parse"]]
}
);
// Add overview eff link-out to effluent measurement nodes wires[0]
// TSS and NH4 are the key effluent quality indicators
const effTss = byId("demo_meas_eff_tss");
effTss.wires[0].push("demo_link_overview_eff_out");
const effNh4 = byId("demo_meas_eff_nh4");
effNh4.wires[0].push("demo_link_overview_eff_out");
// =============================================
// 3b. Parse functions for overview
// =============================================
// Parse merge data for chain visualization + total flow gauge
flow.push({
id: "demo_fn_overview_parse",
type: "function",
z: "demo_tab_dashboard",
name: "Parse Overview (Merge)",
func: `const p = msg.payload || {};
const now = Date.now();
// Store in flow context for the template
flow.set('overview_merge', p);
// Output 1: chain vis data, Output 2: total flow gauge
return [
{ topic: 'overview_chain', payload: p },
p.totalInfluentFlow !== undefined ? { topic: 'Total Influent Flow', payload: p.totalInfluentFlow } : null
];`,
outputs: 2,
x: 280, y: 960,
wires: [
["demo_overview_template"],
["demo_gauge_overview_flow"]
]
});
// Parse reactor data for overview
flow.push({
id: "demo_fn_overview_reactor_parse",
type: "function",
z: "demo_tab_dashboard",
name: "Parse Overview (Reactor)",
func: `const p = msg.payload || {};
if (!p.C || !Array.isArray(p.C)) return null;
flow.set('overview_reactor', p);
// Output: DO gauge value
return { topic: 'Reactor DO', payload: Math.round(p.C[0]*100)/100 };`,
outputs: 1,
x: 280, y: 1020,
wires: [["demo_gauge_overview_do"]]
});
// Parse effluent data for overview KPIs
flow.push({
id: "demo_fn_overview_eff_parse",
type: "function",
z: "demo_tab_dashboard",
name: "Parse Overview (Effluent)",
func: `const p = msg.payload || {};
const topic = msg.topic || '';
const val = Number(p.mAbs);
if (!Number.isFinite(val)) return null;
// Route to appropriate gauge based on measurement type
if (topic.includes('TSS') || topic.includes('tss')) {
return [{ topic: 'Effluent TSS', payload: Math.round(val*100)/100 }, null];
}
if (topic.includes('NH4') || topic.includes('ammonium')) {
return [null, { topic: 'Effluent NH4', payload: Math.round(val*100)/100 }];
}
return [null, null];`,
outputs: 2,
x: 280, y: 1080,
wires: [
["demo_gauge_overview_tss"],
["demo_gauge_overview_nh4"]
]
});
// =============================================
// 3b. Chain visualization template
// =============================================
flow.push({
id: "demo_overview_template",
type: "ui-template",
z: "demo_tab_dashboard",
group: "demo_ui_grp_overview_chain",
name: "Process Chain Diagram",
order: 1,
width: "12",
height: "6",
head: "",
format: `<template>
<div class="chain-container">
<svg viewBox="0 0 900 280" class="chain-svg">
<!-- PS West -->
<g @click="navigateTo('/ps-west')" class="chain-block clickable">
<rect x="20" y="20" width="160" height="80" rx="8" :fill="blockColor(merge?.west)"/>
<text x="100" y="50" class="block-title">PS West</text>
<text x="100" y="70" class="block-value">{{ formatPct(merge?.west?.fillPct) }}</text>
<text x="100" y="86" class="block-sub">{{ formatDir(merge?.west?.direction) }}</text>
</g>
<!-- PS North -->
<g @click="navigateTo('/ps-north')" class="chain-block clickable">
<rect x="20" y="120" width="160" height="80" rx="8" :fill="blockColor(merge?.north)"/>
<text x="100" y="150" class="block-title">PS North</text>
<text x="100" y="170" class="block-value">{{ formatPct(merge?.north?.fillPct) }}</text>
<text x="100" y="186" class="block-sub">{{ formatDir(merge?.north?.direction) }}</text>
</g>
<!-- PS South -->
<g @click="navigateTo('/ps-south')" class="chain-block clickable">
<rect x="20" y="220" width="160" height="80" rx="8" :fill="blockColor(merge?.south)"/>
<text x="100" y="250" class="block-title">PS South</text>
<text x="100" y="270" class="block-value">{{ formatPct(merge?.south?.fillPct) }}</text>
<text x="100" y="286" class="block-sub">{{ formatDir(merge?.south?.direction) }}</text>
</g>
<!-- Merge arrows -->
<line x1="180" y1="60" x2="260" y2="160" class="chain-arrow"/>
<line x1="180" y1="160" x2="260" y2="160" class="chain-arrow"/>
<line x1="180" y1="260" x2="260" y2="160" class="chain-arrow"/>
<!-- Merge point -->
<g class="chain-block">
<rect x="260" y="120" width="120" height="80" rx="8" fill="#0f3460"/>
<text x="320" y="150" class="block-title">Merge</text>
<text x="320" y="170" class="block-value">{{ formatFlow(merge?.totalInfluentFlow) }}</text>
<text x="320" y="186" class="block-sub">m\\u00b3/h total</text>
</g>
<!-- Arrow merge → reactor -->
<line x1="380" y1="160" x2="420" y2="160" class="chain-arrow"/>
<!-- Reactor -->
<g @click="navigateTo('/treatment')" class="chain-block clickable">
<rect x="420" y="120" width="140" height="80" rx="8" :fill="reactorColor"/>
<text x="490" y="150" class="block-title">Reactor</text>
<text x="490" y="170" class="block-value">DO: {{ reactorDO }}</text>
<text x="490" y="186" class="block-sub">mg/L</text>
</g>
<!-- Arrow reactor → settler -->
<line x1="560" y1="160" x2="600" y2="160" class="chain-arrow"/>
<!-- Settler -->
<g @click="navigateTo('/treatment')" class="chain-block clickable">
<rect x="600" y="120" width="120" height="80" rx="8" fill="#0f3460"/>
<text x="660" y="150" class="block-title">Settler</text>
<text x="660" y="170" class="block-value">TSS: {{ effTSS }}</text>
<text x="660" y="186" class="block-sub">mg/L</text>
</g>
<!-- Arrow settler → effluent -->
<line x1="720" y1="160" x2="760" y2="160" class="chain-arrow"/>
<!-- Effluent -->
<g class="chain-block">
<rect x="760" y="120" width="120" height="80" rx="8" :fill="effluentColor"/>
<text x="820" y="150" class="block-title">Effluent</text>
<text x="820" y="170" class="block-value">NH4: {{ effNH4 }}</text>
<text x="820" y="186" class="block-sub">mg/L</text>
</g>
</svg>
</div>
</template>
<script>
export default {
data() {
return {
merge: null,
reactorDO: '--',
effTSS: '--',
effNH4: '--'
}
},
computed: {
reactorColor() {
const d = parseFloat(this.reactorDO);
if (isNaN(d)) return '#0f3460';
if (d < 1) return '#f44336';
if (d < 2) return '#ff9800';
return '#1b5e20';
},
effluentColor() {
const n = parseFloat(this.effNH4);
if (isNaN(n)) return '#0f3460';
if (n > 10) return '#f44336';
if (n > 5) return '#ff9800';
return '#1b5e20';
}
},
watch: {
msg(val) {
if (!val) return;
const t = val.topic || '';
if (t === 'overview_chain') {
this.merge = val.payload;
} else if (t === 'Reactor DO') {
this.reactorDO = val.payload?.toFixed(1) || '--';
} else if (t === 'Effluent TSS') {
this.effTSS = val.payload?.toFixed(1) || '--';
} else if (t === 'Effluent NH4') {
this.effNH4 = val.payload?.toFixed(1) || '--';
}
}
},
methods: {
navigateTo(path) {
this.$router.push('/dashboard' + path);
},
blockColor(ps) {
if (!ps || ps.fillPct === undefined) return '#0f3460';
if (ps.fillPct > 90) return '#f44336';
if (ps.fillPct > 75) return '#ff9800';
if (ps.fillPct < 10) return '#f44336';
return '#0f3460';
},
formatPct(v) { return v !== undefined && v !== null ? v.toFixed(0) + '%' : '--'; },
formatFlow(v) { return v !== undefined && v !== null ? v.toFixed(0) : '--'; },
formatDir(d) { return d === 'filling' ? '\\u2191 filling' : d === 'emptying' ? '\\u2193 emptying' : '--'; }
}
}
</script>
<style>
.chain-container { width: 100%; overflow-x: auto; }
.chain-svg { width: 100%; height: auto; min-height: 200px; }
.chain-block text { text-anchor: middle; fill: #e0e0e0; }
.block-title { font-size: 14px; font-weight: bold; }
.block-value { font-size: 13px; fill: #4fc3f7; }
.block-sub { font-size: 10px; fill: #90a4ae; }
.chain-arrow { stroke: #4fc3f7; stroke-width: 2; marker-end: url(#arrowhead); }
.clickable { cursor: pointer; }
.clickable:hover rect { opacity: 0.8; }
</style>`,
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}`);

View File

@@ -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}`);

View File

@@ -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');

View File

@@ -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": []
}
]

View File

@@ -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

View File

@@ -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)

View File

View File

@@ -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)