iobroker.utility-monitor 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/calculator.js CHANGED
@@ -1,23 +1,34 @@
1
1
  const helpers = require('./utils/helpers');
2
2
 
3
3
  /**
4
- * Converts gas volume from to kWh
5
- * Formula: kWh = × Brennwert × Z-Zahl
4
+ * @file Calculator module for utility consumption and cost calculations.
5
+ * Contains functions for gas conversion, cost calculation, and tariff handling.
6
+ * @module calculator
7
+ */
8
+
9
+ /**
10
+ * Converts gas volume from cubic meters (m³) to kilowatt-hours (kWh).
11
+ * The conversion uses the standard German gas billing formula:
12
+ * kWh = m³ × Brennwert (calorific value) × Z-Zahl (state number)
13
+ * Brennwert: Energy content per cubic meter, typically 9.5-11.5 kWh/m³.
14
+ * Z-Zahl: Correction factor for temperature and pressure differences.
6
15
  *
7
- * @param {number} m3 - Volume in cubic meters
8
- * @param {number} brennwert - Calorific value (typically ~11.5 kWh/m³)
9
- * @param {number} zZahl - Z-number/state number (typically ~0.95)
10
- * @returns {number} Energy in kWh
16
+ * @param {number} m3 - Gas volume in cubic meters (must be >= 0)
17
+ * @param {number} brennwert - Calorific value in kWh/m³ (must be > 0)
18
+ * @param {number} zZahl - State number (must be > 0 and <= 1)
19
+ * @returns {number} Energy consumption in kWh
20
+ * @throws {RangeError} If parameters are outside valid ranges
11
21
  */
12
22
  function convertGasM3ToKWh(m3, brennwert = 11.5, zZahl = 0.95) {
13
- // Hier number zu string
14
23
  const cleanM3 = helpers.ensureNumber(m3);
15
24
  const cleanBrennwert = helpers.ensureNumber(brennwert);
16
25
  const cleanZZahl = helpers.ensureNumber(zZahl);
17
26
 
18
- // Validierung der Logik (jetzt mit den konvertierten Zahlen)
27
+ // Validate parameters
19
28
  if (cleanM3 < 0 || cleanBrennwert <= 0 || cleanZZahl <= 0 || cleanZZahl > 1) {
20
- throw new RangeError('Ungültige Parameterwerte für die Gas-Umrechnung');
29
+ throw new RangeError(
30
+ 'Invalid parameters for gas conversion: m3 must be >= 0, brennwert must be > 0, zZahl must be > 0 and <= 1',
31
+ );
21
32
  }
22
33
 
23
34
  return cleanM3 * cleanBrennwert * cleanZZahl;
@@ -53,11 +64,14 @@ function calculateCost(consumption, price) {
53
64
  }
54
65
 
55
66
  /**
56
- * Checks if the current time is within the High Tariff (HT) period
67
+ * Checks if the current time falls within the High Tariff (HT) period.
68
+ * German electricity providers often offer dual-tariff rates:
69
+ * HT (Haupttarif): Higher rate during peak hours (typically 6:00-22:00)
70
+ * NT (Nebentarif): Lower rate during off-peak hours (typically 22:00-6:00)
57
71
  *
58
- * @param {object} config - Adapter configuration
59
- * @param {string} type - Utility type: 'gas' or 'strom'
60
- * @returns {boolean} True if current time is HT, false if NT
72
+ * @param {object} config - Adapter configuration object with HT/NT settings
73
+ * @param {string} type - Utility type identifier: 'gas', 'strom', 'wasser', or 'pv'
74
+ * @returns {boolean} True if current time is within HT period, false for NT
61
75
  */
62
76
  function isHTTime(config, type) {
63
77
  if (!config || !type) {
@@ -227,7 +227,7 @@ class MultiMeterManager {
227
227
  const timestampRoles = ['lastDayStart', 'lastWeekStart', 'lastMonthStart', 'lastYearStart'];
228
228
 
229
229
  for (const role of timestampRoles) {
230
- const statePath = `${basePath}.statistics.${role}`;
230
+ const statePath = `${basePath}.statistics.timestamps.${role}`;
231
231
  const state = await this.adapter.getStateAsync(statePath);
232
232
 
233
233
  if (role === 'lastYearStart') {
@@ -307,9 +307,148 @@ class MultiMeterManager {
307
307
  // Initial cost calculation
308
308
  await this.updateCosts(type, meterName, config);
309
309
 
310
+ // Reconstruct weekly consumption from daily values if needed
311
+ await this.reconstructPeriodConsumption(type, meterName, basePath);
312
+
310
313
  this.adapter.log.debug(`Meter initialization completed for ${type}.${meterName}`);
311
314
  }
312
315
 
316
+ /**
317
+ * Reconstructs weekly and monthly consumption values after adapter restart
318
+ * This fixes data loss when adapter was offline and missed delta accumulation
319
+ *
320
+ * @param {string} type - Utility type
321
+ * @param {string} meterName - Meter name
322
+ * @param {string} basePath - State base path
323
+ */
324
+ async reconstructPeriodConsumption(type, meterName, basePath) {
325
+ const now = Date.now();
326
+
327
+ // Get period start timestamps
328
+ const lastWeekStartState = await this.adapter.getStateAsync(`${basePath}.statistics.timestamps.lastWeekStart`);
329
+ const lastDayStartState = await this.adapter.getStateAsync(`${basePath}.statistics.timestamps.lastDayStart`);
330
+
331
+ if (!lastWeekStartState?.val || !lastDayStartState?.val) {
332
+ this.adapter.log.debug(`[${basePath}] No period timestamps found, skipping reconstruction`);
333
+ return;
334
+ }
335
+
336
+ const lastWeekStart = lastWeekStartState.val;
337
+ const lastDayStart = lastDayStartState.val;
338
+
339
+ // Calculate days since week start
340
+ const daysSinceWeekStart = (now - lastWeekStart) / (24 * 60 * 60 * 1000);
341
+
342
+ // Only reconstruct if we're within a valid week (0-7 days)
343
+ if (daysSinceWeekStart < 0 || daysSinceWeekStart > 7) {
344
+ this.adapter.log.debug(
345
+ `[${basePath}] Week period out of range (${daysSinceWeekStart.toFixed(1)} days), skipping reconstruction`,
346
+ );
347
+ return;
348
+ }
349
+
350
+ // Get current consumption values
351
+ const weeklyState = await this.adapter.getStateAsync(`${basePath}.consumption.weekly`);
352
+ const dailyState = await this.adapter.getStateAsync(`${basePath}.consumption.daily`);
353
+ const lastDayState = await this.adapter.getStateAsync(`${basePath}.statistics.consumption.lastDay`);
354
+
355
+ const currentWeekly = weeklyState?.val || 0;
356
+ const currentDaily = dailyState?.val || 0;
357
+ const lastDay = lastDayState?.val || 0;
358
+
359
+ // Calculate expected weekly based on lastDay values accumulated since lastWeekStart
360
+ // Simple approach: If daily counter was reset today and we have lastDay,
361
+ // weekly should be at least lastDay + currentDaily
362
+ const daysSinceDayReset = (now - lastDayStart) / (24 * 60 * 60 * 1000);
363
+
364
+ // If daily was reset (daysSinceDayReset < 1) and weekly seems too low
365
+ if (daysSinceDayReset < 1 && currentWeekly < lastDay + currentDaily) {
366
+ // Weekly might have missed the lastDay value
367
+ // This can happen if adapter restarted after daily reset
368
+ const reconstructedWeekly = calculator.roundToDecimals(currentWeekly + lastDay, 2);
369
+
370
+ if (reconstructedWeekly > currentWeekly) {
371
+ this.adapter.log.info(
372
+ `[${basePath}] Reconstructing weekly: ${currentWeekly} -> ${reconstructedWeekly} (added lastDay: ${lastDay})`,
373
+ );
374
+ await this.adapter.setStateAsync(`${basePath}.consumption.weekly`, reconstructedWeekly, true);
375
+
376
+ // Also reconstruct gas volume if applicable
377
+ if (type === 'gas') {
378
+ const weeklyVolumeState = await this.adapter.getStateAsync(`${basePath}.consumption.weeklyVolume`);
379
+ const lastDayVolumeState = await this.adapter.getStateAsync(
380
+ `${basePath}.statistics.consumption.lastDayVolume`,
381
+ );
382
+ const currentWeeklyVolume = weeklyVolumeState?.val || 0;
383
+ const lastDayVolume = lastDayVolumeState?.val || 0;
384
+
385
+ if (lastDayVolume > 0) {
386
+ const reconstructedWeeklyVolume = calculator.roundToDecimals(
387
+ currentWeeklyVolume + lastDayVolume,
388
+ 4,
389
+ );
390
+ await this.adapter.setStateAsync(
391
+ `${basePath}.consumption.weeklyVolume`,
392
+ reconstructedWeeklyVolume,
393
+ true,
394
+ );
395
+ }
396
+ }
397
+ }
398
+ }
399
+
400
+ // Similar logic for monthly reconstruction
401
+ const lastMonthStartState = await this.adapter.getStateAsync(
402
+ `${basePath}.statistics.timestamps.lastMonthStart`,
403
+ );
404
+ if (lastMonthStartState?.val) {
405
+ const lastMonthStart = lastMonthStartState.val;
406
+ const daysSinceMonthStart = (now - lastMonthStart) / (24 * 60 * 60 * 1000);
407
+
408
+ // Only if within valid month range (0-31 days)
409
+ if (daysSinceMonthStart >= 0 && daysSinceMonthStart <= 31) {
410
+ const monthlyState = await this.adapter.getStateAsync(`${basePath}.consumption.monthly`);
411
+ const currentMonthly = monthlyState?.val || 0;
412
+
413
+ // If daily was reset and monthly seems to be missing the lastDay
414
+ if (daysSinceDayReset < 1 && currentMonthly < lastDay + currentDaily) {
415
+ const reconstructedMonthly = calculator.roundToDecimals(currentMonthly + lastDay, 2);
416
+
417
+ if (reconstructedMonthly > currentMonthly) {
418
+ this.adapter.log.info(
419
+ `[${basePath}] Reconstructing monthly: ${currentMonthly} -> ${reconstructedMonthly} (added lastDay: ${lastDay})`,
420
+ );
421
+ await this.adapter.setStateAsync(`${basePath}.consumption.monthly`, reconstructedMonthly, true);
422
+
423
+ // Also reconstruct gas volume if applicable
424
+ if (type === 'gas') {
425
+ const monthlyVolumeState = await this.adapter.getStateAsync(
426
+ `${basePath}.consumption.monthlyVolume`,
427
+ );
428
+ const lastDayVolumeState = await this.adapter.getStateAsync(
429
+ `${basePath}.statistics.consumption.lastDayVolume`,
430
+ );
431
+ const currentMonthlyVolume = monthlyVolumeState?.val || 0;
432
+ const lastDayVolume = lastDayVolumeState?.val || 0;
433
+
434
+ if (lastDayVolume > 0) {
435
+ const reconstructedMonthlyVolume = calculator.roundToDecimals(
436
+ currentMonthlyVolume + lastDayVolume,
437
+ 4,
438
+ );
439
+ await this.adapter.setStateAsync(
440
+ `${basePath}.consumption.monthlyVolume`,
441
+ reconstructedMonthlyVolume,
442
+ true,
443
+ );
444
+ }
445
+ }
446
+ }
447
+ }
448
+ }
449
+ }
450
+ }
451
+
313
452
  /**
314
453
  * Handles sensor value updates
315
454
  *
@@ -463,6 +602,10 @@ class MultiMeterManager {
463
602
  if (recoveredValue > 0 && Math.abs(consumption - recoveredValue) < 100) {
464
603
  this.adapter.log.info(`[${basePath}] Recovered persistent baseline: ${recoveredValue}`);
465
604
  this.lastSensorValues[sensorDP] = recoveredValue;
605
+
606
+ // Validate period consumption values against spike threshold
607
+ // This catches cases where old consumption values are unrealistically high
608
+ await this._validatePeriodConsumption(type, basePath, now);
466
609
  } else {
467
610
  if (recoveredValue > 0) {
468
611
  this.adapter.log.warn(
@@ -479,6 +622,9 @@ class MultiMeterManager {
479
622
  await this.adapter.setStateAsync(`${basePath}.info.meterReadingVolume`, consumptionM3 || 0, true);
480
623
  }
481
624
 
625
+ // On baseline reset, validate and potentially reset period consumption values
626
+ await this._validatePeriodConsumption(type, basePath, now);
627
+
482
628
  if (config.initialReading > 0) {
483
629
  await this.calculateAbsoluteYearly(type, meterName, config, consumption, consumptionM3 || 0, now);
484
630
  await this.updateCosts(type, meterName, config);
@@ -489,6 +635,50 @@ class MultiMeterManager {
489
635
  await this.adapter.setStateAsync(`${basePath}.consumption.lastUpdate`, now, true);
490
636
  }
491
637
 
638
+ /**
639
+ * Validates period consumption values and resets them if they exceed the spike threshold.
640
+ * This prevents unrealistic values after adapter restart or database inconsistencies.
641
+ *
642
+ * @param {string} type - Utility type
643
+ * @param {string} basePath - State base path
644
+ * @param {number} now - Current timestamp
645
+ */
646
+ async _validatePeriodConsumption(type, basePath, now) {
647
+ const spikeThreshold = this.adapter.config.sensorSpikeThreshold || DEFAULT_SPIKE_THRESHOLD;
648
+
649
+ // Check and reset period consumption values that exceed the spike threshold
650
+ const periods = ['daily', 'weekly', 'monthly'];
651
+ for (const period of periods) {
652
+ const state = await this.adapter.getStateAsync(`${basePath}.consumption.${period}`);
653
+ const value = state?.val || 0;
654
+
655
+ // Get period start timestamp to calculate expected max consumption
656
+ const periodKey =
657
+ period === 'daily' ? 'Day' : period === 'weekly' ? 'Week' : period === 'monthly' ? 'Month' : 'Year';
658
+ const periodStartState = await this.adapter.getStateAsync(
659
+ `${basePath}.statistics.timestamps.last${periodKey}Start`,
660
+ );
661
+ const periodStart = periodStartState?.val || now;
662
+ const daysSincePeriodStart = (now - periodStart) / (24 * 60 * 60 * 1000);
663
+
664
+ // Calculate reasonable max: spike threshold per day * days in period
665
+ // Add buffer of 2x for safety
666
+ const maxReasonableConsumption = spikeThreshold * Math.max(1, daysSincePeriodStart) * 2;
667
+
668
+ if (value > maxReasonableConsumption) {
669
+ this.adapter.log.warn(
670
+ `[${basePath}] Resetting ${period} consumption: ${value} exceeds reasonable max of ${maxReasonableConsumption.toFixed(0)} (${daysSincePeriodStart.toFixed(1)} days * ${spikeThreshold} threshold * 2)`,
671
+ );
672
+ await this.adapter.setStateAsync(`${basePath}.consumption.${period}`, 0, true);
673
+
674
+ // Also reset volume states for gas
675
+ if (type === 'gas') {
676
+ await this.adapter.setStateAsync(`${basePath}.consumption.${period}Volume`, 0, true);
677
+ }
678
+ }
679
+ }
680
+ }
681
+
492
682
  /**
493
683
  * Handles meter reset or replacement condition
494
684
  *
@@ -798,7 +988,7 @@ class MultiMeterManager {
798
988
  * @returns {Promise<number>} Months since start (at least 1)
799
989
  */
800
990
  async _calculateMonthsSinceYearStart(basePath) {
801
- const yearStartState = await this.adapter.getStateAsync(`${basePath}.statistics.lastYearStart`);
991
+ const yearStartState = await this.adapter.getStateAsync(`${basePath}.statistics.timestamps.lastYearStart`);
802
992
  let monthsSinceYearStart = 1;
803
993
 
804
994
  if (yearStartState && yearStartState.val) {
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ const STATE_ROLES = require('./roles');
4
+
5
+ /**
6
+ * Creates history structure for a specific year
7
+ *
8
+ * @param {object} adapter - The adapter instance
9
+ * @param {string} type - 'gas', 'water', 'electricity', 'pv'
10
+ * @param {string} meterName - Meter name
11
+ * @param {number|string} year - Year (YYYY)
12
+ * @returns {Promise<void>}
13
+ */
14
+ async function createHistoryStructure(adapter, type, meterName, year) {
15
+ const basePath = `${type}.${meterName}.history.${year}`;
16
+
17
+ await adapter.setObjectNotExistsAsync(`${type}.${meterName}.history`, {
18
+ type: 'channel',
19
+ common: { name: 'Historie' },
20
+ native: {},
21
+ });
22
+
23
+ await adapter.setObjectNotExistsAsync(basePath, {
24
+ type: 'channel',
25
+ common: { name: `Jahr ${year}` },
26
+ native: { year },
27
+ });
28
+
29
+ let consumptionUnit = 'kWh';
30
+ if (type === 'water') {
31
+ consumptionUnit = 'm³';
32
+ } else if (type === 'gas') {
33
+ consumptionUnit = 'kWh';
34
+ }
35
+
36
+ await adapter.setObjectNotExistsAsync(`${basePath}.consumption`, {
37
+ type: 'state',
38
+ common: {
39
+ name: `Jahresverbrauch ${year} (${consumptionUnit})`,
40
+ type: 'number',
41
+ role: STATE_ROLES.consumption,
42
+ read: true,
43
+ write: false,
44
+ unit: consumptionUnit,
45
+ def: 0,
46
+ },
47
+ native: {},
48
+ });
49
+
50
+ if (type === 'gas') {
51
+ await adapter.setObjectNotExistsAsync(`${basePath}.volume`, {
52
+ type: 'state',
53
+ common: {
54
+ name: `Jahresverbrauch ${year} (m³)`,
55
+ type: 'number',
56
+ role: STATE_ROLES.consumption,
57
+ read: true,
58
+ write: false,
59
+ unit: 'm³',
60
+ def: 0,
61
+ },
62
+ native: {},
63
+ });
64
+ }
65
+
66
+ await adapter.setObjectNotExistsAsync(`${basePath}.costs`, {
67
+ type: 'state',
68
+ common: {
69
+ name: `Jahreskosten ${year} (€)`,
70
+ type: 'number',
71
+ role: STATE_ROLES.cost,
72
+ read: true,
73
+ write: false,
74
+ unit: '€',
75
+ def: 0,
76
+ },
77
+ native: {},
78
+ });
79
+
80
+ await adapter.setObjectNotExistsAsync(`${basePath}.balance`, {
81
+ type: 'state',
82
+ common: {
83
+ name: `Bilanz ${year} (€)`,
84
+ type: 'number',
85
+ role: STATE_ROLES.cost,
86
+ read: true,
87
+ write: false,
88
+ unit: '€',
89
+ def: 0,
90
+ },
91
+ native: {},
92
+ });
93
+ }
94
+
95
+ module.exports = createHistoryStructure;