2025-05-14 10:31:50 +02:00
const EventEmitter = require ( 'events' ) ;
2025-08-07 13:51:28 +02:00
const { logger , configUtils , configManager , MeasurementContainer } = require ( 'generalFunctions' ) ;
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>
2026-04-13 13:43:03 +02:00
const Channel = require ( './channel' ) ;
2025-05-14 10:31:50 +02:00
2026-02-23 13:17:03 +01:00
/ * *
* Measurement domain model .
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>
2026-04-13 13:43:03 +02:00
*
* 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 .
2026-02-23 13:17:03 +01:00
* /
2025-05-14 10:31:50 +02:00
class Measurement {
constructor ( config = { } ) {
this . emitter = new EventEmitter ( ) ; // Own EventEmitter
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>
2026-04-13 13:43:03 +02:00
this . configManager = new configManager ( ) ;
2025-06-20 17:14:22 +02:00
this . defaultConfig = this . configManager . getConfig ( 'measurement' ) ;
this . configUtils = new configUtils ( this . defaultConfig ) ;
2025-05-14 10:31:50 +02:00
this . config = this . configUtils . initConfig ( config ) ;
// Init after config is set
2025-06-12 17:05:28 +02:00
this . logger = new logger ( this . config . general . logging . enabled , this . config . general . logging . logLevel , this . config . general . name ) ;
2025-05-14 10:31:50 +02:00
2025-08-07 13:51:28 +02:00
// General properties
this . measurements = new MeasurementContainer ( {
autoConvert : true ,
windowSize : this . config . smoothing . smoothWindow
} ) ;
2025-09-04 17:05:39 +02:00
this . measurements . setChildId ( this . config . general . id ) ;
this . measurements . setChildName ( this . config . general . name ) ;
2025-05-14 10:31:50 +02:00
// Smoothing
this . storedValues = [ ] ;
// Simulation
this . simValue = 0 ;
// Internal tracking
this . inputValue = 0 ;
this . outputAbs = 0 ;
this . outputPercent = 0 ;
// Stability
this . stableThreshold = null ;
//internal variables
this . totalMinValue = Infinity ;
this . totalMaxValue = - Infinity ;
this . totalMinSmooth = 0 ;
this . totalMaxSmooth = 0 ;
// Scaling
this . inputRange = Math . abs ( this . config . scaling . inputMax - this . config . scaling . inputMin ) ;
this . processRange = Math . abs ( this . config . scaling . absMax - this . config . scaling . absMin ) ;
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>
2026-04-13 13:43:03 +02:00
// 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 } ` ) ;
}
2025-05-14 10:31:50 +02:00
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>
2026-04-13 13:43:03 +02:00
/ * *
* 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 ;
2025-05-14 10:31:50 +02:00
}
// -------- Config Initializers -------- //
updateconfig ( newConfig ) {
this . config = this . configUtils . updateConfig ( this . config , newConfig ) ;
}
async tick ( ) {
if ( this . config . simulation . enabled ) {
this . simulateInput ( ) ;
}
this . calculateInput ( this . inputValue ) ;
return Promise . resolve ( ) ;
}
calibrate ( ) {
let offset = 0 ;
const { isStable } = this . isStable ( ) ;
//first check if the input is stable
if ( ! isStable ) {
this . logger . warn ( ` Large fluctuations detected between stored values. Calibration aborted. ` ) ;
} else {
this . logger . info ( ` Stable input value detected. Proceeding with calibration. ` ) ;
// offset should be the difference between the input and the output
if ( this . config . scaling . enabled ) {
offset = this . config . scaling . inputMin - this . outputAbs ;
} else {
offset = this . config . scaling . absMin - this . outputAbs ;
}
this . config . scaling . offset = offset ;
this . logger . info ( ` Calibration completed. Offset set to ${ offset } ` ) ;
}
}
isStable ( ) {
const marginFactor = 2 ; // or 3, depending on strictness
let stableThreshold = 0 ;
if ( this . storedValues . length < 2 ) return false ;
const stdDev = this . standardDeviation ( this . storedValues ) ;
stableThreshold = stdDev * marginFactor ;
return { isStable : ( stdDev < stableThreshold || stdDev == 0 ) , stdDev } ;
}
evaluateRepeatability ( ) {
const { isStable , stdDev } = this . isStable ( ) ;
if ( this . config . smoothing . smoothMethod == 'none' ) {
this . logger . warn ( 'Repeatability evaluation is not possible without smoothing.' ) ;
return null ;
}
if ( this . storedValues . length < 2 ) {
this . logger . warn ( 'Not enough data to evaluate repeatability.' ) ;
return null ;
}
if ( isStable == false ) {
this . logger . warn ( 'Data not stable enough to evaluate repeatability.' ) ;
return null ;
}
const standardDeviation = stdDev
this . logger . info ( ` Repeatability evaluated. Standard Deviation: ${ stdDev } ` ) ;
return standardDeviation ;
}
simulateInput ( ) {
// Simulate input value
const absMax = this . config . scaling . absMax ;
const absMin = this . config . scaling . absMin ;
const inputMin = this . config . scaling . inputMin ;
const inputMax = this . config . scaling . inputMax ;
const sign = Math . random ( ) < 0.5 ? - 1 : 1 ;
let maxStep = 0 ;
switch ( this . config . scaling . enabled ) {
case true :
maxStep = this . inputRange > 0 ? this . inputRange * 0.05 : 1 ;
if ( this . simValue < inputMin || this . simValue > inputMax ) {
this . logger . warn ( ` Simulated value ${ this . simValue } is outside of input range constraining between min= ${ inputMin } and max= ${ inputMax } ` ) ;
this . simValue = this . constrain ( this . simValue , inputMin , inputMax ) ;
}
break ;
case false :
maxStep = this . processRange > 0 ? this . processRange * 0.05 : 1 ;
if ( this . simValue < absMin || this . simValue > absMax ) {
this . logger . warn ( ` Simulated value ${ this . simValue } is outside of abs range constraining between min= ${ absMin } and max= ${ absMax } ` ) ;
this . simValue = this . constrain ( this . simValue , absMin , absMax ) ;
}
break ;
}
this . simValue += sign * Math . random ( ) * maxStep ;
this . inputValue = this . simValue ;
}
outlierDetection ( val ) {
if ( this . storedValues . length < 2 ) return false ;
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>
2026-04-13 13:43:03 +02:00
// 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 ;
2025-05-14 10:31:50 +02:00
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>
2026-04-13 13:43:03 +02:00
this . logger . debug ( ` Outlier detection method: ${ method } ` ) ;
switch ( method ) {
case 'zscore' :
2025-05-14 10:31:50 +02:00
return this . zScoreOutlierDetection ( val ) ;
case 'iqr' :
return this . iqrOutlierDetection ( val ) ;
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>
2026-04-13 13:43:03 +02:00
case 'modifiedzscore' :
2025-05-14 10:31:50 +02:00
return this . modifiedZScoreOutlierDetection ( val ) ;
default :
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>
2026-04-13 13:43:03 +02:00
this . logger . warn ( ` Outlier detection method " ${ raw } " is not recognized. ` ) ;
2025-05-14 10:31:50 +02:00
return false ;
}
}
zScoreOutlierDetection ( val ) {
const threshold = this . config . outlierDetection . threshold || 3 ;
const mean = this . mean ( this . storedValues ) ;
const stdDev = this . standardDeviation ( this . storedValues ) ;
const zScore = ( val - mean ) / stdDev ;
if ( Math . abs ( zScore ) > threshold ) {
this . logger . warn ( ` Outlier detected using Z-Score method. Z-score= ${ zScore } ` ) ;
return true ;
}
return false ;
}
iqrOutlierDetection ( val ) {
const sortedValues = [ ... this . storedValues ] . sort ( ( a , b ) => a - b ) ;
const q1 = sortedValues [ Math . floor ( sortedValues . length / 4 ) ] ;
const q3 = sortedValues [ Math . floor ( sortedValues . length * 3 / 4 ) ] ;
const iqr = q3 - q1 ;
const lowerBound = q1 - 1.5 * iqr ;
const upperBound = q3 + 1.5 * iqr ;
if ( val < lowerBound || val > upperBound ) {
this . logger . warn ( ` Outlier detected using IQR method. Value= ${ val } ` ) ;
return true ;
}
return false ;
}
modifiedZScoreOutlierDetection ( val ) {
const median = this . medianFilter ( this . storedValues ) ;
const mad = this . medianFilter ( this . storedValues . map ( v => Math . abs ( v - median ) ) ) ;
const modifiedZScore = 0.6745 * ( val - median ) / mad ;
const threshold = this . config . outlierDetection . threshold || 3.5 ;
if ( Math . abs ( modifiedZScore ) > threshold ) {
this . logger . warn ( ` Outlier detected using Modified Z-Score method. Modified Z-Score= ${ modifiedZScore } ` ) ;
return true ;
}
return false ;
}
calculateInput ( value ) {
// Check if the value is an outlier and check if outlier detection is enabled
if ( this . config . outlierDetection . enabled ) {
if ( this . outlierDetection ( value ) ) {
this . logger . warn ( ` Outlier detected. Ignoring value= ${ value } ` ) ;
return ;
}
}
// Apply offset
let val = this . applyOffset ( value ) ;
// Track raw min/max
this . updateMinMaxValues ( val ) ;
// Handle scaling if enabled
if ( this . config . scaling . enabled ) {
val = this . handleScaling ( val ) ;
}
// Apply smoothing
const smoothed = this . applySmoothing ( val ) ;
// Update smoothed min/max and output
this . updateSmoothMinMaxValues ( smoothed ) ;
this . updateOutputAbs ( smoothed ) ;
}
applyOffset ( value ) {
return value + this . config . scaling . offset ;
}
handleScaling ( value ) {
// Check if input range is valid
if ( this . inputRange <= 0 ) {
this . logger . warn ( ` Input range is invalid. Falling back to default range [0, 1]. ` ) ;
this . config . scaling . inputMin = 0 ;
this . config . scaling . inputMax = 1 ;
this . inputRange = this . config . scaling . inputMax - this . config . scaling . inputMin ;
}
// Constrain value within input range
if ( value < this . config . scaling . inputMin || value > this . config . scaling . inputMax ) {
this . logger . warn ( ` Value= ${ value } is outside of INPUT range. Constraining. ` ) ;
value = this . constrain ( value , this . config . scaling . inputMin , this . config . scaling . inputMax ) ;
}
// Interpolate value
this . logger . debug ( ` Interpolating value= ${ value } between min= ${ this . config . scaling . inputMin } and max= ${ this . config . scaling . inputMax } to absMin= ${ this . config . scaling . absMin } and absMax= ${ this . config . scaling . absMax } ` ) ;
return this . interpolateLinear ( value , this . config . scaling . inputMin , this . config . scaling . inputMax , this . config . scaling . absMin , this . config . scaling . absMax ) ;
}
constrain ( input , inputMin , inputMax ) {
this . logger . warn ( ` New value= ${ input } is constrained to fit between min= ${ inputMin } and max= ${ inputMax } ` ) ;
return Math . min ( Math . max ( input , inputMin ) , inputMax ) ;
}
interpolateLinear ( iNumber , iMin , iMax , oMin , oMax ) {
if ( iMin >= iMax || oMin >= oMax ) {
this . logger . warn ( ` Invalid input for linear interpolation iMin= ${ JSON . stringify ( iMin ) } iMax= ${ iMax } oMin= ${ JSON . stringify ( oMin ) } oMax= ${ oMax } ` ) ;
return iNumber ;
}
const range = iMax - iMin ;
return oMin + ( ( iNumber - iMin ) * ( oMax - oMin ) ) / range ;
}
applySmoothing ( value ) {
this . storedValues . push ( value ) ;
// Maintain only the latest 'smoothWindow' number of values
if ( this . storedValues . length > this . config . smoothing . smoothWindow ) {
this . storedValues . shift ( ) ;
}
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>
2026-04-13 13:43:03 +02:00
// 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.
2025-05-14 10:31:50 +02:00
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 ) ,
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>
2026-04-13 13:43:03 +02:00
lowpass : ( arr ) => this . lowPassFilter ( arr ) ,
highpass : ( arr ) => this . highPassFilter ( arr ) ,
weightedmovingaverage : ( arr ) => this . weightedMovingAverage ( arr ) ,
bandpass : ( arr ) => this . bandPassFilter ( arr ) ,
2025-05-14 10:31:50 +02:00
median : ( arr ) => this . medianFilter ( arr ) ,
kalman : ( arr ) => this . kalmanFilter ( arr ) ,
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>
2026-04-13 13:43:03 +02:00
savitzkygolay : ( arr ) => this . savitzkyGolayFilter ( arr ) ,
2025-05-14 10:31:50 +02:00
} ;
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>
2026-04-13 13:43:03 +02:00
const raw = this . config . smoothing . smoothMethod ;
const method = typeof raw === 'string' ? raw . toLowerCase ( ) : raw ;
2025-05-14 10:31:50 +02:00
this . logger . debug ( ` Applying smoothing method " ${ method } " ` ) ;
if ( ! smoothingMethods [ method ] ) {
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>
2026-04-13 13:43:03 +02:00
this . logger . error ( ` Smoothing method " ${ raw } " is not implemented. ` ) ;
2025-05-14 10:31:50 +02:00
return value ;
}
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>
2026-04-13 13:43:03 +02:00
2025-05-14 10:31:50 +02:00
// Apply the smoothing method
return smoothingMethods [ method ] ( this . storedValues ) ;
}
standardDeviation ( values ) {
if ( values . length <= 1 ) return 0 ;
const mean = values . reduce ( ( a , b ) => a + b , 0 ) / values . length ;
const sqDiffs = values . map ( v => ( v - mean ) * * 2 ) ;
const variance = sqDiffs . reduce ( ( a , b ) => a + b , 0 ) / ( values . length - 1 ) ;
return Math . sqrt ( variance ) ;
}
savitzkyGolayFilter ( arr ) {
const coefficients = [ - 3 , 12 , 17 , 12 , - 3 ] ; // Example coefficients for 5-point smoothing
const normFactor = coefficients . reduce ( ( a , b ) => a + b , 0 ) ;
if ( arr . length < coefficients . length ) {
return arr [ arr . length - 1 ] ; // Return last value if array is too small
}
let smoothed = 0 ;
for ( let i = 0 ; i < coefficients . length ; i ++ ) {
smoothed += arr [ arr . length - coefficients . length + i ] * coefficients [ i ] ;
}
return smoothed / normFactor ;
}
kalmanFilter ( arr ) {
let estimate = arr [ 0 ] ;
const measurementNoise = 1 ; // Adjust based on your sensor's characteristics
const processNoise = 0.1 ; // Adjust based on signal variability
const kalmanGain = processNoise / ( processNoise + measurementNoise ) ;
for ( let i = 1 ; i < arr . length ; i ++ ) {
estimate = estimate + kalmanGain * ( arr [ i ] - estimate ) ;
}
return estimate ;
}
medianFilter ( arr ) {
const sorted = [ ... arr ] . sort ( ( a , b ) => a - b ) ;
const middle = Math . floor ( sorted . length / 2 ) ;
return sorted . length % 2 !== 0
? sorted [ middle ]
: ( sorted [ middle - 1 ] + sorted [ middle ] ) / 2 ;
}
bandPassFilter ( arr ) {
const lowPass = this . lowPassFilter ( arr ) ; // Apply low-pass filter
const highPass = this . highPassFilter ( arr ) ; // Apply high-pass filter
2026-03-11 15:35:28 +01:00
return arr . map ( ( val , _idx ) => lowPass + highPass - val ) . pop ( ) ; // Combine the filters
2025-05-14 10:31:50 +02:00
}
weightedMovingAverage ( arr ) {
const weights = arr . map ( ( _ , i ) => i + 1 ) ; // Weights increase linearly
const weightedSum = arr . reduce ( ( sum , val , idx ) => sum + val * weights [ idx ] , 0 ) ;
const weightTotal = weights . reduce ( ( sum , weight ) => sum + weight , 0 ) ;
return weightedSum / weightTotal ;
}
highPassFilter ( arr ) {
const alpha = 0.8 ; // Smoothing factor (0 < alpha <= 1)
let filteredValues = [ ] ;
filteredValues [ 0 ] = arr [ 0 ] ;
for ( let i = 1 ; i < arr . length ; i ++ ) {
filteredValues [ i ] = alpha * ( filteredValues [ i - 1 ] + arr [ i ] - arr [ i - 1 ] ) ;
}
return filteredValues [ filteredValues . length - 1 ] ;
}
lowPassFilter ( arr ) {
const alpha = 0.2 ; // Smoothing factor (0 < alpha <= 1)
let smoothedValue = arr [ 0 ] ;
for ( let i = 1 ; i < arr . length ; i ++ ) {
smoothedValue = alpha * arr [ i ] + ( 1 - alpha ) * smoothedValue ;
}
return smoothedValue ;
}
// Or also EMA called exponential moving average
recursiveLowpassFilter ( ) {
}
mean ( arr ) {
return arr . reduce ( ( a , b ) => a + b , 0 ) / arr . length ;
}
min ( arr ) {
return Math . min ( ... arr ) ;
}
max ( arr ) {
return Math . max ( ... arr ) ;
}
updateMinMaxValues ( value ) {
if ( value < this . totalMinValue ) {
this . totalMinValue = value ;
}
if ( value > this . totalMaxValue ) {
this . totalMaxValue = value ;
}
}
updateSmoothMinMaxValues ( value ) {
// If this is the first run, initialize them
if ( this . totalMinSmooth === 0 && this . totalMaxSmooth === 0 ) {
this . totalMinSmooth = value ;
this . totalMaxSmooth = value ;
}
if ( value < this . totalMinSmooth ) {
this . totalMinSmooth = value ;
}
if ( value > this . totalMaxSmooth ) {
this . totalMaxSmooth = value ;
}
}
updateOutputAbs ( val ) {
2025-10-02 17:10:23 +02:00
// Constrain first, then check for changes
let constrainedVal = val ;
if ( val < this . config . scaling . absMin || val > this . config . scaling . absMax ) {
this . logger . warn ( ` Output value= ${ val } is outside of ABS range. Constraining. ` ) ;
constrainedVal = this . constrain ( val , this . config . scaling . absMin , this . config . scaling . absMax ) ;
}
const roundedVal = Math . round ( constrainedVal * 100 ) / 100 ;
2025-05-14 10:31:50 +02:00
//only update on change
2025-10-02 17:10:23 +02:00
if ( roundedVal != this . outputAbs ) {
2025-05-14 10:31:50 +02:00
// Constrain value within process range
if ( val < this . config . scaling . absMin || val > this . config . scaling . absMax ) {
this . logger . warn ( ` Output value= ${ val } is outside of ABS range. Constraining. ` ) ;
val = this . constrain ( val , this . config . scaling . absMin , this . config . scaling . absMax ) ;
}
this . outputAbs = Math . round ( val * 100 ) / 100 ;
this . outputPercent = this . updateOutputPercent ( val ) ;
2025-08-07 13:51:28 +02:00
this . emitter . emit ( 'mAbs' , this . outputAbs ) ; // DEPRECATED: Use measurements container instead
this . logger . debug ( ` Updating type: ${ this . config . asset . type } , variant: ${ "measured" } , postition : ${ this . config . functionality . positionVsParent } container with new value: ${ this . outputAbs } ` ) ;
2025-10-05 09:34:35 +02:00
this . measurements . type ( this . config . asset . type ) . variant ( "measured" ) . position ( this . config . functionality . positionVsParent ) . distance ( this . config . functionality . distance ) . value ( this . outputAbs , Date . now ( ) , this . config . asset . unit ) ;
2025-05-14 10:31:50 +02:00
}
}
updateOutputPercent ( value ) {
let outputPercent ;
if ( this . processRange <= 0 ) {
this . logger . debug ( ` Process range is smaller or equal to 0 interpolating between input range ` ) ;
outputPercent = this . interpolateLinear ( value , this . totalMinValue , this . totalMaxValue , this . config . interpolation . percentMin , this . config . interpolation . percentMax ) ;
}
else {
outputPercent = this . interpolateLinear ( value , this . config . scaling . absMin , this . config . scaling . absMax , this . config . interpolation . percentMin , this . config . interpolation . percentMax ) ;
}
return Math . round ( outputPercent * 100 ) / 100 ;
}
toggleSimulation ( ) {
this . config . simulation . enabled = ! this . config . simulation . enabled ;
}
toggleOutlierDetection ( ) {
2026-02-23 13:17:03 +01:00
// Keep the outlier configuration shape stable and only toggle the enabled flag.
const currentState = Boolean ( this . config ? . outlierDetection ? . enabled ) ;
this . config . outlierDetection = this . config . outlierDetection || { } ;
this . config . outlierDetection . enabled = ! currentState ;
2025-05-14 10:31:50 +02:00
}
getOutput ( ) {
return {
mAbs : this . outputAbs ,
mPercent : this . outputPercent ,
totalMinValue : this . totalMinValue ,
totalMaxValue : this . totalMaxValue ,
totalMinSmooth : this . totalMinSmooth ,
totalMaxSmooth : this . totalMaxSmooth ,
} ;
}
}
module . exports = Measurement ;
/ *
// Testing the class
const configuration = {
general : {
name : "PT1" ,
logging : {
enabled : true ,
logLevel : "debug" ,
} ,
} ,
scaling : {
enabled : true ,
inputMin : 0 ,
inputMax : 3000 ,
absMin : 500 ,
absMax : 4000 ,
offset : 1000
} ,
2025-08-07 13:51:28 +02:00
asset : {
type : "pressure" ,
unit : "bar" ,
category : "measurement" ,
model : "PT1" ,
uuid : "123e4567-e89b-12d3-a456-426614174000" ,
tagCode : "PT1-001" ,
supplier : "DeltaTech"
} ,
2025-05-14 10:31:50 +02:00
smoothing : {
smoothWindow : 10 ,
smoothMethod : 'mean' ,
} ,
simulation : {
enabled : true ,
2025-08-07 13:51:28 +02:00
} ,
functionality : {
2026-03-11 15:35:28 +01:00
positionVsParent : POSITIONS . UPSTREAM
2025-05-14 10:31:50 +02:00
}
} ;
const m = new Measurement ( configuration ) ;
m . logger . info ( ` Measurement created with config : ${ JSON . stringify ( m . config ) } ` ) ;
m . logger . setLogLevel ( "debug" ) ;
2025-08-07 13:51:28 +02:00
//look for flow updates
m . measurements . emitter . on ( 'pressure.measured.upstream' , ( newVal ) => {
m . logger . info ( ` Received : ${ newVal . value } ${ newVal . unit } ` ) ;
2025-05-14 10:31:50 +02:00
const repeatability = m . evaluateRepeatability ( ) ;
if ( repeatability !== null ) {
m . logger . info ( ` Current repeatability (standard deviation): ${ repeatability } ` ) ;
}
} ) ;
const tickLoop = setInterval ( changeInput , 1000 ) ;
function changeInput ( ) {
m . logger . info ( ` tick... ` ) ;
m . tick ( ) ;
//m.inputValue = 5;
}
2026-02-23 13:17:03 +01:00
// */