iobroker.sun2000 2.4.0 → 2.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -262
- package/admin/i18n/de/translations.json +27 -27
- package/admin/i18n/es/translations.json +27 -27
- package/admin/i18n/fr/translations.json +27 -27
- package/admin/i18n/it/translations.json +27 -27
- package/admin/i18n/nl/translations.json +27 -27
- package/admin/i18n/pl/translations.json +27 -27
- package/admin/i18n/pt/translations.json +27 -27
- package/admin/i18n/ru/translations.json +27 -27
- package/admin/i18n/uk/translations.json +27 -27
- package/admin/i18n/zh-cn/translations.json +27 -27
- package/io-package.json +41 -40
- package/lib/modbus/modbus_server.js +27 -4
- package/lib/register.js +4 -1
- package/lib/statistics.js +1027 -286
- package/lib/types.js +1 -0
- package/main.js +88 -0
- package/package.json +5 -4
package/lib/statistics.js
CHANGED
|
@@ -15,6 +15,7 @@ in ioBroker VIS using the ioBroker.flexcharts adapter.
|
|
|
15
15
|
|
|
16
16
|
'use strict';
|
|
17
17
|
|
|
18
|
+
const stringify = require('javascript-stringify').stringify;
|
|
18
19
|
const { dataRefreshRate, statisticsType } = require(`${__dirname}/types.js`);
|
|
19
20
|
const tools = require(`${__dirname}/tools.js`);
|
|
20
21
|
|
|
@@ -23,32 +24,17 @@ class statistics {
|
|
|
23
24
|
this.adapter = adapterInstance;
|
|
24
25
|
this.stateCache = stateCache;
|
|
25
26
|
this.taskTimer = null;
|
|
27
|
+
this._path = 'statistics';
|
|
28
|
+
this._initialized = false;
|
|
26
29
|
this.testing = false; // set to true for testing purposes
|
|
27
|
-
// initialize to current time to avoid immediate backfill on startup
|
|
28
|
-
//const nowInit = new Date();
|
|
29
|
-
this.lastExecution = {
|
|
30
|
-
hourly: undefined,
|
|
31
|
-
daily: undefined,
|
|
32
|
-
weekly: undefined,
|
|
33
|
-
monthly: undefined,
|
|
34
|
-
annual: undefined,
|
|
35
|
-
};
|
|
36
30
|
|
|
37
31
|
this.stats = [
|
|
38
32
|
{
|
|
39
33
|
sourceId: 'collected.consumptionToday',
|
|
40
34
|
targetPath: 'consumption',
|
|
41
35
|
unit: 'kWh',
|
|
42
|
-
type: statisticsType.deltaReset,
|
|
43
|
-
},
|
|
44
|
-
/*
|
|
45
|
-
{
|
|
46
|
-
sourceId: 'collected.consumptionSum',
|
|
47
|
-
targetPath: 'consumptionSum',
|
|
48
|
-
unit: 'kWh',
|
|
49
|
-
type: statisticsType.delta,
|
|
36
|
+
type: statisticsType.deltaReset,
|
|
50
37
|
},
|
|
51
|
-
*/
|
|
52
38
|
{
|
|
53
39
|
sourceId: 'collected.dailySolarYield',
|
|
54
40
|
targetPath: 'solarYield',
|
|
@@ -56,23 +42,50 @@ class statistics {
|
|
|
56
42
|
type: statisticsType.deltaReset,
|
|
57
43
|
},
|
|
58
44
|
{ sourceId: 'collected.dailyInputYield', targetPath: 'inputYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
59
|
-
{
|
|
60
|
-
sourceId: 'collected.dailyExternalYield',
|
|
61
|
-
targetPath: 'externalYield',
|
|
62
|
-
unit: 'kWh',
|
|
63
|
-
type: statisticsType.deltaReset,
|
|
64
|
-
},
|
|
45
|
+
{ sourceId: 'collected.dailyExternalYield', targetPath: 'externalYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
65
46
|
{ sourceId: 'collected.dailyEnergyYield', targetPath: 'energyYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
66
47
|
{
|
|
67
48
|
sourceId: 'collected.SOC',
|
|
68
49
|
targetPath: 'SOC',
|
|
69
50
|
unit: '%',
|
|
70
|
-
type: statisticsType.level,
|
|
51
|
+
type: statisticsType.level,
|
|
71
52
|
},
|
|
72
53
|
{ sourceId: 'collected.currentDayChargeCapacity', targetPath: 'chargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
|
|
73
54
|
{ sourceId: 'collected.currentDayDischargeCapacity', targetPath: 'dischargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
|
|
74
55
|
{ sourceId: 'collected.gridExportToday', targetPath: 'gridExport', unit: 'kWh', type: statisticsType.deltaReset },
|
|
75
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
|
+
},
|
|
76
89
|
];
|
|
77
90
|
|
|
78
91
|
this.postProcessHooks = [
|
|
@@ -119,17 +132,102 @@ class statistics {
|
|
|
119
132
|
desc: 'Annual consumption per year',
|
|
120
133
|
initVal: '[]',
|
|
121
134
|
},
|
|
122
|
-
//
|
|
123
|
-
|
|
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
|
+
},
|
|
124
154
|
{
|
|
125
|
-
id: 'statistics.
|
|
126
|
-
name: 'Flexcharts template',
|
|
155
|
+
id: 'statistics.flexCharts.template.daily',
|
|
156
|
+
name: 'Flexcharts template daily',
|
|
127
157
|
type: 'string',
|
|
128
158
|
role: 'json',
|
|
129
|
-
desc: 'Optional eCharts
|
|
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',
|
|
130
229
|
initVal: '{}',
|
|
131
230
|
},
|
|
132
|
-
*/
|
|
133
231
|
],
|
|
134
232
|
},
|
|
135
233
|
];
|
|
@@ -166,13 +264,11 @@ class statistics {
|
|
|
166
264
|
* Generic function to calculate consumption statistics for different time periods.
|
|
167
265
|
*
|
|
168
266
|
* @param {string} stateId - The state ID for storing the JSON
|
|
169
|
-
* @param {string} consumptionKey - The state key for consumption value
|
|
170
267
|
* @param {Date} periodStart - The start of the current period
|
|
171
|
-
* @param {
|
|
172
|
-
* @returns {
|
|
268
|
+
* @param {Date} periodEnde - The end of the current period
|
|
269
|
+
* @returns {boolean} true if a new entry was appended, false otherwise.
|
|
173
270
|
*/
|
|
174
|
-
|
|
175
|
-
async _calculateGeneric(stateId, periodStart, periodEnde) {
|
|
271
|
+
_calculateGeneric(stateId, periodStart, periodEnde) {
|
|
176
272
|
const toStr = this._localIsoWithOffset(periodEnde);
|
|
177
273
|
let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
|
|
178
274
|
let arr = [];
|
|
@@ -186,7 +282,6 @@ class statistics {
|
|
|
186
282
|
let last = {};
|
|
187
283
|
if (arr.length > 0) {
|
|
188
284
|
last = arr[arr.length - 1];
|
|
189
|
-
// avoid duplicates
|
|
190
285
|
if (last.to === toStr) return false;
|
|
191
286
|
const lastToDate = new Date(last.to);
|
|
192
287
|
const toDate = new Date(toStr);
|
|
@@ -200,7 +295,10 @@ class statistics {
|
|
|
200
295
|
to: toStr,
|
|
201
296
|
};
|
|
202
297
|
|
|
298
|
+
// First pass: deltaReset, delta, level stats
|
|
203
299
|
for (const stat of this.stats) {
|
|
300
|
+
if (stat.type === statisticsType.computed) continue;
|
|
301
|
+
|
|
204
302
|
const source = this.stateCache.get(stat.sourceId)?.value;
|
|
205
303
|
if (source === null || source === undefined) {
|
|
206
304
|
this.adapter.logger.warn(`Source state ${stat.sourceId} not found statistic hook`);
|
|
@@ -210,64 +308,55 @@ class statistics {
|
|
|
210
308
|
if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
|
|
211
309
|
const lastTotal = Number(last[stat.targetPath]?.['total'] ?? 0);
|
|
212
310
|
if (stat.type === statisticsType.deltaReset) {
|
|
213
|
-
//if (value >= lastTotal * 0.5) {
|
|
214
311
|
if (fromDate.getTime() !== periodStart.getTime()) {
|
|
215
|
-
// Delta-Berechnung
|
|
216
312
|
value -= lastTotal;
|
|
217
313
|
}
|
|
218
314
|
} else {
|
|
219
|
-
// Ein lastTotal-Wert vorhanden –> normale Delta-Berechnung
|
|
220
315
|
if (last[stat.targetPath]?.['total'] === undefined) {
|
|
221
|
-
// Kein lastTotal-Wert vorhanden –> wahrscheinlich erster Eintrag, Delta-Berechnung nicht möglich
|
|
222
316
|
this.adapter.logger.debug(`No total value found for ${stat.targetPath} in last entry, setting delta to 0`);
|
|
223
317
|
value = 0;
|
|
224
318
|
} else {
|
|
225
|
-
// Delta-Berechnung
|
|
226
319
|
value -= lastTotal;
|
|
227
320
|
}
|
|
228
321
|
}
|
|
229
322
|
}
|
|
230
323
|
value = Math.round((Number(value) + Number.EPSILON) * 1000) / 1000;
|
|
231
|
-
entry[stat.targetPath] = {
|
|
232
|
-
value: Number(value.toFixed(3)),
|
|
233
|
-
};
|
|
234
|
-
|
|
324
|
+
entry[stat.targetPath] = { value: Number(value.toFixed(3)) };
|
|
235
325
|
if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
|
|
236
326
|
entry[stat.targetPath].total = Number(source.toFixed(3));
|
|
237
327
|
}
|
|
238
|
-
entry[stat.targetPath].unit = stat.unit || 'kWh';
|
|
328
|
+
entry[stat.targetPath].unit = stat.unit || 'kWh';
|
|
239
329
|
}
|
|
240
|
-
arr.push(entry);
|
|
241
330
|
|
|
242
|
-
|
|
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
|
+
}
|
|
243
345
|
|
|
346
|
+
arr.push(entry);
|
|
347
|
+
arr.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
|
|
244
348
|
this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
|
|
245
349
|
this.adapter.logger.debug(`Appended ${stateId} statistic ${toStr}`);
|
|
350
|
+
return arr.length > 0;
|
|
246
351
|
}
|
|
247
352
|
|
|
248
353
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* This function calculates the hourly consumption statistics based on the current day's data.
|
|
252
|
-
* It retrieves the consumption data and updates the hourly consumption JSON accordingly.
|
|
354
|
+
* Removes entries older than periodStart from the given state.
|
|
253
355
|
*
|
|
254
|
-
* @
|
|
356
|
+
* @param {string} stateId
|
|
357
|
+
* @param {Date} periodStart
|
|
255
358
|
*/
|
|
256
|
-
|
|
257
|
-
const now = new Date();
|
|
258
|
-
if (this.testing) {
|
|
259
|
-
const state = await this.adapter.getState('statistics.jsonHourly');
|
|
260
|
-
this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
261
|
-
now.setDate(now.getDate() + 1); // set to start of day for testing to have consistent results
|
|
262
|
-
now.setHours(1, 0, 0, 1); // set to 1ms after midnight to trigger hourly calculation for the new day
|
|
263
|
-
}
|
|
264
|
-
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
265
|
-
const lastHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0);
|
|
266
|
-
this.adapter.log.debug('### Hourly execution triggered ###');
|
|
267
|
-
this._calculateGeneric('statistics.jsonHourly', startOfDay, lastHour) && (this.lastExecution.hourly = now); // only update last execution time if calculation was performed to avoid backfilling multiple hours at startup
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
async _clearGeneric(stateId, periodStart) {
|
|
359
|
+
_clearGeneric(stateId, periodStart) {
|
|
271
360
|
let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
|
|
272
361
|
let arr = [];
|
|
273
362
|
try {
|
|
@@ -276,29 +365,163 @@ class statistics {
|
|
|
276
365
|
} catch {
|
|
277
366
|
arr = [];
|
|
278
367
|
}
|
|
279
|
-
|
|
280
|
-
// Keep only entries within the window
|
|
281
368
|
arr = arr.filter(item => {
|
|
282
369
|
const ts = Date.parse(item.from);
|
|
283
370
|
return !Number.isNaN(ts) && ts >= periodStart.getTime();
|
|
284
371
|
});
|
|
285
|
-
|
|
286
372
|
this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
|
|
287
373
|
}
|
|
288
374
|
|
|
375
|
+
// eslint-disable-next-line jsdoc/require-returns-check
|
|
289
376
|
/**
|
|
290
|
-
* Calculates and aggregates
|
|
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.
|
|
291
382
|
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
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.
|
|
294
517
|
*
|
|
295
|
-
* @param {string} sourceStateId
|
|
296
|
-
* @param {string} targetStateId
|
|
297
|
-
* @param {Function} getWindow
|
|
298
|
-
* @param {string} periodType
|
|
299
|
-
* @returns {
|
|
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.
|
|
300
523
|
*/
|
|
301
|
-
|
|
524
|
+
_calculateAggregation_old(sourceStateId, targetStateId, getWindow, periodType) {
|
|
302
525
|
try {
|
|
303
526
|
const now = new Date();
|
|
304
527
|
const window = getWindow(now);
|
|
@@ -306,11 +529,11 @@ class statistics {
|
|
|
306
529
|
const toDate = window.to;
|
|
307
530
|
if (now < toDate) {
|
|
308
531
|
this.adapter.logger.debug(`statistics.js: Skipping ${periodType} aggregation because current time is before end of aggregation window`);
|
|
309
|
-
return;
|
|
532
|
+
return false;
|
|
310
533
|
}
|
|
311
534
|
const toStr = this._localIsoWithOffset(toDate);
|
|
535
|
+
//const fromStr = this._localIsoWithOffset(fromDate);
|
|
312
536
|
|
|
313
|
-
// Load target array
|
|
314
537
|
let jsonTarget = this.stateCache.get(targetStateId)?.value ?? '[]';
|
|
315
538
|
let targetArray = [];
|
|
316
539
|
try {
|
|
@@ -320,16 +543,15 @@ class statistics {
|
|
|
320
543
|
targetArray = [];
|
|
321
544
|
}
|
|
322
545
|
|
|
323
|
-
// Avoid duplicates
|
|
324
546
|
const last = targetArray.length > 0 ? targetArray[targetArray.length - 1] : {};
|
|
325
|
-
|
|
547
|
+
|
|
548
|
+
if (last.to === toStr) return false;
|
|
326
549
|
|
|
327
550
|
const target = {
|
|
328
551
|
from: this._localIsoWithOffset(fromDate),
|
|
329
552
|
to: toStr,
|
|
330
553
|
};
|
|
331
554
|
|
|
332
|
-
// Load source entries
|
|
333
555
|
let jsonStr = this.stateCache.get(sourceStateId)?.value ?? '[]';
|
|
334
556
|
let sourceEntries = [];
|
|
335
557
|
try {
|
|
@@ -339,27 +561,22 @@ class statistics {
|
|
|
339
561
|
sourceEntries = [];
|
|
340
562
|
}
|
|
341
563
|
|
|
342
|
-
// Keep only entries within the window
|
|
343
564
|
sourceEntries = sourceEntries.filter(item => {
|
|
344
565
|
const ts = Date.parse(item.from);
|
|
345
566
|
return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < toDate.getTime();
|
|
346
567
|
});
|
|
347
|
-
|
|
568
|
+
|
|
348
569
|
if (sourceEntries.length > 0) {
|
|
349
570
|
this.adapter.logger.debug(
|
|
350
571
|
`statistics.js: Found ${sourceEntries.length} source entries for ${periodType} aggregation between ${fromDate.toISOString()} and ${toDate.toISOString()}`,
|
|
351
572
|
);
|
|
352
573
|
|
|
574
|
+
// First pass: sum delta/deltaReset stats
|
|
353
575
|
for (const stat of this.stats) {
|
|
354
|
-
|
|
355
|
-
if (stat.type === statisticsType.
|
|
576
|
+
if (stat.type === statisticsType.level) continue;
|
|
577
|
+
if (stat.type === statisticsType.computed) continue;
|
|
356
578
|
|
|
357
579
|
let sum = 0;
|
|
358
|
-
/*
|
|
359
|
-
if (stat.type === statisticsType.average) {
|
|
360
|
-
stat.sum = sourceEntries.length > 0 ? sourceEntries[sourceEntries.length - 1]?.[stat.targetPath]?.['total'] : 0;
|
|
361
|
-
} else {
|
|
362
|
-
*/
|
|
363
580
|
try {
|
|
364
581
|
sourceEntries.forEach(entry => {
|
|
365
582
|
sum += Number(entry[stat.targetPath]?.['value'] ?? 0);
|
|
@@ -367,154 +584,277 @@ class statistics {
|
|
|
367
584
|
} catch (e) {
|
|
368
585
|
this.adapter.logger.warn(`statistics.js: Error during ${periodType} statistic aggregation: ${e.message}`);
|
|
369
586
|
}
|
|
370
|
-
|
|
371
587
|
sum = Math.round((Number(sum) + Number.EPSILON) * 1000) / 1000;
|
|
588
|
+
target[stat.targetPath] = { value: Number(sum.toFixed(3)), unit: stat.unit || 'kWh' };
|
|
589
|
+
}
|
|
372
590
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
+
}
|
|
377
604
|
}
|
|
378
605
|
|
|
379
606
|
targetArray.push(target);
|
|
380
607
|
}
|
|
381
608
|
|
|
382
|
-
// Retention: keep entries from retention start onwards
|
|
383
|
-
/*
|
|
384
|
-
const retentionStart = getRetentionStart(now);
|
|
385
|
-
targetArray = targetArray.filter(item => {
|
|
386
|
-
const ts = Date.parse(item.from);
|
|
387
|
-
return !Number.isNaN(ts) && ts >= retentionStart.getTime();
|
|
388
|
-
});
|
|
389
|
-
*/
|
|
390
|
-
|
|
391
609
|
targetArray.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
|
|
392
610
|
this.stateCache.set(targetStateId, JSON.stringify(targetArray), { type: 'string' });
|
|
393
|
-
this.adapter.logger.debug(`Appended ${periodType} statistic ${toStr}
|
|
394
|
-
return
|
|
611
|
+
this.adapter.logger.debug(`Appended ${periodType} statistic ${toStr}`);
|
|
612
|
+
return targetArray.length > 0;
|
|
395
613
|
} catch (err) {
|
|
396
614
|
this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
|
|
397
615
|
}
|
|
398
616
|
}
|
|
399
617
|
|
|
400
618
|
/**
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
|
|
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.
|
|
404
683
|
*/
|
|
405
|
-
|
|
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() {
|
|
406
701
|
this.adapter.log.debug('### Daily execution triggered ###');
|
|
702
|
+
|
|
703
|
+
// Abgeschlossener Vortag — normaler Eintrag
|
|
407
704
|
this._calculateAggregation(
|
|
408
705
|
'statistics.jsonHourly',
|
|
409
706
|
'statistics.jsonDaily',
|
|
410
707
|
now => {
|
|
411
|
-
// aggregation window: previous day (day that just ended)
|
|
412
708
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
413
709
|
const yesterday = new Date(today);
|
|
414
710
|
yesterday.setDate(today.getDate() - 1);
|
|
415
711
|
return { from: yesterday, to: today };
|
|
416
712
|
},
|
|
417
713
|
'daily',
|
|
418
|
-
)
|
|
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');
|
|
419
728
|
}
|
|
420
729
|
|
|
421
730
|
/**
|
|
422
731
|
* Calculates and updates weekly consumption statistics from daily data.
|
|
423
|
-
*
|
|
424
|
-
* @returns {void}
|
|
425
732
|
*/
|
|
426
|
-
|
|
733
|
+
_calculateWeekly() {
|
|
427
734
|
this.adapter.log.debug('### Weekly execution triggered ###');
|
|
428
735
|
this._calculateAggregation(
|
|
429
736
|
'statistics.jsonDaily',
|
|
430
737
|
'statistics.jsonWeekly',
|
|
431
738
|
now => {
|
|
432
|
-
//
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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 };
|
|
439
748
|
},
|
|
440
749
|
'weekly',
|
|
441
|
-
)
|
|
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');
|
|
442
766
|
}
|
|
443
767
|
|
|
444
768
|
/**
|
|
445
769
|
* Calculates and updates monthly consumption statistics from daily data.
|
|
446
|
-
*
|
|
447
|
-
* @returns {void}
|
|
448
770
|
*/
|
|
449
|
-
|
|
771
|
+
_calculateMonthly() {
|
|
450
772
|
this.adapter.log.debug('### Monthly execution triggered ###');
|
|
451
773
|
this._calculateAggregation(
|
|
452
774
|
'statistics.jsonDaily',
|
|
453
775
|
'statistics.jsonMonthly',
|
|
454
776
|
now => {
|
|
455
|
-
// aggregation windowStart: previous month (month that just ended)
|
|
456
777
|
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
|
|
457
778
|
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1, 0, 0, 0, 0);
|
|
458
779
|
return { from: prevMonth, to: thisMonth };
|
|
459
780
|
},
|
|
460
781
|
'monthly',
|
|
461
|
-
)
|
|
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');
|
|
462
795
|
}
|
|
463
796
|
|
|
464
797
|
/**
|
|
465
798
|
* Calculates and updates annual consumption statistics from daily data.
|
|
466
|
-
*
|
|
467
|
-
* @returns {void}
|
|
468
799
|
*/
|
|
469
|
-
|
|
800
|
+
_calculateAnnual() {
|
|
470
801
|
this.adapter.log.debug('### Annual execution triggered ###');
|
|
471
802
|
this._calculateAggregation(
|
|
472
803
|
'statistics.jsonDaily',
|
|
473
804
|
'statistics.jsonAnnual',
|
|
474
805
|
now => {
|
|
475
|
-
// aggregation window: previous year (year that just ended)
|
|
476
806
|
const thisYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
|
|
477
807
|
const prevYear = new Date(now.getFullYear() - 1, 0, 1, 0, 0, 0, 0);
|
|
478
808
|
return { from: prevYear, to: thisYear };
|
|
479
809
|
},
|
|
480
810
|
'annual',
|
|
481
|
-
)
|
|
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');
|
|
482
824
|
}
|
|
483
825
|
|
|
484
826
|
/**
|
|
485
827
|
* Initialize and schedule the unified task manager.
|
|
486
|
-
*
|
|
828
|
+
* Runs every full hour.
|
|
487
829
|
*/
|
|
488
|
-
|
|
830
|
+
_initializeTask() {
|
|
489
831
|
const scheduleNextRun = () => {
|
|
490
832
|
const now = new Date();
|
|
833
|
+
|
|
491
834
|
const next = new Date(now);
|
|
492
835
|
if (this.testing) {
|
|
493
|
-
next.setMinutes(now.getMinutes() + 1, 0, 0);
|
|
836
|
+
next.setMinutes(now.getMinutes() + 1, 0, 0);
|
|
494
837
|
} else {
|
|
495
|
-
next.setHours(next.getHours() + 1, 0, 0,
|
|
838
|
+
next.setHours(next.getHours() + 1, 0, 0, 100);
|
|
496
839
|
}
|
|
497
|
-
|
|
498
|
-
// Skip executing tasks exactly at midnight to avoid running aggregated jobs
|
|
499
840
|
if (next.getHours() === 0 && next.getMinutes() === 0) {
|
|
500
|
-
next.setHours(next.getHours() + 1, 0, 0, 0);
|
|
841
|
+
next.setHours(next.getHours() + 1, 0, 0, 0);
|
|
501
842
|
}
|
|
502
843
|
const msToNextHour = next.getTime() - now.getTime();
|
|
844
|
+
this.adapter.logger.debug(`### Statistics - Scheduler start ${now.toLocaleTimeString()} next ${next.toLocaleTimeString()}`);
|
|
503
845
|
|
|
504
846
|
if (this.taskTimer) {
|
|
505
847
|
this.adapter.clearTimeout(this.taskTimer);
|
|
506
848
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
scheduleNextRun(); // reschedule for next hour
|
|
849
|
+
this.taskTimer = this.adapter.setTimeout(() => {
|
|
850
|
+
this._executeScheduledTasks();
|
|
851
|
+
scheduleNextRun();
|
|
511
852
|
}, msToNextHour);
|
|
512
853
|
};
|
|
513
|
-
//await this._executeScheduledTasks(); // execute immediately on startup to catch up on any missed runs while the adapter was not running
|
|
514
|
-
// Schedule the next run
|
|
515
854
|
scheduleNextRun();
|
|
516
855
|
}
|
|
517
|
-
|
|
856
|
+
|
|
857
|
+
_executeScheduledTasks() {
|
|
518
858
|
this._calculateHourly();
|
|
519
859
|
this._calculateDaily();
|
|
520
860
|
this._calculateWeekly();
|
|
@@ -523,23 +863,21 @@ class statistics {
|
|
|
523
863
|
}
|
|
524
864
|
|
|
525
865
|
/**
|
|
526
|
-
* Executes every midnight
|
|
527
|
-
* -
|
|
528
|
-
* -
|
|
866
|
+
* Executes every midnight:
|
|
867
|
+
* - Runs all scheduled tasks
|
|
868
|
+
* - Clears old data based on retention policies
|
|
529
869
|
*/
|
|
530
|
-
|
|
870
|
+
mitNightProcess() {
|
|
531
871
|
const now = new Date();
|
|
532
|
-
|
|
533
|
-
// Clear old data based on retention policies
|
|
872
|
+
this._executeScheduledTasks();
|
|
534
873
|
const startOfYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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));
|
|
539
878
|
}
|
|
540
879
|
|
|
541
880
|
async initialize() {
|
|
542
|
-
// load consumption JSON states (keep as string)
|
|
543
881
|
let state = await this.adapter.getState('statistics.jsonHourly');
|
|
544
882
|
this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
545
883
|
|
|
@@ -555,30 +893,39 @@ class statistics {
|
|
|
555
893
|
state = await this.adapter.getState('statistics.jsonAnnual');
|
|
556
894
|
this.stateCache.set('statistics.jsonAnnual', state?.val ?? '[]', { type: 'string', stored: true });
|
|
557
895
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
this.stateCache.set('statistics.flexChartTemplate', state?.val ?? '{}', { type: 'string', stored: true });
|
|
562
|
-
*/
|
|
563
|
-
// wait until consumptionToday and so on is available to avoid running the task before the initial state is loaded
|
|
564
|
-
/*
|
|
565
|
-
await tools.waitForValue(() => this.stateCache.get('collected.consumptionToday')?.value, 60000);
|
|
566
|
-
await tools.waitForValue(() => this.stateCache.get('collected.dailySolarYield')?.value, 60000);
|
|
567
|
-
await tools.waitForValue(() => this.stateCache.get('collected.SOC')?.value, 60000);
|
|
568
|
-
*/
|
|
896
|
+
state = await this.adapter.getState('statistics.jsonToday');
|
|
897
|
+
this.stateCache.set('statistics.jsonToday', state?.val ?? '{}', { type: 'string', stored: true });
|
|
898
|
+
|
|
569
899
|
await tools.waitForValue(() => this.stateCache.get('collected.accumulatedEnergyYield')?.value, 60000);
|
|
570
|
-
|
|
900
|
+
|
|
901
|
+
// Load templates — one per chart type
|
|
902
|
+
for (const chartType of ['hourly', 'daily', 'weekly', 'monthly', 'annual']) {
|
|
903
|
+
const templateStateId = `statistics.flexCharts.template.${chartType}`;
|
|
904
|
+
state = await this.adapter.getState(templateStateId);
|
|
905
|
+
this.stateCache.set(templateStateId, state?.val ?? '{}', { type: 'string', stored: true });
|
|
906
|
+
if (state?.ack === false) {
|
|
907
|
+
this.stateCache.set(templateStateId, state.val, { type: 'string' });
|
|
908
|
+
await this.adapter.setState(templateStateId, { val: state.val, ack: true });
|
|
909
|
+
this._buildFlexchart(chartType);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
this.mitNightProcess();
|
|
571
914
|
this._initializeTask();
|
|
915
|
+
this.adapter.subscribeStates(`${this._path}.*`);
|
|
916
|
+
this._initialized = true;
|
|
572
917
|
}
|
|
573
918
|
|
|
574
919
|
/**
|
|
575
|
-
*
|
|
576
|
-
* The returned object may be sent to a callback for flexcharts' script source.
|
|
920
|
+
* Builds and updates the Flexchart configuration for the specified chart type.
|
|
577
921
|
*
|
|
578
|
-
* @param {string} myChart -
|
|
579
|
-
* @
|
|
922
|
+
* @param {string} myChart - 'hourly' | 'daily' | 'weekly' | 'monthly' | 'annual'
|
|
923
|
+
* @param {string} [chartStyle] - 'line' | 'bar' — defaults to 'line' for hourly, 'bar' for others
|
|
924
|
+
* @returns {string} The generated chart configuration as a javascript-stringify string
|
|
580
925
|
*/
|
|
581
|
-
_buildFlexchart(myChart) {
|
|
926
|
+
_buildFlexchart(myChart, chartStyle) {
|
|
927
|
+
chartStyle = chartStyle || (myChart === 'hourly' ? 'line' : 'bar');
|
|
928
|
+
|
|
582
929
|
const IDS = {
|
|
583
930
|
hourly: 'statistics.jsonHourly',
|
|
584
931
|
daily: 'statistics.jsonDaily',
|
|
@@ -594,134 +941,528 @@ class statistics {
|
|
|
594
941
|
data = [];
|
|
595
942
|
}
|
|
596
943
|
|
|
597
|
-
//
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
const templ = JSON.parse(templateStr);
|
|
614
|
-
for (const key of Object.keys(templ)) {
|
|
615
|
-
if (chart[key] && typeof chart[key] === 'object' && typeof templ[key] === 'object') {
|
|
616
|
-
// merge sub-objects shallowly
|
|
617
|
-
Object.assign(chart[key], templ[key]);
|
|
618
|
-
} else {
|
|
619
|
-
chart[key] = templ[key];
|
|
620
|
-
}
|
|
944
|
+
// --- X-Axis labels ---
|
|
945
|
+
const xAxisData = data.map(entry => {
|
|
946
|
+
const from = new Date(entry.from);
|
|
947
|
+
const to = new Date(entry.to);
|
|
948
|
+
if (myChart === 'hourly') {
|
|
949
|
+
return `${to.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })} ${to.toLocaleTimeString('de-DE', {
|
|
950
|
+
hour12: false,
|
|
951
|
+
hour: '2-digit',
|
|
952
|
+
minute: '2-digit',
|
|
953
|
+
})}`;
|
|
954
|
+
}
|
|
955
|
+
if (myChart === 'weekly') {
|
|
956
|
+
const toDay = new Date(to);
|
|
957
|
+
//mitnight -> the day before
|
|
958
|
+
if (toDay.getHours() === 0 && toDay.getMinutes() === 0) {
|
|
959
|
+
toDay.setDate(toDay.getDate() - 1);
|
|
621
960
|
}
|
|
622
|
-
|
|
623
|
-
this.adapter.logger.warn(`statistics: invalid flexChartTemplate JSON: ${e.message}`);
|
|
961
|
+
return `${from.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}-${toDay.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}`;
|
|
624
962
|
}
|
|
963
|
+
if (myChart === 'monthly') {
|
|
964
|
+
return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit' });
|
|
965
|
+
}
|
|
966
|
+
if (myChart === 'annual') {
|
|
967
|
+
return from.toLocaleDateString('de-DE', { year: 'numeric' });
|
|
968
|
+
}
|
|
969
|
+
return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
const xAxisDataShort = myChart === 'hourly' ? xAxisData.map(label => label.split(' ')[1]) : xAxisData;
|
|
973
|
+
|
|
974
|
+
// --- Day areas (hourly only) ---
|
|
975
|
+
const dayAreas = [];
|
|
976
|
+
if (myChart === 'hourly' && xAxisData.length > 0) {
|
|
977
|
+
const dayBoundaries = [0];
|
|
978
|
+
xAxisData.forEach((label, i) => {
|
|
979
|
+
if (i === 0) return;
|
|
980
|
+
const date = label.split(' ')[0];
|
|
981
|
+
const prevDate = xAxisData[i - 1].split(' ')[0];
|
|
982
|
+
if (date !== prevDate) dayBoundaries.push(i);
|
|
983
|
+
});
|
|
984
|
+
dayBoundaries.push(xAxisData.length);
|
|
985
|
+
|
|
986
|
+
dayBoundaries.forEach((startIdx, d) => {
|
|
987
|
+
if (d >= dayBoundaries.length - 1) return;
|
|
988
|
+
const endIdx = dayBoundaries[d + 1];
|
|
989
|
+
const date = xAxisData[startIdx].split(' ')[0];
|
|
990
|
+
const shaded = d % 2 === 1;
|
|
991
|
+
dayAreas.push([
|
|
992
|
+
{
|
|
993
|
+
xAxis: startIdx - 0.5,
|
|
994
|
+
label: {
|
|
995
|
+
show: true,
|
|
996
|
+
position: 'insideTop',
|
|
997
|
+
formatter: date,
|
|
998
|
+
color: '#555',
|
|
999
|
+
fontSize: 11,
|
|
1000
|
+
fontWeight: 'bold',
|
|
1001
|
+
backgroundColor: 'rgba(255,255,255,0.7)',
|
|
1002
|
+
padding: [2, 4],
|
|
1003
|
+
borderRadius: 3,
|
|
1004
|
+
},
|
|
1005
|
+
},
|
|
1006
|
+
{
|
|
1007
|
+
xAxis: endIdx - 0.5,
|
|
1008
|
+
itemStyle: shaded
|
|
1009
|
+
? { color: 'rgba(180,180,180,0.15)', borderColor: 'rgba(120,120,120,0.3)', borderWidth: 1, borderType: 'dashed' }
|
|
1010
|
+
: { color: 'rgba(255,255,255,0)' },
|
|
1011
|
+
},
|
|
1012
|
+
]);
|
|
1013
|
+
});
|
|
625
1014
|
}
|
|
626
1015
|
|
|
627
|
-
//
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
const unitMap = {}; // targetPath -> unit string
|
|
1016
|
+
// --- Series data extraction ---
|
|
1017
|
+
const extract = key => data.map(e => Number(Number(e[key]?.value ?? 0).toFixed(3)));
|
|
1018
|
+
const negate = arr => arr.map(v => Number((-v).toFixed(3)));
|
|
631
1019
|
|
|
1020
|
+
// Build seriesData from all this.stats entries (targetPath as key)
|
|
1021
|
+
const seriesData = {};
|
|
632
1022
|
for (const stat of this.stats) {
|
|
633
|
-
|
|
1023
|
+
const values = extract(stat.targetPath);
|
|
1024
|
+
seriesData[stat.targetPath] = values;
|
|
1025
|
+
seriesData[`${stat.targetPath}Neg`] = negate(values);
|
|
634
1026
|
}
|
|
635
1027
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
}
|
|
649
|
-
|
|
1028
|
+
// --- Tooltip formatter ---
|
|
1029
|
+
const tooltipFormatter = params => {
|
|
1030
|
+
if (!Array.isArray(params)) params = [params];
|
|
1031
|
+
return params
|
|
1032
|
+
.filter(p => p.seriesName !== 'DayBreak')
|
|
1033
|
+
.map(p => {
|
|
1034
|
+
const negatedSeries = ['Grid Export', 'Charge'];
|
|
1035
|
+
const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
|
|
1036
|
+
const unit = ['SOC', 'Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? ' %' : ' kWh';
|
|
1037
|
+
const seriesName =
|
|
1038
|
+
myChart === 'hourly' && ['Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? `${p.seriesName} today` : p.seriesName;
|
|
1039
|
+
return `${p.marker}${seriesName}: <b>${val}${unit}</b>`;
|
|
1040
|
+
})
|
|
1041
|
+
.join('<br/>');
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
// --- Load chart-type specific template ---
|
|
1045
|
+
const templateStateId = `statistics.flexCharts.template.${myChart}`;
|
|
1046
|
+
const outputStateId = `statistics.flexCharts.jsonOutput.${myChart}`;
|
|
1047
|
+
|
|
1048
|
+
const templateStr = this.stateCache.get(templateStateId)?.value ?? '{}';
|
|
1049
|
+
let chartStr = '{}';
|
|
1050
|
+
|
|
1051
|
+
try {
|
|
1052
|
+
const templ = JSON.parse(templateStr);
|
|
1053
|
+
if (Object.keys(templ).length === 0) {
|
|
1054
|
+
chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
|
|
1055
|
+
} else {
|
|
1056
|
+
chartStr = stringify(templ);
|
|
650
1057
|
}
|
|
1058
|
+
} catch (e) {
|
|
1059
|
+
this.adapter.logger.warn(`statistics: invalid template for ${myChart}: ${e.message}`);
|
|
1060
|
+
chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
|
|
651
1061
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
// multiple units – show per-series unit in tooltip
|
|
667
|
-
chart.tooltip.formatter = params => {
|
|
668
|
-
if (!Array.isArray(params)) params = [params];
|
|
669
|
-
return params
|
|
670
|
-
.map(p => {
|
|
671
|
-
const u = unitMap[p.seriesName] || '';
|
|
672
|
-
return `${p.seriesName}: ${p.value}${u ? ` ${u}` : ''}`;
|
|
673
|
-
})
|
|
674
|
-
.join('<br/>');
|
|
675
|
-
};
|
|
1062
|
+
|
|
1063
|
+
// --- Replace placeholders ---
|
|
1064
|
+
chartStr = chartStr
|
|
1065
|
+
.replace("'%%xAxisData%%'", JSON.stringify(xAxisData))
|
|
1066
|
+
.replace("'%%xAxisDataShort%%'", JSON.stringify(xAxisDataShort))
|
|
1067
|
+
.replace("'%%xAxisMax%%'", String(xAxisData.length - 1))
|
|
1068
|
+
.replace("'%%chartTitle%%'", JSON.stringify(`PV Statistics — ${myChart}`))
|
|
1069
|
+
.replace("'%%dayAreas%%'", JSON.stringify(dayAreas))
|
|
1070
|
+
.replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
|
|
1071
|
+
|
|
1072
|
+
// All this.stats entries dynamically — both positive and negated
|
|
1073
|
+
for (const stat of this.stats) {
|
|
1074
|
+
const key = stat.targetPath;
|
|
1075
|
+
chartStr = chartStr.replace(`'%%${key}%%'`, JSON.stringify(seriesData[key])).replace(`'%%${key}Neg%%'`, JSON.stringify(seriesData[`${key}Neg`]));
|
|
676
1076
|
}
|
|
677
1077
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
1078
|
+
this.stateCache.set(outputStateId, chartStr, { type: 'string' });
|
|
1079
|
+
this.adapter.logger.debug(`statistics: flexCharts built for ${myChart}/${chartStyle}`);
|
|
1080
|
+
|
|
1081
|
+
return chartStr;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Build the default chart configuration as javascript-stringify string.
|
|
1086
|
+
* Used when no template is provided.
|
|
1087
|
+
* @param myChart
|
|
1088
|
+
* @param chartStyle
|
|
1089
|
+
* @param xAxisData
|
|
1090
|
+
* @param xAxisDataShort
|
|
1091
|
+
* @param dayAreas
|
|
1092
|
+
* @param seriesData
|
|
1093
|
+
*/
|
|
1094
|
+
_buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData) {
|
|
1095
|
+
const xAxisFormatterHourly = value => {
|
|
1096
|
+
if (value.includes('|')) return value;
|
|
1097
|
+
return value.split(' ')[1] ?? value;
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
const seriesType = chartStyle === 'line' ? 'line' : 'bar';
|
|
1101
|
+
const lineOptions =
|
|
1102
|
+
chartStyle === 'line' ? { smooth: true, symbol: 'circle', symbolSize: 4, lineStyle: { width: 2 }, areaStyle: { opacity: 0.15 } } : {};
|
|
1103
|
+
|
|
1104
|
+
const negate = arr => arr.map(v => Number((-v).toFixed(3)));
|
|
1105
|
+
const showSOC = myChart === 'hourly';
|
|
1106
|
+
|
|
1107
|
+
// No-data hint — chart-type specific
|
|
1108
|
+
const noDataHints = {
|
|
1109
|
+
hourly: 'No data yet — first entry available after the next full hour.',
|
|
1110
|
+
daily: 'No data yet — first entry available tomorrow after midnight.',
|
|
1111
|
+
weekly: 'No data yet — first entry available after the current week ends.',
|
|
1112
|
+
monthly: 'No data yet — first entry available after the current month ends.',
|
|
1113
|
+
annual: 'No data yet — first entry available after the current year ends.',
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
const tooltipFormatter = params => {
|
|
1117
|
+
if (!Array.isArray(params)) params = [params];
|
|
1118
|
+
return params
|
|
1119
|
+
.filter(p => p.seriesName !== 'DayBreak')
|
|
1120
|
+
.map(p => {
|
|
1121
|
+
const negatedSeries = ['Grid Export', 'Charge'];
|
|
1122
|
+
const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
|
|
1123
|
+
const unit = ['SOC', 'Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? ' %' : ' kWh';
|
|
1124
|
+
const seriesName =
|
|
1125
|
+
myChart === 'hourly' && ['Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? `${p.seriesName} today` : p.seriesName;
|
|
1126
|
+
return `${p.marker}${seriesName}: <b>${val}${unit}</b>`;
|
|
1127
|
+
})
|
|
1128
|
+
.join('<br/>');
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
// --- Slider start position — show recent entries by default ---
|
|
1132
|
+
// For hourly: show last 25 entries (~1 day)
|
|
1133
|
+
// For daily: show last 7 entries (~1 week)
|
|
1134
|
+
// For weekly: show last 8 entries (~2 months)
|
|
1135
|
+
// For monthly: show last 13 entries (~1 year)
|
|
1136
|
+
// For annual: show full range
|
|
1137
|
+
const sliderDefaults = {
|
|
1138
|
+
hourly: { start: Math.max(0, Math.round((1 - 25 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
|
|
1139
|
+
daily: { start: Math.max(0, Math.round((1 - 7 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
|
|
1140
|
+
weekly: { start: Math.max(0, Math.round((1 - 8 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
|
|
1141
|
+
monthly: { start: Math.max(0, Math.round((1 - 13 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
|
|
1142
|
+
annual: { start: 0, end: 100 },
|
|
1143
|
+
};
|
|
1144
|
+
const slider = sliderDefaults[myChart] ?? { start: 0, end: 100 };
|
|
1145
|
+
|
|
1146
|
+
const chart = {
|
|
1147
|
+
backgroundColor: '#fff',
|
|
1148
|
+
animation: false,
|
|
1149
|
+
title: {
|
|
1150
|
+
left: 'center',
|
|
1151
|
+
text: `SUN2000 - PV Statistics - ${myChart}`,
|
|
1152
|
+
},
|
|
1153
|
+
legend: {
|
|
1154
|
+
top: 35,
|
|
1155
|
+
left: 'center',
|
|
1156
|
+
data: [
|
|
1157
|
+
'Solar Yield',
|
|
1158
|
+
'Grid Export',
|
|
1159
|
+
'Grid Import',
|
|
1160
|
+
'Charge',
|
|
1161
|
+
'Discharge',
|
|
1162
|
+
...(showSOC ? ['SOC'] : []),
|
|
1163
|
+
'Self-sufficiency',
|
|
1164
|
+
'Self-consumption',
|
|
1165
|
+
'Consumption',
|
|
1166
|
+
],
|
|
1167
|
+
},
|
|
1168
|
+
tooltip: {
|
|
1169
|
+
trigger: 'axis',
|
|
1170
|
+
axisPointer: { type: 'cross' },
|
|
1171
|
+
backgroundColor: 'rgba(245,245,245,0.95)',
|
|
1172
|
+
borderWidth: 1,
|
|
1173
|
+
borderColor: '#ccc',
|
|
1174
|
+
padding: 10,
|
|
1175
|
+
textStyle: { color: '#000' },
|
|
1176
|
+
formatter: '%%tooltipFormatter%%',
|
|
1177
|
+
position: (pos, params, el, elRect, size) => {
|
|
1178
|
+
const obj = { top: 10 };
|
|
1179
|
+
obj[pos[0] < size.viewSize[0] / 2 ? 'left' : 'right'] = 30;
|
|
1180
|
+
return obj;
|
|
1181
|
+
},
|
|
1182
|
+
},
|
|
1183
|
+
axisPointer: {
|
|
1184
|
+
link: [{ xAxisIndex: 'all' }],
|
|
1185
|
+
label: { backgroundColor: '#777' },
|
|
1186
|
+
},
|
|
1187
|
+
toolbox: {
|
|
1188
|
+
feature: {
|
|
1189
|
+
dataZoom: { yAxisIndex: false },
|
|
1190
|
+
dataView: { show: true, readOnly: false },
|
|
1191
|
+
restore: { show: true },
|
|
1192
|
+
saveAsImage: { show: true },
|
|
1193
|
+
},
|
|
1194
|
+
},
|
|
1195
|
+
// No-data graphic — shown only when data is empty
|
|
1196
|
+
graphic:
|
|
1197
|
+
xAxisData.length === 0
|
|
1198
|
+
? [
|
|
1199
|
+
{
|
|
1200
|
+
type: 'text',
|
|
1201
|
+
left: 'center',
|
|
1202
|
+
top: 'middle',
|
|
1203
|
+
style: {
|
|
1204
|
+
text: noDataHints[myChart] || 'No data available yet.',
|
|
1205
|
+
fontSize: 14,
|
|
1206
|
+
fill: '#999',
|
|
1207
|
+
},
|
|
1208
|
+
},
|
|
1209
|
+
]
|
|
1210
|
+
: [],
|
|
1211
|
+
grid: [
|
|
1212
|
+
{ left: '8%', right: showSOC ? '8%' : '4%', top: 80, height: '45%' },
|
|
1213
|
+
{ left: '8%', right: showSOC ? '8%' : '4%', top: '72%', height: '15%' },
|
|
1214
|
+
],
|
|
1215
|
+
xAxis: [
|
|
1216
|
+
{
|
|
1217
|
+
type: 'category',
|
|
1218
|
+
data: xAxisDataShort,
|
|
1219
|
+
scale: true,
|
|
1220
|
+
boundaryGap: chartStyle !== 'line',
|
|
1221
|
+
axisLine: { onZero: false },
|
|
1222
|
+
splitLine: { show: false },
|
|
1223
|
+
axisPointer: { z: 100 },
|
|
1224
|
+
min: 0,
|
|
1225
|
+
max: xAxisDataShort.length - 1,
|
|
1226
|
+
axisLabel: {
|
|
1227
|
+
interval: 0,
|
|
1228
|
+
lineHeight: 16,
|
|
1229
|
+
fontSize: 11,
|
|
1230
|
+
formatter: '%%xAxisFormatter%%',
|
|
1231
|
+
},
|
|
1232
|
+
},
|
|
1233
|
+
{
|
|
1234
|
+
type: 'category',
|
|
1235
|
+
gridIndex: 1,
|
|
1236
|
+
data: xAxisData,
|
|
1237
|
+
scale: true,
|
|
1238
|
+
boundaryGap: chartStyle !== 'line',
|
|
1239
|
+
axisLine: { onZero: false },
|
|
1240
|
+
axisTick: { show: false },
|
|
1241
|
+
splitLine: { show: false },
|
|
1242
|
+
axisLabel: { show: false },
|
|
1243
|
+
min: 0,
|
|
1244
|
+
max: xAxisData.length - 1,
|
|
1245
|
+
},
|
|
1246
|
+
],
|
|
1247
|
+
yAxis: [
|
|
1248
|
+
// Index 0 — Energy left axis
|
|
1249
|
+
{
|
|
1250
|
+
scale: false,
|
|
1251
|
+
splitArea: { show: true },
|
|
1252
|
+
name: 'Energy (kWh)',
|
|
1253
|
+
nameLocation: 'middle',
|
|
1254
|
+
nameGap: 50,
|
|
1255
|
+
axisLabel: { formatter: '{value} kWh' },
|
|
1256
|
+
splitLine: { show: true },
|
|
1257
|
+
axisLine: { show: true },
|
|
1258
|
+
},
|
|
1259
|
+
// Index 1 — SOC / ratio right axis
|
|
1260
|
+
{
|
|
1261
|
+
type: 'value',
|
|
1262
|
+
min: 0,
|
|
1263
|
+
max: 100,
|
|
1264
|
+
name: showSOC ? 'SOC / Ratio (%)' : 'Ratio (%)',
|
|
1265
|
+
nameLocation: 'middle',
|
|
1266
|
+
nameGap: 50,
|
|
1267
|
+
axisLabel: { formatter: '{value} %' },
|
|
1268
|
+
splitLine: { show: false },
|
|
1269
|
+
axisLine: { show: true },
|
|
1270
|
+
},
|
|
1271
|
+
// Index 2 — Consumption lower grid
|
|
1272
|
+
{
|
|
1273
|
+
scale: true,
|
|
1274
|
+
gridIndex: 1,
|
|
1275
|
+
splitNumber: 3,
|
|
1276
|
+
axisLine: { show: false },
|
|
1277
|
+
axisTick: { show: false },
|
|
1278
|
+
splitLine: { show: false },
|
|
1279
|
+
name: 'Consumption\n(kWh)',
|
|
1280
|
+
nameLocation: 'middle',
|
|
1281
|
+
nameGap: 50,
|
|
1282
|
+
axisLabel: { formatter: '{value}' },
|
|
1283
|
+
},
|
|
1284
|
+
],
|
|
1285
|
+
dataZoom: [
|
|
1286
|
+
{
|
|
1287
|
+
type: 'inside',
|
|
1288
|
+
xAxisIndex: [0, 1],
|
|
1289
|
+
start: slider.start,
|
|
1290
|
+
end: slider.end,
|
|
1291
|
+
},
|
|
1292
|
+
{
|
|
1293
|
+
show: true,
|
|
1294
|
+
xAxisIndex: [0, 1],
|
|
1295
|
+
type: 'slider',
|
|
1296
|
+
bottom: 5,
|
|
1297
|
+
start: slider.start,
|
|
1298
|
+
end: slider.end,
|
|
1299
|
+
},
|
|
1300
|
+
],
|
|
1301
|
+
series: [
|
|
1302
|
+
// Positive values (above zero line)
|
|
1303
|
+
{
|
|
1304
|
+
name: 'Solar Yield',
|
|
1305
|
+
type: seriesType,
|
|
1306
|
+
data: seriesData.solarYield,
|
|
1307
|
+
itemStyle: { color: '#f6c94e' },
|
|
1308
|
+
emphasis: { focus: 'series' },
|
|
1309
|
+
...lineOptions,
|
|
1310
|
+
},
|
|
1311
|
+
// Negative values (below zero line)
|
|
1312
|
+
{
|
|
1313
|
+
name: 'Grid Export',
|
|
1314
|
+
type: seriesType,
|
|
1315
|
+
data: negate(seriesData.gridExport),
|
|
1316
|
+
itemStyle: { color: '#5cb85c' },
|
|
1317
|
+
emphasis: { focus: 'series' },
|
|
1318
|
+
...lineOptions,
|
|
1319
|
+
},
|
|
1320
|
+
{
|
|
1321
|
+
name: 'Grid Import',
|
|
1322
|
+
type: seriesType,
|
|
1323
|
+
data: seriesData.gridImport,
|
|
1324
|
+
itemStyle: { color: '#ec0000' },
|
|
1325
|
+
emphasis: { focus: 'series' },
|
|
1326
|
+
...lineOptions,
|
|
1327
|
+
},
|
|
1328
|
+
{
|
|
1329
|
+
name: 'Charge',
|
|
1330
|
+
type: seriesType,
|
|
1331
|
+
data: negate(seriesData.chargeCapacity),
|
|
1332
|
+
itemStyle: { color: '#5bc0de' },
|
|
1333
|
+
emphasis: { focus: 'series' },
|
|
1334
|
+
...lineOptions,
|
|
1335
|
+
},
|
|
1336
|
+
{
|
|
1337
|
+
name: 'Discharge',
|
|
1338
|
+
type: seriesType,
|
|
1339
|
+
data: seriesData.dischargeCapacity,
|
|
1340
|
+
itemStyle: { color: '#ed50e0' },
|
|
1341
|
+
emphasis: { focus: 'series' },
|
|
1342
|
+
...lineOptions,
|
|
1343
|
+
},
|
|
1344
|
+
// SOC — hourly only, on right axis
|
|
1345
|
+
...(showSOC
|
|
1346
|
+
? [
|
|
1347
|
+
{
|
|
1348
|
+
name: 'SOC',
|
|
1349
|
+
type: 'line',
|
|
1350
|
+
yAxisIndex: 1,
|
|
1351
|
+
data: seriesData.SOC,
|
|
1352
|
+
itemStyle: { color: '#985e24' },
|
|
1353
|
+
lineStyle: { width: 2, type: 'dashed' },
|
|
1354
|
+
symbol: 'none',
|
|
1355
|
+
smooth: true,
|
|
1356
|
+
},
|
|
1357
|
+
]
|
|
1358
|
+
: []),
|
|
1359
|
+
// Self-sufficiency — right axis
|
|
1360
|
+
{
|
|
1361
|
+
name: 'Self-sufficiency',
|
|
1362
|
+
type: 'line',
|
|
1363
|
+
yAxisIndex: 1,
|
|
1364
|
+
data: seriesData.selfSufficiency,
|
|
1365
|
+
itemStyle: { color: '#9c27b0' },
|
|
1366
|
+
lineStyle: { width: 2, type: 'dashed' },
|
|
1367
|
+
symbol: 'circle',
|
|
1368
|
+
symbolSize: 4,
|
|
1369
|
+
smooth: true,
|
|
1370
|
+
},
|
|
1371
|
+
// Self-consumption — right axis
|
|
1372
|
+
{
|
|
1373
|
+
name: 'Self-consumption',
|
|
1374
|
+
type: 'line',
|
|
1375
|
+
yAxisIndex: 1,
|
|
1376
|
+
data: seriesData.selfConsumption,
|
|
1377
|
+
itemStyle: { color: '#ff9800' },
|
|
1378
|
+
lineStyle: { width: 2, type: 'dashed' },
|
|
1379
|
+
symbol: 'circle',
|
|
1380
|
+
symbolSize: 4,
|
|
1381
|
+
smooth: true,
|
|
1382
|
+
},
|
|
1383
|
+
// Consumption in lower grid — always yAxisIndex 2
|
|
1384
|
+
{
|
|
1385
|
+
name: 'Consumption',
|
|
1386
|
+
type: seriesType,
|
|
1387
|
+
data: seriesData.consumption,
|
|
1388
|
+
itemStyle: { color: '#337ab7' },
|
|
1389
|
+
xAxisIndex: 1,
|
|
1390
|
+
yAxisIndex: 2,
|
|
1391
|
+
...lineOptions,
|
|
1392
|
+
},
|
|
1393
|
+
// Day-break areas (hourly only)
|
|
1394
|
+
...(dayAreas.length > 0
|
|
1395
|
+
? [
|
|
1396
|
+
{
|
|
1397
|
+
name: 'DayBreak',
|
|
1398
|
+
type: 'bar',
|
|
1399
|
+
barWidth: 0,
|
|
1400
|
+
data: [],
|
|
1401
|
+
legendHoverLink: false,
|
|
1402
|
+
silent: true,
|
|
1403
|
+
markArea: { silent: true, data: dayAreas },
|
|
1404
|
+
},
|
|
1405
|
+
]
|
|
1406
|
+
: []),
|
|
1407
|
+
],
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
return stringify(chart).replace("'%%xAxisFormatter%%'", stringify(xAxisFormatterHourly)).replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Merges two objects deeply.
|
|
1415
|
+
* @param target
|
|
1416
|
+
* @param source
|
|
1417
|
+
*/
|
|
1418
|
+
_deepMerge(target, source) {
|
|
1419
|
+
for (const key of Object.keys(source)) {
|
|
1420
|
+
if (
|
|
1421
|
+
source[key] !== null &&
|
|
1422
|
+
typeof source[key] === 'object' &&
|
|
1423
|
+
!Array.isArray(source[key]) &&
|
|
1424
|
+
target[key] !== null &&
|
|
1425
|
+
typeof target[key] === 'object' &&
|
|
1426
|
+
!Array.isArray(target[key])
|
|
1427
|
+
) {
|
|
1428
|
+
this._deepMerge(target[key], source[key]);
|
|
1429
|
+
} else {
|
|
1430
|
+
target[key] = source[key];
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
return target;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Handles a template state change — updates cache, acknowledges and rebuilds chart.
|
|
1438
|
+
* @param chartType
|
|
1439
|
+
* @param state
|
|
1440
|
+
*/
|
|
1441
|
+
async handleTemplateChange(chartType, state) {
|
|
1442
|
+
const templateStateId = `statistics.flexCharts.template.${chartType}`;
|
|
1443
|
+
const template = this.stateCache.get(templateStateId)?.value;
|
|
1444
|
+
if (template === null || template === undefined) {
|
|
1445
|
+
this.adapter.logger.warn(`Template state ${templateStateId} not found for handleTemplateChange`);
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
if (state?.val != null) {
|
|
1449
|
+
this.adapter.logger.debug(`statistics: Event - state: ${chartType} changed: ${state.val} ack: ${state.ack}`);
|
|
1450
|
+
this.stateCache.set(templateStateId, state.val, { type: 'string', stored: true });
|
|
1451
|
+
await this.adapter.setState(templateStateId, { val: state.val, ack: true });
|
|
1452
|
+
this._buildFlexchart(chartType);
|
|
711
1453
|
}
|
|
712
|
-
chart.title.text += myChart;
|
|
713
|
-
return chart;
|
|
714
1454
|
}
|
|
715
1455
|
|
|
716
1456
|
/**
|
|
717
1457
|
* Entry point for adapter to handle messages related to statistics/flexcharts.
|
|
718
1458
|
*
|
|
719
|
-
* @param {{chart?: string}} message
|
|
1459
|
+
* @param {{chart?: string, style?: string}} message
|
|
720
1460
|
* @param {Function} callback
|
|
721
1461
|
*/
|
|
722
1462
|
handleFlexMessage(message, callback) {
|
|
723
1463
|
const chartType = message?.chart || 'hourly';
|
|
724
|
-
const
|
|
1464
|
+
const chartStyle = message?.style;
|
|
1465
|
+
const result = this._buildFlexchart(chartType, chartStyle);
|
|
725
1466
|
if (callback && typeof callback === 'function') {
|
|
726
1467
|
callback(result);
|
|
727
1468
|
}
|