iobroker.sun2000 2.3.6 → 2.4.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/README.md +13 -1
- package/io-package.json +31 -28
- package/lib/alarms.js +7 -7
- package/lib/drivers/driver_base.js +3 -2
- package/lib/drivers/driver_emma.js +16 -0
- package/lib/drivers/driver_inverter.js +17 -12
- package/lib/register.js +50 -28
- package/lib/statistics.js +731 -0
- package/lib/tools.js +39 -4
- package/lib/types.js +7 -0
- package/main.js +20 -14
- package/package.json +7 -7
|
@@ -0,0 +1,731 @@
|
|
|
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 { dataRefreshRate, statisticsType } = require(`${__dirname}/types.js`);
|
|
19
|
+
const tools = require(`${__dirname}/tools.js`);
|
|
20
|
+
|
|
21
|
+
class statistics {
|
|
22
|
+
constructor(adapterInstance, stateCache) {
|
|
23
|
+
this.adapter = adapterInstance;
|
|
24
|
+
this.stateCache = stateCache;
|
|
25
|
+
this.taskTimer = null;
|
|
26
|
+
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
|
+
|
|
37
|
+
this.stats = [
|
|
38
|
+
{
|
|
39
|
+
sourceId: 'collected.consumptionToday',
|
|
40
|
+
targetPath: 'consumption',
|
|
41
|
+
unit: 'kWh',
|
|
42
|
+
type: statisticsType.deltaReset, // value is a total that resets at the start of the period, so we need to calculate the delta to get the actual consumption for the period
|
|
43
|
+
},
|
|
44
|
+
/*
|
|
45
|
+
{
|
|
46
|
+
sourceId: 'collected.consumptionSum',
|
|
47
|
+
targetPath: 'consumptionSum',
|
|
48
|
+
unit: 'kWh',
|
|
49
|
+
type: statisticsType.delta,
|
|
50
|
+
},
|
|
51
|
+
*/
|
|
52
|
+
{
|
|
53
|
+
sourceId: 'collected.dailySolarYield',
|
|
54
|
+
targetPath: 'solarYield',
|
|
55
|
+
unit: 'kWh',
|
|
56
|
+
type: statisticsType.deltaReset,
|
|
57
|
+
},
|
|
58
|
+
{ 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
|
+
},
|
|
65
|
+
{ sourceId: 'collected.dailyEnergyYield', targetPath: 'energyYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
66
|
+
{
|
|
67
|
+
sourceId: 'collected.SOC',
|
|
68
|
+
targetPath: 'SOC',
|
|
69
|
+
unit: '%',
|
|
70
|
+
type: statisticsType.level, // value is a level that can go up and down, so we take the value as is without calculating delta
|
|
71
|
+
},
|
|
72
|
+
{ sourceId: 'collected.currentDayChargeCapacity', targetPath: 'chargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
|
|
73
|
+
{ sourceId: 'collected.currentDayDischargeCapacity', targetPath: 'dischargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
|
|
74
|
+
{ sourceId: 'collected.gridExportToday', targetPath: 'gridExport', unit: 'kWh', type: statisticsType.deltaReset },
|
|
75
|
+
{ sourceId: 'collected.gridImportToday', targetPath: 'gridImport', unit: 'kWh', type: statisticsType.deltaReset },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
this.postProcessHooks = [
|
|
79
|
+
{
|
|
80
|
+
refresh: dataRefreshRate.low,
|
|
81
|
+
states: [
|
|
82
|
+
{
|
|
83
|
+
id: 'statistics.jsonHourly',
|
|
84
|
+
name: 'Hourly consumption JSON',
|
|
85
|
+
type: 'string',
|
|
86
|
+
role: 'json',
|
|
87
|
+
desc: 'Hourly consumption for last and current day per full hour',
|
|
88
|
+
initVal: '[]',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'statistics.jsonDaily',
|
|
92
|
+
name: 'Daily consumption JSON',
|
|
93
|
+
type: 'string',
|
|
94
|
+
role: 'json',
|
|
95
|
+
desc: 'Daily consumption for current month per day',
|
|
96
|
+
initVal: '[]',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'statistics.jsonWeekly',
|
|
100
|
+
name: 'Weekly consumption JSON',
|
|
101
|
+
type: 'string',
|
|
102
|
+
role: 'json',
|
|
103
|
+
desc: 'Weekly consumption for current year per week',
|
|
104
|
+
initVal: '[]',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'statistics.jsonMonthly',
|
|
108
|
+
name: 'Monthly consumption JSON',
|
|
109
|
+
type: 'string',
|
|
110
|
+
role: 'json',
|
|
111
|
+
desc: 'Monthly consumption for current year per month',
|
|
112
|
+
initVal: '[]',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'statistics.jsonAnnual',
|
|
116
|
+
name: 'Annual consumption JSON',
|
|
117
|
+
type: 'string',
|
|
118
|
+
role: 'json',
|
|
119
|
+
desc: 'Annual consumption per year',
|
|
120
|
+
initVal: '[]',
|
|
121
|
+
},
|
|
122
|
+
// a state where users may store a Flexcharts/eCharts options template
|
|
123
|
+
/*
|
|
124
|
+
{
|
|
125
|
+
id: 'statistics.flexChartTemplate',
|
|
126
|
+
name: 'Flexcharts template',
|
|
127
|
+
type: 'string',
|
|
128
|
+
role: 'json',
|
|
129
|
+
desc: 'Optional eCharts option object that will be merged into the default chart. Leave empty for the built‑in layout.',
|
|
130
|
+
initVal: '{}',
|
|
131
|
+
},
|
|
132
|
+
*/
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
this.initialize();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get processHooks() {
|
|
140
|
+
return this.postProcessHooks;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Helper function to format date as ISO string with timezone offset.
|
|
145
|
+
*
|
|
146
|
+
* @param {Date} d - The date to format
|
|
147
|
+
* @returns {string} ISO formatted date string with timezone offset
|
|
148
|
+
*/
|
|
149
|
+
_localIsoWithOffset(d) {
|
|
150
|
+
const pad = n => String(n).padStart(2, '0');
|
|
151
|
+
const year = d.getFullYear();
|
|
152
|
+
const month = pad(d.getMonth() + 1);
|
|
153
|
+
const day = pad(d.getDate());
|
|
154
|
+
const hours = pad(d.getHours());
|
|
155
|
+
const minutes = pad(d.getMinutes());
|
|
156
|
+
const seconds = pad(d.getSeconds());
|
|
157
|
+
const tzOffset = -d.getTimezoneOffset();
|
|
158
|
+
const sign = tzOffset >= 0 ? '+' : '-';
|
|
159
|
+
const absMin = Math.abs(tzOffset);
|
|
160
|
+
const offH = pad(Math.floor(absMin / 60));
|
|
161
|
+
const offM = pad(absMin % 60);
|
|
162
|
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000${sign}${offH}:${offM}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Generic function to calculate consumption statistics for different time periods.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} stateId - The state ID for storing the JSON
|
|
169
|
+
* @param {string} consumptionKey - The state key for consumption value
|
|
170
|
+
* @param {Date} periodStart - The start of the current period
|
|
171
|
+
* @param {string} periodType - The type of period (hourly, daily, weekly, monthly, annual)
|
|
172
|
+
* @returns {Promise<void>}
|
|
173
|
+
*/
|
|
174
|
+
|
|
175
|
+
async _calculateGeneric(stateId, periodStart, periodEnde) {
|
|
176
|
+
const toStr = this._localIsoWithOffset(periodEnde);
|
|
177
|
+
let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
|
|
178
|
+
let arr = [];
|
|
179
|
+
try {
|
|
180
|
+
arr = JSON.parse(jsonStr);
|
|
181
|
+
if (!Array.isArray(arr)) arr = [];
|
|
182
|
+
} catch {
|
|
183
|
+
arr = [];
|
|
184
|
+
}
|
|
185
|
+
let fromDate = periodStart;
|
|
186
|
+
let last = {};
|
|
187
|
+
if (arr.length > 0) {
|
|
188
|
+
last = arr[arr.length - 1];
|
|
189
|
+
// avoid duplicates
|
|
190
|
+
if (last.to === toStr) return false;
|
|
191
|
+
const lastToDate = new Date(last.to);
|
|
192
|
+
const toDate = new Date(toStr);
|
|
193
|
+
if (lastToDate >= periodStart || toDate <= periodStart) {
|
|
194
|
+
fromDate = lastToDate;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const entry = {
|
|
199
|
+
from: this._localIsoWithOffset(fromDate),
|
|
200
|
+
to: toStr,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
for (const stat of this.stats) {
|
|
204
|
+
const source = this.stateCache.get(stat.sourceId)?.value;
|
|
205
|
+
if (source === null || source === undefined) {
|
|
206
|
+
this.adapter.logger.warn(`Source state ${stat.sourceId} not found statistic hook`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
let value = Number(source);
|
|
210
|
+
if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
|
|
211
|
+
const lastTotal = Number(last[stat.targetPath]?.['total'] ?? 0);
|
|
212
|
+
if (stat.type === statisticsType.deltaReset) {
|
|
213
|
+
//if (value >= lastTotal * 0.5) {
|
|
214
|
+
if (fromDate.getTime() !== periodStart.getTime()) {
|
|
215
|
+
// Delta-Berechnung
|
|
216
|
+
value -= lastTotal;
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
// Ein lastTotal-Wert vorhanden –> normale Delta-Berechnung
|
|
220
|
+
if (last[stat.targetPath]?.['total'] === undefined) {
|
|
221
|
+
// Kein lastTotal-Wert vorhanden –> wahrscheinlich erster Eintrag, Delta-Berechnung nicht möglich
|
|
222
|
+
this.adapter.logger.debug(`No total value found for ${stat.targetPath} in last entry, setting delta to 0`);
|
|
223
|
+
value = 0;
|
|
224
|
+
} else {
|
|
225
|
+
// Delta-Berechnung
|
|
226
|
+
value -= lastTotal;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
value = Math.round((Number(value) + Number.EPSILON) * 1000) / 1000;
|
|
231
|
+
entry[stat.targetPath] = {
|
|
232
|
+
value: Number(value.toFixed(3)),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
|
|
236
|
+
entry[stat.targetPath].total = Number(source.toFixed(3));
|
|
237
|
+
}
|
|
238
|
+
entry[stat.targetPath].unit = stat.unit || 'kWh'; // can be extended for other stats with different units
|
|
239
|
+
}
|
|
240
|
+
arr.push(entry);
|
|
241
|
+
|
|
242
|
+
arr.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
|
|
243
|
+
|
|
244
|
+
this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
|
|
245
|
+
this.adapter.logger.debug(`Appended ${stateId} statistic ${toStr}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Calculates and updates hourly consumption statistics.
|
|
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.
|
|
253
|
+
*
|
|
254
|
+
* @returns {void}
|
|
255
|
+
*/
|
|
256
|
+
async _calculateHourly() {
|
|
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) {
|
|
271
|
+
let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
|
|
272
|
+
let arr = [];
|
|
273
|
+
try {
|
|
274
|
+
arr = JSON.parse(jsonStr);
|
|
275
|
+
if (!Array.isArray(arr)) arr = [];
|
|
276
|
+
} catch {
|
|
277
|
+
arr = [];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Keep only entries within the window
|
|
281
|
+
arr = arr.filter(item => {
|
|
282
|
+
const ts = Date.parse(item.from);
|
|
283
|
+
return !Number.isNaN(ts) && ts >= periodStart.getTime();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Calculates and aggregates consumption statistics based on the given parameters.
|
|
291
|
+
*
|
|
292
|
+
* This function calculates and aggregates the consumption statistics based on the source entries within a specific window.
|
|
293
|
+
* It retrieves the source entries, filters them based on the window, calculates the sum of consumption, and appends the result to the target array.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} sourceStateId - The ID of the source state to retrieve entries from.
|
|
296
|
+
* @param {string} targetStateId - The ID of the target state to append the aggregated result.
|
|
297
|
+
* @param {Function} getWindow - A function that returns the start and end date of the window based on the current date.
|
|
298
|
+
* @param {string} periodType - The type of period for which the aggregation is performed.
|
|
299
|
+
* @returns {void}
|
|
300
|
+
*/
|
|
301
|
+
async _calculateAggregation(sourceStateId, targetStateId, getWindow, periodType) {
|
|
302
|
+
try {
|
|
303
|
+
const now = new Date();
|
|
304
|
+
const window = getWindow(now);
|
|
305
|
+
const fromDate = window.from;
|
|
306
|
+
const toDate = window.to;
|
|
307
|
+
if (now < toDate) {
|
|
308
|
+
this.adapter.logger.debug(`statistics.js: Skipping ${periodType} aggregation because current time is before end of aggregation window`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const toStr = this._localIsoWithOffset(toDate);
|
|
312
|
+
|
|
313
|
+
// Load target array
|
|
314
|
+
let jsonTarget = this.stateCache.get(targetStateId)?.value ?? '[]';
|
|
315
|
+
let targetArray = [];
|
|
316
|
+
try {
|
|
317
|
+
targetArray = JSON.parse(jsonTarget);
|
|
318
|
+
if (!Array.isArray(targetArray)) targetArray = [];
|
|
319
|
+
} catch {
|
|
320
|
+
targetArray = [];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Avoid duplicates
|
|
324
|
+
const last = targetArray.length > 0 ? targetArray[targetArray.length - 1] : {};
|
|
325
|
+
if (last.to === toStr) return;
|
|
326
|
+
|
|
327
|
+
const target = {
|
|
328
|
+
from: this._localIsoWithOffset(fromDate),
|
|
329
|
+
to: toStr,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Load source entries
|
|
333
|
+
let jsonStr = this.stateCache.get(sourceStateId)?.value ?? '[]';
|
|
334
|
+
let sourceEntries = [];
|
|
335
|
+
try {
|
|
336
|
+
sourceEntries = JSON.parse(jsonStr);
|
|
337
|
+
if (!Array.isArray(sourceEntries)) sourceEntries = [];
|
|
338
|
+
} catch {
|
|
339
|
+
sourceEntries = [];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Keep only entries within the window
|
|
343
|
+
sourceEntries = sourceEntries.filter(item => {
|
|
344
|
+
const ts = Date.parse(item.from);
|
|
345
|
+
return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < toDate.getTime();
|
|
346
|
+
});
|
|
347
|
+
// If there are no source entries, we can skip the aggregation and avoid creating empty entries in the target array
|
|
348
|
+
if (sourceEntries.length > 0) {
|
|
349
|
+
this.adapter.logger.debug(
|
|
350
|
+
`statistics.js: Found ${sourceEntries.length} source entries for ${periodType} aggregation between ${fromDate.toISOString()} and ${toDate.toISOString()}`,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
for (const stat of this.stats) {
|
|
354
|
+
// Sum consumption for the window
|
|
355
|
+
if (stat.type === statisticsType.level) continue; // Skip level statistics
|
|
356
|
+
|
|
357
|
+
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
|
+
try {
|
|
364
|
+
sourceEntries.forEach(entry => {
|
|
365
|
+
sum += Number(entry[stat.targetPath]?.['value'] ?? 0);
|
|
366
|
+
});
|
|
367
|
+
} catch (e) {
|
|
368
|
+
this.adapter.logger.warn(`statistics.js: Error during ${periodType} statistic aggregation: ${e.message}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
sum = Math.round((Number(sum) + Number.EPSILON) * 1000) / 1000;
|
|
372
|
+
|
|
373
|
+
target[stat.targetPath] = {
|
|
374
|
+
value: Number(sum.toFixed(3)),
|
|
375
|
+
unit: stat.unit || 'kWh',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
targetArray.push(target);
|
|
380
|
+
}
|
|
381
|
+
|
|
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
|
+
targetArray.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
|
|
392
|
+
this.stateCache.set(targetStateId, JSON.stringify(targetArray), { type: 'string' });
|
|
393
|
+
this.adapter.logger.debug(`Appended ${periodType} statistic ${toStr} `);
|
|
394
|
+
return true;
|
|
395
|
+
} catch (err) {
|
|
396
|
+
this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Calculates and updates daily consumption statistics from hourly data.
|
|
402
|
+
*
|
|
403
|
+
* @returns {void}
|
|
404
|
+
*/
|
|
405
|
+
async _calculateDaily() {
|
|
406
|
+
this.adapter.log.debug('### Daily execution triggered ###');
|
|
407
|
+
this._calculateAggregation(
|
|
408
|
+
'statistics.jsonHourly',
|
|
409
|
+
'statistics.jsonDaily',
|
|
410
|
+
now => {
|
|
411
|
+
// aggregation window: previous day (day that just ended)
|
|
412
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
413
|
+
const yesterday = new Date(today);
|
|
414
|
+
yesterday.setDate(today.getDate() - 1);
|
|
415
|
+
return { from: yesterday, to: today };
|
|
416
|
+
},
|
|
417
|
+
'daily',
|
|
418
|
+
) && (this.lastExecution.daily = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple days at startup
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Calculates and updates weekly consumption statistics from daily data.
|
|
423
|
+
*
|
|
424
|
+
* @returns {void}
|
|
425
|
+
*/
|
|
426
|
+
async _calculateWeekly() {
|
|
427
|
+
this.adapter.log.debug('### Weekly execution triggered ###');
|
|
428
|
+
this._calculateAggregation(
|
|
429
|
+
'statistics.jsonDaily',
|
|
430
|
+
'statistics.jsonWeekly',
|
|
431
|
+
now => {
|
|
432
|
+
// aggregation window: Monday to Sunday of the previous week (week that just ended)
|
|
433
|
+
const startday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
434
|
+
const lastday = new Date(startday);
|
|
435
|
+
// set to Monday of previous week
|
|
436
|
+
lastday.setDate(now.getDate() - (now.getDay() || 7) + 1); // set to Monday of actual week
|
|
437
|
+
startday.setDate(lastday.getDate() - 7); // set to Monday of previous week
|
|
438
|
+
return { from: startday, to: lastday };
|
|
439
|
+
},
|
|
440
|
+
'weekly',
|
|
441
|
+
) && (this.lastExecution.weekly = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple weeks at startup
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Calculates and updates monthly consumption statistics from daily data.
|
|
446
|
+
*
|
|
447
|
+
* @returns {void}
|
|
448
|
+
*/
|
|
449
|
+
async _calculateMonthly() {
|
|
450
|
+
this.adapter.log.debug('### Monthly execution triggered ###');
|
|
451
|
+
this._calculateAggregation(
|
|
452
|
+
'statistics.jsonDaily',
|
|
453
|
+
'statistics.jsonMonthly',
|
|
454
|
+
now => {
|
|
455
|
+
// aggregation windowStart: previous month (month that just ended)
|
|
456
|
+
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
|
|
457
|
+
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1, 0, 0, 0, 0);
|
|
458
|
+
return { from: prevMonth, to: thisMonth };
|
|
459
|
+
},
|
|
460
|
+
'monthly',
|
|
461
|
+
) && (this.lastExecution.monthly = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple months at startup
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Calculates and updates annual consumption statistics from daily data.
|
|
466
|
+
*
|
|
467
|
+
* @returns {void}
|
|
468
|
+
*/
|
|
469
|
+
async _calculateAnnual() {
|
|
470
|
+
this.adapter.log.debug('### Annual execution triggered ###');
|
|
471
|
+
this._calculateAggregation(
|
|
472
|
+
'statistics.jsonDaily',
|
|
473
|
+
'statistics.jsonAnnual',
|
|
474
|
+
now => {
|
|
475
|
+
// aggregation window: previous year (year that just ended)
|
|
476
|
+
const thisYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
|
|
477
|
+
const prevYear = new Date(now.getFullYear() - 1, 0, 1, 0, 0, 0, 0);
|
|
478
|
+
return { from: prevYear, to: thisYear };
|
|
479
|
+
},
|
|
480
|
+
'annual',
|
|
481
|
+
) && (this.lastExecution.annual = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple years at startup
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Initialize and schedule the unified task manager.
|
|
486
|
+
* This task runs every minute and checks which statistics need to be calculated.
|
|
487
|
+
*/
|
|
488
|
+
async _initializeTask() {
|
|
489
|
+
const scheduleNextRun = () => {
|
|
490
|
+
const now = new Date();
|
|
491
|
+
const next = new Date(now);
|
|
492
|
+
if (this.testing) {
|
|
493
|
+
next.setMinutes(now.getMinutes() + 1, 0, 0); //every minute
|
|
494
|
+
} else {
|
|
495
|
+
next.setHours(next.getHours() + 1, 0, 0, 0); //every hour
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Skip executing tasks exactly at midnight to avoid running aggregated jobs
|
|
499
|
+
if (next.getHours() === 0 && next.getMinutes() === 0) {
|
|
500
|
+
next.setHours(next.getHours() + 1, 0, 0, 0); //every hour
|
|
501
|
+
}
|
|
502
|
+
const msToNextHour = next.getTime() - now.getTime();
|
|
503
|
+
|
|
504
|
+
if (this.taskTimer) {
|
|
505
|
+
this.adapter.clearTimeout(this.taskTimer);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
this.taskTimer = this.adapter.setTimeout(async () => {
|
|
509
|
+
await this._executeScheduledTasks();
|
|
510
|
+
scheduleNextRun(); // reschedule for next hour
|
|
511
|
+
}, msToNextHour);
|
|
512
|
+
};
|
|
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
|
+
scheduleNextRun();
|
|
516
|
+
}
|
|
517
|
+
async _executeScheduledTasks() {
|
|
518
|
+
this._calculateHourly();
|
|
519
|
+
this._calculateDaily();
|
|
520
|
+
this._calculateWeekly();
|
|
521
|
+
this._calculateMonthly();
|
|
522
|
+
this._calculateAnnual();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Executes every midnight and performs the following tasks:
|
|
527
|
+
* - Execute all scheduled tasks to ensure that statistics are up to date.
|
|
528
|
+
* - Clear old data based on retention policies.
|
|
529
|
+
*/
|
|
530
|
+
async mitNightProcess() {
|
|
531
|
+
const now = new Date();
|
|
532
|
+
await this._executeScheduledTasks();
|
|
533
|
+
// Clear old data based on retention policies
|
|
534
|
+
const startOfYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
|
|
535
|
+
await this._clearGeneric('statistics.jsonDaily', startOfYear);
|
|
536
|
+
await this._clearGeneric('statistics.jsonWeekly', startOfYear);
|
|
537
|
+
await this._clearGeneric('statistics.jsonMonthly', startOfYear);
|
|
538
|
+
await this._clearGeneric('statistics.jsonHourly', new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async initialize() {
|
|
542
|
+
// load consumption JSON states (keep as string)
|
|
543
|
+
let state = await this.adapter.getState('statistics.jsonHourly');
|
|
544
|
+
this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
545
|
+
|
|
546
|
+
state = await this.adapter.getState('statistics.jsonDaily');
|
|
547
|
+
this.stateCache.set('statistics.jsonDaily', state?.val ?? '[]', { type: 'string', stored: true });
|
|
548
|
+
|
|
549
|
+
state = await this.adapter.getState('statistics.jsonWeekly');
|
|
550
|
+
this.stateCache.set('statistics.jsonWeekly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
551
|
+
|
|
552
|
+
state = await this.adapter.getState('statistics.jsonMonthly');
|
|
553
|
+
this.stateCache.set('statistics.jsonMonthly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
554
|
+
|
|
555
|
+
state = await this.adapter.getState('statistics.jsonAnnual');
|
|
556
|
+
this.stateCache.set('statistics.jsonAnnual', state?.val ?? '[]', { type: 'string', stored: true });
|
|
557
|
+
|
|
558
|
+
// load optional flexchart template
|
|
559
|
+
/*
|
|
560
|
+
state = await this.adapter.getState('statistics.flexChartTemplate');
|
|
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
|
+
*/
|
|
569
|
+
await tools.waitForValue(() => this.stateCache.get('collected.accumulatedEnergyYield')?.value, 60000);
|
|
570
|
+
this.mitNightProcess(); // execute once on startup to catch up on any missed runs while the adapter was not running
|
|
571
|
+
this._initializeTask();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Build a flexcharts/eCharts option object from stored statistics.
|
|
576
|
+
* The returned object may be sent to a callback for flexcharts' script source.
|
|
577
|
+
*
|
|
578
|
+
* @param {string} myChart - one of 'hourly','daily','weekly','monthly','annual'
|
|
579
|
+
* @returns {object} chart configuration
|
|
580
|
+
*/
|
|
581
|
+
_buildFlexchart(myChart) {
|
|
582
|
+
const IDS = {
|
|
583
|
+
hourly: 'statistics.jsonHourly',
|
|
584
|
+
daily: 'statistics.jsonDaily',
|
|
585
|
+
weekly: 'statistics.jsonWeekly',
|
|
586
|
+
monthly: 'statistics.jsonMonthly',
|
|
587
|
+
annual: 'statistics.jsonAnnual',
|
|
588
|
+
};
|
|
589
|
+
const id = IDS[myChart] || IDS.hourly;
|
|
590
|
+
let data = [];
|
|
591
|
+
try {
|
|
592
|
+
data = JSON.parse(this.stateCache.get(id)?.value ?? '[]');
|
|
593
|
+
} catch {
|
|
594
|
+
data = [];
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// default chart configuration (based on flexcharts discussion example)
|
|
598
|
+
const chart = {
|
|
599
|
+
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
|
600
|
+
legend: { show: true, orient: 'horizontal', left: 'center', top: 25 },
|
|
601
|
+
title: { left: 'center', text: 'Statistics ' },
|
|
602
|
+
grid: { right: '20%' },
|
|
603
|
+
toolbox: { feature: { dataView: { show: true, readOnly: false }, restore: { show: true }, saveAsImage: { show: true } } },
|
|
604
|
+
xAxis: [{ type: 'category', axisTick: { alignWithLabel: true }, data: [] }],
|
|
605
|
+
yAxis: [{ type: 'value', position: 'left', alignTicks: true, axisLine: { show: true }, axisLabel: { formatter: '{value}' } }],
|
|
606
|
+
series: [],
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
// merge with user-provided template if available
|
|
610
|
+
const templateStr = this.stateCache.get('statistics.flexChartTemplate')?.value;
|
|
611
|
+
if (templateStr) {
|
|
612
|
+
try {
|
|
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
|
+
}
|
|
621
|
+
}
|
|
622
|
+
} catch (e) {
|
|
623
|
+
this.adapter.logger.warn(`statistics: invalid flexChartTemplate JSON: ${e.message}`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// fill data arrays and collect units from stats definitions
|
|
628
|
+
const xAxis = [];
|
|
629
|
+
const seriesData = {};
|
|
630
|
+
const unitMap = {}; // targetPath -> unit string
|
|
631
|
+
|
|
632
|
+
for (const stat of this.stats) {
|
|
633
|
+
unitMap[stat.targetPath] = stat.unit || '';
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
for (const entry of data) {
|
|
637
|
+
const from = new Date(entry.from);
|
|
638
|
+
//const to = new Date(entry.to);
|
|
639
|
+
const xVal =
|
|
640
|
+
myChart === 'hourly'
|
|
641
|
+
? from.toLocaleTimeString('de-DE', { hour12: false, hour: '2-digit', minute: '2-digit' })
|
|
642
|
+
: from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
643
|
+
xAxis.push(xVal);
|
|
644
|
+
for (const stat of this.stats) {
|
|
645
|
+
const val = Number(entry[stat.targetPath]?.value ?? 0);
|
|
646
|
+
if (!seriesData[stat.targetPath]) {
|
|
647
|
+
seriesData[stat.targetPath] = [];
|
|
648
|
+
}
|
|
649
|
+
seriesData[stat.targetPath].push(Number(val.toFixed(3)));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
chart.xAxis[0].data = xAxis;
|
|
653
|
+
|
|
654
|
+
// apply unit information to axis/tooltip if there is a single unit or per-series
|
|
655
|
+
const units = new Set(Object.values(unitMap).filter(u => u));
|
|
656
|
+
if (units.size === 1) {
|
|
657
|
+
const singleUnit = [...units][0];
|
|
658
|
+
// update default yAxis label and tooltip formatter
|
|
659
|
+
chart.yAxis[0].name = singleUnit ? `(${singleUnit})` : '';
|
|
660
|
+
chart.yAxis[0].axisLabel.formatter = `{value}${singleUnit ? ` ${singleUnit}` : ''}`;
|
|
661
|
+
chart.tooltip.formatter = params => {
|
|
662
|
+
if (!Array.isArray(params)) params = [params];
|
|
663
|
+
return params.map(p => `${p.seriesName}: ${p.value}${singleUnit ? ` ${singleUnit}` : ''}`).join('<br/>');
|
|
664
|
+
};
|
|
665
|
+
} else if (units.size > 1) {
|
|
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
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// if chart.series was pre-populated by template use names, otherwise create default series
|
|
679
|
+
if (chart.series && chart.series.length > 0) {
|
|
680
|
+
chart.series.forEach(s => {
|
|
681
|
+
// try exact name first, otherwise case-insensitive lookup
|
|
682
|
+
let key = s.name;
|
|
683
|
+
if (seriesData[key]) {
|
|
684
|
+
s.data = seriesData[key];
|
|
685
|
+
} else {
|
|
686
|
+
// find matching key ignoring case
|
|
687
|
+
const found = Object.keys(seriesData).find(k => k.toLowerCase() === String(key).toLowerCase());
|
|
688
|
+
if (found) {
|
|
689
|
+
s.data = seriesData[found];
|
|
690
|
+
} else {
|
|
691
|
+
s.data = [];
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// ensure unit is added from stats definitions if not present
|
|
695
|
+
if (!s.unit) {
|
|
696
|
+
let unitVal = unitMap[key];
|
|
697
|
+
if (!unitVal) {
|
|
698
|
+
const foundu = Object.keys(unitMap).find(k => k.toLowerCase() === String(key).toLowerCase());
|
|
699
|
+
if (foundu) unitVal = unitMap[foundu];
|
|
700
|
+
}
|
|
701
|
+
s.unit = unitVal || '';
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
} else {
|
|
705
|
+
chart.series = Object.keys(seriesData).map(name => ({
|
|
706
|
+
name,
|
|
707
|
+
type: 'line',
|
|
708
|
+
data: seriesData[name],
|
|
709
|
+
unit: unitMap[name] || '',
|
|
710
|
+
}));
|
|
711
|
+
}
|
|
712
|
+
chart.title.text += myChart;
|
|
713
|
+
return chart;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Entry point for adapter to handle messages related to statistics/flexcharts.
|
|
718
|
+
*
|
|
719
|
+
* @param {{chart?: string}} message
|
|
720
|
+
* @param {Function} callback
|
|
721
|
+
*/
|
|
722
|
+
handleFlexMessage(message, callback) {
|
|
723
|
+
const chartType = message?.chart || 'hourly';
|
|
724
|
+
const result = this._buildFlexchart(chartType);
|
|
725
|
+
if (callback && typeof callback === 'function') {
|
|
726
|
+
callback(result);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
module.exports = statistics;
|