iobroker.sun2000 2.4.2 → 2.4.3

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.
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.
332
382
  *
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.
335
- *
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,12 @@ 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
896
+ state = await this.adapter.getState('statistics.jsonToday');
897
+ this.stateCache.set('statistics.jsonToday', state?.val ?? '{}', { type: 'string', stored: true });
898
+
618
899
  await tools.waitForValue(() => this.stateCache.get('collected.accumulatedEnergyYield')?.value, 60000);
619
900
 
620
- // load templates — eines pro Chart-Typ
901
+ // Load templates — one per chart type
621
902
  for (const chartType of ['hourly', 'daily', 'weekly', 'monthly', 'annual']) {
622
903
  const templateStateId = `statistics.flexCharts.template.${chartType}`;
623
904
  state = await this.adapter.getState(templateStateId);
@@ -629,13 +910,22 @@ class statistics {
629
910
  }
630
911
  }
631
912
 
632
- this.mitNightProcess(); // execute once on startup to catch up on any missed runs while the adapter was not running
913
+ this.mitNightProcess();
633
914
  this._initializeTask();
634
915
  this.adapter.subscribeStates(`${this._path}.*`);
916
+ this._initialized = true;
635
917
  }
636
918
 
919
+ /**
920
+ * Builds and updates the Flexchart configuration for the specified chart type.
921
+ *
922
+ * @param {string} myChart - 'hourly' | 'daily' | 'weekly' | 'monthly' | 'annual'
923
+ * @param {string} [chartStyle] - 'line' | 'bar' — defaults to 'line' for hourly, 'bar' for others
924
+ * @returns {string} The generated chart configuration as a javascript-stringify string
925
+ */
637
926
  _buildFlexchart(myChart, chartStyle) {
638
- chartStyle = chartStyle || (myChart === 'hourly' ? 'line' : 'bar'); // default styles: line for hourly (to better see the curve), bar for others
927
+ chartStyle = chartStyle || (myChart === 'hourly' ? 'line' : 'bar');
928
+
639
929
  const IDS = {
640
930
  hourly: 'statistics.jsonHourly',
641
931
  daily: 'statistics.jsonDaily',
@@ -663,9 +953,12 @@ class statistics {
663
953
  })}`;
664
954
  }
665
955
  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' })}`;
956
+ const toDay = new Date(to);
957
+ //mitnight -> the day before
958
+ if (toDay.getHours() === 0 && toDay.getMinutes() === 0) {
959
+ toDay.setDate(toDay.getDate() - 1);
960
+ }
961
+ return `${from.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}-${toDay.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}`;
669
962
  }
670
963
  if (myChart === 'monthly') {
671
964
  return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit' });
@@ -673,22 +966,12 @@ class statistics {
673
966
  if (myChart === 'annual') {
674
967
  return from.toLocaleDateString('de-DE', { year: 'numeric' });
675
968
  }
676
-
677
969
  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
970
  });
688
971
 
689
972
  const xAxisDataShort = myChart === 'hourly' ? xAxisData.map(label => label.split(' ')[1]) : xAxisData;
690
973
 
691
- // --- Tagesbereiche ---
974
+ // --- Day areas (hourly only) ---
692
975
  const dayAreas = [];
693
976
  if (myChart === 'hourly' && xAxisData.length > 0) {
694
977
  const dayBoundaries = [0];
@@ -734,19 +1017,15 @@ class statistics {
734
1017
  const extract = key => data.map(e => Number(Number(e[key]?.value ?? 0).toFixed(3)));
735
1018
  const negate = arr => arr.map(v => Number((-v).toFixed(3)));
736
1019
 
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
- };
1020
+ // Build seriesData from all this.stats entries (targetPath as key)
1021
+ const seriesData = {};
1022
+ for (const stat of this.stats) {
1023
+ const values = extract(stat.targetPath);
1024
+ seriesData[stat.targetPath] = values;
1025
+ seriesData[`${stat.targetPath}Neg`] = negate(values);
1026
+ }
748
1027
 
749
- // --- Tooltip formatter (zeigt immer positive Werte, filtert DayBreak heraus) ---
1028
+ // --- Tooltip formatter ---
750
1029
  const tooltipFormatter = params => {
751
1030
  if (!Array.isArray(params)) params = [params];
752
1031
  return params
@@ -754,8 +1033,10 @@ class statistics {
754
1033
  .map(p => {
755
1034
  const negatedSeries = ['Grid Export', 'Charge'];
756
1035
  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>`;
1036
+ const unit = ['SOC', 'Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? ' %' : ' kWh';
1037
+ const seriesName =
1038
+ myChart === 'hourly' && ['Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? `${p.seriesName} today` : p.seriesName;
1039
+ return `${p.marker}${seriesName}: <b>${val}${unit}</b>`;
759
1040
  })
760
1041
  .join('<br/>');
761
1042
  };
@@ -769,12 +1050,9 @@ class statistics {
769
1050
 
770
1051
  try {
771
1052
  const templ = JSON.parse(templateStr);
772
-
773
1053
  if (Object.keys(templ).length === 0) {
774
- // Kein Template → built-in Default
775
1054
  chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
776
1055
  } else {
777
- // Template vorhanden → mit javascript-stringify serialisieren
778
1056
  chartStr = stringify(templ);
779
1057
  }
780
1058
  } catch (e) {
@@ -782,30 +1060,21 @@ class statistics {
782
1060
  chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
783
1061
  }
784
1062
 
785
- // --- Replace data placeholders ---
1063
+ // --- Replace placeholders ---
786
1064
  chartStr = chartStr
787
- // X-Achse
788
1065
  .replace("'%%xAxisData%%'", JSON.stringify(xAxisData))
789
1066
  .replace("'%%xAxisDataShort%%'", JSON.stringify(xAxisDataShort))
790
1067
  .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
1068
  .replace("'%%chartTitle%%'", JSON.stringify(`PV Statistics — ${myChart}`))
805
- // Funktionen
1069
+ .replace("'%%dayAreas%%'", JSON.stringify(dayAreas))
806
1070
  .replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
807
1071
 
808
- // --- In chart-type specific output state speichern ---
1072
+ // All this.stats entries dynamically both positive and negated
1073
+ for (const stat of this.stats) {
1074
+ const key = stat.targetPath;
1075
+ chartStr = chartStr.replace(`'%%${key}%%'`, JSON.stringify(seriesData[key])).replace(`'%%${key}Neg%%'`, JSON.stringify(seriesData[`${key}Neg`]));
1076
+ }
1077
+
809
1078
  this.stateCache.set(outputStateId, chartStr, { type: 'string' });
810
1079
  this.adapter.logger.debug(`statistics: flexCharts built for ${myChart}/${chartStyle}`);
811
1080
 
@@ -835,7 +1104,15 @@ class statistics {
835
1104
  const negate = arr => arr.map(v => Number((-v).toFixed(3)));
836
1105
  const showSOC = myChart === 'hourly';
837
1106
 
838
- // Tooltip formatterzeigt immer positive Werte, filtert DayBreak heraus
1107
+ // No-data hintchart-type specific
1108
+ const noDataHints = {
1109
+ hourly: 'No data yet — first entry available after the next full hour.',
1110
+ daily: 'No data yet — first entry available tomorrow after midnight.',
1111
+ weekly: 'No data yet — first entry available after the current week ends.',
1112
+ monthly: 'No data yet — first entry available after the current month ends.',
1113
+ annual: 'No data yet — first entry available after the current year ends.',
1114
+ };
1115
+
839
1116
  const tooltipFormatter = params => {
840
1117
  if (!Array.isArray(params)) params = [params];
841
1118
  return params
@@ -843,12 +1120,29 @@ class statistics {
843
1120
  .map(p => {
844
1121
  const negatedSeries = ['Grid Export', 'Charge'];
845
1122
  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>`;
1123
+ const unit = ['SOC', 'Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? ' %' : ' kWh';
1124
+ const seriesName =
1125
+ myChart === 'hourly' && ['Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? `${p.seriesName} today` : p.seriesName;
1126
+ return `${p.marker}${seriesName}: <b>${val}${unit}</b>`;
848
1127
  })
849
1128
  .join('<br/>');
850
1129
  };
851
1130
 
1131
+ // --- Slider start position — show recent entries by default ---
1132
+ // For hourly: show last 25 entries (~1 day)
1133
+ // For daily: show last 7 entries (~1 week)
1134
+ // For weekly: show last 8 entries (~2 months)
1135
+ // For monthly: show last 13 entries (~1 year)
1136
+ // For annual: show full range
1137
+ const sliderDefaults = {
1138
+ hourly: { start: Math.max(0, Math.round((1 - 25 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1139
+ daily: { start: Math.max(0, Math.round((1 - 7 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1140
+ weekly: { start: Math.max(0, Math.round((1 - 8 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1141
+ monthly: { start: Math.max(0, Math.round((1 - 13 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1142
+ annual: { start: 0, end: 100 },
1143
+ };
1144
+ const slider = sliderDefaults[myChart] ?? { start: 0, end: 100 };
1145
+
852
1146
  const chart = {
853
1147
  backgroundColor: '#fff',
854
1148
  animation: false,
@@ -859,7 +1153,17 @@ class statistics {
859
1153
  legend: {
860
1154
  top: 35,
861
1155
  left: 'center',
862
- data: ['Solar Yield', 'Consumption', 'Grid Export', 'Grid Import', 'Charge', 'Discharge', ...(showSOC ? ['SOC'] : [])],
1156
+ data: [
1157
+ 'Solar Yield',
1158
+ 'Grid Export',
1159
+ 'Grid Import',
1160
+ 'Charge',
1161
+ 'Discharge',
1162
+ ...(showSOC ? ['SOC'] : []),
1163
+ 'Self-sufficiency',
1164
+ 'Self-consumption',
1165
+ 'Consumption',
1166
+ ],
863
1167
  },
864
1168
  tooltip: {
865
1169
  trigger: 'axis',
@@ -888,9 +1192,25 @@ class statistics {
888
1192
  saveAsImage: { show: true },
889
1193
  },
890
1194
  },
1195
+ // No-data graphic — shown only when data is empty
1196
+ graphic:
1197
+ xAxisData.length === 0
1198
+ ? [
1199
+ {
1200
+ type: 'text',
1201
+ left: 'center',
1202
+ top: 'middle',
1203
+ style: {
1204
+ text: noDataHints[myChart] || 'No data available yet.',
1205
+ fontSize: 14,
1206
+ fill: '#999',
1207
+ },
1208
+ },
1209
+ ]
1210
+ : [],
891
1211
  grid: [
892
- { left: '8%', right: showSOC ? '8%' : '4%', top: 80, height: '50%' },
893
- { left: '8%', right: showSOC ? '8%' : '4%', top: '75%', height: '15%' },
1212
+ { left: '8%', right: showSOC ? '8%' : '4%', top: 80, height: '45%' },
1213
+ { left: '8%', right: showSOC ? '8%' : '4%', top: '72%', height: '15%' },
894
1214
  ],
895
1215
  xAxis: [
896
1216
  {
@@ -915,7 +1235,7 @@ class statistics {
915
1235
  gridIndex: 1,
916
1236
  data: xAxisData,
917
1237
  scale: true,
918
- boundaryGap: false,
1238
+ boundaryGap: chartStyle !== 'line',
919
1239
  axisLine: { onZero: false },
920
1240
  axisTick: { show: false },
921
1241
  splitLine: { show: false },
@@ -925,7 +1245,7 @@ class statistics {
925
1245
  },
926
1246
  ],
927
1247
  yAxis: [
928
- // Index 0 — Energie links
1248
+ // Index 0 — Energy left axis
929
1249
  {
930
1250
  scale: false,
931
1251
  splitArea: { show: true },
@@ -936,23 +1256,19 @@ class statistics {
936
1256
  splitLine: { show: true },
937
1257
  axisLine: { show: true },
938
1258
  },
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
1259
+ // Index 1 — SOC / ratio right axis
1260
+ {
1261
+ type: 'value',
1262
+ min: 0,
1263
+ max: 100,
1264
+ name: showSOC ? 'SOC / Ratio (%)' : 'Ratio (%)',
1265
+ nameLocation: 'middle',
1266
+ nameGap: 50,
1267
+ axisLabel: { formatter: '{value} %' },
1268
+ splitLine: { show: false },
1269
+ axisLine: { show: true },
1270
+ },
1271
+ // Index 2 Consumption lower grid
956
1272
  {
957
1273
  scale: true,
958
1274
  gridIndex: 1,
@@ -967,11 +1283,23 @@ class statistics {
967
1283
  },
968
1284
  ],
969
1285
  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 },
1286
+ {
1287
+ type: 'inside',
1288
+ xAxisIndex: [0, 1],
1289
+ start: slider.start,
1290
+ end: slider.end,
1291
+ },
1292
+ {
1293
+ show: true,
1294
+ xAxisIndex: [0, 1],
1295
+ type: 'slider',
1296
+ bottom: 5,
1297
+ start: slider.start,
1298
+ end: slider.end,
1299
+ },
972
1300
  ],
973
1301
  series: [
974
- // Positive Werte
1302
+ // Positive values (above zero line)
975
1303
  {
976
1304
  name: 'Solar Yield',
977
1305
  type: seriesType,
@@ -980,40 +1308,40 @@ class statistics {
980
1308
  emphasis: { focus: 'series' },
981
1309
  ...lineOptions,
982
1310
  },
1311
+ // Negative values (below zero line)
983
1312
  {
984
- name: 'Grid Import',
1313
+ name: 'Grid Export',
985
1314
  type: seriesType,
986
- data: seriesData.gridImport,
987
- itemStyle: { color: '#ec0000' },
1315
+ data: negate(seriesData.gridExport),
1316
+ itemStyle: { color: '#5cb85c' },
988
1317
  emphasis: { focus: 'series' },
989
1318
  ...lineOptions,
990
1319
  },
991
1320
  {
992
- name: 'Discharge',
1321
+ name: 'Grid Import',
993
1322
  type: seriesType,
994
- data: seriesData.dischargeCapacity,
995
- itemStyle: { color: '#ed50e0' },
1323
+ data: seriesData.gridImport,
1324
+ itemStyle: { color: '#ec0000' },
996
1325
  emphasis: { focus: 'series' },
997
1326
  ...lineOptions,
998
1327
  },
999
- // Negative Werte (unterhalb Nulllinie)
1000
1328
  {
1001
- name: 'Grid Export',
1329
+ name: 'Charge',
1002
1330
  type: seriesType,
1003
- data: negate(seriesData.gridExport),
1004
- itemStyle: { color: '#5cb85c' },
1331
+ data: negate(seriesData.chargeCapacity),
1332
+ itemStyle: { color: '#5bc0de' },
1005
1333
  emphasis: { focus: 'series' },
1006
1334
  ...lineOptions,
1007
1335
  },
1008
1336
  {
1009
- name: 'Charge',
1337
+ name: 'Discharge',
1010
1338
  type: seriesType,
1011
- data: negate(seriesData.chargeCapacity),
1012
- itemStyle: { color: '#5bc0de' },
1339
+ data: seriesData.dischargeCapacity,
1340
+ itemStyle: { color: '#ed50e0' },
1013
1341
  emphasis: { focus: 'series' },
1014
1342
  ...lineOptions,
1015
1343
  },
1016
- // SOC — nur bei hourly
1344
+ // SOC — hourly only, on right axis
1017
1345
  ...(showSOC
1018
1346
  ? [
1019
1347
  {
@@ -1028,18 +1356,41 @@ class statistics {
1028
1356
  },
1029
1357
  ]
1030
1358
  : []),
1031
- // Consumption im unteren Grid
1032
- // yAxisIndex passt sich an: 2 wenn SOC vorhanden, sonst 1
1359
+ // Self-sufficiency right axis
1360
+ {
1361
+ name: 'Self-sufficiency',
1362
+ type: 'line',
1363
+ yAxisIndex: 1,
1364
+ data: seriesData.selfSufficiency,
1365
+ itemStyle: { color: '#9c27b0' },
1366
+ lineStyle: { width: 2, type: 'dashed' },
1367
+ symbol: 'circle',
1368
+ symbolSize: 4,
1369
+ smooth: true,
1370
+ },
1371
+ // Self-consumption — right axis
1372
+ {
1373
+ name: 'Self-consumption',
1374
+ type: 'line',
1375
+ yAxisIndex: 1,
1376
+ data: seriesData.selfConsumption,
1377
+ itemStyle: { color: '#ff9800' },
1378
+ lineStyle: { width: 2, type: 'dashed' },
1379
+ symbol: 'circle',
1380
+ symbolSize: 4,
1381
+ smooth: true,
1382
+ },
1383
+ // Consumption in lower grid — always yAxisIndex 2
1033
1384
  {
1034
1385
  name: 'Consumption',
1035
1386
  type: seriesType,
1036
1387
  data: seriesData.consumption,
1037
1388
  itemStyle: { color: '#337ab7' },
1038
1389
  xAxisIndex: 1,
1039
- yAxisIndex: showSOC ? 2 : 1,
1390
+ yAxisIndex: 2,
1040
1391
  ...lineOptions,
1041
1392
  },
1042
- // Tages-Bereiche
1393
+ // Day-break areas (hourly only)
1043
1394
  ...(dayAreas.length > 0
1044
1395
  ? [
1045
1396
  {
@@ -1059,6 +1410,11 @@ class statistics {
1059
1410
  return stringify(chart).replace("'%%xAxisFormatter%%'", stringify(xAxisFormatterHourly)).replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
1060
1411
  }
1061
1412
 
1413
+ /**
1414
+ * Merges two objects deeply.
1415
+ * @param target
1416
+ * @param source
1417
+ */
1062
1418
  _deepMerge(target, source) {
1063
1419
  for (const key of Object.keys(source)) {
1064
1420
  if (
@@ -1069,16 +1425,19 @@ class statistics {
1069
1425
  typeof target[key] === 'object' &&
1070
1426
  !Array.isArray(target[key])
1071
1427
  ) {
1072
- // Rekursiv für verschachtelte Objekte
1073
1428
  this._deepMerge(target[key], source[key]);
1074
1429
  } else {
1075
- // Primitive, Arrays direkt überschreiben
1076
1430
  target[key] = source[key];
1077
1431
  }
1078
1432
  }
1079
1433
  return target;
1080
1434
  }
1081
1435
 
1436
+ /**
1437
+ * Handles a template state change — updates cache, acknowledges and rebuilds chart.
1438
+ * @param chartType
1439
+ * @param state
1440
+ */
1082
1441
  async handleTemplateChange(chartType, state) {
1083
1442
  const templateStateId = `statistics.flexCharts.template.${chartType}`;
1084
1443
  const template = this.stateCache.get(templateStateId)?.value;
@@ -1097,7 +1456,7 @@ class statistics {
1097
1456
  /**
1098
1457
  * Entry point for adapter to handle messages related to statistics/flexcharts.
1099
1458
  *
1100
- * @param {{chart?: string}} message
1459
+ * @param {{chart?: string, style?: string}} message
1101
1460
  * @param {Function} callback
1102
1461
  */
1103
1462
  handleFlexMessage(message, callback) {