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:
znetsixe
2026-05-10 20:32:26 +02:00
parent 998b2002e9
commit b990f67df1
8 changed files with 725 additions and 0 deletions

View 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
View 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
View 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,
},
];

View 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;