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.
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/admin/i18n/de/translations.json +186 -0
- package/admin/i18n/en/translations.json +186 -0
- package/admin/i18n/es/translations.json +186 -0
- package/admin/i18n/fr/translations.json +186 -0
- package/admin/i18n/it/translations.json +186 -0
- package/admin/i18n/nl/translations.json +186 -0
- package/admin/i18n/pl/translations.json +186 -0
- package/admin/i18n/pt/translations.json +186 -0
- package/admin/i18n/ru/translations.json +186 -0
- package/admin/i18n/uk/translations.json +186 -0
- package/admin/i18n/zh-cn/translations.json +186 -0
- package/admin/icon.png +0 -0
- package/admin/jsonConfig.json +266 -0
- package/admin/tab_m.html +2489 -0
- package/io-package.json +211 -0
- package/lib/cycleDetector.js +488 -0
- package/lib/mathUtils.js +180 -0
- package/lib/profileStore.js +770 -0
- package/lib/traceStore.js +297 -0
- package/lib/washDataManager.js +1852 -0
- package/main.js +1965 -0
- package/package.json +59 -0
|
@@ -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 };
|
package/lib/mathUtils.js
ADDED
|
@@ -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
|
+
};
|