chore: clean up superproject structure
Some checks failed
CI / lint-and-test (push) Has been cancelled
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.`);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.`);
|
||||
@@ -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)`);
|
||||
@@ -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...');
|
||||
@@ -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.`);
|
||||
@@ -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);
|
||||
@@ -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.`);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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)`);
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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}`);
|
||||
@@ -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');
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
40
third_party/docs/README.md
vendored
40
third_party/docs/README.md
vendored
@@ -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)
|
||||
0
third_party/docs/sources/.gitkeep
vendored
0
third_party/docs/sources/.gitkeep
vendored
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user