From 7ff7c6ec1d5e3591bf1350fc3d9b65e8c2555d09 Mon Sep 17 00:00:00 2001 From: Rene De Ren Date: Wed, 11 Mar 2026 16:31:53 +0100 Subject: [PATCH] test: add unit tests for specificClass Co-Authored-By: Claude Opus 4.6 --- test/specificClass.test.js | 346 +++++++++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 test/specificClass.test.js diff --git a/test/specificClass.test.js b/test/specificClass.test.js new file mode 100644 index 0000000..93c4a4c --- /dev/null +++ b/test/specificClass.test.js @@ -0,0 +1,346 @@ +/** + * Tests for reactor specificClass (domain logic). + * + * Two reactor classes are exported: Reactor_CSTR and Reactor_PFR. + * Both extend a base Reactor class. + * + * Key methods tested: + * - _calcOTR: oxygen transfer rate calculation + * - _arrayClip2Zero: clip negative values to zero + * - setInfluent / getEffluent: influent/effluent data flow + * - setOTR: external OTR override + * - tick (CSTR): forward Euler state update + * - tick (PFR): finite difference state update + * - registerChild: dispatches to measurement / reactor handlers + */ + +const { Reactor_CSTR, Reactor_PFR } = require('../src/specificClass'); + +// --------------- helpers --------------- + +const NUM_SPECIES = 13; + +function makeCSTRConfig(overrides = {}) { + return { + general: { + name: 'TestCSTR', + id: 'cstr-test-1', + logging: { enabled: false, logLevel: 'error' }, + }, + functionality: { + softwareType: 'reactor', + positionVsParent: 'atEquipment', + }, + volume: 1000, + n_inlets: 1, + kla: 240, + timeStep: 1, // 1 second + initialState: new Array(NUM_SPECIES).fill(1.0), + ...overrides, + }; +} + +function makePFRConfig(overrides = {}) { + return { + general: { + name: 'TestPFR', + id: 'pfr-test-1', + logging: { enabled: false, logLevel: 'error' }, + }, + functionality: { + softwareType: 'reactor', + positionVsParent: 'atEquipment', + }, + volume: 200, + length: 10, + resolution_L: 10, + n_inlets: 1, + kla: 240, + alpha: 0.5, + timeStep: 1, + initialState: new Array(NUM_SPECIES).fill(0.1), + ...overrides, + }; +} + +// --------------- CSTR tests --------------- + +describe('Reactor_CSTR', () => { + + describe('constructor / initialization', () => { + it('should create an instance and set state from initialState', () => { + const r = new Reactor_CSTR(makeCSTRConfig()); + expect(r).toBeDefined(); + expect(r.state).toEqual(new Array(NUM_SPECIES).fill(1.0)); + }); + + it('should initialize Fs and Cs_in arrays based on n_inlets', () => { + const r = new Reactor_CSTR(makeCSTRConfig({ n_inlets: 3 })); + expect(r.Fs).toHaveLength(3); + expect(r.Cs_in).toHaveLength(3); + expect(r.Fs.every(v => v === 0)).toBe(true); + }); + + it('should store volume from config', () => { + const r = new Reactor_CSTR(makeCSTRConfig({ volume: 500 })); + expect(r.volume).toBe(500); + }); + + it('should initialize temperature to 20', () => { + const r = new Reactor_CSTR(makeCSTRConfig()); + expect(r.temperature).toBe(20); + }); + }); + + describe('_calcOTR()', () => { + let r; + beforeAll(() => { r = new Reactor_CSTR(makeCSTRConfig({ kla: 240 })); }); + + it('should return a positive value when S_O < saturation', () => { + const otr = r._calcOTR(0, 20); + expect(otr).toBeGreaterThan(0); + }); + + it('should return approximately zero when S_O equals saturation', () => { + // S_O_sat at T=20: 14.652 - 4.1022e-1*20 + 7.9910e-3*400 + 7.7774e-5*8000 + const T = 20; + const S_O_sat = 14.652 - 4.1022e-1 * T + 7.9910e-3 * T * T + 7.7774e-5 * T * T * T; + const otr = r._calcOTR(S_O_sat, T); + expect(otr).toBeCloseTo(0, 5); + }); + + it('should return a negative value when S_O > saturation (supersaturated)', () => { + const otr = r._calcOTR(100, 20); + expect(otr).toBeLessThan(0); + }); + + it('should use T=20 as default temperature', () => { + const otr1 = r._calcOTR(0); + const otr2 = r._calcOTR(0, 20); + expect(otr1).toBe(otr2); + }); + }); + + describe('_arrayClip2Zero()', () => { + let r; + beforeAll(() => { r = new Reactor_CSTR(makeCSTRConfig()); }); + + it('should clip negative values to zero', () => { + expect(r._arrayClip2Zero([-5, 3, -1, 0, 7])).toEqual([0, 3, 0, 0, 7]); + }); + + it('should leave all-positive arrays unchanged', () => { + expect(r._arrayClip2Zero([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it('should handle nested arrays (2D)', () => { + const result = r._arrayClip2Zero([[-1, 2], [3, -4]]); + expect(result).toEqual([[0, 2], [3, 0]]); + }); + + it('should handle a single scalar', () => { + expect(r._arrayClip2Zero(-5)).toBe(0); + expect(r._arrayClip2Zero(5)).toBe(5); + }); + }); + + describe('setInfluent / getEffluent', () => { + it('should store influent data via setter', () => { + const r = new Reactor_CSTR(makeCSTRConfig({ n_inlets: 2 })); + const input = { + payload: { + inlet: 0, + F: 100, + C: new Array(NUM_SPECIES).fill(5), + }, + }; + r.setInfluent = input; + expect(r.Fs[0]).toBe(100); + expect(r.Cs_in[0]).toEqual(new Array(NUM_SPECIES).fill(5)); + }); + + it('should return effluent with the sum of Fs and the current state', () => { + const r = new Reactor_CSTR(makeCSTRConfig()); + r.Fs[0] = 50; + const eff = r.getEffluent; + expect(eff.topic).toBe('Fluent'); + expect(eff.payload.F).toBe(50); + expect(eff.payload.C).toEqual(r.state); + }); + }); + + describe('setOTR', () => { + it('should set the OTR value', () => { + const r = new Reactor_CSTR(makeCSTRConfig({ kla: NaN })); + r.setOTR = { payload: 42 }; + expect(r.OTR).toBe(42); + }); + }); + + describe('tick()', () => { + it('should return a new state array of correct length', () => { + const r = new Reactor_CSTR(makeCSTRConfig()); + const result = r.tick(0.001); + expect(result).toHaveLength(NUM_SPECIES); + }); + + it('should not produce NaN values', () => { + const r = new Reactor_CSTR(makeCSTRConfig()); + r.Fs[0] = 10; + r.Cs_in[0] = new Array(NUM_SPECIES).fill(5); + const result = r.tick(0.001); + result.forEach(v => expect(Number.isNaN(v)).toBe(false)); + }); + + it('should not produce negative concentrations', () => { + const r = new Reactor_CSTR(makeCSTRConfig()); + // Run multiple ticks + for (let i = 0; i < 100; i++) { + r.tick(0.001); + } + r.state.forEach(v => expect(v).toBeGreaterThanOrEqual(0)); + }); + + it('should reach steady state with zero flow (concentrations change only via reaction)', () => { + const r = new Reactor_CSTR(makeCSTRConfig()); + // No inflow + const initial = [...r.state]; + r.tick(0.0001); + // State should have changed due to reaction/OTR + const changed = r.state.some((v, i) => v !== initial[i]); + expect(changed).toBe(true); + }); + }); + + describe('registerChild()', () => { + it('should not throw for "measurement" software type', () => { + const r = new Reactor_CSTR(makeCSTRConfig()); + // Passing null child will trigger warn but not crash + expect(() => r.registerChild(null, 'measurement')).not.toThrow(); + }); + + it('should not throw for "reactor" software type', () => { + const r = new Reactor_CSTR(makeCSTRConfig()); + expect(() => r.registerChild(null, 'reactor')).not.toThrow(); + }); + + it('should not throw for unknown software type', () => { + const r = new Reactor_CSTR(makeCSTRConfig()); + expect(() => r.registerChild(null, 'unknown')).not.toThrow(); + }); + }); +}); + +// --------------- PFR tests --------------- + +describe('Reactor_PFR', () => { + + describe('constructor / initialization', () => { + it('should create an instance with 2D state grid', () => { + const r = new Reactor_PFR(makePFRConfig()); + expect(r).toBeDefined(); + expect(r.state).toHaveLength(10); // resolution_L = 10 + expect(r.state[0]).toHaveLength(NUM_SPECIES); + }); + + it('should compute d_x = length / n_x', () => { + const r = new Reactor_PFR(makePFRConfig({ length: 10, resolution_L: 5 })); + expect(r.d_x).toBe(2); + }); + + it('should compute cross-sectional area A = volume / length', () => { + const r = new Reactor_PFR(makePFRConfig({ volume: 200, length: 10 })); + expect(r.A).toBe(20); + }); + + it('should initialize D (dispersion) to 0', () => { + const r = new Reactor_PFR(makePFRConfig()); + expect(r.D).toBe(0); + }); + + it('should create derivative operators of correct size', () => { + const r = new Reactor_PFR(makePFRConfig({ resolution_L: 8 })); + expect(r.D_op).toHaveLength(8); + expect(r.D_op[0]).toHaveLength(8); + expect(r.D2_op).toHaveLength(8); + expect(r.D2_op[0]).toHaveLength(8); + }); + }); + + describe('setDispersion', () => { + it('should set the axial dispersion value', () => { + const r = new Reactor_PFR(makePFRConfig()); + r.setDispersion = { payload: 0.5 }; + expect(r.D).toBe(0.5); + }); + }); + + describe('tick()', () => { + it('should return a 2D state grid of correct dimensions', () => { + const r = new Reactor_PFR(makePFRConfig()); + r.D = 0.01; + const result = r.tick(0.0001); + expect(result).toHaveLength(10); + expect(result[0]).toHaveLength(NUM_SPECIES); + }); + + it('should not produce NaN values with small time step and dispersion', () => { + const r = new Reactor_PFR(makePFRConfig()); + r.D = 0.01; + r.Fs[0] = 10; + r.Cs_in[0] = new Array(NUM_SPECIES).fill(5); + const result = r.tick(0.0001); + result.forEach(row => { + row.forEach(v => expect(Number.isNaN(v)).toBe(false)); + }); + }); + + it('should not produce negative concentrations', () => { + const r = new Reactor_PFR(makePFRConfig()); + r.D = 0.01; + for (let i = 0; i < 10; i++) { + r.tick(0.0001); + } + r.state.forEach(row => { + row.forEach(v => expect(v).toBeGreaterThanOrEqual(0)); + }); + }); + }); + + describe('_applyBoundaryConditions()', () => { + it('should apply Neumann BC at outlet (last = second to last)', () => { + const r = new Reactor_PFR(makePFRConfig({ resolution_L: 5 })); + const state = Array.from({ length: 5 }, () => new Array(NUM_SPECIES).fill(1)); + state[3] = new Array(NUM_SPECIES).fill(7); + r._applyBoundaryConditions(state); + // outlet BC: state[4] = state[3] + expect(state[4]).toEqual(new Array(NUM_SPECIES).fill(7)); + }); + + it('should apply Neumann BC at inlet when no flow', () => { + const r = new Reactor_PFR(makePFRConfig({ resolution_L: 5 })); + r.Fs[0] = 0; + const state = Array.from({ length: 5 }, () => new Array(NUM_SPECIES).fill(1)); + state[1] = new Array(NUM_SPECIES).fill(3); + r._applyBoundaryConditions(state); + // No flow: state[0] = state[1] + expect(state[0]).toEqual(new Array(NUM_SPECIES).fill(3)); + }); + }); + + describe('_arrayClip2Zero() (inherited)', () => { + it('should clip 2D arrays correctly', () => { + const r = new Reactor_PFR(makePFRConfig()); + const result = r._arrayClip2Zero([[-1, 2], [3, -4]]); + expect(result).toEqual([[0, 2], [3, 0]]); + }); + }); + + describe('_calcOTR() (inherited)', () => { + it('should work the same as in CSTR', () => { + const r = new Reactor_PFR(makePFRConfig({ kla: 240 })); + const otr = r._calcOTR(0, 20); + expect(otr).toBeGreaterThan(0); + }); + }); +});