iobroker.sun2000 2.4.5 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/admin/jsonConfig.json5 +33 -0
- package/io-package.json +16 -15
- package/lib/drivers/driver_emma.js +6 -6
- package/lib/drivers/driver_inverter.js +67 -23
- package/lib/json_helper.js +3 -25
- package/lib/modbus/modbus_server.js +1 -1
- package/lib/register.js +174 -26
- package/lib/statistics/breakdownChartHelper.js +131 -0
- package/lib/statistics/consumptionBreakdown.js +304 -0
- package/lib/statistics/statistics.js +1453 -0
- package/lib/tools.js +15 -3
- package/main.js +12 -0
- package/package.json +9 -10
- package/lib/statistics.js +0 -1412
|
@@ -0,0 +1,1453 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* statistics.js
|
|
5
|
+
*
|
|
6
|
+
* Prepares statistical data from historical Huawei SUN2000 inverter states.
|
|
7
|
+
* Aggregates raw values into time-based datasets (hourly, daily, monthly, yearly)
|
|
8
|
+
* for analysis and visualisation via ioBroker.flexcharts.
|
|
9
|
+
*
|
|
10
|
+
* Consumption breakdown (splitting the total consumption into user-defined
|
|
11
|
+
* sub-categories) is fully delegated to:
|
|
12
|
+
* lib/statistics/consumptionBreakdown.js – config, validation, foreign-state refresh
|
|
13
|
+
* lib/statistics/breakdownChartHelper.js – eCharts series / legend / placeholder helpers
|
|
14
|
+
*
|
|
15
|
+
* Live chart:
|
|
16
|
+
* Uses this.liveStats data
|
|
17
|
+
* Every this.adapter.settings.statistics.liveInterval the kWh delta is converted to average kW:
|
|
18
|
+
* kW = ΔkWh * (60 / this.adapter.settings.statistics.liveInterval)
|
|
19
|
+
* Data is retained for 48 h, then cleared.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const stringify = require('javascript-stringify').stringify;
|
|
23
|
+
const { dataRefreshRate, statisticsType } = require(`${__dirname}/../types.js`);
|
|
24
|
+
const tools = require(`${__dirname}/../tools.js`);
|
|
25
|
+
const { stringifyWithFunctions, reviveFunctions } = require(`${__dirname}/../json_helper.js`);
|
|
26
|
+
const { ConsumptionBreakdown } = require(`${__dirname}/consumptionBreakdown.js`);
|
|
27
|
+
const chartHelper = require(`${__dirname}/breakdownChartHelper.js`);
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Chart types handled by this module
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// [LIVE] 'live' added
|
|
33
|
+
const CHART_TYPES = ['live', 'hourly', 'daily', 'weekly', 'monthly', 'annual'];
|
|
34
|
+
|
|
35
|
+
const CHART_STATE_IDS = {
|
|
36
|
+
// [LIVE] added
|
|
37
|
+
live: 'statistics.jsonLive',
|
|
38
|
+
hourly: 'statistics.jsonHourly',
|
|
39
|
+
daily: 'statistics.jsonDaily',
|
|
40
|
+
weekly: 'statistics.jsonWeekly',
|
|
41
|
+
monthly: 'statistics.jsonMonthly',
|
|
42
|
+
annual: 'statistics.jsonAnnual',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// [LIVE] Live chart configuration
|
|
47
|
+
// Interval in minutes — controls both the timer and the kW conversion factor.
|
|
48
|
+
// kW = ΔkWh * (60 / this.adapter.settings.statistics.liveInterval)
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
const LIVE_RETENTION_HOURS = 48; // entries older than this are dropped
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Class
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
class statistics {
|
|
57
|
+
constructor(adapterInstance, stateCache) {
|
|
58
|
+
this.adapter = adapterInstance;
|
|
59
|
+
this.stateCache = stateCache;
|
|
60
|
+
this.taskTimer = null;
|
|
61
|
+
// [LIVE]
|
|
62
|
+
this._liveTimer = null;
|
|
63
|
+
this._path = 'statistics';
|
|
64
|
+
this._initialized = false;
|
|
65
|
+
this.testing = false;
|
|
66
|
+
|
|
67
|
+
// Built-in stats processed by the calculation pipeline
|
|
68
|
+
this.stats = [
|
|
69
|
+
{ sourceId: 'collected.consumptionToday', targetPath: 'consumption', unit: 'kWh', type: statisticsType.deltaReset },
|
|
70
|
+
{ sourceId: 'collected.dailySolarYield', targetPath: 'solarYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
71
|
+
{ sourceId: 'collected.dailyInputYield', targetPath: 'inputYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
72
|
+
{ sourceId: 'collected.dailyExternalYield', targetPath: 'externalYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
73
|
+
{ sourceId: 'collected.dailyEnergyYield', targetPath: 'energyYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
74
|
+
{ sourceId: 'collected.SOC', targetPath: 'SOC', unit: '%', type: statisticsType.level },
|
|
75
|
+
{ sourceId: 'collected.currentDayChargeCapacity', targetPath: 'chargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
|
|
76
|
+
{ sourceId: 'collected.currentDayDischargeCapacity', targetPath: 'dischargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
|
|
77
|
+
{ sourceId: 'collected.gridExportToday', targetPath: 'gridExport', unit: 'kWh', type: statisticsType.deltaReset },
|
|
78
|
+
{ sourceId: 'collected.gridImportToday', targetPath: 'gridImport', unit: 'kWh', type: statisticsType.deltaReset },
|
|
79
|
+
// --- Computed ---
|
|
80
|
+
{
|
|
81
|
+
targetPath: 'selfSufficiency',
|
|
82
|
+
unit: '%',
|
|
83
|
+
type: statisticsType.computed,
|
|
84
|
+
compute: entry => {
|
|
85
|
+
const consumption = entry.consumption?.total ?? entry.consumption?.value ?? 0;
|
|
86
|
+
const gridImport = entry.gridImport?.total ?? entry.gridImport?.value ?? 0;
|
|
87
|
+
if (consumption <= 0) return 100;
|
|
88
|
+
return Math.round(Math.max(0, Math.min(100, (1 - gridImport / consumption) * 100)) * 10) / 10;
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
targetPath: 'selfConsumption',
|
|
93
|
+
unit: '%',
|
|
94
|
+
type: statisticsType.computed,
|
|
95
|
+
compute: entry => {
|
|
96
|
+
let solarYield = entry.solarYield?.total ?? entry.solarYield?.value ?? 0;
|
|
97
|
+
solarYield += entry.externalYield?.total ?? entry.externalYield?.value ?? 0;
|
|
98
|
+
const gridExport = entry.gridExport?.total ?? entry.gridExport?.value ?? 0;
|
|
99
|
+
if (solarYield <= 0) return 0;
|
|
100
|
+
return Math.round(Math.max(0, Math.min(100, (1 - gridExport / solarYield) * 100)) * 10) / 10;
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
this.liveStats = [
|
|
106
|
+
{ sourceId: 'collected.sumDaily.houseConsumption', targetPath: 'consumption', unit: 'kWh', type: statisticsType.deltaReset },
|
|
107
|
+
{ sourceId: 'collected.dailySolarYield', targetPath: 'solarYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
108
|
+
{ sourceId: 'collected.sumDaily.inputPower', targetPath: 'inputYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
109
|
+
{ sourceId: 'collected.dailyExternalYield', targetPath: 'externalYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
110
|
+
{ sourceId: 'collected.dailyEnergyYield', targetPath: 'energyYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
111
|
+
{ sourceId: 'collected.SOC', targetPath: 'SOC', unit: '%', type: statisticsType.level },
|
|
112
|
+
{ sourceId: 'collected.sumDaily.chargePower', targetPath: 'chargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
|
|
113
|
+
{ sourceId: 'collected.sumDaily.dischargePower', targetPath: 'dischargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
|
|
114
|
+
{ sourceId: 'collected.sumDaily.feed-outPower', targetPath: 'gridImport', unit: 'kWh', type: statisticsType.deltaReset },
|
|
115
|
+
{ sourceId: 'collected.sumDaily.feed-inPower', targetPath: 'gridExport', unit: 'kWh', type: statisticsType.deltaReset },
|
|
116
|
+
// --- Computed ---
|
|
117
|
+
{
|
|
118
|
+
targetPath: 'selfSufficiency',
|
|
119
|
+
unit: '%',
|
|
120
|
+
type: statisticsType.computed,
|
|
121
|
+
compute: entry => {
|
|
122
|
+
const consumption = entry.consumption?.total ?? entry.consumption?.value ?? 0;
|
|
123
|
+
const gridImport = entry.gridImport?.total ?? entry.gridImport?.value ?? 0;
|
|
124
|
+
if (consumption <= 0) return 100;
|
|
125
|
+
return Math.round(Math.max(0, Math.min(100, (1 - gridImport / consumption) * 100)) * 10) / 10;
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
targetPath: 'selfConsumption',
|
|
130
|
+
unit: '%',
|
|
131
|
+
type: statisticsType.computed,
|
|
132
|
+
compute: entry => {
|
|
133
|
+
let solarYield = entry.solarYield?.total ?? entry.solarYield?.value ?? 0;
|
|
134
|
+
solarYield += entry.externalYield?.total ?? entry.externalYield?.value ?? 0;
|
|
135
|
+
const gridExport = entry.gridExport?.total ?? entry.gridExport?.value ?? 0;
|
|
136
|
+
if (solarYield <= 0) return 0;
|
|
137
|
+
return Math.round(Math.max(0, Math.min(100, (1 - gridExport / solarYield) * 100)) * 10) / 10;
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
// Instantiate the breakdown manager.
|
|
143
|
+
// builtinPaths is passed so the manager can detect targetPath collisions.
|
|
144
|
+
const builtinPaths = this.stats.map(s => s.targetPath);
|
|
145
|
+
this._breakdown = new ConsumptionBreakdown(adapterInstance, stateCache, builtinPaths);
|
|
146
|
+
|
|
147
|
+
// ioBroker state definitions registered via postProcessHooks
|
|
148
|
+
this.postProcessHooks = [
|
|
149
|
+
{
|
|
150
|
+
refresh: dataRefreshRate.low,
|
|
151
|
+
states: [
|
|
152
|
+
// [LIVE] state definitions
|
|
153
|
+
{
|
|
154
|
+
id: 'statistics.jsonLive',
|
|
155
|
+
name: 'Live power JSON',
|
|
156
|
+
type: 'string',
|
|
157
|
+
role: 'json',
|
|
158
|
+
desc: `Live power (kW) chart data, ${this.adapter.settings.statistics.liveInterval}-min intervals, 48 h retention`,
|
|
159
|
+
initVal: '[]',
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: 'statistics.jsonHourly',
|
|
163
|
+
name: 'Hourly consumption JSON',
|
|
164
|
+
type: 'string',
|
|
165
|
+
role: 'json',
|
|
166
|
+
desc: 'Hourly consumption for last and current day per full hour',
|
|
167
|
+
initVal: '[]',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 'statistics.jsonDaily',
|
|
171
|
+
name: 'Daily consumption JSON',
|
|
172
|
+
type: 'string',
|
|
173
|
+
role: 'json',
|
|
174
|
+
desc: 'Daily consumption for current month per day',
|
|
175
|
+
initVal: '[]',
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: 'statistics.jsonWeekly',
|
|
179
|
+
name: 'Weekly consumption JSON',
|
|
180
|
+
type: 'string',
|
|
181
|
+
role: 'json',
|
|
182
|
+
desc: 'Weekly consumption for current year per week',
|
|
183
|
+
initVal: '[]',
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'statistics.jsonMonthly',
|
|
187
|
+
name: 'Monthly consumption JSON',
|
|
188
|
+
type: 'string',
|
|
189
|
+
role: 'json',
|
|
190
|
+
desc: 'Monthly consumption for current year per month',
|
|
191
|
+
initVal: '[]',
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: 'statistics.jsonAnnual',
|
|
195
|
+
name: 'Annual consumption JSON',
|
|
196
|
+
type: 'string',
|
|
197
|
+
role: 'json',
|
|
198
|
+
desc: 'Annual consumption per year',
|
|
199
|
+
initVal: '[]',
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'statistics.jsonToday',
|
|
203
|
+
name: 'Today summary',
|
|
204
|
+
type: 'string',
|
|
205
|
+
role: 'json',
|
|
206
|
+
desc: "Live summary of today's energy values",
|
|
207
|
+
initVal: '{}',
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
// Breakdown config state — definition provided by sub-module
|
|
211
|
+
this._breakdown.stateDef,
|
|
212
|
+
|
|
213
|
+
// Chart templates (user-writable, one per chart type)
|
|
214
|
+
...CHART_TYPES.map(t => ({
|
|
215
|
+
id: `statistics.flexCharts.template.${t}`,
|
|
216
|
+
name: `Flexcharts template ${t}`,
|
|
217
|
+
type: 'string',
|
|
218
|
+
role: 'json',
|
|
219
|
+
desc: `Optional eCharts template for ${t} chart. Leave empty {} for built-in layout.`,
|
|
220
|
+
write: true,
|
|
221
|
+
initVal: '{}',
|
|
222
|
+
})),
|
|
223
|
+
|
|
224
|
+
// Chart output states (read-only, written by this module)
|
|
225
|
+
...CHART_TYPES.map(t => ({
|
|
226
|
+
id: `statistics.flexCharts.jsonOutput.${t}`,
|
|
227
|
+
name: `Flexcharts output ${t}`,
|
|
228
|
+
type: 'string',
|
|
229
|
+
role: 'json',
|
|
230
|
+
desc: `ECharts configuration for ${t} chart`,
|
|
231
|
+
initVal: '{}',
|
|
232
|
+
})),
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
this.initialize();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
get processHooks() {
|
|
241
|
+
return this.postProcessHooks;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// -------------------------------------------------------------------------
|
|
245
|
+
// Effective stats: built-in + breakdown entries (unified pipeline interface)
|
|
246
|
+
// -------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Merges this.stats with the breakdown's stats-shaped entries so all
|
|
250
|
+
* calculation code iterates a single array transparently.
|
|
251
|
+
*
|
|
252
|
+
* @returns {Array}
|
|
253
|
+
*/
|
|
254
|
+
_effectiveStats() {
|
|
255
|
+
return [...this.stats, ...this._breakdown.statsEntries];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
_effectiveLiveStats() {
|
|
259
|
+
return [...this.liveStats, ...this._breakdown.statsEntries];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// -------------------------------------------------------------------------
|
|
263
|
+
// Date helpers
|
|
264
|
+
// -------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
_localIsoWithOffset(d) {
|
|
267
|
+
const pad = n => String(n).padStart(2, '0');
|
|
268
|
+
const tzOffset = -d.getTimezoneOffset();
|
|
269
|
+
const sign = tzOffset >= 0 ? '+' : '-';
|
|
270
|
+
const absMin = Math.abs(tzOffset);
|
|
271
|
+
return (
|
|
272
|
+
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
273
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.000` +
|
|
274
|
+
`${sign}${pad(Math.floor(absMin / 60))}:${pad(absMin % 60)}`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// -------------------------------------------------------------------------
|
|
279
|
+
// Core calculation helpers
|
|
280
|
+
// -------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Appends a new data-point (periodStart → periodEnde) to the JSON array
|
|
284
|
+
* stored in stateId. Breakdown entries are included because _effectiveStats()
|
|
285
|
+
* returns them alongside the built-in stats.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} stateId
|
|
288
|
+
* @param {Date} periodStart
|
|
289
|
+
* @param {Date} periodEnde
|
|
290
|
+
* @param {Array} effectiveStats
|
|
291
|
+
* @returns {boolean} true if an entry was appended
|
|
292
|
+
*/
|
|
293
|
+
_calculateGeneric(stateId, periodStart, periodEnde, effectiveStats = this._effectiveStats()) {
|
|
294
|
+
const toStr = this._localIsoWithOffset(periodEnde);
|
|
295
|
+
|
|
296
|
+
let arr = [];
|
|
297
|
+
try {
|
|
298
|
+
arr = JSON.parse(this.stateCache.get(stateId)?.value ?? '[]');
|
|
299
|
+
if (!Array.isArray(arr)) arr = [];
|
|
300
|
+
} catch {
|
|
301
|
+
arr = [];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let fromDate = periodStart;
|
|
305
|
+
let last = {};
|
|
306
|
+
if (arr.length > 0) {
|
|
307
|
+
last = arr[arr.length - 1];
|
|
308
|
+
if (last.to === toStr) return false;
|
|
309
|
+
const lastToDate = new Date(last.to);
|
|
310
|
+
const toDate = new Date(toStr);
|
|
311
|
+
if (lastToDate >= periodStart || toDate <= periodStart) fromDate = lastToDate;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const entry = { from: this._localIsoWithOffset(fromDate), to: toStr };
|
|
315
|
+
|
|
316
|
+
// Pass 1: delta / level stats (built-in + breakdown)
|
|
317
|
+
for (const stat of effectiveStats) {
|
|
318
|
+
if (stat.type === statisticsType.computed) continue;
|
|
319
|
+
|
|
320
|
+
const source = Math.round((Number(this.stateCache.get(stat.sourceId)?.value ?? 0) + Number.EPSILON) * 1000) / 1000;
|
|
321
|
+
if (source === null || source === undefined) {
|
|
322
|
+
if (!stat._isBreakdown) this.adapter.logger.debug(`Source state ${stat.sourceId} not found statistic hook`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
let value = source;
|
|
326
|
+
if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
|
|
327
|
+
const lastTotal = Number(last[stat.targetPath]?.total ?? 0);
|
|
328
|
+
if (stat.type === statisticsType.deltaReset) {
|
|
329
|
+
if (fromDate.getTime() !== periodStart.getTime()) value -= lastTotal;
|
|
330
|
+
} else {
|
|
331
|
+
if (last[stat.targetPath]?.total === undefined) {
|
|
332
|
+
this.adapter.logger.debug(`No total value for ${stat.targetPath} in last entry, delta set to 0`);
|
|
333
|
+
value = 0;
|
|
334
|
+
} else {
|
|
335
|
+
value -= lastTotal;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
//value = Math.round((Number(value) + Number.EPSILON) * 1000) / 1000;
|
|
340
|
+
entry[stat.targetPath] = { value: Number(value.toFixed(3)) };
|
|
341
|
+
if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
|
|
342
|
+
entry[stat.targetPath].total = Number(source.toFixed(3));
|
|
343
|
+
}
|
|
344
|
+
entry[stat.targetPath].unit = stat.unit || 'kWh';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Pass 2: computed stats
|
|
348
|
+
for (const stat of effectiveStats) {
|
|
349
|
+
if (stat.type !== statisticsType.computed) continue;
|
|
350
|
+
try {
|
|
351
|
+
const value = stat.compute(entry);
|
|
352
|
+
entry[stat.targetPath] = { value: Number(Number(value).toFixed(3)), unit: stat.unit || '%' };
|
|
353
|
+
} catch (e) {
|
|
354
|
+
this.adapter.logger.warn(`statistics: error computing ${stat.targetPath}: ${e.message}`);
|
|
355
|
+
entry[stat.targetPath] = { value: 0, unit: stat.unit || '%' };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Pass 3: subtract breakdown values from consumption
|
|
360
|
+
if (entry.consumption !== undefined) {
|
|
361
|
+
let breakdownSum = 0;
|
|
362
|
+
for (const stat of this._breakdown.statsEntries) {
|
|
363
|
+
if (!stat._isBreakdown) continue;
|
|
364
|
+
if ((stat.unit || 'kWh') !== 'kWh') continue; //??
|
|
365
|
+
breakdownSum += Number(entry[stat.targetPath]?.value ?? 0);
|
|
366
|
+
}
|
|
367
|
+
if (breakdownSum > 0) {
|
|
368
|
+
const remainder = Math.max(0, Math.round((Number(entry.consumption.value) - breakdownSum + Number.EPSILON) * 1000) / 1000);
|
|
369
|
+
entry.consumption = { ...entry.consumption, value: Number(remainder.toFixed(3)) };
|
|
370
|
+
this.adapter.logger.debug(`_calculateGeneric: consumption reduced by breakdown sum ${breakdownSum} → remainder ${remainder}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
arr.push(entry);
|
|
375
|
+
arr.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
|
|
376
|
+
this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
|
|
377
|
+
this.adapter.logger.debug(`Appended ${stateId} statistic ${toStr}`);
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Removes entries older than periodStart from the JSON array in stateId.
|
|
383
|
+
*
|
|
384
|
+
* @param {string} stateId
|
|
385
|
+
* @param {Date} periodStart
|
|
386
|
+
*/
|
|
387
|
+
_clearGeneric(stateId, periodStart) {
|
|
388
|
+
let arr = [];
|
|
389
|
+
try {
|
|
390
|
+
arr = JSON.parse(this.stateCache.get(stateId)?.value ?? '[]');
|
|
391
|
+
if (!Array.isArray(arr)) arr = [];
|
|
392
|
+
} catch {
|
|
393
|
+
arr = [];
|
|
394
|
+
}
|
|
395
|
+
arr = arr.filter(item => {
|
|
396
|
+
const ts = Date.parse(item.from);
|
|
397
|
+
return !Number.isNaN(ts) && ts >= periodStart.getTime();
|
|
398
|
+
});
|
|
399
|
+
this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Aggregates source entries within a time window into a single target entry.
|
|
404
|
+
*
|
|
405
|
+
* @param {string} sourceStateId
|
|
406
|
+
* @param {string} targetStateId
|
|
407
|
+
* @param {(now: Date) => { from: Date, to: Date }} getWindow (now: Date) => { from: Date, to: Date }
|
|
408
|
+
* @param {string} periodType label for log messages
|
|
409
|
+
* @param {Array} effectiveStats
|
|
410
|
+
* @returns {boolean}
|
|
411
|
+
*/
|
|
412
|
+
|
|
413
|
+
_calculateAggregation(sourceStateId, targetStateId, getWindow, periodType) {
|
|
414
|
+
const effectiveStats = this._effectiveStats();
|
|
415
|
+
try {
|
|
416
|
+
const now = new Date();
|
|
417
|
+
const { from: fromDate, to: toDate } = getWindow(now);
|
|
418
|
+
|
|
419
|
+
const isRunning = now < toDate;
|
|
420
|
+
const effectiveTo = isRunning ? now : toDate;
|
|
421
|
+
const toStr = this._localIsoWithOffset(effectiveTo);
|
|
422
|
+
const fromStr = this._localIsoWithOffset(fromDate);
|
|
423
|
+
|
|
424
|
+
let targetArray = [];
|
|
425
|
+
try {
|
|
426
|
+
targetArray = JSON.parse(this.stateCache.get(targetStateId)?.value ?? '[]');
|
|
427
|
+
if (!Array.isArray(targetArray)) targetArray = [];
|
|
428
|
+
} catch {
|
|
429
|
+
targetArray = [];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const existingIdx = targetArray.findLastIndex(e => (isRunning ? e._live === true : e.from === fromStr));
|
|
433
|
+
if (!isRunning && existingIdx >= 0 && targetArray[existingIdx] === this._localIsoWithOffset(toDate)) {
|
|
434
|
+
this.adapter.logger.debug(`statistics.js: ${periodType} entry already finalized, skipping`);
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
let sourceEntries = [];
|
|
439
|
+
try {
|
|
440
|
+
sourceEntries = JSON.parse(this.stateCache.get(sourceStateId)?.value ?? '[]');
|
|
441
|
+
if (!Array.isArray(sourceEntries)) sourceEntries = [];
|
|
442
|
+
} catch {
|
|
443
|
+
sourceEntries = [];
|
|
444
|
+
}
|
|
445
|
+
sourceEntries = sourceEntries.filter(item => {
|
|
446
|
+
const ts = Date.parse(item.from);
|
|
447
|
+
return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < toDate.getTime();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
if (sourceEntries.length === 0) {
|
|
451
|
+
this.adapter.logger.debug(
|
|
452
|
+
`statistics.js: No source entries for ${periodType} between ${fromDate.toISOString()} and ${toDate.toISOString()}, skipping`,
|
|
453
|
+
);
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
this.adapter.logger.debug(`statistics.js: ${sourceEntries.length} entries for ${periodType} (running=${isRunning})`);
|
|
457
|
+
|
|
458
|
+
const target = { from: fromStr, to: toStr };
|
|
459
|
+
if (isRunning) target._live = true;
|
|
460
|
+
|
|
461
|
+
// Pass 1: sum delta / deltaReset stats (built-in + breakdown)
|
|
462
|
+
for (const stat of effectiveStats) {
|
|
463
|
+
if (stat.type === statisticsType.level || stat.type === statisticsType.computed) continue;
|
|
464
|
+
let sum = 0;
|
|
465
|
+
try {
|
|
466
|
+
sourceEntries.forEach(e => {
|
|
467
|
+
sum += Number(e[stat.targetPath]?.value ?? 0);
|
|
468
|
+
});
|
|
469
|
+
} catch (e) {
|
|
470
|
+
this.adapter.logger.warn(`statistics.js: aggregation error ${periodType}: ${e.message}`);
|
|
471
|
+
}
|
|
472
|
+
sum = Math.round((Number(sum) + Number.EPSILON) * 1000) / 1000;
|
|
473
|
+
target[stat.targetPath] = { value: Number(sum.toFixed(3)), unit: stat.unit || 'kWh' };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Pass 2: computed stats
|
|
477
|
+
for (const stat of effectiveStats) {
|
|
478
|
+
if (stat.type !== statisticsType.computed) continue;
|
|
479
|
+
try {
|
|
480
|
+
const value = stat.compute(target);
|
|
481
|
+
target[stat.targetPath] = { value: Number(Number(value).toFixed(3)), unit: stat.unit || '%' };
|
|
482
|
+
} catch (e) {
|
|
483
|
+
this.adapter.logger.warn(`statistics: error computing ${stat.targetPath}: ${e.message}`);
|
|
484
|
+
target[stat.targetPath] = { value: 0, unit: stat.unit || '%' };
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (existingIdx >= 0) {
|
|
489
|
+
targetArray[existingIdx] = target;
|
|
490
|
+
} else {
|
|
491
|
+
targetArray.push(target);
|
|
492
|
+
}
|
|
493
|
+
targetArray.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
|
|
494
|
+
this.stateCache.set(targetStateId, JSON.stringify(targetArray), { type: 'string' });
|
|
495
|
+
return true;
|
|
496
|
+
} catch (err) {
|
|
497
|
+
this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// -------------------------------------------------------------------------
|
|
502
|
+
// Today live summary state
|
|
503
|
+
// -------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
updateJsonToday() {
|
|
506
|
+
if (!this._initialized) {
|
|
507
|
+
this.adapter.logger.debug('statistics: updateJsonToday called before initialization');
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
const now = new Date();
|
|
512
|
+
const today = {};
|
|
513
|
+
const effectiveStats = this._effectiveStats();
|
|
514
|
+
|
|
515
|
+
for (const stat of effectiveStats) {
|
|
516
|
+
if (stat.type === statisticsType.computed) continue;
|
|
517
|
+
const val = this.stateCache.get(stat.sourceId)?.value;
|
|
518
|
+
today[stat.targetPath] = { value: val != null ? Number(Number(val).toFixed(3)) : null, unit: stat.unit };
|
|
519
|
+
}
|
|
520
|
+
for (const stat of effectiveStats) {
|
|
521
|
+
if (stat.type !== statisticsType.computed) continue;
|
|
522
|
+
try {
|
|
523
|
+
const value = stat.compute(today);
|
|
524
|
+
today[stat.targetPath] = { value: Number(Number(value).toFixed(3)), unit: stat.unit };
|
|
525
|
+
} catch (e) {
|
|
526
|
+
this.adapter.logger.warn(`statistics: error computing today.${stat.targetPath}: ${e.message}`);
|
|
527
|
+
today[stat.targetPath] = { value: 0, unit: stat.unit };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
today.updatedAt = this._localIsoWithOffset(now);
|
|
531
|
+
this.stateCache.set('statistics.jsonToday', JSON.stringify(today), { type: 'string' });
|
|
532
|
+
this.adapter.logger.debug('statistics: jsonToday updated');
|
|
533
|
+
} catch (err) {
|
|
534
|
+
this.adapter.logger.warn(`statistics: error updating today state: ${err.message}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// -------------------------------------------------------------------------
|
|
539
|
+
// Periodic calculations
|
|
540
|
+
// -------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
// [LIVE] New method — same pattern as _calculateHourly
|
|
543
|
+
/**
|
|
544
|
+
* Appends one entry to statistics.jsonLive every LIVE_INTERVAL_MINUTES.
|
|
545
|
+
*
|
|
546
|
+
* Uses _calculateGeneric with the current minute boundary as periodEnde and
|
|
547
|
+
* the previous interval boundary as periodStart (= start of current day for
|
|
548
|
+
* the deltaReset baseline).
|
|
549
|
+
*
|
|
550
|
+
* After _calculateGeneric writes the kWh delta, the last entry is converted
|
|
551
|
+
* in-place to average kW:
|
|
552
|
+
* kW = ΔkWh × (60 / LIVE_INTERVAL_MINUTES)
|
|
553
|
+
*
|
|
554
|
+
* 'SOC' and computed stats keep their original unit (%, no conversion).
|
|
555
|
+
* Entries older than LIVE_RETENTION_HOURS are pruned.
|
|
556
|
+
*/
|
|
557
|
+
_calculateLive() {
|
|
558
|
+
const now = new Date();
|
|
559
|
+
// Round down to the current interval boundary
|
|
560
|
+
const intervalMs = (this.adapter.settings.statistics.liveInterval || 5) * 60 * 1000;
|
|
561
|
+
const boundary = new Date(Math.floor(now.getTime() / intervalMs) * intervalMs);
|
|
562
|
+
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
563
|
+
//const convFactor = 60 / LIVE_INTERVAL_MINUTES;
|
|
564
|
+
|
|
565
|
+
this.adapter.log.debug(`### Live execution triggered, boundary: ${boundary.toLocaleTimeString()} ###`);
|
|
566
|
+
|
|
567
|
+
const appended = this._calculateGeneric(CHART_STATE_IDS.live, startOfDay, boundary, this._effectiveLiveStats());
|
|
568
|
+
if (!appended) return;
|
|
569
|
+
|
|
570
|
+
// Convert the freshly appended entry from kWh delta → average kW
|
|
571
|
+
let arr = [];
|
|
572
|
+
try {
|
|
573
|
+
arr = JSON.parse(this.stateCache.get(CHART_STATE_IDS.live)?.value ?? '[]');
|
|
574
|
+
if (!Array.isArray(arr)) arr = [];
|
|
575
|
+
} catch {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (arr.length === 0) return;
|
|
580
|
+
const last = arr[arr.length - 1];
|
|
581
|
+
const fromDate = new Date(last.from);
|
|
582
|
+
const toDate = new Date(last.to);
|
|
583
|
+
const convFactor = toDate.getTime() - fromDate.getTime() > 0 ? (60 * 60 * 1000) / (toDate.getTime() - fromDate.getTime()) : 0;
|
|
584
|
+
|
|
585
|
+
for (const stat of this._effectiveLiveStats()) {
|
|
586
|
+
// Only convert energy (kWh) deltas — leave SOC (%) and computed (%) as-is
|
|
587
|
+
if (stat.type !== statisticsType.deltaReset && stat.type !== statisticsType.delta) continue;
|
|
588
|
+
//if ((stat.unit || 'kWh') !== 'kWh') continue; //??
|
|
589
|
+
if (last[stat.targetPath] === undefined) continue;
|
|
590
|
+
|
|
591
|
+
const kw = Math.round(last[stat.targetPath].value * convFactor * 1000) / 1000;
|
|
592
|
+
if (stat.targetPath === 'solarYield') {
|
|
593
|
+
this.adapter.log.debug(
|
|
594
|
+
`### Live stat solarYield from ${last.from} to ${last.to} - ${stat.targetPath}, total: ${last[stat.targetPath]?.total} ${last[stat.targetPath].value} kWh → ${kw} kW (convFactor ${convFactor.toFixed(2)})`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
last[stat.targetPath] = { ...last[stat.targetPath], value: kw, unit: 'kW' };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Prune entries older than LIVE_RETENTION_HOURS
|
|
601
|
+
const cutoff = now.getTime() - LIVE_RETENTION_HOURS * 3600 * 1000;
|
|
602
|
+
const pruned = arr.filter(e => {
|
|
603
|
+
const t = Date.parse(e.from);
|
|
604
|
+
return !Number.isNaN(t) && t >= cutoff;
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
this.stateCache.set(CHART_STATE_IDS.live, JSON.stringify(pruned), { type: 'string' });
|
|
608
|
+
this._buildFlexchart('live');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
_calculateHourly() {
|
|
612
|
+
const now = new Date();
|
|
613
|
+
if (this.testing) {
|
|
614
|
+
const state = this.adapter.getState('statistics.jsonHourly');
|
|
615
|
+
this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
616
|
+
now.setDate(now.getDate() + 1);
|
|
617
|
+
now.setHours(1, 0, 0, 1);
|
|
618
|
+
}
|
|
619
|
+
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
620
|
+
const lastHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0);
|
|
621
|
+
this.adapter.log.debug(`### Hourly execution triggered with lastHour: ${lastHour.toLocaleTimeString()} ###`);
|
|
622
|
+
if (this._calculateGeneric('statistics.jsonHourly', startOfDay, lastHour)) {
|
|
623
|
+
this._buildFlexchart('hourly');
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
_calculateDaily() {
|
|
628
|
+
this.adapter.log.debug('### Daily execution triggered ###');
|
|
629
|
+
this._calculateAggregation(
|
|
630
|
+
'statistics.jsonHourly',
|
|
631
|
+
'statistics.jsonDaily',
|
|
632
|
+
now => {
|
|
633
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
634
|
+
const yesterday = new Date(today);
|
|
635
|
+
yesterday.setDate(today.getDate() - 1);
|
|
636
|
+
return { from: yesterday, to: today };
|
|
637
|
+
},
|
|
638
|
+
'daily',
|
|
639
|
+
);
|
|
640
|
+
this._calculateAggregation(
|
|
641
|
+
'statistics.jsonHourly',
|
|
642
|
+
'statistics.jsonDaily',
|
|
643
|
+
now => {
|
|
644
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
645
|
+
const tomorrow = new Date(today);
|
|
646
|
+
tomorrow.setDate(today.getDate() + 1);
|
|
647
|
+
return { from: today, to: tomorrow };
|
|
648
|
+
},
|
|
649
|
+
'daily-live',
|
|
650
|
+
);
|
|
651
|
+
this._buildFlexchart('daily');
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
_calculateWeekly() {
|
|
655
|
+
this.adapter.log.debug('### Weekly execution triggered ###');
|
|
656
|
+
this._calculateAggregation(
|
|
657
|
+
'statistics.jsonDaily',
|
|
658
|
+
'statistics.jsonWeekly',
|
|
659
|
+
now => {
|
|
660
|
+
const mon = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
661
|
+
mon.setDate(now.getDate() - (now.getDay() || 7) + 1);
|
|
662
|
+
const prev = new Date(mon);
|
|
663
|
+
prev.setDate(mon.getDate() - 7);
|
|
664
|
+
return { from: prev, to: mon };
|
|
665
|
+
},
|
|
666
|
+
'weekly',
|
|
667
|
+
);
|
|
668
|
+
this._calculateAggregation(
|
|
669
|
+
'statistics.jsonDaily',
|
|
670
|
+
'statistics.jsonWeekly',
|
|
671
|
+
now => {
|
|
672
|
+
const mon = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
673
|
+
mon.setDate(now.getDate() - (now.getDay() || 7) + 1);
|
|
674
|
+
const next = new Date(mon);
|
|
675
|
+
next.setDate(mon.getDate() + 7);
|
|
676
|
+
return { from: mon, to: next };
|
|
677
|
+
},
|
|
678
|
+
'weekly-live',
|
|
679
|
+
);
|
|
680
|
+
this._buildFlexchart('weekly');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
_calculateMonthly() {
|
|
684
|
+
this.adapter.log.debug('### Monthly execution triggered ###');
|
|
685
|
+
this._calculateAggregation(
|
|
686
|
+
'statistics.jsonDaily',
|
|
687
|
+
'statistics.jsonMonthly',
|
|
688
|
+
now => ({ from: new Date(now.getFullYear(), now.getMonth() - 1, 1), to: new Date(now.getFullYear(), now.getMonth(), 1) }),
|
|
689
|
+
'monthly',
|
|
690
|
+
);
|
|
691
|
+
this._calculateAggregation(
|
|
692
|
+
'statistics.jsonDaily',
|
|
693
|
+
'statistics.jsonMonthly',
|
|
694
|
+
now => ({ from: new Date(now.getFullYear(), now.getMonth(), 1), to: new Date(now.getFullYear(), now.getMonth() + 1, 1) }),
|
|
695
|
+
'monthly-live',
|
|
696
|
+
);
|
|
697
|
+
this._buildFlexchart('monthly');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
_calculateAnnual() {
|
|
701
|
+
this.adapter.log.debug('### Annual execution triggered ###');
|
|
702
|
+
this._calculateAggregation(
|
|
703
|
+
'statistics.jsonDaily',
|
|
704
|
+
'statistics.jsonAnnual',
|
|
705
|
+
now => ({ from: new Date(now.getFullYear() - 1, 0, 1), to: new Date(now.getFullYear(), 0, 1) }),
|
|
706
|
+
'annual',
|
|
707
|
+
);
|
|
708
|
+
this._calculateAggregation(
|
|
709
|
+
'statistics.jsonDaily',
|
|
710
|
+
'statistics.jsonAnnual',
|
|
711
|
+
now => ({ from: new Date(now.getFullYear(), 0, 1), to: new Date(now.getFullYear() + 1, 0, 1) }),
|
|
712
|
+
'annual-live',
|
|
713
|
+
);
|
|
714
|
+
this._buildFlexchart('annual');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// [LIVE] Minute-aligned timer, fires every LIVE_INTERVAL_MINUTES
|
|
718
|
+
async _initializeLiveTimer() {
|
|
719
|
+
const scheduleNext = () => {
|
|
720
|
+
const now = new Date();
|
|
721
|
+
const intervalMs = this.adapter.settings.statistics.liveInterval * 60 * 1000;
|
|
722
|
+
// Next boundary strictly in the future
|
|
723
|
+
const nextBoundary = new Date(Math.floor(now.getTime() / intervalMs) * intervalMs + intervalMs + 50);
|
|
724
|
+
const ms = nextBoundary.getTime() - now.getTime();
|
|
725
|
+
|
|
726
|
+
if (this._liveTimer) this.adapter.clearTimeout(this._liveTimer);
|
|
727
|
+
this._liveTimer = this.adapter.setTimeout(async () => {
|
|
728
|
+
await this._breakdown.refreshValues();
|
|
729
|
+
this._calculateLive();
|
|
730
|
+
scheduleNext();
|
|
731
|
+
}, ms);
|
|
732
|
+
|
|
733
|
+
this.adapter.logger.debug(
|
|
734
|
+
`statistics: live timer set, interval=${this.adapter.settings.statistics.liveInterval} min, next run in ${Math.round(ms / 1000)} s`,
|
|
735
|
+
);
|
|
736
|
+
};
|
|
737
|
+
//TEST ??
|
|
738
|
+
/*
|
|
739
|
+
await this._breakdown.refreshValues();
|
|
740
|
+
this._calculateLive();
|
|
741
|
+
*/
|
|
742
|
+
scheduleNext();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// -------------------------------------------------------------------------
|
|
746
|
+
// Scheduler
|
|
747
|
+
// -------------------------------------------------------------------------
|
|
748
|
+
_initializeTask() {
|
|
749
|
+
const scheduleNextRun = () => {
|
|
750
|
+
const now = new Date();
|
|
751
|
+
const next = new Date(now);
|
|
752
|
+
if (this.testing) {
|
|
753
|
+
next.setMinutes(now.getMinutes() + 1, 0, 0);
|
|
754
|
+
} else {
|
|
755
|
+
next.setHours(next.getHours() + 1, 0, 0, 100);
|
|
756
|
+
}
|
|
757
|
+
if (next.getHours() === 0 && next.getMinutes() === 0) next.setHours(1, 0, 0, 0);
|
|
758
|
+
|
|
759
|
+
const ms = next.getTime() - now.getTime();
|
|
760
|
+
this.adapter.logger.debug(`### Statistics - Scheduler start ${now.toLocaleTimeString()} next ${next.toLocaleTimeString()}`);
|
|
761
|
+
if (this.taskTimer) this.adapter.clearTimeout(this.taskTimer);
|
|
762
|
+
|
|
763
|
+
this.taskTimer = this.adapter.setTimeout(async () => {
|
|
764
|
+
await this._executeScheduledTasks();
|
|
765
|
+
scheduleNextRun();
|
|
766
|
+
}, ms);
|
|
767
|
+
};
|
|
768
|
+
scheduleNextRun();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Refreshes breakdown foreign-state values, then runs all calculations.
|
|
773
|
+
*/
|
|
774
|
+
async _executeScheduledTasks() {
|
|
775
|
+
await this._breakdown.refreshValues();
|
|
776
|
+
this._calculateHourly();
|
|
777
|
+
this._calculateDaily();
|
|
778
|
+
this._calculateWeekly();
|
|
779
|
+
this._calculateMonthly();
|
|
780
|
+
this._calculateAnnual();
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async mitNightProcess() {
|
|
784
|
+
const now = new Date();
|
|
785
|
+
await this._executeScheduledTasks();
|
|
786
|
+
const startOfYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
|
|
787
|
+
this._clearGeneric('statistics.jsonDaily', startOfYear);
|
|
788
|
+
this._clearGeneric('statistics.jsonWeekly', startOfYear);
|
|
789
|
+
this._clearGeneric('statistics.jsonMonthly', startOfYear);
|
|
790
|
+
this._clearGeneric('statistics.jsonHourly', new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0));
|
|
791
|
+
// [LIVE] prune to last 48 h (timer also prunes, but be explicit at midnight)
|
|
792
|
+
this._clearGeneric('statistics.jsonLive', new Date(now.getTime() - LIVE_RETENTION_HOURS * 3600 * 1000));
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// -------------------------------------------------------------------------
|
|
796
|
+
// Initialization
|
|
797
|
+
// -------------------------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
async initialize() {
|
|
800
|
+
// Restore persisted JSON states into the cache
|
|
801
|
+
for (const [stateId, defaultVal] of [
|
|
802
|
+
// [LIVE] added
|
|
803
|
+
['statistics.jsonLive', '[]'],
|
|
804
|
+
['statistics.jsonHourly', '[]'],
|
|
805
|
+
['statistics.jsonDaily', '[]'],
|
|
806
|
+
['statistics.jsonWeekly', '[]'],
|
|
807
|
+
['statistics.jsonMonthly', '[]'],
|
|
808
|
+
['statistics.jsonAnnual', '[]'],
|
|
809
|
+
['statistics.jsonToday', '{}'],
|
|
810
|
+
]) {
|
|
811
|
+
const state = await this.adapter.getStateAsync(stateId);
|
|
812
|
+
this.stateCache.set(stateId, state?.val ?? defaultVal, { type: 'string', stored: true });
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Load & validate breakdown config, then fetch current foreign-state values
|
|
816
|
+
await this._breakdown.initialize();
|
|
817
|
+
//await this._breakdown.refreshValues();
|
|
818
|
+
|
|
819
|
+
// Wait until a key collected state is available before starting calculations
|
|
820
|
+
try {
|
|
821
|
+
await tools.waitForValue(() => this.stateCache.get('collected.accumulatedEnergyYield')?.value, 5 * 60000);
|
|
822
|
+
} catch {
|
|
823
|
+
this.adapter.logger.warn(
|
|
824
|
+
"statistics: waited 5 min for 'collected.accumulatedEnergyYield' – computed statistics may be incomplete until that state is present.",
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Restore chart templates (CHART_TYPES now includes 'live')
|
|
829
|
+
for (const chartType of CHART_TYPES) {
|
|
830
|
+
const templateStateId = `statistics.flexCharts.template.${chartType}`;
|
|
831
|
+
const state = await this.adapter.getState(templateStateId);
|
|
832
|
+
this.stateCache.set(templateStateId, state?.val ?? '{}', { type: 'string', stored: true });
|
|
833
|
+
if (state?.ack === false) {
|
|
834
|
+
await this.adapter.setState(templateStateId, { val: state.val, ack: true });
|
|
835
|
+
//this._buildFlexchart(chartType);
|
|
836
|
+
}
|
|
837
|
+
this._buildFlexchart(chartType);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
await this.mitNightProcess();
|
|
841
|
+
|
|
842
|
+
// [LIVE] start the minute-aligned live timer
|
|
843
|
+
await this._initializeLiveTimer();
|
|
844
|
+
this._initializeTask();
|
|
845
|
+
|
|
846
|
+
this.adapter.subscribeStates(`${this._path}.*`);
|
|
847
|
+
this._initialized = true;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// -------------------------------------------------------------------------
|
|
851
|
+
// Chart building
|
|
852
|
+
// -------------------------------------------------------------------------
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Builds and writes the eCharts configuration for the given chart type.
|
|
856
|
+
*
|
|
857
|
+
* Breakdown series are built by breakdownChartHelper.buildBreakdownSeries()
|
|
858
|
+
* and injected into the lower consumption grid (xAxisIndex 1 / yAxisIndex 2).
|
|
859
|
+
* Legend and placeholder replacement are also delegated to breakdownChartHelper.
|
|
860
|
+
*
|
|
861
|
+
* @param {string} myChart - 'hourly' | 'daily' | 'weekly' | 'monthly' | 'annual'
|
|
862
|
+
* @param {string} [chartStyle] - 'line' | 'bar' (default: 'line' for hourly, 'bar' for others)
|
|
863
|
+
* @returns {string} Stringified chart configuration
|
|
864
|
+
*/
|
|
865
|
+
_buildFlexchart(myChart, chartStyle) {
|
|
866
|
+
chartStyle = chartStyle || (myChart === 'hourly' || myChart === 'live' ? 'line' : 'bar');
|
|
867
|
+
|
|
868
|
+
const id = CHART_STATE_IDS[myChart] ?? CHART_STATE_IDS.hourly;
|
|
869
|
+
let data = [];
|
|
870
|
+
try {
|
|
871
|
+
data = JSON.parse(this.stateCache.get(id)?.value ?? '[]');
|
|
872
|
+
} catch {
|
|
873
|
+
data = [];
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// --- X-Axis labels ---
|
|
877
|
+
const xAxisData = data.map(entry => {
|
|
878
|
+
const from = new Date(entry.from);
|
|
879
|
+
const to = new Date(entry.to);
|
|
880
|
+
if (myChart === 'live') {
|
|
881
|
+
return `${to.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })} ${to.toLocaleTimeString('de-DE', { hour12: false, hour: '2-digit', minute: '2-digit' })}`;
|
|
882
|
+
}
|
|
883
|
+
if (myChart === 'hourly') {
|
|
884
|
+
return `${to.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })} ${to.toLocaleTimeString('de-DE', { hour12: false, hour: '2-digit', minute: '2-digit' })}`;
|
|
885
|
+
}
|
|
886
|
+
if (myChart === 'weekly') {
|
|
887
|
+
const toDay = new Date(to);
|
|
888
|
+
if (toDay.getHours() === 0 && toDay.getMinutes() === 0) toDay.setDate(toDay.getDate() - 1);
|
|
889
|
+
return `${from.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}-${toDay.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}`;
|
|
890
|
+
}
|
|
891
|
+
if (myChart === 'monthly') return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit' });
|
|
892
|
+
if (myChart === 'annual') return from.toLocaleDateString('de-DE', { year: 'numeric' });
|
|
893
|
+
return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
const xAxisDataShort =
|
|
897
|
+
myChart === 'hourly' || myChart === 'live'
|
|
898
|
+
? xAxisData.map(l => {
|
|
899
|
+
return l.split(' ')[1];
|
|
900
|
+
})
|
|
901
|
+
: xAxisData;
|
|
902
|
+
|
|
903
|
+
// --- Day shading areas (hourly only) ---
|
|
904
|
+
const dayAreas = [];
|
|
905
|
+
if ((myChart === 'hourly' || myChart === 'live') && xAxisData.length > 0) {
|
|
906
|
+
const bounds = [0];
|
|
907
|
+
xAxisData.forEach((lbl, i) => {
|
|
908
|
+
if (i > 0 && lbl.split(' ')[0] !== xAxisData[i - 1].split(' ')[0]) bounds.push(i);
|
|
909
|
+
});
|
|
910
|
+
bounds.push(xAxisData.length);
|
|
911
|
+
bounds.forEach((startIdx, d) => {
|
|
912
|
+
if (d >= bounds.length - 1) return;
|
|
913
|
+
const endIdx = bounds[d + 1];
|
|
914
|
+
const date = xAxisData[startIdx].split(' ')[0];
|
|
915
|
+
dayAreas.push([
|
|
916
|
+
{
|
|
917
|
+
xAxis: startIdx - 0.5,
|
|
918
|
+
label: {
|
|
919
|
+
show: true,
|
|
920
|
+
position: 'insideTop',
|
|
921
|
+
formatter: date,
|
|
922
|
+
color: '#555',
|
|
923
|
+
fontSize: 11,
|
|
924
|
+
fontWeight: 'bold',
|
|
925
|
+
backgroundColor: 'rgba(255,255,255,0.7)',
|
|
926
|
+
padding: [2, 4],
|
|
927
|
+
borderRadius: 3,
|
|
928
|
+
},
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
xAxis: endIdx - 0.5,
|
|
932
|
+
itemStyle:
|
|
933
|
+
d % 2 === 1
|
|
934
|
+
? { color: 'rgba(180,180,180,0.15)', borderColor: 'rgba(120,120,120,0.3)', borderWidth: 1, borderType: 'dashed' }
|
|
935
|
+
: { color: 'rgba(255,255,255,0)' },
|
|
936
|
+
},
|
|
937
|
+
]);
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// --- Series data ---
|
|
942
|
+
const extract = key => data.map(e => Number(Number(e[key]?.value ?? 0).toFixed(3)));
|
|
943
|
+
const negate = arr => arr.map(v => Number((-v).toFixed(3)));
|
|
944
|
+
const seriesData = {};
|
|
945
|
+
for (const stat of this.stats) {
|
|
946
|
+
seriesData[stat.targetPath] = extract(stat.targetPath);
|
|
947
|
+
seriesData[`${stat.targetPath}Neg`] = negate(seriesData[stat.targetPath]);
|
|
948
|
+
}
|
|
949
|
+
// Populate breakdown keys so chart helper and placeholder replacement can find them
|
|
950
|
+
for (const bd of this._breakdown.entries) {
|
|
951
|
+
seriesData[bd.targetPath] = extract(bd.targetPath);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// --- Tooltip / axis formatters (serialised as functions via stringify) ---
|
|
955
|
+
const xAxisFormatter =
|
|
956
|
+
myChart === 'live'
|
|
957
|
+
? (() => {
|
|
958
|
+
// IIFE executes once on initialization
|
|
959
|
+
const hourLabels = new Set();
|
|
960
|
+
const closestPerHour = new Map();
|
|
961
|
+
xAxisDataShort.forEach((label, i) => {
|
|
962
|
+
const to = new Date(data[i].to);
|
|
963
|
+
const fullHour = new Date(to.getFullYear(), to.getMonth(), to.getDate(), to.getHours(), 0, 0, 0);
|
|
964
|
+
const hourKey = fullHour.getTime();
|
|
965
|
+
const diffMs = Math.abs(to.getTime() - fullHour.getTime());
|
|
966
|
+
const current = closestPerHour.get(hourKey);
|
|
967
|
+
if (!current || diffMs < current.diffMs) {
|
|
968
|
+
closestPerHour.set(hourKey, { label, diffMs });
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
closestPerHour.forEach(({ label }) => hourLabels.add(label));
|
|
972
|
+
// Embed the set content directly into the function string
|
|
973
|
+
const hourLabelsArray = JSON.stringify([...hourLabels]);
|
|
974
|
+
return new Function(`return value => (new Set(${hourLabelsArray}).has(value) ? value : '')`)();
|
|
975
|
+
})()
|
|
976
|
+
: value => (value.includes('|') ? value : (value.split(' ')[1] ?? value));
|
|
977
|
+
|
|
978
|
+
// --- Tooltip formatter ---
|
|
979
|
+
const tooltipFormatter = params => {
|
|
980
|
+
if (!Array.isArray(params)) params = [params];
|
|
981
|
+
return params
|
|
982
|
+
.filter(p => p.seriesName !== 'DayBreak')
|
|
983
|
+
.map(p => {
|
|
984
|
+
const negatedSeries = ['Grid Export', 'Charge'];
|
|
985
|
+
const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
|
|
986
|
+
const unit = ['SOC', 'Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? ' %' : ' kWh';
|
|
987
|
+
const seriesName =
|
|
988
|
+
myChart === 'hourly' && ['Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? `${p.seriesName} today` : p.seriesName;
|
|
989
|
+
return `${p.marker}${seriesName}: <b>${val}${unit}</b>`;
|
|
990
|
+
})
|
|
991
|
+
.join('<br/>');
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
// --- Load / parse chart template ---
|
|
995
|
+
const templateStateId = `statistics.flexCharts.template.${myChart}`;
|
|
996
|
+
const outputStateId = `statistics.flexCharts.jsonOutput.${myChart}`;
|
|
997
|
+
const templateStr = this.stateCache.get(templateStateId)?.value ?? '{}';
|
|
998
|
+
let template = {};
|
|
999
|
+
let command = '';
|
|
1000
|
+
try {
|
|
1001
|
+
const parsed = JSON.parse(templateStr);
|
|
1002
|
+
command = parsed.command || '';
|
|
1003
|
+
if (Object.keys(parsed).length === 0 || command === 'createTemplateFromBuiltin') {
|
|
1004
|
+
template = this._buildDefaultTemplate(myChart, chartStyle);
|
|
1005
|
+
} else {
|
|
1006
|
+
template = parsed;
|
|
1007
|
+
delete template._meta;
|
|
1008
|
+
}
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
this.adapter.logger.warn(`statistics: invalid template for ${myChart}: ${e.message}`);
|
|
1011
|
+
template = this._buildDefaultTemplate(myChart, chartStyle);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// No-data hint — chart-type specific
|
|
1015
|
+
const noDataHints = {
|
|
1016
|
+
live: 'No data yet — first entry available after the next interval boundary.',
|
|
1017
|
+
hourly: 'No data yet — first entry available after the next full hour.',
|
|
1018
|
+
daily: 'No data yet — first entry available tomorrow after midnight.',
|
|
1019
|
+
weekly: 'No data yet — first entry available after the current week ends.',
|
|
1020
|
+
monthly: 'No data yet — first entry available after the current month ends.',
|
|
1021
|
+
annual: 'No data yet — first entry available after the current year ends.',
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
// --- Assemble final chart object ---
|
|
1025
|
+
const chart = {
|
|
1026
|
+
...template,
|
|
1027
|
+
/* now breakdown series are injected via chartHelper.buildBreakdownSeries() into the template's series array, so we don't need to merge them here
|
|
1028
|
+
series: [
|
|
1029
|
+
...(template.series ?? []),
|
|
1030
|
+
// Hourly day-shading areas
|
|
1031
|
+
...(dayAreas.length > 0
|
|
1032
|
+
? [
|
|
1033
|
+
{
|
|
1034
|
+
name: 'DayBreak',
|
|
1035
|
+
type: 'bar',
|
|
1036
|
+
barWidth: 0,
|
|
1037
|
+
data: [],
|
|
1038
|
+
legendHoverLink: false,
|
|
1039
|
+
silent: true,
|
|
1040
|
+
markArea: { silent: true, data: dayAreas },
|
|
1041
|
+
},
|
|
1042
|
+
]
|
|
1043
|
+
: []),
|
|
1044
|
+
],
|
|
1045
|
+
*/
|
|
1046
|
+
graphic:
|
|
1047
|
+
xAxisData.length === 0
|
|
1048
|
+
? [
|
|
1049
|
+
{
|
|
1050
|
+
type: 'text',
|
|
1051
|
+
left: 'center',
|
|
1052
|
+
top: 'middle',
|
|
1053
|
+
style: { text: noDataHints[myChart] || 'No data available yet.', fontSize: 14, fill: '#999' },
|
|
1054
|
+
},
|
|
1055
|
+
]
|
|
1056
|
+
: [],
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
// Apply slider start/end defaults when the template has no explicit values
|
|
1060
|
+
const sliderDefaults = {
|
|
1061
|
+
live: Math.max(0, Math.round((1 - (25 * 12) / Math.max(xAxisData.length, 1)) * 100)),
|
|
1062
|
+
hourly: Math.max(0, Math.round((1 - 25 / Math.max(xAxisData.length, 1)) * 100)),
|
|
1063
|
+
daily: Math.max(0, Math.round((1 - 7 / Math.max(xAxisData.length, 1)) * 100)),
|
|
1064
|
+
weekly: Math.max(0, Math.round((1 - 8 / Math.max(xAxisData.length, 1)) * 100)),
|
|
1065
|
+
monthly: Math.max(0, Math.round((1 - 13 / Math.max(xAxisData.length, 1)) * 100)),
|
|
1066
|
+
annual: 0,
|
|
1067
|
+
};
|
|
1068
|
+
if (chart?.dataZoom?.[0] && !chart.dataZoom[0].start && !chart.dataZoom[0].end) {
|
|
1069
|
+
chart.dataZoom[0].start = sliderDefaults[myChart] ?? 0;
|
|
1070
|
+
chart.dataZoom[0].end = 100;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// --- Serialise and replace placeholders ---
|
|
1074
|
+
let chartStr = stringify(reviveFunctions(chart));
|
|
1075
|
+
|
|
1076
|
+
// Built-in placeholders
|
|
1077
|
+
chartStr = chartStr
|
|
1078
|
+
.replace("'%%xAxisData%%'", JSON.stringify(xAxisData))
|
|
1079
|
+
.replace("'%%xAxisDataShort%%'", JSON.stringify(xAxisDataShort))
|
|
1080
|
+
.replaceAll("'%%xAxisMax%%'", String(xAxisData.length - 1))
|
|
1081
|
+
.replace("'%%chartTitle%%'", JSON.stringify(`PV Statistics — ${myChart}`))
|
|
1082
|
+
.replace("'%%dayAreas%%'", JSON.stringify(dayAreas))
|
|
1083
|
+
.replace("'%%xAxisFormatter%%'", stringify(xAxisFormatter))
|
|
1084
|
+
.replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
|
|
1085
|
+
|
|
1086
|
+
for (const stat of this.stats) {
|
|
1087
|
+
const key = stat.targetPath;
|
|
1088
|
+
chartStr = chartStr.replace(`'%%${key}%%'`, JSON.stringify(seriesData[key])).replace(`'%%${key}Neg%%'`, JSON.stringify(seriesData[`${key}Neg`]));
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Breakdown placeholders — delegated to chart helper
|
|
1092
|
+
chartStr = chartHelper.replacePlaceholders(chartStr, this._breakdown.entries, seriesData);
|
|
1093
|
+
|
|
1094
|
+
this.stateCache.set(outputStateId, chartStr, { type: 'string', renew: command === 'createTemplateFromBuiltin' ? true : false });
|
|
1095
|
+
this.adapter.logger.debug(`statistics: flexCharts built for ${myChart}/${chartStyle}`);
|
|
1096
|
+
|
|
1097
|
+
if (command === 'createTemplateFromBuiltin') {
|
|
1098
|
+
template = { _meta: { generatedFrom: 'builtin', generatedAt: new Date().toISOString() }, ...template };
|
|
1099
|
+
this.stateCache.set(templateStateId, stringifyWithFunctions(template), { type: 'string' });
|
|
1100
|
+
this.adapter.logger.debug(`statistics: new template created for ${myChart}`);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
return chartStr;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Builds the default built-in eCharts template.
|
|
1108
|
+
* Breakdown series are NOT listed here — they are injected dynamically
|
|
1109
|
+
* in _buildFlexchart() via chartHelper.buildBreakdownSeries().
|
|
1110
|
+
* The legend does include breakdown names (via chartHelper.mergeBreakdownLegend).
|
|
1111
|
+
*
|
|
1112
|
+
* @param {string} myChart
|
|
1113
|
+
* @param {string} chartStyle
|
|
1114
|
+
* @returns {object}
|
|
1115
|
+
*/
|
|
1116
|
+
_buildDefaultTemplate(myChart, chartStyle) {
|
|
1117
|
+
const seriesType = chartStyle === 'line' ? 'line' : 'bar';
|
|
1118
|
+
const lineOptions =
|
|
1119
|
+
chartStyle === 'line' ? { smooth: false, symbol: 'none', symbolSize: 4, lineStyle: { width: 2 }, areaStyle: { opacity: 0.15 } } : {};
|
|
1120
|
+
if (myChart === 'hourly' && chartStyle === 'line') {
|
|
1121
|
+
lineOptions.smooth = true;
|
|
1122
|
+
lineOptions.lineStyle.smooth = 0.3;
|
|
1123
|
+
lineOptions.symbol = 'circle';
|
|
1124
|
+
}
|
|
1125
|
+
const showBat = this._batteryExists();
|
|
1126
|
+
const showSOC = showBat && (myChart === 'hourly' || myChart === 'live');
|
|
1127
|
+
const unit = myChart === 'live' ? 'kW' : 'kWh';
|
|
1128
|
+
|
|
1129
|
+
const baseLegend = [
|
|
1130
|
+
'Solar Yield',
|
|
1131
|
+
'Grid Export',
|
|
1132
|
+
'Grid Import',
|
|
1133
|
+
...(showBat ? ['Charge', 'Discharge'] : []),
|
|
1134
|
+
...(showSOC ? ['SOC'] : []),
|
|
1135
|
+
'Self-sufficiency',
|
|
1136
|
+
'Self-consumption',
|
|
1137
|
+
'Consumption',
|
|
1138
|
+
];
|
|
1139
|
+
const legendData = chartHelper.mergeBreakdownLegend(baseLegend, this._breakdown.entries);
|
|
1140
|
+
|
|
1141
|
+
const formatTooltipValue = (unit, negative = false, decimals = 2) => {
|
|
1142
|
+
//valueFormatter: `value => \`\${value} ${unit}\``,
|
|
1143
|
+
return negative ? `value => \`\${(-value).toFixed(${decimals})} ${unit}\`` : `value => \`\${value.toFixed(${decimals})} ${unit}\``;
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
return {
|
|
1147
|
+
backgroundColor: '#fff',
|
|
1148
|
+
animation: false,
|
|
1149
|
+
title: { left: 'center', text: '%%chartTitle%%' },
|
|
1150
|
+
legend: { top: 35, left: 'center', data: legendData },
|
|
1151
|
+
|
|
1152
|
+
tooltip: {
|
|
1153
|
+
trigger: 'axis',
|
|
1154
|
+
axisPointer: { type: 'cross' },
|
|
1155
|
+
backgroundColor: 'rgba(245,245,245,0.95)',
|
|
1156
|
+
borderWidth: 1,
|
|
1157
|
+
borderColor: '#ccc',
|
|
1158
|
+
padding: 10,
|
|
1159
|
+
textStyle: { color: '#000' },
|
|
1160
|
+
//formatter: '%%tooltipFormatter%%',
|
|
1161
|
+
position: (pos, params, el, elRect, size) => {
|
|
1162
|
+
const obj = { top: 10 };
|
|
1163
|
+
obj[pos[0] < size.viewSize[0] / 2 ? 'left' : 'right'] = 30;
|
|
1164
|
+
return obj;
|
|
1165
|
+
},
|
|
1166
|
+
},
|
|
1167
|
+
|
|
1168
|
+
axisPointer: { link: [{ xAxisIndex: 'all' }], label: { backgroundColor: '#777' } },
|
|
1169
|
+
toolbox: {
|
|
1170
|
+
feature: {
|
|
1171
|
+
dataZoom: { yAxisIndex: false },
|
|
1172
|
+
dataView: { show: true, readOnly: false },
|
|
1173
|
+
restore: { show: true },
|
|
1174
|
+
saveAsImage: { show: true },
|
|
1175
|
+
},
|
|
1176
|
+
},
|
|
1177
|
+
grid: [
|
|
1178
|
+
{ left: '8%', right: showSOC ? '8%' : '4%', top: 80, height: '45%' },
|
|
1179
|
+
{ left: '8%', right: showSOC ? '8%' : '4%', top: '72%', height: '15%' },
|
|
1180
|
+
],
|
|
1181
|
+
xAxis: [
|
|
1182
|
+
{
|
|
1183
|
+
type: 'category',
|
|
1184
|
+
data: '%%xAxisDataShort%%',
|
|
1185
|
+
scale: true,
|
|
1186
|
+
boundaryGap: chartStyle !== 'line',
|
|
1187
|
+
axisLine: { onZero: false },
|
|
1188
|
+
...(myChart === 'live'
|
|
1189
|
+
? {
|
|
1190
|
+
axisTick: {
|
|
1191
|
+
interval: (index, value) => value.endsWith(':00'),
|
|
1192
|
+
},
|
|
1193
|
+
}
|
|
1194
|
+
: {}),
|
|
1195
|
+
|
|
1196
|
+
splitLine: { show: false },
|
|
1197
|
+
axisPointer: { z: 100 },
|
|
1198
|
+
axisLabel: {
|
|
1199
|
+
interval: 0,
|
|
1200
|
+
lineHeight: 16,
|
|
1201
|
+
fontSize: 11,
|
|
1202
|
+
formatter: '%%xAxisFormatter%%',
|
|
1203
|
+
},
|
|
1204
|
+
min: 0,
|
|
1205
|
+
max: '%%xAxisMax%%',
|
|
1206
|
+
},
|
|
1207
|
+
{
|
|
1208
|
+
type: 'category',
|
|
1209
|
+
gridIndex: 1,
|
|
1210
|
+
data: '%%xAxisData%%',
|
|
1211
|
+
scale: true,
|
|
1212
|
+
boundaryGap: chartStyle !== 'line',
|
|
1213
|
+
axisLine: { onZero: false },
|
|
1214
|
+
axisTick: { show: false },
|
|
1215
|
+
splitLine: { show: false },
|
|
1216
|
+
axisLabel: { show: false },
|
|
1217
|
+
min: 0,
|
|
1218
|
+
max: '%%xAxisMax%%',
|
|
1219
|
+
},
|
|
1220
|
+
],
|
|
1221
|
+
yAxis: [
|
|
1222
|
+
{
|
|
1223
|
+
scale: false,
|
|
1224
|
+
splitArea: { show: true },
|
|
1225
|
+
name: `Energy (${unit})`,
|
|
1226
|
+
nameLocation: 'middle',
|
|
1227
|
+
nameGap: 50,
|
|
1228
|
+
axisLabel: { formatter: `{value} ${unit}` },
|
|
1229
|
+
splitLine: { show: true },
|
|
1230
|
+
axisLine: { show: true },
|
|
1231
|
+
},
|
|
1232
|
+
{
|
|
1233
|
+
type: 'value',
|
|
1234
|
+
min: 0,
|
|
1235
|
+
max: 100,
|
|
1236
|
+
name: showSOC ? 'SOC / Ratio (%)' : 'Ratio (%)',
|
|
1237
|
+
nameLocation: 'middle',
|
|
1238
|
+
nameGap: 50,
|
|
1239
|
+
axisLabel: { formatter: '{value} %' },
|
|
1240
|
+
splitLine: { show: false },
|
|
1241
|
+
axisLine: { show: true },
|
|
1242
|
+
},
|
|
1243
|
+
{
|
|
1244
|
+
scale: true,
|
|
1245
|
+
gridIndex: 1,
|
|
1246
|
+
splitNumber: 3,
|
|
1247
|
+
axisLine: { show: false },
|
|
1248
|
+
axisTick: { show: false },
|
|
1249
|
+
splitLine: { show: false },
|
|
1250
|
+
name: `Consumption\n(${unit})`,
|
|
1251
|
+
nameLocation: 'middle',
|
|
1252
|
+
nameGap: 50,
|
|
1253
|
+
axisLabel: { formatter: `{value} ${unit}` },
|
|
1254
|
+
},
|
|
1255
|
+
],
|
|
1256
|
+
dataZoom: [
|
|
1257
|
+
{ type: 'inside', xAxisIndex: [0, 1] },
|
|
1258
|
+
{ show: true, xAxisIndex: [0, 1], type: 'slider', bottom: 5 },
|
|
1259
|
+
],
|
|
1260
|
+
series: [
|
|
1261
|
+
{
|
|
1262
|
+
name: 'Solar Yield',
|
|
1263
|
+
type: seriesType,
|
|
1264
|
+
unit: unit,
|
|
1265
|
+
invertSign: false,
|
|
1266
|
+
data: '%%solarYield%%',
|
|
1267
|
+
itemStyle: { color: '#f6c94e' },
|
|
1268
|
+
emphasis: { focus: 'series' },
|
|
1269
|
+
tooltip: {
|
|
1270
|
+
valueFormatter: formatTooltipValue(unit),
|
|
1271
|
+
},
|
|
1272
|
+
...lineOptions,
|
|
1273
|
+
},
|
|
1274
|
+
{
|
|
1275
|
+
name: 'Grid Export',
|
|
1276
|
+
type: seriesType,
|
|
1277
|
+
data: '%%gridExportNeg%%',
|
|
1278
|
+
itemStyle: { color: '#5cb85c' },
|
|
1279
|
+
emphasis: { focus: 'series' },
|
|
1280
|
+
tooltip: { valueFormatter: formatTooltipValue(unit, true) },
|
|
1281
|
+
...lineOptions,
|
|
1282
|
+
},
|
|
1283
|
+
{
|
|
1284
|
+
name: 'Grid Import',
|
|
1285
|
+
type: seriesType,
|
|
1286
|
+
data: '%%gridImport%%',
|
|
1287
|
+
itemStyle: { color: '#ec0000' },
|
|
1288
|
+
emphasis: { focus: 'series' },
|
|
1289
|
+
tooltip: { valueFormatter: formatTooltipValue(unit) },
|
|
1290
|
+
...lineOptions,
|
|
1291
|
+
},
|
|
1292
|
+
...(showBat
|
|
1293
|
+
? [
|
|
1294
|
+
{
|
|
1295
|
+
name: 'Charge',
|
|
1296
|
+
type: seriesType,
|
|
1297
|
+
data: '%%chargeCapacityNeg%%',
|
|
1298
|
+
itemStyle: { color: '#5bc0de' },
|
|
1299
|
+
emphasis: { focus: 'series' },
|
|
1300
|
+
tooltip: { valueFormatter: formatTooltipValue(unit, true) },
|
|
1301
|
+
...lineOptions,
|
|
1302
|
+
},
|
|
1303
|
+
{
|
|
1304
|
+
name: 'Discharge',
|
|
1305
|
+
type: seriesType,
|
|
1306
|
+
data: '%%dischargeCapacity%%',
|
|
1307
|
+
itemStyle: { color: '#ed50e0' },
|
|
1308
|
+
emphasis: { focus: 'series' },
|
|
1309
|
+
tooltip: { valueFormatter: formatTooltipValue(unit) },
|
|
1310
|
+
...lineOptions,
|
|
1311
|
+
},
|
|
1312
|
+
]
|
|
1313
|
+
: []),
|
|
1314
|
+
...(showSOC
|
|
1315
|
+
? [
|
|
1316
|
+
{
|
|
1317
|
+
name: 'SOC',
|
|
1318
|
+
type: 'line',
|
|
1319
|
+
yAxisIndex: 1,
|
|
1320
|
+
data: '%%SOC%%',
|
|
1321
|
+
itemStyle: { color: '#985e24' },
|
|
1322
|
+
lineStyle: { width: 2, type: 'dashed' },
|
|
1323
|
+
symbol: 'none',
|
|
1324
|
+
smooth: true,
|
|
1325
|
+
tooltip: { valueFormatter: formatTooltipValue('%', false, 0) },
|
|
1326
|
+
},
|
|
1327
|
+
]
|
|
1328
|
+
: []),
|
|
1329
|
+
{
|
|
1330
|
+
name: 'Self-sufficiency',
|
|
1331
|
+
type: 'line',
|
|
1332
|
+
yAxisIndex: 1,
|
|
1333
|
+
data: '%%selfSufficiency%%',
|
|
1334
|
+
itemStyle: { color: '#9c27b0' },
|
|
1335
|
+
lineStyle: { width: 2, type: 'dashed' },
|
|
1336
|
+
symbol: 'none', //'circle'
|
|
1337
|
+
symbolSize: 4,
|
|
1338
|
+
smooth: true,
|
|
1339
|
+
tooltip: { valueFormatter: formatTooltipValue('%', false, 0) },
|
|
1340
|
+
},
|
|
1341
|
+
{
|
|
1342
|
+
name: 'Self-consumption',
|
|
1343
|
+
type: 'line',
|
|
1344
|
+
yAxisIndex: 1,
|
|
1345
|
+
data: '%%selfConsumption%%',
|
|
1346
|
+
itemStyle: { color: '#ff9800' },
|
|
1347
|
+
lineStyle: { width: 2, type: 'dashed' },
|
|
1348
|
+
symbol: 'none', //'circle'
|
|
1349
|
+
symbolSize: 4,
|
|
1350
|
+
smooth: true,
|
|
1351
|
+
tooltip: { valueFormatter: formatTooltipValue('%', false, 0) },
|
|
1352
|
+
},
|
|
1353
|
+
...chartHelper.buildBreakdownSeries(this._breakdown.entries, seriesType, lineOptions, formatTooltipValue(unit)),
|
|
1354
|
+
|
|
1355
|
+
{
|
|
1356
|
+
name: 'Consumption',
|
|
1357
|
+
type: seriesType,
|
|
1358
|
+
stack: 'consumptionBreakdown',
|
|
1359
|
+
xAxisIndex: 1,
|
|
1360
|
+
yAxisIndex: 2,
|
|
1361
|
+
data: '%%consumption%%',
|
|
1362
|
+
itemStyle: { color: '#337ab7' },
|
|
1363
|
+
tooltip: { valueFormatter: formatTooltipValue(unit) },
|
|
1364
|
+
...lineOptions,
|
|
1365
|
+
},
|
|
1366
|
+
...(myChart === 'hourly' || myChart === 'live'
|
|
1367
|
+
? [
|
|
1368
|
+
{
|
|
1369
|
+
name: 'DayBreak',
|
|
1370
|
+
type: 'bar',
|
|
1371
|
+
barWidth: 0,
|
|
1372
|
+
data: [],
|
|
1373
|
+
legendHoverLink: false,
|
|
1374
|
+
silent: true,
|
|
1375
|
+
markArea: { silent: true, data: '%%dayAreas%%' },
|
|
1376
|
+
},
|
|
1377
|
+
]
|
|
1378
|
+
: []),
|
|
1379
|
+
],
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
_batteryExists() {
|
|
1384
|
+
return this.stateCache.get(`collected.ratedCapacity`)?.value > 0;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// -------------------------------------------------------------------------
|
|
1388
|
+
// External event handlers (called from adapter onStateChange)
|
|
1389
|
+
// -------------------------------------------------------------------------
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* Handles a user edit of a chart template state.
|
|
1393
|
+
*
|
|
1394
|
+
* @param {string} chartType
|
|
1395
|
+
* @param {object} state
|
|
1396
|
+
*/
|
|
1397
|
+
async handleTemplateChange(chartType, state) {
|
|
1398
|
+
const templateStateId = `statistics.flexCharts.template.${chartType}`;
|
|
1399
|
+
if (this.stateCache.get(templateStateId) === undefined) {
|
|
1400
|
+
this.adapter.logger.warn(`Template state ${templateStateId} not found for handleTemplateChange`);
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
if (state?.val != null) {
|
|
1404
|
+
this.adapter.logger.debug(`statistics: template state ${chartType} changed (ack: ${state.ack})`);
|
|
1405
|
+
this.stateCache.set(templateStateId, state.val, { type: 'string', stored: true });
|
|
1406
|
+
this._buildFlexchart(chartType);
|
|
1407
|
+
await this.adapter.setState(templateStateId, { val: this.stateCache.get(templateStateId)?.value, ack: true });
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Handles a user edit of the consumptionBreakdown config state.
|
|
1413
|
+
* Delegates all validation and foreign-state refresh to ConsumptionBreakdown,
|
|
1414
|
+
* then rebuilds all charts.
|
|
1415
|
+
*
|
|
1416
|
+
* Wire up in adapter onStateChange:
|
|
1417
|
+
*
|
|
1418
|
+
* if (idArray[2] == 'statistics' && idArray[3] == 'consumptionBreakdown') {
|
|
1419
|
+
* if (this.state.statistics && typeof this.state.statistics.handleBreakdownChange === 'function') {
|
|
1420
|
+
* this.state.statistics.handleBreakdownChange(state);
|
|
1421
|
+
* }
|
|
1422
|
+
* }
|
|
1423
|
+
* }
|
|
1424
|
+
*
|
|
1425
|
+
* @param {object} state - ioBroker state object (val = new JSON string)
|
|
1426
|
+
* @returns {Promise<void>}
|
|
1427
|
+
*/
|
|
1428
|
+
async handleBreakdownChange(state) {
|
|
1429
|
+
const changed = await this._breakdown.handleStateChange(state);
|
|
1430
|
+
if (!changed) return;
|
|
1431
|
+
for (const chartType of CHART_TYPES) {
|
|
1432
|
+
this._buildFlexchart(chartType);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Handles flexcharts rebuild messages from VIS widgets or adapter messages.
|
|
1438
|
+
*
|
|
1439
|
+
* @param {{ chart?: string, style?: string }} message
|
|
1440
|
+
* @param {callback} callback
|
|
1441
|
+
*/
|
|
1442
|
+
handleFlexMessage(message, callback) {
|
|
1443
|
+
const result = this._buildFlexchart(message?.chart || 'hourly', message?.style);
|
|
1444
|
+
if (typeof callback === 'function') callback(result);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
destroy() {
|
|
1448
|
+
if (this.taskTimer) this.adapter.clearTimeout(this.taskTimer);
|
|
1449
|
+
if (this._liveTimer) this.adapter.clearTimeout(this._liveTimer);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
module.exports = statistics;
|