Files
reactor/src/specificClass.js
znetsixe 297c6713de fix: expose tick(dt) on Reactor wrapper
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>
2026-05-11 16:04:47 +02:00

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;