iobroker.sun2000 2.4.2 → 2.4.4

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/lib/statistics.js CHANGED
@@ -25,6 +25,7 @@ class statistics {
25
25
  this.stateCache = stateCache;
26
26
  this.taskTimer = null;
27
27
  this._path = 'statistics';
28
+ this._initialized = false;
28
29
  this.testing = false; // set to true for testing purposes
29
30
 
30
31
  this.stats = [
@@ -32,7 +33,7 @@ class statistics {
32
33
  sourceId: 'collected.consumptionToday',
33
34
  targetPath: 'consumption',
34
35
  unit: 'kWh',
35
- type: statisticsType.deltaReset, // value is a total that resets at the start of the period, so we need to calculate the delta to get the actual consumption for the period
36
+ type: statisticsType.deltaReset,
36
37
  },
37
38
  {
38
39
  sourceId: 'collected.dailySolarYield',
@@ -41,23 +42,50 @@ class statistics {
41
42
  type: statisticsType.deltaReset,
42
43
  },
43
44
  { sourceId: 'collected.dailyInputYield', targetPath: 'inputYield', unit: 'kWh', type: statisticsType.deltaReset },
44
- {
45
- sourceId: 'collected.dailyExternalYield',
46
- targetPath: 'externalYield',
47
- unit: 'kWh',
48
- type: statisticsType.deltaReset,
49
- },
45
+ { sourceId: 'collected.dailyExternalYield', targetPath: 'externalYield', unit: 'kWh', type: statisticsType.deltaReset },
50
46
  { sourceId: 'collected.dailyEnergyYield', targetPath: 'energyYield', unit: 'kWh', type: statisticsType.deltaReset },
51
47
  {
52
48
  sourceId: 'collected.SOC',
53
49
  targetPath: 'SOC',
54
50
  unit: '%',
55
- type: statisticsType.level, // value is a level that can go up and down, so we take the value as is without calculating delta
51
+ type: statisticsType.level,
56
52
  },
57
53
  { sourceId: 'collected.currentDayChargeCapacity', targetPath: 'chargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
58
54
  { sourceId: 'collected.currentDayDischargeCapacity', targetPath: 'dischargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
59
55
  { sourceId: 'collected.gridExportToday', targetPath: 'gridExport', unit: 'kWh', type: statisticsType.deltaReset },
60
56
  { sourceId: 'collected.gridImportToday', targetPath: 'gridImport', unit: 'kWh', type: statisticsType.deltaReset },
57
+ // --- Computed stats ---
58
+ {
59
+ targetPath: 'selfSufficiency',
60
+ unit: '%',
61
+ type: statisticsType.computed,
62
+ // unten im FusionSolar Fenster
63
+ // selfSufficiency = (consumption - gridImport)/consumption * 100
64
+ // selfSufficiency = (1 - gridImport / consumption) * 100
65
+ // If consumption = 0 → 100% (no consumption, fully self-sufficient)
66
+ compute: entry => {
67
+ const consumption = (entry.consumption?.total != null ? entry.consumption?.total : entry.consumption?.value) ?? 0;
68
+ const gridImport = (entry.gridImport?.total != null ? entry.gridImport?.total : entry.gridImport?.value) ?? 0;
69
+ if (consumption <= 0) return 100;
70
+ return Math.round(Math.max(0, Math.min(100, (1 - gridImport / consumption) * 100)) * 10) / 10;
71
+ },
72
+ },
73
+ {
74
+ targetPath: 'selfConsumption',
75
+ unit: '%',
76
+ type: statisticsType.computed,
77
+ // oben im FusionSolar Fenster
78
+ // selfConsumption = (solarYield - gridExport) / solarYield * 100
79
+ // selfConsumption = (1 - gridExport / solarYield) * 100
80
+ // If solarYield = 0 → 0% (no generation, no self-consumption possible)
81
+ compute: entry => {
82
+ let solarYield = (entry.solarYield?.total != null ? entry.solarYield?.total : entry.solarYield?.value) ?? 0;
83
+ solarYield += (entry.externalYield?.total != null ? entry.externalYield?.total : entry.externalYield?.value) ?? 0;
84
+ const gridExport = (entry.gridExport?.total != null ? entry.gridExport?.total : entry.gridExport?.value) ?? 0;
85
+ if (solarYield <= 0) return 0;
86
+ return Math.round(Math.max(0, Math.min(100, (1 - gridExport / solarYield) * 100)) * 10) / 10;
87
+ },
88
+ },
61
89
  ];
62
90
 
63
91
  this.postProcessHooks = [
@@ -104,8 +132,16 @@ class statistics {
104
132
  desc: 'Annual consumption per year',
105
133
  initVal: '[]',
106
134
  },
107
- // a state where users may store a Flexcharts/eCharts options template
108
- // --- Templates: eines pro Chart-Typ ---
135
+ // Today summary state
136
+ {
137
+ id: 'statistics.jsonToday',
138
+ name: 'Today summary',
139
+ type: 'string',
140
+ role: 'json',
141
+ desc: "Live summary of today's energy values",
142
+ initVal: '{}',
143
+ },
144
+ // Templates: one per chart type
109
145
  {
110
146
  id: 'statistics.flexCharts.template.hourly',
111
147
  name: 'Flexcharts template hourly',
@@ -151,7 +187,7 @@ class statistics {
151
187
  write: true,
152
188
  initVal: '{}',
153
189
  },
154
- // --- Output: eines pro Chart-Typ ---
190
+ // Output: one per chart type
155
191
  {
156
192
  id: 'statistics.flexCharts.jsonOutput.hourly',
157
193
  name: 'Flexcharts output hourly',
@@ -228,12 +264,10 @@ class statistics {
228
264
  * Generic function to calculate consumption statistics for different time periods.
229
265
  *
230
266
  * @param {string} stateId - The state ID for storing the JSON
231
- * @param {string} consumptionKey - The state key for consumption value
232
267
  * @param {Date} periodStart - The start of the current period
233
- * @param {string} periodType - The type of period (hourly, daily, weekly, monthly, annual)
234
- * @returns {Promise<void>}
268
+ * @param {Date} periodEnde - The end of the current period
269
+ * @returns {boolean} true if a new entry was appended, false otherwise.
235
270
  */
236
-
237
271
  _calculateGeneric(stateId, periodStart, periodEnde) {
238
272
  const toStr = this._localIsoWithOffset(periodEnde);
239
273
  let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
@@ -248,7 +282,6 @@ class statistics {
248
282
  let last = {};
249
283
  if (arr.length > 0) {
250
284
  last = arr[arr.length - 1];
251
- // avoid duplicates
252
285
  if (last.to === toStr) return false;
253
286
  const lastToDate = new Date(last.to);
254
287
  const toDate = new Date(toStr);
@@ -262,7 +295,10 @@ class statistics {
262
295
  to: toStr,
263
296
  };
264
297
 
298
+ // First pass: deltaReset, delta, level stats
265
299
  for (const stat of this.stats) {
300
+ if (stat.type === statisticsType.computed) continue;
301
+
266
302
  const source = this.stateCache.get(stat.sourceId)?.value;
267
303
  if (source === null || source === undefined) {
268
304
  this.adapter.logger.warn(`Source state ${stat.sourceId} not found statistic hook`);
@@ -272,42 +308,54 @@ class statistics {
272
308
  if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
273
309
  const lastTotal = Number(last[stat.targetPath]?.['total'] ?? 0);
274
310
  if (stat.type === statisticsType.deltaReset) {
275
- //if (value >= lastTotal * 0.5) {
276
311
  if (fromDate.getTime() !== periodStart.getTime()) {
277
- // Delta-Berechnung
278
312
  value -= lastTotal;
279
313
  }
280
314
  } else {
281
- // Ein lastTotal-Wert vorhanden –> normale Delta-Berechnung
282
315
  if (last[stat.targetPath]?.['total'] === undefined) {
283
- // Kein lastTotal-Wert vorhanden –> wahrscheinlich erster Eintrag, Delta-Berechnung nicht möglich
284
316
  this.adapter.logger.debug(`No total value found for ${stat.targetPath} in last entry, setting delta to 0`);
285
317
  value = 0;
286
318
  } else {
287
- // Delta-Berechnung
288
319
  value -= lastTotal;
289
320
  }
290
321
  }
291
322
  }
292
323
  value = Math.round((Number(value) + Number.EPSILON) * 1000) / 1000;
293
- entry[stat.targetPath] = {
294
- value: Number(value.toFixed(3)),
295
- };
296
-
324
+ entry[stat.targetPath] = { value: Number(value.toFixed(3)) };
297
325
  if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
298
326
  entry[stat.targetPath].total = Number(source.toFixed(3));
299
327
  }
300
- entry[stat.targetPath].unit = stat.unit || 'kWh'; // can be extended for other stats with different units
328
+ entry[stat.targetPath].unit = stat.unit || 'kWh';
301
329
  }
302
- arr.push(entry);
303
330
 
304
- arr.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
331
+ // Second pass: computed stats (all other values are now in entry)
332
+ for (const stat of this.stats) {
333
+ if (stat.type !== statisticsType.computed) continue;
334
+ try {
335
+ const value = stat.compute(entry);
336
+ entry[stat.targetPath] = {
337
+ value: Number(Number(value).toFixed(3)),
338
+ unit: stat.unit || '%',
339
+ };
340
+ } catch (e) {
341
+ this.adapter.logger.warn(`statistics: error computing ${stat.targetPath}: ${e.message}`);
342
+ entry[stat.targetPath] = { value: 0, unit: stat.unit || '%' };
343
+ }
344
+ }
305
345
 
346
+ arr.push(entry);
347
+ arr.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
306
348
  this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
307
349
  this.adapter.logger.debug(`Appended ${stateId} statistic ${toStr}`);
308
350
  return arr.length > 0;
309
351
  }
310
352
 
353
+ /**
354
+ * Removes entries older than periodStart from the given state.
355
+ *
356
+ * @param {string} stateId
357
+ * @param {Date} periodStart
358
+ */
311
359
  _clearGeneric(stateId, periodStart) {
312
360
  let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
313
361
  let arr = [];
@@ -317,29 +365,163 @@ class statistics {
317
365
  } catch {
318
366
  arr = [];
319
367
  }
320
-
321
- // Keep only entries within the window
322
368
  arr = arr.filter(item => {
323
369
  const ts = Date.parse(item.from);
324
370
  return !Number.isNaN(ts) && ts >= periodStart.getTime();
325
371
  });
326
-
327
372
  this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
328
373
  }
329
374
 
375
+ // eslint-disable-next-line jsdoc/require-returns-check
330
376
  /**
331
- * Calculates and aggregates consumption statistics based on the given parameters.
332
- *
333
- * This function calculates and aggregates the consumption statistics based on the source entries within a specific window.
334
- * It retrieves the source entries, filters them based on the window, calculates the sum of consumption, and appends the result to the target array.
377
+ * Calculates and aggregates statistics for a given time window.
378
+ * If the window end is in the future (current period), the last entry
379
+ * is updated in place on every call effectively a live running total.
380
+ * If the window end is in the past (completed period), a new entry is
381
+ * appended only once and never touched again.
335
382
  *
336
- * @param {string} sourceStateId - The ID of the source state to retrieve entries from.
337
- * @param {string} targetStateId - The ID of the target state to append the aggregated result.
338
- * @param {Function} getWindow - A function that returns the start and end date of the window based on the current date.
339
- * @param {string} periodType - The type of period for which the aggregation is performed.
340
- * @returns {void}
383
+ * @param {string} sourceStateId
384
+ * @param {string} targetStateId
385
+ * @param {Function} getWindow - returns { from: Date, to: Date }
386
+ * where to is the natural end of the period (e.g. tomorrow 0:00)
387
+ * @param {string} periodType
388
+ * @returns {boolean} true if a new entry was appended, false otherwise
341
389
  */
342
390
  _calculateAggregation(sourceStateId, targetStateId, getWindow, periodType) {
391
+ try {
392
+ const now = new Date();
393
+ const window = getWindow(now);
394
+ const fromDate = window.from;
395
+ const toDate = window.to;
396
+
397
+ // Effective end: either the natural period end (past) or now (running)
398
+ const isRunning = now < toDate;
399
+ const effectiveTo = isRunning ? now : toDate;
400
+ const toStr = this._localIsoWithOffset(effectiveTo);
401
+
402
+ let jsonTarget = this.stateCache.get(targetStateId)?.value ?? '[]';
403
+ let targetArray = [];
404
+ try {
405
+ targetArray = JSON.parse(jsonTarget);
406
+ if (!Array.isArray(targetArray)) targetArray = [];
407
+ } catch {
408
+ targetArray = [];
409
+ }
410
+
411
+ // Find existing entry for this window (matched by from date)
412
+ const fromStr = this._localIsoWithOffset(fromDate);
413
+ const existingIdx = targetArray.findLastIndex(e => (isRunning ? e._live === true : e.from === fromStr));
414
+ //const existing = existingIdx >= 0 ? targetArray[existingIdx] : null;
415
+
416
+ // For completed periods: skip if already finalized (to matches natural end)
417
+ if (!isRunning && existingIdx >= 0) {
418
+ //const naturalToStr = this._localIsoWithOffset(toDate);
419
+ if (targetArray[existingIdx] === this._localIsoWithOffset(toDate)) {
420
+ this.adapter.logger.debug(`statistics.js: ${periodType} entry already finalized, skipping`);
421
+ return false;
422
+ }
423
+ }
424
+
425
+ const target = {
426
+ from: fromStr,
427
+ to: toStr,
428
+ };
429
+
430
+ if (isRunning) {
431
+ target._live = true;
432
+ }
433
+
434
+ // Load source entries within the window
435
+ let jsonStr = this.stateCache.get(sourceStateId)?.value ?? '[]';
436
+ let sourceEntries = [];
437
+ try {
438
+ sourceEntries = JSON.parse(jsonStr);
439
+ if (!Array.isArray(sourceEntries)) sourceEntries = [];
440
+ } catch {
441
+ sourceEntries = [];
442
+ }
443
+
444
+ sourceEntries = sourceEntries.filter(item => {
445
+ const ts = Date.parse(item.from);
446
+ //return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < effectiveTo.getTime(); //toDate ?
447
+ return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < toDate.getTime();
448
+ });
449
+
450
+ if (sourceEntries.length === 0) {
451
+ /*
452
+ this.adapter.logger.debug(
453
+ `statistics.js: No source entries for ${periodType} between ${fromDate.toISOString()} and ${effectiveTo.toISOString()}, skipping`,
454
+ );
455
+ */
456
+ this.adapter.logger.debug(
457
+ `statistics.js: No source entries for ${periodType} between ${fromDate.toISOString()} and ${toDate.toISOString()}, skipping`,
458
+ );
459
+ return false;
460
+ }
461
+
462
+ this.adapter.logger.debug(
463
+ `statistics.js: ${sourceEntries.length} source entries for ${periodType} between ${fromDate.toISOString()} and ${effectiveTo.toISOString()} (running=${isRunning})`,
464
+ );
465
+
466
+ // First pass: sum delta/deltaReset stats
467
+ for (const stat of this.stats) {
468
+ if (stat.type === statisticsType.level) continue;
469
+ if (stat.type === statisticsType.computed) continue;
470
+
471
+ let sum = 0;
472
+ try {
473
+ sourceEntries.forEach(entry => {
474
+ sum += Number(entry[stat.targetPath]?.['value'] ?? 0);
475
+ });
476
+ } catch (e) {
477
+ this.adapter.logger.warn(`statistics.js: Error during ${periodType} aggregation: ${e.message}`);
478
+ }
479
+ sum = Math.round((Number(sum) + Number.EPSILON) * 1000) / 1000;
480
+ target[stat.targetPath] = { value: Number(sum.toFixed(3)), unit: stat.unit || 'kWh' };
481
+ }
482
+
483
+ // Second pass: computed stats
484
+ for (const stat of this.stats) {
485
+ if (stat.type !== statisticsType.computed) continue;
486
+ try {
487
+ const value = stat.compute(target);
488
+ target[stat.targetPath] = {
489
+ value: Number(Number(value).toFixed(3)),
490
+ unit: stat.unit || '%',
491
+ };
492
+ } catch (e) {
493
+ this.adapter.logger.warn(`statistics: error computing ${stat.targetPath}: ${e.message}`);
494
+ target[stat.targetPath] = { value: 0, unit: stat.unit || '%' };
495
+ }
496
+ }
497
+
498
+ // Update in place or append
499
+ if (existingIdx >= 0) {
500
+ targetArray[existingIdx] = target;
501
+ this.adapter.logger.debug(`statistics.js: Updated ${periodType} entry (running=${isRunning})`);
502
+ } else {
503
+ targetArray.push(target);
504
+ this.adapter.logger.debug(`statistics.js: Appended ${periodType} entry (running=${isRunning})`);
505
+ }
506
+
507
+ targetArray.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
508
+ this.stateCache.set(targetStateId, JSON.stringify(targetArray), { type: 'string' });
509
+ return targetArray.length > 0;
510
+ } catch (err) {
511
+ this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
512
+ }
513
+ }
514
+ // eslint-disable-next-line jsdoc/require-returns-check
515
+ /**
516
+ * Calculates and aggregates consumption statistics based on the given parameters.
517
+ *
518
+ * @param {string} sourceStateId
519
+ * @param {string} targetStateId
520
+ * @param {Function} getWindow
521
+ * @param {string} periodType
522
+ * @returns {boolean} true if a new entry was appended, false otherwise.
523
+ */
524
+ _calculateAggregation_old(sourceStateId, targetStateId, getWindow, periodType) {
343
525
  try {
344
526
  const now = new Date();
345
527
  const window = getWindow(now);
@@ -350,8 +532,8 @@ class statistics {
350
532
  return false;
351
533
  }
352
534
  const toStr = this._localIsoWithOffset(toDate);
535
+ //const fromStr = this._localIsoWithOffset(fromDate);
353
536
 
354
- // Load target array
355
537
  let jsonTarget = this.stateCache.get(targetStateId)?.value ?? '[]';
356
538
  let targetArray = [];
357
539
  try {
@@ -361,8 +543,8 @@ class statistics {
361
543
  targetArray = [];
362
544
  }
363
545
 
364
- // Avoid duplicates
365
546
  const last = targetArray.length > 0 ? targetArray[targetArray.length - 1] : {};
547
+
366
548
  if (last.to === toStr) return false;
367
549
 
368
550
  const target = {
@@ -370,7 +552,6 @@ class statistics {
370
552
  to: toStr,
371
553
  };
372
554
 
373
- // Load source entries
374
555
  let jsonStr = this.stateCache.get(sourceStateId)?.value ?? '[]';
375
556
  let sourceEntries = [];
376
557
  try {
@@ -380,27 +561,22 @@ class statistics {
380
561
  sourceEntries = [];
381
562
  }
382
563
 
383
- // Keep only entries within the window
384
564
  sourceEntries = sourceEntries.filter(item => {
385
565
  const ts = Date.parse(item.from);
386
566
  return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < toDate.getTime();
387
567
  });
388
- // If there are no source entries, we can skip the aggregation and avoid creating empty entries in the target array
568
+
389
569
  if (sourceEntries.length > 0) {
390
570
  this.adapter.logger.debug(
391
571
  `statistics.js: Found ${sourceEntries.length} source entries for ${periodType} aggregation between ${fromDate.toISOString()} and ${toDate.toISOString()}`,
392
572
  );
393
573
 
574
+ // First pass: sum delta/deltaReset stats
394
575
  for (const stat of this.stats) {
395
- // Sum consumption for the window
396
- if (stat.type === statisticsType.level) continue; // Skip level statistics
576
+ if (stat.type === statisticsType.level) continue;
577
+ if (stat.type === statisticsType.computed) continue;
397
578
 
398
579
  let sum = 0;
399
- /*
400
- if (stat.type === statisticsType.average) {
401
- stat.sum = sourceEntries.length > 0 ? sourceEntries[sourceEntries.length - 1]?.[stat.targetPath]?.['total'] : 0;
402
- } else {
403
- */
404
580
  try {
405
581
  sourceEntries.forEach(entry => {
406
582
  sum += Number(entry[stat.targetPath]?.['value'] ?? 0);
@@ -408,13 +584,23 @@ class statistics {
408
584
  } catch (e) {
409
585
  this.adapter.logger.warn(`statistics.js: Error during ${periodType} statistic aggregation: ${e.message}`);
410
586
  }
411
-
412
587
  sum = Math.round((Number(sum) + Number.EPSILON) * 1000) / 1000;
588
+ target[stat.targetPath] = { value: Number(sum.toFixed(3)), unit: stat.unit || 'kWh' };
589
+ }
413
590
 
414
- target[stat.targetPath] = {
415
- value: Number(sum.toFixed(3)),
416
- unit: stat.unit || 'kWh',
417
- };
591
+ // Second pass: compute derived stats from aggregated values
592
+ for (const stat of this.stats) {
593
+ if (stat.type !== statisticsType.computed) continue;
594
+ try {
595
+ const value = stat.compute(target);
596
+ target[stat.targetPath] = {
597
+ value: Number(Number(value).toFixed(3)),
598
+ unit: stat.unit || '%',
599
+ };
600
+ } catch (e) {
601
+ this.adapter.logger.warn(`statistics: error computing aggregated ${stat.targetPath}: ${e.message}`);
602
+ target[stat.targetPath] = { value: 0, unit: stat.unit || '%' };
603
+ }
418
604
  }
419
605
 
420
606
  targetArray.push(target);
@@ -422,89 +608,165 @@ class statistics {
422
608
 
423
609
  targetArray.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
424
610
  this.stateCache.set(targetStateId, JSON.stringify(targetArray), { type: 'string' });
425
- this.adapter.logger.debug(`Appended ${periodType} statistic ${toStr} `);
611
+ this.adapter.logger.debug(`Appended ${periodType} statistic ${toStr}`);
426
612
  return targetArray.length > 0;
427
613
  } catch (err) {
428
614
  this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
429
615
  }
430
616
  }
431
617
 
618
+ /**
619
+ * Updates the statistics.jsonToday state with the current live day values.
620
+ * Reads directly from the stateCache (collected.*) and computes derived values.
621
+ */
622
+ updateJsonToday() {
623
+ if (!this._initialized) {
624
+ this.adapter.logger.debug('statistics: updateJsonToday called before initialization');
625
+ return;
626
+ }
627
+
628
+ try {
629
+ const now = new Date();
630
+ const today = {};
631
+
632
+ // Read all non-computed stats directly from stateCache
633
+ for (const stat of this.stats) {
634
+ if (stat.type === statisticsType.computed) continue;
635
+ if (stat.type === statisticsType.level) {
636
+ const val = this.stateCache.get(stat.sourceId)?.value;
637
+ today[stat.targetPath] = {
638
+ value: val != null ? Number(Number(val).toFixed(3)) : null,
639
+ unit: stat.unit,
640
+ };
641
+ } else {
642
+ // deltaReset: value is the current day total from the source state
643
+ const val = this.stateCache.get(stat.sourceId)?.value;
644
+ today[stat.targetPath] = {
645
+ value: val != null ? Number(Number(val).toFixed(3)) : null,
646
+ unit: stat.unit,
647
+ };
648
+ }
649
+ }
650
+
651
+ // Compute derived stats
652
+ for (const stat of this.stats) {
653
+ if (stat.type !== statisticsType.computed) continue;
654
+ try {
655
+ // Build a proxy entry using the raw today values
656
+ const proxyEntry = {};
657
+ for (const key of Object.keys(today)) {
658
+ proxyEntry[key] = today[key];
659
+ }
660
+ const value = stat.compute(proxyEntry);
661
+ today[stat.targetPath] = {
662
+ value: Number(Number(value).toFixed(3)),
663
+ unit: stat.unit || '%',
664
+ };
665
+ } catch (e) {
666
+ this.adapter.logger.warn(`statistics: error computing today.${stat.targetPath}: ${e.message}`);
667
+ today[stat.targetPath] = { value: 0, unit: stat.unit || '%' };
668
+ }
669
+ }
670
+
671
+ today.updatedAt = this._localIsoWithOffset(now);
672
+
673
+ const todayStr = JSON.stringify(today);
674
+ this.stateCache.set('statistics.jsonToday', todayStr, { type: 'string' });
675
+ this.adapter.logger.debug('statistics: jsonToday state updated');
676
+ } catch (err) {
677
+ this.adapter.logger.warn(`statistics: error updating today state: ${err.message}`);
678
+ }
679
+ }
680
+
432
681
  /**
433
682
  * Calculates and updates hourly consumption statistics.
434
- *
435
- * This function calculates the hourly consumption statistics based on the current day's data.
436
- * It retrieves the consumption data and updates the hourly consumption JSON accordingly.
437
- *
438
- * @returns {void}
439
683
  */
440
684
  _calculateHourly() {
441
685
  const now = new Date();
442
686
  if (this.testing) {
443
687
  const state = this.adapter.getState('statistics.jsonHourly');
444
688
  this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
445
- now.setDate(now.getDate() + 1); // set to start of day for testing to have consistent results
446
- now.setHours(1, 0, 0, 1); // set to 1ms after midnight to trigger hourly calculation for the new day
689
+ now.setDate(now.getDate() + 1);
690
+ now.setHours(1, 0, 0, 1);
447
691
  }
448
692
  const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
449
693
  const lastHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0);
450
- this.adapter.log.debug('### Hourly execution triggered ###');
694
+ this.adapter.log.debug(`### Hourly execution triggered with lastHour: ${lastHour.toLocaleTimeString()} ###`);
451
695
  if (this._calculateGeneric('statistics.jsonHourly', startOfDay, lastHour)) {
452
696
  this._buildFlexchart('hourly');
453
697
  }
454
698
  }
455
699
 
456
- /**
457
- * Calculates and updates daily consumption statistics from hourly data.
458
- *
459
- * @returns {void}
460
- */
461
700
  _calculateDaily() {
462
701
  this.adapter.log.debug('### Daily execution triggered ###');
702
+
703
+ // Abgeschlossener Vortag — normaler Eintrag
463
704
  this._calculateAggregation(
464
705
  'statistics.jsonHourly',
465
706
  'statistics.jsonDaily',
466
707
  now => {
467
- // aggregation window: previous day (day that just ended)
468
708
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
469
709
  const yesterday = new Date(today);
470
710
  yesterday.setDate(today.getDate() - 1);
471
711
  return { from: yesterday, to: today };
472
712
  },
473
713
  'daily',
474
- ) && this._buildFlexchart('daily'); // only update last execution time if aggregation was performed to avoid backfilling multiple days at startup
714
+ );
715
+ // Laufender heutiger Tag — live entry aus jsonHourly aggregieren
716
+ this._calculateAggregation(
717
+ 'statistics.jsonHourly',
718
+ 'statistics.jsonDaily',
719
+ now => {
720
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
721
+ const tomorrow = new Date(today);
722
+ tomorrow.setDate(today.getDate() + 1);
723
+ return { from: today, to: tomorrow };
724
+ },
725
+ 'daily-live',
726
+ );
727
+ this._buildFlexchart('daily');
475
728
  }
476
729
 
477
730
  /**
478
731
  * Calculates and updates weekly consumption statistics from daily data.
479
- *
480
- * @returns {void}
481
732
  */
482
733
  _calculateWeekly() {
483
734
  this.adapter.log.debug('### Weekly execution triggered ###');
484
- if (
485
- this._calculateAggregation(
486
- 'statistics.jsonDaily',
487
- 'statistics.jsonWeekly',
488
- now => {
489
- // aggregation window: Monday to Sunday of the previous week (week that just ended)
490
- const startday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
491
- const lastday = new Date(startday);
492
- // set to Monday of previous week
493
- lastday.setDate(now.getDate() - (now.getDay() || 7) + 1); // set to Monday of actual week
494
- startday.setDate(lastday.getDate() - 7); // set to Monday of previous week
495
- return { from: startday, to: lastday };
496
- },
497
- 'weekly',
498
- )
499
- ) {
500
- this._buildFlexchart('weekly');
501
- } // only update last execution time if aggregation was performed to avoid backfilling multiple weeks at startup
735
+ this._calculateAggregation(
736
+ 'statistics.jsonDaily',
737
+ 'statistics.jsonWeekly',
738
+ now => {
739
+ // Previous week (completed)
740
+ const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
741
+ monday.setDate(now.getDate() - (now.getDay() || 7) + 1);
742
+ const prevMonday = new Date(monday);
743
+ prevMonday.setDate(monday.getDate() - 7);
744
+ const nextMonday = new Date(monday);
745
+ nextMonday.setDate(monday.getDate() + 7);
746
+ // Window: previous Monday next Monday (covers both last and current week)
747
+ return { from: prevMonday, to: nextMonday };
748
+ },
749
+ 'weekly',
750
+ );
751
+
752
+ // Laufende Woche live entry
753
+ this._calculateAggregation(
754
+ 'statistics.jsonDaily',
755
+ 'statistics.jsonWeekly',
756
+ now => {
757
+ const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
758
+ monday.setDate(now.getDate() - (now.getDay() || 7) + 1);
759
+ const nextMonday = new Date(monday);
760
+ nextMonday.setDate(monday.getDate() + 7);
761
+ return { from: monday, to: nextMonday };
762
+ },
763
+ 'weekly-live',
764
+ );
765
+ this._buildFlexchart('weekly');
502
766
  }
503
767
 
504
768
  /**
505
769
  * Calculates and updates monthly consumption statistics from daily data.
506
- *
507
- * @returns {void}
508
770
  */
509
771
  _calculateMonthly() {
510
772
  this.adapter.log.debug('### Monthly execution triggered ###');
@@ -512,19 +774,28 @@ class statistics {
512
774
  'statistics.jsonDaily',
513
775
  'statistics.jsonMonthly',
514
776
  now => {
515
- // aggregation windowStart: previous month (month that just ended)
516
777
  const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
517
778
  const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1, 0, 0, 0, 0);
518
779
  return { from: prevMonth, to: thisMonth };
519
780
  },
520
781
  'monthly',
521
- ) && this._buildFlexchart('monthly'); // only update last execution time if aggregation was performed to avoid backfilling multiple months at startup
782
+ );
783
+ // Laufender Monat — live entry
784
+ this._calculateAggregation(
785
+ 'statistics.jsonDaily',
786
+ 'statistics.jsonMonthly',
787
+ now => {
788
+ const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
789
+ const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
790
+ return { from: thisMonth, to: nextMonth };
791
+ },
792
+ 'monthly-live',
793
+ );
794
+ this._buildFlexchart('monthly');
522
795
  }
523
796
 
524
797
  /**
525
798
  * Calculates and updates annual consumption statistics from daily data.
526
- *
527
- * @returns {void}
528
799
  */
529
800
  _calculateAnnual() {
530
801
  this.adapter.log.debug('### Annual execution triggered ###');
@@ -532,47 +803,57 @@ class statistics {
532
803
  'statistics.jsonDaily',
533
804
  'statistics.jsonAnnual',
534
805
  now => {
535
- // aggregation window: previous year (year that just ended)
536
806
  const thisYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
537
807
  const prevYear = new Date(now.getFullYear() - 1, 0, 1, 0, 0, 0, 0);
538
808
  return { from: prevYear, to: thisYear };
539
809
  },
540
810
  'annual',
541
- ) && this._buildFlexchart('annual'); // only update last execution time if aggregation was performed to avoid backfilling multiple years at startup
811
+ );
812
+ // Laufendes Jahr — live entry
813
+ this._calculateAggregation(
814
+ 'statistics.jsonDaily',
815
+ 'statistics.jsonAnnual',
816
+ now => {
817
+ const thisYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
818
+ const nextYear = new Date(now.getFullYear() + 1, 0, 1, 0, 0, 0, 0);
819
+ return { from: thisYear, to: nextYear };
820
+ },
821
+ 'annual-live',
822
+ );
823
+ this._buildFlexchart('annual');
542
824
  }
543
825
 
544
826
  /**
545
827
  * Initialize and schedule the unified task manager.
546
- * This task runs every minute and checks which statistics need to be calculated.
828
+ * Runs every full hour.
547
829
  */
548
830
  _initializeTask() {
549
831
  const scheduleNextRun = () => {
550
832
  const now = new Date();
833
+
551
834
  const next = new Date(now);
552
835
  if (this.testing) {
553
- next.setMinutes(now.getMinutes() + 1, 0, 0); //every minute
836
+ next.setMinutes(now.getMinutes() + 1, 0, 0);
554
837
  } else {
555
- next.setHours(next.getHours() + 1, 0, 0, 0); //every hour
838
+ next.setHours(next.getHours() + 1, 0, 0, 100);
556
839
  }
557
-
558
- // Skip executing tasks exactly at midnight to avoid running aggregated jobs
559
840
  if (next.getHours() === 0 && next.getMinutes() === 0) {
560
- next.setHours(next.getHours() + 1, 0, 0, 0); //every hour
841
+ next.setHours(next.getHours() + 1, 0, 0, 0);
561
842
  }
562
843
  const msToNextHour = next.getTime() - now.getTime();
844
+ this.adapter.logger.debug(`### Statistics - Scheduler start ${now.toLocaleTimeString()} next ${next.toLocaleTimeString()}`);
563
845
 
564
846
  if (this.taskTimer) {
565
847
  this.adapter.clearTimeout(this.taskTimer);
566
848
  }
567
-
568
849
  this.taskTimer = this.adapter.setTimeout(() => {
569
850
  this._executeScheduledTasks();
570
- scheduleNextRun(); // reschedule for next hour
851
+ scheduleNextRun();
571
852
  }, msToNextHour);
572
853
  };
573
- // Schedule the next run
574
854
  scheduleNextRun();
575
855
  }
856
+
576
857
  _executeScheduledTasks() {
577
858
  this._calculateHourly();
578
859
  this._calculateDaily();
@@ -582,14 +863,13 @@ class statistics {
582
863
  }
583
864
 
584
865
  /**
585
- * Executes every midnight and performs the following tasks:
586
- * - Execute all scheduled tasks to ensure that statistics are up to date.
587
- * - Clear old data based on retention policies.
866
+ * Executes every midnight:
867
+ * - Runs all scheduled tasks
868
+ * - Clears old data based on retention policies
588
869
  */
589
870
  mitNightProcess() {
590
871
  const now = new Date();
591
872
  this._executeScheduledTasks();
592
- // Clear old data based on retention policies
593
873
  const startOfYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
594
874
  this._clearGeneric('statistics.jsonDaily', startOfYear);
595
875
  this._clearGeneric('statistics.jsonWeekly', startOfYear);
@@ -598,7 +878,6 @@ class statistics {
598
878
  }
599
879
 
600
880
  async initialize() {
601
- // load consumption JSON states (keep as string)
602
881
  let state = await this.adapter.getState('statistics.jsonHourly');
603
882
  this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
604
883
 
@@ -606,7 +885,7 @@ class statistics {
606
885
  this.stateCache.set('statistics.jsonDaily', state?.val ?? '[]', { type: 'string', stored: true });
607
886
 
608
887
  state = await this.adapter.getState('statistics.jsonWeekly');
609
- this.stateCache.set('statistics.jsonWeekly', state?.val ?? '[]', { type: 'string', stored: true }); //is already stored
888
+ this.stateCache.set('statistics.jsonWeekly', state?.val ?? '[]', { type: 'string', stored: true });
610
889
 
611
890
  state = await this.adapter.getState('statistics.jsonMonthly');
612
891
  this.stateCache.set('statistics.jsonMonthly', state?.val ?? '[]', { type: 'string', stored: true });
@@ -614,10 +893,17 @@ class statistics {
614
893
  state = await this.adapter.getState('statistics.jsonAnnual');
615
894
  this.stateCache.set('statistics.jsonAnnual', state?.val ?? '[]', { type: 'string', stored: true });
616
895
 
617
- // wait until consumptionToday and so on is available to avoid running the task before the initial state is loaded
618
- await tools.waitForValue(() => this.stateCache.get('collected.accumulatedEnergyYield')?.value, 60000);
896
+ state = await this.adapter.getState('statistics.jsonToday');
897
+ this.stateCache.set('statistics.jsonToday', state?.val ?? '{}', { type: 'string', stored: true });
898
+ try {
899
+ await tools.waitForValue(() => this.stateCache.get('collected.accumulatedEnergyYield')?.value, 5 * 60000);
900
+ } catch {
901
+ this.adapter.logger.warn(
902
+ "statistics: waited 5 minutes for state 'collected.accumulatedEnergyYield' to be available but it didn't, computed statistics will not work until this state is present",
903
+ );
904
+ }
619
905
 
620
- // load templates — eines pro Chart-Typ
906
+ // Load templates — one per chart type
621
907
  for (const chartType of ['hourly', 'daily', 'weekly', 'monthly', 'annual']) {
622
908
  const templateStateId = `statistics.flexCharts.template.${chartType}`;
623
909
  state = await this.adapter.getState(templateStateId);
@@ -629,13 +915,22 @@ class statistics {
629
915
  }
630
916
  }
631
917
 
632
- this.mitNightProcess(); // execute once on startup to catch up on any missed runs while the adapter was not running
918
+ this.mitNightProcess();
633
919
  this._initializeTask();
634
920
  this.adapter.subscribeStates(`${this._path}.*`);
921
+ this._initialized = true;
635
922
  }
636
923
 
924
+ /**
925
+ * Builds and updates the Flexchart configuration for the specified chart type.
926
+ *
927
+ * @param {string} myChart - 'hourly' | 'daily' | 'weekly' | 'monthly' | 'annual'
928
+ * @param {string} [chartStyle] - 'line' | 'bar' — defaults to 'line' for hourly, 'bar' for others
929
+ * @returns {string} The generated chart configuration as a javascript-stringify string
930
+ */
637
931
  _buildFlexchart(myChart, chartStyle) {
638
- chartStyle = chartStyle || (myChart === 'hourly' ? 'line' : 'bar'); // default styles: line for hourly (to better see the curve), bar for others
932
+ chartStyle = chartStyle || (myChart === 'hourly' ? 'line' : 'bar');
933
+
639
934
  const IDS = {
640
935
  hourly: 'statistics.jsonHourly',
641
936
  daily: 'statistics.jsonDaily',
@@ -663,9 +958,12 @@ class statistics {
663
958
  })}`;
664
959
  }
665
960
  if (myChart === 'weekly') {
666
- const yesterday = new Date(to);
667
- yesterday.setDate(yesterday.getDate() - 1);
668
- return `${from.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}..${yesterday.toLocaleTimeString('de-DE', { month: '2-digit', day: '2-digit' })}`;
961
+ const toDay = new Date(to);
962
+ //mitnight -> the day before
963
+ if (toDay.getHours() === 0 && toDay.getMinutes() === 0) {
964
+ toDay.setDate(toDay.getDate() - 1);
965
+ }
966
+ return `${from.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}-${toDay.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}`;
669
967
  }
670
968
  if (myChart === 'monthly') {
671
969
  return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit' });
@@ -673,22 +971,12 @@ class statistics {
673
971
  if (myChart === 'annual') {
674
972
  return from.toLocaleDateString('de-DE', { year: 'numeric' });
675
973
  }
676
-
677
974
  return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
678
- /*
679
- return myChart === 'hourly'
680
- ? `${to.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })} ${to.toLocaleTimeString('de-DE', {
681
- hour12: false,
682
- hour: '2-digit',
683
- minute: '2-digit',
684
- })}`
685
- : from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
686
- */
687
975
  });
688
976
 
689
977
  const xAxisDataShort = myChart === 'hourly' ? xAxisData.map(label => label.split(' ')[1]) : xAxisData;
690
978
 
691
- // --- Tagesbereiche ---
979
+ // --- Day areas (hourly only) ---
692
980
  const dayAreas = [];
693
981
  if (myChart === 'hourly' && xAxisData.length > 0) {
694
982
  const dayBoundaries = [0];
@@ -734,19 +1022,15 @@ class statistics {
734
1022
  const extract = key => data.map(e => Number(Number(e[key]?.value ?? 0).toFixed(3)));
735
1023
  const negate = arr => arr.map(v => Number((-v).toFixed(3)));
736
1024
 
737
- const seriesData = {
738
- solarYield: extract('solarYield'),
739
- consumption: extract('consumption'),
740
- gridExport: extract('gridExport'),
741
- gridImport: extract('gridImport'),
742
- chargeCapacity: extract('chargeCapacity'),
743
- dischargeCapacity: extract('dischargeCapacity'),
744
- SOC: extract('SOC'),
745
- gridExportNeg: negate(extract('gridExport')),
746
- chargeCapacityNeg: negate(extract('chargeCapacity')),
747
- };
1025
+ // Build seriesData from all this.stats entries (targetPath as key)
1026
+ const seriesData = {};
1027
+ for (const stat of this.stats) {
1028
+ const values = extract(stat.targetPath);
1029
+ seriesData[stat.targetPath] = values;
1030
+ seriesData[`${stat.targetPath}Neg`] = negate(values);
1031
+ }
748
1032
 
749
- // --- Tooltip formatter (zeigt immer positive Werte, filtert DayBreak heraus) ---
1033
+ // --- Tooltip formatter ---
750
1034
  const tooltipFormatter = params => {
751
1035
  if (!Array.isArray(params)) params = [params];
752
1036
  return params
@@ -754,8 +1038,10 @@ class statistics {
754
1038
  .map(p => {
755
1039
  const negatedSeries = ['Grid Export', 'Charge'];
756
1040
  const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
757
- const unit = p.seriesName === 'SOC' ? ' %' : ' kWh';
758
- return `${p.marker}${p.seriesName}: <b>${val}${unit}</b>`;
1041
+ const unit = ['SOC', 'Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? ' %' : ' kWh';
1042
+ const seriesName =
1043
+ myChart === 'hourly' && ['Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? `${p.seriesName} today` : p.seriesName;
1044
+ return `${p.marker}${seriesName}: <b>${val}${unit}</b>`;
759
1045
  })
760
1046
  .join('<br/>');
761
1047
  };
@@ -769,12 +1055,9 @@ class statistics {
769
1055
 
770
1056
  try {
771
1057
  const templ = JSON.parse(templateStr);
772
-
773
1058
  if (Object.keys(templ).length === 0) {
774
- // Kein Template → built-in Default
775
1059
  chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
776
1060
  } else {
777
- // Template vorhanden → mit javascript-stringify serialisieren
778
1061
  chartStr = stringify(templ);
779
1062
  }
780
1063
  } catch (e) {
@@ -782,30 +1065,21 @@ class statistics {
782
1065
  chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
783
1066
  }
784
1067
 
785
- // --- Replace data placeholders ---
1068
+ // --- Replace placeholders ---
786
1069
  chartStr = chartStr
787
- // X-Achse
788
1070
  .replace("'%%xAxisData%%'", JSON.stringify(xAxisData))
789
1071
  .replace("'%%xAxisDataShort%%'", JSON.stringify(xAxisDataShort))
790
1072
  .replace("'%%xAxisMax%%'", String(xAxisData.length - 1))
791
- // Originaldaten (immer positiv)
792
- .replace("'%%solarYield%%'", JSON.stringify(seriesData.solarYield))
793
- .replace("'%%consumption%%'", JSON.stringify(seriesData.consumption))
794
- .replace("'%%gridExport%%'", JSON.stringify(seriesData.gridExport))
795
- .replace("'%%gridImport%%'", JSON.stringify(seriesData.gridImport))
796
- .replace("'%%chargeCapacity%%'", JSON.stringify(seriesData.chargeCapacity))
797
- .replace("'%%dischargeCapacity%%'", JSON.stringify(seriesData.dischargeCapacity))
798
- .replace("'%%SOC%%'", JSON.stringify(seriesData.SOC))
799
- // Negierte Varianten für gegenläufige Darstellung
800
- .replace("'%%gridExportNeg%%'", JSON.stringify(seriesData.gridExportNeg))
801
- .replace("'%%chargeCapacityNeg%%'", JSON.stringify(seriesData.chargeCapacityNeg))
802
- // Sonstiges
803
- .replace("'%%dayAreas%%'", JSON.stringify(dayAreas))
804
1073
  .replace("'%%chartTitle%%'", JSON.stringify(`PV Statistics — ${myChart}`))
805
- // Funktionen
1074
+ .replace("'%%dayAreas%%'", JSON.stringify(dayAreas))
806
1075
  .replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
807
1076
 
808
- // --- In chart-type specific output state speichern ---
1077
+ // All this.stats entries dynamically both positive and negated
1078
+ for (const stat of this.stats) {
1079
+ const key = stat.targetPath;
1080
+ chartStr = chartStr.replace(`'%%${key}%%'`, JSON.stringify(seriesData[key])).replace(`'%%${key}Neg%%'`, JSON.stringify(seriesData[`${key}Neg`]));
1081
+ }
1082
+
809
1083
  this.stateCache.set(outputStateId, chartStr, { type: 'string' });
810
1084
  this.adapter.logger.debug(`statistics: flexCharts built for ${myChart}/${chartStyle}`);
811
1085
 
@@ -835,7 +1109,15 @@ class statistics {
835
1109
  const negate = arr => arr.map(v => Number((-v).toFixed(3)));
836
1110
  const showSOC = myChart === 'hourly';
837
1111
 
838
- // Tooltip formatterzeigt immer positive Werte, filtert DayBreak heraus
1112
+ // No-data hintchart-type specific
1113
+ const noDataHints = {
1114
+ hourly: 'No data yet — first entry available after the next full hour.',
1115
+ daily: 'No data yet — first entry available tomorrow after midnight.',
1116
+ weekly: 'No data yet — first entry available after the current week ends.',
1117
+ monthly: 'No data yet — first entry available after the current month ends.',
1118
+ annual: 'No data yet — first entry available after the current year ends.',
1119
+ };
1120
+
839
1121
  const tooltipFormatter = params => {
840
1122
  if (!Array.isArray(params)) params = [params];
841
1123
  return params
@@ -843,12 +1125,29 @@ class statistics {
843
1125
  .map(p => {
844
1126
  const negatedSeries = ['Grid Export', 'Charge'];
845
1127
  const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
846
- const unit = p.seriesName === 'SOC' ? ' %' : ' kWh';
847
- return `${p.marker}${p.seriesName}: <b>${val}${unit}</b>`;
1128
+ const unit = ['SOC', 'Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? ' %' : ' kWh';
1129
+ const seriesName =
1130
+ myChart === 'hourly' && ['Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? `${p.seriesName} today` : p.seriesName;
1131
+ return `${p.marker}${seriesName}: <b>${val}${unit}</b>`;
848
1132
  })
849
1133
  .join('<br/>');
850
1134
  };
851
1135
 
1136
+ // --- Slider start position — show recent entries by default ---
1137
+ // For hourly: show last 25 entries (~1 day)
1138
+ // For daily: show last 7 entries (~1 week)
1139
+ // For weekly: show last 8 entries (~2 months)
1140
+ // For monthly: show last 13 entries (~1 year)
1141
+ // For annual: show full range
1142
+ const sliderDefaults = {
1143
+ hourly: { start: Math.max(0, Math.round((1 - 25 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1144
+ daily: { start: Math.max(0, Math.round((1 - 7 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1145
+ weekly: { start: Math.max(0, Math.round((1 - 8 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1146
+ monthly: { start: Math.max(0, Math.round((1 - 13 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1147
+ annual: { start: 0, end: 100 },
1148
+ };
1149
+ const slider = sliderDefaults[myChart] ?? { start: 0, end: 100 };
1150
+
852
1151
  const chart = {
853
1152
  backgroundColor: '#fff',
854
1153
  animation: false,
@@ -859,7 +1158,17 @@ class statistics {
859
1158
  legend: {
860
1159
  top: 35,
861
1160
  left: 'center',
862
- data: ['Solar Yield', 'Consumption', 'Grid Export', 'Grid Import', 'Charge', 'Discharge', ...(showSOC ? ['SOC'] : [])],
1161
+ data: [
1162
+ 'Solar Yield',
1163
+ 'Grid Export',
1164
+ 'Grid Import',
1165
+ 'Charge',
1166
+ 'Discharge',
1167
+ ...(showSOC ? ['SOC'] : []),
1168
+ 'Self-sufficiency',
1169
+ 'Self-consumption',
1170
+ 'Consumption',
1171
+ ],
863
1172
  },
864
1173
  tooltip: {
865
1174
  trigger: 'axis',
@@ -888,9 +1197,25 @@ class statistics {
888
1197
  saveAsImage: { show: true },
889
1198
  },
890
1199
  },
1200
+ // No-data graphic — shown only when data is empty
1201
+ graphic:
1202
+ xAxisData.length === 0
1203
+ ? [
1204
+ {
1205
+ type: 'text',
1206
+ left: 'center',
1207
+ top: 'middle',
1208
+ style: {
1209
+ text: noDataHints[myChart] || 'No data available yet.',
1210
+ fontSize: 14,
1211
+ fill: '#999',
1212
+ },
1213
+ },
1214
+ ]
1215
+ : [],
891
1216
  grid: [
892
- { left: '8%', right: showSOC ? '8%' : '4%', top: 80, height: '50%' },
893
- { left: '8%', right: showSOC ? '8%' : '4%', top: '75%', height: '15%' },
1217
+ { left: '8%', right: showSOC ? '8%' : '4%', top: 80, height: '45%' },
1218
+ { left: '8%', right: showSOC ? '8%' : '4%', top: '72%', height: '15%' },
894
1219
  ],
895
1220
  xAxis: [
896
1221
  {
@@ -915,7 +1240,7 @@ class statistics {
915
1240
  gridIndex: 1,
916
1241
  data: xAxisData,
917
1242
  scale: true,
918
- boundaryGap: false,
1243
+ boundaryGap: chartStyle !== 'line',
919
1244
  axisLine: { onZero: false },
920
1245
  axisTick: { show: false },
921
1246
  splitLine: { show: false },
@@ -925,7 +1250,7 @@ class statistics {
925
1250
  },
926
1251
  ],
927
1252
  yAxis: [
928
- // Index 0 — Energie links
1253
+ // Index 0 — Energy left axis
929
1254
  {
930
1255
  scale: false,
931
1256
  splitArea: { show: true },
@@ -936,23 +1261,19 @@ class statistics {
936
1261
  splitLine: { show: true },
937
1262
  axisLine: { show: true },
938
1263
  },
939
- // Index 1 — SOC rechts (nur bei hourly)
940
- ...(showSOC
941
- ? [
942
- {
943
- type: 'value',
944
- min: 0,
945
- max: 100,
946
- name: 'SOC (%)',
947
- nameLocation: 'middle',
948
- nameGap: 40,
949
- axisLabel: { formatter: '{value} %' },
950
- splitLine: { show: false },
951
- axisLine: { show: true },
952
- },
953
- ]
954
- : []),
955
- // Index 1 oder 2 — Consumption unteres Grid
1264
+ // Index 1 — SOC / ratio right axis
1265
+ {
1266
+ type: 'value',
1267
+ min: 0,
1268
+ max: 100,
1269
+ name: showSOC ? 'SOC / Ratio (%)' : 'Ratio (%)',
1270
+ nameLocation: 'middle',
1271
+ nameGap: 50,
1272
+ axisLabel: { formatter: '{value} %' },
1273
+ splitLine: { show: false },
1274
+ axisLine: { show: true },
1275
+ },
1276
+ // Index 2 Consumption lower grid
956
1277
  {
957
1278
  scale: true,
958
1279
  gridIndex: 1,
@@ -967,11 +1288,23 @@ class statistics {
967
1288
  },
968
1289
  ],
969
1290
  dataZoom: [
970
- { type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 },
971
- { show: true, xAxisIndex: [0, 1], type: 'slider', bottom: 5, start: 0, end: 100 },
1291
+ {
1292
+ type: 'inside',
1293
+ xAxisIndex: [0, 1],
1294
+ start: slider.start,
1295
+ end: slider.end,
1296
+ },
1297
+ {
1298
+ show: true,
1299
+ xAxisIndex: [0, 1],
1300
+ type: 'slider',
1301
+ bottom: 5,
1302
+ start: slider.start,
1303
+ end: slider.end,
1304
+ },
972
1305
  ],
973
1306
  series: [
974
- // Positive Werte
1307
+ // Positive values (above zero line)
975
1308
  {
976
1309
  name: 'Solar Yield',
977
1310
  type: seriesType,
@@ -980,40 +1313,40 @@ class statistics {
980
1313
  emphasis: { focus: 'series' },
981
1314
  ...lineOptions,
982
1315
  },
1316
+ // Negative values (below zero line)
983
1317
  {
984
- name: 'Grid Import',
1318
+ name: 'Grid Export',
985
1319
  type: seriesType,
986
- data: seriesData.gridImport,
987
- itemStyle: { color: '#ec0000' },
1320
+ data: negate(seriesData.gridExport),
1321
+ itemStyle: { color: '#5cb85c' },
988
1322
  emphasis: { focus: 'series' },
989
1323
  ...lineOptions,
990
1324
  },
991
1325
  {
992
- name: 'Discharge',
1326
+ name: 'Grid Import',
993
1327
  type: seriesType,
994
- data: seriesData.dischargeCapacity,
995
- itemStyle: { color: '#ed50e0' },
1328
+ data: seriesData.gridImport,
1329
+ itemStyle: { color: '#ec0000' },
996
1330
  emphasis: { focus: 'series' },
997
1331
  ...lineOptions,
998
1332
  },
999
- // Negative Werte (unterhalb Nulllinie)
1000
1333
  {
1001
- name: 'Grid Export',
1334
+ name: 'Charge',
1002
1335
  type: seriesType,
1003
- data: negate(seriesData.gridExport),
1004
- itemStyle: { color: '#5cb85c' },
1336
+ data: negate(seriesData.chargeCapacity),
1337
+ itemStyle: { color: '#5bc0de' },
1005
1338
  emphasis: { focus: 'series' },
1006
1339
  ...lineOptions,
1007
1340
  },
1008
1341
  {
1009
- name: 'Charge',
1342
+ name: 'Discharge',
1010
1343
  type: seriesType,
1011
- data: negate(seriesData.chargeCapacity),
1012
- itemStyle: { color: '#5bc0de' },
1344
+ data: seriesData.dischargeCapacity,
1345
+ itemStyle: { color: '#ed50e0' },
1013
1346
  emphasis: { focus: 'series' },
1014
1347
  ...lineOptions,
1015
1348
  },
1016
- // SOC — nur bei hourly
1349
+ // SOC — hourly only, on right axis
1017
1350
  ...(showSOC
1018
1351
  ? [
1019
1352
  {
@@ -1028,18 +1361,41 @@ class statistics {
1028
1361
  },
1029
1362
  ]
1030
1363
  : []),
1031
- // Consumption im unteren Grid
1032
- // yAxisIndex passt sich an: 2 wenn SOC vorhanden, sonst 1
1364
+ // Self-sufficiency right axis
1365
+ {
1366
+ name: 'Self-sufficiency',
1367
+ type: 'line',
1368
+ yAxisIndex: 1,
1369
+ data: seriesData.selfSufficiency,
1370
+ itemStyle: { color: '#9c27b0' },
1371
+ lineStyle: { width: 2, type: 'dashed' },
1372
+ symbol: 'circle',
1373
+ symbolSize: 4,
1374
+ smooth: true,
1375
+ },
1376
+ // Self-consumption — right axis
1377
+ {
1378
+ name: 'Self-consumption',
1379
+ type: 'line',
1380
+ yAxisIndex: 1,
1381
+ data: seriesData.selfConsumption,
1382
+ itemStyle: { color: '#ff9800' },
1383
+ lineStyle: { width: 2, type: 'dashed' },
1384
+ symbol: 'circle',
1385
+ symbolSize: 4,
1386
+ smooth: true,
1387
+ },
1388
+ // Consumption in lower grid — always yAxisIndex 2
1033
1389
  {
1034
1390
  name: 'Consumption',
1035
1391
  type: seriesType,
1036
1392
  data: seriesData.consumption,
1037
1393
  itemStyle: { color: '#337ab7' },
1038
1394
  xAxisIndex: 1,
1039
- yAxisIndex: showSOC ? 2 : 1,
1395
+ yAxisIndex: 2,
1040
1396
  ...lineOptions,
1041
1397
  },
1042
- // Tages-Bereiche
1398
+ // Day-break areas (hourly only)
1043
1399
  ...(dayAreas.length > 0
1044
1400
  ? [
1045
1401
  {
@@ -1059,6 +1415,11 @@ class statistics {
1059
1415
  return stringify(chart).replace("'%%xAxisFormatter%%'", stringify(xAxisFormatterHourly)).replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
1060
1416
  }
1061
1417
 
1418
+ /**
1419
+ * Merges two objects deeply.
1420
+ * @param target
1421
+ * @param source
1422
+ */
1062
1423
  _deepMerge(target, source) {
1063
1424
  for (const key of Object.keys(source)) {
1064
1425
  if (
@@ -1069,16 +1430,19 @@ class statistics {
1069
1430
  typeof target[key] === 'object' &&
1070
1431
  !Array.isArray(target[key])
1071
1432
  ) {
1072
- // Rekursiv für verschachtelte Objekte
1073
1433
  this._deepMerge(target[key], source[key]);
1074
1434
  } else {
1075
- // Primitive, Arrays direkt überschreiben
1076
1435
  target[key] = source[key];
1077
1436
  }
1078
1437
  }
1079
1438
  return target;
1080
1439
  }
1081
1440
 
1441
+ /**
1442
+ * Handles a template state change — updates cache, acknowledges and rebuilds chart.
1443
+ * @param chartType
1444
+ * @param state
1445
+ */
1082
1446
  async handleTemplateChange(chartType, state) {
1083
1447
  const templateStateId = `statistics.flexCharts.template.${chartType}`;
1084
1448
  const template = this.stateCache.get(templateStateId)?.value;
@@ -1097,7 +1461,7 @@ class statistics {
1097
1461
  /**
1098
1462
  * Entry point for adapter to handle messages related to statistics/flexcharts.
1099
1463
  *
1100
- * @param {{chart?: string}} message
1464
+ * @param {{chart?: string, style?: string}} message
1101
1465
  * @param {Function} callback
1102
1466
  */
1103
1467
  handleFlexMessage(message, callback) {