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:
znetsixe
2026-04-13 13:43:03 +02:00
parent 0918be7705
commit 495b4cf400
10 changed files with 1367 additions and 45 deletions

View File

@@ -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);
}