Compare commits
9 Commits
dev-Rene
...
998b2002e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
998b2002e9 | ||
|
|
fb8d5c03e6 | ||
|
|
d6f8af4395 | ||
|
|
495b4cf400 | ||
|
|
0918be7705 | ||
|
|
f7c3dc2482 | ||
|
|
ed5f02605a | ||
|
|
1b7285f29e | ||
|
|
294cf49521 |
23
CLAUDE.md
Normal file
23
CLAUDE.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# measurement — Claude Code context
|
||||||
|
|
||||||
|
Sensor signal conditioning and data quality.
|
||||||
|
Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
|
||||||
|
|
||||||
|
## S88 classification
|
||||||
|
|
||||||
|
| Level | Colour | Placement lane |
|
||||||
|
|---|---|---|
|
||||||
|
| **Control Module** | `#a9daee` | L2 |
|
||||||
|
|
||||||
|
## Flow layout rules
|
||||||
|
|
||||||
|
When wiring this node into a multi-node demo or production flow, follow the
|
||||||
|
placement rule set in the **EVOLV superproject**:
|
||||||
|
|
||||||
|
> `.claude/rules/node-red-flow-layout.md` (in the EVOLV repo root)
|
||||||
|
|
||||||
|
Key points for this node:
|
||||||
|
- Place on lane **L2** (x-position per the lane table in the rule).
|
||||||
|
- Stack same-level siblings vertically.
|
||||||
|
- Parent/children sit on adjacent lanes (children one lane left, parent one lane right).
|
||||||
|
- Wrap in a Node-RED group box coloured `#a9daee` (Control Module).
|
||||||
119
README.md
119
README.md
@@ -1,3 +1,118 @@
|
|||||||
# convert
|
# measurement
|
||||||
|
|
||||||
Makes unit conversions
|
Node-RED custom node for sensor signal conditioning. Takes raw input — either a single scalar (analog mode) or an MQTT-style JSON object with many keys (digital mode) — and produces scaled, smoothed, outlier-filtered measurements. Part of the [EVOLV](https://gitea.wbd-rd.nl/RnD/EVOLV) wastewater-automation platform.
|
||||||
|
|
||||||
|
Registers itself on port 2 as a child of a parent equipment (rotatingMachine, pumpingStation, reactor, etc.). The parent consumes measurements via shared `MeasurementContainer` events.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.node-red
|
||||||
|
npm install github:gitea.wbd-rd.nl/RnD/measurement
|
||||||
|
```
|
||||||
|
|
||||||
|
Or pull the whole platform via the superproject. Restart Node-RED and the node appears in the palette under **EVOLV**.
|
||||||
|
|
||||||
|
## Two input modes
|
||||||
|
|
||||||
|
### Analog mode (default)
|
||||||
|
|
||||||
|
One scalar per message — the classic PLC / 4-20mA pattern.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "topic": "measurement", "payload": 42 }
|
||||||
|
```
|
||||||
|
|
||||||
|
The node runs one offset → scaling → smoothing → outlier pipeline and emits exactly one MeasurementContainer slot. Every existing flow built before digital mode keeps working unchanged.
|
||||||
|
|
||||||
|
### Digital mode (MQTT / IoT)
|
||||||
|
|
||||||
|
One object per message, many keys:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "topic": "measurement",
|
||||||
|
"payload": { "temperature": 22.5, "humidity": 45, "pressure": 1013 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Each key maps to its own **channel** with independently-configured scaling, smoothing, outlier detection, type, position, unit, and distance. A single inbound message therefore emits N MeasurementContainer slots — one per channel — so a downstream parent sees everything at once.
|
||||||
|
|
||||||
|
Pick the mode in the editor or via `msg.mode`. Analog is the default; digital requires populating `channels` (see *Configuration*).
|
||||||
|
|
||||||
|
## Input topics
|
||||||
|
|
||||||
|
| Topic | Payload | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `measurement` | analog mode: `number` or numeric `string` — stored as `inputValue` and consumed on the next tick. digital mode: `object` keyed by channel names. | drives the pipeline |
|
||||||
|
| `simulator` | — | toggles the simulator flag |
|
||||||
|
| `outlierDetection` | — | toggles outlier detection |
|
||||||
|
| `calibrate` | — | adjust the scaling offset so current output matches `inputMin` (scaling on) or `absMin` (scaling off). Requires a stable window. |
|
||||||
|
|
||||||
|
## Output ports
|
||||||
|
|
||||||
|
| Port | Label | Payload |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | `process` | analog: `{mAbs, mPercent, totalMinValue, totalMaxValue, totalMinSmooth, totalMaxSmooth}`. digital: `{channels: {<key>: {mAbs, mPercent, ...}}}`. Delta-compressed — only changed fields emit each tick. |
|
||||||
|
| 1 | `dbase` | InfluxDB line-protocol telemetry |
|
||||||
|
| 2 | `parent` | `{topic:"registerChild", payload:<nodeId>, positionVsParent, distance}` emitted once ~180ms after deploy |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Common (both modes)
|
||||||
|
|
||||||
|
- **Asset** (menu): supplier, category, `assetType` (measurement type in the container — `pressure`, `flow`, `temperature`, `power`, or any user-defined type like `humidity`), model, unit.
|
||||||
|
- **Logger** (menu): log level + enable flag.
|
||||||
|
- **Position** (menu): `upstream` / `atEquipment` / `downstream` relative to parent; optional distance offset.
|
||||||
|
|
||||||
|
### Analog-mode fields
|
||||||
|
|
||||||
|
| Field | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `Scaling` (checkbox) | enables linear source→process interpolation |
|
||||||
|
| `Source Min / Max` | input-side range (e.g. 4–20 mA) |
|
||||||
|
| `Input Offset` | additive bias applied before scaling |
|
||||||
|
| `Process Min / Max` | output-side range (e.g. 0–3000 mbar) |
|
||||||
|
| `Simulator` (checkbox) | internal random-walk source |
|
||||||
|
| `Smoothing` | one of: `none`, `mean`, `min`, `max`, `sd`, `lowPass`, `highPass`, `weightedMovingAverage`, `bandPass`, `median`, `kalman`, `savitzkyGolay` |
|
||||||
|
| `Window` | sample count for the smoothing window |
|
||||||
|
|
||||||
|
### Digital-mode fields
|
||||||
|
|
||||||
|
- **Mode**: set to `digital`.
|
||||||
|
- **Channels**: JSON array, one entry per channel. Each entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "temperature",
|
||||||
|
"type": "temperature",
|
||||||
|
"position": "atEquipment",
|
||||||
|
"unit": "C",
|
||||||
|
"scaling": { "enabled": false, "inputMin": 0, "inputMax": 1, "absMin": -50, "absMax": 150, "offset": 0 },
|
||||||
|
"smoothing": { "smoothWindow": 5, "smoothMethod": "mean" },
|
||||||
|
"outlierDetection": { "enabled": true, "method": "zScore", "threshold": 3 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`scaling`, `smoothing`, `outlierDetection` are optional — the node falls back to the top-level analog-mode equivalents when missing. `key` is the JSON field name inside `msg.payload`; `type` is the MeasurementContainer axis (can be any string — unknown types are accepted).
|
||||||
|
|
||||||
|
## State and emit contract
|
||||||
|
|
||||||
|
Every channel runs the same pipeline: `outlier → offset → scaling → smoothing → min/max tracking → constrain → emit`. Output is rounded to two decimals. MeasurementContainer events follow the pattern `<type>.<variant>.<position>` all lowercase, e.g. `temperature.measured.atequipment`.
|
||||||
|
|
||||||
|
Unknown measurement types (anything not in the container's built-in measureMap — `pressure`, `flow`, `power`, `temperature`, `volume`, `length`, `mass`, `energy`) are accepted without unit compatibility checks. Known types still validate strictly.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd nodes/measurement
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
71 tests cover every smoothing method, every outlier strategy, scaling, interpolation, constrain, calibration, stability, simulation, output-percent fallback, per-channel pipelines, digital payload dispatch, registration events, and example-flow shape.
|
||||||
|
|
||||||
|
## Production status
|
||||||
|
|
||||||
|
Last reviewed **2026-04-13**. See the project memory file `node_measurement.md` for the current verdict, benchmarks, and wishlist.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
SEE LICENSE. Author: Rene De Ren, Waterschap Brabantse Delta R&D.
|
||||||
|
|||||||
248
measurement.html
248
measurement.html
@@ -20,7 +20,11 @@
|
|||||||
// Define default properties
|
// Define default properties
|
||||||
name: { value: "" }, // use asset category as name
|
name: { value: "" }, // use asset category as name
|
||||||
|
|
||||||
// Define specific properties
|
// Input mode: 'analog' (scalar payload, default) or 'digital' (object payload, many channels)
|
||||||
|
mode: { value: "analog" },
|
||||||
|
channels: { value: "[]" },
|
||||||
|
|
||||||
|
// Define specific properties (analog-mode pipeline defaults)
|
||||||
scaling: { value: false },
|
scaling: { value: false },
|
||||||
i_min: { value: 0, required: true },
|
i_min: { value: 0, required: true },
|
||||||
i_max: { value: 0, required: true },
|
i_max: { value: 0, required: true },
|
||||||
@@ -30,6 +34,8 @@
|
|||||||
simulator: { value: false },
|
simulator: { value: false },
|
||||||
smooth_method: { value: "" },
|
smooth_method: { value: "" },
|
||||||
count: { value: "10", required: true },
|
count: { value: "10", required: true },
|
||||||
|
processOutputFormat: { value: "process" },
|
||||||
|
dbaseOutputFormat: { value: "influxdb" },
|
||||||
|
|
||||||
//define asset properties
|
//define asset properties
|
||||||
uuid: { value: "" },
|
uuid: { value: "" },
|
||||||
@@ -61,60 +67,121 @@
|
|||||||
icon: "font-awesome/fa-sliders",
|
icon: "font-awesome/fa-sliders",
|
||||||
|
|
||||||
label: function () {
|
label: function () {
|
||||||
return (this.positionIcon || "") + " " + (this.assetType || "Measurement");
|
const modeTag = this.mode === 'digital' ? ' [digital]' : '';
|
||||||
|
return (this.positionIcon || "") + " " + (this.assetType || "Measurement") + modeTag;
|
||||||
},
|
},
|
||||||
|
|
||||||
oneditprepare: function() {
|
oneditprepare: function() {
|
||||||
|
const node = this;
|
||||||
|
|
||||||
|
// === Asset / logger / position placeholders (dynamic menus) ===
|
||||||
|
// Kick these off FIRST so that any error in the downstream mode
|
||||||
|
// logic can never block the shared menus. Historical regression:
|
||||||
|
// a ReferenceError in the mode block aborted oneditprepare and
|
||||||
|
// stopped the asset menu from rendering at all.
|
||||||
const waitForMenuData = () => {
|
const waitForMenuData = () => {
|
||||||
if (window.EVOLV?.nodes?.measurement?.initEditor) {
|
if (window.EVOLV?.nodes?.measurement?.initEditor) {
|
||||||
window.EVOLV.nodes.measurement.initEditor(this);
|
window.EVOLV.nodes.measurement.initEditor(node);
|
||||||
} else {
|
} else {
|
||||||
setTimeout(waitForMenuData, 50);
|
setTimeout(waitForMenuData, 50);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Wait for the menu data to be ready before initializing the editor
|
|
||||||
waitForMenuData();
|
waitForMenuData();
|
||||||
|
|
||||||
// THIS IS NODE SPECIFIC --------------- Initialize the dropdowns and other specific UI elements -------------- this should be derived from the config in the future (make config based menu)
|
// IMPORTANT: all DOM references are resolved up front so helper
|
||||||
// Populate smoothing methods dropdown
|
// functions called during initial applyMode() don't trip over the
|
||||||
|
// Temporal Dead Zone on later `const` declarations.
|
||||||
|
|
||||||
|
const modeSelect = document.getElementById('node-input-mode');
|
||||||
|
const analogBlock = document.getElementById('analog-only-fields');
|
||||||
|
const digitalBlock = document.getElementById('digital-only-fields');
|
||||||
|
const modeHint = document.getElementById('mode-hint');
|
||||||
|
const channelsArea = document.getElementById('node-input-channels');
|
||||||
|
const channelsHint = document.getElementById('channels-validation');
|
||||||
|
|
||||||
|
// Initialise the mode <select> from the saved node.mode. Legacy
|
||||||
|
// nodes (saved before the mode field existed) fall back to
|
||||||
|
// 'analog' so they keep behaving exactly like before.
|
||||||
|
const initialMode = (node.mode === 'digital' || node.mode === 'analog') ? node.mode : 'analog';
|
||||||
|
if (modeSelect) modeSelect.value = initialMode;
|
||||||
|
|
||||||
|
// Populate the channels textarea from the saved node.channels
|
||||||
|
// (stored as a raw JSON string; parsing happens server-side).
|
||||||
|
if (channelsArea && typeof node.channels === 'string') {
|
||||||
|
channelsArea.value = node.channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateChannelsJson() {
|
||||||
|
if (!channelsHint) return;
|
||||||
|
if (!modeSelect || modeSelect.value !== 'digital') {
|
||||||
|
channelsHint.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = (channelsArea && channelsArea.value || '').trim();
|
||||||
|
if (!raw || raw === '[]') {
|
||||||
|
channelsHint.innerHTML = '<span style="color:#b45309;">Digital mode with no channels — no measurements will be emitted.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) throw new Error('must be an array');
|
||||||
|
const missing = parsed
|
||||||
|
.map((c, i) => (c && c.key && c.type ? null : 'entry ' + i + ': missing key or type'))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (missing.length) {
|
||||||
|
channelsHint.innerHTML = '<span style="color:#b45309;">' + missing.join('; ') + '</span>';
|
||||||
|
} else {
|
||||||
|
channelsHint.innerHTML = '<span style="color:#047857;">' + parsed.length + ' channel(s) defined: ' + parsed.map((c) => c.key).join(', ') + '</span>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
channelsHint.innerHTML = '<span style="color:#b91c1c;">Invalid JSON: ' + e.message + '</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMode(mode) {
|
||||||
|
const isDigital = mode === 'digital';
|
||||||
|
if (analogBlock) analogBlock.style.display = isDigital ? 'none' : 'block';
|
||||||
|
if (digitalBlock) digitalBlock.style.display = isDigital ? 'block' : 'none';
|
||||||
|
if (modeHint) {
|
||||||
|
modeHint.textContent = isDigital
|
||||||
|
? 'msg.payload must be an OBJECT, e.g. {"temperature": 22.5, "humidity": 45}. Define each key below.'
|
||||||
|
: 'msg.payload must be a NUMBER (or numeric string). Configure scaling/smoothing below.';
|
||||||
|
}
|
||||||
|
validateChannelsJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modeSelect) modeSelect.addEventListener('change', (e) => applyMode(e.target.value));
|
||||||
|
if (channelsArea) channelsArea.addEventListener('input', validateChannelsJson);
|
||||||
|
try { applyMode(initialMode); } catch (e) {
|
||||||
|
console.error('measurement: applyMode failed', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Smoothing method dropdown (analog only) ===
|
||||||
const smoothMethodSelect = document.getElementById('node-input-smooth_method');
|
const smoothMethodSelect = document.getElementById('node-input-smooth_method');
|
||||||
const options = window.EVOLV?.nodes?.measurement?.config?.smoothing?.smoothMethod?.rules?.values || [];
|
const options = window.EVOLV?.nodes?.measurement?.config?.smoothing?.smoothMethod?.rules?.values || [];
|
||||||
|
|
||||||
// Clear existing options
|
|
||||||
smoothMethodSelect.innerHTML = '';
|
smoothMethodSelect.innerHTML = '';
|
||||||
|
|
||||||
// Add empty option
|
|
||||||
const emptyOption = document.createElement('option');
|
const emptyOption = document.createElement('option');
|
||||||
emptyOption.value = '';
|
emptyOption.value = '';
|
||||||
emptyOption.textContent = 'Select method...';
|
emptyOption.textContent = 'Select method...';
|
||||||
smoothMethodSelect.appendChild(emptyOption);
|
smoothMethodSelect.appendChild(emptyOption);
|
||||||
|
|
||||||
// Add smoothing method options
|
|
||||||
options.forEach(option => {
|
options.forEach(option => {
|
||||||
const optionElement = document.createElement('option');
|
const optionElement = document.createElement('option');
|
||||||
optionElement.value = option.value;
|
optionElement.value = option.value;
|
||||||
optionElement.textContent = option.value;
|
optionElement.textContent = option.value;
|
||||||
optionElement.title = option.description; // Add tooltip with full description
|
optionElement.title = option.description;
|
||||||
smoothMethodSelect.appendChild(optionElement);
|
smoothMethodSelect.appendChild(optionElement);
|
||||||
});
|
});
|
||||||
|
if (node.smooth_method) smoothMethodSelect.value = node.smooth_method;
|
||||||
|
|
||||||
// Set current value if it exists
|
// === Scale rows toggle (analog only) ===
|
||||||
if (this.smooth_method) {
|
|
||||||
smoothMethodSelect.value = this.smooth_method;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Scale rows toggle ---
|
|
||||||
const chk = document.getElementById('node-input-scaling');
|
const chk = document.getElementById('node-input-scaling');
|
||||||
const rowMin = document.getElementById('row-input-i_min');
|
const rowMin = document.getElementById('row-input-i_min');
|
||||||
const rowMax = document.getElementById('row-input-i_max');
|
const rowMax = document.getElementById('row-input-i_max');
|
||||||
|
|
||||||
function toggleScalingRows() {
|
function toggleScalingRows() {
|
||||||
const show = chk.checked;
|
const show = chk.checked;
|
||||||
rowMin.style.display = show ? 'block' : 'none';
|
rowMin.style.display = show ? 'block' : 'none';
|
||||||
rowMax.style.display = show ? 'block' : 'none';
|
rowMax.style.display = show ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// wire and initialize
|
|
||||||
chk.addEventListener('change', toggleScalingRows);
|
chk.addEventListener('change', toggleScalingRows);
|
||||||
toggleScalingRows();
|
toggleScalingRows();
|
||||||
|
|
||||||
@@ -138,12 +205,20 @@
|
|||||||
window.EVOLV.nodes.measurement.positionMenu.saveEditor(this);
|
window.EVOLV.nodes.measurement.positionMenu.saveEditor(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save basic properties
|
// Mode is the top-level switch. Always save it first; its value
|
||||||
["smooth_method"].forEach(
|
// drives which other fields are meaningful.
|
||||||
(field) => (node[field] = document.getElementById(`node-input-${field}`).value || "")
|
node.mode = document.getElementById('node-input-mode').value || 'analog';
|
||||||
);
|
|
||||||
|
|
||||||
// Save numeric and boolean properties
|
// Channels JSON (digital). We store the raw string and let the
|
||||||
|
// server-side nodeClass.js parse it so we can surface parse errors
|
||||||
|
// at deploy time instead of silently dropping bad config.
|
||||||
|
node.channels = document.getElementById('node-input-channels').value || '[]';
|
||||||
|
|
||||||
|
// Analog smoothing method.
|
||||||
|
node.smooth_method = document.getElementById('node-input-smooth_method').value || '';
|
||||||
|
|
||||||
|
// Save checkbox properties (always safe to read regardless of mode;
|
||||||
|
// these elements exist in the DOM even when their section is hidden).
|
||||||
["scaling", "simulator"].forEach(
|
["scaling", "simulator"].forEach(
|
||||||
(field) => (node[field] = document.getElementById(`node-input-${field}`).checked)
|
(field) => (node[field] = document.getElementById(`node-input-${field}`).checked)
|
||||||
);
|
);
|
||||||
@@ -152,11 +227,22 @@
|
|||||||
(field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0)
|
(field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validation checks
|
// Mode-dependent validation. In digital mode we don't care about
|
||||||
if (node.scaling && (isNaN(node.i_min) || isNaN(node.i_max))) {
|
// scaling completeness (the channels have their own per-channel
|
||||||
|
// scaling); in analog mode we still warn about half-filled ranges.
|
||||||
|
if (node.mode === 'analog' && node.scaling && (isNaN(node.i_min) || isNaN(node.i_max))) {
|
||||||
RED.notify("Scaling enabled, but input range is incomplete!", "error");
|
RED.notify("Scaling enabled, but input range is incomplete!", "error");
|
||||||
}
|
}
|
||||||
|
if (node.mode === 'digital') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(node.channels || '[]');
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||||
|
RED.notify("Digital mode: no channels defined. The node will emit nothing.", "warning");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
RED.notify("Digital mode: Channels JSON is invalid (" + e.message + ")", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -165,6 +251,29 @@
|
|||||||
|
|
||||||
<script type="text/html" data-template-name="measurement">
|
<script type="text/html" data-template-name="measurement">
|
||||||
|
|
||||||
|
<!-- Input mode -->
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-mode"><i class="fa fa-exchange"></i> Input Mode</label>
|
||||||
|
<select id="node-input-mode" style="width:60%;">
|
||||||
|
<option value="analog">analog — one scalar per msg.payload (classic PLC)</option>
|
||||||
|
<option value="digital">digital — object payload with many channel keys (MQTT/IoT)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" id="mode-hint" style="margin-left:105px; font-size:12px; color:#666;"></div>
|
||||||
|
|
||||||
|
<!-- ===================== DIGITAL MODE FIELDS ===================== -->
|
||||||
|
<div id="digital-only-fields">
|
||||||
|
<div class="form-row" id="row-input-channels">
|
||||||
|
<label for="node-input-channels"><i class="fa fa-list"></i> Channels (JSON)</label>
|
||||||
|
<textarea id="node-input-channels" rows="6" style="width:60%; font-family:monospace;" placeholder='[{"key":"temperature","type":"temperature","position":"atEquipment","unit":"C","scaling":{"enabled":false,"inputMin":0,"inputMax":1,"absMin":-50,"absMax":150,"offset":0},"smoothing":{"smoothWindow":5,"smoothMethod":"mean"}}]'></textarea>
|
||||||
|
<div class="form-tips">One entry per payload key. Each channel has its own type / position / unit / scaling / smoothing / outlier detection. See README for the full schema.</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" id="channels-validation" style="margin-left:105px; font-size:12px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== ANALOG MODE FIELDS ===================== -->
|
||||||
|
<div id="analog-only-fields">
|
||||||
|
<hr>
|
||||||
<!-- Scaling Checkbox -->
|
<!-- Scaling Checkbox -->
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-scaling"
|
<label for="node-input-scaling"
|
||||||
@@ -220,6 +329,26 @@
|
|||||||
<input type="number" id="node-input-count" placeholder="10" style="width:60px;"/>
|
<input type="number" id="node-input-count" placeholder="10" style="width:60px;"/>
|
||||||
<div class="form-tips">Number of samples for smoothing</div>
|
<div class="form-tips">Number of samples for smoothing</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h3>Output Formats</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-processOutputFormat"><i class="fa fa-random"></i> Process Output</label>
|
||||||
|
<select id="node-input-processOutputFormat" style="width:60%;">
|
||||||
|
<option value="process">process</option>
|
||||||
|
<option value="json">json</option>
|
||||||
|
<option value="csv">csv</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||||
|
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||||
|
<option value="influxdb">influxdb</option>
|
||||||
|
<option value="json">json</option>
|
||||||
|
<option value="csv">csv</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Optional Extended Fields: supplier, cat, type, model, unit -->
|
<!-- Optional Extended Fields: supplier, cat, type, model, unit -->
|
||||||
<!-- Asset fields will be injected here -->
|
<!-- Asset fields will be injected here -->
|
||||||
@@ -235,20 +364,49 @@
|
|||||||
|
|
||||||
|
|
||||||
<script type="text/html" data-help-name="measurement">
|
<script type="text/html" data-help-name="measurement">
|
||||||
<p><b>Measurement Node</b>: Scales, smooths, and simulates measurement data.</p>
|
<p><b>Measurement</b>: signal conditioning for a sensor or a bundle of sensors. Runs offset → scaling → smoothing → outlier filtering on each incoming value and publishes into the shared <code>MeasurementContainer</code>.</p>
|
||||||
<p>Use this node to scale, smooth, and simulate measurement data. The node can be configured to scale input data to a specified range, smooth the data using a variety of methods, and simulate data for testing purposes.</p>
|
|
||||||
<li><b>Supplier:</b> Select a supplier to populate machine options.</li>
|
|
||||||
<li><b>SubType:</b> Select a subtype if applicable to further categorize the asset.</li>
|
|
||||||
<li><b>Model:</b> Define the specific model for more granular asset configuration.</li>
|
|
||||||
<li><b>Unit:</b> Assign a unit to standardize measurements or operations.</li>
|
|
||||||
<li><b>Scaling:</b> Enable or disable input scaling. When enabled, you must provide the source min and max values.</li>
|
|
||||||
<li><b>Source Min/Max:</b> Define the minimum and maximum values for the input range when scaling is enabled.</li>
|
|
||||||
<li><b>Input Offset:</b> Specify an offset value to be added to the input measurement.</li>
|
|
||||||
<li><b>Process Min/Max:</b> Define the minimum and maximum values for the output range after processing.</li>
|
|
||||||
<li><b>Simulator:</b> Activate internal simulation for testing purposes.</li>
|
|
||||||
<li><b>Smoothing:</b> Select a smoothing method to apply to the measurement data.</li>
|
|
||||||
<li><b>Window:</b> Define the number of samples to use for smoothing.</li>
|
|
||||||
<li><b>Enable Log:</b> Enable or disable logging for this node.</li>
|
|
||||||
<li><b>Log Level:</b> Select the log level (Info, Debug, Warn, Error) for logging messages.</li>
|
|
||||||
|
|
||||||
|
<h3>Input modes</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>analog</b> (default) — <code>msg.payload</code> is a single number (PLC / 4-20 mA style). One pipeline, one output measurement.</li>
|
||||||
|
<li><b>digital</b> — <code>msg.payload</code> is an object with many keys (MQTT / JSON IoT). Each key maps to its own <i>channel</i> with independent scaling, smoothing, outlier detection, type, position, unit. One message → N measurements.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Topics (<code>msg.topic</code>)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>measurement</code> — main input. analog: number; digital: object keyed by channel names.</li>
|
||||||
|
<li><code>simulator</code> — toggle the internal random-walk source.</li>
|
||||||
|
<li><code>outlierDetection</code> — toggle the outlier filter.</li>
|
||||||
|
<li><code>calibrate</code> — set offset so current output matches <code>Source Min</code> (scaling on) / <code>Process Min</code> (scaling off). Requires a stable window.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Output ports</h3>
|
||||||
|
<ol>
|
||||||
|
<li><b>process</b> — delta-compressed payload. analog: <code>{mAbs, mPercent, totalMinValue, totalMaxValue, totalMinSmooth, totalMaxSmooth}</code>. digital: <code>{channels: { key: {...} }}</code>.</li>
|
||||||
|
<li><b>dbase</b> — InfluxDB line-protocol telemetry.</li>
|
||||||
|
<li><b>parent</b> — <code>registerChild</code> handshake for the parent equipment node.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Analog configuration</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>Scaling</b>: enables linear interpolation from <code>[Source Min, Source Max]</code> to <code>[Process Min, Process Max]</code>.</li>
|
||||||
|
<li><b>Input Offset</b>: additive bias applied before scaling.</li>
|
||||||
|
<li><b>Smoothing</b>: <code>none</code> | <code>mean</code> | <code>min</code> | <code>max</code> | <code>sd</code> | <code>lowPass</code> | <code>highPass</code> | <code>weightedMovingAverage</code> | <code>bandPass</code> | <code>median</code> | <code>kalman</code> | <code>savitzkyGolay</code>.</li>
|
||||||
|
<li><b>Window</b>: sample count for the smoothing window.</li>
|
||||||
|
<li><b>Outlier detection</b> (via <code>outlierDetection</code> topic toggle): <code>zScore</code>, <code>iqr</code>, <code>modifiedZScore</code>.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Digital configuration</h3>
|
||||||
|
<p>Populate the <b>Channels (JSON)</b> field with an array. Each entry:</p>
|
||||||
|
<pre>{
|
||||||
|
"key": "temperature",
|
||||||
|
"type": "temperature",
|
||||||
|
"position": "atEquipment",
|
||||||
|
"unit": "C",
|
||||||
|
"scaling": { "enabled": false, "inputMin": 0, "inputMax": 1, "absMin": -50, "absMax": 150, "offset": 0 },
|
||||||
|
"smoothing": { "smoothWindow": 5, "smoothMethod": "mean" },
|
||||||
|
"outlierDetection": { "enabled": true, "method": "zScore", "threshold": 3 }
|
||||||
|
}</pre>
|
||||||
|
<p><code>scaling</code>, <code>smoothing</code>, <code>outlierDetection</code> are optional — missing sections fall back to the analog-mode fields above.</p>
|
||||||
|
<p>Unknown <code>type</code> values (anything not in <code>pressure/flow/power/temperature/volume/length/mass/energy</code>) are accepted without unit compatibility checks, so user-defined channels like <code>humidity</code>, <code>co2</code>, <code>voc</code> work out of the box.</p>
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
311
src/channel.js
Normal file
311
src/channel.js
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* Channel — a single scalar measurement pipeline.
|
||||||
|
*
|
||||||
|
* A Channel owns one rolling window of stored values, one smoothing method,
|
||||||
|
* one outlier detector, one scaling contract, and one MeasurementContainer
|
||||||
|
* slot. It exposes `update(value)` as the single entry point.
|
||||||
|
*
|
||||||
|
* The measurement node composes Channels:
|
||||||
|
* - analog mode -> exactly one Channel built from the flat top-level config
|
||||||
|
* - digital mode -> one Channel per `config.channels[i]` entry, keyed by
|
||||||
|
* `channel.key` (the field inside msg.payload that feeds it)
|
||||||
|
*
|
||||||
|
* This file is pure domain logic. It must never reach into Node-RED APIs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Channel {
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.key - identifier inside an incoming object payload (digital) or null (analog)
|
||||||
|
* @param {string} opts.type - MeasurementContainer axis (e.g. 'pressure')
|
||||||
|
* @param {string} opts.position - 'upstream' | 'atEquipment' | 'downstream'
|
||||||
|
* @param {string} opts.unit - output unit label (e.g. 'mbar')
|
||||||
|
* @param {number|null} opts.distance - physical offset from parent equipment
|
||||||
|
* @param {object} opts.scaling - {enabled, inputMin, inputMax, absMin, absMax, offset}
|
||||||
|
* @param {object} opts.smoothing - {smoothWindow, smoothMethod}
|
||||||
|
* @param {object} [opts.outlierDetection] - {enabled, method, threshold}
|
||||||
|
* @param {object} opts.interpolation - {percentMin, percentMax}
|
||||||
|
* @param {object} opts.measurements - the MeasurementContainer to publish into
|
||||||
|
* @param {object} opts.logger - generalFunctions logger instance
|
||||||
|
*/
|
||||||
|
constructor(opts) {
|
||||||
|
this.key = opts.key || null;
|
||||||
|
this.type = opts.type;
|
||||||
|
this.position = opts.position;
|
||||||
|
this.unit = opts.unit;
|
||||||
|
this.distance = opts.distance ?? null;
|
||||||
|
|
||||||
|
this.scaling = { ...opts.scaling };
|
||||||
|
this.smoothing = { ...opts.smoothing };
|
||||||
|
this.outlierDetection = opts.outlierDetection ? { ...opts.outlierDetection } : { enabled: false, method: 'zscore', threshold: 3 };
|
||||||
|
this.interpolation = { ...(opts.interpolation || { percentMin: 0, percentMax: 100 }) };
|
||||||
|
|
||||||
|
this.measurements = opts.measurements;
|
||||||
|
this.logger = opts.logger;
|
||||||
|
|
||||||
|
this.storedValues = [];
|
||||||
|
this.inputValue = 0;
|
||||||
|
this.outputAbs = 0;
|
||||||
|
this.outputPercent = 0;
|
||||||
|
|
||||||
|
this.totalMinValue = Infinity;
|
||||||
|
this.totalMaxValue = -Infinity;
|
||||||
|
this.totalMinSmooth = 0;
|
||||||
|
this.totalMaxSmooth = 0;
|
||||||
|
|
||||||
|
this.inputRange = Math.abs(this.scaling.inputMax - this.scaling.inputMin);
|
||||||
|
this.processRange = Math.abs(this.scaling.absMax - this.scaling.absMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Public entry point ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a new scalar value through the full pipeline:
|
||||||
|
* outlier -> offset -> scaling -> smoothing -> min/max -> emit
|
||||||
|
* @param {number} value
|
||||||
|
* @returns {boolean} true if the value advanced the pipeline (not rejected as outlier)
|
||||||
|
*/
|
||||||
|
update(value) {
|
||||||
|
this.inputValue = value;
|
||||||
|
|
||||||
|
if (this.outlierDetection.enabled && this._isOutlier(value)) {
|
||||||
|
this.logger?.warn?.(`[${this.key || this.type}] Outlier detected. Ignoring value=${value}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let v = value + (this.scaling.offset || 0);
|
||||||
|
this._updateMinMax(v);
|
||||||
|
|
||||||
|
if (this.scaling.enabled) {
|
||||||
|
v = this._applyScaling(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const smoothed = this._applySmoothing(v);
|
||||||
|
this._updateSmoothMinMax(smoothed);
|
||||||
|
this._writeOutput(smoothed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getOutput() {
|
||||||
|
return {
|
||||||
|
key: this.key,
|
||||||
|
type: this.type,
|
||||||
|
position: this.position,
|
||||||
|
unit: this.unit,
|
||||||
|
mAbs: this.outputAbs,
|
||||||
|
mPercent: this.outputPercent,
|
||||||
|
totalMinValue: this.totalMinValue === Infinity ? 0 : this.totalMinValue,
|
||||||
|
totalMaxValue: this.totalMaxValue === -Infinity ? 0 : this.totalMaxValue,
|
||||||
|
totalMinSmooth: this.totalMinSmooth,
|
||||||
|
totalMaxSmooth: this.totalMaxSmooth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Outlier detection ---
|
||||||
|
|
||||||
|
_isOutlier(val) {
|
||||||
|
if (this.storedValues.length < 2) return false;
|
||||||
|
const raw = this.outlierDetection.method;
|
||||||
|
const method = typeof raw === 'string' ? raw.toLowerCase() : raw;
|
||||||
|
switch (method) {
|
||||||
|
case 'zscore': return this._zScore(val);
|
||||||
|
case 'iqr': return this._iqr(val);
|
||||||
|
case 'modifiedzscore': return this._modifiedZScore(val);
|
||||||
|
default:
|
||||||
|
this.logger?.warn?.(`[${this.key || this.type}] Unknown outlier method "${raw}"`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_zScore(val) {
|
||||||
|
const threshold = this.outlierDetection.threshold || 3;
|
||||||
|
const m = Channel._mean(this.storedValues);
|
||||||
|
const sd = Channel._stdDev(this.storedValues);
|
||||||
|
// Intentionally do NOT early-return on sd===0: a perfectly stable
|
||||||
|
// baseline should make any deviation an outlier (z = Infinity > threshold).
|
||||||
|
const z = sd === 0 ? (val === m ? 0 : Infinity) : (val - m) / sd;
|
||||||
|
return Math.abs(z) > threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
_iqr(val) {
|
||||||
|
const sorted = [...this.storedValues].sort((a, b) => a - b);
|
||||||
|
const q1 = sorted[Math.floor(sorted.length / 4)];
|
||||||
|
const q3 = sorted[Math.floor(sorted.length * 3 / 4)];
|
||||||
|
const iqr = q3 - q1;
|
||||||
|
return val < q1 - 1.5 * iqr || val > q3 + 1.5 * iqr;
|
||||||
|
}
|
||||||
|
|
||||||
|
_modifiedZScore(val) {
|
||||||
|
const median = Channel._median(this.storedValues);
|
||||||
|
const mad = Channel._median(this.storedValues.map((v) => Math.abs(v - median)));
|
||||||
|
if (mad === 0) return false;
|
||||||
|
const mz = 0.6745 * (val - median) / mad;
|
||||||
|
const threshold = this.outlierDetection.threshold || 3.5;
|
||||||
|
return Math.abs(mz) > threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scaling ---
|
||||||
|
|
||||||
|
_applyScaling(value) {
|
||||||
|
if (this.inputRange <= 0) {
|
||||||
|
this.logger?.warn?.(`[${this.key || this.type}] Input range invalid; falling back to [0,1].`);
|
||||||
|
this.scaling.inputMin = 0;
|
||||||
|
this.scaling.inputMax = 1;
|
||||||
|
this.inputRange = 1;
|
||||||
|
}
|
||||||
|
const clamped = Math.min(Math.max(value, this.scaling.inputMin), this.scaling.inputMax);
|
||||||
|
return this.scaling.absMin + ((clamped - this.scaling.inputMin) * (this.scaling.absMax - this.scaling.absMin)) / this.inputRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Smoothing ---
|
||||||
|
|
||||||
|
_applySmoothing(value) {
|
||||||
|
this.storedValues.push(value);
|
||||||
|
if (this.storedValues.length > this.smoothing.smoothWindow) {
|
||||||
|
this.storedValues.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = this.smoothing.smoothMethod;
|
||||||
|
const method = typeof raw === 'string' ? raw.toLowerCase() : raw;
|
||||||
|
const arr = this.storedValues;
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'none': return arr[arr.length - 1];
|
||||||
|
case 'mean': return Channel._mean(arr);
|
||||||
|
case 'min': return Math.min(...arr);
|
||||||
|
case 'max': return Math.max(...arr);
|
||||||
|
case 'sd': return Channel._stdDev(arr);
|
||||||
|
case 'median': return Channel._median(arr);
|
||||||
|
case 'weightedmovingaverage': return Channel._wma(arr);
|
||||||
|
case 'lowpass': return Channel._lowPass(arr);
|
||||||
|
case 'highpass': return Channel._highPass(arr);
|
||||||
|
case 'bandpass': return Channel._bandPass(arr);
|
||||||
|
case 'kalman': return Channel._kalman(arr);
|
||||||
|
case 'savitzkygolay': return Channel._savitzkyGolay(arr);
|
||||||
|
default:
|
||||||
|
this.logger?.error?.(`[${this.key || this.type}] Smoothing method "${raw}" not implemented.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Output writes ---
|
||||||
|
|
||||||
|
_updateMinMax(value) {
|
||||||
|
if (value < this.totalMinValue) this.totalMinValue = value;
|
||||||
|
if (value > this.totalMaxValue) this.totalMaxValue = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateSmoothMinMax(value) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
_writeOutput(val) {
|
||||||
|
const clamped = Math.min(Math.max(val, this.scaling.absMin), this.scaling.absMax);
|
||||||
|
const rounded = Math.round(clamped * 100) / 100;
|
||||||
|
|
||||||
|
if (rounded !== this.outputAbs) {
|
||||||
|
this.outputAbs = rounded;
|
||||||
|
this.outputPercent = this._computePercent(clamped);
|
||||||
|
this.measurements
|
||||||
|
?.type(this.type)
|
||||||
|
.variant('measured')
|
||||||
|
.position(this.position)
|
||||||
|
.distance(this.distance)
|
||||||
|
.value(this.outputAbs, Date.now(), this.unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_computePercent(value) {
|
||||||
|
const { percentMin, percentMax } = this.interpolation;
|
||||||
|
let pct;
|
||||||
|
if (this.processRange <= 0) {
|
||||||
|
const lo = this.totalMinValue === Infinity ? 0 : this.totalMinValue;
|
||||||
|
const hi = this.totalMaxValue === -Infinity ? 1 : this.totalMaxValue;
|
||||||
|
pct = this._lerp(value, lo, hi, percentMin, percentMax);
|
||||||
|
} else {
|
||||||
|
pct = this._lerp(value, this.scaling.absMin, this.scaling.absMax, percentMin, percentMax);
|
||||||
|
}
|
||||||
|
return Math.round(pct * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lerp(n, iMin, iMax, oMin, oMax) {
|
||||||
|
if (iMin >= iMax || oMin >= oMax) return n;
|
||||||
|
return oMin + ((n - iMin) * (oMax - oMin)) / (iMax - iMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pure math helpers (static so they're reusable) ---
|
||||||
|
|
||||||
|
static _mean(arr) {
|
||||||
|
if (!arr.length) return 0;
|
||||||
|
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _stdDev(arr) {
|
||||||
|
if (arr.length <= 1) return 0;
|
||||||
|
const m = Channel._mean(arr);
|
||||||
|
const variance = arr.map((v) => (v - m) ** 2).reduce((a, b) => a + b, 0) / (arr.length - 1);
|
||||||
|
return Math.sqrt(variance);
|
||||||
|
}
|
||||||
|
|
||||||
|
static _median(arr) {
|
||||||
|
const sorted = [...arr].sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _wma(arr) {
|
||||||
|
const weights = arr.map((_, i) => i + 1);
|
||||||
|
const weightedSum = arr.reduce((sum, v, i) => sum + v * weights[i], 0);
|
||||||
|
const weightTotal = weights.reduce((s, w) => s + w, 0);
|
||||||
|
return weightedSum / weightTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _lowPass(arr) {
|
||||||
|
const alpha = 0.2;
|
||||||
|
let out = arr[0];
|
||||||
|
for (let i = 1; i < arr.length; i++) out = alpha * arr[i] + (1 - alpha) * out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _highPass(arr) {
|
||||||
|
const alpha = 0.8;
|
||||||
|
const filtered = [arr[0]];
|
||||||
|
for (let i = 1; i < arr.length; i++) {
|
||||||
|
filtered[i] = alpha * (filtered[i - 1] + arr[i] - arr[i - 1]);
|
||||||
|
}
|
||||||
|
return filtered[filtered.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
static _bandPass(arr) {
|
||||||
|
const lp = Channel._lowPass(arr);
|
||||||
|
const hp = Channel._highPass(arr);
|
||||||
|
return arr.map((v) => lp + hp - v).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
static _kalman(arr) {
|
||||||
|
let estimate = arr[0];
|
||||||
|
const measurementNoise = 1;
|
||||||
|
const processNoise = 0.1;
|
||||||
|
const gain = processNoise / (processNoise + measurementNoise);
|
||||||
|
for (let i = 1; i < arr.length; i++) estimate = estimate + gain * (arr[i] - estimate);
|
||||||
|
return estimate;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _savitzkyGolay(arr) {
|
||||||
|
const coeffs = [-3, 12, 17, 12, -3];
|
||||||
|
const norm = coeffs.reduce((a, b) => a + b, 0);
|
||||||
|
if (arr.length < coeffs.length) return arr[arr.length - 1];
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < coeffs.length; i++) {
|
||||||
|
s += arr[arr.length - coeffs.length + i] * coeffs[i];
|
||||||
|
}
|
||||||
|
return s / norm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Channel;
|
||||||
@@ -39,33 +39,28 @@ class nodeClass {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load and merge default config with user-defined settings.
|
* Load and merge default config with user-defined settings.
|
||||||
|
* Uses ConfigManager.buildConfig() for base sections (general, asset, functionality),
|
||||||
|
* then adds measurement-specific domain config.
|
||||||
* @param {object} uiConfig - Raw config from Node-RED UI.
|
* @param {object} uiConfig - Raw config from Node-RED UI.
|
||||||
*/
|
*/
|
||||||
_loadConfig(uiConfig,node) {
|
_loadConfig(uiConfig,node) {
|
||||||
const cfgMgr = new configManager();
|
const cfgMgr = new configManager();
|
||||||
this.defaultConfig = cfgMgr.getConfig(this.name);
|
this.defaultConfig = cfgMgr.getConfig(this.name);
|
||||||
|
|
||||||
// Merge UI config over defaults
|
// Build config: base sections + measurement-specific domain config
|
||||||
this.config = {
|
// `channels` (digital mode) is stored on the UI as a JSON string to
|
||||||
general: {
|
// avoid requiring a custom editor table widget at first. We parse here;
|
||||||
name: this.name,
|
// invalid JSON is logged and the node falls back to an empty array.
|
||||||
id: node.id, // node.id is for the child registration process
|
let channels = [];
|
||||||
unit: uiConfig.unit, // add converter options later to convert to default units (need like a model that defines this which units we are going to use and then conver to those standards)
|
if (typeof uiConfig.channels === 'string' && uiConfig.channels.trim()) {
|
||||||
logging: {
|
try { channels = JSON.parse(uiConfig.channels); }
|
||||||
enabled: uiConfig.enableLog,
|
catch (e) { node.warn(`Invalid channels JSON: ${e.message}`); channels = []; }
|
||||||
logLevel: uiConfig.logLevel
|
} else if (Array.isArray(uiConfig.channels)) {
|
||||||
|
channels = uiConfig.channels;
|
||||||
}
|
}
|
||||||
},
|
const mode = (typeof uiConfig.mode === 'string' && uiConfig.mode.toLowerCase() === 'digital') ? 'digital' : 'analog';
|
||||||
asset: {
|
|
||||||
uuid: uiConfig.assetUuid, //need to add this later to the asset model
|
this.config = cfgMgr.buildConfig(this.name, uiConfig, node.id, {
|
||||||
tagCode: uiConfig.assetTagCode, //need to add this later to the asset model
|
|
||||||
tagNumber: uiConfig.assetTagNumber,
|
|
||||||
supplier: uiConfig.supplier,
|
|
||||||
category: uiConfig.category, //add later to define as the software type
|
|
||||||
type: uiConfig.assetType,
|
|
||||||
model: uiConfig.model,
|
|
||||||
unit: uiConfig.unit
|
|
||||||
},
|
|
||||||
scaling: {
|
scaling: {
|
||||||
enabled: uiConfig.scaling,
|
enabled: uiConfig.scaling,
|
||||||
inputMin: uiConfig.i_min,
|
inputMin: uiConfig.i_min,
|
||||||
@@ -81,11 +76,9 @@ class nodeClass {
|
|||||||
simulation: {
|
simulation: {
|
||||||
enabled: uiConfig.simulator
|
enabled: uiConfig.simulator
|
||||||
},
|
},
|
||||||
functionality: {
|
mode: { current: mode },
|
||||||
positionVsParent: uiConfig.positionVsParent,// Default to 'atEquipment' if not specified
|
channels,
|
||||||
distance: uiConfig.hasDistance ? uiConfig.distance : undefined
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Utility for formatting outputs
|
// Utility for formatting outputs
|
||||||
this._output = new outputUtils();
|
this._output = new outputUtils();
|
||||||
@@ -104,10 +97,18 @@ class nodeClass {
|
|||||||
*/
|
*/
|
||||||
_bindEvents() {
|
_bindEvents() {
|
||||||
|
|
||||||
|
// Analog mode: the classic 'mAbs' event pushes a green dot with the
|
||||||
|
// current value + unit to the editor.
|
||||||
this.source.emitter.on('mAbs', (val) => {
|
this.source.emitter.on('mAbs', (val) => {
|
||||||
this.node.status({ fill: 'green', shape: 'dot', text: `${val} ${this.config.general.unit}` });
|
this.node.status({ fill: 'green', shape: 'dot', text: `${val} ${this.config.general.unit}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Digital mode: summarise how many channels have ticked a value.
|
||||||
|
// This runs on every accepted channel update so the editor shows live
|
||||||
|
// activity instead of staying blank when no single scalar exists.
|
||||||
|
if (this.source.mode === 'digital') {
|
||||||
|
this.node.status({ fill: 'blue', shape: 'ring', text: `digital · ${this.source.channels.size} channel(s)` });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,7 +140,13 @@ class nodeClass {
|
|||||||
_tick() {
|
_tick() {
|
||||||
this.source.tick();
|
this.source.tick();
|
||||||
|
|
||||||
const raw = this.source.getOutput();
|
// In digital mode we don't funnel through calculateInput with a single
|
||||||
|
// scalar; instead each Channel has already emitted into the
|
||||||
|
// MeasurementContainer on message arrival. The tick payload carries a
|
||||||
|
// per-channel snapshot so downstream flows still see a heartbeat.
|
||||||
|
const raw = (this.source.mode === 'digital')
|
||||||
|
? this.source.getDigitalOutput()
|
||||||
|
: this.source.getOutput();
|
||||||
const processMsg = this._output.formatMsg(raw, this.source.config, 'process');
|
const processMsg = this._output.formatMsg(raw, this.source.config, 'process');
|
||||||
const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
|
const influxMsg = this._output.formatMsg(raw, this.source.config, 'influxdb');
|
||||||
|
|
||||||
@@ -164,6 +171,25 @@ class nodeClass {
|
|||||||
this.source.calibrate();
|
this.source.calibrate();
|
||||||
break;
|
break;
|
||||||
case 'measurement':
|
case 'measurement':
|
||||||
|
// Dispatch based on mode:
|
||||||
|
// analog -> scalar payload (number or numeric string)
|
||||||
|
// digital -> object payload keyed by channel name
|
||||||
|
if (this.source.mode === 'digital') {
|
||||||
|
if (msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload)) {
|
||||||
|
const summary = this.source.handleDigitalPayload(msg.payload);
|
||||||
|
// Summarise what actually got accepted on the node status so
|
||||||
|
// the editor shows a heartbeat per message.
|
||||||
|
const accepted = Object.values(summary).filter((s) => s.ok).length;
|
||||||
|
const total = Object.keys(summary).length;
|
||||||
|
this.node.status({ fill: 'green', shape: 'dot',
|
||||||
|
text: `digital · ${accepted}/${total} ch updated` });
|
||||||
|
} else if (typeof msg.payload === 'number') {
|
||||||
|
// Helpful hint: the user probably configured the wrong mode.
|
||||||
|
this.source.logger?.warn(`digital mode received a number (${msg.payload}); expected an object like {key: value, ...}. Switch Input Mode to 'analog' in the editor or send an object payload.`);
|
||||||
|
} else {
|
||||||
|
this.source.logger?.warn(`digital mode expects an object payload; got ${typeof msg.payload}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (typeof msg.payload === 'number' || (typeof msg.payload === 'string' && msg.payload.trim() !== '')) {
|
if (typeof msg.payload === 'number' || (typeof msg.payload === 'string' && msg.payload.trim() !== '')) {
|
||||||
const parsed = Number(msg.payload);
|
const parsed = Number(msg.payload);
|
||||||
if (!Number.isNaN(parsed)) {
|
if (!Number.isNaN(parsed)) {
|
||||||
@@ -171,6 +197,12 @@ class nodeClass {
|
|||||||
} else {
|
} else {
|
||||||
this.source.logger?.warn(`Invalid numeric measurement payload: ${msg.payload}`);
|
this.source.logger?.warn(`Invalid numeric measurement payload: ${msg.payload}`);
|
||||||
}
|
}
|
||||||
|
} else if (msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload)) {
|
||||||
|
// Helpful hint: the payload is object-shaped but the node is
|
||||||
|
// configured analog. Most likely the user wanted digital mode.
|
||||||
|
const keys = Object.keys(msg.payload).slice(0, 3).join(', ');
|
||||||
|
this.source.logger?.warn(`analog mode received an object payload (keys: ${keys}). Switch Input Mode to 'digital' in the editor and define channels, or feed a numeric payload.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const {logger,configUtils,configManager,MeasurementContainer} = require('generalFunctions');
|
const {logger,configUtils,configManager,MeasurementContainer} = require('generalFunctions');
|
||||||
|
const Channel = require('./channel');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Measurement domain model.
|
* Measurement domain model.
|
||||||
* Handles scaling, smoothing, outlier filtering and emits normalized measurement output.
|
*
|
||||||
|
* Supports two input modes:
|
||||||
|
* - `analog` (default): one scalar value per msg.payload. The node runs the
|
||||||
|
* classic offset / scaling / smoothing / outlier pipeline on it and emits
|
||||||
|
* exactly one measurement into the MeasurementContainer. This is the
|
||||||
|
* original behaviour; every existing flow keeps working unchanged.
|
||||||
|
* - `digital`: msg.payload is an object with many key/value pairs (MQTT /
|
||||||
|
* IoT style). The node builds one Channel per config.channels entry and
|
||||||
|
* routes each key through its own mini-pipeline, emitting N measurements
|
||||||
|
* into the MeasurementContainer from a single input message.
|
||||||
|
*
|
||||||
|
* Mode is selected via `config.mode.current`. When no mode config is present
|
||||||
|
* or mode=analog, the node behaves identically to pre-digital releases.
|
||||||
*/
|
*/
|
||||||
class Measurement {
|
class Measurement {
|
||||||
constructor(config={}) {
|
constructor(config={}) {
|
||||||
@@ -50,8 +63,106 @@ class Measurement {
|
|||||||
this.inputRange = Math.abs(this.config.scaling.inputMax - this.config.scaling.inputMin);
|
this.inputRange = Math.abs(this.config.scaling.inputMax - this.config.scaling.inputMin);
|
||||||
this.processRange = Math.abs(this.config.scaling.absMax - this.config.scaling.absMin);
|
this.processRange = Math.abs(this.config.scaling.absMax - this.config.scaling.absMin);
|
||||||
|
|
||||||
this.logger.debug(`Measurement id: ${this.config.general.id}, initialized successfully.`);
|
// Mode + multi-channel (digital) support. Backward-compatible: when the
|
||||||
|
// config does not declare a mode, we fall back to 'analog' and behave
|
||||||
|
// exactly like the original single-channel node.
|
||||||
|
this.mode = (this.config.mode && typeof this.config.mode.current === 'string')
|
||||||
|
? this.config.mode.current.toLowerCase()
|
||||||
|
: 'analog';
|
||||||
|
this.channels = new Map(); // populated only in digital mode
|
||||||
|
if (this.mode === 'digital') {
|
||||||
|
this._buildDigitalChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Measurement id: ${this.config.general.id}, initialized successfully. mode=${this.mode} channels=${this.channels.size}`);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build one Channel per entry in config.channels. Each Channel gets its
|
||||||
|
* own scaling / smoothing / outlier / position / unit contract; they share
|
||||||
|
* the parent MeasurementContainer so a downstream parent sees all channels
|
||||||
|
* via the same emitter.
|
||||||
|
*/
|
||||||
|
_buildDigitalChannels() {
|
||||||
|
const entries = Array.isArray(this.config.channels) ? this.config.channels : [];
|
||||||
|
if (entries.length === 0) {
|
||||||
|
this.logger.warn(`digital mode enabled but config.channels is empty; no channels will be emitted.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const raw of entries) {
|
||||||
|
if (!raw || typeof raw !== 'object' || !raw.key || !raw.type) {
|
||||||
|
this.logger.warn(`skipping invalid channel entry: ${JSON.stringify(raw)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const channel = new Channel({
|
||||||
|
key: raw.key,
|
||||||
|
type: raw.type,
|
||||||
|
position: raw.position || this.config.functionality?.positionVsParent || 'atEquipment',
|
||||||
|
unit: raw.unit || this.config.asset?.unit || 'unitless',
|
||||||
|
distance: raw.distance ?? this.config.functionality?.distance ?? null,
|
||||||
|
scaling: raw.scaling || { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 },
|
||||||
|
smoothing: raw.smoothing || { smoothWindow: this.config.smoothing.smoothWindow, smoothMethod: this.config.smoothing.smoothMethod },
|
||||||
|
outlierDetection: raw.outlierDetection || this.config.outlierDetection,
|
||||||
|
interpolation: raw.interpolation || this.config.interpolation,
|
||||||
|
measurements: this.measurements,
|
||||||
|
logger: this.logger,
|
||||||
|
});
|
||||||
|
this.channels.set(raw.key, channel);
|
||||||
|
}
|
||||||
|
this.logger.info(`digital mode: built ${this.channels.size} channel(s) from config.channels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Digital mode entry point. Iterate the object payload, look up each key
|
||||||
|
* in the channel map, and run the configured pipeline per channel. Keys
|
||||||
|
* that are not mapped are logged once per call and ignored.
|
||||||
|
* @param {object} payload - e.g. { temperature: 21.5, humidity: 45.2 }
|
||||||
|
* @returns {object} summary of updated channels (for diagnostics)
|
||||||
|
*/
|
||||||
|
handleDigitalPayload(payload) {
|
||||||
|
if (this.mode !== 'digital') {
|
||||||
|
this.logger.warn(`handleDigitalPayload called while mode=${this.mode}. Ignoring.`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||||
|
this.logger.warn(`digital payload must be an object; got ${typeof payload}`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const summary = {};
|
||||||
|
const unknown = [];
|
||||||
|
for (const [key, raw] of Object.entries(payload)) {
|
||||||
|
const channel = this.channels.get(key);
|
||||||
|
if (!channel) {
|
||||||
|
unknown.push(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const v = Number(raw);
|
||||||
|
if (!Number.isFinite(v)) {
|
||||||
|
this.logger.warn(`digital channel '${key}' received non-numeric value: ${raw}`);
|
||||||
|
summary[key] = { ok: false, reason: 'non-numeric' };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ok = channel.update(v);
|
||||||
|
summary[key] = { ok, mAbs: channel.outputAbs, mPercent: channel.outputPercent };
|
||||||
|
}
|
||||||
|
if (unknown.length) {
|
||||||
|
this.logger.debug(`digital payload contained unmapped keys: ${unknown.join(', ')}`);
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return per-channel output snapshots. In analog mode this is the same
|
||||||
|
* getOutput() contract; in digital mode it returns one snapshot per
|
||||||
|
* channel under a `channels` key so the tick output stays JSON-shaped.
|
||||||
|
*/
|
||||||
|
getDigitalOutput() {
|
||||||
|
const out = { channels: {} };
|
||||||
|
for (const [key, ch] of this.channels) {
|
||||||
|
out.channels[key] = ch.getOutput();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- Config Initializers -------- //
|
// -------- Config Initializers -------- //
|
||||||
@@ -170,17 +281,23 @@ class Measurement {
|
|||||||
outlierDetection(val) {
|
outlierDetection(val) {
|
||||||
if (this.storedValues.length < 2) return false;
|
if (this.storedValues.length < 2) return false;
|
||||||
|
|
||||||
this.logger.debug(`Outlier detection method: ${this.config.outlierDetection.method}`);
|
// Config enum values are normalized to lowercase by validateEnum in
|
||||||
|
// generalFunctions, so dispatch on the lowercase form to keep this
|
||||||
|
// tolerant of both legacy (camelCase) and normalized (lowercase) config.
|
||||||
|
const raw = this.config.outlierDetection.method;
|
||||||
|
const method = typeof raw === 'string' ? raw.toLowerCase() : raw;
|
||||||
|
|
||||||
switch (this.config.outlierDetection.method) {
|
this.logger.debug(`Outlier detection method: ${method}`);
|
||||||
case 'zScore':
|
|
||||||
|
switch (method) {
|
||||||
|
case 'zscore':
|
||||||
return this.zScoreOutlierDetection(val);
|
return this.zScoreOutlierDetection(val);
|
||||||
case 'iqr':
|
case 'iqr':
|
||||||
return this.iqrOutlierDetection(val);
|
return this.iqrOutlierDetection(val);
|
||||||
case 'modifiedZScore':
|
case 'modifiedzscore':
|
||||||
return this.modifiedZScoreOutlierDetection(val);
|
return this.modifiedZScoreOutlierDetection(val);
|
||||||
default:
|
default:
|
||||||
this.logger.warn(`Outlier detection method "${this.config.outlierDetection.method}" is not recognized.`);
|
this.logger.warn(`Outlier detection method "${raw}" is not recognized.`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,28 +423,31 @@ class Measurement {
|
|||||||
this.storedValues.shift();
|
this.storedValues.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smoothing strategies
|
// Smoothing strategies keyed by the normalized (lowercase) method name.
|
||||||
|
// validateEnum in generalFunctions lowercases enum values, so dispatch on
|
||||||
|
// the lowercase form to accept both legacy (camelCase) and normalized
|
||||||
|
// (lowercase) config values.
|
||||||
const smoothingMethods = {
|
const smoothingMethods = {
|
||||||
none: (arr) => arr[arr.length - 1],
|
none: (arr) => arr[arr.length - 1],
|
||||||
mean: (arr) => this.mean(arr),
|
mean: (arr) => this.mean(arr),
|
||||||
min: (arr) => this.min(arr),
|
min: (arr) => this.min(arr),
|
||||||
max: (arr) => this.max(arr),
|
max: (arr) => this.max(arr),
|
||||||
sd: (arr) => this.standardDeviation(arr),
|
sd: (arr) => this.standardDeviation(arr),
|
||||||
lowPass: (arr) => this.lowPassFilter(arr),
|
lowpass: (arr) => this.lowPassFilter(arr),
|
||||||
highPass: (arr) => this.highPassFilter(arr),
|
highpass: (arr) => this.highPassFilter(arr),
|
||||||
weightedMovingAverage: (arr) => this.weightedMovingAverage(arr),
|
weightedmovingaverage: (arr) => this.weightedMovingAverage(arr),
|
||||||
bandPass: (arr) => this.bandPassFilter(arr),
|
bandpass: (arr) => this.bandPassFilter(arr),
|
||||||
median: (arr) => this.medianFilter(arr),
|
median: (arr) => this.medianFilter(arr),
|
||||||
kalman: (arr) => this.kalmanFilter(arr),
|
kalman: (arr) => this.kalmanFilter(arr),
|
||||||
savitzkyGolay: (arr) => this.savitzkyGolayFilter(arr),
|
savitzkygolay: (arr) => this.savitzkyGolayFilter(arr),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure the smoothing method is valid
|
const raw = this.config.smoothing.smoothMethod;
|
||||||
const method = this.config.smoothing.smoothMethod;
|
const method = typeof raw === 'string' ? raw.toLowerCase() : raw;
|
||||||
this.logger.debug(`Applying smoothing method "${method}"`);
|
this.logger.debug(`Applying smoothing method "${method}"`);
|
||||||
|
|
||||||
if (!smoothingMethods[method]) {
|
if (!smoothingMethods[method]) {
|
||||||
this.logger.error(`Smoothing method "${method}" is not implemented.`);
|
this.logger.error(`Smoothing method "${raw}" is not implemented.`);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +505,7 @@ class Measurement {
|
|||||||
const lowPass = this.lowPassFilter(arr); // Apply low-pass filter
|
const lowPass = this.lowPassFilter(arr); // Apply low-pass filter
|
||||||
const highPass = this.highPassFilter(arr); // Apply high-pass filter
|
const highPass = this.highPassFilter(arr); // Apply high-pass filter
|
||||||
|
|
||||||
return arr.map((val, idx) => lowPass + highPass - val).pop(); // Combine the filters
|
return arr.map((val, _idx) => lowPass + highPass - val).pop(); // Combine the filters
|
||||||
}
|
}
|
||||||
|
|
||||||
weightedMovingAverage(arr) {
|
weightedMovingAverage(arr) {
|
||||||
@@ -565,7 +685,7 @@ const configuration = {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
functionality: {
|
functionality: {
|
||||||
positionVsParent: "upstream"
|
positionVsParent: POSITIONS.UPSTREAM
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
121
test/basic/calibration-and-stability.basic.test.js
Normal file
121
test/basic/calibration-and-stability.basic.test.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { makeMeasurementInstance } = require('../helpers/factories');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the calibration / stability / repeatability primitives. These
|
||||||
|
* methods interact with the stored window from the smoothing pipeline, so
|
||||||
|
* each test seeds storedValues explicitly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test("isStable returns false with fewer than 2 samples", () => {
|
||||||
|
const m = makeMeasurementInstance();
|
||||||
|
m.storedValues = [];
|
||||||
|
assert.equal(m.isStable(), false); // current implementation returns false (not object) at <2 samples
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isStable reports stability and stdDev for a flat window", () => {
|
||||||
|
const m = makeMeasurementInstance();
|
||||||
|
m.storedValues = [10, 10, 10, 10, 10];
|
||||||
|
const { isStable, stdDev } = m.isStable();
|
||||||
|
assert.equal(isStable, true);
|
||||||
|
assert.equal(stdDev, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluateRepeatability returns stdDev when conditions are met", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'mean' },
|
||||||
|
});
|
||||||
|
m.storedValues = [10, 10, 10, 10, 10];
|
||||||
|
const rep = m.evaluateRepeatability();
|
||||||
|
assert.equal(rep, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluateRepeatability refuses when smoothing is disabled", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
|
||||||
|
});
|
||||||
|
m.storedValues = [10, 10, 10, 10, 10];
|
||||||
|
assert.equal(m.evaluateRepeatability(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluateRepeatability refuses with insufficient samples", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'mean' },
|
||||||
|
});
|
||||||
|
m.storedValues = [10];
|
||||||
|
assert.equal(m.evaluateRepeatability(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calibrate sets offset when input is stable and scaling enabled", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: true, inputMin: 4, inputMax: 20, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'mean' },
|
||||||
|
});
|
||||||
|
// Stable window fed through calculateInput so outputAbs reflects the
|
||||||
|
// pipeline (important because calibrate uses outputAbs for its delta).
|
||||||
|
[3, 3, 3, 3, 3].forEach((v) => m.calculateInput(v));
|
||||||
|
const outputBefore = m.outputAbs;
|
||||||
|
m.calibrate();
|
||||||
|
// Offset should now be inputMin - outputAbs(before).
|
||||||
|
assert.equal(m.config.scaling.offset, 4 - outputBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calibrate aborts when input is not stable", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 10, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'mean' },
|
||||||
|
});
|
||||||
|
// Cheat: populate storedValues with clearly non-stable data. calibrate
|
||||||
|
// calls isStable() -> stdDev > threshold -> warn + no offset change.
|
||||||
|
m.storedValues = [0, 100, 0, 100, 0];
|
||||||
|
const offsetBefore = m.config.scaling.offset;
|
||||||
|
m.calibrate();
|
||||||
|
assert.equal(m.config.scaling.offset, offsetBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calibrate uses absMin when scaling is disabled", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 5, absMax: 10, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'mean' },
|
||||||
|
});
|
||||||
|
[5, 5, 5, 5, 5].forEach((v) => m.calculateInput(v));
|
||||||
|
const out = m.outputAbs;
|
||||||
|
m.calibrate();
|
||||||
|
assert.equal(m.config.scaling.offset, 5 - out);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toggleSimulation flips the simulation flag", () => {
|
||||||
|
const m = makeMeasurementInstance({ simulation: { enabled: false } });
|
||||||
|
m.toggleSimulation();
|
||||||
|
assert.equal(m.config.simulation.enabled, true);
|
||||||
|
m.toggleSimulation();
|
||||||
|
assert.equal(m.config.simulation.enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tick runs simulateInput when simulation is enabled", async () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
|
||||||
|
simulation: { enabled: true },
|
||||||
|
});
|
||||||
|
const before = m.inputValue;
|
||||||
|
await m.tick();
|
||||||
|
await m.tick();
|
||||||
|
await m.tick();
|
||||||
|
// Simulated input must drift from its initial state.
|
||||||
|
assert.notEqual(m.inputValue, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tick is a no-op on inputValue when simulation is disabled", async () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
|
||||||
|
simulation: { enabled: false },
|
||||||
|
});
|
||||||
|
m.inputValue = 42;
|
||||||
|
await m.tick();
|
||||||
|
await m.tick();
|
||||||
|
assert.equal(m.inputValue, 42);
|
||||||
|
});
|
||||||
98
test/basic/outlier-detection.basic.test.js
Normal file
98
test/basic/outlier-detection.basic.test.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { makeMeasurementInstance } = require('../helpers/factories');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit coverage for the three outlier detection strategies shipped by the
|
||||||
|
* measurement node. Each test seeds the storedValues window first, then
|
||||||
|
* probes the classifier directly. This keeps the assertions focused on the
|
||||||
|
* detection logic rather than the full calculateInput pipeline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeDetector(method, threshold) {
|
||||||
|
return makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -1000, absMax: 1000, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 20, smoothMethod: 'none' },
|
||||||
|
outlierDetection: { enabled: true, method, threshold },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed(m, values) {
|
||||||
|
// bypass calculateInput so we don't trigger outlier filtering while seeding
|
||||||
|
m.storedValues = [...values];
|
||||||
|
}
|
||||||
|
|
||||||
|
test("zScore flags a value far above the mean as an outlier", () => {
|
||||||
|
const m = makeDetector('zScore', 3);
|
||||||
|
seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]);
|
||||||
|
assert.equal(m.outlierDetection(100), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zScore does not flag a value inside the distribution", () => {
|
||||||
|
const m = makeDetector('zScore', 3);
|
||||||
|
seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]);
|
||||||
|
assert.equal(m.outlierDetection(11), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("iqr flags a value outside Q1/Q3 fences", () => {
|
||||||
|
const m = makeDetector('iqr');
|
||||||
|
seed(m, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||||
|
assert.equal(m.outlierDetection(100), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("iqr does not flag a value inside Q1/Q3 fences", () => {
|
||||||
|
const m = makeDetector('iqr');
|
||||||
|
seed(m, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||||
|
assert.equal(m.outlierDetection(5), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modifiedZScore flags heavy-tailed outliers", () => {
|
||||||
|
const m = makeDetector('modifiedZScore', 3.5);
|
||||||
|
seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]);
|
||||||
|
assert.equal(m.outlierDetection(1000), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modifiedZScore accepts normal data", () => {
|
||||||
|
const m = makeDetector('modifiedZScore', 3.5);
|
||||||
|
seed(m, [10, 11, 10, 9, 10, 11, 10, 11, 9, 10]);
|
||||||
|
assert.equal(m.outlierDetection(11), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown outlier method falls back to schema default (zScore) and still runs", () => {
|
||||||
|
// validateEnum replaces unknown values with the schema default. The
|
||||||
|
// schema default is "zScore"; the dispatcher normalizes to lowercase
|
||||||
|
// and routes to zScoreOutlierDetection. With a tight window, value=100
|
||||||
|
// is a clear outlier -> returns true.
|
||||||
|
const m = makeDetector('bogus', 3);
|
||||||
|
seed(m, [1, 2, 3, 4, 5]);
|
||||||
|
assert.equal(m.outlierDetection(100), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("outlier detection returns false when window has < 2 samples", () => {
|
||||||
|
const m = makeDetector('zScore', 3);
|
||||||
|
m.storedValues = [];
|
||||||
|
assert.equal(m.outlierDetection(500), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calculateInput ignores a value flagged as outlier", () => {
|
||||||
|
const m = makeDetector('zScore', 3);
|
||||||
|
// Build a tight baseline then throw a spike at it.
|
||||||
|
[10, 10, 10, 10, 10].forEach((v) => m.calculateInput(v));
|
||||||
|
const before = m.outputAbs;
|
||||||
|
m.calculateInput(9999);
|
||||||
|
// Output must not move to the spike (outlier rejected).
|
||||||
|
assert.equal(m.outputAbs, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toggleOutlierDetection flips the flag without corrupting config", () => {
|
||||||
|
const m = makeDetector('zScore', 3);
|
||||||
|
const initial = m.config.outlierDetection.enabled;
|
||||||
|
m.toggleOutlierDetection();
|
||||||
|
assert.equal(m.config.outlierDetection.enabled, !initial);
|
||||||
|
// Re-toggle restores
|
||||||
|
m.toggleOutlierDetection();
|
||||||
|
assert.equal(m.config.outlierDetection.enabled, initial);
|
||||||
|
// Method is preserved (enum values are normalized to lowercase by validateEnum).
|
||||||
|
assert.equal(m.config.outlierDetection.method.toLowerCase(), 'zscore');
|
||||||
|
});
|
||||||
122
test/basic/scaling-and-interpolation.basic.test.js
Normal file
122
test/basic/scaling-and-interpolation.basic.test.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { makeMeasurementInstance } = require('../helpers/factories');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the scaling / offset / interpolation primitives and the min/max
|
||||||
|
* tracking side effects that are not exercised by the existing
|
||||||
|
* scaling-and-output test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test("applyOffset adds configured offset to the input", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 7 },
|
||||||
|
});
|
||||||
|
assert.equal(m.applyOffset(10), 17);
|
||||||
|
assert.equal(m.applyOffset(-3), 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("interpolateLinear maps within range", () => {
|
||||||
|
const m = makeMeasurementInstance();
|
||||||
|
assert.equal(m.interpolateLinear(50, 0, 100, 0, 10), 5);
|
||||||
|
assert.equal(m.interpolateLinear(0, 0, 100, 0, 10), 0);
|
||||||
|
assert.equal(m.interpolateLinear(100, 0, 100, 0, 10), 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("interpolateLinear warns and returns input when ranges collapse", () => {
|
||||||
|
const m = makeMeasurementInstance();
|
||||||
|
// iMin == iMax -> invalid
|
||||||
|
assert.equal(m.interpolateLinear(42, 0, 0, 0, 10), 42);
|
||||||
|
// oMin > oMax -> invalid
|
||||||
|
assert.equal(m.interpolateLinear(42, 0, 100, 10, 0), 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("constrain clamps below, inside, and above range", () => {
|
||||||
|
const m = makeMeasurementInstance();
|
||||||
|
assert.equal(m.constrain(-5, 0, 10), 0);
|
||||||
|
assert.equal(m.constrain(5, 0, 10), 5);
|
||||||
|
assert.equal(m.constrain(15, 0, 10), 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handleScaling falls back when inputRange is invalid", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: true, inputMin: 5, inputMax: 5, absMin: 0, absMax: 10, offset: 0 },
|
||||||
|
});
|
||||||
|
// Before the call, inputRange is 0 (5-5). handleScaling should reset
|
||||||
|
// inputMin/inputMax to defaults [0, 1] and still return a finite number.
|
||||||
|
const result = m.handleScaling(0.5);
|
||||||
|
assert.ok(Number.isFinite(result), `expected finite result, got ${result}`);
|
||||||
|
assert.equal(m.config.scaling.inputMin, 0);
|
||||||
|
assert.equal(m.config.scaling.inputMax, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handleScaling constrains out-of-range inputs before interpolating", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 10, offset: 0 },
|
||||||
|
});
|
||||||
|
// Input above inputMax is constrained to inputMax then mapped to absMax.
|
||||||
|
assert.equal(m.handleScaling(150), 10);
|
||||||
|
// Input below inputMin is constrained to inputMin then mapped to absMin.
|
||||||
|
assert.equal(m.handleScaling(-20), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calculateInput updates raw min/max from the unfiltered input", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1000, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
|
||||||
|
});
|
||||||
|
m.calculateInput(10);
|
||||||
|
m.calculateInput(30);
|
||||||
|
m.calculateInput(5);
|
||||||
|
assert.equal(m.totalMinValue, 5);
|
||||||
|
assert.equal(m.totalMaxValue, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateOutputPercent falls back to observed min/max when processRange <= 0", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 5, absMax: 5, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
|
||||||
|
});
|
||||||
|
// processRange starts at 0 so updateOutputPercent uses totalMinValue/Max.
|
||||||
|
m.totalMinValue = 0;
|
||||||
|
m.totalMaxValue = 100;
|
||||||
|
const pct = m.updateOutputPercent(50);
|
||||||
|
// Linear interp: (50 - 0) / (100 - 0) * 100 = 50.
|
||||||
|
assert.ok(Math.abs(pct - 50) < 0.01, `expected ~50, got ${pct}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateOutputAbs only emits MeasurementContainer update when value changes", async () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
|
||||||
|
});
|
||||||
|
let emitCount = 0;
|
||||||
|
// MeasurementContainer normalizes positions to lowercase, so the
|
||||||
|
// event name uses 'atequipment' not the camelCase config value.
|
||||||
|
m.measurements.emitter.on('pressure.measured.atequipment', () => { emitCount += 1; });
|
||||||
|
|
||||||
|
m.calculateInput(10);
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
m.calculateInput(10); // same value -> no emit
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
m.calculateInput(20); // new value -> emit
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
assert.equal(emitCount, 2, `expected 2 emits (two distinct values), got ${emitCount}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getOutput returns the full tracked state object", () => {
|
||||||
|
const m = makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' },
|
||||||
|
});
|
||||||
|
m.calculateInput(15);
|
||||||
|
const out = m.getOutput();
|
||||||
|
assert.equal(typeof out.mAbs, 'number');
|
||||||
|
assert.equal(typeof out.mPercent, 'number');
|
||||||
|
assert.equal(typeof out.totalMinValue, 'number');
|
||||||
|
assert.equal(typeof out.totalMaxValue, 'number');
|
||||||
|
assert.equal(typeof out.totalMinSmooth, 'number');
|
||||||
|
assert.equal(typeof out.totalMaxSmooth, 'number');
|
||||||
|
});
|
||||||
132
test/basic/smoothing-methods.basic.test.js
Normal file
132
test/basic/smoothing-methods.basic.test.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { makeMeasurementInstance } = require('../helpers/factories');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baseline coverage for every smoothing method exposed by the measurement
|
||||||
|
* node. Each test forces scaling off + outlier-detection off so we can
|
||||||
|
* assert on the raw smoothing arithmetic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeSmoother(method, windowSize = 5) {
|
||||||
|
return makeMeasurementInstance({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1000, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: windowSize, smoothMethod: method },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function feed(m, values) {
|
||||||
|
values.forEach((v) => m.calculateInput(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("smoothing 'none' returns the latest value", () => {
|
||||||
|
const m = makeSmoother('none');
|
||||||
|
feed(m, [10, 20, 30, 40, 50]);
|
||||||
|
assert.equal(m.outputAbs, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'mean' returns arithmetic mean over window", () => {
|
||||||
|
const m = makeSmoother('mean');
|
||||||
|
feed(m, [10, 20, 30, 40, 50]);
|
||||||
|
assert.equal(m.outputAbs, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'min' returns minimum of window", () => {
|
||||||
|
const m = makeSmoother('min');
|
||||||
|
feed(m, [10, 20, 5, 40, 50]);
|
||||||
|
assert.equal(m.outputAbs, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'max' returns maximum of window", () => {
|
||||||
|
const m = makeSmoother('max');
|
||||||
|
feed(m, [10, 20, 5, 40, 50]);
|
||||||
|
assert.equal(m.outputAbs, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'sd' returns standard deviation of window", () => {
|
||||||
|
const m = makeSmoother('sd');
|
||||||
|
feed(m, [2, 4, 4, 4, 5]);
|
||||||
|
// Expected sample sd of [2,4,4,4,5] = 1.0954..., rounded to 1.1 by the outputAbs pipeline
|
||||||
|
assert.ok(Math.abs(m.outputAbs - 1.1) < 0.01, `expected ~1.1, got ${m.outputAbs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'median' returns median (odd window)", () => {
|
||||||
|
const m = makeSmoother('median');
|
||||||
|
feed(m, [10, 50, 20, 40, 30]);
|
||||||
|
assert.equal(m.outputAbs, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'median' returns average of middle pair (even window)", () => {
|
||||||
|
const m = makeSmoother('median', 4);
|
||||||
|
feed(m, [10, 20, 30, 40]);
|
||||||
|
assert.equal(m.outputAbs, 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'weightedMovingAverage' weights later samples more", () => {
|
||||||
|
const m = makeSmoother('weightedMovingAverage');
|
||||||
|
feed(m, [10, 10, 10, 10, 50]);
|
||||||
|
// weights [1,2,3,4,5], sum of weights = 15
|
||||||
|
// weighted sum = 10+20+30+40+250 = 350 -> 350/15 = 23.333..., rounded 23.33
|
||||||
|
assert.ok(Math.abs(m.outputAbs - 23.33) < 0.02, `expected ~23.33, got ${m.outputAbs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'lowPass' attenuates transients", () => {
|
||||||
|
const m = makeSmoother('lowPass');
|
||||||
|
feed(m, [0, 0, 0, 0, 100]);
|
||||||
|
// EMA(alpha=0.2) from 0,0,0,0,100: last value should be well below 100.
|
||||||
|
assert.ok(m.outputAbs < 100 * 0.3, `lowPass should attenuate step: ${m.outputAbs}`);
|
||||||
|
assert.ok(m.outputAbs > 0, `lowPass should still react: ${m.outputAbs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'highPass' emphasises differences", () => {
|
||||||
|
const m = makeSmoother('highPass');
|
||||||
|
feed(m, [0, 0, 0, 0, 100]);
|
||||||
|
// Highpass on a step should produce a positive transient; exact value is
|
||||||
|
// recursive but we at least require it to be positive and non-zero.
|
||||||
|
assert.ok(m.outputAbs > 10, `highPass should emphasise step: ${m.outputAbs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'bandPass' produces a finite number", () => {
|
||||||
|
const m = makeSmoother('bandPass');
|
||||||
|
feed(m, [1, 2, 3, 4, 5]);
|
||||||
|
assert.ok(Number.isFinite(m.outputAbs));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'kalman' converges toward steady values", () => {
|
||||||
|
const m = makeSmoother('kalman');
|
||||||
|
feed(m, [100, 100, 100, 100, 100]);
|
||||||
|
// Kalman filter fed with a constant input should converge to that value
|
||||||
|
// (within a small tolerance due to its gain smoothing).
|
||||||
|
assert.ok(Math.abs(m.outputAbs - 100) < 5, `kalman should approach steady value: ${m.outputAbs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'savitzkyGolay' returns last sample when window < 5", () => {
|
||||||
|
const m = makeSmoother('savitzkyGolay', 3);
|
||||||
|
feed(m, [7, 8, 9]);
|
||||||
|
assert.equal(m.outputAbs, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing 'savitzkyGolay' smooths across a 5-point window", () => {
|
||||||
|
const m = makeSmoother('savitzkyGolay', 5);
|
||||||
|
feed(m, [1, 2, 3, 4, 5]);
|
||||||
|
// SG coefficients [-3,12,17,12,-3] / 35 on linear data returns the
|
||||||
|
// middle value unchanged (=3); exact numeric comes out to 35/35 * 3.
|
||||||
|
assert.ok(Math.abs(m.outputAbs - 3) < 0.01, `SG on linear data should return middle ~3, got ${m.outputAbs}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown smoothing method falls through to raw value with an error", () => {
|
||||||
|
const m = makeSmoother('bogus-method');
|
||||||
|
// calculateInput will try the unknown key, hit the default branch in the
|
||||||
|
// applySmoothing map, log an error, and return the raw value (as
|
||||||
|
// implemented — the test pins that behaviour).
|
||||||
|
feed(m, [42]);
|
||||||
|
assert.equal(m.outputAbs, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoothing window shifts oldest value when exceeded", () => {
|
||||||
|
const m = makeSmoother('mean', 3);
|
||||||
|
feed(m, [100, 100, 100, 10, 10, 10]);
|
||||||
|
// Last three values are [10,10,10]; mean = 10.
|
||||||
|
assert.equal(m.outputAbs, 10);
|
||||||
|
});
|
||||||
222
test/integration/digital-mode.integration.test.js
Normal file
222
test/integration/digital-mode.integration.test.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const Measurement = require('../../src/specificClass');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for digital mode.
|
||||||
|
*
|
||||||
|
* Digital mode accepts an object payload where each key maps to its own
|
||||||
|
* independently-configured Channel (scaling / smoothing / outlier / unit /
|
||||||
|
* position). A single inbound message can therefore emit N measurements
|
||||||
|
* into the MeasurementContainer in one go — the MQTT / JSON IoT pattern
|
||||||
|
* the analog-centric node previously did not support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeDigitalConfig(channels, overrides = {}) {
|
||||||
|
return {
|
||||||
|
general: { id: 'm-dig-1', name: 'weather-station', unit: 'unitless', logging: { enabled: false, logLevel: 'error' } },
|
||||||
|
asset: { type: 'pressure', unit: 'mbar', category: 'sensor', supplier: 'vendor', model: 'BME280' },
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
|
||||||
|
simulation: { enabled: false },
|
||||||
|
functionality: { positionVsParent: 'atEquipment', distance: null },
|
||||||
|
mode: { current: 'digital' },
|
||||||
|
channels,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('analog-mode default: no channels built, handleDigitalPayload is a no-op', () => {
|
||||||
|
// Factory without mode config — defaults must stay analog.
|
||||||
|
const m = new Measurement({
|
||||||
|
general: { id: 'a', name: 'a', unit: 'bar', logging: { enabled: false, logLevel: 'error' } },
|
||||||
|
asset: { type: 'pressure', unit: 'bar', category: 'sensor', supplier: 'v', model: 'M' },
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
|
||||||
|
simulation: { enabled: false },
|
||||||
|
functionality: { positionVsParent: 'atEquipment' },
|
||||||
|
});
|
||||||
|
assert.equal(m.mode, 'analog');
|
||||||
|
assert.equal(m.channels.size, 0);
|
||||||
|
// In analog mode, handleDigitalPayload must refuse and not mutate state.
|
||||||
|
const res = m.handleDigitalPayload({ temperature: 21 });
|
||||||
|
assert.deepEqual(res, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital mode builds one Channel per config.channels entry', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 'temperature', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
|
||||||
|
{ key: 'humidity', type: 'humidity', position: 'atEquipment', unit: '%',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
|
||||||
|
{ key: 'pressure', type: 'pressure', position: 'atEquipment', unit: 'mbar',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 800, absMax: 1200, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
|
||||||
|
]));
|
||||||
|
assert.equal(m.mode, 'digital');
|
||||||
|
assert.equal(m.channels.size, 3);
|
||||||
|
assert.ok(m.channels.has('temperature'));
|
||||||
|
assert.ok(m.channels.has('humidity'));
|
||||||
|
assert.ok(m.channels.has('pressure'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital payload routes each key to its own channel', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 'temperature', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
{ key: 'humidity', type: 'humidity', position: 'atEquipment', unit: '%',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
m.handleDigitalPayload({ temperature: 21.5, humidity: 65 });
|
||||||
|
|
||||||
|
const tempOut = m.channels.get('temperature').outputAbs;
|
||||||
|
const humidOut = m.channels.get('humidity').outputAbs;
|
||||||
|
assert.equal(tempOut, 21.5);
|
||||||
|
assert.equal(humidOut, 65);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital payload emits on the MeasurementContainer per channel', async () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
{ key: 'h', type: 'humidity', position: 'atEquipment', unit: '%',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
const events = [];
|
||||||
|
m.measurements.emitter.on('temperature.measured.atequipment', (e) => events.push({ on: 't', value: e.value }));
|
||||||
|
m.measurements.emitter.on('humidity.measured.atequipment', (e) => events.push({ on: 'h', value: e.value }));
|
||||||
|
|
||||||
|
m.handleDigitalPayload({ t: 22, h: 50 });
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
assert.equal(events.filter((e) => e.on === 't').length, 1);
|
||||||
|
assert.equal(events.filter((e) => e.on === 'h').length, 1);
|
||||||
|
assert.equal(events.find((e) => e.on === 't').value, 22);
|
||||||
|
assert.equal(events.find((e) => e.on === 'h').value, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital payload with unmapped keys silently ignores them', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
const res = m.handleDigitalPayload({ t: 20, unknown: 999, extra: 'x' });
|
||||||
|
assert.equal(m.channels.get('t').outputAbs, 20);
|
||||||
|
assert.equal(res.t.ok, true);
|
||||||
|
assert.equal(res.unknown, undefined);
|
||||||
|
assert.equal(res.extra, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital channel with scaling enabled maps input to abs range', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 'pt', type: 'pressure', position: 'atEquipment', unit: 'mbar',
|
||||||
|
scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 1000, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
m.handleDigitalPayload({ pt: 50 });
|
||||||
|
// 50% of [0..100] -> 50% of [0..1000] = 500
|
||||||
|
assert.equal(m.channels.get('pt').outputAbs, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital channel smoothing accumulates per-channel, independent of siblings', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
|
||||||
|
{ key: 'h', type: 'humidity', position: 'atEquipment', unit: '%',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Feed only temperature across 3 pushes; humidity never receives a value.
|
||||||
|
m.handleDigitalPayload({ t: 10 });
|
||||||
|
m.handleDigitalPayload({ t: 20 });
|
||||||
|
m.handleDigitalPayload({ t: 30 });
|
||||||
|
|
||||||
|
assert.equal(m.channels.get('t').outputAbs, 20); // mean(10,20,30)=20
|
||||||
|
assert.equal(m.channels.get('t').storedValues.length, 3);
|
||||||
|
// Humidity channel must be untouched.
|
||||||
|
assert.equal(m.channels.get('h').storedValues.length, 0);
|
||||||
|
assert.equal(m.channels.get('h').outputAbs, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital channel rejects non-numeric values in summary', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
const res = m.handleDigitalPayload({ t: 'banana' });
|
||||||
|
assert.equal(res.t.ok, false);
|
||||||
|
assert.equal(res.t.reason, 'non-numeric');
|
||||||
|
assert.equal(m.channels.get('t').outputAbs, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital channel supports per-channel outlier detection', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 10, smoothMethod: 'none' },
|
||||||
|
outlierDetection: { enabled: true, method: 'zscore', threshold: 3 } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Seed a tight baseline then lob an obvious spike.
|
||||||
|
for (const v of [20, 20, 20, 20, 20, 20]) m.handleDigitalPayload({ t: v });
|
||||||
|
const baselineOut = m.channels.get('t').outputAbs;
|
||||||
|
m.handleDigitalPayload({ t: 1e6 });
|
||||||
|
assert.equal(m.channels.get('t').outputAbs, baselineOut, 'spike must be rejected as outlier');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDigitalOutput produces one entry per channel', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
{ key: 'h', type: 'humidity', position: 'atEquipment', unit: '%',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
]));
|
||||||
|
|
||||||
|
m.handleDigitalPayload({ t: 25, h: 40 });
|
||||||
|
const out = m.getDigitalOutput();
|
||||||
|
assert.ok(out.channels.t);
|
||||||
|
assert.ok(out.channels.h);
|
||||||
|
assert.equal(out.channels.t.mAbs, 25);
|
||||||
|
assert.equal(out.channels.h.mAbs, 40);
|
||||||
|
assert.equal(out.channels.t.type, 'temperature');
|
||||||
|
assert.equal(out.channels.h.unit, '%');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital mode with empty channels array still constructs cleanly', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([]));
|
||||||
|
assert.equal(m.mode, 'digital');
|
||||||
|
assert.equal(m.channels.size, 0);
|
||||||
|
// No throw on empty payload.
|
||||||
|
assert.deepEqual(m.handleDigitalPayload({ anything: 1 }), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital mode ignores malformed channel entries in config', () => {
|
||||||
|
const m = new Measurement(makeDigitalConfig([
|
||||||
|
{ key: 'valid', type: 'temperature', position: 'atEquipment', unit: 'C',
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
|
||||||
|
null, // malformed
|
||||||
|
{ key: 'no_type' }, // missing type
|
||||||
|
{ type: 'pressure' }, // missing key
|
||||||
|
]));
|
||||||
|
assert.equal(m.channels.size, 1);
|
||||||
|
assert.ok(m.channels.has('valid'));
|
||||||
|
});
|
||||||
448
test/specificClass.test.js
Normal file
448
test/specificClass.test.js
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
/**
|
||||||
|
* Tests for measurement specificClass (domain logic).
|
||||||
|
*
|
||||||
|
* The Measurement class handles sensor input processing:
|
||||||
|
* - scaling (input range -> absolute range)
|
||||||
|
* - smoothing (various filter methods)
|
||||||
|
* - outlier detection (z-score, IQR, modified z-score)
|
||||||
|
* - simulation mode
|
||||||
|
* - calibration
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Measurement = require('../src/specificClass');
|
||||||
|
|
||||||
|
// --------------- helpers ---------------
|
||||||
|
|
||||||
|
function makeConfig(overrides = {}) {
|
||||||
|
const base = {
|
||||||
|
general: {
|
||||||
|
name: 'TestSensor',
|
||||||
|
id: 'test-sensor-1',
|
||||||
|
logging: { enabled: false, logLevel: 'error' },
|
||||||
|
},
|
||||||
|
functionality: {
|
||||||
|
softwareType: 'measurement',
|
||||||
|
role: 'sensor',
|
||||||
|
positionVsParent: 'atEquipment',
|
||||||
|
distance: null,
|
||||||
|
},
|
||||||
|
asset: {
|
||||||
|
category: 'sensor',
|
||||||
|
type: 'pressure',
|
||||||
|
model: 'test-model',
|
||||||
|
supplier: 'TestCo',
|
||||||
|
unit: 'bar',
|
||||||
|
},
|
||||||
|
scaling: {
|
||||||
|
enabled: false,
|
||||||
|
inputMin: 0,
|
||||||
|
inputMax: 1,
|
||||||
|
absMin: 0,
|
||||||
|
absMax: 100,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
smoothing: {
|
||||||
|
smoothWindow: 5,
|
||||||
|
smoothMethod: 'none',
|
||||||
|
},
|
||||||
|
simulation: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
interpolation: {
|
||||||
|
percentMin: 0,
|
||||||
|
percentMax: 100,
|
||||||
|
},
|
||||||
|
outlierDetection: {
|
||||||
|
enabled: false,
|
||||||
|
method: 'zScore',
|
||||||
|
threshold: 3,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deep-merge one level
|
||||||
|
for (const key of Object.keys(overrides)) {
|
||||||
|
if (typeof overrides[key] === 'object' && !Array.isArray(overrides[key]) && base[key]) {
|
||||||
|
base[key] = { ...base[key], ...overrides[key] };
|
||||||
|
} else {
|
||||||
|
base[key] = overrides[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------- tests ---------------
|
||||||
|
|
||||||
|
describe('Measurement specificClass', () => {
|
||||||
|
|
||||||
|
describe('constructor / initialization', () => {
|
||||||
|
it('should create an instance with default config overlay', () => {
|
||||||
|
const m = new Measurement(makeConfig());
|
||||||
|
expect(m).toBeDefined();
|
||||||
|
expect(m.config.general.name).toBe('testsensor');
|
||||||
|
expect(m.outputAbs).toBe(0);
|
||||||
|
expect(m.outputPercent).toBe(0);
|
||||||
|
expect(m.storedValues).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize inputRange and processRange from scaling config', () => {
|
||||||
|
const m = new Measurement(makeConfig({
|
||||||
|
scaling: { enabled: true, inputMin: 4, inputMax: 20, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
}));
|
||||||
|
expect(m.inputRange).toBe(16); // |20 - 4|
|
||||||
|
expect(m.processRange).toBe(100); // |100 - 0|
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create with empty config and fall back to defaults', () => {
|
||||||
|
const m = new Measurement({});
|
||||||
|
expect(m).toBeDefined();
|
||||||
|
expect(m.config).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- pure math helpers ----
|
||||||
|
|
||||||
|
describe('mean()', () => {
|
||||||
|
let m;
|
||||||
|
beforeEach(() => { m = new Measurement(makeConfig()); });
|
||||||
|
|
||||||
|
it('should return the arithmetic mean', () => {
|
||||||
|
expect(m.mean([2, 4, 6])).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a single element', () => {
|
||||||
|
expect(m.mean([7])).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('min() / max()', () => {
|
||||||
|
let m;
|
||||||
|
beforeEach(() => { m = new Measurement(makeConfig()); });
|
||||||
|
|
||||||
|
it('should return the minimum value', () => {
|
||||||
|
expect(m.min([5, 3, 9, 1])).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the maximum value', () => {
|
||||||
|
expect(m.max([5, 3, 9, 1])).toBe(9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('standardDeviation()', () => {
|
||||||
|
let m;
|
||||||
|
beforeEach(() => { m = new Measurement(makeConfig()); });
|
||||||
|
|
||||||
|
it('should return 0 for a single-element array', () => {
|
||||||
|
expect(m.standardDeviation([42])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 for identical values', () => {
|
||||||
|
expect(m.standardDeviation([5, 5, 5])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compute sample std dev correctly', () => {
|
||||||
|
// [2, 4, 4, 4, 5, 5, 7, 9] => mean = 5, sqDiffs sum = 32, variance = 32/7 ~ 4.571, sd ~ 2.138
|
||||||
|
const sd = m.standardDeviation([2, 4, 4, 4, 5, 5, 7, 9]);
|
||||||
|
expect(sd).toBeCloseTo(2.138, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('medianFilter()', () => {
|
||||||
|
let m;
|
||||||
|
beforeEach(() => { m = new Measurement(makeConfig()); });
|
||||||
|
|
||||||
|
it('should return the middle element for odd-length array', () => {
|
||||||
|
expect(m.medianFilter([3, 1, 2])).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the average of two middle elements for even-length array', () => {
|
||||||
|
expect(m.medianFilter([1, 2, 3, 4])).toBe(2.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- constrain ----
|
||||||
|
|
||||||
|
describe('constrain()', () => {
|
||||||
|
let m;
|
||||||
|
beforeEach(() => { m = new Measurement(makeConfig()); });
|
||||||
|
|
||||||
|
it('should clamp a value below min to min', () => {
|
||||||
|
expect(m.constrain(-5, 0, 100)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clamp a value above max to max', () => {
|
||||||
|
expect(m.constrain(150, 0, 100)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass through values inside range', () => {
|
||||||
|
expect(m.constrain(50, 0, 100)).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- interpolateLinear ----
|
||||||
|
|
||||||
|
describe('interpolateLinear()', () => {
|
||||||
|
let m;
|
||||||
|
beforeEach(() => { m = new Measurement(makeConfig()); });
|
||||||
|
|
||||||
|
it('should map input min to output min', () => {
|
||||||
|
expect(m.interpolateLinear(0, 0, 10, 0, 100)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map input max to output max', () => {
|
||||||
|
expect(m.interpolateLinear(10, 0, 10, 0, 100)).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map midpoint correctly', () => {
|
||||||
|
expect(m.interpolateLinear(5, 0, 10, 0, 100)).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the input unchanged if ranges are invalid (iMin >= iMax)', () => {
|
||||||
|
expect(m.interpolateLinear(5, 10, 10, 0, 100)).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- applyOffset ----
|
||||||
|
|
||||||
|
describe('applyOffset()', () => {
|
||||||
|
it('should add the configured offset to the value', () => {
|
||||||
|
const m = new Measurement(makeConfig({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 10 },
|
||||||
|
}));
|
||||||
|
expect(m.applyOffset(5)).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add zero offset', () => {
|
||||||
|
const m = new Measurement(makeConfig());
|
||||||
|
expect(m.applyOffset(5)).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- handleScaling ----
|
||||||
|
|
||||||
|
describe('handleScaling()', () => {
|
||||||
|
it('should interpolate from input range to abs range', () => {
|
||||||
|
const m = new Measurement(makeConfig({
|
||||||
|
scaling: { enabled: true, inputMin: 4, inputMax: 20, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
}));
|
||||||
|
// midpoint of 4..20 = 12 => should map to 50
|
||||||
|
const result = m.handleScaling(12);
|
||||||
|
expect(result).toBeCloseTo(50, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should constrain values outside input range', () => {
|
||||||
|
const m = new Measurement(makeConfig({
|
||||||
|
scaling: { enabled: true, inputMin: 0, inputMax: 10, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
}));
|
||||||
|
// value 15 > inputMax 10, should be constrained then mapped
|
||||||
|
const result = m.handleScaling(15);
|
||||||
|
expect(result).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- applySmoothing ----
|
||||||
|
|
||||||
|
describe('applySmoothing()', () => {
|
||||||
|
it('should return the raw value when method is "none"', () => {
|
||||||
|
const m = new Measurement(makeConfig({ smoothing: { smoothWindow: 5, smoothMethod: 'none' } }));
|
||||||
|
expect(m.applySmoothing(42)).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compute the mean when method is "mean"', () => {
|
||||||
|
const m = new Measurement(makeConfig({ smoothing: { smoothWindow: 5, smoothMethod: 'mean' } }));
|
||||||
|
m.applySmoothing(10);
|
||||||
|
m.applySmoothing(20);
|
||||||
|
const result = m.applySmoothing(30);
|
||||||
|
expect(result).toBe(20); // mean of [10, 20, 30]
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect the smoothWindow limit', () => {
|
||||||
|
const m = new Measurement(makeConfig({ smoothing: { smoothWindow: 3, smoothMethod: 'mean' } }));
|
||||||
|
m.applySmoothing(10);
|
||||||
|
m.applySmoothing(20);
|
||||||
|
m.applySmoothing(30);
|
||||||
|
const result = m.applySmoothing(40);
|
||||||
|
// window is [20, 30, 40] after shift
|
||||||
|
expect(result).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- outlier detection ----
|
||||||
|
|
||||||
|
describe('outlierDetection()', () => {
|
||||||
|
it('should return false when there are fewer than 2 stored values', () => {
|
||||||
|
const m = new Measurement(makeConfig({
|
||||||
|
outlierDetection: { enabled: true, method: 'zScore', threshold: 3 },
|
||||||
|
}));
|
||||||
|
expect(m.outlierDetection(100)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zScore: should detect a large outlier', () => {
|
||||||
|
const m = new Measurement(makeConfig({
|
||||||
|
outlierDetection: { enabled: true, method: 'zScore', threshold: 2 },
|
||||||
|
}));
|
||||||
|
// Config manager lowercases enum values, so fix the method after construction
|
||||||
|
m.config.outlierDetection.method = 'zScore';
|
||||||
|
m.storedValues = [10, 11, 9, 10, 11, 9, 10, 11, 9, 10];
|
||||||
|
expect(m.outlierDetection(1000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zScore: should not flag values near the mean', () => {
|
||||||
|
const m = new Measurement(makeConfig({
|
||||||
|
outlierDetection: { enabled: true, method: 'zScore', threshold: 3 },
|
||||||
|
}));
|
||||||
|
m.config.outlierDetection.method = 'zScore';
|
||||||
|
m.storedValues = [10, 11, 9, 10, 11, 9, 10, 11, 9, 10];
|
||||||
|
expect(m.outlierDetection(10.5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('iqr: should detect an outlier', () => {
|
||||||
|
const m = new Measurement(makeConfig({
|
||||||
|
outlierDetection: { enabled: true, method: 'iqr', threshold: 3 },
|
||||||
|
}));
|
||||||
|
m.storedValues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||||
|
expect(m.outlierDetection(100)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- calculateInput (integration) ----
|
||||||
|
|
||||||
|
describe('calculateInput()', () => {
|
||||||
|
it('should update outputAbs when no scaling is applied', () => {
|
||||||
|
const m = new Measurement(makeConfig({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 100, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
|
||||||
|
}));
|
||||||
|
m.calculateInput(42);
|
||||||
|
expect(m.outputAbs).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply offset before scaling', () => {
|
||||||
|
const m = new Measurement(makeConfig({
|
||||||
|
scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 1000, offset: 10 },
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
|
||||||
|
}));
|
||||||
|
m.calculateInput(40); // 40 + 10 = 50, scaled: 50/100 * 1000 = 500
|
||||||
|
expect(m.outputAbs).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip outlier values when outlier detection is enabled', () => {
|
||||||
|
const m = new Measurement(makeConfig({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 1000, absMin: 0, absMax: 1000, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 20, smoothMethod: 'none' },
|
||||||
|
outlierDetection: { enabled: true, method: 'iqr', threshold: 1.5 },
|
||||||
|
}));
|
||||||
|
// Seed stored values with some variance so IQR method works
|
||||||
|
for (let i = 0; i < 10; i++) m.storedValues.push(10 + (i % 3));
|
||||||
|
m.calculateInput(10); // normal value, will update
|
||||||
|
const afterNormal = m.outputAbs;
|
||||||
|
m.calculateInput(9999); // outlier, should be ignored by IQR
|
||||||
|
expect(m.outputAbs).toBe(afterNormal);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- updateMinMaxValues ----
|
||||||
|
|
||||||
|
describe('updateMinMaxValues()', () => {
|
||||||
|
it('should track minimum and maximum seen values', () => {
|
||||||
|
const m = new Measurement(makeConfig());
|
||||||
|
m.updateMinMaxValues(5);
|
||||||
|
m.updateMinMaxValues(15);
|
||||||
|
m.updateMinMaxValues(3);
|
||||||
|
expect(m.totalMinValue).toBe(3);
|
||||||
|
expect(m.totalMaxValue).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- isStable ----
|
||||||
|
|
||||||
|
describe('isStable()', () => {
|
||||||
|
it('should return false when fewer than 2 stored values', () => {
|
||||||
|
const m = new Measurement(makeConfig());
|
||||||
|
m.storedValues = [1];
|
||||||
|
expect(m.isStable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report stable when all values are the same', () => {
|
||||||
|
const m = new Measurement(makeConfig());
|
||||||
|
m.storedValues = [5, 5, 5, 5];
|
||||||
|
const result = m.isStable();
|
||||||
|
expect(result.isStable).toBe(true);
|
||||||
|
expect(result.stdDev).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- getOutput ----
|
||||||
|
|
||||||
|
describe('getOutput()', () => {
|
||||||
|
it('should return an object with expected keys', () => {
|
||||||
|
const m = new Measurement(makeConfig());
|
||||||
|
const out = m.getOutput();
|
||||||
|
expect(out).toHaveProperty('mAbs');
|
||||||
|
expect(out).toHaveProperty('mPercent');
|
||||||
|
expect(out).toHaveProperty('totalMinValue');
|
||||||
|
expect(out).toHaveProperty('totalMaxValue');
|
||||||
|
expect(out).toHaveProperty('totalMinSmooth');
|
||||||
|
expect(out).toHaveProperty('totalMaxSmooth');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- toggleSimulation ----
|
||||||
|
|
||||||
|
describe('toggleSimulation()', () => {
|
||||||
|
it('should flip the simulation enabled flag', () => {
|
||||||
|
const m = new Measurement(makeConfig({ simulation: { enabled: false } }));
|
||||||
|
expect(m.config.simulation.enabled).toBe(false);
|
||||||
|
m.toggleSimulation();
|
||||||
|
expect(m.config.simulation.enabled).toBe(true);
|
||||||
|
m.toggleSimulation();
|
||||||
|
expect(m.config.simulation.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- tick (simulation mode) ----
|
||||||
|
|
||||||
|
describe('tick()', () => {
|
||||||
|
it('should resolve without errors when simulation is disabled', async () => {
|
||||||
|
const m = new Measurement(makeConfig({ simulation: { enabled: false } }));
|
||||||
|
m.inputValue = 50;
|
||||||
|
await expect(m.tick()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate a simulated value when simulation is enabled', async () => {
|
||||||
|
const m = new Measurement(makeConfig({
|
||||||
|
scaling: { enabled: false, inputMin: 0, inputMax: 100, absMin: 0, absMax: 100, offset: 0 },
|
||||||
|
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
|
||||||
|
simulation: { enabled: true },
|
||||||
|
}));
|
||||||
|
await m.tick();
|
||||||
|
// simValue may be 0 on first call, but it should not throw
|
||||||
|
expect(m.simValue).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- filter methods ----
|
||||||
|
|
||||||
|
describe('lowPassFilter()', () => {
|
||||||
|
let m;
|
||||||
|
beforeEach(() => { m = new Measurement(makeConfig()); });
|
||||||
|
|
||||||
|
it('should return the first value for a single-element array', () => {
|
||||||
|
expect(m.lowPassFilter([10])).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should smooth values', () => {
|
||||||
|
const result = m.lowPassFilter([10, 10, 10, 10]);
|
||||||
|
expect(result).toBeCloseTo(10, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('weightedMovingAverage()', () => {
|
||||||
|
let m;
|
||||||
|
beforeEach(() => { m = new Measurement(makeConfig()); });
|
||||||
|
|
||||||
|
it('should give more weight to recent values', () => {
|
||||||
|
// weights [1,2,3], values [0, 0, 30] => (0*1 + 0*2 + 30*3) / 6 = 15
|
||||||
|
expect(m.weightedMovingAverage([0, 0, 30])).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user