diff --git a/settler.html b/settler.html index 24578fb..703a588 100644 --- a/settler.html +++ b/settler.html @@ -1,68 +1,88 @@ - - - - - - - \ No newline at end of file + + + + + + + diff --git a/src/nodeClass.js b/src/nodeClass.js index f0a8c11..c1c3526 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -1,33 +1,34 @@ -const { Settler } = require('./specificClass.js'); - - -class nodeClass { - /** - * Node-RED node class for settler. - * @param {object} uiConfig - Node-RED node configuration - * @param {object} RED - Node-RED runtime API - * @param {object} nodeInstance - Node-RED node instance - * @param {string} nameOfNode - Name of the node - */ - constructor(uiConfig, RED, nodeInstance, nameOfNode) { - // Preserve RED reference for HTTP endpoints if needed - this.node = nodeInstance; - this.RED = RED; - this.name = nameOfNode; - this.source = null; - - this._loadConfig(uiConfig) - this._setupClass(); - - this._attachInputHandler(); - this._registerChild(); - this._startTickLoop(); - this._attachCloseHandler(); - } - - /** - * Handle node-red input messages - */ +const { Settler } = require('./specificClass.js'); +const { configManager } = require('generalFunctions'); + + +class nodeClass { + /** + * Node-RED node class for settler. + * @param {object} uiConfig - Node-RED node configuration + * @param {object} RED - Node-RED runtime API + * @param {object} nodeInstance - Node-RED node instance + * @param {string} nameOfNode - Name of the node + */ + constructor(uiConfig, RED, nodeInstance, nameOfNode) { + // Preserve RED reference for HTTP endpoints if needed + this.node = nodeInstance; + this.RED = RED; + this.name = nameOfNode; + this.source = null; + + this._loadConfig(uiConfig) + this._setupClass(); + + this._attachInputHandler(); + this._registerChild(); + this._startTickLoop(); + this._attachCloseHandler(); + } + + /** + * Handle node-red input messages + */ _attachInputHandler() { this.node.on('input', (msg, send, done) => { try { @@ -54,62 +55,49 @@ class nodeClass { } }); } - - /** - * Parse node configuration - * @param {object} uiConfig Config set in UI in node-red - */ - _loadConfig(uiConfig) { - this.config = { - general: { - name: uiConfig.name || this.name, - id: this.node.id, - unit: null, - logging: { - enabled: uiConfig.enableLog, - logLevel: uiConfig.logLevel - } - }, - functionality: { - positionVsParent: uiConfig.positionVsParent || 'atEquipment', // Default to 'atEquipment' if not specified - softwareType: "settler" // should be set in config manager - } - } - } - - /** - * Register this node as a child upstream and downstream. - * Delayed to avoid Node-RED startup race conditions. - */ - _registerChild() { - setTimeout(() => { - this.node.send([ - null, - null, - { topic: 'registerChild', payload: this.node.id, positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' } - ]); - }, 100); - } - - /** - * Setup settler class - */ - _setupClass() { - - this.source = new Settler(this.config); // protect from reassignment - this.node.source = this.source; - } - - _startTickLoop() { - setTimeout(() => { - this._tickInterval = setInterval(() => this._tick(), 1000); - }, 1000); - } - - _tick(){ - this.node.send([this.source.getEffluent, null, null]); - } - + + /** + * Parse node configuration + * @param {object} uiConfig Config set in UI in node-red + */ + _loadConfig(uiConfig) { + const cfgMgr = new configManager(); + this.config = cfgMgr.buildConfig('settler', uiConfig, this.node.id); + } + + /** + * Register this node as a child upstream and downstream. + * Delayed to avoid Node-RED startup race conditions. + */ + _registerChild() { + setTimeout(() => { + this.node.send([ + null, + null, + { topic: 'registerChild', payload: this.node.id, positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment' } + ]); + }, 100); + } + + /** + * Setup settler class + */ + _setupClass() { + + this.source = new Settler(this.config); // protect from reassignment + this.node.source = this.source; + } + + _startTickLoop() { + setTimeout(() => { + this._tickInterval = setInterval(() => this._tick(), 1000); + }, 1000); + } + + _tick(){ + this.node.send([this.source.getEffluent, null, null]); + } + _attachCloseHandler() { this.node.on('close', (done) => { clearInterval(this._tickInterval); @@ -117,5 +105,5 @@ class nodeClass { }); } } - + module.exports = nodeClass; diff --git a/src/specificClass.js b/src/specificClass.js index 3f2627c..6886946 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -1,157 +1,157 @@ -const { childRegistrationUtils, logger, MeasurementContainer } = require('generalFunctions'); -const EventEmitter = require('events'); - -// Compatibility-safe array clone for Node runtimes without global structuredClone. -function cloneArray(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 - - 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 - } - - 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("atEquipment").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; - } - - // 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; - } - - 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() } - ]; - } - - 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}`; - - // 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") - .position(position) - .value(eventData.value, eventData.timestamp, eventData.unit); - - this._updateMeasurement(measurementType, eventData.value, position, eventData); - }); - } - - _connectReactor(reactorChild) { - if (reactorChild.config.functionality.positionVsParent != "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.`); - 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; - }); - } - - _connectMachine(machineChild) { - if (machineChild.config.functionality.positionVsParent == "downstream") { - machineChild.upstreamSource = this; - this.returnPump = machineChild; - return; - } - this.logger.warn(`Failed to register machine child.`); - } - - _updateMeasurement(measurementType, value, position, context) { - switch(measurementType) { - case "quantity (tss)": - this.C_TS = value; - break; - - default: - this.logger.error(`Type '${measurementType}' not recognized for measured update.`); - return; - } - } -} - -module.exports = { Settler }; +const { childRegistrationUtils, logger, MeasurementContainer, POSITIONS } = require('generalFunctions'); +const EventEmitter = require('events'); + +// Compatibility-safe array clone for Node runtimes without global structuredClone. +function cloneArray(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 + + 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 + } + + 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); + } + 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; + } + + // 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; + } + + 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() } + ]; + } + + 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}`; + + // 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") + .position(position) + .value(eventData.value, eventData.timestamp, eventData.unit); + + this._updateMeasurement(measurementType, eventData.value, position, eventData); + }); + } + + _connectReactor(reactorChild) { + 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.`); + 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; + }); + } + + _connectMachine(machineChild) { + if (machineChild.config.functionality.positionVsParent == POSITIONS.DOWNSTREAM) { + machineChild.upstreamSource = this; + this.returnPump = machineChild; + return; + } + this.logger.warn(`Failed to register machine child.`); + } + + _updateMeasurement(measurementType, value, _position, _context) { + switch(measurementType) { + case "quantity (tss)": + this.C_TS = value; + break; + + default: + this.logger.error(`Type '${measurementType}' not recognized for measured update.`); + return; + } + } +} + +module.exports = { Settler }; diff --git a/test/specificClass.test.js b/test/specificClass.test.js new file mode 100644 index 0000000..83e3e72 --- /dev/null +++ b/test/specificClass.test.js @@ -0,0 +1,263 @@ +/** + * Tests for settler specificClass (domain logic). + * + * The Settler class is a simple mass-balance separator: + * - Splits influent into effluent (clarified), surplus sludge, and return sludge + * - Concentrates particulate species (indices 7-12) into sludge stream + * - Removes particulates from effluent stream + * - registerChild: connects measurements, upstream reactors, machines + */ + +const { Settler } = require('../src/specificClass'); + +// --------------- helpers --------------- + +const NUM_SPECIES = 13; + +function makeConfig(overrides = {}) { + return { + general: { + name: 'TestSettler', + id: 'settler-test-1', + logging: { enabled: false, logLevel: 'error' }, + }, + functionality: { + softwareType: 'settler', + role: 'separator', + positionVsParent: 'downstream', + }, + ...overrides, + }; +} + +// --------------- tests --------------- + +describe('Settler specificClass', () => { + + describe('constructor / initialization', () => { + it('should create an instance with default values', () => { + const s = new Settler(makeConfig()); + expect(s).toBeDefined(); + expect(s.F_in).toBe(0); + expect(s.Cs_in).toEqual(new Array(NUM_SPECIES).fill(0)); + expect(s.C_TS).toBe(2500); + }); + + it('should have null upstreamReactor and returnPump initially', () => { + const s = new Settler(makeConfig()); + expect(s.upstreamReactor).toBeNull(); + expect(s.returnPump).toBeNull(); + }); + + it('should initialize an EventEmitter', () => { + const s = new Settler(makeConfig()); + expect(s.emitter).toBeDefined(); + expect(typeof s.emitter.on).toBe('function'); + }); + + it('should initialize a MeasurementContainer', () => { + const s = new Settler(makeConfig()); + expect(s.measurements).toBeDefined(); + }); + }); + + describe('getEffluent', () => { + + describe('with zero inflow', () => { + it('should return three streams with zero flows', () => { + const s = new Settler(makeConfig()); + s.F_in = 0; + s.Cs_in = new Array(NUM_SPECIES).fill(0); + const result = s.getEffluent; + expect(result).toHaveLength(3); + expect(result[0].payload.F).toBe(0); // effluent + expect(result[1].payload.F).toBe(0); // surplus sludge + expect(result[2].payload.F).toBe(0); // return sludge + }); + }); + + describe('with normal inflow and particulates', () => { + let s; + let result; + + beforeAll(() => { + s = new Settler(makeConfig()); + s.F_in = 100; + s.C_TS = 5000; + // Set concentrations: solubles at indices 0-6, particulates at 7-12 + const C = new Array(NUM_SPECIES).fill(10); + C[12] = 5000; // X_TS + s.Cs_in = C; + result = s.getEffluent; + }); + + it('should return 3 output streams', () => { + expect(result).toHaveLength(3); + }); + + it('should have effluent topic "Fluent"', () => { + expect(result[0].topic).toBe('Fluent'); + }); + + it('should calculate sludge flow F_s = min(F_in * X_TS_in / C_TS, F_in)', () => { + // F_s = min(100 * 5000 / 5000, 100) = min(100, 100) = 100 + const F_eff = result[0].payload.F; + const F_so = result[1].payload.F; + const F_sr = result[2].payload.F; + // Total out should equal F_in (mass balance) + expect(F_eff + F_so + F_sr).toBeCloseTo(100, 5); + }); + + it('should set effluent particulate indices to zero when F_s > 0', () => { + const Cs_eff = result[0].payload.C; + for (let i = 7; i <= 12; i++) { + expect(Cs_eff[i]).toBe(0); + } + }); + + it('should keep effluent soluble indices unchanged', () => { + const Cs_eff = result[0].payload.C; + for (let i = 0; i < 7; i++) { + expect(Cs_eff[i]).toBe(10); + } + }); + + it('should concentrate particulates in sludge stream', () => { + const Cs_s = result[1].payload.C; + const F_s = Math.min((s.F_in * s.Cs_in[12]) / s.C_TS, s.F_in); + // Cs_s[i] = F_in * Cs_in[i] / F_s for particulate indices + for (let i = 7; i <= 12; i++) { + const expected = s.F_in * s.Cs_in[i] / F_s; + expect(Cs_s[i]).toBeCloseTo(expected, 5); + } + }); + }); + + describe('with low X_TS and high C_TS (dilute sludge)', () => { + it('should produce mostly effluent flow', () => { + const s = new Settler(makeConfig()); + s.F_in = 100; + s.C_TS = 10000; // high target concentration + const C = new Array(NUM_SPECIES).fill(10); + C[12] = 100; // low X_TS in + s.Cs_in = C; + const result = s.getEffluent; + // F_s = min(100 * 100 / 10000, 100) = min(1, 100) = 1 + expect(result[0].payload.F).toBeCloseTo(99, 5); // most flow is effluent + }); + }); + + describe('mass balance', () => { + it('should conserve total flow (F_eff + F_so + F_sr = F_in)', () => { + const s = new Settler(makeConfig()); + s.F_in = 200; + s.C_TS = 3000; + const C = new Array(NUM_SPECIES).fill(5); + C[12] = 2000; + s.Cs_in = C; + const result = s.getEffluent; + const totalOut = result[0].payload.F + result[1].payload.F + result[2].payload.F; + expect(totalOut).toBeCloseTo(200, 5); + }); + + it('should not produce negative flows', () => { + const s = new Settler(makeConfig()); + s.F_in = 50; + s.C_TS = 1000; + const C = new Array(NUM_SPECIES).fill(0); + C[12] = 500; + s.Cs_in = C; + const result = s.getEffluent; + result.forEach(stream => { + expect(stream.payload.F).toBeGreaterThanOrEqual(0); + }); + }); + }); + + describe('no return pump', () => { + it('should have F_sr = 0 when there is no return pump', () => { + const s = new Settler(makeConfig()); + s.F_in = 100; + s.C_TS = 5000; + s.Cs_in = new Array(NUM_SPECIES).fill(10); + s.Cs_in[12] = 3000; + const result = s.getEffluent; + expect(result[2].payload.F).toBe(0); + }); + }); + + describe('edge case: X_TS > C_TS (F_s clamped to F_in)', () => { + it('should clamp F_s to F_in when X_TS/C_TS ratio exceeds 1', () => { + const s = new Settler(makeConfig()); + s.F_in = 100; + s.C_TS = 1000; + s.Cs_in = new Array(NUM_SPECIES).fill(10); + s.Cs_in[12] = 5000; // X_TS_in > C_TS => F_s = min(500, 100) = 100 + const result = s.getEffluent; + expect(result[0].payload.F).toBe(0); // all flow goes to sludge + }); + }); + }); + + describe('registerChild()', () => { + it('should not throw for null child', () => { + const s = new Settler(makeConfig()); + // null child should trigger the error log but not crash + expect(() => s.registerChild(null, 'measurement')).not.toThrow(); + }); + + it('should not throw for unknown software type with valid child', () => { + const s = new Settler(makeConfig()); + const fakeChild = { + config: { + general: { name: 'fake', id: 'fake-1' }, + functionality: { positionVsParent: 'upstream' }, + asset: { type: 'pressure' }, + }, + measurements: { emitter: { on: jest.fn() } }, + }; + expect(() => s.registerChild(fakeChild, 'unknownType')).not.toThrow(); + }); + }); + + describe('_connectMachine()', () => { + it('should set returnPump for downstream machine', () => { + const s = new Settler(makeConfig()); + const fakeMachine = { + config: { + general: { name: 'pump', id: 'pump-1' }, + functionality: { positionVsParent: 'downstream' }, + }, + }; + s._connectMachine(fakeMachine); + expect(s.returnPump).toBe(fakeMachine); + expect(fakeMachine.upstreamSource).toBe(s); + }); + + it('should not set returnPump for non-downstream machine', () => { + const s = new Settler(makeConfig()); + const fakeMachine = { + config: { + general: { name: 'pump', id: 'pump-2' }, + functionality: { positionVsParent: 'upstream' }, + }, + }; + s._connectMachine(fakeMachine); + expect(s.returnPump).toBeNull(); + }); + }); + + describe('_updateMeasurement()', () => { + it('should update C_TS when measurement type is "quantity (tss)"', () => { + const s = new Settler(makeConfig()); + s._updateMeasurement('quantity (tss)', 7000, 'atEquipment', {}); + expect(s.C_TS).toBe(7000); + }); + + it('should not change C_TS for unrecognized measurement type', () => { + const s = new Settler(makeConfig()); + s._updateMeasurement('temperature', 25, 'atEquipment', {}); + expect(s.C_TS).toBe(2500); // unchanged + }); + }); +});