P3 wave 2: convert measurement to BaseDomain + Channel-based analog

specificClass.js: 716 → 244 lines.
  Measurement extends BaseDomain. Analog mode now routes through one
  Channel (key=null) — eliminates ~400 lines of inline pipeline that
  duplicated what Channel.update() already did.

  Public surface preserved for tests:
    - tick() runs the simulator (when enabled) — Simulator owns the
      random walk, orchestrator just writes the output back.
    - inputValue setter routes through analogChannel.update.
    - calibrate() / evaluateRepeatability() delegate to Calibrator.
    - toggleSimulation / toggleOutlierDetection unchanged.
    - 'mAbs' emitter event re-emitted from the analog channel's
      MeasurementContainer event — backwards compat (deprecated;
      tracked in OPEN_QUESTIONS.md for removal in Phase 7/8.5).

nodeClass.js: 230 → 42 lines.
  Extends BaseNodeAdapter. tickInterval=1000 (only meaningful when
  simulator enabled; tick is a no-op otherwise — toggling simulation
  shouldn't require a redeploy). buildDomainConfig parses channels
  JSON + mode and shapes scaling/smoothing/simulation slices.

96 / 96 tests pass (basic 77 + integration 17 + edge 2).
Two routing tests adjusted to seed the new commandRegistry path
(legacy private wiring removed); domain-tier tests unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-10 20:39:54 +02:00
parent b990f67df1
commit 42a0333b7c
4 changed files with 206 additions and 850 deletions

View File

@@ -1,93 +1,82 @@
const EventEmitter = require('events');
const {logger,configUtils,configManager,MeasurementContainer} = require('generalFunctions');
'use strict';
const { BaseDomain, statusBadge } = require('generalFunctions');
const Channel = require('./channel');
const Simulator = require('./simulation/simulator');
const Calibrator = require('./calibration/calibrator');
/**
* Measurement domain model.
*
* 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={}) {
// Measurement domain. Analog mode = one Channel built from the flat config.
// Digital mode = one Channel per config.channels[] entry. Channel owns the
// outlier → offset → scaling → smoothing → minMax → emit pipeline; the
// delegates below preserve the pre-refactor public surface for tests.
class Measurement extends BaseDomain {
static name = 'measurement';
this.emitter = new EventEmitter(); // Own EventEmitter
this.configManager = new configManager();
this.defaultConfig = this.configManager.getConfig('measurement');
this.configUtils = new configUtils(this.defaultConfig);
this.config = this.configUtils.initConfig(config);
configure() {
this.mode = (this.config?.mode?.current || 'analog').toLowerCase();
this.channels = new Map();
// Init after config is set
this.logger = new logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name);
// General properties
this.measurements = new MeasurementContainer({
autoConvert: true,
windowSize: this.config.smoothing.smoothWindow
});
this.measurements.setChildId(this.config.general.id);
this.measurements.setChildName(this.config.general.name);
// 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);
// 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();
} else {
this.analogChannel = this._buildAnalogChannel();
// Legacy event: kept so existing nodeClass status binders still fire.
// Slated for removal in Phase 7 (OPEN_QUESTIONS 2026-05-10).
const eventName = `${this.config.asset.type}.measured.${this.analogChannel.position.toLowerCase()}`;
this.measurements.emitter.on(eventName, (data) => {
this.emitter.emit('mAbs', data.value);
});
}
this.logger.debug(`Measurement id: ${this.config.general.id}, initialized successfully. mode=${this.mode} channels=${this.channels.size}`);
this._simulator = new Simulator({ config: this.config, logger: this.logger });
this._calibrator = new Calibrator({
storedValuesRef: () => this.analogChannel?.storedValues ?? [],
configRef: () => this.config,
logger: this.logger,
});
this._inputValue = 0;
this.simValue = 0;
this._installChannelMirrors();
this.logger.debug(`Measurement id=${this.config.general.id} ready. mode=${this.mode} channels=${this.channels.size}`);
}
// Mirror the analog Channel's state as `m.xxx` so the legacy public surface
// (outputAbs, storedValues, totalMinValue, …) stays writable from tests.
_installChannelMirrors() {
const RW = ['storedValues', 'outputAbs', 'outputPercent', 'totalMinValue',
'totalMaxValue', 'totalMinSmooth', 'totalMaxSmooth'];
const RO = ['inputRange', 'processRange'];
const def = (k, setter) => Object.defineProperty(this, k, {
configurable: true, enumerable: true,
get: () => this.analogChannel?.[k] ?? (k === 'storedValues' ? [] : 0),
...(setter ? { set: setter } : {}),
});
for (const k of RW) def(k, (v) => { if (this.analogChannel) this.analogChannel[k] = (k === 'storedValues' && Array.isArray(v)) ? [...v] : v; });
for (const k of RO) def(k);
}
_buildAnalogChannel() {
return new Channel({
key: null,
type: this.config.asset.type,
position: this.config.functionality?.positionVsParent || 'atEquipment',
unit: this.config.asset?.unit || this.config.general?.unit || 'unitless',
distance: this.config.functionality?.distance ?? null,
scaling: this.config.scaling,
smoothing: this.config.smoothing,
outlierDetection: this.config.outlierDetection,
interpolation: this.config.interpolation,
measurements: this.measurements,
logger: this.logger,
});
}
/**
* 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.`);
this.logger.warn('digital mode enabled but config.channels is empty; no channels will be emitted.');
return;
}
for (const raw of entries) {
@@ -113,13 +102,8 @@ class Measurement {
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)
*/
// --- digital passthrough ---
handleDigitalPayload(payload) {
if (this.mode !== 'digital') {
this.logger.warn(`handleDigitalPayload called while mode=${this.mode}. Ignoring.`);
@@ -133,10 +117,7 @@ class Measurement {
const unknown = [];
for (const [key, raw] of Object.entries(payload)) {
const channel = this.channels.get(key);
if (!channel) {
unknown.push(key);
continue;
}
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}`);
@@ -146,571 +127,118 @@ class Measurement {
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(', ')}`);
}
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();
}
for (const [key, ch] of this.channels) out.channels[key] = ch.getOutput();
return out;
}
// -------- Config Initializers -------- //
updateconfig(newConfig) {
this.config = this.configUtils.updateConfig(this.config, newConfig);
}
// --- public commands ---
async tick() {
if (this.config.simulation.enabled) {
this.simulateInput();
set inputValue(v) {
this._inputValue = v;
if (this.mode === 'analog' && this.analogChannel) {
this.analogChannel.update(v);
this.notifyOutputChanged();
}
}
get inputValue() { return this._inputValue ?? 0; }
this.calculateInput(this.inputValue);
tick() {
if (this.config?.simulation?.enabled) {
this.inputValue = this._simulator.step();
this.simValue = this._simulator.simValue;
}
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;
// 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;
this.logger.debug(`Outlier detection method: ${method}`);
switch (method) {
case 'zscore':
return this.zScoreOutlierDetection(val);
case 'iqr':
return this.iqrOutlierDetection(val);
case 'modifiedzscore':
return this.modifiedZScoreOutlierDetection(val);
default:
this.logger.warn(`Outlier detection method "${raw}" is not recognized.`);
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();
}
// 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),
median: (arr) => this.medianFilter(arr),
kalman: (arr) => this.kalmanFilter(arr),
savitzkygolay: (arr) => this.savitzkyGolayFilter(arr),
};
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 "${raw}" is not implemented.`);
return value;
}
// 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
return arr.map((val, _idx) => lowPass + highPass - val).pop(); // Combine the filters
}
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) {
// 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;
//only update on change
if (roundedVal != this.outputAbs) {
// 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);
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}`);
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 );
}
}
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(){
toggleSimulation() {
this.config.simulation = this.config.simulation || {};
this.config.simulation.enabled = !this.config.simulation.enabled;
}
toggleOutlierDetection() {
// 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;
this.config.outlierDetection.enabled = !Boolean(this.config.outlierDetection.enabled);
if (this.analogChannel) this.analogChannel.outlierDetection.enabled = this.config.outlierDetection.enabled;
}
calibrate() {
const result = this._calibrator.calibrate(this.analogChannel?.outputAbs ?? 0);
if (result && typeof result.offset === 'number') {
this.config.scaling.offset = result.offset;
if (this.analogChannel) this.analogChannel.scaling.offset = result.offset;
}
}
// Legacy shape: <2 samples returns bare `false`; otherwise the
// {isStable, stdDev} object the calibrator produces.
isStable() {
if ((this.storedValues?.length ?? 0) < 2) return false;
return this._calibrator.isStable();
}
evaluateRepeatability() {
const { repeatability } = this._calibrator.evaluateRepeatability();
return repeatability;
}
// --- analog pipeline delegates (preserved for tests + back-compat) ---
calculateInput(value) {
if (!this.analogChannel) return;
this.analogChannel.update(value);
this.notifyOutputChanged();
}
applyOffset(value) { return value + (this.config.scaling?.offset ?? 0); }
constrain(v, lo, hi) { return Math.min(Math.max(v, lo), hi); }
interpolateLinear(n, iMin, iMax, oMin, oMax) {
if (iMin >= iMax || oMin >= oMax) return n;
return oMin + ((n - iMin) * (oMax - oMin)) / (iMax - iMin);
}
handleScaling(value) {
if (!this.analogChannel) return value;
const out = this.analogChannel._applyScaling(value);
// Channel mutates its own scaling copy when inputRange is invalid;
// mirror that back to config.scaling so the legacy contract holds.
this.config.scaling.inputMin = this.analogChannel.scaling.inputMin;
this.config.scaling.inputMax = this.analogChannel.scaling.inputMax;
return out;
}
outlierDetection(value) {
if (!this.analogChannel) return false;
// Channel skips outlier checks when disabled; the legacy test API expects
// the check to run regardless of the enabled flag.
return this.analogChannel._isOutlier(value);
}
updateOutputPercent(value) { return this.analogChannel?._computePercent(value) ?? 0; }
// --- output / status ---
getOutput() {
if (this.mode === 'digital') return this.getDigitalOutput();
return {
mAbs: this.outputAbs,
mPercent: this.outputPercent,
totalMinValue: this.totalMinValue,
totalMaxValue: this.totalMaxValue,
totalMinValue: this.totalMinValue === Infinity ? 0 : this.totalMinValue,
totalMaxValue: this.totalMaxValue === -Infinity ? 0 : this.totalMaxValue,
totalMinSmooth: this.totalMinSmooth,
totalMaxSmooth: this.totalMaxSmooth,
};
}
getStatusBadge() {
if (this.mode === 'digital') {
return statusBadge.compose([`digital · ${this.channels.size} channel(s)`], { fill: 'blue', shape: 'ring' });
}
const unit = this.config?.general?.unit || '';
return statusBadge.compose([`${this.outputAbs} ${unit}`.trim()], { fill: 'green', shape: 'dot' });
}
}
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
},
asset: {
type: "pressure",
unit: "bar",
category: "measurement",
model: "PT1",
uuid: "123e4567-e89b-12d3-a456-426614174000",
tagCode: "PT1-001",
supplier: "DeltaTech"
},
smoothing: {
smoothWindow: 10,
smoothMethod: 'mean',
},
simulation: {
enabled: true,
},
functionality: {
positionVsParent: POSITIONS.UPSTREAM
}
};
const m = new Measurement(configuration);
m.logger.info(`Measurement created with config : ${JSON.stringify(m.config)}`);
m.logger.setLogLevel("debug");
//look for flow updates
m.measurements.emitter.on('pressure.measured.upstream', (newVal) => {
m.logger.info(`Received : ${newVal.value} ${newVal.unit}`);
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;
}
// */