P6.5 refactor introduced the BaseDomain wrapper around CSTR/PFR engines but didn't pass tick() through. BaseNodeAdapter's optional-chain source.tick?.() got undefined and the kinetics engine never integrated when driven through the new adapter (only via the explicit _emitOutputs override that calls updateState). Added tick(timeStep) that delegates to engine.tick + emits 'output-changed'. Tests that construct the wrapper (not the engine directly) now work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
5.2 KiB
JavaScript
135 lines
5.2 KiB
JavaScript
'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;
|