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,770 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * ProfileStore v0.4
5
+ *
6
+ * v0.4: Real curve matching with stored power trace
7
+ * - createProfile() now stores the actual power curve
8
+ * - learnFromCycle() updates the curve with 80/20 weighting
9
+ * - matchProfile() uses a 3-stage pipeline:
10
+ * Stage 1: fast reject (duration ± tolerance)
11
+ * Stage 2: Pearson corrcoef on resampled curve
12
+ * Stage 3: DTW-lite tie-breaking
13
+ * - Auto-maintenance: guards against orphaned profiles
14
+ */
15
+
16
+ const {
17
+ corrcoef,
18
+ dtwDistance,
19
+ resample,
20
+ traceToWatts,
21
+ traceToEnergy,
22
+ normalise,
23
+ } = require("./mathUtils");
24
+
25
+ const RESAMPLE_LEN = 100; // Points for curve comparison (more = better for similar curves)
26
+ let MIN_CONFIDENCE = 0.55; // Below this threshold → "detecting..." – overridable via setMatchThreshold()
27
+ const DTW_TIEBREAK = 0.05; // corrcoef distance for DTW tie-breaking
28
+ const MIN_TRACE_PTS = 5; // Minimum points for curve matching
29
+
30
+ let _idCounter = 0;
31
+
32
+ /**
33
+ * Stores learned appliance program profiles (name, learned power curve,
34
+ * duration history) and matches a running cycle's trace against them.
35
+ */
36
+ class ProfileStore {
37
+ /**
38
+ * @param {object} adapter – the ioBroker adapter instance (for file storage + logging)
39
+ * @param {string} deviceId – the device this store belongs to
40
+ */
41
+ constructor(adapter, deviceId) {
42
+ this.adapter = adapter;
43
+ this.deviceId = deviceId;
44
+ this.profiles = {};
45
+ this._dismissedSuggestions = {}; // fieldKey -> dismissed value
46
+
47
+ // Auto-maintenance: last cleanup run
48
+ this._lastMaintenance = null;
49
+ this._maintenanceTimer = null;
50
+ }
51
+
52
+ // ── Lifecycle ────────────────────────────────────────────────
53
+
54
+ /** Loads all stored profiles (and the Anti-Knitter reference, if any) for this device. */
55
+ async load() {
56
+ try {
57
+ const raw = await this.adapter.readFileAsync(
58
+ `laundrylens.${this.adapter.instance}.files`,
59
+ `profiles_${this.deviceId}.json`,
60
+ );
61
+ if (raw && raw.file) {
62
+ const data = JSON.parse(raw.file);
63
+ this.profiles = data.profiles || {};
64
+ this._antiKnitter = data.antiKnitter || null;
65
+ this._dismissedSuggestions = data.dismissedSuggestions || {};
66
+ // Migration: recompute profiles with an old RESAMPLE_LEN on the next learn
67
+ // Don't delete resampled – otherwise no matching until the next cycle!
68
+ let migrated = 0;
69
+ for (const profile of Object.values(this.profiles)) {
70
+ if (profile.resampled && profile.resampled.length !== RESAMPLE_LEN) {
71
+ // Interpolate to the new length (simple resample)
72
+ const old = profile.resampled;
73
+ const newR = [];
74
+ for (let i = 0; i < RESAMPLE_LEN; i++) {
75
+ const pos = (i / (RESAMPLE_LEN - 1)) * (old.length - 1);
76
+ const lo = Math.floor(pos),
77
+ hi = Math.ceil(pos);
78
+ newR.push(old[lo] + (old[hi] - old[lo]) * (pos - lo));
79
+ }
80
+ profile.resampled = newR;
81
+ migrated++;
82
+ }
83
+ }
84
+ if (migrated > 0) {
85
+ this.adapter.log.info(
86
+ `[ProfileStore] ${migrated} profiles will be recomputed on the next cycle (RESAMPLE_LEN changed)`,
87
+ );
88
+ }
89
+ this.adapter.log.info(
90
+ `[ProfileStore] Loaded ${Object.keys(this.profiles).length} profiles for device ${this.deviceId}`,
91
+ );
92
+ }
93
+ } catch {
94
+ this.profiles = {};
95
+ }
96
+ this._scheduleMaintenance();
97
+ }
98
+
99
+ /** Persists all currently stored profiles (and the Anti-Knitter reference) to the ioBroker file system. */
100
+ async save() {
101
+ try {
102
+ await this.adapter.writeFileAsync(
103
+ `laundrylens.${this.adapter.instance}.files`,
104
+ `profiles_${this.deviceId}.json`,
105
+ JSON.stringify(
106
+ {
107
+ profiles: this.profiles,
108
+ antiKnitter: this._antiKnitter || null,
109
+ dismissedSuggestions: this._dismissedSuggestions || {},
110
+ },
111
+ null,
112
+ 2,
113
+ ),
114
+ );
115
+ } catch (err) {
116
+ this.adapter.log.error(`[ProfileStore] Save failed: ${err.message}`);
117
+ }
118
+ }
119
+
120
+ // ── Profile CRUD ─────────────────────────────────────────────
121
+
122
+ /**
123
+ * Counts contiguous heating blocks in a trace (e.g. for dishwasher
124
+ * phase assignment).
125
+ *
126
+ * @param {Array<{ts:number, watts:number}>} trace – power curve to analyze
127
+ * @returns {{segments:number, maxHeatDurS:number}} – number of heating blocks and longest block found (s)
128
+ */
129
+ _analyzeHeatPattern(trace) {
130
+ const HEAT_W = 800;
131
+ const MIN_HEAT_S = 15;
132
+ let segments = 0,
133
+ inHeat = false,
134
+ heatStart = null,
135
+ maxDurS = 0;
136
+ for (const p of trace) {
137
+ const ts = p.ts,
138
+ w = p.watts;
139
+ if (w >= HEAT_W && !inHeat) {
140
+ inHeat = true;
141
+ heatStart = ts;
142
+ } else if (w < HEAT_W && inHeat) {
143
+ const dur = (ts - heatStart) / 1000;
144
+ if (dur >= MIN_HEAT_S) {
145
+ segments++;
146
+ maxDurS = Math.max(maxDurS, dur);
147
+ }
148
+ inHeat = false;
149
+ }
150
+ }
151
+ return { segments, maxHeatDurS: maxDurS };
152
+ }
153
+
154
+ /**
155
+ * Creates a new profile from a real recorded power curve.
156
+ *
157
+ * @param {string} name – display name of the program
158
+ * @param {Array<{ts:number, watts:number}>} trace – recorded power curve
159
+ * @param {string} [deviceType] – device type (e.g. 'washing_machine', 'dryer', 'dishwasher')
160
+ * @returns {string} – ID of the newly created profile
161
+ */
162
+ createProfile(name, trace, deviceType = "washing_machine") {
163
+ const id = `${this.deviceId}_${Date.now()}`;
164
+ const watts = traceToWatts(trace);
165
+ const energy = traceToEnergy(trace);
166
+ const durationMs =
167
+ trace.length > 1 ? trace[trace.length - 1].ts - trace[0].ts : 0;
168
+
169
+ const resampled =
170
+ watts.length >= MIN_TRACE_PTS
171
+ ? normalise(resample(watts, RESAMPLE_LEN))
172
+ : null;
173
+
174
+ const heatPattern = this._analyzeHeatPattern(trace);
175
+
176
+ this.profiles[id] = {
177
+ id,
178
+ name,
179
+ deviceType,
180
+ createdAt: Date.now(),
181
+ durationMs,
182
+ energyWh: energy,
183
+ cycleCount: 1,
184
+ resampled,
185
+ heatPattern,
186
+ isManual: false,
187
+ stats: {
188
+ meanW: watts.reduce((s, v) => s + v, 0) / (watts.length || 1),
189
+ maxW: Math.max(...watts, 0),
190
+ energyWh: energy,
191
+ },
192
+ durationHistory: [durationMs],
193
+ };
194
+
195
+ this.adapter.log.info(
196
+ `[ProfileStore] Created profile "${name}" from trace (${trace.length} pts)`,
197
+ );
198
+ return id;
199
+ }
200
+
201
+ /**
202
+ * Manual profile without a power curve.
203
+ *
204
+ * @param {string} name – display name of the program
205
+ * @param {number} durationMs – expected run time in milliseconds
206
+ * @param {string} [deviceType] – device type (e.g. 'washing_machine', 'dryer', 'dishwasher')
207
+ * @returns {string} – ID of the newly created profile
208
+ */
209
+ createManualProfile(name, durationMs, deviceType = "washing_machine") {
210
+ const id = `${this.deviceId}_manual_${Date.now()}_${++_idCounter}`;
211
+ this.profiles[id] = {
212
+ id,
213
+ name,
214
+ deviceType,
215
+ createdAt: Date.now(),
216
+ durationMs,
217
+ energyWh: 0,
218
+ cycleCount: 0,
219
+ resampled: null,
220
+ isManual: true,
221
+ stats: { meanW: 0, maxW: 0, energyWh: 0 },
222
+ durationHistory: [durationMs],
223
+ };
224
+ this.adapter.log.info(
225
+ `[ProfileStore] Created manual profile "${name}" (${Math.round(durationMs / 60000)} min)`,
226
+ );
227
+ return id;
228
+ }
229
+
230
+ /**
231
+ * @param {string} id – profile to delete
232
+ * @returns {boolean} – true if a profile was deleted
233
+ */
234
+ deleteProfile(id) {
235
+ if (!this.profiles[id]) {
236
+ return false;
237
+ }
238
+ delete this.profiles[id];
239
+ return true;
240
+ }
241
+
242
+ // Threshold per device type: dryer power curves vary a lot depending on
243
+ // load/residual moisture, so pure curve correlation is structurally
244
+ // weaker there than for washing machines - a lower threshold is needed.
245
+ // Washing machines deliberately get a HIGHER threshold than the global
246
+ // default: better to show "detecting..." than to falsely lock in an
247
+ // uncertain/ambiguous profile (e.g. 30°/60°).
248
+ /**
249
+ * @param {string} deviceType – device type
250
+ * @returns {number} – device-type-specific confidence threshold (0–1)
251
+ */
252
+ _confidenceThresholdFor(deviceType) {
253
+ const dt = (deviceType || "").toLowerCase();
254
+ if (dt === "dryer" || dt === "trockner") {
255
+ return Math.max(0.1, MIN_CONFIDENCE - 0.15);
256
+ }
257
+ if (dt === "washing_machine" || dt === "washer" || dt === "waschmaschine") {
258
+ return Math.min(0.95, MIN_CONFIDENCE + 0.1);
259
+ }
260
+ return MIN_CONFIDENCE;
261
+ }
262
+
263
+ /**
264
+ * @param {string} id – gesuchtes Profil
265
+ * @returns {object|null} – profile or null if not found
266
+ */
267
+ getProfile(id) {
268
+ return this.profiles[id] || null;
269
+ }
270
+
271
+ /**
272
+ * Returns the best candidate even if its score is below the threshold.
273
+ *
274
+ * @param {Array<{ts:number, watts:number}>} trace – current cycle
275
+ * @param {number} [toleranceFactor] – allowed duration deviation (0.2 = ±20%)
276
+ * @returns {{profileId, name, confidence}|null} – best profile found, regardless of how low the confidence is
277
+ */
278
+ getBestCandidate(trace, toleranceFactor = 0.2) {
279
+ const profiles = Object.values(this.profiles);
280
+ if (profiles.length === 0 || trace.length < MIN_TRACE_PTS) {
281
+ return null;
282
+ }
283
+ const currentWatts = traceToWatts(trace);
284
+ const currentDurationMs =
285
+ trace.length > 1 ? trace[trace.length - 1].ts - trace[0].ts : 0;
286
+
287
+ let best = null;
288
+ const debugInfo = [];
289
+ for (const profile of profiles) {
290
+ if (!profile.resampled) {
291
+ debugInfo.push(`${profile.name}: no resampled curve`);
292
+ continue;
293
+ }
294
+ // Fast reject
295
+ if (profile.durationMs > 0 && currentDurationMs > 0) {
296
+ const ratio = currentDurationMs / profile.durationMs;
297
+ if (ratio > 1 + toleranceFactor + 0.3) {
298
+ debugInfo.push(
299
+ `${profile.name}: fast-rejected (elapsed ${Math.round(currentDurationMs / 60000)}min is ${Math.round(ratio * 100)}% of learned ${Math.round(profile.durationMs / 60000)}min, limit ${Math.round((1 + toleranceFactor + 0.3) * 100)}%)`,
300
+ );
301
+ continue;
302
+ }
303
+ }
304
+ // Use the same segment-weighted score calculation as matchProfile(),
305
+ // so the UI preview ("≈" pill) doesn't rely on a different, less
306
+ // accurate method than the actual matching.
307
+ const score = this._scoreProfile(
308
+ profile,
309
+ currentWatts,
310
+ currentDurationMs,
311
+ );
312
+ debugInfo.push(`${profile.name}: score=${(score * 100).toFixed(1)}%`);
313
+ if (!best || score > best.confidence) {
314
+ best = { name: profile.name, id: profile.id, confidence: score };
315
+ }
316
+ }
317
+ if (this.adapter && this.adapter.log) {
318
+ this.adapter.log.debug(
319
+ `[ProfileStore] getBestCandidate for ${this.deviceId}: ${debugInfo.join(" | ")}`,
320
+ );
321
+ }
322
+ return best && best.confidence > 0.3 ? best : null;
323
+ }
324
+ /** @returns {Array<object>} – all stored profiles for this device */
325
+ getAllProfiles() {
326
+ return Object.values(this.profiles);
327
+ }
328
+
329
+ // ── 3-stage matching ─────────────────────────────────────────
330
+
331
+ // ── Shared score calculation (segment weighting) ──────────────
332
+ // Used by both matchProfile() (Stage 2) and getBestCandidate() so that
333
+ // the live preview (UI "≈" pill) is based on the same, improved
334
+ // calculation as the actual matching - previously getBestCandidate used
335
+ // an older, simpler, unweighted correlation, which could cause the UI
336
+ // preview and the actual matching log to show significantly different
337
+ // values (e.g. 86% vs. 51-56%).
338
+ /**
339
+ * @param {object} profile – zu bewertendes Profil
340
+ * @param {number[]} currentWatts – aktuelle Leistungswerte
341
+ * @param {number} currentDurationMs – bisherige Laufzeit des Zyklus
342
+ * @returns {number} – confidence score between 0 and 1
343
+ */
344
+ _scoreProfile(profile, currentWatts, currentDurationMs) {
345
+ let score = 0;
346
+ if (profile.resampled && currentWatts.length >= MIN_TRACE_PTS) {
347
+ const progressRatio =
348
+ profile.durationMs > 0
349
+ ? Math.min(1.0, currentDurationMs / profile.durationMs)
350
+ : 1.0;
351
+ const compareLen = Math.max(
352
+ MIN_TRACE_PTS,
353
+ Math.round(RESAMPLE_LEN * progressRatio),
354
+ );
355
+ const resampled = normalise(resample(currentWatts, compareLen));
356
+ const refSlice = profile.resampled.slice(0, compareLen);
357
+
358
+ const EARLY_WINDOW_MS = 12 * 60000;
359
+ const EARLY_RESAMPLE_LEN = 30;
360
+ const EARLY_WEIGHT = 0.65;
361
+
362
+ let earlyScore = null,
363
+ lateScore = null;
364
+
365
+ if (
366
+ currentDurationMs >= EARLY_WINDOW_MS * 0.5 &&
367
+ profile.durationMs >= EARLY_WINDOW_MS * 0.5
368
+ ) {
369
+ const curEarlyPtCount = Math.max(
370
+ MIN_TRACE_PTS,
371
+ Math.round(
372
+ currentWatts.length *
373
+ Math.min(1, EARLY_WINDOW_MS / currentDurationMs),
374
+ ),
375
+ );
376
+ const earlyCurRaw = currentWatts.slice(0, curEarlyPtCount);
377
+
378
+ const refEarlyFraction = Math.min(
379
+ 1,
380
+ EARLY_WINDOW_MS / profile.durationMs,
381
+ );
382
+ const refEarlyPtCount = Math.max(
383
+ MIN_TRACE_PTS,
384
+ Math.round(RESAMPLE_LEN * refEarlyFraction),
385
+ );
386
+ const earlyRefRaw = profile.resampled.slice(0, refEarlyPtCount);
387
+
388
+ const earlyCur = normalise(resample(earlyCurRaw, EARLY_RESAMPLE_LEN));
389
+ const earlyRef = normalise(resample(earlyRefRaw, EARLY_RESAMPLE_LEN));
390
+ earlyScore = corrcoef(earlyRef, earlyCur);
391
+ if (isNaN(earlyScore)) {
392
+ earlyScore = null;
393
+ }
394
+ }
395
+
396
+ lateScore = corrcoef(refSlice, resampled);
397
+ if (isNaN(lateScore)) {
398
+ lateScore = 0;
399
+ }
400
+
401
+ if (earlyScore !== null) {
402
+ score = EARLY_WEIGHT * earlyScore + (1 - EARLY_WEIGHT) * lateScore;
403
+ } else {
404
+ score = lateScore;
405
+ }
406
+
407
+ if (score > 0.85) {
408
+ score = Math.min(1.0, score * 1.15);
409
+ }
410
+
411
+ if (profile.durationMs > 0 && currentDurationMs > 0) {
412
+ const progressRatioCheck = currentDurationMs / profile.durationMs;
413
+ if (progressRatioCheck >= 0.1 && progressRatioCheck <= 1.1) {
414
+ const durationBonus = Math.min(0.05, progressRatio * 0.05);
415
+ score = Math.min(1.0, score + durationBonus);
416
+ }
417
+ }
418
+ } else if (
419
+ profile.isManual &&
420
+ profile.durationMs > 0 &&
421
+ currentDurationMs > 0
422
+ ) {
423
+ const dRatio =
424
+ 1 -
425
+ Math.abs(currentDurationMs - profile.durationMs) / profile.durationMs;
426
+ score = Math.max(0, Math.min(0.7, dRatio));
427
+ } else if (
428
+ !profile.resampled &&
429
+ profile.durationMs > 0 &&
430
+ currentDurationMs > 0
431
+ ) {
432
+ const dRatio =
433
+ 1 -
434
+ Math.abs(currentDurationMs - profile.durationMs) / profile.durationMs;
435
+ score = Math.max(0, Math.min(0.6, dRatio));
436
+ }
437
+ return score;
438
+ }
439
+
440
+ /**
441
+ * Compares the running curve against all profiles (3-stage pipeline:
442
+ * fast reject → Pearson correlation → DTW tie-break).
443
+ *
444
+ * @param {Array<{ts:number, watts:number}>} trace – current cycle
445
+ * @param {number} [toleranceFactor] – allowed duration deviation (0.2 = ±20%)
446
+ * @returns {{profileId, name, confidence, stage}|null} – confirmed match or null
447
+ */
448
+ matchProfile(trace, toleranceFactor = 0.2) {
449
+ const profiles = Object.values(this.profiles);
450
+ if (profiles.length === 0 || trace.length < MIN_TRACE_PTS) {
451
+ return null;
452
+ }
453
+
454
+ const currentDurationMs =
455
+ trace.length > 1 ? trace[trace.length - 1].ts - trace[0].ts : 0;
456
+ const currentWatts = traceToWatts(trace);
457
+
458
+ const candidates = [];
459
+
460
+ // Heating structure of the current trace, for early filtering
461
+ const currentHeat = this._analyzeHeatPattern(trace);
462
+
463
+ for (const profile of profiles) {
464
+ // ── Stage 1: fast reject ─────────────────────────────
465
+ if (profile.durationMs > 0 && currentDurationMs > 0) {
466
+ const ratio = currentDurationMs / profile.durationMs;
467
+ if (ratio > 1 + toleranceFactor + 0.3) {
468
+ this.adapter.log.debug(
469
+ `[ProfileStore] Duration-Reject: ${profile.name} (elapsed ${Math.round(currentDurationMs / 60000)}min is ${Math.round(ratio * 100)}% of learned ${Math.round(profile.durationMs / 60000)}min, limit ${Math.round((1 + toleranceFactor + 0.3) * 100)}%)`,
470
+ );
471
+ continue;
472
+ }
473
+ }
474
+
475
+ // ── Stage 1b: heating-phase structure check ───────────
476
+ // After 10 minutes: distinguish a long single block vs. many short peaks
477
+ if (profile.heatPattern && currentDurationMs > 10 * 60000) {
478
+ const ph = profile.heatPattern;
479
+ // Profile has a long heating block (>5min) but the current trace has many short peaks
480
+ if (
481
+ ph.maxHeatDurS > 300 &&
482
+ currentHeat.segments >= 3 &&
483
+ currentHeat.maxHeatDurS < 120
484
+ ) {
485
+ this.adapter.log.debug(
486
+ `[ProfileStore] Heat-Reject: ${profile.name} (block ${Math.round(ph.maxHeatDurS / 60)}min vs ${currentHeat.segments} short peaks)`,
487
+ );
488
+ continue;
489
+ }
490
+ // Profile has many short peaks but the current trace has a long block
491
+ if (
492
+ ph.segments >= 3 &&
493
+ ph.maxHeatDurS < 120 &&
494
+ currentHeat.maxHeatDurS > 300
495
+ ) {
496
+ this.adapter.log.debug(
497
+ `[ProfileStore] Heat-Reject: ${profile.name} (${ph.segments} short peaks vs block ${Math.round(currentHeat.maxHeatDurS / 60)}min)`,
498
+ );
499
+ continue;
500
+ }
501
+ }
502
+
503
+ // ── Stage 2: curve correlation (segment-weighted) ─────
504
+ const score = this._scoreProfile(
505
+ profile,
506
+ currentWatts,
507
+ currentDurationMs,
508
+ );
509
+ candidates.push({ profile, score, stage: 2 });
510
+ }
511
+
512
+ if (candidates.length === 0) {
513
+ this.adapter.log.debug(
514
+ `[ProfileStore] matchProfile for ${this.deviceId}: no candidates survived Stage 1/1b (all ${profiles.length} profiles rejected)`,
515
+ );
516
+ return null;
517
+ }
518
+
519
+ candidates.sort((a, b) => b.score - a.score);
520
+ const best = candidates[0];
521
+ const second = candidates[1];
522
+
523
+ // ── Stage 3: DTW Tie-Breaking ────────────────────────────
524
+ if (
525
+ second &&
526
+ Math.abs(best.score - second.score) < DTW_TIEBREAK &&
527
+ best.profile.resampled &&
528
+ second.profile.resampled &&
529
+ currentWatts.length >= MIN_TRACE_PTS
530
+ ) {
531
+ const resampled = normalise(resample(currentWatts, RESAMPLE_LEN));
532
+ // Segment-weighted DTW: the early phase counts more (analogous to
533
+ // Stage 2), so tie-breaking isn't dominated by the long, similar
534
+ // wash/spin portion. Fixed real-time window length per profile
535
+ // (not a fixed percentage), since compared profiles can have
536
+ // different total durations (e.g. "30" vs "60").
537
+ const EARLY_WINDOW_MS_DTW = 12 * 60000;
538
+ const EARLY_W_DTW = 0.65;
539
+
540
+ const weightedDtw = (profile, ref) => {
541
+ const earlyFraction =
542
+ profile.durationMs > 0
543
+ ? Math.min(1, EARLY_WINDOW_MS_DTW / profile.durationMs)
544
+ : 0.22;
545
+ const earlyLen = Math.max(
546
+ MIN_TRACE_PTS,
547
+ Math.round(RESAMPLE_LEN * earlyFraction),
548
+ );
549
+ const dEarly = dtwDistance(
550
+ ref.slice(0, earlyLen),
551
+ resampled.slice(0, earlyLen),
552
+ );
553
+ const dLate = dtwDistance(
554
+ ref.slice(earlyLen),
555
+ resampled.slice(earlyLen),
556
+ );
557
+ return EARLY_W_DTW * dEarly + (1 - EARLY_W_DTW) * dLate;
558
+ };
559
+
560
+ const dtwBest = weightedDtw(best.profile, best.profile.resampled);
561
+ const dtwSecond = weightedDtw(second.profile, second.profile.resampled);
562
+
563
+ // Duration tiebreaker: if DTW is nearly equal, prefer the profile
564
+ // whose duration better matches the current progress
565
+ let winner;
566
+ const dtwDiff = Math.abs(dtwBest - dtwSecond);
567
+ if (dtwDiff < 0.01 && currentDurationMs > 0) {
568
+ // DTW too similar → duration decides
569
+ const ratioBest = currentDurationMs / best.profile.durationMs;
570
+ const ratioSecond = currentDurationMs / second.profile.durationMs;
571
+ // Prefer the profile where current progress is smaller (more time remaining)
572
+ // This is more robust: if both are 24% and 44% → the 30° program has more remaining = more likely
573
+ winner = ratioBest <= ratioSecond ? best : second;
574
+ this.adapter.log.debug(
575
+ `[ProfileStore] DTW+Dauer: ${best.profile.name}=${dtwBest.toFixed(3)}(${Math.round(ratioBest * 100)}%) vs ${second.profile.name}=${dtwSecond.toFixed(3)}(${Math.round(ratioSecond * 100)}%) → ${winner.profile.name}`,
576
+ );
577
+ } else {
578
+ winner = dtwBest <= dtwSecond ? best : second;
579
+ this.adapter.log.debug(
580
+ `[ProfileStore] DTW: ${best.profile.name}=${dtwBest.toFixed(3)} vs ${second.profile.name}=${dtwSecond.toFixed(3)} → ${winner.profile.name}`,
581
+ );
582
+ }
583
+ winner.stage = 3;
584
+
585
+ const winnerThreshold = this._confidenceThresholdFor(
586
+ winner.profile.deviceType,
587
+ );
588
+ return winner.score >= winnerThreshold
589
+ ? {
590
+ profileId: winner.profile.id,
591
+ name: winner.profile.name,
592
+ confidence: winner.score,
593
+ stage: 3,
594
+ }
595
+ : null;
596
+ }
597
+
598
+ const bestThreshold = this._confidenceThresholdFor(best.profile.deviceType);
599
+ if (best.score < bestThreshold) {
600
+ return null;
601
+ }
602
+
603
+ return {
604
+ profileId: best.profile.id,
605
+ name: best.profile.name,
606
+ confidence: best.score,
607
+ stage: best.stage,
608
+ };
609
+ }
610
+
611
+ // ── Learning ─────────────────────────────────────────────────
612
+
613
+ /**
614
+ * Updates a profile after a confirmed cycle (80/20 weighting).
615
+ *
616
+ * @param {string} profileId – profile to update
617
+ * @param {Array<{ts,watts}>} trace – power curve of the confirmed cycle
618
+ * @param {number} confirmedDurationMs – actual run time of the cycle
619
+ */
620
+ learnFromCycle(profileId, trace, confirmedDurationMs) {
621
+ const profile = this.profiles[profileId];
622
+ if (!profile) {
623
+ return;
624
+ }
625
+
626
+ // Duration history (max 20 cycles)
627
+ profile.durationHistory = profile.durationHistory || [];
628
+ profile.durationHistory.push(confirmedDurationMs);
629
+ if (profile.durationHistory.length > 20) {
630
+ profile.durationHistory.shift();
631
+ }
632
+
633
+ // Weighted average of all confirmed durations
634
+ profile.durationMs =
635
+ profile.durationHistory.reduce((s, v) => s + v, 0) /
636
+ profile.durationHistory.length;
637
+
638
+ profile.cycleCount = (profile.cycleCount || 0) + 1;
639
+
640
+ // Blend the curve 80/20 if a real trace is available
641
+ if (trace && trace.length >= MIN_TRACE_PTS) {
642
+ const newWatts = normalise(resample(traceToWatts(trace), RESAMPLE_LEN));
643
+ const oldCurve = profile.resampled || newWatts;
644
+ profile.resampled = oldCurve.map((v, i) => v * 0.8 + newWatts[i] * 0.2);
645
+ profile.isManual = false;
646
+ profile.energyWh = traceToEnergy(trace);
647
+ // Update heating phase structure
648
+ profile.heatPattern = this._analyzeHeatPattern(trace);
649
+ }
650
+
651
+ this.adapter.log.info(
652
+ `[ProfileStore] Learned: "${profile.name}" #${profile.cycleCount}, ` +
653
+ `avg ${Math.round(profile.durationMs / 60000)} min`,
654
+ );
655
+ }
656
+
657
+ // ── Auto-maintenance ─────────────────────────────────────────
658
+
659
+ /** Schedules the first automatic maintenance run for the next midnight, then daily. */
660
+ _scheduleMaintenance() {
661
+ if (this._maintenanceTimer) {
662
+ this.adapter.clearTimeout(this._maintenanceTimer);
663
+ this.adapter.clearInterval(this._maintenanceTimer);
664
+ }
665
+
666
+ // Compute next midnight
667
+ const now = new Date();
668
+ const next = new Date(now);
669
+ next.setHours(0, 0, 0, 0);
670
+ next.setDate(next.getDate() + 1);
671
+ const msUntilMidnight = next - now;
672
+
673
+ this._maintenanceTimer = this.adapter.setTimeout(() => {
674
+ this._runMaintenance();
675
+ // Daily from then on
676
+ this._maintenanceTimer = this.adapter.setInterval(
677
+ () => this._runMaintenance(),
678
+ 24 * 60 * 60 * 1000,
679
+ );
680
+ }, msUntilMidnight);
681
+
682
+ this.adapter.log.debug(
683
+ `[ProfileStore] Next maintenance in ${Math.round(msUntilMidnight / 3600000)}h (midnight)`,
684
+ );
685
+ }
686
+
687
+ /** Removes empty, never-used manual profiles older than 7 days. */
688
+ _runMaintenance() {
689
+ let removed = 0;
690
+ for (const id of Object.keys(this.profiles)) {
691
+ const p = this.profiles[id];
692
+ // Remove empty profiles without cycles and without a curve after 7 days
693
+ if (p.cycleCount === 0 && !p.resampled && p.isManual) {
694
+ const age = Date.now() - (p.createdAt || 0);
695
+ if (age > 7 * 24 * 60 * 60 * 1000) {
696
+ delete this.profiles[id];
697
+ removed++;
698
+ }
699
+ }
700
+ }
701
+ if (removed > 0) {
702
+ this.adapter.log.info(
703
+ `[ProfileStore] Maintenance: ${removed} leere Profile entfernt`,
704
+ );
705
+ this.save();
706
+ }
707
+ this._lastMaintenance = new Date().toISOString();
708
+ }
709
+
710
+ // Store/load the anti-crease (Anti-Knitter) reference pattern
711
+ /**
712
+ * @param {object} root0 – reference values from a chosen cycle
713
+ * @param {number} root0.maxWatts – 85th percentile of the power values
714
+ * @param {number} root0.durationMs – duration of the reference cycle
715
+ */
716
+ async setAntiKnitter({ maxWatts, durationMs }) {
717
+ this._antiKnitter = { maxWatts, durationMs, learnedAt: Date.now() };
718
+ await this.save();
719
+ }
720
+
721
+ /** @returns {object|null} – stored anti-crease reference pattern or null */
722
+ getAntiKnitter() {
723
+ return this._antiKnitter || null;
724
+ }
725
+
726
+ /** Deletes the stored anti-crease reference pattern. */
727
+ async clearAntiKnitter() {
728
+ this._antiKnitter = null;
729
+ await this.save();
730
+ }
731
+
732
+ // ── Dismissed suggestions ────────────────────────────────────
733
+ // Lets a user ignore a single suggested-settings field without
734
+ // affecting the others - the dismissal is remembered per field
735
+ // together with the exact value that was dismissed, so a NEW
736
+ // suggestion for that field (a different value, computed from more
737
+ // recent cycles) is shown again rather than staying hidden forever.
738
+
739
+ /** @returns {object} – map of fieldKey -> dismissed value */
740
+ getDismissedSuggestions() {
741
+ return this._dismissedSuggestions || {};
742
+ }
743
+
744
+ /**
745
+ * @param {string} field – suggestion field key (e.g. 'offDelayMin')
746
+ * @param {number} value – the specific suggested value being dismissed
747
+ */
748
+ async dismissSuggestion(field, value) {
749
+ if (!this._dismissedSuggestions) {
750
+ this._dismissedSuggestions = {};
751
+ }
752
+ this._dismissedSuggestions[field] = value;
753
+ await this.save();
754
+ }
755
+
756
+ /** @param {number} pct – new detection threshold in percent (0–100) */
757
+ setMatchThreshold(pct) {
758
+ MIN_CONFIDENCE = Math.max(0.1, Math.min(0.95, pct / 100));
759
+ this.adapter.log.debug(
760
+ `[ProfileStore] matchThreshold gesetzt: ${Math.round(MIN_CONFIDENCE * 100)}%`,
761
+ );
762
+ }
763
+
764
+ /** @returns {number} – currently configured detection threshold (0–1) */
765
+ getMatchThreshold() {
766
+ return MIN_CONFIDENCE;
767
+ }
768
+ }
769
+
770
+ module.exports = { ProfileStore, RESAMPLE_LEN, MIN_CONFIDENCE };