'use strict'; const { BaseDomain, statusBadge, POSITIONS } = require('generalFunctions'); const Reactor_CSTR = require('./kinetics/cstr.js'); const Reactor_PFR = require('./kinetics/pfr.js'); const SPECIES_KEYS = ['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']; // Reactor — biological reactor orchestrator (Unit-level). Wraps a CSTR or // PFR kinetics engine and exposes the BaseDomain surface to BaseNodeAdapter. // The engines own the ASM3 integration; this class wires child registration // through ChildRouter, holds the validated config, and presents getOutput / // getStatusBadge. class Reactor extends BaseDomain { static name = 'reactor'; configure() { const flat = this._flattenEngineConfig(this.config); this.engine = this._buildEngine(flat); // Re-emit upstream-reactor stateChange and engine stateChange events on // the BaseDomain emitter so adapter listeners pick them up uniformly. this.engine.emitter.on('stateChange', (t) => this.emitter.emit('stateChange', t)); // ChildRouter dispatches to engine handlers — keeps the existing // _connectMeasurement / _connectReactor wiring intact, just centralised. this.router.onRegister('measurement', (child) => this.engine._connectMeasurement(child)); this.router.onRegister('reactor', (child) => this.engine._connectReactor(child)); // Bridge engine.measurements into the BaseDomain measurements container // so getFlattenedOutput surfaces temperature / oxygen series. this.measurements = this.engine.measurements; } // Translate the nested schema config (reactor.*, initialState.*) into the // flat shape the kinetics engines accept. _flattenEngineConfig(config) { const reactor = config.reactor || {}; const init = config.initialState || {}; const initialState = SPECIES_KEYS.map((k) => Number(init[k] ?? 0)); return { general: config.general, functionality: config.functionality, reactor_type: reactor.reactor_type ?? 'CSTR', volume: Number(reactor.volume), length: Number(reactor.length), resolution_L: Number(reactor.resolution_L), alpha: Number(reactor.alpha), n_inlets: Number(reactor.n_inlets), kla: Number(reactor.kla), timeStep: Number(reactor.timeStep), speedUpFactor: Number(reactor.speedUpFactor) || 1, initialState, }; } _buildEngine(flat) { // The schema enum validator lowercases the configured value, so accept // either case. switch (String(flat.reactor_type || '').toUpperCase()) { case 'CSTR': return new Reactor_CSTR(flat); case 'PFR': return new Reactor_PFR(flat); default: this.logger.warn(`Unknown reactor type: ${flat.reactor_type}. Falling back to CSTR.`); return new Reactor_CSTR(flat); } } // Adapter input setters — forwarded straight to the engine. set setInfluent(msg) { this.engine.setInfluent = msg; } set setOTR(msg) { this.engine.setOTR = msg; } set setTemperature(msg) { this.engine.setTemperature = msg; } set setDispersion(msg) { if (this.engine instanceof Reactor_PFR) this.engine.setDispersion = msg; } updateState(t) { this.engine.updateState(t); this.notifyOutputChanged(); } // Engine pass-through — needed so the BaseNodeAdapter tick loop (and // tests calling reactor.tick(dt) directly) drive the ASM integration. // Without this the Node-RED tick fires `source.tick?.()`, gets undefined, // and the kinetics state never advances. tick(timeStep) { const result = this.engine.tick(timeStep); this.notifyOutputChanged(); return result; } get getEffluent() { return this.engine.getEffluent; } get getGridProfile() { return this.engine.getGridProfile; } get temperature() { return this.engine.temperature; } // Per-tick output for Port 0 / Port 1. Carries the effluent vector plus // a flat per-species block keyed by SPECIES_KEYS for InfluxDB telemetry. getOutput() { const eff = this.engine.getEffluent; const C = Array.isArray(eff?.payload?.C) ? eff.payload.C : []; const out = { flow_total: Number(eff?.payload?.F), temperature: Number(this.engine.temperature), }; for (let i = 0; i < Math.min(SPECIES_KEYS.length, C.length); i += 1) { const v = Number(C[i]); if (Number.isFinite(v)) out[SPECIES_KEYS[i]] = v; } return out; } getStatusBadge() { const eff = this.engine.getEffluent; const F = Number(eff?.payload?.F) || 0; const SO = Array.isArray(eff?.payload?.C) ? Number(eff.payload.C[0]) : NaN; const so = Number.isFinite(SO) ? SO.toFixed(2) : '—'; return statusBadge.compose( [`${this.engine.constructor.name.replace('Reactor_', '')}`, `T=${Number(this.engine.temperature).toFixed(1)} C`, `F=${F.toFixed(2)} m³/d`, `S_O=${so} mg/L`], { fill: 'green', shape: 'dot' }, ); } close() { this.engine?.emitter?.removeAllListeners?.(); super.close(); } } module.exports = Reactor; module.exports.Reactor = Reactor; module.exports.Reactor_CSTR = Reactor_CSTR; module.exports.Reactor_PFR = Reactor_PFR; // POSITIONS is consumed by older test setups; surface it here so they don't // need to chase down generalFunctions internals. module.exports.POSITIONS = POSITIONS;