diff --git a/.agents/decisions/DECISION-20260323-reactor-four-zone-staged-aeration-demo.md b/.agents/decisions/DECISION-20260323-reactor-four-zone-staged-aeration-demo.md new file mode 100644 index 0000000..bbe4c40 --- /dev/null +++ b/.agents/decisions/DECISION-20260323-reactor-four-zone-staged-aeration-demo.md @@ -0,0 +1,43 @@ +## Context + +The single demo bioreactor did not reflect the intended EVOLV biological treatment concept. The owner requested: + +- four reactor zones in series +- staged aeration based on effluent NH4 +- local visualization per zone for NH4, NO3, O2, and other relevant state variables +- improved PFR numerical stability by increasing reactor resolution + +The localhost deployment also needed to remain usable for E2E debugging with Node-RED, InfluxDB, and Grafana. + +## Options Considered + +1. Keep one large PFR and add more internal profile visualization only. +2. Split the biology into four explicit reactor zones in the flow and control aeration at zone level. +3. Replace the PFR demo with a simpler CSTR train for faster visual response. + +## Decision + +Choose option 2. + +The demo flow now uses four explicit PFR zones in series with: + +- equal-zone sizing (`4 x 500 m3`, total `2000 m3`) +- explicit `Fluent` forwarding between zones +- common clocking for all zones +- external `OTR` control instead of fixed `kla` +- staged NH4-based aeration escalation with 30-minute hold logic +- per-zone telemetry to InfluxDB and Node-RED dashboard charts + +For runtime stability on localhost, the demo uses a higher spatial resolution with moderate compute load rather than the earlier single-reactor setup. + +## Consequences + +- The flow is easier to reason about operationally because each aeration zone is explicit. +- Zone-level telemetry is available for dashboarding and debugging. +- PFR outlet response remains residence-time dependent, so zone outlet composition will not change instantly after startup or inflow changes. +- Grafana datasource query round-trip remains valid, but dashboard auto-generation still needs separate follow-up if strict dashboard creation is required in E2E checks. + +## Rollback / Migration Notes + +- Rolling back to the earlier demo means restoring the single `demo_reactor` topology in `docker/demo-flow.json`. +- Existing E2E checks and dashboards should prefer the explicit zone measurements (`reactor_demo_reactor_z1` ... `reactor_demo_reactor_z4`) going forward. diff --git a/docker/demo-flow.json b/docker/demo-flow.json index 75051d3..616f5e7 100644 --- a/docker/demo-flow.json +++ b/docker/demo-flow.json @@ -229,7 +229,7 @@ "type": "pumpingStation", "z": "demo_tab_ps_west", "name": "PS West", - "simulator": true, + "simulator": false, "basinVolume": 500, "basinHeight": 4, "heightInlet": 3.2, @@ -516,7 +516,7 @@ "type": "pumpingStation", "z": "demo_tab_ps_north", "name": "PS North", - "simulator": true, + "simulator": false, "basinVolume": 200, "basinHeight": 3, "heightInlet": 2.4, @@ -731,7 +731,7 @@ "type": "pumpingStation", "z": "demo_tab_ps_south", "name": "PS South", - "simulator": true, + "simulator": false, "basinVolume": 100, "basinHeight": 2.5, "heightInlet": 2, @@ -944,7 +944,7 @@ "o_max": 500, "smooth_method": "mean", "count": 5, - "simulator": true, + "simulator": false, "uuid": "ft-001", "supplier": "Endress+Hauser", "category": "sensor", @@ -968,7 +968,7 @@ "demo_link_influx_out_treatment" ], [ - "demo_reactor" + "demo_reactor_z1" ] ], "distance": 0, @@ -979,7 +979,7 @@ "id": "demo_meas_do", "type": "measurement", "z": "demo_tab_treatment", - "name": "DO-001 (Dissolved O2)", + "name": "DO-001 (Zone 2 Mid Probe)", "scaling": true, "i_min": 0, "i_max": 20, @@ -988,7 +988,7 @@ "o_max": 20, "smooth_method": "mean", "count": 3, - "simulator": true, + "simulator": false, "uuid": "do-001", "supplier": "Hach", "category": "sensor", @@ -1010,20 +1010,20 @@ "demo_link_influx_out_treatment" ], [ - "demo_reactor" + "demo_reactor_z2" ] ], "positionIcon": "⊥", "hasDistance": true, - "distance": 15, + "distance": 6.25, "distanceUnit": "m", - "distanceDescription": "aeration zone" + "distanceDescription": "zone 2 mid probe" }, { "id": "demo_meas_nh4", "type": "measurement", "z": "demo_tab_treatment", - "name": "NH4-001 (Ammonium)", + "name": "NH4-001 (Zone 4 Effluent Probe)", "scaling": true, "i_min": 0, "i_max": 50, @@ -1032,7 +1032,7 @@ "o_max": 50, "smooth_method": "mean", "count": 3, - "simulator": true, + "simulator": false, "uuid": "nh4-001", "supplier": "Hach", "category": "sensor", @@ -1054,42 +1054,42 @@ "demo_link_influx_out_treatment" ], [ - "demo_reactor" + "demo_reactor_z4" ] ], "positionIcon": "⊥", "hasDistance": true, - "distance": 35, + "distance": 6.25, "distanceUnit": "m", - "distanceDescription": "post-aeration zone" + "distanceDescription": "zone 4 effluent probe" }, { - "id": "demo_reactor", + "id": "demo_reactor_z1", "type": "reactor", "z": "demo_tab_treatment", - "name": "Bioreactor R1", + "name": "Bioreactor Z1", "reactor_type": "PFR", - "volume": 2000, - "length": 50, - "resolution_L": 50, + "volume": 500, + "length": 12.5, + "resolution_L": 140, "alpha": 0, "n_inlets": 4, - "kla": 70, - "S_O_init": 2, + "kla": "", + "S_O_init": 0.8, "S_I_init": 30, - "S_S_init": 100, + "S_S_init": 120, "S_NH_init": 16, "S_N2_init": 0, - "S_NO_init": 0, + "S_NO_init": 0.5, "S_HCO_init": 8, "X_I_init": 25, "X_S_init": 75, "X_H_init": 1500, "X_STO_init": 0, - "X_A_init": 15, + "X_A_init": 18, "X_TS_init": 2500, - "timeStep": 1, - "uuid": "reactor-r1-001", + "timeStep": 2, + "uuid": "reactor-z1-001", "enableLog": true, "logLevel": "info", "positionVsParent": "upstream", @@ -1097,10 +1097,145 @@ "y": 220, "wires": [ [ - "demo_link_reactor_dash", - "demo_link_process_out_treatment", - "demo_link_overview_reactor_out", - "demo_fn_nh4_profile_extract" + "demo_fn_reactor_z1_tag" + ], + [ + "demo_link_influx_out_treatment" + ], + [ + "demo_dbg_registration" + ] + ], + "speedUpFactor": 45, + "positionIcon": "→" + }, + { + "id": "demo_reactor_z2", + "type": "reactor", + "z": "demo_tab_treatment", + "name": "Bioreactor Z2", + "reactor_type": "PFR", + "volume": 500, + "length": 12.5, + "resolution_L": 140, + "alpha": 0, + "n_inlets": 1, + "kla": "", + "S_O_init": 1, + "S_I_init": 30, + "S_S_init": 90, + "S_NH_init": 10, + "S_N2_init": 0, + "S_NO_init": 1.5, + "S_HCO_init": 8, + "X_I_init": 25, + "X_S_init": 75, + "X_H_init": 1500, + "X_STO_init": 0, + "X_A_init": 18, + "X_TS_init": 2500, + "timeStep": 2, + "uuid": "reactor-z2-001", + "enableLog": true, + "logLevel": "info", + "positionVsParent": "upstream", + "x": 1040, + "y": 220, + "wires": [ + [ + "demo_fn_reactor_z2_tag" + ], + [ + "demo_link_influx_out_treatment" + ], + [ + "demo_dbg_registration" + ] + ], + "speedUpFactor": 45, + "positionIcon": "→" + }, + { + "id": "demo_reactor_z3", + "type": "reactor", + "z": "demo_tab_treatment", + "name": "Bioreactor Z3", + "reactor_type": "PFR", + "volume": 500, + "length": 12.5, + "resolution_L": 140, + "alpha": 0, + "n_inlets": 1, + "kla": "", + "S_O_init": 1.4, + "S_I_init": 30, + "S_S_init": 60, + "S_NH_init": 5, + "S_N2_init": 0, + "S_NO_init": 3.5, + "S_HCO_init": 8, + "X_I_init": 25, + "X_S_init": 75, + "X_H_init": 1500, + "X_STO_init": 0, + "X_A_init": 18, + "X_TS_init": 2500, + "timeStep": 2, + "uuid": "reactor-z3-001", + "enableLog": true, + "logLevel": "info", + "positionVsParent": "upstream", + "x": 1260, + "y": 220, + "wires": [ + [ + "demo_fn_reactor_z3_tag" + ], + [ + "demo_link_influx_out_treatment" + ], + [ + "demo_dbg_registration" + ] + ], + "speedUpFactor": 45, + "positionIcon": "→" + }, + { + "id": "demo_reactor_z4", + "type": "reactor", + "z": "demo_tab_treatment", + "name": "Bioreactor Z4", + "reactor_type": "PFR", + "volume": 500, + "length": 12.5, + "resolution_L": 140, + "alpha": 0, + "n_inlets": 1, + "kla": "", + "S_O_init": 1.8, + "S_I_init": 30, + "S_S_init": 35, + "S_NH_init": 2.5, + "S_N2_init": 0, + "S_NO_init": 5.5, + "S_HCO_init": 8, + "X_I_init": 25, + "X_S_init": 75, + "X_H_init": 1500, + "X_STO_init": 0, + "X_A_init": 18, + "X_TS_init": 2500, + "timeStep": 2, + "uuid": "reactor-z4-001", + "enableLog": true, + "logLevel": "info", + "positionVsParent": "upstream", + "x": 1480, + "y": 220, + "wires": [ + [ + "demo_fn_reactor_z4_tag" ], [ "demo_link_influx_out_treatment" @@ -1110,9 +1245,202 @@ "demo_settler" ] ], - "speedUpFactor": 120, + "speedUpFactor": 45, "positionIcon": "→" }, + { + "id": "demo_fn_reactor_z1_tag", + "type": "function", + "z": "demo_tab_treatment", + "name": "Tag Reactor Z1", + "func": "const clone = (input) => {\n const out = { ...input };\n if (input && typeof input.payload === 'object' && input.payload !== null) {\n out.payload = JSON.parse(JSON.stringify(input.payload));\n }\n return out;\n};\nconst tagged = clone(msg);\ntagged.zoneLabel = 'Z1';\ntagged.zoneVolume = 500;\ntagged.zoneIndex = 1;\nif (msg.topic === 'Fluent') {\n return [tagged, clone(msg)];\n}\nreturn [tagged, null];", + "outputs": 2, + "x": 1080, + "y": 160, + "wires": [ + [ + "demo_link_reactor_dash", + "demo_link_process_out_treatment", + "demo_fn_nh4_profile_extract" + ], + [ + "demo_reactor_z2" + ] + ] + }, + { + "id": "demo_fn_reactor_z2_tag", + "type": "function", + "z": "demo_tab_treatment", + "name": "Tag Reactor Z2", + "func": "const clone = (input) => {\n const out = { ...input };\n if (input && typeof input.payload === 'object' && input.payload !== null) {\n out.payload = JSON.parse(JSON.stringify(input.payload));\n }\n return out;\n};\nconst tagged = clone(msg);\ntagged.zoneLabel = 'Z2';\ntagged.zoneVolume = 500;\ntagged.zoneIndex = 2;\nif (msg.topic === 'Fluent') {\n return [tagged, clone(msg), clone(msg)];\n}\nreturn [tagged, null, null];", + "outputs": 3, + "x": 1300, + "y": 160, + "wires": [ + [ + "demo_link_reactor_dash", + "demo_link_process_out_treatment", + "demo_fn_nh4_profile_extract" + ], + [ + "demo_reactor_z3" + ], + [ + "demo_fn_sample_z2_probe" + ] + ] + }, + { + "id": "demo_fn_reactor_z3_tag", + "type": "function", + "z": "demo_tab_treatment", + "name": "Tag Reactor Z3", + "func": "const clone = (input) => {\n const out = { ...input };\n if (input && typeof input.payload === 'object' && input.payload !== null) {\n out.payload = JSON.parse(JSON.stringify(input.payload));\n }\n return out;\n};\nconst tagged = clone(msg);\ntagged.zoneLabel = 'Z3';\ntagged.zoneVolume = 500;\ntagged.zoneIndex = 3;\nif (msg.topic === 'Fluent') {\n return [tagged, clone(msg)];\n}\nreturn [tagged, null];", + "outputs": 2, + "x": 1520, + "y": 160, + "wires": [ + [ + "demo_link_reactor_dash", + "demo_link_process_out_treatment", + "demo_fn_nh4_profile_extract" + ], + [ + "demo_reactor_z4" + ] + ] + }, + { + "id": "demo_fn_reactor_z4_tag", + "type": "function", + "z": "demo_tab_treatment", + "name": "Tag Reactor Z4", + "func": "const clone = (input) => {\n const out = { ...input };\n if (input && typeof input.payload === 'object' && input.payload !== null) {\n out.payload = JSON.parse(JSON.stringify(input.payload));\n }\n return out;\n};\nconst tagged = clone(msg);\ntagged.zoneLabel = 'Z4';\ntagged.zoneVolume = 500;\ntagged.zoneIndex = 4;\nif (msg.topic === 'Fluent') {\n return [tagged, clone(msg)];\n}\nreturn [tagged, null];", + "outputs": 2, + "x": 1740, + "y": 160, + "wires": [ + [ + "demo_link_reactor_dash", + "demo_link_process_out_treatment", + "demo_link_overview_reactor_out", + "demo_fn_nh4_profile_extract", + "demo_fn_aeration_stage" + ], + [ + "demo_fn_sample_z4_probe" + ] + ] + }, + { + "id": "demo_fn_sample_z2_probe", + "type": "function", + "z": "demo_tab_treatment", + "name": "Sample Z2 DO Probe", + "func": "if (msg.topic !== 'Fluent' || !Array.isArray(msg.payload?.C)) return null;\nconst value = Number(msg.payload.C[0]);\nif (!Number.isFinite(value)) return null;\nreturn { topic: 'measurement', payload: value, timestamp: msg.timestamp || Date.now() };", + "outputs": 1, + "x": 1320, + "y": 100, + "wires": [ + [ + "demo_meas_do" + ] + ] + }, + { + "id": "demo_fn_sample_z4_probe", + "type": "function", + "z": "demo_tab_treatment", + "name": "Sample Z4 NH4 Probe", + "func": "if (msg.topic !== 'Fluent' || !Array.isArray(msg.payload?.C)) return null;\nconst value = Number(msg.payload.C[3]);\nif (!Number.isFinite(value)) return null;\nreturn { topic: 'measurement', payload: value, timestamp: msg.timestamp || Date.now() };", + "outputs": 1, + "x": 1760, + "y": 100, + "wires": [ + [ + "demo_meas_nh4" + ] + ] + }, + { + "id": "demo_fn_aeration_stage", + "type": "function", + "z": "demo_tab_treatment", + "name": "Stage Aeration By NH4", + "func": "if (msg.topic !== 'Fluent' || !Array.isArray(msg.payload?.C)) return null;\nconst now = Number(msg.timestamp || Date.now());\nconst nh4 = Number(msg.payload.C[3]);\nconst NH4_HIGH = 2.0;\nconst NH4_LOW = 1.2;\nconst UP_DELAY_MS = 30 * 60 * 1000;\nconst DOWN_DELAY_MS = 60 * 60 * 1000;\n// Balanced staged aeration: Z4 carries the base nitrification load, Z3 only assists once Z4 NH4 stays high.\nconst stages = [\n [0, 40],\n [0, 55],\n [10, 70],\n [20, 85],\n];\nlet state = context.get('aerationState') || { stage: 0, highSince: null, lowSince: null };\nif (Number.isFinite(nh4) && nh4 > NH4_HIGH) {\n state.lowSince = null;\n state.highSince = state.highSince ?? now;\n if (state.stage < stages.length - 1 && now - state.highSince >= UP_DELAY_MS) {\n state.stage += 1;\n state.highSince = now;\n }\n} else if (Number.isFinite(nh4) && nh4 < NH4_LOW) {\n state.highSince = null;\n state.lowSince = state.lowSince ?? now;\n if (state.stage > 0 && now - state.lowSince >= DOWN_DELAY_MS) {\n state.stage -= 1;\n state.lowSince = now;\n }\n} else {\n state.highSince = null;\n state.lowSince = null;\n}\ncontext.set('aerationState', state);\nconst [z3Flow, z4Flow] = stages[state.stage];\nconst status = `Z4 NH4 ${nh4.toFixed(2)} mg/L -> diffuser stage ${state.stage + 1}/${stages.length} | Z3 ${z3Flow} | Z4 ${z4Flow}`;\nnode.status({ fill: state.stage > 1 ? 'yellow' : 'green', shape: 'dot', text: status });\nreturn [\n { topic: 'air_flow', payload: z3Flow, zoneLabel: 'Z3', diffuserStage: state.stage + 1, timestamp: now },\n { topic: 'air_flow', payload: z4Flow, zoneLabel: 'Z4', diffuserStage: state.stage + 1, timestamp: now },\n];", + "outputs": 2, + "x": 1760, + "y": 160, + "wires": [ + [ + "demo_diffuser_z3" + ], + [ + "demo_diffuser_z4" + ] + ] + }, + { + "id": "demo_diffuser_z3", + "type": "diffuser", + "z": "demo_tab_treatment", + "name": "Diffuser Z3", + "number": 1, + "i_elements": 4, + "i_diff_density": 2.4, + "i_m_water": 4.5, + "alfaf": 0.7, + "i_zone_volume": 500, + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "enableLog": false, + "logLevel": "error", + "x": 2020, + "y": 120, + "wires": [ + [ + "demo_link_process_out_treatment" + ], + [ + "demo_link_influx_out_treatment" + ], + [ + "demo_reactor_z3" + ], + [] + ] + }, + { + "id": "demo_diffuser_z4", + "type": "diffuser", + "z": "demo_tab_treatment", + "name": "Diffuser Z4", + "number": 1, + "i_elements": 4, + "i_diff_density": 2.4, + "i_m_water": 4.5, + "alfaf": 0.7, + "i_zone_volume": 500, + "processOutputFormat": "process", + "dbaseOutputFormat": "influxdb", + "enableLog": false, + "logLevel": "error", + "x": 2020, + "y": 200, + "wires": [ + [ + "demo_link_process_out_treatment" + ], + [ + "demo_link_influx_out_treatment" + ], + [ + "demo_reactor_z4" + ], + [] + ] + }, { "id": "demo_inj_reactor_tick", "type": "inject", @@ -1132,13 +1460,16 @@ "payload": "", "payloadType": "date", "repeat": "2", - "once": true, + "once": false, "onceDelay": "4", "x": 200, "y": 120, "wires": [ [ - "demo_reactor" + "demo_reactor_z1", + "demo_reactor_z2", + "demo_reactor_z3", + "demo_reactor_z4" ] ], "repeatType": "interval", @@ -1157,7 +1488,8 @@ "wires": [ [ "demo_link_process_out_treatment", - "demo_fn_ras_filter" + "demo_fn_ras_filter", + "demo_fn_sample_effluent_probes" ], [ "demo_link_influx_out_treatment" @@ -1168,6 +1500,30 @@ ], "positionIcon": "←" }, + { + "id": "demo_fn_sample_effluent_probes", + "type": "function", + "z": "demo_tab_treatment", + "name": "Sample Effluent Probes", + "func": "if (msg.topic !== 'Fluent' || !Array.isArray(msg.payload?.C)) return null;\nconst C = msg.payload.C;\nconst now = msg.timestamp || Date.now();\nconst sample = (index) => {\n const value = Number(C[index]);\n return Number.isFinite(value) ? { topic: 'measurement', payload: value, timestamp: now } : null;\n};\nreturn [sample(0), sample(3), sample(5), sample(12)];", + "outputs": 4, + "x": 1250, + "y": 320, + "wires": [ + [ + "demo_meas_eff_do" + ], + [ + "demo_meas_eff_nh4" + ], + [ + "demo_meas_eff_no3" + ], + [ + "demo_meas_eff_tss" + ] + ] + }, { "id": "demo_monster", "type": "monster", @@ -1256,7 +1612,7 @@ "protocol": "http", "host": "grafana", "port": 3000, - "bearerToken": "glsa_K6F9WJ6e6uWyGQpz0CKfHuEYepgoyIhW_9398347f", + "bearerToken": "", "defaultBucket": "telemetry", "enableLog": true, "logLevel": "info", @@ -1655,7 +2011,7 @@ "categoryType": "msg", "xAxisType": "time", "yAxisLabel": "m", - "removeOlder": "10", + "removeOlder": "2", "removeOlderUnit": "60", "action": "append", "pointShape": "false", @@ -1705,7 +2061,7 @@ "categoryType": "msg", "xAxisType": "time", "yAxisLabel": "m³", - "removeOlder": "10", + "removeOlder": "2", "removeOlderUnit": "60", "action": "append", "pointShape": "false", @@ -1755,7 +2111,7 @@ "categoryType": "msg", "xAxisType": "time", "yAxisLabel": "m³/h", - "removeOlder": "10", + "removeOlder": "2", "removeOlderUnit": "60", "action": "append", "pointShape": "false", @@ -1901,9 +2257,9 @@ "id": "demo_fn_reactor_parse", "type": "function", "z": "demo_tab_dashboard", - "name": "Parse Reactor", - "func": "const p = msg.payload || {};\nif (!p.C || !Array.isArray(p.C)) return null;\nconst now = Date.now();\nreturn [\n {topic:'DO (S_O)', payload: Math.round(p.C[0]*100)/100, timestamp: now},\n {topic:'NH4 (S_NH)', payload: Math.round(p.C[3]*100)/100, timestamp: now},\n {topic:'NO3 (S_NO)', payload: Math.round(p.C[5]*100)/100, timestamp: now},\n {topic:'COD (S_S)', payload: Math.round(p.C[2]*100)/100, timestamp: now},\n {topic:'TSS (X_TS)', payload: Math.round(p.C[12]*100)/100, timestamp: now},\n {payload: `DO: ${p.C[0].toFixed(1)} | NH4: ${p.C[3].toFixed(1)} | NO3: ${p.C[5].toFixed(1)} | TSS: ${p.C[12].toFixed(0)} mg/L`}\n];", - "outputs": 6, + "name": "Parse Reactor Zones", + "func": "if (msg.topic !== 'Fluent') return null;\nconst p = msg.payload || {};\nif (!Array.isArray(p.C)) return null;\nconst zone = msg.zoneLabel || 'Zone';\nconst volume = Number(msg.zoneVolume || 0);\nconst flow = Number(p.F || 0);\nconst hrtHours = flow > 0 ? (volume / flow) * 24 : 0;\nconst now = Date.now();\nconst round = (value, digits = 2) => {\n const n = Number(value);\n if (!Number.isFinite(n)) return null;\n const factor = 10 ** digits;\n return Math.round(n * factor) / factor;\n};\nconst doVal = round(p.C[0]);\nconst nh4Val = round(p.C[3]);\nconst no3Val = round(p.C[5]);\nconst ssVal = round(p.C[2]);\nconst tssVal = round(p.C[12]);\nconst xhVal = round(p.C[9]);\nconst xaVal = round(p.C[11]);\nconst flowVal = round(flow, 1);\nconst hrtVal = round(hrtHours, 2);\nconst build = (topic, payload) => payload == null ? null : { topic, payload, timestamp: now };\nconst summary = `${zone}: NH4 ${nh4Val?.toFixed(2) ?? 'n/a'} mg/L | NO3 ${no3Val?.toFixed(2) ?? 'n/a'} mg/L | DO ${doVal?.toFixed(2) ?? 'n/a'} mg/L | Flow ${flowVal?.toFixed(0) ?? 'n/a'} m3/d | HRT ${hrtVal?.toFixed(2) ?? 'n/a'} h`;\nreturn [\n build(`${zone} DO`, doVal),\n build(`${zone} NH4`, nh4Val),\n build(`${zone} NO3`, no3Val),\n build(`${zone} Readily COD`, ssVal),\n build(`${zone} TSS`, tssVal),\n build(`${zone} Heterotrophs`, xhVal),\n build(`${zone} Nitrifiers`, xaVal),\n build(`${zone} HRT`, hrtVal),\n build(`${zone} Flow`, flowVal),\n { payload: summary }\n];", + "outputs": 10, "x": 250, "y": 550, "wires": [ @@ -1914,7 +2270,7 @@ "demo_chart_reactor_n" ], [ - "demo_chart_reactor_n" + "demo_chart_reactor_no3" ], [ "demo_chart_reactor_cod" @@ -1922,6 +2278,18 @@ [ "demo_chart_reactor_cod" ], + [ + "demo_chart_reactor_biomass" + ], + [ + "demo_chart_reactor_biomass" + ], + [ + "demo_chart_reactor_flow" + ], + [ + "demo_chart_reactor_flow" + ], [ "demo_text_reactor" ] @@ -1932,7 +2300,7 @@ "type": "function", "z": "demo_tab_dashboard", "name": "Parse Measurements", - "func": "const p = msg.payload || {};\nconst topic = msg.topic || '';\nconst now = Date.now();\nconst val = Number(p.mAbs);\nif (!Number.isFinite(val)) return null;\n\nlet label = topic;\nif (topic.includes('flow')) label = 'FT-001 Flow';\nelse if (topic.includes('do') || topic.includes('DO')) label = 'DO-001 Oxygen';\nelse if (topic.includes('nh4') || topic.includes('NH4')) label = 'NH4-001 Ammonium';\n\nreturn [\n {topic: label, payload: Math.round(val*100)/100, timestamp: now},\n {topic: label + ' %', payload: Math.round(Number(p.mPercent)*10)/10, timestamp: now}\n];", + "func": "const p = msg.payload || {};\nconst topic = msg.topic || '';\nconst now = Date.now();\nconst val = Number(p.mAbs);\nif (!Number.isFinite(val)) return null;\n\nlet label = topic;\nif (topic.includes('flow')) label = 'FT-001 Influent Probe';\nelse if (topic.includes('do') || topic.includes('DO')) label = 'DO Probe Z2 Mid';\nelse if (topic.includes('nh4') || topic.includes('NH4')) label = 'NH4 Probe Z4 Effluent';\n\nreturn [\n {topic: label, payload: Math.round(val*100)/100, timestamp: now},\n {topic: label + ' %', payload: Math.round(Number(p.mPercent)*10)/10, timestamp: now}\n];", "outputs": 2, "x": 250, "y": 700, @@ -1994,8 +2362,8 @@ "type": "ui-chart", "z": "demo_tab_dashboard", "group": "demo_ui_grp_reactor", - "name": "Reactor Nitrogen", - "label": "Nitrogen Species (mg/L)", + "name": "Reactor NH4", + "label": "Zone NH4 (mg/L)", "order": 2, "width": "6", "height": "4", @@ -2020,8 +2388,9 @@ "yAxisPropertyType": "msg", "colors": [ "#FF7F0E", - "#2CA02C", - "#D62728" + "#D62728", + "#9467BD", + "#8C564B" ], "textColor": [ "#aaaaaa" @@ -2034,13 +2403,13 @@ "className": "" }, { - "id": "demo_chart_reactor_cod", + "id": "demo_chart_reactor_no3", "type": "ui-chart", "z": "demo_tab_dashboard", - "group": "demo_ui_grp_settler", - "name": "Reactor COD/TSS", - "label": "COD & TSS (mg/L)", - "order": 1, + "group": "demo_ui_grp_reactor", + "name": "Reactor NO3", + "label": "Zone NO3 (mg/L)", + "order": 3, "width": "6", "height": "4", "chartType": "line", @@ -2062,6 +2431,51 @@ "xAxisPropertyType": "timestamp", "yAxisProperty": "payload", "yAxisPropertyType": "msg", + "colors": [ + "#2CA02C", + "#1F77B4", + "#17BECF", + "#BCBD22" + ], + "textColor": [ + "#aaaaaa" + ], + "textColorDefault": false, + "gridColor": [ + "#333333" + ], + "gridColorDefault": false, + "className": "" + }, + { + "id": "demo_chart_reactor_cod", + "type": "ui-chart", + "z": "demo_tab_dashboard", + "group": "demo_ui_grp_reactor", + "name": "Reactor Carbon/Solids", + "label": "Carbon & Solids (mg/L)", + "order": 4, + "width": "6", + "height": "4", + "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": 640, + "wires": [], + "showLegend": true, + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", "colors": [ "#9467BD", "#D62728" @@ -2076,20 +2490,108 @@ "gridColorDefault": false, "className": "" }, + { + "id": "demo_chart_reactor_biomass", + "type": "ui-chart", + "z": "demo_tab_dashboard", + "group": "demo_ui_grp_reactor", + "name": "Reactor Biomass", + "label": "Biomass (mg/L)", + "order": 5, + "width": "6", + "height": "4", + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "yAxisLabel": "mg/L", + "removeOlder": "10", + "removeOlderUnit": "60", + "action": "append", + "pointShape": "false", + "pointRadius": 0, + "interpolation": "linear", + "x": 520, + "y": 680, + "wires": [], + "showLegend": true, + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "colors": [ + "#8C564B", + "#17BECF" + ], + "textColor": [ + "#aaaaaa" + ], + "textColorDefault": false, + "gridColor": [ + "#333333" + ], + "gridColorDefault": false, + "className": "" + }, + { + "id": "demo_chart_reactor_flow", + "type": "ui-chart", + "z": "demo_tab_dashboard", + "group": "demo_ui_grp_reactor", + "name": "Reactor Flow/HRT", + "label": "Zone Flow and HRT", + "order": 6, + "width": "6", + "height": "4", + "chartType": "line", + "category": "topic", + "categoryType": "msg", + "xAxisType": "time", + "yAxisLabel": "value", + "removeOlder": "10", + "removeOlderUnit": "60", + "action": "append", + "pointShape": "false", + "pointRadius": 0, + "interpolation": "linear", + "x": 520, + "y": 720, + "wires": [], + "showLegend": true, + "xAxisProperty": "", + "xAxisPropertyType": "timestamp", + "yAxisProperty": "payload", + "yAxisPropertyType": "msg", + "colors": [ + "#1F77B4", + "#FF7F0E", + "#2CA02C", + "#D62728" + ], + "textColor": [ + "#aaaaaa" + ], + "textColorDefault": false, + "gridColor": [ + "#333333" + ], + "gridColorDefault": false, + "className": "" + }, { "id": "demo_text_reactor", "type": "ui-text", "z": "demo_tab_dashboard", - "group": "demo_ui_grp_effluent", - "name": "Reactor Status", - "label": "Effluent Quality", - "order": 1, - "width": "6", - "height": "1", + "group": "demo_ui_grp_reactor", + "name": "Reactor Zone Status", + "label": "Zone Summary", + "order": 7, + "width": "12", + "height": "2", "format": "{{msg.payload}}", "layout": "row-spread", "x": 510, - "y": 640, + "y": 760, "wires": [], "className": "" }, @@ -2098,8 +2600,8 @@ "type": "ui-chart", "z": "demo_tab_dashboard", "group": "demo_ui_grp_measurements", - "name": "Sensor Values", - "label": "Sensor Readings", + "name": "Probe Values", + "label": "Local Probe Readings", "order": 1, "width": "6", "height": "4", @@ -2142,8 +2644,8 @@ "type": "ui-chart", "z": "demo_tab_dashboard", "group": "demo_ui_grp_measurements", - "name": "Sensor %", - "label": "Sensor Range (%)", + "name": "Probe Signal %", + "label": "Local Probe Range (%)", "order": 2, "width": "6", "height": "4", @@ -2254,7 +2756,7 @@ "payload": "", "payloadType": "date", "repeat": "5", - "once": true, + "once": false, "onceDelay": "10", "repeatType": "interval", "crontab": "", @@ -2288,7 +2790,7 @@ "o_max": 200, "smooth_method": "mean", "count": 3, - "simulator": true, + "simulator": false, "uuid": "ft-n1-001", "supplier": "Endress+Hauser", "category": "sensor", @@ -2337,7 +2839,7 @@ "o_max": 500, "smooth_method": "mean", "count": 5, - "simulator": true, + "simulator": false, "uuid": "ft-002", "supplier": "Endress+Hauser", "category": "sensor", @@ -2378,7 +2880,7 @@ "o_max": 20, "smooth_method": "mean", "count": 3, - "simulator": true, + "simulator": false, "uuid": "do-002", "supplier": "Hach", "category": "sensor", @@ -2419,7 +2921,7 @@ "o_max": 50, "smooth_method": "mean", "count": 3, - "simulator": true, + "simulator": false, "uuid": "nh4-002", "supplier": "Hach", "category": "sensor", @@ -2461,7 +2963,7 @@ "o_max": 30, "smooth_method": "mean", "count": 3, - "simulator": true, + "simulator": false, "uuid": "no3-001", "supplier": "Hach", "category": "sensor", @@ -2502,7 +3004,7 @@ "o_max": 100, "smooth_method": "mean", "count": 3, - "simulator": true, + "simulator": false, "uuid": "tss-001", "supplier": "Hach", "category": "sensor", @@ -2764,7 +3266,7 @@ "i_max": 1500, "o_min": 800, "o_max": 1500, - "simulator": true, + "simulator": false, "uuid": "pt-w-dn-001", "supplier": "Endress+Hauser", "model": "Cerabar-PMC51", @@ -3020,7 +3522,7 @@ ], "repeat": "", "crontab": "", - "once": true, + "once": false, "onceDelay": "0.5", "topic": "calibratePredictedVolume", "payload": "200", @@ -3050,7 +3552,7 @@ ], "repeat": "", "crontab": "", - "once": true, + "once": false, "onceDelay": "0.5", "topic": "calibratePredictedVolume", "payload": "100", @@ -3080,7 +3582,7 @@ ], "repeat": "", "crontab": "", - "once": true, + "once": false, "onceDelay": "0.5", "topic": "calibratePredictedVolume", "payload": "50", @@ -6106,7 +6608,7 @@ "demo_link_influx_out_treatment" ], [ - "demo_reactor" + "demo_reactor_z1" ] ], "positionIcon": "⊥", @@ -6215,7 +6717,7 @@ "y": 1100, "wires": [ [ - "demo_reactor" + "demo_reactor_z1" ] ] }, @@ -6372,13 +6874,13 @@ "type": "function", "z": "demo_tab_treatment", "name": "RAS Filter", - "func": "// Only pass RAS (inlet 2) from settler to reactor as inlet 3\nif (msg.topic === 'Fluent' && msg.payload && msg.payload.inlet === 2) {\n msg.payload.inlet = 3; // reactor inlet 3 = RAS\n return msg;\n}\nreturn null;", + "func": "// Only pass RAS (inlet 2) from settler to reactor zone 1 as inlet 3\nif (msg.topic === 'Fluent' && msg.payload && msg.payload.inlet === 2) {\n msg.payload.inlet = 3;\n return msg;\n}\nreturn null;", "outputs": 1, "x": 1060, "y": 760, "wires": [ [ - "demo_reactor" + "demo_reactor_z1" ] ] }, @@ -6387,7 +6889,7 @@ "type": "function", "z": "demo_tab_wwtp", "name": "Prep Grafana Request", - "func": "// Stringify payload for http request node (Node-RED 4.x requires string body)\nmsg.payload = JSON.stringify(msg.payload);\nmsg.headers = msg.headers || {};\nmsg.headers['Content-Type'] = 'application/json';\nreturn msg;", + "func": "// Stringify payload for http request node (Node-RED 4.x requires string body)\nmsg.payload = JSON.stringify(msg.payload);\nmsg.headers = msg.headers || {};\nmsg.headers['Content-Type'] = 'application/json';\n// Force local dev auth so stale bearer/api-key headers cannot downgrade requests to anonymous.\nmsg.headers.Authorization = 'Basic ' + Buffer.from('admin:evolv').toString('base64');\nreturn msg;", "outputs": 1, "x": 1100, "y": 1100, @@ -6525,6 +7027,41 @@ } ], "once": true, + "onceDelay": "34", + "x": 810, + "y": 1200, + "wires": [ + [ + "demo_dashapi" + ] + ], + "repeatType": "none", + "crontab": "", + "repeat": "" + }, + { + "id": "demo_inj_dashapi_reactor_z1", + "type": "inject", + "z": "demo_tab_wwtp", + "name": "Gen dashboards: Reactor Z1", + "props": [ + { + "p": "topic", + "v": "registerChild", + "vt": "str" + }, + { + "p": "payload", + "v": "demo_reactor_z1", + "vt": "str" + }, + { + "p": "includeChildren", + "v": "true", + "vt": "bool" + } + ], + "once": true, "onceDelay": "26", "x": 810, "y": 1200, @@ -6537,12 +7074,117 @@ "crontab": "", "repeat": "" }, + { + "id": "demo_inj_dashapi_reactor_z2", + "type": "inject", + "z": "demo_tab_wwtp", + "name": "Gen dashboards: Reactor Z2", + "props": [ + { + "p": "topic", + "v": "registerChild", + "vt": "str" + }, + { + "p": "payload", + "v": "demo_reactor_z2", + "vt": "str" + }, + { + "p": "includeChildren", + "v": "true", + "vt": "bool" + } + ], + "once": true, + "onceDelay": "28", + "x": 810, + "y": 1240, + "wires": [ + [ + "demo_dashapi" + ] + ], + "repeatType": "none", + "crontab": "", + "repeat": "" + }, + { + "id": "demo_inj_dashapi_reactor_z3", + "type": "inject", + "z": "demo_tab_wwtp", + "name": "Gen dashboards: Reactor Z3", + "props": [ + { + "p": "topic", + "v": "registerChild", + "vt": "str" + }, + { + "p": "payload", + "v": "demo_reactor_z3", + "vt": "str" + }, + { + "p": "includeChildren", + "v": "true", + "vt": "bool" + } + ], + "once": true, + "onceDelay": "30", + "x": 810, + "y": 1280, + "wires": [ + [ + "demo_dashapi" + ] + ], + "repeatType": "none", + "crontab": "", + "repeat": "" + }, + { + "id": "demo_inj_dashapi_reactor_z4", + "type": "inject", + "z": "demo_tab_wwtp", + "name": "Gen dashboards: Reactor Z4", + "props": [ + { + "p": "topic", + "v": "registerChild", + "vt": "str" + }, + { + "p": "payload", + "v": "demo_reactor_z4", + "vt": "str" + }, + { + "p": "includeChildren", + "v": "true", + "vt": "bool" + } + ], + "once": true, + "onceDelay": "32", + "x": 810, + "y": 1320, + "wires": [ + [ + "demo_dashapi" + ] + ], + "repeatType": "none", + "crontab": "", + "repeat": "" + }, { "id": "demo_fn_nh4_profile_extract", "type": "function", "z": "demo_tab_treatment", - "name": "Extract NH4 Profile", - "func": "if (msg.topic !== \"GridProfile\") return null;\nconst p = msg.payload;\nif (!p || !p.grid || !Array.isArray(p.grid)) return null;\n\nconst S_NH = 3;\nconst d_x = p.d_x;\nconst n_x = p.n_x;\nconst now = Date.now();\n\nconst positions = [0, 10, 25, 35, 50];\nconst msgs = positions.map(dist => {\n const idx = Math.min(Math.max(Math.round(dist / d_x), 0), n_x - 1);\n const val = p.grid[idx] ? p.grid[idx][S_NH] : null;\n if (val == null) return null;\n return { topic: \"NH4 @ \" + dist + \"m\", payload: Math.round(val * 100) / 100, timestamp: now };\n}).filter(Boolean);\n\nreturn [msgs];", + "name": "Extract Zone NH4 Profiles", + "func": "if (msg.topic !== 'GridProfile') return null;\nconst p = msg.payload;\nif (!p || !Array.isArray(p.grid) || !p.grid.length) return null;\nconst zone = msg.zoneLabel || 'Zone';\nconst now = Date.now();\nconst S_NH = 3;\nconst indices = [\n { label: 'In', index: 0 },\n { label: 'Mid', index: Math.max(0, Math.round((p.n_x - 1) / 2)) },\n { label: 'Out', index: p.n_x - 1 },\n];\nconst msgs = indices.map(({ label, index }) => {\n const row = p.grid[index];\n if (!Array.isArray(row)) return null;\n return { topic: `${zone} NH4 ${label}`, payload: Math.round(Number(row[S_NH]) * 100) / 100, timestamp: now };\n}).filter(Boolean);\nreturn [msgs];", "outputs": 1, "x": 640, "y": 280, @@ -6556,7 +7198,7 @@ "id": "demo_inj_reactor_dispersion", "type": "inject", "z": "demo_tab_treatment", - "name": "Reactor Dispersion D=10", + "name": "Reactor Dispersion D=25", "props": [ { "p": "topic", @@ -6566,7 +7208,7 @@ { "p": "payload", "vt": "num", - "v": "10" + "v": "25" } ], "repeat": "", @@ -6578,7 +7220,10 @@ "y": 160, "wires": [ [ - "demo_reactor" + "demo_reactor_z1", + "demo_reactor_z2", + "demo_reactor_z3", + "demo_reactor_z4" ] ] } diff --git a/nodes/diffuser b/nodes/diffuser index c4dda59..3ccac81 160000 --- a/nodes/diffuser +++ b/nodes/diffuser @@ -1 +1 @@ -Subproject commit c4dda5955fadfeeb630805933ef7c54171aca3b3 +Subproject commit 3ccac81acfd18a650c7369702eef383ab3323828 diff --git a/nodes/reactor b/nodes/reactor index 2c69a5a..2e3ba8a 160000 --- a/nodes/reactor +++ b/nodes/reactor @@ -1 +1 @@ -Subproject commit 2c69a5a0c1a615fc301d10adc56267c8d2992e36 +Subproject commit 2e3ba8a9bf6b1421165264760c1f886828d13c87 diff --git a/nodes/settler b/nodes/settler index 7f2d326..9af42bd 160000 --- a/nodes/settler +++ b/nodes/settler @@ -1 +1 @@ -Subproject commit 7f2d3266123218003838ca8cf0f9abd0c1bc2856 +Subproject commit 9af42bdc4c0aae92a1c02094a758e3ddf53f1ac1 diff --git a/package.json b/package.json index 4af5c16..65fd655 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,11 @@ "wastewater" ], "node-red": { - "nodes": { - "dashboardapi": "nodes/dashboardAPI/dashboardapi.js", - "machineGroupControl": "nodes/machineGroupControl/mgc.js", - "measurement": "nodes/measurement/measurement.js", + "nodes": { + "dashboardapi": "nodes/dashboardAPI/dashboardapi.js", + "diffuser": "nodes/diffuser/diffuser.js", + "machineGroupControl": "nodes/machineGroupControl/mgc.js", + "measurement": "nodes/measurement/measurement.js", "monster": "nodes/monster/monster.js", "reactor": "nodes/reactor/reactor.js", "rotatingMachine": "nodes/rotatingMachine/rotatingMachine.js", @@ -30,11 +31,12 @@ "docker:logs": "docker compose logs -f nodered", "docker:shell": "docker compose exec nodered sh", "docker:test": "docker compose exec nodered sh /data/evolv/scripts/test-all.sh", - "docker:test:basic": "docker compose exec nodered sh /data/evolv/scripts/test-all.sh basic", - "docker:test:integration": "docker compose exec nodered sh /data/evolv/scripts/test-all.sh integration", - "docker:test:edge": "docker compose exec nodered sh /data/evolv/scripts/test-all.sh edge", - "docker:test:gf": "docker compose exec nodered sh /data/evolv/scripts/test-all.sh gf", - "docker:validate": "docker compose exec nodered sh /data/evolv/scripts/validate-nodes.sh", + "docker:test:basic": "docker compose exec nodered sh /data/evolv/scripts/test-all.sh basic", + "docker:test:integration": "docker compose exec nodered sh /data/evolv/scripts/test-all.sh integration", + "docker:test:edge": "docker compose exec nodered sh /data/evolv/scripts/test-all.sh edge", + "docker:test:gf": "docker compose exec nodered sh /data/evolv/scripts/test-all.sh gf", + "test:e2e:reactor": "node scripts/e2e-reactor-roundtrip.js", + "docker:validate": "docker compose exec nodered sh /data/evolv/scripts/validate-nodes.sh", "docker:deploy": "docker compose exec nodered sh /data/evolv/scripts/deploy-flow.sh", "docker:reset": "docker compose down -v && docker compose up -d --build" }, diff --git a/scripts/e2e-reactor-roundtrip.js b/scripts/e2e-reactor-roundtrip.js new file mode 100644 index 0000000..afc31fc --- /dev/null +++ b/scripts/e2e-reactor-roundtrip.js @@ -0,0 +1,269 @@ +#!/usr/bin/env node +/** + * E2E reactor round-trip test: + * Node-RED -> InfluxDB -> Grafana proxy query + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const NR_URL = process.env.NR_URL || 'http://localhost:1880'; +const INFLUX_URL = process.env.INFLUX_URL || 'http://localhost:8086'; +const GRAFANA_URL = process.env.GRAFANA_URL || 'http://localhost:3000'; +const GRAFANA_USER = process.env.GRAFANA_USER || 'admin'; +const GRAFANA_PASSWORD = process.env.GRAFANA_PASSWORD || 'evolv'; +const INFLUX_ORG = process.env.INFLUX_ORG || 'evolv'; +const INFLUX_BUCKET = process.env.INFLUX_BUCKET || 'telemetry'; +const INFLUX_TOKEN = process.env.INFLUX_TOKEN || 'evolv-dev-token'; +const GRAFANA_DS_UID = process.env.GRAFANA_DS_UID || 'cdzg44tv250jkd'; +const FLOW_FILE = path.join(__dirname, '..', 'docker', 'demo-flow.json'); +const REQUIRE_GRAFANA_DASHBOARDS = process.env.REQUIRE_GRAFANA_DASHBOARDS === '1'; +const REACTOR_MEASUREMENTS = [ + 'reactor_demo_reactor_z1', + 'reactor_demo_reactor_z2', + 'reactor_demo_reactor_z3', + 'reactor_demo_reactor_z4', +]; +const REACTOR_MEASUREMENT = REACTOR_MEASUREMENTS[3]; +const QUERY_TIMEOUT_MS = 90000; +const POLL_INTERVAL_MS = 3000; +const REQUIRED_DASHBOARD_TITLES = ['Bioreactor Z1', 'Bioreactor Z2', 'Bioreactor Z3', 'Bioreactor Z4', 'Settler S1']; + +async function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchJson(url, options = {}) { + const response = await fetch(url, options); + const text = await response.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body, text }; +} + +async function assertReachable() { + const checks = [ + [`${NR_URL}/settings`, 'Node-RED'], + [`${INFLUX_URL}/health`, 'InfluxDB'], + [`${GRAFANA_URL}/api/health`, 'Grafana'], + ]; + + for (const [url, label] of checks) { + const { response, text } = await fetchJson(url, { + headers: label === 'Grafana' + ? { Authorization: `Basic ${Buffer.from(`${GRAFANA_USER}:${GRAFANA_PASSWORD}`).toString('base64')}` } + : undefined, + }); + if (!response.ok) { + throw new Error(`${label} not reachable at ${url} (${response.status}): ${text}`); + } + console.log(`PASS: ${label} reachable`); + } +} + +async function deployDemoFlow() { + const flow = JSON.parse(fs.readFileSync(FLOW_FILE, 'utf8')); + const { response, text } = await fetchJson(`${NR_URL}/flows`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Node-RED-Deployment-Type': 'full', + }, + body: JSON.stringify(flow), + }); + + if (!(response.status === 200 || response.status === 204)) { + throw new Error(`Flow deploy failed (${response.status}): ${text}`); + } + console.log(`PASS: Demo flow deployed (${response.status})`); +} + +async function queryInfluxCsv(query) { + const response = await fetch(`${INFLUX_URL}/api/v2/query?org=${encodeURIComponent(INFLUX_ORG)}`, { + method: 'POST', + headers: { + Authorization: `Token ${INFLUX_TOKEN}`, + 'Content-Type': 'application/json', + Accept: 'application/csv', + }, + body: JSON.stringify({ query }), + }); + + const text = await response.text(); + if (!response.ok) { + throw new Error(`Influx query failed (${response.status}): ${text}`); + } + return text; +} + +function countCsvDataRows(csvText) { + return csvText + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#') && line.includes(',')) + .length; +} + +async function waitForReactorTelemetry() { + const deadline = Date.now() + QUERY_TIMEOUT_MS; + + while (Date.now() < deadline) { + const counts = {}; + for (const measurement of REACTOR_MEASUREMENTS) { + const query = ` +from(bucket: "${INFLUX_BUCKET}") + |> range(start: -15m) + |> filter(fn: (r) => r._measurement == "${measurement}") + |> limit(n: 20) +`.trim(); + counts[measurement] = countCsvDataRows(await queryInfluxCsv(query)); + } + + const missing = Object.entries(counts) + .filter(([, rows]) => rows === 0) + .map(([measurement]) => measurement); + + if (missing.length === 0) { + const summary = Object.entries(counts) + .map(([measurement, rows]) => `${measurement}=${rows}`) + .join(', '); + console.log(`PASS: Reactor telemetry reached InfluxDB (${summary})`); + return; + } + console.log(`WAIT: reactor telemetry not yet present in InfluxDB for ${missing.join(', ')}`); + await wait(POLL_INTERVAL_MS); + } + + throw new Error(`Timed out waiting for reactor telemetry measurements ${REACTOR_MEASUREMENTS.join(', ')}`); +} + +async function assertGrafanaDatasource() { + const auth = `Basic ${Buffer.from(`${GRAFANA_USER}:${GRAFANA_PASSWORD}`).toString('base64')}`; + const { response, body, text } = await fetchJson(`${GRAFANA_URL}/api/datasources/uid/${GRAFANA_DS_UID}`, { + headers: { Authorization: auth }, + }); + + if (!response.ok) { + throw new Error(`Grafana datasource lookup failed (${response.status}): ${text}`); + } + + if (body?.uid !== GRAFANA_DS_UID) { + throw new Error(`Grafana datasource UID mismatch: expected ${GRAFANA_DS_UID}, got ${body?.uid}`); + } + + console.log(`PASS: Grafana datasource ${GRAFANA_DS_UID} is present`); +} + +async function queryGrafanaDatasource() { + const auth = `Basic ${Buffer.from(`${GRAFANA_USER}:${GRAFANA_PASSWORD}`).toString('base64')}`; + const response = await fetch(`${GRAFANA_URL}/api/ds/query`, { + method: 'POST', + headers: { + Authorization: auth, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: 'now-15m', + to: 'now', + queries: [ + { + refId: 'A', + datasource: { uid: GRAFANA_DS_UID, type: 'influxdb' }, + query: ` +from(bucket: "${INFLUX_BUCKET}") + |> range(start: -15m) + |> filter(fn: (r) => r._measurement == "${REACTOR_MEASUREMENT}" and r._field == "S_O") + |> last() +`.trim(), + rawQuery: true, + intervalMs: 1000, + maxDataPoints: 100, + } + ], + }), + }); + + const text = await response.text(); + if (!response.ok) { + throw new Error(`Grafana datasource query failed (${response.status}): ${text}`); + } + + const body = JSON.parse(text); + const frames = body?.results?.A?.frames || []; + if (frames.length === 0) { + throw new Error('Grafana datasource query returned no reactor frames'); + } + + console.log(`PASS: Grafana can query reactor telemetry through datasource (${frames.length} frame(s))`); +} + +async function waitForGrafanaDashboards(timeoutMs = QUERY_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs; + const auth = `Basic ${Buffer.from(`${GRAFANA_USER}:${GRAFANA_PASSWORD}`).toString('base64')}`; + + while (Date.now() < deadline) { + const response = await fetch(`${GRAFANA_URL}/api/search?query=`, { + headers: { Authorization: auth }, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Grafana dashboard search failed (${response.status}): ${text}`); + } + + const results = JSON.parse(text); + const titles = new Set(results.map((item) => item.title)); + const missing = REQUIRED_DASHBOARD_TITLES.filter((title) => !titles.has(title)); + const pumpingStationCount = results.filter((item) => item.title === 'pumpingStation').length; + if (missing.length === 0 && pumpingStationCount >= 3) { + console.log(`PASS: Grafana dashboards created (${REQUIRED_DASHBOARD_TITLES.join(', ')} + ${pumpingStationCount} pumpingStation dashboards)`); + return; + } + + const missingParts = []; + if (missing.length > 0) { + missingParts.push(`missing titled dashboards: ${missing.join(', ')}`); + } + if (pumpingStationCount < 3) { + missingParts.push(`pumpingStation dashboards=${pumpingStationCount}`); + } + console.log(`WAIT: Grafana dashboards not ready: ${missingParts.join(' | ')}`); + await wait(POLL_INTERVAL_MS); + } + + throw new Error(`Timed out waiting for Grafana dashboards: ${REQUIRED_DASHBOARD_TITLES.join(', ')} and >=3 pumpingStation dashboards`); +} + +async function main() { + console.log('=== EVOLV Reactor E2E Round Trip ==='); + await assertReachable(); + await deployDemoFlow(); + console.log('WAIT: allowing Node-RED inject/tick loops to populate telemetry'); + await wait(12000); + await waitForReactorTelemetry(); + await assertGrafanaDatasource(); + await queryGrafanaDatasource(); + if (REQUIRE_GRAFANA_DASHBOARDS) { + await waitForGrafanaDashboards(); + console.log('PASS: Node-RED -> InfluxDB -> Grafana round trip is working for reactor telemetry and dashboard generation'); + return; + } + + try { + await waitForGrafanaDashboards(15000); + console.log('PASS: Node-RED -> InfluxDB -> Grafana round trip is working for reactor telemetry and dashboard generation'); + } catch (error) { + console.warn(`WARN: Grafana dashboard auto-generation is not ready yet: ${error.message}`); + console.log('PASS: Node-RED -> InfluxDB -> Grafana round trip is working for live reactor telemetry'); + } +} + +main().catch((error) => { + console.error(`FAIL: ${error.message}`); + process.exit(1); +});