iobroker.sun2000 2.4.4 → 2.5.0

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 DELETED
@@ -1,1477 +0,0 @@
1
- /**
2
- statistics.js
3
-
4
- This module prepares statistical data based on historical datapoints from the
5
- Huawei SUN2000 inverter states. It aggregates raw values into structured
6
- time-based datasets (e.g., hourly, daily, monthly, yearly) that can be used
7
- for further analysis or visualization.
8
-
9
- The goal of this processing layer is to provide normalized statistical data
10
- independent from the raw state history.
11
-
12
- In the mid-term, these statistics are intended to be visualized graphically
13
- in ioBroker VIS using the ioBroker.flexcharts adapter.
14
- */
15
-
16
- 'use strict';
17
-
18
- const stringify = require('javascript-stringify').stringify;
19
- const { dataRefreshRate, statisticsType } = require(`${__dirname}/types.js`);
20
- const tools = require(`${__dirname}/tools.js`);
21
-
22
- class statistics {
23
- constructor(adapterInstance, stateCache) {
24
- this.adapter = adapterInstance;
25
- this.stateCache = stateCache;
26
- this.taskTimer = null;
27
- this._path = 'statistics';
28
- this._initialized = false;
29
- this.testing = false; // set to true for testing purposes
30
-
31
- this.stats = [
32
- {
33
- sourceId: 'collected.consumptionToday',
34
- targetPath: 'consumption',
35
- unit: 'kWh',
36
- type: statisticsType.deltaReset,
37
- },
38
- {
39
- sourceId: 'collected.dailySolarYield',
40
- targetPath: 'solarYield',
41
- unit: 'kWh',
42
- type: statisticsType.deltaReset,
43
- },
44
- { sourceId: 'collected.dailyInputYield', targetPath: 'inputYield', unit: 'kWh', type: statisticsType.deltaReset },
45
- { sourceId: 'collected.dailyExternalYield', targetPath: 'externalYield', unit: 'kWh', type: statisticsType.deltaReset },
46
- { sourceId: 'collected.dailyEnergyYield', targetPath: 'energyYield', unit: 'kWh', type: statisticsType.deltaReset },
47
- {
48
- sourceId: 'collected.SOC',
49
- targetPath: 'SOC',
50
- unit: '%',
51
- type: statisticsType.level,
52
- },
53
- { sourceId: 'collected.currentDayChargeCapacity', targetPath: 'chargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
54
- { sourceId: 'collected.currentDayDischargeCapacity', targetPath: 'dischargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
55
- { sourceId: 'collected.gridExportToday', targetPath: 'gridExport', unit: 'kWh', type: statisticsType.deltaReset },
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
- },
89
- ];
90
-
91
- this.postProcessHooks = [
92
- {
93
- refresh: dataRefreshRate.low,
94
- states: [
95
- {
96
- id: 'statistics.jsonHourly',
97
- name: 'Hourly consumption JSON',
98
- type: 'string',
99
- role: 'json',
100
- desc: 'Hourly consumption for last and current day per full hour',
101
- initVal: '[]',
102
- },
103
- {
104
- id: 'statistics.jsonDaily',
105
- name: 'Daily consumption JSON',
106
- type: 'string',
107
- role: 'json',
108
- desc: 'Daily consumption for current month per day',
109
- initVal: '[]',
110
- },
111
- {
112
- id: 'statistics.jsonWeekly',
113
- name: 'Weekly consumption JSON',
114
- type: 'string',
115
- role: 'json',
116
- desc: 'Weekly consumption for current year per week',
117
- initVal: '[]',
118
- },
119
- {
120
- id: 'statistics.jsonMonthly',
121
- name: 'Monthly consumption JSON',
122
- type: 'string',
123
- role: 'json',
124
- desc: 'Monthly consumption for current year per month',
125
- initVal: '[]',
126
- },
127
- {
128
- id: 'statistics.jsonAnnual',
129
- name: 'Annual consumption JSON',
130
- type: 'string',
131
- role: 'json',
132
- desc: 'Annual consumption per year',
133
- initVal: '[]',
134
- },
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
145
- {
146
- id: 'statistics.flexCharts.template.hourly',
147
- name: 'Flexcharts template hourly',
148
- type: 'string',
149
- role: 'json',
150
- desc: 'Optional eCharts template for hourly chart. Leave empty {} for built-in layout.',
151
- write: true,
152
- initVal: '{}',
153
- },
154
- {
155
- id: 'statistics.flexCharts.template.daily',
156
- name: 'Flexcharts template daily',
157
- type: 'string',
158
- role: 'json',
159
- desc: 'Optional eCharts template for daily chart. Leave empty {} for built-in layout.',
160
- write: true,
161
- initVal: '{}',
162
- },
163
- {
164
- id: 'statistics.flexCharts.template.weekly',
165
- name: 'Flexcharts template weekly',
166
- type: 'string',
167
- role: 'json',
168
- desc: 'Optional eCharts template for weekly chart. Leave empty {} for built-in layout.',
169
- write: true,
170
- initVal: '{}',
171
- },
172
- {
173
- id: 'statistics.flexCharts.template.monthly',
174
- name: 'Flexcharts template monthly',
175
- type: 'string',
176
- role: 'json',
177
- desc: 'Optional eCharts template for monthly chart. Leave empty {} for built-in layout.',
178
- write: true,
179
- initVal: '{}',
180
- },
181
- {
182
- id: 'statistics.flexCharts.template.annual',
183
- name: 'Flexcharts template annual',
184
- type: 'string',
185
- role: 'json',
186
- desc: 'Optional eCharts template for annual chart. Leave empty {} for built-in layout.',
187
- write: true,
188
- initVal: '{}',
189
- },
190
- // Output: one per chart type
191
- {
192
- id: 'statistics.flexCharts.jsonOutput.hourly',
193
- name: 'Flexcharts output hourly',
194
- type: 'string',
195
- role: 'json',
196
- desc: 'ECharts configuration for hourly chart',
197
- initVal: '{}',
198
- },
199
- {
200
- id: 'statistics.flexCharts.jsonOutput.daily',
201
- name: 'Flexcharts output daily',
202
- type: 'string',
203
- role: 'json',
204
- desc: 'ECharts configuration for daily chart',
205
- initVal: '{}',
206
- },
207
- {
208
- id: 'statistics.flexCharts.jsonOutput.weekly',
209
- name: 'Flexcharts output weekly',
210
- type: 'string',
211
- role: 'json',
212
- desc: 'ECharts configuration for weekly chart',
213
- initVal: '{}',
214
- },
215
- {
216
- id: 'statistics.flexCharts.jsonOutput.monthly',
217
- name: 'Flexcharts output monthly',
218
- type: 'string',
219
- role: 'json',
220
- desc: 'ECharts configuration for monthly chart',
221
- initVal: '{}',
222
- },
223
- {
224
- id: 'statistics.flexCharts.jsonOutput.annual',
225
- name: 'Flexcharts output annual',
226
- type: 'string',
227
- role: 'json',
228
- desc: 'ECharts configuration for annual chart',
229
- initVal: '{}',
230
- },
231
- ],
232
- },
233
- ];
234
- this.initialize();
235
- }
236
-
237
- get processHooks() {
238
- return this.postProcessHooks;
239
- }
240
-
241
- /**
242
- * Helper function to format date as ISO string with timezone offset.
243
- *
244
- * @param {Date} d - The date to format
245
- * @returns {string} ISO formatted date string with timezone offset
246
- */
247
- _localIsoWithOffset(d) {
248
- const pad = n => String(n).padStart(2, '0');
249
- const year = d.getFullYear();
250
- const month = pad(d.getMonth() + 1);
251
- const day = pad(d.getDate());
252
- const hours = pad(d.getHours());
253
- const minutes = pad(d.getMinutes());
254
- const seconds = pad(d.getSeconds());
255
- const tzOffset = -d.getTimezoneOffset();
256
- const sign = tzOffset >= 0 ? '+' : '-';
257
- const absMin = Math.abs(tzOffset);
258
- const offH = pad(Math.floor(absMin / 60));
259
- const offM = pad(absMin % 60);
260
- return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000${sign}${offH}:${offM}`;
261
- }
262
-
263
- /**
264
- * Generic function to calculate consumption statistics for different time periods.
265
- *
266
- * @param {string} stateId - The state ID for storing the JSON
267
- * @param {Date} periodStart - The start of the current period
268
- * @param {Date} periodEnde - The end of the current period
269
- * @returns {boolean} true if a new entry was appended, false otherwise.
270
- */
271
- _calculateGeneric(stateId, periodStart, periodEnde) {
272
- const toStr = this._localIsoWithOffset(periodEnde);
273
- let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
274
- let arr = [];
275
- try {
276
- arr = JSON.parse(jsonStr);
277
- if (!Array.isArray(arr)) arr = [];
278
- } catch {
279
- arr = [];
280
- }
281
- let fromDate = periodStart;
282
- let last = {};
283
- if (arr.length > 0) {
284
- last = arr[arr.length - 1];
285
- if (last.to === toStr) return false;
286
- const lastToDate = new Date(last.to);
287
- const toDate = new Date(toStr);
288
- if (lastToDate >= periodStart || toDate <= periodStart) {
289
- fromDate = lastToDate;
290
- }
291
- }
292
-
293
- const entry = {
294
- from: this._localIsoWithOffset(fromDate),
295
- to: toStr,
296
- };
297
-
298
- // First pass: deltaReset, delta, level stats
299
- for (const stat of this.stats) {
300
- if (stat.type === statisticsType.computed) continue;
301
-
302
- const source = this.stateCache.get(stat.sourceId)?.value;
303
- if (source === null || source === undefined) {
304
- this.adapter.logger.warn(`Source state ${stat.sourceId} not found statistic hook`);
305
- continue;
306
- }
307
- let value = Number(source);
308
- if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
309
- const lastTotal = Number(last[stat.targetPath]?.['total'] ?? 0);
310
- if (stat.type === statisticsType.deltaReset) {
311
- if (fromDate.getTime() !== periodStart.getTime()) {
312
- value -= lastTotal;
313
- }
314
- } else {
315
- if (last[stat.targetPath]?.['total'] === undefined) {
316
- this.adapter.logger.debug(`No total value found for ${stat.targetPath} in last entry, setting delta to 0`);
317
- value = 0;
318
- } else {
319
- value -= lastTotal;
320
- }
321
- }
322
- }
323
- value = Math.round((Number(value) + Number.EPSILON) * 1000) / 1000;
324
- entry[stat.targetPath] = { value: Number(value.toFixed(3)) };
325
- if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
326
- entry[stat.targetPath].total = Number(source.toFixed(3));
327
- }
328
- entry[stat.targetPath].unit = stat.unit || 'kWh';
329
- }
330
-
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
- }
345
-
346
- arr.push(entry);
347
- arr.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
348
- this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
349
- this.adapter.logger.debug(`Appended ${stateId} statistic ${toStr}`);
350
- return arr.length > 0;
351
- }
352
-
353
- /**
354
- * Removes entries older than periodStart from the given state.
355
- *
356
- * @param {string} stateId
357
- * @param {Date} periodStart
358
- */
359
- _clearGeneric(stateId, periodStart) {
360
- let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
361
- let arr = [];
362
- try {
363
- arr = JSON.parse(jsonStr);
364
- if (!Array.isArray(arr)) arr = [];
365
- } catch {
366
- arr = [];
367
- }
368
- arr = arr.filter(item => {
369
- const ts = Date.parse(item.from);
370
- return !Number.isNaN(ts) && ts >= periodStart.getTime();
371
- });
372
- this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
373
- }
374
-
375
- // eslint-disable-next-line jsdoc/require-returns-check
376
- /**
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.
382
- *
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
389
- */
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) {
525
- try {
526
- const now = new Date();
527
- const window = getWindow(now);
528
- const fromDate = window.from;
529
- const toDate = window.to;
530
- if (now < toDate) {
531
- this.adapter.logger.debug(`statistics.js: Skipping ${periodType} aggregation because current time is before end of aggregation window`);
532
- return false;
533
- }
534
- const toStr = this._localIsoWithOffset(toDate);
535
- //const fromStr = this._localIsoWithOffset(fromDate);
536
-
537
- let jsonTarget = this.stateCache.get(targetStateId)?.value ?? '[]';
538
- let targetArray = [];
539
- try {
540
- targetArray = JSON.parse(jsonTarget);
541
- if (!Array.isArray(targetArray)) targetArray = [];
542
- } catch {
543
- targetArray = [];
544
- }
545
-
546
- const last = targetArray.length > 0 ? targetArray[targetArray.length - 1] : {};
547
-
548
- if (last.to === toStr) return false;
549
-
550
- const target = {
551
- from: this._localIsoWithOffset(fromDate),
552
- to: toStr,
553
- };
554
-
555
- let jsonStr = this.stateCache.get(sourceStateId)?.value ?? '[]';
556
- let sourceEntries = [];
557
- try {
558
- sourceEntries = JSON.parse(jsonStr);
559
- if (!Array.isArray(sourceEntries)) sourceEntries = [];
560
- } catch {
561
- sourceEntries = [];
562
- }
563
-
564
- sourceEntries = sourceEntries.filter(item => {
565
- const ts = Date.parse(item.from);
566
- return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < toDate.getTime();
567
- });
568
-
569
- if (sourceEntries.length > 0) {
570
- this.adapter.logger.debug(
571
- `statistics.js: Found ${sourceEntries.length} source entries for ${periodType} aggregation between ${fromDate.toISOString()} and ${toDate.toISOString()}`,
572
- );
573
-
574
- // First pass: sum delta/deltaReset stats
575
- for (const stat of this.stats) {
576
- if (stat.type === statisticsType.level) continue;
577
- if (stat.type === statisticsType.computed) continue;
578
-
579
- let sum = 0;
580
- try {
581
- sourceEntries.forEach(entry => {
582
- sum += Number(entry[stat.targetPath]?.['value'] ?? 0);
583
- });
584
- } catch (e) {
585
- this.adapter.logger.warn(`statistics.js: Error during ${periodType} statistic aggregation: ${e.message}`);
586
- }
587
- sum = Math.round((Number(sum) + Number.EPSILON) * 1000) / 1000;
588
- target[stat.targetPath] = { value: Number(sum.toFixed(3)), unit: stat.unit || 'kWh' };
589
- }
590
-
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
- }
604
- }
605
-
606
- targetArray.push(target);
607
- }
608
-
609
- targetArray.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
610
- this.stateCache.set(targetStateId, JSON.stringify(targetArray), { type: 'string' });
611
- this.adapter.logger.debug(`Appended ${periodType} statistic ${toStr}`);
612
- return targetArray.length > 0;
613
- } catch (err) {
614
- this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
615
- }
616
- }
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
-
681
- /**
682
- * Calculates and updates hourly consumption statistics.
683
- */
684
- _calculateHourly() {
685
- const now = new Date();
686
- if (this.testing) {
687
- const state = this.adapter.getState('statistics.jsonHourly');
688
- this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
689
- now.setDate(now.getDate() + 1);
690
- now.setHours(1, 0, 0, 1);
691
- }
692
- const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
693
- const lastHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0);
694
- this.adapter.log.debug(`### Hourly execution triggered with lastHour: ${lastHour.toLocaleTimeString()} ###`);
695
- if (this._calculateGeneric('statistics.jsonHourly', startOfDay, lastHour)) {
696
- this._buildFlexchart('hourly');
697
- }
698
- }
699
-
700
- _calculateDaily() {
701
- this.adapter.log.debug('### Daily execution triggered ###');
702
-
703
- // Abgeschlossener Vortag — normaler Eintrag
704
- this._calculateAggregation(
705
- 'statistics.jsonHourly',
706
- 'statistics.jsonDaily',
707
- now => {
708
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
709
- const yesterday = new Date(today);
710
- yesterday.setDate(today.getDate() - 1);
711
- return { from: yesterday, to: today };
712
- },
713
- 'daily',
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');
728
- }
729
-
730
- /**
731
- * Calculates and updates weekly consumption statistics from daily data.
732
- */
733
- _calculateWeekly() {
734
- this.adapter.log.debug('### Weekly execution triggered ###');
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');
766
- }
767
-
768
- /**
769
- * Calculates and updates monthly consumption statistics from daily data.
770
- */
771
- _calculateMonthly() {
772
- this.adapter.log.debug('### Monthly execution triggered ###');
773
- this._calculateAggregation(
774
- 'statistics.jsonDaily',
775
- 'statistics.jsonMonthly',
776
- now => {
777
- const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
778
- const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1, 0, 0, 0, 0);
779
- return { from: prevMonth, to: thisMonth };
780
- },
781
- 'monthly',
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');
795
- }
796
-
797
- /**
798
- * Calculates and updates annual consumption statistics from daily data.
799
- */
800
- _calculateAnnual() {
801
- this.adapter.log.debug('### Annual execution triggered ###');
802
- this._calculateAggregation(
803
- 'statistics.jsonDaily',
804
- 'statistics.jsonAnnual',
805
- now => {
806
- const thisYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
807
- const prevYear = new Date(now.getFullYear() - 1, 0, 1, 0, 0, 0, 0);
808
- return { from: prevYear, to: thisYear };
809
- },
810
- 'annual',
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');
824
- }
825
-
826
- /**
827
- * Initialize and schedule the unified task manager.
828
- * Runs every full hour.
829
- */
830
- _initializeTask() {
831
- const scheduleNextRun = () => {
832
- const now = new Date();
833
-
834
- const next = new Date(now);
835
- if (this.testing) {
836
- next.setMinutes(now.getMinutes() + 1, 0, 0);
837
- } else {
838
- next.setHours(next.getHours() + 1, 0, 0, 100);
839
- }
840
- if (next.getHours() === 0 && next.getMinutes() === 0) {
841
- next.setHours(next.getHours() + 1, 0, 0, 0);
842
- }
843
- const msToNextHour = next.getTime() - now.getTime();
844
- this.adapter.logger.debug(`### Statistics - Scheduler start ${now.toLocaleTimeString()} next ${next.toLocaleTimeString()}`);
845
-
846
- if (this.taskTimer) {
847
- this.adapter.clearTimeout(this.taskTimer);
848
- }
849
- this.taskTimer = this.adapter.setTimeout(() => {
850
- this._executeScheduledTasks();
851
- scheduleNextRun();
852
- }, msToNextHour);
853
- };
854
- scheduleNextRun();
855
- }
856
-
857
- _executeScheduledTasks() {
858
- this._calculateHourly();
859
- this._calculateDaily();
860
- this._calculateWeekly();
861
- this._calculateMonthly();
862
- this._calculateAnnual();
863
- }
864
-
865
- /**
866
- * Executes every midnight:
867
- * - Runs all scheduled tasks
868
- * - Clears old data based on retention policies
869
- */
870
- mitNightProcess() {
871
- const now = new Date();
872
- this._executeScheduledTasks();
873
- const startOfYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
874
- this._clearGeneric('statistics.jsonDaily', startOfYear);
875
- this._clearGeneric('statistics.jsonWeekly', startOfYear);
876
- this._clearGeneric('statistics.jsonMonthly', startOfYear);
877
- this._clearGeneric('statistics.jsonHourly', new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0));
878
- }
879
-
880
- async initialize() {
881
- let state = await this.adapter.getState('statistics.jsonHourly');
882
- this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
883
-
884
- state = await this.adapter.getState('statistics.jsonDaily');
885
- this.stateCache.set('statistics.jsonDaily', state?.val ?? '[]', { type: 'string', stored: true });
886
-
887
- state = await this.adapter.getState('statistics.jsonWeekly');
888
- this.stateCache.set('statistics.jsonWeekly', state?.val ?? '[]', { type: 'string', stored: true });
889
-
890
- state = await this.adapter.getState('statistics.jsonMonthly');
891
- this.stateCache.set('statistics.jsonMonthly', state?.val ?? '[]', { type: 'string', stored: true });
892
-
893
- state = await this.adapter.getState('statistics.jsonAnnual');
894
- this.stateCache.set('statistics.jsonAnnual', state?.val ?? '[]', { type: 'string', stored: true });
895
-
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
- }
905
-
906
- // Load templates — one per chart type
907
- for (const chartType of ['hourly', 'daily', 'weekly', 'monthly', 'annual']) {
908
- const templateStateId = `statistics.flexCharts.template.${chartType}`;
909
- state = await this.adapter.getState(templateStateId);
910
- this.stateCache.set(templateStateId, state?.val ?? '{}', { type: 'string', stored: true });
911
- if (state?.ack === false) {
912
- this.stateCache.set(templateStateId, state.val, { type: 'string' });
913
- await this.adapter.setState(templateStateId, { val: state.val, ack: true });
914
- this._buildFlexchart(chartType);
915
- }
916
- }
917
-
918
- this.mitNightProcess();
919
- this._initializeTask();
920
- this.adapter.subscribeStates(`${this._path}.*`);
921
- this._initialized = true;
922
- }
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
- */
931
- _buildFlexchart(myChart, chartStyle) {
932
- chartStyle = chartStyle || (myChart === 'hourly' ? 'line' : 'bar');
933
-
934
- const IDS = {
935
- hourly: 'statistics.jsonHourly',
936
- daily: 'statistics.jsonDaily',
937
- weekly: 'statistics.jsonWeekly',
938
- monthly: 'statistics.jsonMonthly',
939
- annual: 'statistics.jsonAnnual',
940
- };
941
- const id = IDS[myChart] || IDS.hourly;
942
- let data = [];
943
- try {
944
- data = JSON.parse(this.stateCache.get(id)?.value ?? '[]');
945
- } catch {
946
- data = [];
947
- }
948
-
949
- // --- X-Axis labels ---
950
- const xAxisData = data.map(entry => {
951
- const from = new Date(entry.from);
952
- const to = new Date(entry.to);
953
- if (myChart === 'hourly') {
954
- return `${to.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })} ${to.toLocaleTimeString('de-DE', {
955
- hour12: false,
956
- hour: '2-digit',
957
- minute: '2-digit',
958
- })}`;
959
- }
960
- if (myChart === 'weekly') {
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' })}`;
967
- }
968
- if (myChart === 'monthly') {
969
- return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit' });
970
- }
971
- if (myChart === 'annual') {
972
- return from.toLocaleDateString('de-DE', { year: 'numeric' });
973
- }
974
- return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
975
- });
976
-
977
- const xAxisDataShort = myChart === 'hourly' ? xAxisData.map(label => label.split(' ')[1]) : xAxisData;
978
-
979
- // --- Day areas (hourly only) ---
980
- const dayAreas = [];
981
- if (myChart === 'hourly' && xAxisData.length > 0) {
982
- const dayBoundaries = [0];
983
- xAxisData.forEach((label, i) => {
984
- if (i === 0) return;
985
- const date = label.split(' ')[0];
986
- const prevDate = xAxisData[i - 1].split(' ')[0];
987
- if (date !== prevDate) dayBoundaries.push(i);
988
- });
989
- dayBoundaries.push(xAxisData.length);
990
-
991
- dayBoundaries.forEach((startIdx, d) => {
992
- if (d >= dayBoundaries.length - 1) return;
993
- const endIdx = dayBoundaries[d + 1];
994
- const date = xAxisData[startIdx].split(' ')[0];
995
- const shaded = d % 2 === 1;
996
- dayAreas.push([
997
- {
998
- xAxis: startIdx - 0.5,
999
- label: {
1000
- show: true,
1001
- position: 'insideTop',
1002
- formatter: date,
1003
- color: '#555',
1004
- fontSize: 11,
1005
- fontWeight: 'bold',
1006
- backgroundColor: 'rgba(255,255,255,0.7)',
1007
- padding: [2, 4],
1008
- borderRadius: 3,
1009
- },
1010
- },
1011
- {
1012
- xAxis: endIdx - 0.5,
1013
- itemStyle: shaded
1014
- ? { color: 'rgba(180,180,180,0.15)', borderColor: 'rgba(120,120,120,0.3)', borderWidth: 1, borderType: 'dashed' }
1015
- : { color: 'rgba(255,255,255,0)' },
1016
- },
1017
- ]);
1018
- });
1019
- }
1020
-
1021
- // --- Series data extraction ---
1022
- const extract = key => data.map(e => Number(Number(e[key]?.value ?? 0).toFixed(3)));
1023
- const negate = arr => arr.map(v => Number((-v).toFixed(3)));
1024
-
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
- }
1032
-
1033
- // --- Tooltip formatter ---
1034
- const tooltipFormatter = params => {
1035
- if (!Array.isArray(params)) params = [params];
1036
- return params
1037
- .filter(p => p.seriesName !== 'DayBreak')
1038
- .map(p => {
1039
- const negatedSeries = ['Grid Export', 'Charge'];
1040
- const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
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>`;
1045
- })
1046
- .join('<br/>');
1047
- };
1048
-
1049
- // --- Load chart-type specific template ---
1050
- const templateStateId = `statistics.flexCharts.template.${myChart}`;
1051
- const outputStateId = `statistics.flexCharts.jsonOutput.${myChart}`;
1052
-
1053
- const templateStr = this.stateCache.get(templateStateId)?.value ?? '{}';
1054
- let chartStr = '{}';
1055
-
1056
- try {
1057
- const templ = JSON.parse(templateStr);
1058
- if (Object.keys(templ).length === 0) {
1059
- chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
1060
- } else {
1061
- chartStr = stringify(templ);
1062
- }
1063
- } catch (e) {
1064
- this.adapter.logger.warn(`statistics: invalid template for ${myChart}: ${e.message}`);
1065
- chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
1066
- }
1067
-
1068
- // --- Replace placeholders ---
1069
- chartStr = chartStr
1070
- .replace("'%%xAxisData%%'", JSON.stringify(xAxisData))
1071
- .replace("'%%xAxisDataShort%%'", JSON.stringify(xAxisDataShort))
1072
- .replace("'%%xAxisMax%%'", String(xAxisData.length - 1))
1073
- .replace("'%%chartTitle%%'", JSON.stringify(`PV Statistics — ${myChart}`))
1074
- .replace("'%%dayAreas%%'", JSON.stringify(dayAreas))
1075
- .replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
1076
-
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
-
1083
- this.stateCache.set(outputStateId, chartStr, { type: 'string' });
1084
- this.adapter.logger.debug(`statistics: flexCharts built for ${myChart}/${chartStyle}`);
1085
-
1086
- return chartStr;
1087
- }
1088
-
1089
- /**
1090
- * Build the default chart configuration as javascript-stringify string.
1091
- * Used when no template is provided.
1092
- * @param myChart
1093
- * @param chartStyle
1094
- * @param xAxisData
1095
- * @param xAxisDataShort
1096
- * @param dayAreas
1097
- * @param seriesData
1098
- */
1099
- _buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData) {
1100
- const xAxisFormatterHourly = value => {
1101
- if (value.includes('|')) return value;
1102
- return value.split(' ')[1] ?? value;
1103
- };
1104
-
1105
- const seriesType = chartStyle === 'line' ? 'line' : 'bar';
1106
- const lineOptions =
1107
- chartStyle === 'line' ? { smooth: true, symbol: 'circle', symbolSize: 4, lineStyle: { width: 2 }, areaStyle: { opacity: 0.15 } } : {};
1108
-
1109
- const negate = arr => arr.map(v => Number((-v).toFixed(3)));
1110
- const showSOC = myChart === 'hourly';
1111
-
1112
- // No-data hint — chart-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
-
1121
- const tooltipFormatter = params => {
1122
- if (!Array.isArray(params)) params = [params];
1123
- return params
1124
- .filter(p => p.seriesName !== 'DayBreak')
1125
- .map(p => {
1126
- const negatedSeries = ['Grid Export', 'Charge'];
1127
- const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
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>`;
1132
- })
1133
- .join('<br/>');
1134
- };
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
-
1151
- const chart = {
1152
- backgroundColor: '#fff',
1153
- animation: false,
1154
- title: {
1155
- left: 'center',
1156
- text: `SUN2000 - PV Statistics - ${myChart}`,
1157
- },
1158
- legend: {
1159
- top: 35,
1160
- left: 'center',
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
- ],
1172
- },
1173
- tooltip: {
1174
- trigger: 'axis',
1175
- axisPointer: { type: 'cross' },
1176
- backgroundColor: 'rgba(245,245,245,0.95)',
1177
- borderWidth: 1,
1178
- borderColor: '#ccc',
1179
- padding: 10,
1180
- textStyle: { color: '#000' },
1181
- formatter: '%%tooltipFormatter%%',
1182
- position: (pos, params, el, elRect, size) => {
1183
- const obj = { top: 10 };
1184
- obj[pos[0] < size.viewSize[0] / 2 ? 'left' : 'right'] = 30;
1185
- return obj;
1186
- },
1187
- },
1188
- axisPointer: {
1189
- link: [{ xAxisIndex: 'all' }],
1190
- label: { backgroundColor: '#777' },
1191
- },
1192
- toolbox: {
1193
- feature: {
1194
- dataZoom: { yAxisIndex: false },
1195
- dataView: { show: true, readOnly: false },
1196
- restore: { show: true },
1197
- saveAsImage: { show: true },
1198
- },
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
- : [],
1216
- grid: [
1217
- { left: '8%', right: showSOC ? '8%' : '4%', top: 80, height: '45%' },
1218
- { left: '8%', right: showSOC ? '8%' : '4%', top: '72%', height: '15%' },
1219
- ],
1220
- xAxis: [
1221
- {
1222
- type: 'category',
1223
- data: xAxisDataShort,
1224
- scale: true,
1225
- boundaryGap: chartStyle !== 'line',
1226
- axisLine: { onZero: false },
1227
- splitLine: { show: false },
1228
- axisPointer: { z: 100 },
1229
- min: 0,
1230
- max: xAxisDataShort.length - 1,
1231
- axisLabel: {
1232
- interval: 0,
1233
- lineHeight: 16,
1234
- fontSize: 11,
1235
- formatter: '%%xAxisFormatter%%',
1236
- },
1237
- },
1238
- {
1239
- type: 'category',
1240
- gridIndex: 1,
1241
- data: xAxisData,
1242
- scale: true,
1243
- boundaryGap: chartStyle !== 'line',
1244
- axisLine: { onZero: false },
1245
- axisTick: { show: false },
1246
- splitLine: { show: false },
1247
- axisLabel: { show: false },
1248
- min: 0,
1249
- max: xAxisData.length - 1,
1250
- },
1251
- ],
1252
- yAxis: [
1253
- // Index 0 — Energy left axis
1254
- {
1255
- scale: false,
1256
- splitArea: { show: true },
1257
- name: 'Energy (kWh)',
1258
- nameLocation: 'middle',
1259
- nameGap: 50,
1260
- axisLabel: { formatter: '{value} kWh' },
1261
- splitLine: { show: true },
1262
- axisLine: { show: true },
1263
- },
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
1277
- {
1278
- scale: true,
1279
- gridIndex: 1,
1280
- splitNumber: 3,
1281
- axisLine: { show: false },
1282
- axisTick: { show: false },
1283
- splitLine: { show: false },
1284
- name: 'Consumption\n(kWh)',
1285
- nameLocation: 'middle',
1286
- nameGap: 50,
1287
- axisLabel: { formatter: '{value}' },
1288
- },
1289
- ],
1290
- dataZoom: [
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
- },
1305
- ],
1306
- series: [
1307
- // Positive values (above zero line)
1308
- {
1309
- name: 'Solar Yield',
1310
- type: seriesType,
1311
- data: seriesData.solarYield,
1312
- itemStyle: { color: '#f6c94e' },
1313
- emphasis: { focus: 'series' },
1314
- ...lineOptions,
1315
- },
1316
- // Negative values (below zero line)
1317
- {
1318
- name: 'Grid Export',
1319
- type: seriesType,
1320
- data: negate(seriesData.gridExport),
1321
- itemStyle: { color: '#5cb85c' },
1322
- emphasis: { focus: 'series' },
1323
- ...lineOptions,
1324
- },
1325
- {
1326
- name: 'Grid Import',
1327
- type: seriesType,
1328
- data: seriesData.gridImport,
1329
- itemStyle: { color: '#ec0000' },
1330
- emphasis: { focus: 'series' },
1331
- ...lineOptions,
1332
- },
1333
- {
1334
- name: 'Charge',
1335
- type: seriesType,
1336
- data: negate(seriesData.chargeCapacity),
1337
- itemStyle: { color: '#5bc0de' },
1338
- emphasis: { focus: 'series' },
1339
- ...lineOptions,
1340
- },
1341
- {
1342
- name: 'Discharge',
1343
- type: seriesType,
1344
- data: seriesData.dischargeCapacity,
1345
- itemStyle: { color: '#ed50e0' },
1346
- emphasis: { focus: 'series' },
1347
- ...lineOptions,
1348
- },
1349
- // SOC — hourly only, on right axis
1350
- ...(showSOC
1351
- ? [
1352
- {
1353
- name: 'SOC',
1354
- type: 'line',
1355
- yAxisIndex: 1,
1356
- data: seriesData.SOC,
1357
- itemStyle: { color: '#985e24' },
1358
- lineStyle: { width: 2, type: 'dashed' },
1359
- symbol: 'none',
1360
- smooth: true,
1361
- },
1362
- ]
1363
- : []),
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
1389
- {
1390
- name: 'Consumption',
1391
- type: seriesType,
1392
- data: seriesData.consumption,
1393
- itemStyle: { color: '#337ab7' },
1394
- xAxisIndex: 1,
1395
- yAxisIndex: 2,
1396
- ...lineOptions,
1397
- },
1398
- // Day-break areas (hourly only)
1399
- ...(dayAreas.length > 0
1400
- ? [
1401
- {
1402
- name: 'DayBreak',
1403
- type: 'bar',
1404
- barWidth: 0,
1405
- data: [],
1406
- legendHoverLink: false,
1407
- silent: true,
1408
- markArea: { silent: true, data: dayAreas },
1409
- },
1410
- ]
1411
- : []),
1412
- ],
1413
- };
1414
-
1415
- return stringify(chart).replace("'%%xAxisFormatter%%'", stringify(xAxisFormatterHourly)).replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
1416
- }
1417
-
1418
- /**
1419
- * Merges two objects deeply.
1420
- * @param target
1421
- * @param source
1422
- */
1423
- _deepMerge(target, source) {
1424
- for (const key of Object.keys(source)) {
1425
- if (
1426
- source[key] !== null &&
1427
- typeof source[key] === 'object' &&
1428
- !Array.isArray(source[key]) &&
1429
- target[key] !== null &&
1430
- typeof target[key] === 'object' &&
1431
- !Array.isArray(target[key])
1432
- ) {
1433
- this._deepMerge(target[key], source[key]);
1434
- } else {
1435
- target[key] = source[key];
1436
- }
1437
- }
1438
- return target;
1439
- }
1440
-
1441
- /**
1442
- * Handles a template state change — updates cache, acknowledges and rebuilds chart.
1443
- * @param chartType
1444
- * @param state
1445
- */
1446
- async handleTemplateChange(chartType, state) {
1447
- const templateStateId = `statistics.flexCharts.template.${chartType}`;
1448
- const template = this.stateCache.get(templateStateId)?.value;
1449
- if (template === null || template === undefined) {
1450
- this.adapter.logger.warn(`Template state ${templateStateId} not found for handleTemplateChange`);
1451
- return;
1452
- }
1453
- if (state?.val != null) {
1454
- this.adapter.logger.debug(`statistics: Event - state: ${chartType} changed: ${state.val} ack: ${state.ack}`);
1455
- this.stateCache.set(templateStateId, state.val, { type: 'string', stored: true });
1456
- await this.adapter.setState(templateStateId, { val: state.val, ack: true });
1457
- this._buildFlexchart(chartType);
1458
- }
1459
- }
1460
-
1461
- /**
1462
- * Entry point for adapter to handle messages related to statistics/flexcharts.
1463
- *
1464
- * @param {{chart?: string, style?: string}} message
1465
- * @param {Function} callback
1466
- */
1467
- handleFlexMessage(message, callback) {
1468
- const chartType = message?.chart || 'hourly';
1469
- const chartStyle = message?.style;
1470
- const result = this._buildFlexchart(chartType, chartStyle);
1471
- if (callback && typeof callback === 'function') {
1472
- callback(result);
1473
- }
1474
- }
1475
- }
1476
-
1477
- module.exports = statistics;