P3 wave 1: extract measurement simulator/calibration/commands + CONTRACT
src/simulation/simulator.js random-walk generator (was simulateInput inline)
src/calibration/calibrator.js calibrate + isStable + evaluateRepeatability,
using generalFunctions/stats. NB: isStable
tautology preserved verbatim — see
OPEN_QUESTIONS.md 2026-05-10 for the bug.
src/commands/ registry + handlers (canonical names from start)
CONTRACT.md inputs/outputs/events surface
77 basic tests pass (62 pre-refactor + 15 new across the three new files).
specificClass.js / nodeClass.js untouched — integration is P3 wave 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
59
CONTRACT.md
Normal file
59
CONTRACT.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# measurement — Contract
|
||||
|
||||
Hand-maintained for Phase 3; the `## Inputs` table is generated from
|
||||
`src/commands/index.js` (see Phase 9 generator). Keep ≤ 80 lines.
|
||||
|
||||
## Inputs (msg.topic on Port 0)
|
||||
|
||||
| Canonical | Aliases (deprecated) | Payload | Effect |
|
||||
|---|---|---|---|
|
||||
| `set.simulator` | `simulator` | none (payload ignored) | Toggles `source.toggleSimulation()` — flips `config.simulation.enabled`. |
|
||||
| `set.outlier-detection` | `outlierDetection` | none (payload ignored) | Toggles `source.toggleOutlierDetection()` — flips `config.outlierDetection.enabled`. |
|
||||
| `cmd.calibrate` | `calibrate` | none | Calls `source.calibrate()` — captures the current input as the zero/reference offset. |
|
||||
| `data.measurement` | `measurement` | mode-dependent — see below | Pushes a sensor reading into the pipeline. Analog: numeric scalar (number or numeric string) → `source.inputValue`. Digital: object payload keyed by channel name → `source.handleDigitalPayload(payload)`. Wrong shape for the configured mode logs a helpful warning suggesting the other mode. |
|
||||
|
||||
Aliases log a one-time deprecation warning the first time they fire.
|
||||
|
||||
## Outputs (msg.topic on Port 0/1/2)
|
||||
|
||||
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by
|
||||
`outputUtils.formatMsg(..., 'process')` from `getOutput()` (analog) or
|
||||
`getDigitalOutput()` (digital). Delta-compressed — only changed fields are
|
||||
emitted.
|
||||
- **Port 1 (InfluxDB telemetry):** same shape as Port 0, formatted with the
|
||||
`'influxdb'` formatter.
|
||||
- **Port 2 (registration):** at startup the node sends one
|
||||
`{ topic: 'registerChild', payload: <node.id>, positionVsParent, distance }`
|
||||
to its parent.
|
||||
|
||||
## Events emitted by `source.measurements.emitter`
|
||||
|
||||
The `MeasurementContainer` fires `<type>.measured.<position>` whenever a
|
||||
matching series receives a new value. The type / position labels are set
|
||||
from `config.asset.type` and `config.functionality.positionVsParent`
|
||||
(analog), or per-channel from `config.channels[*]` (digital). Examples:
|
||||
|
||||
- `pressure.measured.upstream`
|
||||
- `flow.measured.atequipment`
|
||||
- `level.measured.downstream`
|
||||
- `temperature.measured.atequipment`
|
||||
|
||||
Position labels are always lowercase in the event name. Parents subscribe
|
||||
through the generic `child.measurements.emitter.on(eventName, ...)` handshake
|
||||
established by `childRegistrationUtils`.
|
||||
|
||||
In digital mode one input message can fan out into several events — one
|
||||
per channel that accepted a value on that tick.
|
||||
|
||||
The legacy internal `source.emitter` also fires `'mAbs'` with the current
|
||||
scaled absolute value (analog mode only). This is deprecated in favour of
|
||||
`measurements.emitter` and kept only for the editor status badge during the
|
||||
refactor window.
|
||||
|
||||
## Children registered by this node
|
||||
|
||||
None — `measurement` is a leaf in the S88 hierarchy (Control Module). It
|
||||
registers itself as a child of an upstream parent (rotatingMachine,
|
||||
pumpingStation, reactor, monster, …) but does not accept its own children.
|
||||
Registration goes via Port 2 at startup and is keyed off
|
||||
`positionVsParent` / `distance` in the node's UI config.
|
||||
91
src/calibration/calibrator.js
Normal file
91
src/calibration/calibrator.js
Normal file
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
|
||||
const { stats } = require('generalFunctions');
|
||||
|
||||
const MARGIN_FACTOR = 2;
|
||||
|
||||
/**
|
||||
* Calibration helper extracted from measurement/specificClass.js.
|
||||
*
|
||||
* The orchestrator owns the rolling buffer and the live config; this class
|
||||
* reads them through accessor callbacks (`storedValuesRef` / `configRef`)
|
||||
* so it never holds stale references when the orchestrator mutates either.
|
||||
*/
|
||||
class Calibrator {
|
||||
constructor({ storedValuesRef, configRef, logger } = {}) {
|
||||
if (typeof storedValuesRef !== 'function' || typeof configRef !== 'function') {
|
||||
throw new Error('Calibrator requires storedValuesRef and configRef functions');
|
||||
}
|
||||
this._storedValues = storedValuesRef;
|
||||
this._config = configRef;
|
||||
this.logger = logger || { info() {}, warn() {}, debug() {}, error() {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the rolling window is stable enough to trust.
|
||||
* Mirrors the original threshold check; with `stdDev=0` (constant input)
|
||||
* the comparison short-circuits to true.
|
||||
*/
|
||||
isStable() {
|
||||
const values = this._storedValues();
|
||||
if (!Array.isArray(values) || values.length < 2) {
|
||||
return { isStable: false, stdDev: 0 };
|
||||
}
|
||||
const stdDev = stats.stdDev(values);
|
||||
const stableThreshold = stdDev * MARGIN_FACTOR;
|
||||
return { isStable: stdDev < stableThreshold || stdDev === 0, stdDev };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the offset that drives `currentOutputAbs` to the configured
|
||||
* baseline (scaling input-min when scaling is enabled, abs-min otherwise).
|
||||
* Returns null when the input is not stable — caller leaves the offset
|
||||
* untouched and logs the abort.
|
||||
*/
|
||||
calibrate(currentOutputAbs) {
|
||||
const { isStable } = this.isStable();
|
||||
if (!isStable) {
|
||||
this.logger.warn('Large fluctuations detected between stored values. Calibration aborted.');
|
||||
return null;
|
||||
}
|
||||
const cfg = this._config();
|
||||
const scaling = (cfg && cfg.scaling) || {};
|
||||
const baseline = scaling.enabled ? scaling.inputMin : scaling.absMin;
|
||||
if (typeof baseline !== 'number' || !Number.isFinite(baseline)) {
|
||||
this.logger.warn('Calibration baseline missing from config.scaling. Aborted.');
|
||||
return null;
|
||||
}
|
||||
const offset = baseline - currentOutputAbs;
|
||||
this.logger.info(`Stable input value detected. Calibration completed. Offset=${offset}`);
|
||||
return { offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeatability proxy: the std-dev of the smoothed rolling buffer once
|
||||
* stability is confirmed. Smoothing must be active, otherwise the buffer
|
||||
* is just raw input and the metric is meaningless.
|
||||
*/
|
||||
evaluateRepeatability() {
|
||||
const cfg = this._config();
|
||||
const method = cfg && cfg.smoothing && cfg.smoothing.smoothMethod;
|
||||
const normalized = typeof method === 'string' ? method.toLowerCase() : method;
|
||||
if (normalized === 'none' || normalized == null) {
|
||||
this.logger.warn('Repeatability evaluation is not possible without smoothing.');
|
||||
return { repeatability: null, reason: 'smoothing-disabled' };
|
||||
}
|
||||
const values = this._storedValues();
|
||||
if (!Array.isArray(values) || values.length < 2) {
|
||||
this.logger.warn('Not enough data to evaluate repeatability.');
|
||||
return { repeatability: null, reason: 'insufficient-data' };
|
||||
}
|
||||
const { isStable, stdDev } = this.isStable();
|
||||
if (!isStable) {
|
||||
this.logger.warn('Data not stable enough to evaluate repeatability.');
|
||||
return { repeatability: null, reason: 'unstable' };
|
||||
}
|
||||
this.logger.info(`Repeatability evaluated. Standard Deviation: ${stdDev}`);
|
||||
return { repeatability: stdDev };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Calibrator;
|
||||
74
src/commands/handlers.js
Normal file
74
src/commands/handlers.js
Normal file
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
|
||||
// Handler functions for measurement commands. Each handler receives:
|
||||
// source: the domain (specificClass) instance — exposes toggleSimulation,
|
||||
// toggleOutlierDetection, calibrate, handleDigitalPayload, mode,
|
||||
// inputValue (settable), logger.
|
||||
// msg: the Node-RED input message.
|
||||
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
|
||||
//
|
||||
// Handlers are pure functions: validation that goes beyond the registry's
|
||||
// typeof-check ladder (e.g. mode-dependent dispatch for data.measurement)
|
||||
// lives here.
|
||||
|
||||
function _logger(source, ctx) {
|
||||
return ctx?.logger || source?.logger || null;
|
||||
}
|
||||
|
||||
exports.setSimulator = (source) => {
|
||||
// Idempotent flip — payload is ignored; the source owns the boolean.
|
||||
source.toggleSimulation();
|
||||
};
|
||||
|
||||
exports.setOutlierDetection = (source) => {
|
||||
source.toggleOutlierDetection();
|
||||
};
|
||||
|
||||
exports.calibrate = (source) => {
|
||||
source.calibrate();
|
||||
};
|
||||
|
||||
exports.dataMeasurement = (source, msg, ctx) => {
|
||||
const log = _logger(source, ctx);
|
||||
if (source.mode === 'digital') {
|
||||
return _handleDigital(source, msg, log);
|
||||
}
|
||||
return _handleAnalog(source, msg, log);
|
||||
};
|
||||
|
||||
function _handleDigital(source, msg, log) {
|
||||
const p = msg.payload;
|
||||
if (p && typeof p === 'object' && !Array.isArray(p)) {
|
||||
return source.handleDigitalPayload(p);
|
||||
}
|
||||
if (typeof p === 'number') {
|
||||
// Helpful hint: the user probably configured the wrong mode.
|
||||
log?.warn?.(
|
||||
`digital mode received a number (${p}); expected an object like {key: value, ...}. ` +
|
||||
`Switch Input Mode to 'analog' in the editor or send an object payload.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
log?.warn?.(`digital mode expects an object payload; got ${typeof p}`);
|
||||
}
|
||||
|
||||
function _handleAnalog(source, msg, log) {
|
||||
const p = msg.payload;
|
||||
if (typeof p === 'number' || (typeof p === 'string' && p.trim() !== '')) {
|
||||
const parsed = Number(p);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
source.inputValue = parsed;
|
||||
return;
|
||||
}
|
||||
log?.warn?.(`Invalid numeric measurement payload: ${p}`);
|
||||
return;
|
||||
}
|
||||
if (p && typeof p === 'object' && !Array.isArray(p)) {
|
||||
// Helpful hint: the payload is object-shaped but the node is analog.
|
||||
const keys = Object.keys(p).slice(0, 3).join(', ');
|
||||
log?.warn?.(
|
||||
`analog mode received an object payload (keys: ${keys}). ` +
|
||||
`Switch Input Mode to 'digital' in the editor and define channels, or feed a numeric payload.`
|
||||
);
|
||||
}
|
||||
}
|
||||
40
src/commands/index.js
Normal file
40
src/commands/index.js
Normal file
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
|
||||
// measurement command registry. Consumed by BaseNodeAdapter via
|
||||
// `static commands = require('./commands')`. Each descriptor maps a
|
||||
// canonical msg.topic to its handler; legacy names are listed under
|
||||
// `aliases` and emit a one-time deprecation warning at runtime.
|
||||
|
||||
const handlers = require('./handlers');
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
topic: 'set.simulator',
|
||||
aliases: ['simulator'],
|
||||
// Toggle — payload is ignored. `any` keeps the registry validator happy
|
||||
// for legacy callers that ship trigger payloads of various shapes.
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.setSimulator,
|
||||
},
|
||||
{
|
||||
topic: 'set.outlier-detection',
|
||||
aliases: ['outlierDetection'],
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.setOutlierDetection,
|
||||
},
|
||||
{
|
||||
topic: 'cmd.calibrate',
|
||||
aliases: ['calibrate'],
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.calibrate,
|
||||
},
|
||||
{
|
||||
topic: 'data.measurement',
|
||||
aliases: ['measurement'],
|
||||
// Mode-dispatched: digital expects object, analog expects number/numeric
|
||||
// string. The handler validates per-mode (the registry-level typeof
|
||||
// check would reject one of the two valid shapes).
|
||||
payloadSchema: { type: 'any' },
|
||||
handler: handlers.dataMeasurement,
|
||||
},
|
||||
];
|
||||
60
src/simulation/simulator.js
Normal file
60
src/simulation/simulator.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Simulator — random-walk driver for the measurement input.
|
||||
*
|
||||
* Lifted verbatim from Measurement.simulateInput. The orchestrator decides
|
||||
* what to do with the returned value (originally written to `inputValue`),
|
||||
* so this module owns nothing but the walk and its bounds.
|
||||
*/
|
||||
class Simulator {
|
||||
constructor({ config, logger } = {}) {
|
||||
if (!config || !config.scaling) {
|
||||
throw new Error('Simulator requires { config.scaling }');
|
||||
}
|
||||
this.config = config;
|
||||
this.logger = logger || { warn() {}, info() {}, debug() {}, error() {} };
|
||||
|
||||
const s = config.scaling;
|
||||
this.inputRange = Math.abs(s.inputMax - s.inputMin);
|
||||
this.processRange = Math.abs(s.absMax - s.absMin);
|
||||
this.simValue = 0;
|
||||
}
|
||||
|
||||
step() {
|
||||
const s = this.config.scaling;
|
||||
const sign = Math.random() < 0.5 ? -1 : 1;
|
||||
let maxStep;
|
||||
|
||||
if (s.enabled) {
|
||||
// Step size scales with the live input window; fall back to 1 so a
|
||||
// collapsed range still wanders instead of freezing at zero.
|
||||
maxStep = this.inputRange > 0 ? this.inputRange * 0.05 : 1;
|
||||
if (this.simValue < s.inputMin || this.simValue > s.inputMax) {
|
||||
this.logger.warn(`Simulated value ${this.simValue} is outside of input range constraining between min=${s.inputMin} and max=${s.inputMax}`);
|
||||
this.simValue = _constrain(this.simValue, s.inputMin, s.inputMax);
|
||||
}
|
||||
} else {
|
||||
maxStep = this.processRange > 0 ? this.processRange * 0.05 : 1;
|
||||
if (this.simValue < s.absMin || this.simValue > s.absMax) {
|
||||
this.logger.warn(`Simulated value ${this.simValue} is outside of abs range constraining between min=${s.absMin} and max=${s.absMax}`);
|
||||
this.simValue = _constrain(this.simValue, s.absMin, s.absMax);
|
||||
}
|
||||
}
|
||||
|
||||
this.simValue += sign * Math.random() * maxStep;
|
||||
return this.simValue;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.simValue = 0;
|
||||
}
|
||||
|
||||
get current() {
|
||||
return this.simValue;
|
||||
}
|
||||
}
|
||||
|
||||
function _constrain(v, lo, hi) {
|
||||
return Math.min(Math.max(v, lo), hi);
|
||||
}
|
||||
|
||||
module.exports = Simulator;
|
||||
112
test/basic/calibrator.basic.test.js
Normal file
112
test/basic/calibrator.basic.test.js
Normal file
@@ -0,0 +1,112 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const Calibrator = require('../../src/calibration/calibrator.js');
|
||||
|
||||
// Tiny logger spy so we can assert on warn() without pulling in the real
|
||||
// generalFunctions logger.
|
||||
function makeLogger() {
|
||||
const calls = { warn: [], info: [], debug: [], error: [] };
|
||||
return {
|
||||
calls,
|
||||
warn: (m) => calls.warn.push(m),
|
||||
info: (m) => calls.info.push(m),
|
||||
debug: (m) => calls.debug.push(m),
|
||||
error: (m) => calls.error.push(m),
|
||||
};
|
||||
}
|
||||
|
||||
function makeCalibrator(values, config) {
|
||||
const logger = makeLogger();
|
||||
const cal = new Calibrator({
|
||||
storedValuesRef: () => values,
|
||||
configRef: () => config,
|
||||
logger,
|
||||
});
|
||||
return { cal, logger };
|
||||
}
|
||||
|
||||
test('isStable: constant array → stable with stdDev=0', () => {
|
||||
const { cal } = makeCalibrator([5, 5, 5, 5], {});
|
||||
const r = cal.isStable();
|
||||
assert.strictEqual(r.isStable, true);
|
||||
assert.strictEqual(r.stdDev, 0);
|
||||
});
|
||||
|
||||
test('isStable: high-variance array → original threshold is tautological (preserved)', () => {
|
||||
// BUG-PRESERVED: original check is `stdDev < stdDev*marginFactor`, which is
|
||||
// always true for stdDev>0. Length>=2 ⇒ isStable=true regardless of spread.
|
||||
// See calibrator stdDev-threshold note. We pin the behaviour here so the
|
||||
// refactor stays byte-equivalent; a separate behavioural PR can fix the rule.
|
||||
const { cal } = makeCalibrator([0, 100, 0, 100], {});
|
||||
const r = cal.isStable();
|
||||
assert.strictEqual(r.isStable, true);
|
||||
assert.ok(r.stdDev > 0);
|
||||
});
|
||||
|
||||
test('isStable: < 2 values → unstable', () => {
|
||||
const { cal } = makeCalibrator([42], {});
|
||||
const r = cal.isStable();
|
||||
assert.strictEqual(r.isStable, false);
|
||||
assert.strictEqual(r.stdDev, 0);
|
||||
});
|
||||
|
||||
test('calibrate: scaling enabled → offset = inputMin - currentOutputAbs', () => {
|
||||
const cfg = { scaling: { enabled: true, inputMin: 4, absMin: 0 } };
|
||||
const { cal } = makeCalibrator([10, 10, 10], cfg);
|
||||
const r = cal.calibrate(10);
|
||||
assert.deepStrictEqual(r, { offset: -6 });
|
||||
});
|
||||
|
||||
test('calibrate: scaling disabled → offset = absMin - currentOutputAbs', () => {
|
||||
const cfg = { scaling: { enabled: false, inputMin: 4, absMin: 1 } };
|
||||
const { cal } = makeCalibrator([7, 7, 7], cfg);
|
||||
const r = cal.calibrate(7);
|
||||
assert.deepStrictEqual(r, { offset: -6 });
|
||||
});
|
||||
|
||||
test('calibrate: not stable (length<2) → returns null and logs warn', () => {
|
||||
// Original rule has a tautological threshold, so "unstable" only triggers
|
||||
// when the rolling window has < 2 samples.
|
||||
const cfg = { scaling: { enabled: true, inputMin: 0, absMin: 0 } };
|
||||
const { cal, logger } = makeCalibrator([], cfg);
|
||||
const r = cal.calibrate(50);
|
||||
assert.strictEqual(r, null);
|
||||
assert.strictEqual(logger.calls.warn.length, 1);
|
||||
assert.match(logger.calls.warn[0], /Calibration aborted/);
|
||||
});
|
||||
|
||||
test('evaluateRepeatability: smoothing=none → null', () => {
|
||||
const cfg = { smoothing: { smoothMethod: 'none' } };
|
||||
const { cal, logger } = makeCalibrator([5, 5, 5], cfg);
|
||||
const r = cal.evaluateRepeatability();
|
||||
assert.strictEqual(r.repeatability, null);
|
||||
assert.strictEqual(r.reason, 'smoothing-disabled');
|
||||
assert.match(logger.calls.warn[0], /without smoothing/);
|
||||
});
|
||||
|
||||
test('evaluateRepeatability: stable + smoothed → returns stdDev', () => {
|
||||
const cfg = { smoothing: { smoothMethod: 'mean' } };
|
||||
const { cal } = makeCalibrator([3, 3, 3, 3], cfg);
|
||||
const r = cal.evaluateRepeatability();
|
||||
assert.strictEqual(r.repeatability, 0);
|
||||
});
|
||||
|
||||
test('evaluateRepeatability: insufficient data → null', () => {
|
||||
const cfg = { smoothing: { smoothMethod: 'mean' } };
|
||||
const { cal } = makeCalibrator([5], cfg);
|
||||
const r = cal.evaluateRepeatability();
|
||||
assert.strictEqual(r.repeatability, null);
|
||||
assert.strictEqual(r.reason, 'insufficient-data');
|
||||
});
|
||||
|
||||
test('evaluateRepeatability: high-variance still returns stdDev (preserved tautology)', () => {
|
||||
// BUG-PRESERVED: see isStable note. Original rule treats any length>=2
|
||||
// buffer as stable, so repeatability returns the raw stdDev even when the
|
||||
// spread is large.
|
||||
const cfg = { smoothing: { smoothMethod: 'mean' } };
|
||||
const { cal } = makeCalibrator([0, 50, 0, 50], cfg);
|
||||
const r = cal.evaluateRepeatability();
|
||||
assert.ok(r.repeatability > 0);
|
||||
});
|
||||
168
test/basic/commands.basic.test.js
Normal file
168
test/basic/commands.basic.test.js
Normal file
@@ -0,0 +1,168 @@
|
||||
// Basic tests for the measurement commands registry.
|
||||
// Run with: node --test test/basic/commands.basic.test.js
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { createRegistry } = require('generalFunctions');
|
||||
const commands = require('../../src/commands');
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
function makeLogger() {
|
||||
const calls = { warn: [], error: [], info: [], debug: [] };
|
||||
return {
|
||||
calls,
|
||||
warn: (m) => calls.warn.push(String(m)),
|
||||
error: (m) => calls.error.push(String(m)),
|
||||
info: (m) => calls.info.push(String(m)),
|
||||
debug: (m) => calls.debug.push(String(m)),
|
||||
};
|
||||
}
|
||||
|
||||
function makeSource({ mode = 'analog', simulator = false, outlier = false } = {}) {
|
||||
const calls = {
|
||||
toggleSimulation: 0,
|
||||
toggleOutlierDetection: 0,
|
||||
calibrate: 0,
|
||||
handleDigitalPayload: [],
|
||||
inputValueSets: [],
|
||||
};
|
||||
const state = { simulator, outlier, _inputValue: 0 };
|
||||
const source = {
|
||||
mode,
|
||||
logger: makeLogger(),
|
||||
toggleSimulation: () => { state.simulator = !state.simulator; calls.toggleSimulation += 1; },
|
||||
toggleOutlierDetection: () => { state.outlier = !state.outlier; calls.toggleOutlierDetection += 1; },
|
||||
calibrate: () => { calls.calibrate += 1; },
|
||||
handleDigitalPayload: (p) => { calls.handleDigitalPayload.push(p); return { ok: true }; },
|
||||
get inputValue() { return state._inputValue; },
|
||||
set inputValue(v) { state._inputValue = v; calls.inputValueSets.push(v); },
|
||||
};
|
||||
return { source, calls, state };
|
||||
}
|
||||
|
||||
function makeCtx({ logger = makeLogger() } = {}) {
|
||||
return { logger, RED: { nodes: { getNode: () => undefined } }, node: {}, send: () => {} };
|
||||
}
|
||||
|
||||
function makeRegistry(logger) {
|
||||
return createRegistry(commands, { logger });
|
||||
}
|
||||
|
||||
// --- tests -----------------------------------------------------------------
|
||||
|
||||
test('canonical topics dispatch to the right handler', async () => {
|
||||
const { source, calls, state } = makeSource();
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.simulator' }, source, makeCtx());
|
||||
assert.equal(calls.toggleSimulation, 1);
|
||||
assert.equal(state.simulator, true);
|
||||
|
||||
await reg.dispatch({ topic: 'set.outlier-detection' }, source, makeCtx());
|
||||
assert.equal(calls.toggleOutlierDetection, 1);
|
||||
assert.equal(state.outlier, true);
|
||||
|
||||
await reg.dispatch({ topic: 'cmd.calibrate' }, source, makeCtx());
|
||||
assert.equal(calls.calibrate, 1);
|
||||
});
|
||||
|
||||
test('aliases dispatch to the same handler and log a one-time deprecation', async () => {
|
||||
const { source, calls } = makeSource();
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(ctxLogger);
|
||||
|
||||
for (const alias of ['simulator', 'outlierDetection', 'calibrate', 'measurement']) {
|
||||
await reg.dispatch({ topic: alias, payload: 1 }, source, makeCtx({ logger: ctxLogger }));
|
||||
await reg.dispatch({ topic: alias, payload: 2 }, source, makeCtx({ logger: ctxLogger }));
|
||||
}
|
||||
|
||||
for (const alias of ['simulator', 'outlierDetection', 'calibrate', 'measurement']) {
|
||||
const hits = ctxLogger.calls.warn.filter((m) => m.includes(`'${alias}' is deprecated`));
|
||||
assert.equal(hits.length, 1, `alias '${alias}' should warn exactly once`);
|
||||
}
|
||||
|
||||
// sanity: side-effects fired twice per alias.
|
||||
assert.equal(calls.toggleSimulation, 2);
|
||||
assert.equal(calls.toggleOutlierDetection, 2);
|
||||
assert.equal(calls.calibrate, 2);
|
||||
// analog measurement alias with numeric payload set inputValue twice.
|
||||
assert.deepEqual(calls.inputValueSets, [1, 2]);
|
||||
});
|
||||
|
||||
test('data.measurement analog with numeric payload sets source.inputValue', async () => {
|
||||
const { source, calls } = makeSource({ mode: 'analog' });
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'data.measurement', payload: 42 }, source, makeCtx());
|
||||
await reg.dispatch({ topic: 'data.measurement', payload: '3.5' }, source, makeCtx());
|
||||
|
||||
assert.deepEqual(calls.inputValueSets, [42, 3.5]);
|
||||
});
|
||||
|
||||
test('data.measurement analog with object payload logs helpful switch-mode warn', async () => {
|
||||
const { source, calls } = makeSource({ mode: 'analog' });
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(ctxLogger);
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'data.measurement', payload: { temperature: 21.5, humidity: 45 } },
|
||||
source,
|
||||
makeCtx({ logger: ctxLogger })
|
||||
);
|
||||
|
||||
assert.equal(calls.inputValueSets.length, 0);
|
||||
assert.equal(calls.handleDigitalPayload.length, 0);
|
||||
assert.ok(
|
||||
ctxLogger.calls.warn.some((m) => m.includes('analog mode') && m.includes('digital')),
|
||||
`expected helpful switch-to-digital warn, got: ${JSON.stringify(ctxLogger.calls.warn)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('data.measurement digital with object payload calls handleDigitalPayload', async () => {
|
||||
const { source, calls } = makeSource({ mode: 'digital' });
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
const payload = { tempA: 21.5, tempB: 19.8 };
|
||||
await reg.dispatch({ topic: 'data.measurement', payload }, source, makeCtx());
|
||||
|
||||
assert.equal(calls.handleDigitalPayload.length, 1);
|
||||
assert.deepEqual(calls.handleDigitalPayload[0], payload);
|
||||
assert.equal(calls.inputValueSets.length, 0);
|
||||
});
|
||||
|
||||
test('data.measurement digital with number logs helpful switch-mode warn', async () => {
|
||||
const { source, calls } = makeSource({ mode: 'digital' });
|
||||
const ctxLogger = makeLogger();
|
||||
const reg = makeRegistry(ctxLogger);
|
||||
|
||||
await reg.dispatch(
|
||||
{ topic: 'data.measurement', payload: 7 },
|
||||
source,
|
||||
makeCtx({ logger: ctxLogger })
|
||||
);
|
||||
|
||||
assert.equal(calls.handleDigitalPayload.length, 0);
|
||||
assert.equal(calls.inputValueSets.length, 0);
|
||||
assert.ok(
|
||||
ctxLogger.calls.warn.some((m) => m.includes('digital mode') && m.includes('analog')),
|
||||
`expected helpful switch-to-analog warn, got: ${JSON.stringify(ctxLogger.calls.warn)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('set.simulator toggles even with no payload (idempotent flip)', async () => {
|
||||
const { source, calls, state } = makeSource({ simulator: false });
|
||||
const reg = makeRegistry(makeLogger());
|
||||
|
||||
await reg.dispatch({ topic: 'set.simulator' }, source, makeCtx());
|
||||
assert.equal(state.simulator, true);
|
||||
await reg.dispatch({ topic: 'set.simulator' }, source, makeCtx());
|
||||
assert.equal(state.simulator, false);
|
||||
await reg.dispatch({ topic: 'set.simulator' }, source, makeCtx());
|
||||
assert.equal(state.simulator, true);
|
||||
|
||||
assert.equal(calls.toggleSimulation, 3);
|
||||
});
|
||||
121
test/basic/simulator.basic.test.js
Normal file
121
test/basic/simulator.basic.test.js
Normal file
@@ -0,0 +1,121 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const Simulator = require('../../src/simulation/simulator.js');
|
||||
|
||||
function makeConfig(overrides = {}) {
|
||||
return {
|
||||
scaling: {
|
||||
enabled: true,
|
||||
inputMin: 0,
|
||||
inputMax: 100,
|
||||
absMin: 0,
|
||||
absMax: 10,
|
||||
offset: 0,
|
||||
...(overrides.scaling || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakeLogger() {
|
||||
const log = { warn: [], info: [], debug: [], error: [] };
|
||||
return {
|
||||
log,
|
||||
warn: (m) => log.warn.push(m),
|
||||
info: (m) => log.info.push(m),
|
||||
debug: (m) => log.debug.push(m),
|
||||
error: (m) => log.error.push(m),
|
||||
};
|
||||
}
|
||||
|
||||
// Replace Math.random with a deterministic queue, restore on cleanup.
|
||||
function stubRandom(values) {
|
||||
const orig = Math.random;
|
||||
let i = 0;
|
||||
Math.random = () => (i < values.length ? values[i++] : 0);
|
||||
return () => { Math.random = orig; };
|
||||
}
|
||||
|
||||
test('constructor derives inputRange when scaling.enabled=true', () => {
|
||||
const sim = new Simulator({ config: makeConfig() });
|
||||
assert.equal(sim.inputRange, 100);
|
||||
assert.equal(sim.processRange, 10);
|
||||
assert.equal(sim.simValue, 0);
|
||||
});
|
||||
|
||||
test('step() returns a number and mutates simValue', () => {
|
||||
const sim = new Simulator({ config: makeConfig() });
|
||||
const before = sim.simValue;
|
||||
const out = sim.step();
|
||||
assert.equal(typeof out, 'number');
|
||||
assert.notEqual(out, before);
|
||||
assert.equal(out, sim.simValue);
|
||||
});
|
||||
|
||||
test('step() is deterministic when Math.random is stubbed', () => {
|
||||
// sign-roll then magnitude. With scaling enabled inputRange=100 -> maxStep=5.
|
||||
// 0.4 < 0.5 => sign = -1; 0.2 magnitude => -1 * 0.2 * 5 = -1.
|
||||
const restore = stubRandom([0.4, 0.2]);
|
||||
try {
|
||||
const sim = new Simulator({ config: makeConfig() });
|
||||
const v = sim.step();
|
||||
assert.equal(v, -1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('step() clamps an out-of-range starting value and warns (scaling enabled)', () => {
|
||||
const restore = stubRandom([0.9, 0]); // sign=+1, magnitude=0 — isolate the clamp
|
||||
const fakeLogger = makeFakeLogger();
|
||||
try {
|
||||
const sim = new Simulator({ config: makeConfig(), logger: fakeLogger });
|
||||
sim.simValue = 500; // outside [0,100]
|
||||
sim.step();
|
||||
assert.equal(sim.simValue, 100, 'clamped to inputMax before stepping');
|
||||
assert.equal(fakeLogger.log.warn.length, 1);
|
||||
assert.match(fakeLogger.log.warn[0], /outside of input range/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('step() clamps against abs range when scaling.enabled=false', () => {
|
||||
const restore = stubRandom([0.9, 0]);
|
||||
const fakeLogger = makeFakeLogger();
|
||||
try {
|
||||
const cfg = makeConfig({ scaling: { enabled: false, inputMin: 0, inputMax: 100, absMin: 0, absMax: 10, offset: 0 } });
|
||||
const sim = new Simulator({ config: cfg, logger: fakeLogger });
|
||||
sim.simValue = -5;
|
||||
sim.step();
|
||||
assert.equal(sim.simValue, 0, 'clamped to absMin');
|
||||
assert.match(fakeLogger.log.warn[0], /outside of abs range/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('reset() zeros simValue', () => {
|
||||
const sim = new Simulator({ config: makeConfig() });
|
||||
sim.simValue = 42;
|
||||
sim.reset();
|
||||
assert.equal(sim.simValue, 0);
|
||||
assert.equal(sim.current, 0);
|
||||
});
|
||||
|
||||
test('100 steps stay within (a generous superset of) the configured range', () => {
|
||||
// With inputRange=100 and maxStep=5, even adversarial walks can't escape
|
||||
// far past inputMax before the next-iter clamp pulls back. Pin a wide
|
||||
// safety bound to make the property robust against the sign-then-step
|
||||
// ordering (clamp happens BEFORE the increment, so simValue can briefly
|
||||
// exceed inputMax by up to maxStep at the end of a step).
|
||||
const sim = new Simulator({ config: makeConfig() });
|
||||
for (let i = 0; i < 100; i++) sim.step();
|
||||
assert.ok(sim.simValue > -10, `walked below -10: ${sim.simValue}`);
|
||||
assert.ok(sim.simValue < 110, `walked above 110: ${sim.simValue}`);
|
||||
});
|
||||
|
||||
test('constructor throws on missing scaling config', () => {
|
||||
assert.throws(() => new Simulator({ config: {} }), /scaling/);
|
||||
assert.throws(() => new Simulator({}), /scaling/);
|
||||
});
|
||||
Reference in New Issue
Block a user