iobroker.sun2000 2.4.5 → 2.5.1

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,1458 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * statistics.js
5
+ *
6
+ * Prepares statistical data from historical Huawei SUN2000 inverter states.
7
+ * Aggregates raw values into time-based datasets (hourly, daily, monthly, yearly)
8
+ * for analysis and visualisation via ioBroker.flexcharts.
9
+ *
10
+ * Consumption breakdown (splitting the total consumption into user-defined
11
+ * sub-categories) is fully delegated to:
12
+ * lib/statistics/consumptionBreakdown.js – config, validation, foreign-state refresh
13
+ * lib/statistics/breakdownChartHelper.js – eCharts series / legend / placeholder helpers
14
+ *
15
+ * Live chart:
16
+ * Uses this.liveStats data
17
+ * Every this.adapter.settings.statistics.liveInterval the kWh delta is converted to average kW:
18
+ * kW = ΔkWh * (60 / this.adapter.settings.statistics.liveInterval)
19
+ * Data is retained for 48 h, then cleared.
20
+ */
21
+
22
+ const stringify = require('javascript-stringify').stringify;
23
+ const { dataRefreshRate, statisticsType } = require(`${__dirname}/../types.js`);
24
+ const tools = require(`${__dirname}/../tools.js`);
25
+ const { stringifyWithFunctions, reviveFunctions } = require(`${__dirname}/../json_helper.js`);
26
+ const { ConsumptionBreakdown } = require(`${__dirname}/consumptionBreakdown.js`);
27
+ const chartHelper = require(`${__dirname}/breakdownChartHelper.js`);
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Chart types handled by this module
31
+ // ---------------------------------------------------------------------------
32
+ // [LIVE] 'live' added
33
+ const CHART_TYPES = ['live', 'hourly', 'daily', 'weekly', 'monthly', 'annual'];
34
+
35
+ const CHART_STATE_IDS = {
36
+ // [LIVE] added
37
+ live: 'statistics.jsonLive',
38
+ hourly: 'statistics.jsonHourly',
39
+ daily: 'statistics.jsonDaily',
40
+ weekly: 'statistics.jsonWeekly',
41
+ monthly: 'statistics.jsonMonthly',
42
+ annual: 'statistics.jsonAnnual',
43
+ };
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // [LIVE] Live chart configuration
47
+ // Interval in minutes — controls both the timer and the kW conversion factor.
48
+ // kW = ΔkWh * (60 / this.adapter.settings.statistics.liveInterval)
49
+ // ---------------------------------------------------------------------------
50
+ const LIVE_RETENTION_HOURS = 48; // entries older than this are dropped
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Class
54
+ // ---------------------------------------------------------------------------
55
+
56
+ class statistics {
57
+ constructor(adapterInstance, stateCache) {
58
+ this.adapter = adapterInstance;
59
+ this.stateCache = stateCache;
60
+ this.taskTimer = null;
61
+ // [LIVE]
62
+ this._liveTimer = null;
63
+ this._path = 'statistics';
64
+ this._initialized = false;
65
+ this.testing = false;
66
+
67
+ // Built-in stats processed by the calculation pipeline
68
+ this.stats = [
69
+ { sourceId: 'collected.consumptionToday', targetPath: 'consumption', unit: 'kWh', type: statisticsType.deltaReset },
70
+ { sourceId: 'collected.dailySolarYield', targetPath: 'solarYield', unit: 'kWh', type: statisticsType.deltaReset },
71
+ { sourceId: 'collected.dailyInputYield', targetPath: 'inputYield', unit: 'kWh', type: statisticsType.deltaReset },
72
+ { sourceId: 'collected.dailyExternalYield', targetPath: 'externalYield', unit: 'kWh', type: statisticsType.deltaReset },
73
+ { sourceId: 'collected.dailyEnergyYield', targetPath: 'energyYield', unit: 'kWh', type: statisticsType.deltaReset },
74
+ { sourceId: 'collected.SOC', targetPath: 'SOC', unit: '%', type: statisticsType.level },
75
+ { sourceId: 'collected.currentDayChargeCapacity', targetPath: 'chargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
76
+ { sourceId: 'collected.currentDayDischargeCapacity', targetPath: 'dischargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
77
+ { sourceId: 'collected.gridExportToday', targetPath: 'gridExport', unit: 'kWh', type: statisticsType.deltaReset },
78
+ { sourceId: 'collected.gridImportToday', targetPath: 'gridImport', unit: 'kWh', type: statisticsType.deltaReset },
79
+ // --- Computed ---
80
+ {
81
+ targetPath: 'selfSufficiency',
82
+ unit: '%',
83
+ type: statisticsType.computed,
84
+ compute: entry => {
85
+ const consumption = entry.consumption?.total ?? entry.consumption?.value ?? 0;
86
+ const gridImport = entry.gridImport?.total ?? entry.gridImport?.value ?? 0;
87
+ if (consumption <= 0) return 100;
88
+ return Math.round(Math.max(0, Math.min(100, (1 - gridImport / consumption) * 100)) * 10) / 10;
89
+ },
90
+ },
91
+ {
92
+ targetPath: 'selfConsumption',
93
+ unit: '%',
94
+ type: statisticsType.computed,
95
+ compute: entry => {
96
+ let solarYield = entry.solarYield?.total ?? entry.solarYield?.value ?? 0;
97
+ solarYield += entry.externalYield?.total ?? entry.externalYield?.value ?? 0;
98
+ const gridExport = entry.gridExport?.total ?? entry.gridExport?.value ?? 0;
99
+ if (solarYield <= 0) return 0;
100
+ return Math.round(Math.max(0, Math.min(100, (1 - gridExport / solarYield) * 100)) * 10) / 10;
101
+ },
102
+ },
103
+ ];
104
+
105
+ this.liveStats = [
106
+ { sourceId: 'collected.sumDaily.houseConsumption', targetPath: 'consumption', unit: 'kWh', type: statisticsType.deltaReset },
107
+ { sourceId: 'collected.dailySolarYield', targetPath: 'solarYield', unit: 'kWh', type: statisticsType.deltaReset },
108
+ { sourceId: 'collected.sumDaily.inputPower', targetPath: 'inputYield', unit: 'kWh', type: statisticsType.deltaReset },
109
+ { sourceId: 'collected.dailyExternalYield', targetPath: 'externalYield', unit: 'kWh', type: statisticsType.deltaReset },
110
+ { sourceId: 'collected.dailyEnergyYield', targetPath: 'energyYield', unit: 'kWh', type: statisticsType.deltaReset },
111
+ { sourceId: 'collected.SOC', targetPath: 'SOC', unit: '%', type: statisticsType.level },
112
+ { sourceId: 'collected.sumDaily.chargePower', targetPath: 'chargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
113
+ { sourceId: 'collected.sumDaily.dischargePower', targetPath: 'dischargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
114
+ { sourceId: 'collected.sumDaily.feed-outPower', targetPath: 'gridImport', unit: 'kWh', type: statisticsType.deltaReset },
115
+ { sourceId: 'collected.sumDaily.feed-inPower', targetPath: 'gridExport', unit: 'kWh', type: statisticsType.deltaReset },
116
+ // --- Computed ---
117
+ {
118
+ targetPath: 'selfSufficiency',
119
+ unit: '%',
120
+ type: statisticsType.computed,
121
+ compute: entry => {
122
+ const consumption = entry.consumption?.total ?? entry.consumption?.value ?? 0;
123
+ const gridImport = entry.gridImport?.total ?? entry.gridImport?.value ?? 0;
124
+ if (consumption <= 0) return 100;
125
+ return Math.round(Math.max(0, Math.min(100, (1 - gridImport / consumption) * 100)) * 10) / 10;
126
+ },
127
+ },
128
+ {
129
+ targetPath: 'selfConsumption',
130
+ unit: '%',
131
+ type: statisticsType.computed,
132
+ compute: entry => {
133
+ let solarYield = entry.solarYield?.total ?? entry.solarYield?.value ?? 0;
134
+ solarYield += entry.externalYield?.total ?? entry.externalYield?.value ?? 0;
135
+ const gridExport = entry.gridExport?.total ?? entry.gridExport?.value ?? 0;
136
+ if (solarYield <= 0) return 0;
137
+ return Math.round(Math.max(0, Math.min(100, (1 - gridExport / solarYield) * 100)) * 10) / 10;
138
+ },
139
+ },
140
+ ];
141
+
142
+ // Instantiate the breakdown manager.
143
+ // builtinPaths is passed so the manager can detect targetPath collisions.
144
+ const builtinPaths = this.stats.map(s => s.targetPath);
145
+ this._breakdown = new ConsumptionBreakdown(adapterInstance, stateCache, builtinPaths);
146
+
147
+ // ioBroker state definitions registered via postProcessHooks
148
+ this.postProcessHooks = [
149
+ {
150
+ refresh: dataRefreshRate.low,
151
+ states: [
152
+ // [LIVE] state definitions
153
+ {
154
+ id: 'statistics.jsonLive',
155
+ name: 'Live power JSON',
156
+ type: 'string',
157
+ role: 'json',
158
+ desc: `Live power (kW) chart data, ${this.adapter.settings.statistics.liveInterval}-min intervals, 48 h retention`,
159
+ initVal: '[]',
160
+ },
161
+ {
162
+ id: 'statistics.jsonHourly',
163
+ name: 'Hourly consumption JSON',
164
+ type: 'string',
165
+ role: 'json',
166
+ desc: 'Hourly consumption for last and current day per full hour',
167
+ initVal: '[]',
168
+ },
169
+ {
170
+ id: 'statistics.jsonDaily',
171
+ name: 'Daily consumption JSON',
172
+ type: 'string',
173
+ role: 'json',
174
+ desc: 'Daily consumption for current month per day',
175
+ initVal: '[]',
176
+ },
177
+ {
178
+ id: 'statistics.jsonWeekly',
179
+ name: 'Weekly consumption JSON',
180
+ type: 'string',
181
+ role: 'json',
182
+ desc: 'Weekly consumption for current year per week',
183
+ initVal: '[]',
184
+ },
185
+ {
186
+ id: 'statistics.jsonMonthly',
187
+ name: 'Monthly consumption JSON',
188
+ type: 'string',
189
+ role: 'json',
190
+ desc: 'Monthly consumption for current year per month',
191
+ initVal: '[]',
192
+ },
193
+ {
194
+ id: 'statistics.jsonAnnual',
195
+ name: 'Annual consumption JSON',
196
+ type: 'string',
197
+ role: 'json',
198
+ desc: 'Annual consumption per year',
199
+ initVal: '[]',
200
+ },
201
+ {
202
+ id: 'statistics.jsonToday',
203
+ name: 'Today summary',
204
+ type: 'string',
205
+ role: 'json',
206
+ desc: "Live summary of today's energy values",
207
+ initVal: '{}',
208
+ },
209
+
210
+ // Breakdown config state — definition provided by sub-module
211
+ this._breakdown.stateDef,
212
+
213
+ // Chart templates (user-writable, one per chart type)
214
+ ...CHART_TYPES.map(t => ({
215
+ id: `statistics.flexCharts.template.${t}`,
216
+ name: `Flexcharts template ${t}`,
217
+ type: 'string',
218
+ role: 'json',
219
+ desc: `Optional eCharts template for ${t} chart. Leave empty {} for built-in layout.`,
220
+ write: true,
221
+ initVal: '{}',
222
+ })),
223
+
224
+ // Chart output states (read-only, written by this module)
225
+ ...CHART_TYPES.map(t => ({
226
+ id: `statistics.flexCharts.jsonOutput.${t}`,
227
+ name: `Flexcharts output ${t}`,
228
+ type: 'string',
229
+ role: 'json',
230
+ desc: `ECharts configuration for ${t} chart`,
231
+ initVal: '{}',
232
+ })),
233
+ ],
234
+ },
235
+ ];
236
+
237
+ this.initialize();
238
+ }
239
+
240
+ get processHooks() {
241
+ return this.postProcessHooks;
242
+ }
243
+
244
+ // -------------------------------------------------------------------------
245
+ // Effective stats: built-in + breakdown entries (unified pipeline interface)
246
+ // -------------------------------------------------------------------------
247
+
248
+ /**
249
+ * Merges this.stats with the breakdown's stats-shaped entries so all
250
+ * calculation code iterates a single array transparently.
251
+ *
252
+ * @returns {Array}
253
+ */
254
+ _effectiveStats() {
255
+ return [...this.stats, ...this._breakdown.statsEntries];
256
+ }
257
+
258
+ _effectiveLiveStats() {
259
+ return [...this.liveStats, ...this._breakdown.statsEntries];
260
+ }
261
+
262
+ // -------------------------------------------------------------------------
263
+ // Date helpers
264
+ // -------------------------------------------------------------------------
265
+
266
+ _localIsoWithOffset(d) {
267
+ const pad = n => String(n).padStart(2, '0');
268
+ const tzOffset = -d.getTimezoneOffset();
269
+ const sign = tzOffset >= 0 ? '+' : '-';
270
+ const absMin = Math.abs(tzOffset);
271
+ return (
272
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
273
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.000` +
274
+ `${sign}${pad(Math.floor(absMin / 60))}:${pad(absMin % 60)}`
275
+ );
276
+ }
277
+
278
+ // -------------------------------------------------------------------------
279
+ // Core calculation helpers
280
+ // -------------------------------------------------------------------------
281
+
282
+ /**
283
+ * Appends a new data-point (periodStart → periodEnde) to the JSON array
284
+ * stored in stateId. Breakdown entries are included because _effectiveStats()
285
+ * returns them alongside the built-in stats.
286
+ *
287
+ * @param {string} stateId
288
+ * @param {Date} periodStart
289
+ * @param {Date} periodEnde
290
+ * @param {Array} effectiveStats
291
+ * @returns {boolean} true if an entry was appended
292
+ */
293
+ _calculateGeneric(stateId, periodStart, periodEnde, effectiveStats = this._effectiveStats()) {
294
+ const toStr = this._localIsoWithOffset(periodEnde);
295
+
296
+ let arr = [];
297
+ try {
298
+ arr = JSON.parse(this.stateCache.get(stateId)?.value ?? '[]');
299
+ if (!Array.isArray(arr)) arr = [];
300
+ } catch {
301
+ arr = [];
302
+ }
303
+
304
+ let fromDate = periodStart;
305
+ let last = {};
306
+ if (arr.length > 0) {
307
+ last = arr[arr.length - 1];
308
+ if (last.to === toStr) return false;
309
+ const lastToDate = new Date(last.to);
310
+ const toDate = new Date(toStr);
311
+ if (lastToDate >= periodStart || toDate <= periodStart) fromDate = lastToDate;
312
+ }
313
+
314
+ const entry = { from: this._localIsoWithOffset(fromDate), to: toStr };
315
+
316
+ // Pass 1: delta / level stats (built-in + breakdown)
317
+ for (const stat of effectiveStats) {
318
+ if (stat.type === statisticsType.computed) continue;
319
+
320
+ //const source = Math.round((Number(this.stateCache.get(stat.sourceId)?.value ?? 0) + Number.EPSILON) * 1000) / 1000;
321
+ const source = this.stateCache.get(stat.sourceId)?.value;
322
+ if (source === null || source === undefined) {
323
+ if (!stat._isBreakdown) this.adapter.logger.debug(`Source state ${stat.sourceId} not found statistic hook`);
324
+ continue;
325
+ }
326
+ let value = source;
327
+ if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
328
+ if (last[stat.targetPath]?.total === undefined) {
329
+ this.adapter.logger.debug(`No total value for ${stat.targetPath} in last entry, delta set to 0`);
330
+ value = 0;
331
+ } else {
332
+ const lastTotal = Number(last[stat.targetPath]?.total ?? 0);
333
+ if (stat.type === statisticsType.deltaReset) {
334
+ // Handle resets: If the new value is significantly lower than the last total value, a reset has likely occurred. Otherwise, the difference relative to the last total value is treated as consumption.
335
+ if (value > lastTotal * 0.8) {
336
+ value -= lastTotal;
337
+ }
338
+ } else {
339
+ value -= lastTotal;
340
+ }
341
+ }
342
+ }
343
+ value = Math.round((Number(value) + Number.EPSILON) * 1000) / 1000;
344
+ entry[stat.targetPath] = { value: Number(value.toFixed(3)) };
345
+ if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
346
+ entry[stat.targetPath].total = Number(source.toFixed(5));
347
+ }
348
+ entry[stat.targetPath].unit = stat.unit || 'kWh';
349
+ }
350
+
351
+ // Pass 2: computed stats
352
+ for (const stat of effectiveStats) {
353
+ if (stat.type !== statisticsType.computed) continue;
354
+ try {
355
+ const value = stat.compute(entry);
356
+ entry[stat.targetPath] = { value: Number(Number(value).toFixed(3)), unit: stat.unit || '%' };
357
+ } catch (e) {
358
+ this.adapter.logger.warn(`statistics: error computing ${stat.targetPath}: ${e.message}`);
359
+ entry[stat.targetPath] = { value: 0, unit: stat.unit || '%' };
360
+ }
361
+ }
362
+
363
+ // Pass 3: subtract breakdown values from consumption
364
+ if (entry.consumption !== undefined) {
365
+ let breakdownSum = 0;
366
+ for (const stat of this._breakdown.statsEntries) {
367
+ if (!stat._isBreakdown) continue;
368
+ if ((stat.unit || 'kWh') !== 'kWh') continue; //??
369
+ breakdownSum += Number(entry[stat.targetPath]?.value ?? 0);
370
+ }
371
+ if (breakdownSum > 0) {
372
+ const remainder = Math.max(0, Math.round((Number(entry.consumption.value) - breakdownSum + Number.EPSILON) * 1000) / 1000);
373
+ entry.consumption = { ...entry.consumption, value: Number(remainder.toFixed(3)) };
374
+ this.adapter.logger.debug(`_calculateGeneric: consumption reduced by breakdown sum ${breakdownSum} → remainder ${remainder}`);
375
+ }
376
+ }
377
+
378
+ arr.push(entry);
379
+ arr.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
380
+ this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
381
+ this.adapter.logger.debug(`Appended ${stateId} statistic ${toStr}`);
382
+ return true;
383
+ }
384
+
385
+ /**
386
+ * Removes entries older than periodStart from the JSON array in stateId.
387
+ *
388
+ * @param {string} stateId
389
+ * @param {Date} periodStart
390
+ */
391
+ _clearGeneric(stateId, periodStart) {
392
+ let arr = [];
393
+ try {
394
+ arr = JSON.parse(this.stateCache.get(stateId)?.value ?? '[]');
395
+ if (!Array.isArray(arr)) arr = [];
396
+ } catch {
397
+ arr = [];
398
+ }
399
+ arr = arr.filter(item => {
400
+ const ts = Date.parse(item.from);
401
+ return !Number.isNaN(ts) && ts >= periodStart.getTime();
402
+ });
403
+ this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
404
+ }
405
+
406
+ /**
407
+ * Aggregates source entries within a time window into a single target entry.
408
+ *
409
+ * @param {string} sourceStateId
410
+ * @param {string} targetStateId
411
+ * @param {(now: Date) => { from: Date, to: Date }} getWindow (now: Date) => { from: Date, to: Date }
412
+ * @param {string} periodType label for log messages
413
+ * @param {Array} effectiveStats
414
+ * @returns {boolean}
415
+ */
416
+
417
+ _calculateAggregation(sourceStateId, targetStateId, getWindow, periodType) {
418
+ const effectiveStats = this._effectiveStats();
419
+ try {
420
+ const now = new Date();
421
+ const { from: fromDate, to: toDate } = getWindow(now);
422
+
423
+ const isRunning = now < toDate;
424
+ const effectiveTo = isRunning ? now : toDate;
425
+ const toStr = this._localIsoWithOffset(effectiveTo);
426
+ const fromStr = this._localIsoWithOffset(fromDate);
427
+
428
+ let targetArray = [];
429
+ try {
430
+ targetArray = JSON.parse(this.stateCache.get(targetStateId)?.value ?? '[]');
431
+ if (!Array.isArray(targetArray)) targetArray = [];
432
+ } catch {
433
+ targetArray = [];
434
+ }
435
+
436
+ const existingIdx = targetArray.findLastIndex(e => (isRunning ? e._live === true : e.from === fromStr));
437
+ if (!isRunning && existingIdx >= 0 && targetArray[existingIdx] === this._localIsoWithOffset(toDate)) {
438
+ this.adapter.logger.debug(`statistics.js: ${periodType} entry already finalized, skipping`);
439
+ return false;
440
+ }
441
+
442
+ let sourceEntries = [];
443
+ try {
444
+ sourceEntries = JSON.parse(this.stateCache.get(sourceStateId)?.value ?? '[]');
445
+ if (!Array.isArray(sourceEntries)) sourceEntries = [];
446
+ } catch {
447
+ sourceEntries = [];
448
+ }
449
+ sourceEntries = sourceEntries.filter(item => {
450
+ const ts = Date.parse(item.from);
451
+ return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < toDate.getTime();
452
+ });
453
+
454
+ if (sourceEntries.length === 0) {
455
+ this.adapter.logger.debug(
456
+ `statistics.js: No source entries for ${periodType} between ${fromDate.toISOString()} and ${toDate.toISOString()}, skipping`,
457
+ );
458
+ return false;
459
+ }
460
+ this.adapter.logger.debug(`statistics.js: ${sourceEntries.length} entries for ${periodType} (running=${isRunning})`);
461
+
462
+ const target = { from: fromStr, to: toStr };
463
+ if (isRunning) target._live = true;
464
+
465
+ // Pass 1: sum delta / deltaReset stats (built-in + breakdown)
466
+ for (const stat of effectiveStats) {
467
+ if (stat.type === statisticsType.level || stat.type === statisticsType.computed) continue;
468
+ let sum = 0;
469
+ try {
470
+ sourceEntries.forEach(e => {
471
+ sum += Number(e[stat.targetPath]?.value ?? 0);
472
+ });
473
+ } catch (e) {
474
+ this.adapter.logger.warn(`statistics.js: aggregation error ${periodType}: ${e.message}`);
475
+ }
476
+ sum = Math.round((Number(sum) + Number.EPSILON) * 1000) / 1000;
477
+ target[stat.targetPath] = { value: Number(sum.toFixed(3)), unit: stat.unit || 'kWh' };
478
+ }
479
+
480
+ // Pass 2: computed stats
481
+ for (const stat of effectiveStats) {
482
+ if (stat.type !== statisticsType.computed) continue;
483
+ try {
484
+ const value = stat.compute(target);
485
+ target[stat.targetPath] = { value: Number(Number(value).toFixed(3)), unit: stat.unit || '%' };
486
+ } catch (e) {
487
+ this.adapter.logger.warn(`statistics: error computing ${stat.targetPath}: ${e.message}`);
488
+ target[stat.targetPath] = { value: 0, unit: stat.unit || '%' };
489
+ }
490
+ }
491
+
492
+ if (existingIdx >= 0) {
493
+ targetArray[existingIdx] = target;
494
+ } else {
495
+ targetArray.push(target);
496
+ }
497
+ targetArray.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
498
+ this.stateCache.set(targetStateId, JSON.stringify(targetArray), { type: 'string' });
499
+ return true;
500
+ } catch (err) {
501
+ this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
502
+ }
503
+ }
504
+
505
+ // -------------------------------------------------------------------------
506
+ // Today live summary state
507
+ // -------------------------------------------------------------------------
508
+
509
+ updateJsonToday() {
510
+ if (!this._initialized) {
511
+ this.adapter.logger.debug('statistics: updateJsonToday called before initialization');
512
+ return;
513
+ }
514
+ try {
515
+ const now = new Date();
516
+ const today = {};
517
+ const effectiveStats = this._effectiveStats();
518
+
519
+ for (const stat of effectiveStats) {
520
+ if (stat.type === statisticsType.computed) continue;
521
+ const val = this.stateCache.get(stat.sourceId)?.value;
522
+ today[stat.targetPath] = { value: val != null ? Number(Number(val).toFixed(3)) : null, unit: stat.unit };
523
+ }
524
+ for (const stat of effectiveStats) {
525
+ if (stat.type !== statisticsType.computed) continue;
526
+ try {
527
+ const value = stat.compute(today);
528
+ today[stat.targetPath] = { value: Number(Number(value).toFixed(3)), unit: stat.unit };
529
+ } catch (e) {
530
+ this.adapter.logger.warn(`statistics: error computing today.${stat.targetPath}: ${e.message}`);
531
+ today[stat.targetPath] = { value: 0, unit: stat.unit };
532
+ }
533
+ }
534
+ today.updatedAt = this._localIsoWithOffset(now);
535
+ this.stateCache.set('statistics.jsonToday', JSON.stringify(today), { type: 'string' });
536
+ this.adapter.logger.debug('statistics: jsonToday updated');
537
+ } catch (err) {
538
+ this.adapter.logger.warn(`statistics: error updating today state: ${err.message}`);
539
+ }
540
+ }
541
+
542
+ // -------------------------------------------------------------------------
543
+ // Periodic calculations
544
+ // -------------------------------------------------------------------------
545
+
546
+ // [LIVE] New method — same pattern as _calculateHourly
547
+ /**
548
+ * Appends one entry to statistics.jsonLive every LIVE_INTERVAL_MINUTES.
549
+ *
550
+ * Uses _calculateGeneric with the current minute boundary as periodEnde and
551
+ * the previous interval boundary as periodStart (= start of current day for
552
+ * the deltaReset baseline).
553
+ *
554
+ * After _calculateGeneric writes the kWh delta, the last entry is converted
555
+ * in-place to average kW:
556
+ * kW = ΔkWh × (60 / LIVE_INTERVAL_MINUTES)
557
+ *
558
+ * 'SOC' and computed stats keep their original unit (%, no conversion).
559
+ * Entries older than LIVE_RETENTION_HOURS are pruned.
560
+ */
561
+ _calculateLive() {
562
+ const now = new Date();
563
+ // Round down to the current interval boundary
564
+ const intervalMs = (this.adapter.settings.statistics.liveInterval || 5) * 60 * 1000;
565
+ const boundary = new Date(Math.floor(now.getTime() / intervalMs) * intervalMs);
566
+ const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
567
+ //const convFactor = 60 / LIVE_INTERVAL_MINUTES;
568
+
569
+ this.adapter.log.debug(`### Live execution triggered, boundary: ${boundary.toLocaleTimeString()} ###`);
570
+
571
+ const appended = this._calculateGeneric(CHART_STATE_IDS.live, startOfDay, boundary, this._effectiveLiveStats());
572
+ if (!appended) return;
573
+
574
+ // Convert the freshly appended entry from kWh delta → average kW
575
+ let arr = [];
576
+ try {
577
+ arr = JSON.parse(this.stateCache.get(CHART_STATE_IDS.live)?.value ?? '[]');
578
+ if (!Array.isArray(arr)) arr = [];
579
+ } catch {
580
+ return;
581
+ }
582
+
583
+ if (arr.length === 0) return;
584
+ const last = arr[arr.length - 1];
585
+ const fromDate = new Date(last.from);
586
+ const toDate = new Date(last.to);
587
+ const convFactor = toDate.getTime() - fromDate.getTime() > 0 ? (60 * 60 * 1000) / (toDate.getTime() - fromDate.getTime()) : 0;
588
+
589
+ for (const stat of this._effectiveLiveStats()) {
590
+ // Only convert energy (kWh) deltas — leave SOC (%) and computed (%) as-is
591
+ if (stat.type !== statisticsType.deltaReset && stat.type !== statisticsType.delta) continue;
592
+ if (last[stat.targetPath] === undefined) continue;
593
+
594
+ const kw = Math.round(last[stat.targetPath].value * convFactor * 1000) / 1000;
595
+ if (stat.targetPath === 'solarYield') {
596
+ this.adapter.log.debug(
597
+ `### Live stat solarYield from ${last.from} to ${last.to} - ${stat.targetPath}, total: ${last[stat.targetPath]?.total} ${last[stat.targetPath].value} kWh → ${kw} kW (convFactor ${convFactor.toFixed(2)})`,
598
+ );
599
+ }
600
+ last[stat.targetPath] = { ...last[stat.targetPath], value: kw, unit: 'kW' };
601
+ }
602
+
603
+ // Prune entries older than LIVE_RETENTION_HOURS
604
+ const cutoff = now.getTime() - LIVE_RETENTION_HOURS * 3600 * 1000;
605
+ const pruned = arr.filter(e => {
606
+ const t = Date.parse(e.from);
607
+ return !Number.isNaN(t) && t >= cutoff;
608
+ });
609
+
610
+ this.stateCache.set(CHART_STATE_IDS.live, JSON.stringify(pruned), { type: 'string' });
611
+ this._buildFlexchart('live');
612
+ }
613
+
614
+ _calculateHourly() {
615
+ const now = new Date();
616
+ if (this.testing) {
617
+ const state = this.adapter.getState('statistics.jsonHourly');
618
+ this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
619
+ now.setDate(now.getDate() + 1);
620
+ now.setHours(1, 0, 0, 1);
621
+ }
622
+ const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
623
+ const lastHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0);
624
+ this.adapter.log.debug(`### Hourly execution triggered with lastHour: ${lastHour.toLocaleTimeString()} ###`);
625
+ if (this._calculateGeneric('statistics.jsonHourly', startOfDay, lastHour)) {
626
+ this._buildFlexchart('hourly');
627
+ }
628
+ }
629
+
630
+ _calculateDaily() {
631
+ this.adapter.log.debug('### Daily execution triggered ###');
632
+ this._calculateAggregation(
633
+ 'statistics.jsonHourly',
634
+ 'statistics.jsonDaily',
635
+ now => {
636
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
637
+ const yesterday = new Date(today);
638
+ yesterday.setDate(today.getDate() - 1);
639
+ return { from: yesterday, to: today };
640
+ },
641
+ 'daily',
642
+ );
643
+ this._calculateAggregation(
644
+ 'statistics.jsonHourly',
645
+ 'statistics.jsonDaily',
646
+ now => {
647
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
648
+ const tomorrow = new Date(today);
649
+ tomorrow.setDate(today.getDate() + 1);
650
+ return { from: today, to: tomorrow };
651
+ },
652
+ 'daily-live',
653
+ );
654
+ this._buildFlexchart('daily');
655
+ }
656
+
657
+ _calculateWeekly() {
658
+ this.adapter.log.debug('### Weekly execution triggered ###');
659
+ this._calculateAggregation(
660
+ 'statistics.jsonDaily',
661
+ 'statistics.jsonWeekly',
662
+ now => {
663
+ const mon = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
664
+ mon.setDate(now.getDate() - (now.getDay() || 7) + 1);
665
+ const prev = new Date(mon);
666
+ prev.setDate(mon.getDate() - 7);
667
+ return { from: prev, to: mon };
668
+ },
669
+ 'weekly',
670
+ );
671
+ this._calculateAggregation(
672
+ 'statistics.jsonDaily',
673
+ 'statistics.jsonWeekly',
674
+ now => {
675
+ const mon = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
676
+ mon.setDate(now.getDate() - (now.getDay() || 7) + 1);
677
+ const next = new Date(mon);
678
+ next.setDate(mon.getDate() + 7);
679
+ return { from: mon, to: next };
680
+ },
681
+ 'weekly-live',
682
+ );
683
+ this._buildFlexchart('weekly');
684
+ }
685
+
686
+ _calculateMonthly() {
687
+ this.adapter.log.debug('### Monthly execution triggered ###');
688
+ this._calculateAggregation(
689
+ 'statistics.jsonDaily',
690
+ 'statistics.jsonMonthly',
691
+ now => ({ from: new Date(now.getFullYear(), now.getMonth() - 1, 1), to: new Date(now.getFullYear(), now.getMonth(), 1) }),
692
+ 'monthly',
693
+ );
694
+ this._calculateAggregation(
695
+ 'statistics.jsonDaily',
696
+ 'statistics.jsonMonthly',
697
+ now => ({ from: new Date(now.getFullYear(), now.getMonth(), 1), to: new Date(now.getFullYear(), now.getMonth() + 1, 1) }),
698
+ 'monthly-live',
699
+ );
700
+ this._buildFlexchart('monthly');
701
+ }
702
+
703
+ _calculateAnnual() {
704
+ this.adapter.log.debug('### Annual execution triggered ###');
705
+ this._calculateAggregation(
706
+ 'statistics.jsonDaily',
707
+ 'statistics.jsonAnnual',
708
+ now => ({ from: new Date(now.getFullYear() - 1, 0, 1), to: new Date(now.getFullYear(), 0, 1) }),
709
+ 'annual',
710
+ );
711
+ this._calculateAggregation(
712
+ 'statistics.jsonDaily',
713
+ 'statistics.jsonAnnual',
714
+ now => ({ from: new Date(now.getFullYear(), 0, 1), to: new Date(now.getFullYear() + 1, 0, 1) }),
715
+ 'annual-live',
716
+ );
717
+ this._buildFlexchart('annual');
718
+ }
719
+
720
+ // [LIVE] Minute-aligned timer, fires every LIVE_INTERVAL_MINUTES
721
+ async _initializeLiveTimer() {
722
+ const scheduleNext = () => {
723
+ const now = new Date();
724
+ const intervalMs = this.adapter.settings.statistics.liveInterval * 60 * 1000;
725
+ // Next boundary strictly in the future
726
+ const nextBoundary = new Date(Math.floor(now.getTime() / intervalMs) * intervalMs + intervalMs + 50);
727
+ const ms = nextBoundary.getTime() - now.getTime();
728
+
729
+ if (this._liveTimer) this.adapter.clearTimeout(this._liveTimer);
730
+ this._liveTimer = this.adapter.setTimeout(async () => {
731
+ await this._breakdown.refreshValues();
732
+ this._calculateLive();
733
+ scheduleNext();
734
+ }, ms);
735
+
736
+ this.adapter.logger.debug(
737
+ `statistics: live timer set, interval=${this.adapter.settings.statistics.liveInterval} min, next run in ${Math.round(ms / 1000)} s`,
738
+ );
739
+ };
740
+ //TEST ??
741
+ /*
742
+ await this._breakdown.refreshValues();
743
+ this._calculateLive();
744
+ */
745
+ scheduleNext();
746
+ }
747
+
748
+ // -------------------------------------------------------------------------
749
+ // Scheduler
750
+ // -------------------------------------------------------------------------
751
+ _initializeTask() {
752
+ const scheduleNextRun = () => {
753
+ const now = new Date();
754
+ const next = new Date(now);
755
+ if (this.testing) {
756
+ next.setMinutes(now.getMinutes() + 1, 0, 0);
757
+ } else {
758
+ next.setHours(next.getHours() + 1, 0, 0, 100);
759
+ }
760
+ if (next.getHours() === 0 && next.getMinutes() === 0) next.setHours(1, 0, 0, 0);
761
+
762
+ const ms = next.getTime() - now.getTime();
763
+ this.adapter.logger.debug(`### Statistics - Scheduler start ${now.toLocaleTimeString()} next ${next.toLocaleTimeString()}`);
764
+ if (this.taskTimer) this.adapter.clearTimeout(this.taskTimer);
765
+
766
+ this.taskTimer = this.adapter.setTimeout(async () => {
767
+ await this._executeScheduledTasks();
768
+ scheduleNextRun();
769
+ }, ms);
770
+ };
771
+ scheduleNextRun();
772
+ }
773
+
774
+ /**
775
+ * Refreshes breakdown foreign-state values, then runs all calculations.
776
+ */
777
+ async _executeScheduledTasks() {
778
+ await this._breakdown.refreshValues();
779
+ this._calculateHourly();
780
+ this._calculateDaily();
781
+ this._calculateWeekly();
782
+ this._calculateMonthly();
783
+ this._calculateAnnual();
784
+ }
785
+
786
+ async mitNightProcess() {
787
+ const now = new Date();
788
+ await this._executeScheduledTasks();
789
+ const startOfYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
790
+ this._clearGeneric('statistics.jsonDaily', startOfYear);
791
+ this._clearGeneric('statistics.jsonWeekly', startOfYear);
792
+ this._clearGeneric('statistics.jsonMonthly', startOfYear);
793
+ this._clearGeneric('statistics.jsonHourly', new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0));
794
+ // [LIVE] prune to last 48 h (timer also prunes, but be explicit at midnight)
795
+ this._clearGeneric('statistics.jsonLive', new Date(now.getTime() - LIVE_RETENTION_HOURS * 3600 * 1000));
796
+ }
797
+
798
+ // -------------------------------------------------------------------------
799
+ // Initialization
800
+ // -------------------------------------------------------------------------
801
+
802
+ async initialize() {
803
+ // Restore persisted JSON states into the cache
804
+ for (const [stateId, defaultVal] of [
805
+ // [LIVE] added
806
+ ['statistics.jsonLive', '[]'],
807
+ ['statistics.jsonHourly', '[]'],
808
+ ['statistics.jsonDaily', '[]'],
809
+ ['statistics.jsonWeekly', '[]'],
810
+ ['statistics.jsonMonthly', '[]'],
811
+ ['statistics.jsonAnnual', '[]'],
812
+ ['statistics.jsonToday', '{}'],
813
+ ]) {
814
+ const state = await this.adapter.getStateAsync(stateId);
815
+ this.stateCache.set(stateId, state?.val ?? defaultVal, { type: 'string', stored: true });
816
+ }
817
+
818
+ // Load & validate breakdown config, then fetch current foreign-state values
819
+ await this._breakdown.initialize();
820
+ //await this._breakdown.refreshValues();
821
+
822
+ // Wait until a key collected state is available before starting calculations
823
+ try {
824
+ await tools.waitForValue(() => this.stateCache.get('collected.accumulatedEnergyYield')?.value, 5 * 60000);
825
+ } catch {
826
+ this.adapter.logger.warn(
827
+ "statistics: waited 5 min for 'collected.accumulatedEnergyYield' – computed statistics may be incomplete until that state is present.",
828
+ );
829
+ }
830
+
831
+ // Restore chart templates (CHART_TYPES now includes 'live')
832
+ for (const chartType of CHART_TYPES) {
833
+ const templateStateId = `statistics.flexCharts.template.${chartType}`;
834
+ const state = await this.adapter.getState(templateStateId);
835
+ this.stateCache.set(templateStateId, state?.val ?? '{}', { type: 'string', stored: true });
836
+ if (state?.ack === false) {
837
+ await this.adapter.setState(templateStateId, { val: state.val, ack: true });
838
+ //this._buildFlexchart(chartType);
839
+ }
840
+ this._buildFlexchart(chartType);
841
+ }
842
+
843
+ await this.mitNightProcess();
844
+
845
+ // [LIVE] start the minute-aligned live timer
846
+ await this._initializeLiveTimer();
847
+ this._initializeTask();
848
+
849
+ this.adapter.subscribeStates(`${this._path}.flexCharts.template*`);
850
+ this.adapter.subscribeStates(`${this._path}.dataDef*`);
851
+
852
+ this._initialized = true;
853
+ }
854
+
855
+ // -------------------------------------------------------------------------
856
+ // Chart building
857
+ // -------------------------------------------------------------------------
858
+
859
+ /**
860
+ * Builds and writes the eCharts configuration for the given chart type.
861
+ *
862
+ * Breakdown series are built by breakdownChartHelper.buildBreakdownSeries()
863
+ * and injected into the lower consumption grid (xAxisIndex 1 / yAxisIndex 2).
864
+ * Legend and placeholder replacement are also delegated to breakdownChartHelper.
865
+ *
866
+ * @param {string} myChart - 'hourly' | 'daily' | 'weekly' | 'monthly' | 'annual'
867
+ * @param {string} [chartStyle] - 'line' | 'bar' (default: 'line' for hourly, 'bar' for others)
868
+ * @returns {string} Stringified chart configuration
869
+ */
870
+ _buildFlexchart(myChart, chartStyle) {
871
+ chartStyle = chartStyle || (myChart === 'hourly' || myChart === 'live' ? 'line' : 'bar');
872
+
873
+ const id = CHART_STATE_IDS[myChart] ?? CHART_STATE_IDS.hourly;
874
+ let data = [];
875
+ try {
876
+ data = JSON.parse(this.stateCache.get(id)?.value ?? '[]');
877
+ } catch {
878
+ data = [];
879
+ }
880
+
881
+ // --- X-Axis labels ---
882
+ const xAxisData = data.map(entry => {
883
+ const from = new Date(entry.from);
884
+ const to = new Date(entry.to);
885
+ if (myChart === 'live') {
886
+ return `${to.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })} ${to.toLocaleTimeString('de-DE', { hour12: false, hour: '2-digit', minute: '2-digit' })}`;
887
+ }
888
+ if (myChart === 'hourly') {
889
+ return `${to.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })} ${to.toLocaleTimeString('de-DE', { hour12: false, hour: '2-digit', minute: '2-digit' })}`;
890
+ }
891
+ if (myChart === 'weekly') {
892
+ const toDay = new Date(to);
893
+ if (toDay.getHours() === 0 && toDay.getMinutes() === 0) toDay.setDate(toDay.getDate() - 1);
894
+ return `${from.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}-${toDay.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}`;
895
+ }
896
+ if (myChart === 'monthly') return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit' });
897
+ if (myChart === 'annual') return from.toLocaleDateString('de-DE', { year: 'numeric' });
898
+ return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
899
+ });
900
+
901
+ const xAxisDataShort =
902
+ myChart === 'hourly' || myChart === 'live'
903
+ ? xAxisData.map(l => {
904
+ return l.split(' ')[1];
905
+ })
906
+ : xAxisData;
907
+
908
+ // --- Day shading areas (hourly only) ---
909
+ const dayAreas = [];
910
+ if ((myChart === 'hourly' || myChart === 'live') && xAxisData.length > 0) {
911
+ const bounds = [0];
912
+ xAxisData.forEach((lbl, i) => {
913
+ if (i > 0 && lbl.split(' ')[0] !== xAxisData[i - 1].split(' ')[0]) bounds.push(i);
914
+ });
915
+ bounds.push(xAxisData.length);
916
+ bounds.forEach((startIdx, d) => {
917
+ if (d >= bounds.length - 1) return;
918
+ const endIdx = bounds[d + 1];
919
+ const date = xAxisData[startIdx].split(' ')[0];
920
+ dayAreas.push([
921
+ {
922
+ xAxis: startIdx - 0.5,
923
+ label: {
924
+ show: true,
925
+ position: 'insideTop',
926
+ formatter: date,
927
+ color: '#555',
928
+ fontSize: 11,
929
+ fontWeight: 'bold',
930
+ backgroundColor: 'rgba(255,255,255,0.7)',
931
+ padding: [2, 4],
932
+ borderRadius: 3,
933
+ },
934
+ },
935
+ {
936
+ xAxis: endIdx - 0.5,
937
+ itemStyle:
938
+ d % 2 === 1
939
+ ? { color: 'rgba(180,180,180,0.15)', borderColor: 'rgba(120,120,120,0.3)', borderWidth: 1, borderType: 'dashed' }
940
+ : { color: 'rgba(255,255,255,0)' },
941
+ },
942
+ ]);
943
+ });
944
+ }
945
+
946
+ // --- Series data ---
947
+ const extract = key => data.map(e => Number(Number(e[key]?.value ?? 0).toFixed(3)));
948
+ const negate = arr => arr.map(v => Number((-v).toFixed(3)));
949
+ const seriesData = {};
950
+ for (const stat of this.stats) {
951
+ seriesData[stat.targetPath] = extract(stat.targetPath);
952
+ seriesData[`${stat.targetPath}Neg`] = negate(seriesData[stat.targetPath]);
953
+ }
954
+ // Populate breakdown keys so chart helper and placeholder replacement can find them
955
+ for (const bd of this._breakdown.entries) {
956
+ seriesData[bd.targetPath] = extract(bd.targetPath);
957
+ }
958
+
959
+ // --- Tooltip / axis formatters (serialised as functions via stringify) ---
960
+ const xAxisFormatter =
961
+ myChart === 'live'
962
+ ? (() => {
963
+ // IIFE executes once on initialization
964
+ const hourLabels = new Set();
965
+ const closestPerHour = new Map();
966
+ xAxisDataShort.forEach((label, i) => {
967
+ const to = new Date(data[i].to);
968
+ const fullHour = new Date(to.getFullYear(), to.getMonth(), to.getDate(), to.getHours(), 0, 0, 0);
969
+ const hourKey = fullHour.getTime();
970
+ const diffMs = Math.abs(to.getTime() - fullHour.getTime());
971
+ const current = closestPerHour.get(hourKey);
972
+ if (!current || diffMs < current.diffMs) {
973
+ closestPerHour.set(hourKey, { label, diffMs });
974
+ }
975
+ });
976
+ closestPerHour.forEach(({ label }) => hourLabels.add(label));
977
+ // Embed the set content directly into the function string
978
+ const hourLabelsArray = JSON.stringify([...hourLabels]);
979
+ return new Function(`return value => (new Set(${hourLabelsArray}).has(value) ? value : '')`)();
980
+ })()
981
+ : value => (value.includes('|') ? value : (value.split(' ')[1] ?? value));
982
+
983
+ // --- Tooltip formatter ---
984
+ const tooltipFormatter = params => {
985
+ if (!Array.isArray(params)) params = [params];
986
+ return params
987
+ .filter(p => p.seriesName !== 'DayBreak')
988
+ .map(p => {
989
+ const negatedSeries = ['Grid Export', 'Charge'];
990
+ const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
991
+ const unit = ['SOC', 'Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? ' %' : ' kWh';
992
+ const seriesName =
993
+ myChart === 'hourly' && ['Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? `${p.seriesName} today` : p.seriesName;
994
+ return `${p.marker}${seriesName}: <b>${val}${unit}</b>`;
995
+ })
996
+ .join('<br/>');
997
+ };
998
+
999
+ // --- Load / parse chart template ---
1000
+ const templateStateId = `statistics.flexCharts.template.${myChart}`;
1001
+ const outputStateId = `statistics.flexCharts.jsonOutput.${myChart}`;
1002
+ const templateStr = this.stateCache.get(templateStateId)?.value ?? '{}';
1003
+ let template = {};
1004
+ let command = '';
1005
+ try {
1006
+ const parsed = JSON.parse(templateStr);
1007
+ command = parsed.command || '';
1008
+ if (Object.keys(parsed).length === 0 || command === 'createTemplateFromBuiltin') {
1009
+ template = this._buildDefaultTemplate(myChart, chartStyle);
1010
+ } else {
1011
+ template = parsed;
1012
+ delete template._meta;
1013
+ }
1014
+ } catch (e) {
1015
+ this.adapter.logger.warn(`statistics: invalid template for ${myChart}: ${e.message}`);
1016
+ template = this._buildDefaultTemplate(myChart, chartStyle);
1017
+ }
1018
+
1019
+ // No-data hint — chart-type specific
1020
+ const noDataHints = {
1021
+ live: 'No data yet — first entry available after the next interval boundary.',
1022
+ hourly: 'No data yet — first entry available after the next full hour.',
1023
+ daily: 'No data yet — first entry available tomorrow after midnight.',
1024
+ weekly: 'No data yet — first entry available after the current week ends.',
1025
+ monthly: 'No data yet — first entry available after the current month ends.',
1026
+ annual: 'No data yet — first entry available after the current year ends.',
1027
+ };
1028
+
1029
+ // --- Assemble final chart object ---
1030
+ const chart = {
1031
+ ...template,
1032
+ /* now breakdown series are injected via chartHelper.buildBreakdownSeries() into the template's series array, so we don't need to merge them here
1033
+ series: [
1034
+ ...(template.series ?? []),
1035
+ // Hourly day-shading areas
1036
+ ...(dayAreas.length > 0
1037
+ ? [
1038
+ {
1039
+ name: 'DayBreak',
1040
+ type: 'bar',
1041
+ barWidth: 0,
1042
+ data: [],
1043
+ legendHoverLink: false,
1044
+ silent: true,
1045
+ markArea: { silent: true, data: dayAreas },
1046
+ },
1047
+ ]
1048
+ : []),
1049
+ ],
1050
+ */
1051
+ graphic:
1052
+ xAxisData.length === 0
1053
+ ? [
1054
+ {
1055
+ type: 'text',
1056
+ left: 'center',
1057
+ top: 'middle',
1058
+ style: { text: noDataHints[myChart] || 'No data available yet.', fontSize: 14, fill: '#999' },
1059
+ },
1060
+ ]
1061
+ : [],
1062
+ };
1063
+
1064
+ // Apply slider start/end defaults when the template has no explicit values
1065
+ const sliderDefaults = {
1066
+ live: Math.max(0, Math.round((1 - (25 * 12) / Math.max(xAxisData.length, 1)) * 100)),
1067
+ hourly: Math.max(0, Math.round((1 - 25 / Math.max(xAxisData.length, 1)) * 100)),
1068
+ daily: Math.max(0, Math.round((1 - 7 / Math.max(xAxisData.length, 1)) * 100)),
1069
+ weekly: Math.max(0, Math.round((1 - 8 / Math.max(xAxisData.length, 1)) * 100)),
1070
+ monthly: Math.max(0, Math.round((1 - 13 / Math.max(xAxisData.length, 1)) * 100)),
1071
+ annual: 0,
1072
+ };
1073
+ if (chart?.dataZoom?.[0] && !chart.dataZoom[0].start && !chart.dataZoom[0].end) {
1074
+ chart.dataZoom[0].start = sliderDefaults[myChart] ?? 0;
1075
+ chart.dataZoom[0].end = 100;
1076
+ }
1077
+
1078
+ // --- Serialise and replace placeholders ---
1079
+ let chartStr = stringify(reviveFunctions(chart));
1080
+
1081
+ // Built-in placeholders
1082
+ chartStr = chartStr
1083
+ .replace("'%%xAxisData%%'", JSON.stringify(xAxisData))
1084
+ .replace("'%%xAxisDataShort%%'", JSON.stringify(xAxisDataShort))
1085
+ .replaceAll("'%%xAxisMax%%'", String(xAxisData.length - 1))
1086
+ .replace("'%%chartTitle%%'", JSON.stringify(`PV Statistics — ${myChart}`))
1087
+ .replace("'%%dayAreas%%'", JSON.stringify(dayAreas))
1088
+ .replace("'%%xAxisFormatter%%'", stringify(xAxisFormatter))
1089
+ .replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
1090
+
1091
+ for (const stat of this.stats) {
1092
+ const key = stat.targetPath;
1093
+ chartStr = chartStr.replace(`'%%${key}%%'`, JSON.stringify(seriesData[key])).replace(`'%%${key}Neg%%'`, JSON.stringify(seriesData[`${key}Neg`]));
1094
+ }
1095
+
1096
+ // Breakdown placeholders — delegated to chart helper
1097
+ chartStr = chartHelper.replacePlaceholders(chartStr, this._breakdown.entries, seriesData);
1098
+
1099
+ this.stateCache.set(outputStateId, chartStr, { type: 'string', renew: command === 'createTemplateFromBuiltin' ? true : false });
1100
+ this.adapter.logger.debug(`statistics: flexCharts built for ${myChart}/${chartStyle}`);
1101
+
1102
+ if (command === 'createTemplateFromBuiltin') {
1103
+ template = { _meta: { generatedFrom: 'builtin', generatedAt: new Date().toISOString() }, ...template };
1104
+ this.stateCache.set(templateStateId, stringifyWithFunctions(template), { type: 'string' });
1105
+ this.adapter.logger.debug(`statistics: new template created for ${myChart}`);
1106
+ }
1107
+
1108
+ return chartStr;
1109
+ }
1110
+
1111
+ /**
1112
+ * Builds the default built-in eCharts template.
1113
+ * Breakdown series are NOT listed here — they are injected dynamically
1114
+ * in _buildFlexchart() via chartHelper.buildBreakdownSeries().
1115
+ * The legend does include breakdown names (via chartHelper.mergeBreakdownLegend).
1116
+ *
1117
+ * @param {string} myChart
1118
+ * @param {string} chartStyle
1119
+ * @returns {object}
1120
+ */
1121
+ _buildDefaultTemplate(myChart, chartStyle) {
1122
+ const seriesType = chartStyle === 'line' ? 'line' : 'bar';
1123
+ const lineOptions =
1124
+ chartStyle === 'line' ? { smooth: false, symbol: 'none', symbolSize: 4, lineStyle: { width: 2 }, areaStyle: { opacity: 0.15 } } : {};
1125
+ if (myChart === 'hourly' && chartStyle === 'line') {
1126
+ lineOptions.smooth = true;
1127
+ lineOptions.lineStyle.smooth = 0.3;
1128
+ lineOptions.symbol = 'circle';
1129
+ }
1130
+ const showBat = this._batteryExists();
1131
+ const showSOC = showBat && (myChart === 'hourly' || myChart === 'live');
1132
+ const unit = myChart === 'live' ? 'kW' : 'kWh';
1133
+
1134
+ const baseLegend = [
1135
+ 'Solar Yield',
1136
+ 'Grid Export',
1137
+ 'Grid Import',
1138
+ ...(showBat ? ['Charge', 'Discharge'] : []),
1139
+ ...(showSOC ? ['SOC'] : []),
1140
+ 'Self-sufficiency',
1141
+ 'Self-consumption',
1142
+ 'Consumption',
1143
+ ];
1144
+ const legendData = chartHelper.mergeBreakdownLegend(baseLegend, this._breakdown.entries);
1145
+
1146
+ const formatTooltipValue = (unit, negative = false, decimals = 2) => {
1147
+ //valueFormatter: `value => \`\${value} ${unit}\``,
1148
+ return negative ? `value => \`\${(-value).toFixed(${decimals})} ${unit}\`` : `value => \`\${value.toFixed(${decimals})} ${unit}\``;
1149
+ };
1150
+
1151
+ return {
1152
+ backgroundColor: '#fff',
1153
+ animation: false,
1154
+ title: { left: 'center', text: '%%chartTitle%%' },
1155
+ legend: { top: 35, left: 'center', data: legendData },
1156
+
1157
+ tooltip: {
1158
+ trigger: 'axis',
1159
+ axisPointer: { type: 'cross' },
1160
+ backgroundColor: 'rgba(245,245,245,0.95)',
1161
+ borderWidth: 1,
1162
+ borderColor: '#ccc',
1163
+ padding: 10,
1164
+ textStyle: { color: '#000' },
1165
+ //formatter: '%%tooltipFormatter%%',
1166
+ position: (pos, params, el, elRect, size) => {
1167
+ const obj = { top: 10 };
1168
+ obj[pos[0] < size.viewSize[0] / 2 ? 'left' : 'right'] = 30;
1169
+ return obj;
1170
+ },
1171
+ },
1172
+
1173
+ axisPointer: { link: [{ xAxisIndex: 'all' }], label: { backgroundColor: '#777' } },
1174
+ toolbox: {
1175
+ feature: {
1176
+ dataZoom: { yAxisIndex: false },
1177
+ dataView: { show: true, readOnly: false },
1178
+ restore: { show: true },
1179
+ saveAsImage: { show: true },
1180
+ },
1181
+ },
1182
+ grid: [
1183
+ { left: '8%', right: showSOC ? '8%' : '4%', top: 80, height: '45%' },
1184
+ { left: '8%', right: showSOC ? '8%' : '4%', top: '72%', height: '15%' },
1185
+ ],
1186
+ xAxis: [
1187
+ {
1188
+ type: 'category',
1189
+ data: '%%xAxisDataShort%%',
1190
+ scale: true,
1191
+ boundaryGap: chartStyle !== 'line',
1192
+ axisLine: { onZero: false },
1193
+ ...(myChart === 'live'
1194
+ ? {
1195
+ axisTick: {
1196
+ interval: (index, value) => value.endsWith(':00'),
1197
+ },
1198
+ }
1199
+ : {}),
1200
+
1201
+ splitLine: { show: false },
1202
+ axisPointer: { z: 100 },
1203
+ axisLabel: {
1204
+ interval: 0,
1205
+ lineHeight: 16,
1206
+ fontSize: 11,
1207
+ formatter: '%%xAxisFormatter%%',
1208
+ },
1209
+ min: 0,
1210
+ max: '%%xAxisMax%%',
1211
+ },
1212
+ {
1213
+ type: 'category',
1214
+ gridIndex: 1,
1215
+ data: '%%xAxisData%%',
1216
+ scale: true,
1217
+ boundaryGap: chartStyle !== 'line',
1218
+ axisLine: { onZero: false },
1219
+ axisTick: { show: false },
1220
+ splitLine: { show: false },
1221
+ axisLabel: { show: false },
1222
+ min: 0,
1223
+ max: '%%xAxisMax%%',
1224
+ },
1225
+ ],
1226
+ yAxis: [
1227
+ {
1228
+ scale: false,
1229
+ splitArea: { show: true },
1230
+ name: `Energy (${unit})`,
1231
+ nameLocation: 'middle',
1232
+ nameGap: 50,
1233
+ axisLabel: { formatter: `{value} ${unit}` },
1234
+ splitLine: { show: true },
1235
+ axisLine: { show: true },
1236
+ },
1237
+ {
1238
+ type: 'value',
1239
+ min: 0,
1240
+ max: 100,
1241
+ name: showSOC ? 'SOC / Ratio (%)' : 'Ratio (%)',
1242
+ nameLocation: 'middle',
1243
+ nameGap: 50,
1244
+ axisLabel: { formatter: '{value} %' },
1245
+ splitLine: { show: false },
1246
+ axisLine: { show: true },
1247
+ },
1248
+ {
1249
+ scale: true,
1250
+ gridIndex: 1,
1251
+ splitNumber: 3,
1252
+ axisLine: { show: false },
1253
+ axisTick: { show: false },
1254
+ splitLine: { show: false },
1255
+ name: `Consumption\n(${unit})`,
1256
+ nameLocation: 'middle',
1257
+ nameGap: 50,
1258
+ axisLabel: { formatter: `{value} ${unit}` },
1259
+ },
1260
+ ],
1261
+ dataZoom: [
1262
+ { type: 'inside', xAxisIndex: [0, 1] },
1263
+ { show: true, xAxisIndex: [0, 1], type: 'slider', bottom: 5 },
1264
+ ],
1265
+ series: [
1266
+ {
1267
+ name: 'Solar Yield',
1268
+ type: seriesType,
1269
+ unit: unit,
1270
+ invertSign: false,
1271
+ data: '%%solarYield%%',
1272
+ itemStyle: { color: '#f6c94e' },
1273
+ emphasis: { focus: 'series' },
1274
+ tooltip: {
1275
+ valueFormatter: formatTooltipValue(unit),
1276
+ },
1277
+ ...lineOptions,
1278
+ },
1279
+ {
1280
+ name: 'Grid Export',
1281
+ type: seriesType,
1282
+ data: '%%gridExportNeg%%',
1283
+ itemStyle: { color: '#5cb85c' },
1284
+ emphasis: { focus: 'series' },
1285
+ tooltip: { valueFormatter: formatTooltipValue(unit, true) },
1286
+ ...lineOptions,
1287
+ },
1288
+ {
1289
+ name: 'Grid Import',
1290
+ type: seriesType,
1291
+ data: '%%gridImport%%',
1292
+ itemStyle: { color: '#ec0000' },
1293
+ emphasis: { focus: 'series' },
1294
+ tooltip: { valueFormatter: formatTooltipValue(unit) },
1295
+ ...lineOptions,
1296
+ },
1297
+ ...(showBat
1298
+ ? [
1299
+ {
1300
+ name: 'Charge',
1301
+ type: seriesType,
1302
+ data: '%%chargeCapacityNeg%%',
1303
+ itemStyle: { color: '#5bc0de' },
1304
+ emphasis: { focus: 'series' },
1305
+ tooltip: { valueFormatter: formatTooltipValue(unit, true) },
1306
+ ...lineOptions,
1307
+ },
1308
+ {
1309
+ name: 'Discharge',
1310
+ type: seriesType,
1311
+ data: '%%dischargeCapacity%%',
1312
+ itemStyle: { color: '#ed50e0' },
1313
+ emphasis: { focus: 'series' },
1314
+ tooltip: { valueFormatter: formatTooltipValue(unit) },
1315
+ ...lineOptions,
1316
+ },
1317
+ ]
1318
+ : []),
1319
+ ...(showSOC
1320
+ ? [
1321
+ {
1322
+ name: 'SOC',
1323
+ type: 'line',
1324
+ yAxisIndex: 1,
1325
+ data: '%%SOC%%',
1326
+ itemStyle: { color: '#985e24' },
1327
+ lineStyle: { width: 2, type: 'dashed' },
1328
+ symbol: 'none',
1329
+ smooth: true,
1330
+ tooltip: { valueFormatter: formatTooltipValue('%', false, 0) },
1331
+ },
1332
+ ]
1333
+ : []),
1334
+ {
1335
+ name: 'Self-sufficiency',
1336
+ type: 'line',
1337
+ yAxisIndex: 1,
1338
+ data: '%%selfSufficiency%%',
1339
+ itemStyle: { color: '#9c27b0' },
1340
+ lineStyle: { width: 2, type: 'dashed' },
1341
+ symbol: 'none', //'circle'
1342
+ symbolSize: 4,
1343
+ smooth: true,
1344
+ tooltip: { valueFormatter: formatTooltipValue('%', false, 0) },
1345
+ },
1346
+ {
1347
+ name: 'Self-consumption',
1348
+ type: 'line',
1349
+ yAxisIndex: 1,
1350
+ data: '%%selfConsumption%%',
1351
+ itemStyle: { color: '#ff9800' },
1352
+ lineStyle: { width: 2, type: 'dashed' },
1353
+ symbol: 'none', //'circle'
1354
+ symbolSize: 4,
1355
+ smooth: true,
1356
+ tooltip: { valueFormatter: formatTooltipValue('%', false, 0) },
1357
+ },
1358
+ ...chartHelper.buildBreakdownSeries(this._breakdown.entries, seriesType, lineOptions, formatTooltipValue(unit)),
1359
+
1360
+ {
1361
+ name: 'Consumption',
1362
+ type: seriesType,
1363
+ stack: 'consumptionBreakdown',
1364
+ xAxisIndex: 1,
1365
+ yAxisIndex: 2,
1366
+ data: '%%consumption%%',
1367
+ itemStyle: { color: '#337ab7' },
1368
+ tooltip: { valueFormatter: formatTooltipValue(unit) },
1369
+ ...lineOptions,
1370
+ },
1371
+ ...(myChart === 'hourly' || myChart === 'live'
1372
+ ? [
1373
+ {
1374
+ name: 'DayBreak',
1375
+ type: 'bar',
1376
+ barWidth: 0,
1377
+ data: [],
1378
+ legendHoverLink: false,
1379
+ silent: true,
1380
+ markArea: { silent: true, data: '%%dayAreas%%' },
1381
+ },
1382
+ ]
1383
+ : []),
1384
+ ],
1385
+ };
1386
+ }
1387
+
1388
+ _batteryExists() {
1389
+ return this.stateCache.get(`collected.ratedCapacity`)?.value > 0;
1390
+ }
1391
+
1392
+ // -------------------------------------------------------------------------
1393
+ // External event handlers (called from adapter onStateChange)
1394
+ // -------------------------------------------------------------------------
1395
+
1396
+ /**
1397
+ * Handles a user edit of a chart template state.
1398
+ *
1399
+ * @param {string} chartType
1400
+ * @param {object} state
1401
+ */
1402
+ async handleTemplateChange(chartType, state) {
1403
+ const templateStateId = `statistics.flexCharts.template.${chartType}`;
1404
+ if (this.stateCache.get(templateStateId) === undefined) {
1405
+ this.adapter.logger.warn(`Template state ${templateStateId} not found for handleTemplateChange`);
1406
+ return;
1407
+ }
1408
+ if (state?.val != null) {
1409
+ this.adapter.logger.debug(`statistics: template state ${chartType} changed (ack: ${state.ack})`);
1410
+ this.stateCache.set(templateStateId, state.val, { type: 'string', stored: true });
1411
+ this._buildFlexchart(chartType);
1412
+ await this.adapter.setState(templateStateId, { val: this.stateCache.get(templateStateId)?.value, ack: true });
1413
+ }
1414
+ }
1415
+
1416
+ /**
1417
+ * Handles a user edit of the consumptionBreakdown config state.
1418
+ * Delegates all validation and foreign-state refresh to ConsumptionBreakdown,
1419
+ * then rebuilds all charts.
1420
+ *
1421
+ * Wire up in adapter onStateChange:
1422
+ *
1423
+ * if (idArray[2] == 'statistics' && idArray[3] == 'consumptionBreakdown') {
1424
+ * if (this.state.statistics && typeof this.state.statistics.handleBreakdownChange === 'function') {
1425
+ * this.state.statistics.handleBreakdownChange(state);
1426
+ * }
1427
+ * }
1428
+ * }
1429
+ *
1430
+ * @param {object} state - ioBroker state object (val = new JSON string)
1431
+ * @returns {Promise<void>}
1432
+ */
1433
+ async handleBreakdownChange(state) {
1434
+ const changed = await this._breakdown.handleStateChange(state);
1435
+ if (!changed) return;
1436
+ for (const chartType of CHART_TYPES) {
1437
+ this._buildFlexchart(chartType);
1438
+ }
1439
+ }
1440
+
1441
+ /**
1442
+ * Handles flexcharts rebuild messages from VIS widgets or adapter messages.
1443
+ *
1444
+ * @param {{ chart?: string, style?: string }} message
1445
+ * @param {callback} callback
1446
+ */
1447
+ handleFlexMessage(message, callback) {
1448
+ const result = this._buildFlexchart(message?.chart || 'hourly', message?.style);
1449
+ if (typeof callback === 'function') callback(result);
1450
+ }
1451
+
1452
+ destroy() {
1453
+ if (this.taskTimer) this.adapter.clearTimeout(this.taskTimer);
1454
+ if (this._liveTimer) this.adapter.clearTimeout(this._liveTimer);
1455
+ }
1456
+ }
1457
+
1458
+ module.exports = statistics;