P6: convert settler to platform infrastructure

Refactor of settler to use BaseNodeAdapter + commandRegistry + statusBadge.
settler follows the platform refactor plan in .claude/refactor/MODULE_SPLIT.md.
Tests stay green; CONTRACT.md generated; legacy aliases preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-10 22:23:44 +02:00
parent b199663c77
commit b8247fc755
6 changed files with 328 additions and 190 deletions

View File

@@ -1,157 +1,146 @@
const { childRegistrationUtils, logger, MeasurementContainer, POSITIONS } = require('generalFunctions');
const EventEmitter = require('events');
'use strict';
const { BaseDomain, POSITIONS, statusBadge } = require('generalFunctions');
// Compatibility-safe array clone for Node runtimes without global structuredClone.
function cloneArray(values) {
if (typeof structuredClone === 'function') {
return structuredClone(values);
}
if (typeof structuredClone === 'function') return structuredClone(values);
return Array.isArray(values) ? [...values] : values;
}
/**
* Settler domain model.
* Splits influent into effluent, sludge and return sludge based on solids balance.
*/
class Settler {
constructor(config) {
this.config = config;
// EVOLV stuff
this.logger = new logger(this.config.general.logging.enabled, this.config.general.logging.logLevel, config.general.name);
this.emitter = new EventEmitter();
this.measurements = new MeasurementContainer();
this.childRegistrationUtils = new childRegistrationUtils(this); // Child registration utility
// Settler — secondary clarifier / sludge separator (Unit level).
// Splits influent into effluent, surplus sludge and return sludge based
// on a TSS mass balance. State updates come from an upstream reactor
// (stateChange → pull `getEffluent`) or operator-supplied influent via
// the `data.influent` command. The 3-port Fluent stream is produced by
// `getEffluent` and pushed onto Port 0 by the nodeClass.
class Settler extends BaseDomain {
static name = 'settler';
configure() {
this.upstreamReactor = null;
this.returnPump = null;
// state variables
this.F_in = 0; // debit in
this.Cs_in = new Array(13).fill(0); // Concentrations in
this.C_TS = 2500; // Total solids concentration sludge
this.F_in = 0;
this.Cs_in = new Array(13).fill(0);
this.C_TS = 2500;
this.router
.onRegister('measurement', (child) => this._connectMeasurement(child))
.onRegister('reactor', (child) => this._connectReactor(child))
.onRegister('machine', (child) => this._connectMachine(child));
}
// Three-stream output: effluent (inlet=0), surplus sludge (inlet=1),
// return sludge (inlet=2). Downstream consumers (reactor inlets,
// returnPump) read these by `payload.inlet`. F_s is clamped to F_in
// to prevent negative effluent when X_TS_in/C_TS exceeds 1.
get getEffluent() {
// constrain flow to prevent negatives
const F_s = Math.min((this.F_in * this.Cs_in[12]) / this.C_TS, this.F_in);
const F_eff = this.F_in - F_s;
let F_sr = 0;
if (this.returnPump) {
F_sr = Math.min(this.returnPump.measurements.type("flow").variant("measured").position(POSITIONS.AT_EQUIPMENT).getCurrentValue(), F_s);
F_sr = Math.min(
this.returnPump.measurements.type('flow').variant('measured').position(POSITIONS.AT_EQUIPMENT).getCurrentValue(),
F_s,
);
}
const F_so = F_s - F_sr;
// effluent
const Cs_eff = cloneArray(this.Cs_in);
if (F_s > 0) {
Cs_eff[7] = 0;
Cs_eff[8] = 0;
Cs_eff[9] = 0;
Cs_eff[10] = 0;
Cs_eff[11] = 0;
Cs_eff[12] = 0;
}
if (F_s > 0) for (let i = 7; i <= 12; i++) Cs_eff[i] = 0;
// sludge
const Cs_s = cloneArray(this.Cs_in);
if (F_s > 0) {
Cs_s[7] = this.F_in * this.Cs_in[7] / F_s;
Cs_s[8] = this.F_in * this.Cs_in[8] / F_s;
Cs_s[9] = this.F_in * this.Cs_in[9] / F_s;
Cs_s[10] = this.F_in * this.Cs_in[10] / F_s;
Cs_s[11] = this.F_in * this.Cs_in[11] / F_s;
Cs_s[12] = this.F_in * this.Cs_in[12] / F_s;
}
if (F_s > 0) for (let i = 7; i <= 12; i++) Cs_s[i] = this.F_in * this.Cs_in[i] / F_s;
const ts = Date.now();
return [
{ topic: "Fluent", payload: { inlet: 0, F: F_eff, C: Cs_eff }, timestamp: Date.now() },
{ topic: "Fluent", payload: { inlet: 1, F: F_so, C: Cs_s }, timestamp: Date.now() },
{ topic: "Fluent", payload: { inlet: 2, F: F_sr, C: Cs_s }, timestamp: Date.now() }
{ topic: 'Fluent', payload: { inlet: 0, F: F_eff, C: Cs_eff }, timestamp: ts },
{ topic: 'Fluent', payload: { inlet: 1, F: F_so, C: Cs_s }, timestamp: ts },
{ topic: 'Fluent', payload: { inlet: 2, F: F_sr, C: Cs_s }, timestamp: ts },
];
}
registerChild(child, softwareType) {
if(!child) {
this.logger.error(`Invalid ${softwareType} child provided.`);
return;
}
switch (softwareType) {
case "measurement":
this.logger.debug(`Registering measurement child...`);
this._connectMeasurement(child);
break;
case "reactor":
this.logger.debug(`Registering reactor child...`);
this._connectReactor(child);
break;
case "machine":
this.logger.debug(`Registering machine child...`);
this._connectMachine(child);
break;
default:
this.logger.error(`Unrecognized softwareType: ${softwareType}`);
}
}
_connectMeasurement(measurementChild) {
const position = measurementChild.config.functionality.positionVsParent;
const measurementType = measurementChild.config.asset.type;
const eventName = `${measurementType}.measured.${position}`;
const eventName = `${measurementType}.measured.${String(position).toLowerCase()}`;
// Register event listener for measurement updates
measurementChild.measurements.emitter.on(eventName, (eventData) => {
this.logger.debug(`${position} ${measurementType} from ${eventData.childName}: ${eventData.value} ${eventData.unit}`);
// Store directly in parent's measurement container
this.measurements
.type(measurementType)
.variant("measured")
.variant('measured')
.position(position)
.value(eventData.value, eventData.timestamp, eventData.unit);
this._updateMeasurement(measurementType, eventData.value, position, eventData);
});
}
// Reactor → settler integration: the reactor pushes a `stateChange` event
// on its own emitter (NOT measurements.emitter), so router.onMeasurement
// can't subscribe — we wire the listener manually here, mirroring the
// pre-refactor `_connectReactor`. The settler pulls `getEffluent` rather
// than receiving it pushed; reactor.getEffluent may return an array or a
// single envelope (the 2026-03-02 bug fix preserved both shapes).
_connectReactor(reactorChild) {
if (reactorChild.config.functionality.positionVsParent != POSITIONS.UPSTREAM) {
this.logger.warn("Reactor children of settlers should be upstream.");
if (reactorChild.config.functionality.positionVsParent !== POSITIONS.UPSTREAM) {
this.logger.warn('Reactor children of settlers should be upstream.');
}
this.upstreamReactor = reactorChild;
reactorChild.emitter.on("stateChange", (_eventData) => {
this.logger.debug(`State change of upstream reactor detected.`);
reactorChild.emitter.on('stateChange', () => {
this.logger.debug('State change of upstream reactor detected.');
const raw = this.upstreamReactor.getEffluent;
const effluent = Array.isArray(raw) ? raw[0] : raw;
this.F_in = effluent.payload.F;
this.Cs_in = effluent.payload.C;
this.notifyOutputChanged();
});
}
_connectMachine(machineChild) {
if (machineChild.config.functionality.positionVsParent == POSITIONS.DOWNSTREAM) {
if (machineChild.config.functionality.positionVsParent === POSITIONS.DOWNSTREAM) {
machineChild.upstreamSource = this;
this.returnPump = machineChild;
return;
}
this.logger.warn(`Failed to register machine child.`);
this.logger.warn('Failed to register machine child.');
}
_updateMeasurement(measurementType, value, _position, _context) {
switch(measurementType) {
case "quantity (tss)":
_updateMeasurement(measurementType, value /*, _position, _context */) {
switch (measurementType) {
case 'quantity (tss)':
this.C_TS = value;
break;
this.notifyOutputChanged();
return;
default:
this.logger.error(`Type '${measurementType}' not recognized for measured update.`);
return;
}
}
// Telemetry snapshot for Port 1 (InfluxDB). Port 0 carries the 3-message
// Fluent stream directly; this scalar view feeds dashboards.
getOutput() {
const streams = this.getEffluent;
return {
...this.measurements.getFlattenedOutput?.(),
F_in: this.F_in,
C_TS: this.C_TS,
F_eff: streams[0].payload.F,
F_surplus: streams[1].payload.F,
F_return: streams[2].payload.F,
};
}
getStatusBadge() {
if (this.F_in <= 0) return statusBadge.idle('no influent');
const streams = this.getEffluent;
const eff = streams[0].payload.F.toFixed(2);
const sur = streams[1].payload.F.toFixed(2);
return statusBadge.compose([`F_in=${this.F_in.toFixed(2)}`, `eff=${eff}`, `surplus=${sur}`], { fill: 'green', shape: 'dot' });
}
}
module.exports = { Settler };
module.exports = Settler;
module.exports.Settler = Settler;