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,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 };
|