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:
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;
|
||||
Reference in New Issue
Block a user