iobroker.laundrylens 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,488 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * CycleDetector v0.4
5
+ *
6
+ * State machine: OFF → STARTING → RUNNING ↔ PAUSED → ENDING → OFF
7
+ *
8
+ * v0.4 changes:
9
+ * - Power trace stored with timestamps (for curve matching)
10
+ * - Watchdog against stuck cycles
11
+ * - End-spike protection (dishwasher drain pump discharge)
12
+ * - Zombie protection (hard limit at 200% of expected duration)
13
+ */
14
+
15
+ const STATES = {
16
+ OFF: "off",
17
+ STARTING: "starting",
18
+ RUNNING: "running",
19
+ PAUSED: "paused",
20
+ ENDING: "ending",
21
+ };
22
+
23
+ const DEFAULT_CONFIG = {
24
+ powerThreshold: 10, // W
25
+ startEnergyThreshold: 2, // Wh
26
+ endEnergyThreshold: 0.5, // Wh
27
+ offDelay: 300, // s
28
+ startOffDelay: 120, // s – minimum time below threshold in STARTING before falling back to OFF
29
+ minOffGap: 120, // s
30
+ suspiciousWindow: 1200, // s (20 min ghost protection)
31
+ pauseDelay: 60, // s
32
+ endPhaseOffDelay: 60, // s – shortened offDelay during the end phase
33
+ endPhasePowerRatio: 0.2, // < 20% of max watts = end phase
34
+ endPhaseProgressMin: 0.7, // > 70% of expected duration = end phase
35
+ watchdogMs: 2 * 60 * 60 * 1000, // 2h max cycle duration
36
+ zombieFactor: 2.0, // 200% of profile duration = zombie kill
37
+ traceResolutionMs: 10000, // trace point every 10s
38
+ };
39
+
40
+ /**
41
+ * Power-based state machine that detects appliance cycles (OFF → STARTING →
42
+ * RUNNING ↔ PAUSED → ENDING → OFF) from a stream of watt readings.
43
+ */
44
+ class CycleDetector {
45
+ /**
46
+ * @param {object} config – overrides merged onto DEFAULT_CONFIG
47
+ * @param {(function(string, string): void)|null} onStateChange – callback(newState, prevState) fired on every state transition
48
+ * @param {object|null} [adapter] – optional ioBroker adapter instance. When provided, adapter.setTimeout()/adapter.clearTimeout() are used for the watchdog timer (so it gets cleaned up automatically on adapter unload) and adapter.log is used for diagnostic logging. Falls back to the native global timers when omitted (e.g. in unit tests).
49
+ */
50
+ constructor(config = {}, onStateChange = null, adapter = null) {
51
+ this.cfg = { ...DEFAULT_CONFIG, ...config };
52
+ this.onStateChange = onStateChange;
53
+ this.adapter = adapter;
54
+
55
+ // State
56
+ this.state = STATES.OFF;
57
+ this.powerTrace = [];
58
+ this._maxWattsObserved = 0;
59
+ this._inEndPhase = false; // [{ts, watts}, ...]
60
+ this._lastTracePoint = 0; // last stored trace point
61
+ this.cycleStartTime = null;
62
+ this.lastReadingTime = null;
63
+ this.lastAboveThreshold = null;
64
+ this.lastCycleCompleted = false;
65
+ this.lastBelowThreshold = null;
66
+ this.belowPauseThreshold = null;
67
+
68
+ // Energy
69
+ this.accumulatedEnergy = 0;
70
+ this.endingEnergy = 0;
71
+
72
+ // Ghost protection
73
+ this.lastCycleEndTime = null;
74
+
75
+ // Watchdog
76
+ this._watchdogTimer = null;
77
+
78
+ // For zombie protection: expected profile duration
79
+ this.expectedDurationMs = null;
80
+
81
+ // Adaptive offDelay
82
+ this._maxWattsObserved = 0;
83
+ this._inEndPhase = false;
84
+ }
85
+
86
+ // ── Public API ───────────────────────────────────────────────
87
+
88
+ /**
89
+ * Feed one power reading into the state machine. Drives all state
90
+ * transitions (OFF/STARTING/RUNNING/PAUSED/ENDING), energy accumulation,
91
+ * and power-trace recording.
92
+ *
93
+ * @param {number} watts – current power reading in watts
94
+ * @param {number} timestamp – reading timestamp in ms (epoch)
95
+ */
96
+ processReading(watts, timestamp) {
97
+ const prevState = this.state;
98
+
99
+ // Accumulate energy (Wh, trapezoidal)
100
+ if (this.lastReadingTime !== null) {
101
+ const dtH = (timestamp - this.lastReadingTime) / 3_600_000;
102
+ if (this.state !== STATES.OFF) {
103
+ this.accumulatedEnergy += watts * dtH;
104
+ }
105
+ if (this.state === STATES.ENDING) {
106
+ this.endingEnergy += watts * dtH;
107
+ }
108
+ }
109
+ this.lastReadingTime = timestamp;
110
+
111
+ // Store power trace at the configured resolution
112
+ if (this.state !== STATES.OFF) {
113
+ if (timestamp - this._lastTracePoint >= this.cfg.traceResolutionMs) {
114
+ this.powerTrace.push({ ts: timestamp, watts });
115
+ this._lastTracePoint = timestamp;
116
+ }
117
+ // Track max watts for adaptive offDelay
118
+ if (watts > this._maxWattsObserved) {
119
+ this._maxWattsObserved = watts;
120
+ }
121
+ }
122
+
123
+ // Gate timestamps
124
+ if (watts >= this.cfg.powerThreshold) {
125
+ this.lastAboveThreshold = timestamp;
126
+ this.belowPauseThreshold = null;
127
+ } else {
128
+ if (this.belowPauseThreshold === null) {
129
+ this.belowPauseThreshold = timestamp;
130
+ }
131
+ this.lastBelowThreshold = timestamp;
132
+ }
133
+
134
+ this._updateStateMachine(watts, timestamp);
135
+
136
+ if (this.state !== prevState) {
137
+ const data = this._buildEventData();
138
+ if (typeof this.onStateChange === "function") {
139
+ this.onStateChange(this.state, data);
140
+ }
141
+ return this.state;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /** Resets the detector after a completed cycle */
147
+ reset() {
148
+ this._stopWatchdog();
149
+ this.state = STATES.OFF;
150
+ this.powerTrace = [];
151
+ this._lastTracePoint = 0;
152
+ this.cycleStartTime = null;
153
+ this.lastAboveThreshold = null;
154
+ this.lastBelowThreshold = null;
155
+ this.belowPauseThreshold = null;
156
+ this.accumulatedEnergy = 0;
157
+ this.endingEnergy = 0;
158
+ this.expectedDurationMs = null;
159
+ }
160
+
161
+ /**
162
+ * Sets the expected profile duration used for zombie protection
163
+ *
164
+ * @param {number} ms – expected cycle duration in milliseconds
165
+ */
166
+ setExpectedDuration(ms) {
167
+ this.expectedDurationMs = ms;
168
+ }
169
+
170
+ /** @returns {Array<{ts:number, watts:number}>} – copy of the recorded power trace for the current cycle */
171
+ getPowerTrace() {
172
+ return [...this.powerTrace];
173
+ }
174
+
175
+ /**
176
+ * Restore a previously saved power trace (e.g. after adapter restart).
177
+ *
178
+ * @param {Array<{ts:number, watts:number}>} points – saved trace points
179
+ */
180
+ restoreTrace(points) {
181
+ // Restore trace from saved state
182
+ // points: [{ts, watts}]
183
+ if (!points || points.length === 0) {
184
+ return;
185
+ }
186
+ this.powerTrace = points.map((p) => ({ ts: p.ts, watts: p.watts }));
187
+ }
188
+
189
+ /** @returns {string} – current state machine state (one of STATES) */
190
+ getCurrentState() {
191
+ return this.state;
192
+ }
193
+
194
+ // ── State Machine ────────────────────────────────────────────
195
+
196
+ /**
197
+ * Dispatches to the handler for the current state.
198
+ *
199
+ * @param {number} watts – current power reading
200
+ * @param {number} now – current timestamp in ms
201
+ */
202
+ _updateStateMachine(watts, now) {
203
+ switch (this.state) {
204
+ case STATES.OFF:
205
+ this._handleOff(watts, now);
206
+ break;
207
+ case STATES.STARTING:
208
+ this._handleStarting(watts, now);
209
+ break;
210
+ case STATES.RUNNING:
211
+ this._handleRunning(watts, now);
212
+ break;
213
+ case STATES.PAUSED:
214
+ this._handlePaused(watts, now);
215
+ break;
216
+ case STATES.ENDING:
217
+ this._handleEnding(watts, now);
218
+ break;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * OFF state handler: waits for power to cross the threshold, then starts
224
+ * a new cycle. Suppresses "ghost" restarts shortly after an unclean end.
225
+ *
226
+ * @param {number} watts – current power reading
227
+ * @param {number} now – current timestamp in ms
228
+ */
229
+ _handleOff(watts, now) {
230
+ if (watts < this.cfg.powerThreshold) {
231
+ return;
232
+ }
233
+
234
+ // Suppress ghost cycles - only if the last cycle didn't end cleanly
235
+ if (this.lastCycleEndTime !== null && !this.lastCycleCompleted) {
236
+ const gapSec = (now - this.lastCycleEndTime) / 1000;
237
+ if (gapSec < this.cfg.minOffGap) {
238
+ return;
239
+ }
240
+ if (
241
+ gapSec < this.cfg.suspiciousWindow &&
242
+ this.endingEnergy < this.cfg.endEnergyThreshold
243
+ ) {
244
+ return;
245
+ }
246
+ }
247
+
248
+ this.cycleStartTime = now;
249
+ this.accumulatedEnergy = 0;
250
+ this.endingEnergy = 0;
251
+ this.powerTrace = [{ ts: now, watts }];
252
+ this._lastTracePoint = now;
253
+ this.belowStartThreshold = null;
254
+ this._setState(STATES.STARTING);
255
+ this._startWatchdog();
256
+ }
257
+
258
+ /**
259
+ * STARTING state handler: confirms the cycle is real once enough energy
260
+ * has accumulated, or falls back to OFF if power drops before that.
261
+ *
262
+ * @param {number} watts – current power reading
263
+ * @param {number} now – current timestamp in ms
264
+ */
265
+ _handleStarting(watts, now) {
266
+ if (watts < this.cfg.powerThreshold) {
267
+ // Only fall back to OFF after startOffDelay – prevents flapping
268
+ if (this.belowStartThreshold === null) {
269
+ this.belowStartThreshold = now;
270
+ }
271
+ const belowSec = (now - this.belowStartThreshold) / 1000;
272
+ if (belowSec >= this.cfg.startOffDelay) {
273
+ this._stopWatchdog();
274
+ this._setState(STATES.OFF);
275
+ this.powerTrace = [];
276
+ this.accumulatedEnergy = 0;
277
+ this.belowStartThreshold = null;
278
+ }
279
+ return;
280
+ }
281
+ this.belowStartThreshold = null;
282
+ if (this.accumulatedEnergy >= this.cfg.startEnergyThreshold) {
283
+ this._setState(STATES.RUNNING);
284
+ }
285
+ }
286
+
287
+ /**
288
+ * RUNNING state handler: monitors for the zombie-cycle hard limit and for
289
+ * power drops that indicate a pause or the end of the cycle.
290
+ *
291
+ * @param {number} watts – current power reading
292
+ * @param {number} now – current timestamp in ms
293
+ */
294
+ _handleRunning(watts, now) {
295
+ // Zombie protection: hard limit at 200% of expected duration
296
+ if (this.expectedDurationMs && this.cycleStartTime) {
297
+ const elapsed = now - this.cycleStartTime;
298
+ if (elapsed > this.expectedDurationMs * this.cfg.zombieFactor) {
299
+ this.adapter &&
300
+ this.adapter.log.warn(
301
+ `[CycleDetector] Zombie-Kill: ${Math.round(elapsed / 60000)} min > ${Math.round((this.expectedDurationMs * this.cfg.zombieFactor) / 60000)} min`,
302
+ );
303
+ this.lastCycleEndTime = now;
304
+ this.endingEnergy = 0;
305
+ this._setState(STATES.ENDING);
306
+ return;
307
+ }
308
+ }
309
+
310
+ if (watts >= this.cfg.powerThreshold) {
311
+ return;
312
+ }
313
+
314
+ if (this.belowPauseThreshold !== null) {
315
+ const belowSec = (now - this.belowPauseThreshold) / 1000;
316
+ const effectiveOff = this._getEffectiveOffDelay(watts, now);
317
+ if (belowSec >= this.cfg.pauseDelay && belowSec < effectiveOff) {
318
+ this._setState(STATES.PAUSED);
319
+ return;
320
+ }
321
+ if (belowSec >= effectiveOff) {
322
+ this.endingEnergy = 0;
323
+ this._setState(STATES.ENDING);
324
+ }
325
+ }
326
+ }
327
+
328
+ /**
329
+ * PAUSED state handler: resumes to RUNNING if power comes back, or moves
330
+ * to ENDING once the pause has lasted longer than the effective off-delay.
331
+ *
332
+ * @param {number} watts – current power reading
333
+ * @param {number} now – current timestamp in ms
334
+ */
335
+ _handlePaused(watts, now) {
336
+ if (watts >= this.cfg.powerThreshold) {
337
+ this._setState(STATES.RUNNING);
338
+ return;
339
+ }
340
+ if (this.lastAboveThreshold !== null) {
341
+ const pausedSec = (now - this.lastAboveThreshold) / 1000;
342
+ const effectiveOff = this._getEffectiveOffDelay(watts, now);
343
+ if (pausedSec >= effectiveOff) {
344
+ this.endingEnergy = 0;
345
+ this._setState(STATES.ENDING);
346
+ }
347
+ }
348
+ }
349
+
350
+ /**
351
+ * ENDING state handler: confirms the cycle is truly over after
352
+ * minOffGap seconds without power, with protection against brief
353
+ * end-of-cycle power spikes (e.g. a dishwasher's drain pump).
354
+ *
355
+ * @param {number} watts – current power reading
356
+ * @param {number} now – current timestamp in ms
357
+ */
358
+ _handleEnding(watts, now) {
359
+ // End-spike protection (e.g. dishwasher drain pump discharge)
360
+ if (watts >= this.cfg.powerThreshold) {
361
+ this._setState(STATES.RUNNING);
362
+ return;
363
+ }
364
+ if (this.lastAboveThreshold !== null) {
365
+ const offSec = (now - this.lastAboveThreshold) / 1000;
366
+ if (offSec >= this.cfg.minOffGap) {
367
+ this.lastCycleEndTime = now;
368
+ this.lastCycleCompleted = true; // Ended cleanly → ghost protection off
369
+ this._stopWatchdog();
370
+ this._setState(STATES.OFF);
371
+ }
372
+ }
373
+ }
374
+
375
+ // ── Adaptive offDelay ─────────────────────────────────────────
376
+
377
+ /**
378
+ * Detects whether the cycle is likely in its final, low-power tail end
379
+ * (based on elapsed progress vs. expected duration and current power
380
+ * relative to the observed peak).
381
+ *
382
+ * @param {number} watts – current power reading
383
+ * @param {number} now – current timestamp in ms
384
+ * @returns {boolean} – true if the cycle appears to be in its end phase
385
+ */
386
+ _isInEndPhase(watts, now) {
387
+ if (!this.cycleStartTime || !this.expectedDurationMs) {
388
+ return false;
389
+ }
390
+ if (this._maxWattsObserved < 50) {
391
+ return false;
392
+ } // Not enough data yet
393
+
394
+ const progress = (now - this.cycleStartTime) / this.expectedDurationMs;
395
+ const powerRatio = watts / this._maxWattsObserved;
396
+
397
+ return (
398
+ progress >= this.cfg.endPhaseProgressMin &&
399
+ powerRatio <= this.cfg.endPhasePowerRatio
400
+ );
401
+ }
402
+
403
+ /**
404
+ * Returns the off-delay to apply right now: shortened while the cycle is
405
+ * in its end phase, otherwise the normal configured off-delay.
406
+ *
407
+ * @param {number} watts – current power reading
408
+ * @param {number} now – current timestamp in ms
409
+ * @returns {number} – off-delay in seconds to use for ending detection
410
+ */
411
+ _getEffectiveOffDelay(watts, now) {
412
+ if (this._isInEndPhase(watts, now)) {
413
+ if (!this._inEndPhase) {
414
+ this._inEndPhase = true;
415
+ // Adapter log if available
416
+ }
417
+ return this.cfg.endPhaseOffDelay;
418
+ }
419
+ this._inEndPhase = false;
420
+ return this.cfg.offDelay;
421
+ }
422
+
423
+ // ── Watchdog ─────────────────────────────────────────────────
424
+
425
+ /** Arms the hard-limit watchdog timer that force-ends a cycle stuck for too long. */
426
+ _startWatchdog() {
427
+ this._stopWatchdog();
428
+ const armTimeout = this.adapter
429
+ ? this.adapter.setTimeout.bind(this.adapter)
430
+ : setTimeout;
431
+ this._watchdogTimer = armTimeout(() => {
432
+ if (this.state !== STATES.OFF) {
433
+ this.lastCycleEndTime = Date.now();
434
+ this.lastCycleCompleted = false;
435
+ this._setState(STATES.ENDING);
436
+ }
437
+ }, this.cfg.watchdogMs);
438
+ }
439
+
440
+ /** Cancels the watchdog timer, if one is currently armed. */
441
+ _stopWatchdog() {
442
+ if (this._watchdogTimer) {
443
+ const clear = this.adapter
444
+ ? this.adapter.clearTimeout.bind(this.adapter)
445
+ : clearTimeout;
446
+ clear(this._watchdogTimer);
447
+ this._watchdogTimer = null;
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Immediately and silently ends the current cycle (no state-change
453
+ * callback fired) - used e.g. by the dryer power-drop detection.
454
+ *
455
+ * @param {number} [ts] – timestamp to record as the end time (defaults to now)
456
+ */
457
+ forceEnd(ts) {
458
+ const now = ts || Date.now();
459
+ this.lastReadingTime = now;
460
+ this.lastCycleEndTime = now;
461
+ this.lastCycleCompleted = true; // Mark as cleanly ended
462
+ this.state = STATES.OFF; // Go straight to OFF without callback
463
+ this.powerTrace = [];
464
+ this.accumulatedEnergy = 0;
465
+ this.belowStartThreshold = null;
466
+ this._stopWatchdog();
467
+ }
468
+
469
+ // ── Helpers ──────────────────────────────────────────────────
470
+
471
+ /** @param {string} newState – one of STATES to transition to */
472
+ _setState(newState) {
473
+ this.state = newState;
474
+ }
475
+
476
+ /** @returns {object} – snapshot of current cycle info passed to the onStateChange callback */
477
+ _buildEventData() {
478
+ return {
479
+ state: this.state,
480
+ cycleStartTime: this.cycleStartTime,
481
+ accumulatedEnergy: this.accumulatedEnergy,
482
+ traceLength: this.powerTrace.length,
483
+ timestamp: this.lastReadingTime || Date.now(),
484
+ };
485
+ }
486
+ }
487
+
488
+ module.exports = { CycleDetector, STATES, DEFAULT_CONFIG };
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * mathUtils.js – NumPy/SciPy-style math utilities
5
+ *
6
+ * Replaces:
7
+ * numpy.corrcoef → corrcoef(a, b)
8
+ * DTW-Lite → dtwDistance(a, b, window)
9
+ * Resampling → resample(trace, targetLength)
10
+ */
11
+
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ // Pearson Correlation Coefficient (numpy.corrcoef equivalent)
14
+ // Returns value in [-1, 1]. 1 = perfect match, 0 = no correlation.
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+
17
+ /**
18
+ * @param {number[]} a – first data series
19
+ * @param {number[]} b – must be same length as a
20
+ * @returns {number} – Pearson r, or 0 if degenerate
21
+ */
22
+ function corrcoef(a, b) {
23
+ const n = Math.min(a.length, b.length);
24
+ if (n < 2) {
25
+ return 0;
26
+ }
27
+
28
+ let sumA = 0,
29
+ sumB = 0;
30
+ for (let i = 0; i < n; i++) {
31
+ sumA += a[i];
32
+ sumB += b[i];
33
+ }
34
+ const meanA = sumA / n;
35
+ const meanB = sumB / n;
36
+
37
+ let num = 0,
38
+ denA = 0,
39
+ denB = 0;
40
+ for (let i = 0; i < n; i++) {
41
+ const da = a[i] - meanA;
42
+ const db = b[i] - meanB;
43
+ num += da * db;
44
+ denA += da * da;
45
+ denB += db * db;
46
+ }
47
+
48
+ const denom = Math.sqrt(denA * denB);
49
+ if (denom === 0) {
50
+ return 0;
51
+ }
52
+ return num / denom;
53
+ }
54
+
55
+ // ─────────────────────────────────────────────────────────────────────────────
56
+ // DTW-Lite (Dynamic Time Warping – simplified, Sakoe-Chiba band)
57
+ // Lower score = better match.
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * @param {number[]} a – first data series
62
+ * @param {number[]} b – second data series
63
+ * @param {number} window – Sakoe-Chiba band width (0 = no constraint)
64
+ * @returns {number} – normalised DTW distance [0, ∞)
65
+ */
66
+ function dtwDistance(a, b, window = 0) {
67
+ const n = a.length;
68
+ const m = b.length;
69
+ if (n === 0 || m === 0) {
70
+ return Infinity;
71
+ }
72
+
73
+ const w = window > 0 ? window : Math.max(n, m);
74
+
75
+ // Use flat Float64Array for speed
76
+ const dtw = new Float64Array((n + 1) * (m + 1)).fill(Infinity);
77
+ const idx = (i, j) => i * (m + 1) + j;
78
+ dtw[idx(0, 0)] = 0;
79
+
80
+ for (let i = 1; i <= n; i++) {
81
+ for (let j = 1; j <= m; j++) {
82
+ if (w > 0 && Math.abs(i - j) > w) {
83
+ continue;
84
+ }
85
+ const cost = Math.abs(a[i - 1] - b[j - 1]);
86
+ dtw[idx(i, j)] =
87
+ cost +
88
+ Math.min(
89
+ dtw[idx(i - 1, j)],
90
+ dtw[idx(i, j - 1)],
91
+ dtw[idx(i - 1, j - 1)],
92
+ );
93
+ }
94
+ }
95
+
96
+ // Normalise by path length
97
+ return dtw[idx(n, m)] / (n + m);
98
+ }
99
+
100
+ // ─────────────────────────────────────────────────────────────────────────────
101
+ // Linear resampling (matches scipy.signal.resample for 1-D)
102
+ // Used to align traces of different lengths before correlation.
103
+ // ─────────────────────────────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * Resample array `arr` to exactly `targetLen` points using linear interpolation.
107
+ *
108
+ * @param {number[]} arr – source data series
109
+ * @param {number} targetLen – desired output length
110
+ * @returns {number[]} – resampled array of length targetLen
111
+ */
112
+ function resample(arr, targetLen) {
113
+ if (arr.length === 0) {
114
+ return new Array(targetLen).fill(0);
115
+ }
116
+ if (arr.length === targetLen) {
117
+ return arr.slice();
118
+ }
119
+
120
+ const result = new Array(targetLen);
121
+ const scale = (arr.length - 1) / (targetLen - 1 || 1);
122
+
123
+ for (let i = 0; i < targetLen; i++) {
124
+ const pos = i * scale;
125
+ const lo = Math.floor(pos);
126
+ const hi = Math.min(lo + 1, arr.length - 1);
127
+ const frac = pos - lo;
128
+ result[i] = arr[lo] * (1 - frac) + arr[hi] * frac;
129
+ }
130
+ return result;
131
+ }
132
+
133
+ // ─────────────────────────────────────────────────────────────────────────────
134
+ // Utility: extract watts array from power trace [{ts, watts}, ...]
135
+ // ─────────────────────────────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * @param {Array<{ts:number, watts:number}>} trace – power trace to extract from
139
+ * @returns {number[]} – array of watt values only
140
+ */
141
+ function traceToWatts(trace) {
142
+ return trace.map((p) => p.watts);
143
+ }
144
+
145
+ /**
146
+ * Compute total energy (Wh) from a power trace using trapezoidal integration.
147
+ *
148
+ * @param {Array<{ts:number, watts:number}>} trace – power trace to integrate
149
+ * @returns {number} – total energy in watt-hours
150
+ */
151
+ function traceToEnergy(trace) {
152
+ let energy = 0;
153
+ for (let i = 1; i < trace.length; i++) {
154
+ const dtH = (trace[i].ts - trace[i - 1].ts) / 3_600_000;
155
+ energy += ((trace[i].watts + trace[i - 1].watts) / 2) * dtH;
156
+ }
157
+ return energy;
158
+ }
159
+
160
+ /**
161
+ * Normalise an array to [0, 1] range.
162
+ *
163
+ * @param {number[]} arr – source data series
164
+ * @returns {number[]} – normalised values in [0, 1]
165
+ */
166
+ function normalise(arr) {
167
+ const max = Math.max(...arr);
168
+ const min = Math.min(...arr);
169
+ const range = max - min || 1;
170
+ return arr.map((v) => (v - min) / range);
171
+ }
172
+
173
+ module.exports = {
174
+ corrcoef,
175
+ dtwDistance,
176
+ resample,
177
+ traceToWatts,
178
+ traceToEnergy,
179
+ normalise,
180
+ };