/** * 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); }); }); });