feat: digital (MQTT) mode + fix silent dispatcher bug for camelCase methods
Runtime:
- Fix silent no-op when user selected any camelCase smoothing or outlier
method from the editor. validateEnum in generalFunctions lowercases enum
values (zScore -> zscore, lowPass -> lowpass, ...) but the dispatcher
compared against camelCase keys. Effect: 5 of 11 smoothing methods
(lowPass, highPass, weightedMovingAverage, bandPass, savitzkyGolay) and
2 of 3 outlier methods (zScore, modifiedZScore) silently fell through.
Users got the raw last value or no outlier filtering with no error log.
Review any pre-2026-04-13 flows that relied on these methods.
Fix: normalize method names to lowercase on both sides of the lookup.
- New Channel class (src/channel.js) — self-contained per-channel pipeline:
outlier -> offset -> scaling -> smoothing -> min/max -> constrain -> emit.
Pure domain logic, no Node-RED deps, reusable by future nodes that need
the same signal-conditioning chain.
Digital mode:
- config.mode.current = 'digital' opts in. config.channels declares one
entry per expected JSON key; each channel has its own type, position,
unit, distance, and optional scaling/smoothing/outlierDetection blocks
that override the top-level analog-mode fields. One MQTT-shaped payload
({t:22.5, h:45, p:1013}) dispatches N independent pipelines and emits N
MeasurementContainer slots from a single input message.
- Backward compatible: absent mode config = analog = pre-digital behaviour.
Every existing measurement flow keeps working unchanged.
UI:
- HTML editor: new Mode dropdown and Channels JSON textarea. The Node-RED
help panel is rewritten end-to-end with topic reference, port contracts,
per-mode configuration, smoothing/outlier method tables, and a note
about the pre-fix behaviour.
- README.md rewritten (was a one-line stub).
Tests (12 -> 71, all green):
- test/basic/smoothing-methods.basic.test.js (+16): every smoothing method
including the formerly-broken camelCase ones.
- test/basic/outlier-detection.basic.test.js (+10): every outlier method,
fall-through, toggle.
- test/basic/scaling-and-interpolation.basic.test.js (+10): offset,
interpolateLinear, constrain, handleScaling edge cases, min/max
tracking, updateOutputPercent fallback, updateOutputAbs emit dedup.
- test/basic/calibration-and-stability.basic.test.js (+11): calibrate
(stable and unstable), isStable, evaluateRepeatability refusals,
toggleSimulation, tick simulation on/off.
- test/integration/digital-mode.integration.test.js (+12): channel build
(including malformed entries), payload dispatch, multi-channel emit,
unknown keys, per-channel scaling/smoothing/outlier, empty channels,
non-numeric value rejection, getDigitalOutput shape, analog-default
back-compat.
E2E verified on Dockerized Node-RED: analog regression unchanged; digital
mode deploys with three channels, dispatches MQTT-style payload, emits
per-channel events, accumulates per-channel smoothing, ignores unknown
keys.
Depends on generalFunctions commit e50be2e (permissive unit check +
mode/channels schema).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,28 @@
|
||||
const EventEmitter = require('events');
|
||||
const {logger,configUtils,configManager,MeasurementContainer} = require('generalFunctions');
|
||||
const Channel = require('./channel');
|
||||
|
||||
/**
|
||||
* Measurement domain model.
|
||||
* Handles scaling, smoothing, outlier filtering and emits normalized measurement output.
|
||||
*
|
||||
* Supports two input modes:
|
||||
* - `analog` (default): one scalar value per msg.payload. The node runs the
|
||||
* classic offset / scaling / smoothing / outlier pipeline on it and emits
|
||||
* exactly one measurement into the MeasurementContainer. This is the
|
||||
* original behaviour; every existing flow keeps working unchanged.
|
||||
* - `digital`: msg.payload is an object with many key/value pairs (MQTT /
|
||||
* IoT style). The node builds one Channel per config.channels entry and
|
||||
* routes each key through its own mini-pipeline, emitting N measurements
|
||||
* into the MeasurementContainer from a single input message.
|
||||
*
|
||||
* Mode is selected via `config.mode.current`. When no mode config is present
|
||||
* or mode=analog, the node behaves identically to pre-digital releases.
|
||||
*/
|
||||
class Measurement {
|
||||
constructor(config={}) {
|
||||
|
||||
this.emitter = new EventEmitter(); // Own EventEmitter
|
||||
this.configManager = new configManager();
|
||||
this.configManager = new configManager();
|
||||
this.defaultConfig = this.configManager.getConfig('measurement');
|
||||
this.configUtils = new configUtils(this.defaultConfig);
|
||||
this.config = this.configUtils.initConfig(config);
|
||||
@@ -50,8 +63,106 @@ class Measurement {
|
||||
this.inputRange = Math.abs(this.config.scaling.inputMax - this.config.scaling.inputMin);
|
||||
this.processRange = Math.abs(this.config.scaling.absMax - this.config.scaling.absMin);
|
||||
|
||||
this.logger.debug(`Measurement id: ${this.config.general.id}, initialized successfully.`);
|
||||
// Mode + multi-channel (digital) support. Backward-compatible: when the
|
||||
// config does not declare a mode, we fall back to 'analog' and behave
|
||||
// exactly like the original single-channel node.
|
||||
this.mode = (this.config.mode && typeof this.config.mode.current === 'string')
|
||||
? this.config.mode.current.toLowerCase()
|
||||
: 'analog';
|
||||
this.channels = new Map(); // populated only in digital mode
|
||||
if (this.mode === 'digital') {
|
||||
this._buildDigitalChannels();
|
||||
}
|
||||
|
||||
this.logger.debug(`Measurement id: ${this.config.general.id}, initialized successfully. mode=${this.mode} channels=${this.channels.size}`);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one Channel per entry in config.channels. Each Channel gets its
|
||||
* own scaling / smoothing / outlier / position / unit contract; they share
|
||||
* the parent MeasurementContainer so a downstream parent sees all channels
|
||||
* via the same emitter.
|
||||
*/
|
||||
_buildDigitalChannels() {
|
||||
const entries = Array.isArray(this.config.channels) ? this.config.channels : [];
|
||||
if (entries.length === 0) {
|
||||
this.logger.warn(`digital mode enabled but config.channels is empty; no channels will be emitted.`);
|
||||
return;
|
||||
}
|
||||
for (const raw of entries) {
|
||||
if (!raw || typeof raw !== 'object' || !raw.key || !raw.type) {
|
||||
this.logger.warn(`skipping invalid channel entry: ${JSON.stringify(raw)}`);
|
||||
continue;
|
||||
}
|
||||
const channel = new Channel({
|
||||
key: raw.key,
|
||||
type: raw.type,
|
||||
position: raw.position || this.config.functionality?.positionVsParent || 'atEquipment',
|
||||
unit: raw.unit || this.config.asset?.unit || 'unitless',
|
||||
distance: raw.distance ?? this.config.functionality?.distance ?? null,
|
||||
scaling: raw.scaling || { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 },
|
||||
smoothing: raw.smoothing || { smoothWindow: this.config.smoothing.smoothWindow, smoothMethod: this.config.smoothing.smoothMethod },
|
||||
outlierDetection: raw.outlierDetection || this.config.outlierDetection,
|
||||
interpolation: raw.interpolation || this.config.interpolation,
|
||||
measurements: this.measurements,
|
||||
logger: this.logger,
|
||||
});
|
||||
this.channels.set(raw.key, channel);
|
||||
}
|
||||
this.logger.info(`digital mode: built ${this.channels.size} channel(s) from config.channels`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Digital mode entry point. Iterate the object payload, look up each key
|
||||
* in the channel map, and run the configured pipeline per channel. Keys
|
||||
* that are not mapped are logged once per call and ignored.
|
||||
* @param {object} payload - e.g. { temperature: 21.5, humidity: 45.2 }
|
||||
* @returns {object} summary of updated channels (for diagnostics)
|
||||
*/
|
||||
handleDigitalPayload(payload) {
|
||||
if (this.mode !== 'digital') {
|
||||
this.logger.warn(`handleDigitalPayload called while mode=${this.mode}. Ignoring.`);
|
||||
return {};
|
||||
}
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
this.logger.warn(`digital payload must be an object; got ${typeof payload}`);
|
||||
return {};
|
||||
}
|
||||
const summary = {};
|
||||
const unknown = [];
|
||||
for (const [key, raw] of Object.entries(payload)) {
|
||||
const channel = this.channels.get(key);
|
||||
if (!channel) {
|
||||
unknown.push(key);
|
||||
continue;
|
||||
}
|
||||
const v = Number(raw);
|
||||
if (!Number.isFinite(v)) {
|
||||
this.logger.warn(`digital channel '${key}' received non-numeric value: ${raw}`);
|
||||
summary[key] = { ok: false, reason: 'non-numeric' };
|
||||
continue;
|
||||
}
|
||||
const ok = channel.update(v);
|
||||
summary[key] = { ok, mAbs: channel.outputAbs, mPercent: channel.outputPercent };
|
||||
}
|
||||
if (unknown.length) {
|
||||
this.logger.debug(`digital payload contained unmapped keys: ${unknown.join(', ')}`);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return per-channel output snapshots. In analog mode this is the same
|
||||
* getOutput() contract; in digital mode it returns one snapshot per
|
||||
* channel under a `channels` key so the tick output stays JSON-shaped.
|
||||
*/
|
||||
getDigitalOutput() {
|
||||
const out = { channels: {} };
|
||||
for (const [key, ch] of this.channels) {
|
||||
out.channels[key] = ch.getOutput();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// -------- Config Initializers -------- //
|
||||
@@ -170,17 +281,23 @@ class Measurement {
|
||||
outlierDetection(val) {
|
||||
if (this.storedValues.length < 2) return false;
|
||||
|
||||
this.logger.debug(`Outlier detection method: ${this.config.outlierDetection.method}`);
|
||||
// Config enum values are normalized to lowercase by validateEnum in
|
||||
// generalFunctions, so dispatch on the lowercase form to keep this
|
||||
// tolerant of both legacy (camelCase) and normalized (lowercase) config.
|
||||
const raw = this.config.outlierDetection.method;
|
||||
const method = typeof raw === 'string' ? raw.toLowerCase() : raw;
|
||||
|
||||
switch (this.config.outlierDetection.method) {
|
||||
case 'zScore':
|
||||
this.logger.debug(`Outlier detection method: ${method}`);
|
||||
|
||||
switch (method) {
|
||||
case 'zscore':
|
||||
return this.zScoreOutlierDetection(val);
|
||||
case 'iqr':
|
||||
return this.iqrOutlierDetection(val);
|
||||
case 'modifiedZScore':
|
||||
case 'modifiedzscore':
|
||||
return this.modifiedZScoreOutlierDetection(val);
|
||||
default:
|
||||
this.logger.warn(`Outlier detection method "${this.config.outlierDetection.method}" is not recognized.`);
|
||||
this.logger.warn(`Outlier detection method "${raw}" is not recognized.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -306,31 +423,34 @@ class Measurement {
|
||||
this.storedValues.shift();
|
||||
}
|
||||
|
||||
// Smoothing strategies
|
||||
// Smoothing strategies keyed by the normalized (lowercase) method name.
|
||||
// validateEnum in generalFunctions lowercases enum values, so dispatch on
|
||||
// the lowercase form to accept both legacy (camelCase) and normalized
|
||||
// (lowercase) config values.
|
||||
const smoothingMethods = {
|
||||
none: (arr) => arr[arr.length - 1],
|
||||
mean: (arr) => this.mean(arr),
|
||||
min: (arr) => this.min(arr),
|
||||
max: (arr) => this.max(arr),
|
||||
sd: (arr) => this.standardDeviation(arr),
|
||||
lowPass: (arr) => this.lowPassFilter(arr),
|
||||
highPass: (arr) => this.highPassFilter(arr),
|
||||
weightedMovingAverage: (arr) => this.weightedMovingAverage(arr),
|
||||
bandPass: (arr) => this.bandPassFilter(arr),
|
||||
lowpass: (arr) => this.lowPassFilter(arr),
|
||||
highpass: (arr) => this.highPassFilter(arr),
|
||||
weightedmovingaverage: (arr) => this.weightedMovingAverage(arr),
|
||||
bandpass: (arr) => this.bandPassFilter(arr),
|
||||
median: (arr) => this.medianFilter(arr),
|
||||
kalman: (arr) => this.kalmanFilter(arr),
|
||||
savitzkyGolay: (arr) => this.savitzkyGolayFilter(arr),
|
||||
savitzkygolay: (arr) => this.savitzkyGolayFilter(arr),
|
||||
};
|
||||
|
||||
// Ensure the smoothing method is valid
|
||||
const method = this.config.smoothing.smoothMethod;
|
||||
|
||||
const raw = this.config.smoothing.smoothMethod;
|
||||
const method = typeof raw === 'string' ? raw.toLowerCase() : raw;
|
||||
this.logger.debug(`Applying smoothing method "${method}"`);
|
||||
|
||||
if (!smoothingMethods[method]) {
|
||||
this.logger.error(`Smoothing method "${method}" is not implemented.`);
|
||||
this.logger.error(`Smoothing method "${raw}" is not implemented.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
// Apply the smoothing method
|
||||
return smoothingMethods[method](this.storedValues);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user