hoffmation-base 3.8.0 → 4.0.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.
Files changed (127) hide show
  1. package/lib/devices/dachs/dachs.d.ts +79 -2
  2. package/lib/devices/dachs/dachs.js +214 -18
  3. package/lib/devices/devices.d.ts +36 -1
  4. package/lib/devices/devices.js +64 -0
  5. package/lib/devices/jsObject/jsObjectEnergyManager.d.ts +16 -1
  6. package/lib/devices/jsObject/jsObjectEnergyManager.js +23 -0
  7. package/lib/devices/victron/victron-device.d.ts +83 -1
  8. package/lib/devices/victron/victron-device.js +151 -2
  9. package/lib/devices/victron/victron-morning-release.d.ts +16 -0
  10. package/lib/devices/victron/victron-morning-release.js +2 -0
  11. package/lib/enums/DeviceCapability.d.ts +1 -0
  12. package/lib/enums/DeviceCapability.js +1 -0
  13. package/lib/interfaces/baseDevices/iAirQualityCollector.d.ts +2 -1
  14. package/lib/interfaces/baseDevices/iAirQualityReadings.d.ts +50 -0
  15. package/lib/interfaces/baseDevices/iAirQualityReadings.js +2 -0
  16. package/lib/interfaces/baseDevices/iAirQualitySensor.d.ts +1 -54
  17. package/lib/interfaces/baseDevices/iAirQualitySensor.js +0 -5
  18. package/lib/interfaces/baseDevices/iEnergyManager.d.ts +43 -0
  19. package/lib/interfaces/baseDevices/iHumidityCollector.d.ts +0 -1
  20. package/lib/interfaces/baseDevices/iHumidityCollector.js +0 -2
  21. package/lib/interfaces/baseDevices/iTemperatureCollector.d.ts +0 -1
  22. package/lib/interfaces/baseDevices/iTemperatureCollector.js +0 -2
  23. package/lib/interfaces/baseDevices/index.d.ts +4 -0
  24. package/lib/interfaces/baseDevices/index.js +4 -0
  25. package/lib/interfaces/baseDevices/undefinedAirQualityValue.d.ts +4 -0
  26. package/lib/interfaces/baseDevices/undefinedAirQualityValue.js +7 -0
  27. package/lib/interfaces/baseDevices/undefinedHumidityValue.d.ts +4 -0
  28. package/lib/interfaces/baseDevices/undefinedHumidityValue.js +7 -0
  29. package/lib/interfaces/baseDevices/undefinedTempValue.d.ts +4 -0
  30. package/lib/interfaces/baseDevices/undefinedTempValue.js +7 -0
  31. package/lib/interfaces/dachs/KeyListEntity.d.ts +0 -5
  32. package/lib/interfaces/dachs/KeyListEntityResponse.d.ts +6 -0
  33. package/lib/interfaces/dachs/KeyListEntityResponse.js +2 -0
  34. package/lib/interfaces/dachs/index.d.ts +1 -0
  35. package/lib/interfaces/dachs/index.js +1 -0
  36. package/lib/interfaces/deviceSettings/iDachsDeviceSettings.d.ts +15 -0
  37. package/lib/interfaces/iActuatorStateSample.d.ts +13 -0
  38. package/lib/interfaces/iActuatorStateSample.js +2 -0
  39. package/lib/interfaces/iBatteryLevelSample.d.ts +13 -0
  40. package/lib/interfaces/iBatteryLevelSample.js +2 -0
  41. package/lib/interfaces/iConsumptionWindowSample.d.ts +26 -0
  42. package/lib/interfaces/iConsumptionWindowSample.js +2 -0
  43. package/lib/interfaces/iDachsHistoryGateResult.d.ts +29 -0
  44. package/lib/interfaces/iDachsHistoryGateResult.js +2 -0
  45. package/lib/interfaces/iEnergyHistoryBasis.d.ts +26 -0
  46. package/lib/interfaces/iEnergyHistoryBasis.js +2 -0
  47. package/lib/interfaces/iEnergyHistoryEstimate.d.ts +21 -0
  48. package/lib/interfaces/iEnergyHistoryEstimate.js +2 -0
  49. package/lib/interfaces/iEnergyHistoryFeatures.d.ts +21 -0
  50. package/lib/interfaces/iEnergyHistoryFeatures.js +2 -0
  51. package/lib/interfaces/iEnergyHistoryModel.d.ts +21 -0
  52. package/lib/interfaces/iEnergyHistoryModel.js +2 -0
  53. package/lib/interfaces/iEnergyHistoryOptions.d.ts +29 -0
  54. package/lib/interfaces/iEnergyHistoryOptions.js +2 -0
  55. package/lib/interfaces/iEnergyHistoryOutlook.d.ts +41 -0
  56. package/lib/interfaces/iEnergyHistoryOutlook.js +2 -0
  57. package/lib/interfaces/iEnergyHistorySample.d.ts +19 -0
  58. package/lib/interfaces/iEnergyHistorySample.js +2 -0
  59. package/lib/interfaces/iFossilGeneratorRun.d.ts +22 -0
  60. package/lib/interfaces/iFossilGeneratorRun.js +2 -0
  61. package/lib/interfaces/iFossilGeneratorSource.d.ts +21 -0
  62. package/lib/interfaces/iFossilGeneratorSource.js +2 -0
  63. package/lib/interfaces/iMorningReserveVerdict.d.ts +38 -0
  64. package/lib/interfaces/iMorningReserveVerdict.js +2 -0
  65. package/lib/interfaces/iPersist.d.ts +51 -0
  66. package/lib/interfaces/iProjectedSocBand.d.ts +7 -0
  67. package/lib/interfaces/iProjectedSocBand.js +2 -0
  68. package/lib/interfaces/iWeatherDaySummary.d.ts +21 -0
  69. package/lib/interfaces/iWeatherDaySummary.js +2 -0
  70. package/lib/interfaces/index.d.ts +16 -0
  71. package/lib/interfaces/index.js +16 -0
  72. package/lib/services/dbo/actuator-state-row.d.ts +14 -0
  73. package/lib/services/dbo/actuator-state-row.js +2 -0
  74. package/lib/services/dbo/battery-level-row.d.ts +16 -0
  75. package/lib/services/dbo/battery-level-row.js +2 -0
  76. package/lib/services/dbo/energy-consumption-row.d.ts +16 -0
  77. package/lib/services/dbo/energy-consumption-row.js +2 -0
  78. package/lib/services/dbo/postgreSqlPersist.d.ts +48 -1
  79. package/lib/services/dbo/postgreSqlPersist.js +298 -28
  80. package/lib/services/dbo/weather-day-summary-row.d.ts +18 -0
  81. package/lib/services/dbo/weather-day-summary-row.js +2 -0
  82. package/lib/services/energy/energy-history-service.d.ts +168 -0
  83. package/lib/services/energy/energy-history-service.js +499 -0
  84. package/lib/services/energy/index.d.ts +1 -0
  85. package/lib/services/energy/index.js +17 -0
  86. package/lib/services/energy/plant-energy-dials.d.ts +22 -0
  87. package/lib/services/energy/plant-energy-dials.js +2 -0
  88. package/lib/services/https-service.d.ts +48 -2
  89. package/lib/services/https-service.js +136 -10
  90. package/lib/services/index.d.ts +1 -0
  91. package/lib/services/index.js +1 -0
  92. package/lib/services/time-callback-service.d.ts +12 -0
  93. package/lib/services/time-callback-service.js +16 -2
  94. package/lib/services/weather/index.d.ts +1 -0
  95. package/lib/services/weather/index.js +1 -0
  96. package/lib/services/weather/open-weather-day-summary.d.ts +21 -0
  97. package/lib/services/weather/open-weather-day-summary.js +2 -0
  98. package/lib/services/weather/weather-day-summary-fetcher.d.ts +6 -0
  99. package/lib/services/weather/weather-day-summary-fetcher.js +2 -0
  100. package/lib/services/weather/weather-history-backfill.d.ts +88 -0
  101. package/lib/services/weather/weather-history-backfill.js +228 -0
  102. package/lib/services/weather/weather-service.js +29 -4
  103. package/lib/settings-service-instance.js +5 -2
  104. package/lib/settingsObjects/deviceSettings/dachsSettings.d.ts +6 -0
  105. package/lib/settingsObjects/deviceSettings/dachsSettings.js +14 -1
  106. package/lib/settingsObjects/deviceSettings/victronDeviceSettings.d.ts +116 -0
  107. package/lib/settingsObjects/deviceSettings/victronDeviceSettings.js +150 -5
  108. package/lib/tsconfig.tsbuildinfo +1 -1
  109. package/lib/utils/dayMs.d.ts +7 -0
  110. package/lib/utils/dayMs.js +10 -0
  111. package/lib/utils/energy-history-dials.d.ts +19 -0
  112. package/lib/utils/energy-history-dials.js +2 -0
  113. package/lib/utils/energy-history-utils.d.ts +143 -0
  114. package/lib/utils/energy-history-utils.js +470 -0
  115. package/lib/utils/energy-manager-utils.d.ts +123 -1
  116. package/lib/utils/energy-manager-utils.js +226 -0
  117. package/lib/utils/index.d.ts +4 -0
  118. package/lib/utils/index.js +4 -0
  119. package/lib/utils/model-shadow.d.ts +61 -0
  120. package/lib/utils/model-shadow.js +83 -0
  121. package/lib/utils/morning-reserve-dials.d.ts +21 -0
  122. package/lib/utils/morning-reserve-dials.js +2 -0
  123. package/lib/utils/morning-verdict.d.ts +5 -0
  124. package/lib/utils/morning-verdict.js +2 -0
  125. package/lib/utils/utils.d.ts +0 -1
  126. package/lib/utils/utils.js +5 -5
  127. package/package.json +3 -2
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The length of a day in milliseconds.
3
+ *
4
+ * A fixed length, not a calendar day: on the two days a year a zone changes its offset, a real day is 23 or
5
+ * 25 hours long. Use calendar arithmetic where the boundary of a day matters.
6
+ */
7
+ export declare const DAYMS: number;
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DAYMS = void 0;
4
+ /**
5
+ * The length of a day in milliseconds.
6
+ *
7
+ * A fixed length, not a calendar day: on the two days a year a zone changes its offset, a real day is 23 or
8
+ * 25 hours long. Use calendar arithmetic where the boundary of a day matters.
9
+ */
10
+ exports.DAYMS = 24 * 60 * 60 * 1000;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The dials an energy manager has to state before the plant's recorded history can be read at all.
3
+ *
4
+ * Read structurally off the manager's settings, so this stays off any concrete manager class - and required
5
+ * rather than defaulted, because a default here would be a second place stating the delivered numbers. A
6
+ * manager that states none of them says nothing about the coming morning, which is the honest answer for a
7
+ * manager that never described a history to begin with.
8
+ *
9
+ * Not part of the published surface: it describes how the shared implementation reads a manager, not something
10
+ * a consumer of the library implements.
11
+ */
12
+ export interface iEnergyHistoryDials {
13
+ /** Length of the sliding window in days that is read, fitted and backfilled */
14
+ historyWindowDays: number;
15
+ /** Below this many usable historical days no model is fitted at all */
16
+ historyMinimumDays: number;
17
+ /** How many residual sigmas each edge of the band lies away from the point estimate */
18
+ historyBandSigma: number;
19
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,143 @@
1
+ import { iActuatorStateSample } from '../interfaces/iActuatorStateSample';
2
+ import { iBatteryLevelSample } from '../interfaces/iBatteryLevelSample';
3
+ import { iConsumptionWindowSample } from '../interfaces/iConsumptionWindowSample';
4
+ import { iEnergyHistoryEstimate } from '../interfaces/iEnergyHistoryEstimate';
5
+ import { iEnergyHistoryFeatures } from '../interfaces/iEnergyHistoryFeatures';
6
+ import { iEnergyHistoryModel } from '../interfaces/iEnergyHistoryModel';
7
+ import { iEnergyHistorySample } from '../interfaces/iEnergyHistorySample';
8
+ import { iFossilGeneratorRun } from '../interfaces/iFossilGeneratorRun';
9
+ /**
10
+ * Pure arithmetic over what the plant's own history says: how the state of charge developed towards
11
+ * a morning low, and how much the house consumed over a window.
12
+ *
13
+ * This answers questions about the plant, not about any one device. A device asking "where will the
14
+ * battery bottom out tomorrow morning" is a caller here; what it then decides to do about the answer
15
+ * is that device's own policy and stays with it.
16
+ *
17
+ * Nothing in here reads persistence, devices, settings or the clock - every input arrives as a
18
+ * parameter. That is what makes the back test in test/utils/energy-history-backtest.test.ts
19
+ * checkable without a harness. No method throws; unusable input yields `undefined` or `0`.
20
+ */
21
+ export declare class EnergyHistoryUtils {
22
+ /**
23
+ * Fits the weights from history by ordinary least squares over the four features plus an
24
+ * intercept. The weights are never written by hand - they come out of this fit.
25
+ * @param samples - The observations to fit from; this method uses these and nothing else.
26
+ * @param minimumDays - Below this many usable observations no model is produced at all.
27
+ * @returns The fitted model, or `undefined` when there is too little or unusable history.
28
+ */
29
+ static fit(samples: iEnergyHistorySample[], minimumDays: number): iEnergyHistoryModel | undefined;
30
+ /**
31
+ * Applies the model and widens the point estimate into a band.
32
+ * @param model - The fitted model to apply.
33
+ * @param features - The four quantities the prediction is based on.
34
+ * @param bandSigma - How many residual sigmas each edge lies away from the point estimate.
35
+ * @returns The point estimate together with its lower and upper edge.
36
+ */
37
+ static estimate(model: iEnergyHistoryModel, features: iEnergyHistoryFeatures, bandSigma: number): iEnergyHistoryEstimate;
38
+ /**
39
+ * Removes what the fuel burning generators contributed from an observed change, so the sample
40
+ * describes what the photovoltaic system alone would have achieved.
41
+ *
42
+ * The generators are summed rather than branched over: every one of them pushed watt hours into
43
+ * the same battery over the same window, so their shares of the state of charge simply add up. A
44
+ * second unit is therefore one more list entry and needs no change here.
45
+ * @param observedDelta - The raw change in percentage points.
46
+ * @param generators - What each fuel burning generator ran inside the same window; an empty list
47
+ * means nothing is subtracted.
48
+ * @param batteryCapacityWattHours - Usable battery capacity, shared by all of them.
49
+ * @returns The corrected change; never above the observed change, and the input unchanged when
50
+ * nothing usable was handed over.
51
+ */
52
+ static correctForFossilGeneration(observedDelta: number, generators: iFossilGeneratorRun[], batteryCapacityWattHours: number): number;
53
+ /**
54
+ * The change in state of charge from a given moment to the low point within the window that
55
+ * follows it. The window is passed in - this class never reads the clock.
56
+ * @param samples - The persisted state of charge readings, in any order.
57
+ * @param fromMs - The moment of evaluation.
58
+ * @param untilMs - End of the window, that is the following sunrise plus a buffer hour.
59
+ * @returns The change in percentage points, or `undefined` when the data does not cover the
60
+ * window or holds no reading at the moment of evaluation.
61
+ */
62
+ static deltaToNextMorningLow(samples: iBatteryLevelSample[], fromMs: number, untilMs: number): number | undefined;
63
+ /**
64
+ * Total milliseconds the actuator was on within a window, from its recorded state changes.
65
+ * @param samples - The recorded state changes, in any order.
66
+ * @param fromMs - Start of the window.
67
+ * @param toMs - End of the window.
68
+ * @returns The on time in milliseconds; `0` when nothing is known about the window.
69
+ */
70
+ static onMillisecondsWithin(samples: iActuatorStateSample[], fromMs: number, toMs: number): number;
71
+ /**
72
+ * Where the state of charge would bottom out if no further photovoltaic yield arrived at all.
73
+ * @param currentSoc - The state of charge right now in percent.
74
+ * @param expectedConsumptionKwh - House consumption expected over the window ahead.
75
+ * @param batteryCapacityWattHours - Usable battery capacity.
76
+ * @returns The bound in percent, never below zero, and `0` for an unusable capacity.
77
+ */
78
+ static worstCaseLowSoc(currentSoc: number, expectedConsumptionKwh: number, batteryCapacityWattHours: number): number;
79
+ /**
80
+ * House consumption over the same window on the days handed in, taken at an upper quantile
81
+ * rather than at the median: the bound has to hold on a heavy night, not on half of them.
82
+ *
83
+ * Below `minimumSamples` there is no answer at all, the same discipline `fit` keeps. An upper
84
+ * quantile of one night IS that night, and of two nights it sits all but on their maximum - so a
85
+ * single quiet night would produce a low expected consumption, a high bound and a suppression with
86
+ * nothing behind it. The measured spread makes the size of that error concrete: at a day regime the
87
+ * upper quantile sits more than four times above the median, which is some sixteen state of charge
88
+ * points that simply do not exist while the sample is one night long.
89
+ * @param samples - The consumption windows of the historical days.
90
+ * @param quantile - The quantile to read, between 0 and 1.
91
+ * @param minimumSamples - Fewer usable window sums than this yield no answer.
92
+ * @returns The consumption in kWh, or `undefined` for too small a sample - never a zero, which
93
+ * would read as "nothing expected" and suppress without any data at all.
94
+ */
95
+ static consumptionQuantileKwh(samples: iConsumptionWindowSample[], quantile: number, minimumSamples: number): number | undefined;
96
+ /**
97
+ * One window sum per historical day: for the same time of day window as the one handed in, the sum
98
+ * of that day's readings inside it.
99
+ *
100
+ * This is the step that makes the uncertainty measurable rather than assumed. The expected
101
+ * consumption is the median of the returned sums, the bound their upper quantile - both read with
102
+ * consumptionQuantileKwh. The quantile belongs on the sums and never on the individual readings:
103
+ * summing per reading quantiles would apply the safety margin once per interval instead of once per
104
+ * window, which is markedly more.
105
+ *
106
+ * A day whose readings do not cover the window is dropped, not patched. Its sum would be too small
107
+ * and would therefore look like a frugal night, pulling the median down and making the bound too
108
+ * optimistic - the direction that suppresses a start the house needed.
109
+ *
110
+ * Windows longer than a calendar day are allowed. "The same window one day earlier" then overlaps
111
+ * its own neighbour, and a reading in the shared stretch belongs to both occurrences - so it is
112
+ * counted in both sums, because each sum answers "what did this window consume" and both windows
113
+ * really did contain it. Only a partition would have to choose, and no partition is needed here.
114
+ * @param readings - The persisted consumption readings of one measuring interval each, in any order.
115
+ * Each is dated at the END of the interval it closes, the way the persistence hands them over.
116
+ * @param fromMs - Start of the window under evaluation.
117
+ * @param untilMs - End of the window under evaluation.
118
+ * @param readingIntervalMs - Length of one measuring interval, so that the expected number of
119
+ * readings per window follows from the window rather than from the data.
120
+ * @param minimumCoverage - Share of the expected readings a day must carry to be counted, 0 to 1.
121
+ * @returns One sum per sufficiently covered day, ascending by window start; empty when none
122
+ * qualifies. Each returned sample is dated at the START of its window.
123
+ */
124
+ static windowConsumptionSums(readings: iConsumptionWindowSample[], fromMs: number, untilMs: number, readingIntervalMs: number, minimumCoverage: number): iConsumptionWindowSample[];
125
+ /**
126
+ * The same moment shifted by whole calendar days, keeping its time of day.
127
+ * @param ms - The moment to shift.
128
+ * @param days - How many calendar days to add; negative shifts into the past.
129
+ * @returns The shifted moment in milliseconds.
130
+ */
131
+ private static shiftCalendarDays;
132
+ private static isUsableSample;
133
+ private static toFeatureVector;
134
+ private static columnMeans;
135
+ private static zeroMatrix;
136
+ /**
137
+ * Gaussian elimination with partial pivoting.
138
+ * @param matrix - The square coefficient matrix of the normal equations.
139
+ * @param vector - The right hand side.
140
+ * @returns The solution, or `undefined` when the system has no unique one.
141
+ */
142
+ private static solve;
143
+ }
@@ -0,0 +1,470 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EnergyHistoryUtils = void 0;
4
+ /** The declaration order of iEnergyHistoryFeatures - the fit and the model tuple follow it. */
5
+ const FEATURE_COUNT = 4;
6
+ /** Least squares needs one observation per fitted parameter: four weights plus the intercept. */
7
+ const MINIMUM_FITTABLE_SAMPLES = FEATURE_COUNT + 1;
8
+ const MILLISECONDS_PER_HOUR = 3600000;
9
+ const WATT_HOURS_PER_KWH = 1000;
10
+ const PERCENT_FACTOR = 100;
11
+ const MILLISECONDS_PER_DAY = 24 * MILLISECONDS_PER_HOUR;
12
+ /**
13
+ * Pure arithmetic over what the plant's own history says: how the state of charge developed towards
14
+ * a morning low, and how much the house consumed over a window.
15
+ *
16
+ * This answers questions about the plant, not about any one device. A device asking "where will the
17
+ * battery bottom out tomorrow morning" is a caller here; what it then decides to do about the answer
18
+ * is that device's own policy and stays with it.
19
+ *
20
+ * Nothing in here reads persistence, devices, settings or the clock - every input arrives as a
21
+ * parameter. That is what makes the back test in test/utils/energy-history-backtest.test.ts
22
+ * checkable without a harness. No method throws; unusable input yields `undefined` or `0`.
23
+ */
24
+ class EnergyHistoryUtils {
25
+ /**
26
+ * Fits the weights from history by ordinary least squares over the four features plus an
27
+ * intercept. The weights are never written by hand - they come out of this fit.
28
+ * @param samples - The observations to fit from; this method uses these and nothing else.
29
+ * @param minimumDays - Below this many usable observations no model is produced at all.
30
+ * @returns The fitted model, or `undefined` when there is too little or unusable history.
31
+ */
32
+ static fit(samples, minimumDays) {
33
+ const usable = samples.filter((sample) => EnergyHistoryUtils.isUsableSample(sample));
34
+ if (usable.length < Math.max(minimumDays, MINIMUM_FITTABLE_SAMPLES)) {
35
+ return undefined;
36
+ }
37
+ const featureRows = usable.map((sample) => EnergyHistoryUtils.toFeatureVector(sample.features));
38
+ const targets = usable.map((sample) => sample.observedDelta);
39
+ const featureMeans = EnergyHistoryUtils.columnMeans(featureRows);
40
+ const targetMean = targets.reduce((sum, value) => sum + value, 0) / targets.length;
41
+ // Centering the columns before the normal equations keeps the 4x4 system well conditioned and
42
+ // moves the intercept out of the matrix - it is recovered from the means afterwards.
43
+ const normalMatrix = EnergyHistoryUtils.zeroMatrix(FEATURE_COUNT);
44
+ const normalVector = new Array(FEATURE_COUNT).fill(0);
45
+ for (let row = 0; row < featureRows.length; row++) {
46
+ const centered = featureRows[row].map((value, column) => value - featureMeans[column]);
47
+ const centeredTarget = targets[row] - targetMean;
48
+ for (let i = 0; i < FEATURE_COUNT; i++) {
49
+ for (let j = 0; j < FEATURE_COUNT; j++) {
50
+ normalMatrix[i][j] += centered[i] * centered[j];
51
+ }
52
+ normalVector[i] += centered[i] * centeredTarget;
53
+ }
54
+ }
55
+ const solution = EnergyHistoryUtils.solve(normalMatrix, normalVector);
56
+ if (solution === undefined) {
57
+ return undefined;
58
+ }
59
+ const intercept = targetMean - solution.reduce((sum, weight, column) => sum + weight * featureMeans[column], 0);
60
+ let residualSquareSum = 0;
61
+ for (let row = 0; row < featureRows.length; row++) {
62
+ const predicted = featureRows[row].reduce((sum, value, column) => sum + value * solution[column], intercept);
63
+ residualSquareSum += (targets[row] - predicted) ** 2;
64
+ }
65
+ // Residual standard error: the degrees of freedom left after five parameters were spent.
66
+ const degreesOfFreedom = Math.max(featureRows.length - MINIMUM_FITTABLE_SAMPLES, 1);
67
+ const residualSigma = Math.sqrt(residualSquareSum / degreesOfFreedom);
68
+ const weights = [solution[0], solution[1], solution[2], solution[3]];
69
+ if (!weights.every((weight) => Number.isFinite(weight)) || !Number.isFinite(intercept)) {
70
+ return undefined;
71
+ }
72
+ if (!Number.isFinite(residualSigma)) {
73
+ return undefined;
74
+ }
75
+ return { weights, intercept, residualSigma, sampleDays: featureRows.length };
76
+ }
77
+ /**
78
+ * Applies the model and widens the point estimate into a band.
79
+ * @param model - The fitted model to apply.
80
+ * @param features - The four quantities the prediction is based on.
81
+ * @param bandSigma - How many residual sigmas each edge lies away from the point estimate.
82
+ * @returns The point estimate together with its lower and upper edge.
83
+ */
84
+ static estimate(model, features, bandSigma) {
85
+ const featureVector = EnergyHistoryUtils.toFeatureVector(features);
86
+ const expectedDelta = featureVector.reduce((sum, value, column) => sum + value * model.weights[column], model.intercept);
87
+ const rawBand = bandSigma * model.residualSigma;
88
+ // The magnitude keeps the edges ordered even for a negative sigma - a band never flips over.
89
+ const band = Number.isFinite(rawBand) ? Math.abs(rawBand) : 0;
90
+ return {
91
+ expectedDelta,
92
+ lowerEdgeDelta: expectedDelta - band,
93
+ upperEdgeDelta: expectedDelta + band,
94
+ sampleDays: model.sampleDays,
95
+ };
96
+ }
97
+ /**
98
+ * Removes what the fuel burning generators contributed from an observed change, so the sample
99
+ * describes what the photovoltaic system alone would have achieved.
100
+ *
101
+ * The generators are summed rather than branched over: every one of them pushed watt hours into
102
+ * the same battery over the same window, so their shares of the state of charge simply add up. A
103
+ * second unit is therefore one more list entry and needs no change here.
104
+ * @param observedDelta - The raw change in percentage points.
105
+ * @param generators - What each fuel burning generator ran inside the same window; an empty list
106
+ * means nothing is subtracted.
107
+ * @param batteryCapacityWattHours - Usable battery capacity, shared by all of them.
108
+ * @returns The corrected change; never above the observed change, and the input unchanged when
109
+ * nothing usable was handed over.
110
+ */
111
+ static correctForFossilGeneration(observedDelta, generators, batteryCapacityWattHours) {
112
+ if (!Number.isFinite(observedDelta)) {
113
+ return observedDelta;
114
+ }
115
+ if (!Number.isFinite(batteryCapacityWattHours) || batteryCapacityWattHours <= 0) {
116
+ // Without a capacity no run can be expressed in points of charge at all, so no entry survives.
117
+ return observedDelta;
118
+ }
119
+ let socPoints = 0;
120
+ for (const generator of generators) {
121
+ // Judged per entry, not over the list: a run of an unknown size is worse than none and drops
122
+ // out, while the runs that are known stay in. An unusable entry therefore under-corrects the
123
+ // sample, which makes the photovoltaic look better than it was.
124
+ const runUsable = Number.isFinite(generator.runMilliseconds) &&
125
+ generator.runMilliseconds > 0 &&
126
+ Number.isFinite(generator.ratedElectricalWattage) &&
127
+ generator.ratedElectricalWattage > 0 &&
128
+ Number.isFinite(generator.conversionFactor) &&
129
+ generator.conversionFactor > 0;
130
+ if (!runUsable) {
131
+ continue;
132
+ }
133
+ const runHours = generator.runMilliseconds / MILLISECONDS_PER_HOUR;
134
+ socPoints +=
135
+ ((runHours * generator.ratedElectricalWattage * generator.conversionFactor) / batteryCapacityWattHours) *
136
+ PERCENT_FACTOR;
137
+ }
138
+ if (!Number.isFinite(socPoints)) {
139
+ return observedDelta;
140
+ }
141
+ return observedDelta - socPoints;
142
+ }
143
+ /**
144
+ * The change in state of charge from a given moment to the low point within the window that
145
+ * follows it. The window is passed in - this class never reads the clock.
146
+ * @param samples - The persisted state of charge readings, in any order.
147
+ * @param fromMs - The moment of evaluation.
148
+ * @param untilMs - End of the window, that is the following sunrise plus a buffer hour.
149
+ * @returns The change in percentage points, or `undefined` when the data does not cover the
150
+ * window or holds no reading at the moment of evaluation.
151
+ */
152
+ static deltaToNextMorningLow(samples, fromMs, untilMs) {
153
+ var _a;
154
+ if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) {
155
+ return undefined;
156
+ }
157
+ let startLevel = undefined;
158
+ let startMs = Number.NEGATIVE_INFINITY;
159
+ let lastMs = Number.NEGATIVE_INFINITY;
160
+ let low = undefined;
161
+ for (const sample of samples) {
162
+ const sampleMs = (_a = sample.date) === null || _a === void 0 ? void 0 : _a.getTime();
163
+ if (sampleMs === undefined || !Number.isFinite(sampleMs) || !Number.isFinite(sample.level)) {
164
+ continue;
165
+ }
166
+ if (sampleMs > lastMs) {
167
+ lastMs = sampleMs;
168
+ }
169
+ if (sampleMs <= fromMs && sampleMs > startMs) {
170
+ startMs = sampleMs;
171
+ startLevel = sample.level;
172
+ }
173
+ // Strictly after the moment of evaluation: the reading at that moment is the starting point,
174
+ // not a candidate for the low that follows it.
175
+ if (sampleMs > fromMs && sampleMs <= untilMs && (low === undefined || sample.level < low)) {
176
+ low = sample.level;
177
+ }
178
+ }
179
+ if (startLevel === undefined || low === undefined) {
180
+ return undefined;
181
+ }
182
+ if (lastMs < untilMs) {
183
+ // The series stops before the window ends, so the following morning is simply not in the
184
+ // data. Reporting the lowest value seen so far would invent a low point.
185
+ return undefined;
186
+ }
187
+ return low - startLevel;
188
+ }
189
+ /**
190
+ * Total milliseconds the actuator was on within a window, from its recorded state changes.
191
+ * @param samples - The recorded state changes, in any order.
192
+ * @param fromMs - Start of the window.
193
+ * @param toMs - End of the window.
194
+ * @returns The on time in milliseconds; `0` when nothing is known about the window.
195
+ */
196
+ static onMillisecondsWithin(samples, fromMs, toMs) {
197
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) {
198
+ return 0;
199
+ }
200
+ const ordered = samples
201
+ .filter((sample) => { var _a; return Number.isFinite((_a = sample.date) === null || _a === void 0 ? void 0 : _a.getTime()); })
202
+ .map((sample) => ({ on: sample.on === true, ms: sample.date.getTime() }))
203
+ .sort((a, b) => a.ms - b.ms);
204
+ if (ordered.length === 0) {
205
+ return 0;
206
+ }
207
+ const before = ordered.filter((sample) => sample.ms <= fromMs);
208
+ const inside = ordered.filter((sample) => sample.ms > fromMs && sample.ms < toMs);
209
+ let state;
210
+ if (before.length > 0) {
211
+ state = before[before.length - 1].on;
212
+ }
213
+ else if (inside.length > 0) {
214
+ // Nothing is recorded before the window, but a recorded change is a transition: the state it
215
+ // switches away from is the state the window started in. Reading a leading "off" as "was on"
216
+ // over-counts the generator share at worst, and over-counting errs towards one run too many
217
+ // rather than towards a night that does not carry.
218
+ state = !inside[0].on;
219
+ }
220
+ else {
221
+ state = false;
222
+ }
223
+ let cursorMs = fromMs;
224
+ let onMilliseconds = 0;
225
+ for (const sample of inside) {
226
+ if (state) {
227
+ onMilliseconds += sample.ms - cursorMs;
228
+ }
229
+ cursorMs = sample.ms;
230
+ state = sample.on;
231
+ }
232
+ if (state) {
233
+ onMilliseconds += toMs - cursorMs;
234
+ }
235
+ return onMilliseconds;
236
+ }
237
+ /**
238
+ * Where the state of charge would bottom out if no further photovoltaic yield arrived at all.
239
+ * @param currentSoc - The state of charge right now in percent.
240
+ * @param expectedConsumptionKwh - House consumption expected over the window ahead.
241
+ * @param batteryCapacityWattHours - Usable battery capacity.
242
+ * @returns The bound in percent, never below zero, and `0` for an unusable capacity.
243
+ */
244
+ static worstCaseLowSoc(currentSoc, expectedConsumptionKwh, batteryCapacityWattHours) {
245
+ if (!Number.isFinite(currentSoc)) {
246
+ return 0;
247
+ }
248
+ const startingPoint = Math.max(currentSoc, 0);
249
+ if (!Number.isFinite(batteryCapacityWattHours) || batteryCapacityWattHours <= 0) {
250
+ // Pessimistic on purpose: a zero bound never suppresses and at most requests, so an unusable
251
+ // capacity errs towards one run too many instead of towards a night that does not carry.
252
+ return 0;
253
+ }
254
+ if (!Number.isFinite(expectedConsumptionKwh) || expectedConsumptionKwh <= 0) {
255
+ return startingPoint;
256
+ }
257
+ const socPoints = ((expectedConsumptionKwh * WATT_HOURS_PER_KWH) / batteryCapacityWattHours) * PERCENT_FACTOR;
258
+ if (!Number.isFinite(socPoints)) {
259
+ return startingPoint;
260
+ }
261
+ return Math.max(startingPoint - socPoints, 0);
262
+ }
263
+ /**
264
+ * House consumption over the same window on the days handed in, taken at an upper quantile
265
+ * rather than at the median: the bound has to hold on a heavy night, not on half of them.
266
+ *
267
+ * Below `minimumSamples` there is no answer at all, the same discipline `fit` keeps. An upper
268
+ * quantile of one night IS that night, and of two nights it sits all but on their maximum - so a
269
+ * single quiet night would produce a low expected consumption, a high bound and a suppression with
270
+ * nothing behind it. The measured spread makes the size of that error concrete: at a day regime the
271
+ * upper quantile sits more than four times above the median, which is some sixteen state of charge
272
+ * points that simply do not exist while the sample is one night long.
273
+ * @param samples - The consumption windows of the historical days.
274
+ * @param quantile - The quantile to read, between 0 and 1.
275
+ * @param minimumSamples - Fewer usable window sums than this yield no answer.
276
+ * @returns The consumption in kWh, or `undefined` for too small a sample - never a zero, which
277
+ * would read as "nothing expected" and suppress without any data at all.
278
+ */
279
+ static consumptionQuantileKwh(samples, quantile, minimumSamples) {
280
+ if (!Number.isFinite(quantile) || !Number.isFinite(minimumSamples)) {
281
+ return undefined;
282
+ }
283
+ const values = samples
284
+ .map((sample) => sample.consumedKwh)
285
+ .filter((value) => Number.isFinite(value))
286
+ .sort((a, b) => a - b);
287
+ if (values.length === 0 || values.length < minimumSamples) {
288
+ return undefined;
289
+ }
290
+ // Linear interpolation between the two neighbouring order statistics, so the median of an even
291
+ // count is the mean of the two middle values.
292
+ const position = Math.min(Math.max(quantile, 0), 1) * (values.length - 1);
293
+ const lower = Math.floor(position);
294
+ const upper = Math.ceil(position);
295
+ if (lower === upper) {
296
+ return values[lower];
297
+ }
298
+ return values[lower] + (position - lower) * (values[upper] - values[lower]);
299
+ }
300
+ /**
301
+ * One window sum per historical day: for the same time of day window as the one handed in, the sum
302
+ * of that day's readings inside it.
303
+ *
304
+ * This is the step that makes the uncertainty measurable rather than assumed. The expected
305
+ * consumption is the median of the returned sums, the bound their upper quantile - both read with
306
+ * consumptionQuantileKwh. The quantile belongs on the sums and never on the individual readings:
307
+ * summing per reading quantiles would apply the safety margin once per interval instead of once per
308
+ * window, which is markedly more.
309
+ *
310
+ * A day whose readings do not cover the window is dropped, not patched. Its sum would be too small
311
+ * and would therefore look like a frugal night, pulling the median down and making the bound too
312
+ * optimistic - the direction that suppresses a start the house needed.
313
+ *
314
+ * Windows longer than a calendar day are allowed. "The same window one day earlier" then overlaps
315
+ * its own neighbour, and a reading in the shared stretch belongs to both occurrences - so it is
316
+ * counted in both sums, because each sum answers "what did this window consume" and both windows
317
+ * really did contain it. Only a partition would have to choose, and no partition is needed here.
318
+ * @param readings - The persisted consumption readings of one measuring interval each, in any order.
319
+ * Each is dated at the END of the interval it closes, the way the persistence hands them over.
320
+ * @param fromMs - Start of the window under evaluation.
321
+ * @param untilMs - End of the window under evaluation.
322
+ * @param readingIntervalMs - Length of one measuring interval, so that the expected number of
323
+ * readings per window follows from the window rather than from the data.
324
+ * @param minimumCoverage - Share of the expected readings a day must carry to be counted, 0 to 1.
325
+ * @returns One sum per sufficiently covered day, ascending by window start; empty when none
326
+ * qualifies. Each returned sample is dated at the START of its window.
327
+ */
328
+ static windowConsumptionSums(readings, fromMs, untilMs, readingIntervalMs, minimumCoverage) {
329
+ var _a;
330
+ if (!Number.isFinite(fromMs) || !Number.isFinite(untilMs) || untilMs <= fromMs) {
331
+ return [];
332
+ }
333
+ const windowMs = untilMs - fromMs;
334
+ if (!Number.isFinite(readingIntervalMs) || readingIntervalMs <= 0 || !Number.isFinite(minimumCoverage)) {
335
+ return [];
336
+ }
337
+ const expectedReadings = Math.round(windowMs / readingIntervalMs);
338
+ if (expectedReadings <= 0) {
339
+ return [];
340
+ }
341
+ const usable = readings.filter((reading) => { var _a; return Number.isFinite((_a = reading.date) === null || _a === void 0 ? void 0 : _a.getTime()) && Number.isFinite(reading.consumedKwh); });
342
+ if (usable.length === 0) {
343
+ return [];
344
+ }
345
+ let earliestMs = Number.POSITIVE_INFINITY;
346
+ let latestMs = Number.NEGATIVE_INFINITY;
347
+ for (const reading of usable) {
348
+ const readingMs = reading.date.getTime();
349
+ earliestMs = Math.min(earliestMs, readingMs);
350
+ latestMs = Math.max(latestMs, readingMs);
351
+ }
352
+ // One occurrence of the same wall clock window per calendar day the readings can reach. Shifting
353
+ // by whole calendar days rather than by 86400000 ms is what keeps the window at the same time of
354
+ // day across a daylight saving change - fixed millisecond steps move every occurrence on the far
355
+ // side of the change by an hour, which costs those days part of their coverage and drops them.
356
+ // Days drop out on one side of the change, so the surviving sample would be biased by season.
357
+ const occurrences = new Map();
358
+ const firstOffset = Math.floor((earliestMs - untilMs) / MILLISECONDS_PER_DAY) - 1;
359
+ const lastOffset = Math.ceil((latestMs - fromMs) / MILLISECONDS_PER_DAY) + 1;
360
+ for (let offset = firstOffset; offset <= lastOffset; offset++) {
361
+ occurrences.set(offset, {
362
+ fromMs: EnergyHistoryUtils.shiftCalendarDays(fromMs, offset),
363
+ untilMs: EnergyHistoryUtils.shiftCalendarDays(untilMs, offset),
364
+ });
365
+ }
366
+ // How many neighbouring occurrences a reading can reach: one per calendar day the window spans,
367
+ // plus one for the 23, 24 or 25 hour spread of a calendar day itself.
368
+ const reachableOffsets = Math.ceil(windowMs / MILLISECONDS_PER_DAY) + 1;
369
+ const perDay = new Map();
370
+ for (const reading of usable) {
371
+ const readingMs = reading.date.getTime();
372
+ const estimate = Math.round((readingMs - fromMs) / MILLISECONDS_PER_DAY);
373
+ // No early exit: a reading inside two overlapping occurrences counts towards both sums.
374
+ for (let offset = estimate - reachableOffsets; offset <= estimate + reachableOffsets; offset++) {
375
+ const occurrence = occurrences.get(offset);
376
+ // Half open (from, until], the convention iPersist.getEnergyConsumptionHistory documents: a
377
+ // reading is dated at the END of the interval it closes, so the one dated at `from` closes the
378
+ // interval before the window and the one dated at `until` closes the window's last interval.
379
+ // Reversing the two edges keeps the reading count and therefore the coverage intact, so the
380
+ // sum would silently describe a window shifted by one interval.
381
+ if (occurrence === undefined || readingMs <= occurrence.fromMs || readingMs > occurrence.untilMs) {
382
+ continue;
383
+ }
384
+ const bucket = (_a = perDay.get(offset)) !== null && _a !== void 0 ? _a : { sum: 0, count: 0 };
385
+ bucket.sum += reading.consumedKwh;
386
+ bucket.count++;
387
+ perDay.set(offset, bucket);
388
+ }
389
+ }
390
+ return [...perDay.entries()]
391
+ .filter(([, bucket]) => bucket.count / expectedReadings >= minimumCoverage)
392
+ .sort((a, b) => a[0] - b[0])
393
+ .map(([offset, bucket]) => ({
394
+ consumedKwh: bucket.sum,
395
+ date: new Date(EnergyHistoryUtils.shiftCalendarDays(fromMs, offset)),
396
+ }));
397
+ }
398
+ /**
399
+ * The same moment shifted by whole calendar days, keeping its time of day.
400
+ * @param ms - The moment to shift.
401
+ * @param days - How many calendar days to add; negative shifts into the past.
402
+ * @returns The shifted moment in milliseconds.
403
+ */
404
+ static shiftCalendarDays(ms, days) {
405
+ const shifted = new Date(ms);
406
+ shifted.setDate(shifted.getDate() + days);
407
+ return shifted.getTime();
408
+ }
409
+ static isUsableSample(sample) {
410
+ return (Number.isFinite(sample.observedDelta) &&
411
+ EnergyHistoryUtils.toFeatureVector(sample.features).every((value) => Number.isFinite(value)));
412
+ }
413
+ static toFeatureVector(features) {
414
+ return [features.remainingSunHours, features.cloudCover, features.consumedSoFarKwh, features.maxTemperature];
415
+ }
416
+ static columnMeans(rows) {
417
+ const means = new Array(FEATURE_COUNT).fill(0);
418
+ for (const row of rows) {
419
+ for (let column = 0; column < FEATURE_COUNT; column++) {
420
+ means[column] += row[column];
421
+ }
422
+ }
423
+ return means.map((sum) => sum / rows.length);
424
+ }
425
+ static zeroMatrix(size) {
426
+ return Array.from({ length: size }, () => new Array(size).fill(0));
427
+ }
428
+ /**
429
+ * Gaussian elimination with partial pivoting.
430
+ * @param matrix - The square coefficient matrix of the normal equations.
431
+ * @param vector - The right hand side.
432
+ * @returns The solution, or `undefined` when the system has no unique one.
433
+ */
434
+ static solve(matrix, vector) {
435
+ const size = vector.length;
436
+ const work = matrix.map((row, index) => [...row, vector[index]]);
437
+ const scale = Math.max(...work.flat().map((value) => Math.abs(value)), 1);
438
+ const tolerance = scale * 1e-12;
439
+ for (let column = 0; column < size; column++) {
440
+ let pivotRow = column;
441
+ for (let row = column + 1; row < size; row++) {
442
+ if (Math.abs(work[row][column]) > Math.abs(work[pivotRow][column])) {
443
+ pivotRow = row;
444
+ }
445
+ }
446
+ if (Math.abs(work[pivotRow][column]) < tolerance) {
447
+ // Rank deficient: at least two features moved in lockstep, so their weights cannot be told
448
+ // apart. No model at all is the honest answer.
449
+ return undefined;
450
+ }
451
+ [work[column], work[pivotRow]] = [work[pivotRow], work[column]];
452
+ for (let row = column + 1; row < size; row++) {
453
+ const factor = work[row][column] / work[column][column];
454
+ for (let target = column; target <= size; target++) {
455
+ work[row][target] -= factor * work[column][target];
456
+ }
457
+ }
458
+ }
459
+ const solution = new Array(size).fill(0);
460
+ for (let row = size - 1; row >= 0; row--) {
461
+ let sum = work[row][size];
462
+ for (let column = row + 1; column < size; column++) {
463
+ sum -= work[row][column] * solution[column];
464
+ }
465
+ solution[row] = sum / work[row][row];
466
+ }
467
+ return solution.every((value) => Number.isFinite(value)) ? solution : undefined;
468
+ }
469
+ }
470
+ exports.EnergyHistoryUtils = EnergyHistoryUtils;