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