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