@enyo-energy/energy-app-sdk 0.0.181 → 0.0.183

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 (58) hide show
  1. package/README.md +45 -1
  2. package/dist/cjs/energy-app-package-definition.d.cts +20 -0
  3. package/dist/cjs/implementations/forecasts/air-conditioning-consumption-forecast.cjs +221 -0
  4. package/dist/cjs/implementations/forecasts/air-conditioning-consumption-forecast.d.cts +94 -0
  5. package/dist/cjs/implementations/forecasts/air-conditioning-room-temperature-forecast.cjs +234 -0
  6. package/dist/cjs/implementations/forecasts/air-conditioning-room-temperature-forecast.d.cts +97 -0
  7. package/dist/cjs/implementations/onboarding-v2/define-onboarding-guide-v2.cjs +184 -0
  8. package/dist/cjs/implementations/onboarding-v2/define-onboarding-guide-v2.d.cts +131 -0
  9. package/dist/cjs/implementations/onboarding-v2/onboarding-v2-validators.cjs +181 -0
  10. package/dist/cjs/implementations/onboarding-v2/onboarding-v2-validators.d.cts +50 -0
  11. package/dist/cjs/index.cjs +7 -0
  12. package/dist/cjs/index.d.cts +7 -0
  13. package/dist/cjs/integrations/energy-manager-energy-app.cjs +53 -0
  14. package/dist/cjs/integrations/energy-manager-energy-app.d.cts +21 -0
  15. package/dist/cjs/packages/energy-app-diagnostics.d.cts +84 -3
  16. package/dist/cjs/packages/energy-app-onboarding.d.cts +15 -0
  17. package/dist/cjs/packages/energy-app-timeseries.d.cts +11 -3
  18. package/dist/cjs/types/enyo-charger-appliance.d.cts +13 -0
  19. package/dist/cjs/types/enyo-data-bus-value.cjs +2 -0
  20. package/dist/cjs/types/enyo-data-bus-value.d.cts +63 -0
  21. package/dist/cjs/types/enyo-flexibility-announcement.cjs +93 -0
  22. package/dist/cjs/types/enyo-flexibility-announcement.d.cts +284 -0
  23. package/dist/cjs/types/enyo-onboarding-v2.cjs +134 -0
  24. package/dist/cjs/types/enyo-onboarding-v2.d.cts +305 -0
  25. package/dist/cjs/types/enyo-onboarding.cjs +7 -0
  26. package/dist/cjs/types/enyo-onboarding.d.cts +20 -0
  27. package/dist/cjs/types/enyo-timeseries.d.cts +45 -3
  28. package/dist/cjs/version.cjs +1 -1
  29. package/dist/cjs/version.d.cts +1 -1
  30. package/dist/energy-app-package-definition.d.ts +20 -0
  31. package/dist/implementations/forecasts/air-conditioning-consumption-forecast.d.ts +94 -0
  32. package/dist/implementations/forecasts/air-conditioning-consumption-forecast.js +217 -0
  33. package/dist/implementations/forecasts/air-conditioning-room-temperature-forecast.d.ts +97 -0
  34. package/dist/implementations/forecasts/air-conditioning-room-temperature-forecast.js +230 -0
  35. package/dist/implementations/onboarding-v2/define-onboarding-guide-v2.d.ts +131 -0
  36. package/dist/implementations/onboarding-v2/define-onboarding-guide-v2.js +177 -0
  37. package/dist/implementations/onboarding-v2/onboarding-v2-validators.d.ts +50 -0
  38. package/dist/implementations/onboarding-v2/onboarding-v2-validators.js +175 -0
  39. package/dist/index.d.ts +7 -0
  40. package/dist/index.js +7 -0
  41. package/dist/integrations/energy-manager-energy-app.d.ts +21 -0
  42. package/dist/integrations/energy-manager-energy-app.js +53 -0
  43. package/dist/packages/energy-app-diagnostics.d.ts +84 -3
  44. package/dist/packages/energy-app-onboarding.d.ts +15 -0
  45. package/dist/packages/energy-app-timeseries.d.ts +11 -3
  46. package/dist/types/enyo-charger-appliance.d.ts +13 -0
  47. package/dist/types/enyo-data-bus-value.d.ts +63 -0
  48. package/dist/types/enyo-data-bus-value.js +2 -0
  49. package/dist/types/enyo-flexibility-announcement.d.ts +284 -0
  50. package/dist/types/enyo-flexibility-announcement.js +90 -0
  51. package/dist/types/enyo-onboarding-v2.d.ts +305 -0
  52. package/dist/types/enyo-onboarding-v2.js +131 -0
  53. package/dist/types/enyo-onboarding.d.ts +20 -0
  54. package/dist/types/enyo-onboarding.js +7 -0
  55. package/dist/types/enyo-timeseries.d.ts +45 -3
  56. package/dist/version.d.ts +1 -1
  57. package/dist/version.js +1 -1
  58. package/package.json +1 -1
package/README.md CHANGED
@@ -49,6 +49,8 @@ The official TypeScript SDK for building Energy Apps on the enyo platform. Creat
49
49
  - [EvChargingForecast](#evchargingforecast)
50
50
  - [HeatpumpConsumptionForecast](#heatpumpconsumptionforecast)
51
51
  - [HeatpumpDhwTemperatureForecast](#heatpumpdhwtemperatureforecast)
52
+ - [AirConditioningConsumptionForecast](#airconditioningconsumptionforecast)
53
+ - [AirConditioningRoomTemperatureForecast](#airconditioningroomtemperatureforecast)
52
54
  - [Appliance Energy-Manager Forecast](#appliance-energy-manager-forecast)
53
55
  - [`useApplianceEnergyManagerForecast()`](#useapplianceenergymanagerforecast-energyappapplianceenergymanagerforecast)
54
56
  - [ChargerForecast](#chargerforecast)
@@ -144,6 +146,8 @@ The SDK exposes several layered building blocks. Pick the one that matches the k
144
146
  | Forecast EV charging demand | [`EvChargingForecast`](#evchargingforecast) |
145
147
  | Forecast heatpump electrical consumption | [`HeatpumpConsumptionForecast`](#heatpumpconsumptionforecast) |
146
148
  | Forecast heatpump DHW tank temperature | [`HeatpumpDhwTemperatureForecast`](#heatpumpdhwtemperatureforecast) |
149
+ | Forecast air conditioning electrical consumption | [`AirConditioningConsumptionForecast`](#airconditioningconsumptionforecast) |
150
+ | Forecast air conditioning room temperature | [`AirConditioningRoomTemperatureForecast`](#airconditioningroomtemperatureforecast) |
147
151
  | Announce a charger / battery / heatpump command plan you **intend to apply** | [`useApplianceEnergyManagerForecast()`](#useapplianceenergymanagerforecast-energyappapplianceenergymanagerforecast) |
148
152
  | Talk to an EEBUS / SHIP / SPINE device | [`useEebus()`](#useeebus-energyappeebus) |
149
153
  | Speak MQTT (SDK broker or external) | [`useMqtt()`](#usemqtt-energyappmqtt) |
@@ -256,6 +260,14 @@ const packageDef = defineEnergyAppPackage({
256
260
  registerSize: 2,
257
261
  type: 'string',
258
262
  matchingValues: ['SolarMax', 'SMA']
263
+ }],
264
+ mdns: [{
265
+ // The Envoy advertises under a vendor-specific service type; without
266
+ // `serviceType` the host never browses it and this rule can't fire.
267
+ serviceType: '_enphase-envoy._tcp.local',
268
+ key: 'serialnum',
269
+ operation: 'startsWith',
270
+ matchingValues: ['1224']
259
271
  }]
260
272
  }
261
273
  }
@@ -1744,6 +1756,8 @@ new EnergyManagerEnergyApp({
1744
1756
  - `getEvChargingForecast(applianceId, config?)`
1745
1757
  - `getHeatpumpConsumptionForecast(applianceId, config?)`
1746
1758
  - `getHeatpumpDhwTemperatureForecast(applianceId, config?)`
1759
+ - `getAirConditioningConsumptionForecast(applianceId, config?)`
1760
+ - `getAirConditioningRoomTemperatureForecast(applianceId, config?)`
1747
1761
 
1748
1762
  **Lifecycle**
1749
1763
 
@@ -1761,7 +1775,7 @@ const batteryForecast = battery.getForecast();
1761
1775
 
1762
1776
  ## Forecasting
1763
1777
 
1764
- The forecasting module provides 24-hour predictions across the energy domains the SDK already understands (PV, battery, home consumption, EV charging, heatpump consumption, DHW temperature). Every forecaster follows the same lifecycle and shares the same configuration shape, so once you've used one you've used them all.
1778
+ The forecasting module provides 24-hour predictions across the energy domains the SDK already understands (PV, battery, home consumption, EV charging, heatpump consumption, DHW temperature, air conditioning consumption, air conditioning room temperature). Every forecaster follows the same lifecycle and shares the same configuration shape, so once you've used one you've used them all.
1765
1779
 
1766
1780
  ### ✨ Common pattern
1767
1781
 
@@ -1880,6 +1894,36 @@ new HeatpumpDhwTemperatureForecast(app, applianceId, {
1880
1894
  - **Notable config:** `dhwTankIndex` selects a specific tank (zero-based); omit to average across all tanks.
1881
1895
  - **Live source:** heatpump temperature timeseries / live updates.
1882
1896
 
1897
+ ### AirConditioningConsumptionForecast
1898
+
1899
+ Forecasts the electrical consumption of an air conditioning appliance (cooling + heating combined).
1900
+
1901
+ ```typescript
1902
+ new AirConditioningConsumptionForecast(app, applianceId, { source: EnyoSourceEnum.Device, config? });
1903
+ ```
1904
+
1905
+ - **Output per slot:** `{ powerW: number; powerWh: number }`
1906
+ - **History default:** 14 days — AC load is weather-driven and bursty, so a wider window than the heatpump's 7 days gives a more stable per-slot profile.
1907
+ - **Live source:** `AirConditioningValuesUpdateV1`.
1908
+ - **Note:** `powerW` is optional on that message; updates without a power reading are ignored rather than counted as `0 W`, so an appliance that only reports its operation mode does not drag the forecast towards zero.
1909
+ - **Note:** like the heatpump forecaster, it does not adjust for forecasted weather; layer a correction on top if you need that.
1910
+
1911
+ ### AirConditioningRoomTemperatureForecast
1912
+
1913
+ Forecasts the room temperature served by an air conditioning appliance — the input you need to plan pre-cooling on forecasted PV surplus.
1914
+
1915
+ ```typescript
1916
+ new AirConditioningRoomTemperatureForecast(app, applianceId, {
1917
+ source: EnyoSourceEnum.Device,
1918
+ config?: { roomIndex?: number, ...ForecastConfig }
1919
+ });
1920
+ ```
1921
+
1922
+ - **Output per slot:** `{ temperatureC: number }` (rounded to 0.1 °C).
1923
+ - **History default:** 7 days.
1924
+ - **Notable config:** `roomIndex` selects a specific room (zero-based); omit to average across all rooms. When set, it is echoed back on the published message's `data.roomIndex`.
1925
+ - **Live source:** `AirConditioningTemperaturesUpdateV1`.
1926
+
1883
1927
  ### 🚀 Common usage example
1884
1928
 
1885
1929
  ```typescript
@@ -84,6 +84,26 @@ export interface EnergyAppPackageOptionsDeviceDetectionMdns {
84
84
  operation: 'eq' | 'startsWith';
85
85
  /** Values to match the TXT record value against */
86
86
  matchingValues: string[];
87
+ /**
88
+ * The DNS-SD service type the device advertises itself under, including the
89
+ * `.local` suffix — e.g. `'_enphase-envoy._tcp.local'`.
90
+ *
91
+ * The host browses a small built-in set of common service types
92
+ * (`_http._tcp`, `_https._tcp`, `_device-info._tcp`, `_modbus._tcp`). Devices
93
+ * advertising a vendor-specific type are invisible to that set, so their TXT
94
+ * records never reach detection. Declaring the type here adds it to the
95
+ * host's browse set — no host firmware update needed.
96
+ *
97
+ * When set, this rule only matches TXT records advertised by a service of
98
+ * this exact type. When omitted, the rule matches TXT records from any
99
+ * browsed service on the device (previous behaviour, unchanged).
100
+ *
101
+ * Find a device's type with `avahi-browse -a -t` (Linux) or
102
+ * `dns-sd -B _services._dns-sd._udp` (macOS).
103
+ *
104
+ * @example '_enphase-envoy._tcp.local'
105
+ */
106
+ serviceType?: string;
87
107
  }
88
108
  /**
89
109
  * Optional device detection configuration for MQTT-based detection.
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AirConditioningConsumptionForecast = void 0;
4
+ const enyo_data_bus_value_js_1 = require("../../types/enyo-data-bus-value.cjs");
5
+ const forecast_types_js_1 = require("./forecast-types.cjs");
6
+ const forecast_utils_js_1 = require("./forecast-utils.cjs");
7
+ /**
8
+ * Builds a 24-hour air conditioning electrical consumption forecast for a
9
+ * single air conditioning appliance (cooling and heating combined). Historical
10
+ * buckets are sourced from the dedicated
11
+ * `useTimeseries().getAirConditioningPowerTimeseries()` endpoint, and live
12
+ * `AirConditioningValuesUpdateV1` data-bus messages are merged in as new
13
+ * readings arrive.
14
+ *
15
+ * Air conditioning load follows the household's daily occupancy rhythm, so the
16
+ * algorithm uses a same-weekday recency-weighted average over the configured
17
+ * history window, with an all-weekday fallback. The default history window is
18
+ * 14 days (longer than the heatpump's 7): AC operation is weather-driven and
19
+ * bursty, so a wider window yields a more stable per-slot profile.
20
+ *
21
+ * Note: `powerW` is optional on `AirConditioningValuesUpdateV1`. Messages
22
+ * without a power reading are ignored rather than counted as `0 W`, so that an
23
+ * appliance that only reports its operation mode does not drag the forecast
24
+ * towards zero.
25
+ *
26
+ * Like the heatpump forecaster, this implementation does not adjust for
27
+ * forecasted outdoor temperature; layer a correction on top of the returned
28
+ * `powerW` values using the weather forecast for the same horizon if you need
29
+ * that.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * const forecast = new AirConditioningConsumptionForecast(app, 'ac-1', {
34
+ * source: EnyoSourceEnum.Device,
35
+ * });
36
+ * await forecast.initialize();
37
+ * const result = forecast.getForecast();
38
+ * ```
39
+ */
40
+ class AirConditioningConsumptionForecast {
41
+ app;
42
+ applianceId;
43
+ /**
44
+ * Default history window. AC load is weather-driven and bursty, so a longer
45
+ * window than the heatpump's is used to stabilise the per-slot profile.
46
+ */
47
+ static DEFAULT_HISTORY_DAYS = 14;
48
+ config;
49
+ history = [];
50
+ listenerId;
51
+ currentSlotMs;
52
+ currentSlotPowerSum = 0;
53
+ currentSlotSamples = 0;
54
+ initialized = false;
55
+ source;
56
+ /**
57
+ * @param app - The energy-app SDK instance.
58
+ * @param applianceId - Air conditioning appliance ID.
59
+ * @param options.source - Source identifier used for outgoing forecast messages.
60
+ * @param options.config - Optional config overrides; `historyDays` defaults to 14.
61
+ */
62
+ constructor(app, applianceId, options) {
63
+ this.app = app;
64
+ this.applianceId = applianceId;
65
+ this.config = (0, forecast_types_js_1.resolveForecastConfig)({ historyDays: AirConditioningConsumptionForecast.DEFAULT_HISTORY_DAYS }, options.config);
66
+ this.source = options.source;
67
+ }
68
+ /**
69
+ * Loads historical air conditioning power buckets via
70
+ * `useTimeseries().getAirConditioningPowerTimeseries()` and starts listening
71
+ * to live `AirConditioningValuesUpdateV1` events for ongoing updates.
72
+ * Idempotent.
73
+ */
74
+ async initialize() {
75
+ if (this.initialized)
76
+ return;
77
+ this.initialized = true;
78
+ const now = Date.now();
79
+ const startMs = now - this.config.historyDays * 24 * 60 * 60 * 1000;
80
+ const response = await this.app.useTimeseries().getAirConditioningPowerTimeseries({
81
+ startDateIso: new Date(startMs).toISOString(),
82
+ endDateIso: new Date(now).toISOString(),
83
+ applianceIds: [this.applianceId],
84
+ resolution: this.config.resolution,
85
+ });
86
+ for (const entry of response.entries) {
87
+ this.history.push({
88
+ timestampIso: entry.timestampIso,
89
+ values: {
90
+ powerW: entry.airConditioningPowerW,
91
+ powerWh: entry.airConditioningPowerWh,
92
+ samples: 1,
93
+ },
94
+ });
95
+ }
96
+ this.listenerId = this.app.useDataBus().listenForMessages([enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.AirConditioningValuesUpdateV1], (msg) => this.onAirConditioningValues(msg));
97
+ }
98
+ /**
99
+ * Returns the current forecast as an
100
+ * `EnyoDataBusAirConditioningConsumptionForecastV1`-shaped payload over the
101
+ * configured horizon.
102
+ */
103
+ getForecast() {
104
+ const now = Date.now();
105
+ this.flushCurrentSlot(now);
106
+ const parsed = (0, forecast_utils_js_1.parseHistory)(this.history);
107
+ const todayWeekday = new Date(now).getUTCDay();
108
+ const sameWeekdayMap = (0, forecast_utils_js_1.buildSlotMap)(parsed, (b) => b.weekday === todayWeekday);
109
+ const fallbackMap = (0, forecast_utils_js_1.buildSlotMap)(parsed);
110
+ const isoIndex = (0, forecast_utils_js_1.buildIsoIndex)(parsed, (v) => v.powerW);
111
+ const buckets = (0, forecast_utils_js_1.buildForecastSlots)(now, this.config.horizonHours, this.config.resolution).map((slotMs) => {
112
+ const key = formatSlotKey(slotMs);
113
+ const slotBuckets = sameWeekdayMap.get(key) ?? fallbackMap.get(key);
114
+ const avg = (0, forecast_utils_js_1.weightedAverageBySlot)(slotBuckets, now, this.config.historyDays, {
115
+ powerW: (v) => v.powerW,
116
+ });
117
+ const powerW = Math.max(0, Math.round(avg.powerW));
118
+ return {
119
+ timestampIso: new Date(slotMs).toISOString(),
120
+ payload: { powerW, powerWh: (0, forecast_utils_js_1.wToWhPer15Min)(powerW) },
121
+ };
122
+ });
123
+ if (this.config.alignToRecentActuals) {
124
+ (0, forecast_utils_js_1.alignForecastToRecentActuals)(buckets, (iso) => isoIndex.get(iso), 'powerW', ['powerW', 'powerWh']);
125
+ for (const b of buckets) {
126
+ b.payload.powerW = Math.max(0, Math.round(b.payload.powerW));
127
+ b.payload.powerWh = (0, forecast_utils_js_1.wToWhPer15Min)(b.payload.powerW);
128
+ }
129
+ }
130
+ const entries = buckets.map((b) => ({
131
+ timestampIso: b.timestampIso,
132
+ powerW: b.payload.powerW,
133
+ powerWh: b.payload.powerWh,
134
+ }));
135
+ const result = {
136
+ generatedAtIso: new Date(now).toISOString(),
137
+ data: { resolution: this.config.resolution, entries },
138
+ };
139
+ if (this.config.publishToBus) {
140
+ this.publish(result);
141
+ }
142
+ return result;
143
+ }
144
+ /**
145
+ * Computes and explicitly publishes the current forecast on the data bus.
146
+ */
147
+ publishForecast() {
148
+ const result = this.getForecast();
149
+ if (!this.config.publishToBus) {
150
+ this.publish(result);
151
+ }
152
+ }
153
+ /**
154
+ * Detaches data-bus listeners and clears in-memory history.
155
+ */
156
+ dispose() {
157
+ if (this.listenerId) {
158
+ this.app.useDataBus().unsubscribe(this.listenerId);
159
+ this.listenerId = undefined;
160
+ }
161
+ this.history.length = 0;
162
+ this.initialized = false;
163
+ }
164
+ onAirConditioningValues(message) {
165
+ if (message.applianceId !== this.applianceId)
166
+ return;
167
+ const power = message.data.values.powerW;
168
+ if (typeof power !== 'number' || Number.isNaN(power))
169
+ return;
170
+ const ts = new Date(message.timestampIso).getTime();
171
+ const slotMs = (0, forecast_utils_js_1.roundDownTo15Minutes)(ts);
172
+ if (this.currentSlotMs === undefined || slotMs !== this.currentSlotMs) {
173
+ this.flushCurrentSlot(ts);
174
+ this.currentSlotMs = slotMs;
175
+ this.currentSlotPowerSum = 0;
176
+ this.currentSlotSamples = 0;
177
+ }
178
+ this.currentSlotPowerSum += power;
179
+ this.currentSlotSamples += 1;
180
+ }
181
+ flushCurrentSlot(nowMs) {
182
+ if (this.currentSlotMs === undefined || this.currentSlotSamples === 0)
183
+ return;
184
+ const avg = this.currentSlotPowerSum / this.currentSlotSamples;
185
+ (0, forecast_utils_js_1.upsertLiveBucket)(this.history, this.currentSlotMs, { powerW: avg, powerWh: (0, forecast_utils_js_1.wToWhPer15Min)(avg), samples: this.currentSlotSamples }, (existing, incoming) => {
186
+ const total = existing.samples + incoming.samples;
187
+ const mergedPowerW = (existing.powerW * existing.samples + incoming.powerW * incoming.samples) / total;
188
+ return {
189
+ powerW: mergedPowerW,
190
+ powerWh: (0, forecast_utils_js_1.wToWhPer15Min)(mergedPowerW),
191
+ samples: total,
192
+ };
193
+ });
194
+ if (this.currentSlotMs < (0, forecast_utils_js_1.roundDownTo15Minutes)(nowMs)) {
195
+ this.currentSlotMs = undefined;
196
+ this.currentSlotPowerSum = 0;
197
+ this.currentSlotSamples = 0;
198
+ }
199
+ (0, forecast_utils_js_1.trimHistory)(this.history, nowMs, this.config.historyDays * 24 * 60 * 60 * 1000 + forecast_utils_js_1.FIFTEEN_MIN_MS);
200
+ }
201
+ publish(result) {
202
+ const message = {
203
+ id: globalThis.crypto.randomUUID(),
204
+ type: 'message',
205
+ message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.AirConditioningConsumptionForecastV1,
206
+ source: this.source,
207
+ applianceId: this.applianceId,
208
+ timestampIso: result.generatedAtIso,
209
+ resolution: 'dynamic',
210
+ data: result.data,
211
+ };
212
+ this.app.useDataBus().sendMessage([message]);
213
+ }
214
+ }
215
+ exports.AirConditioningConsumptionForecast = AirConditioningConsumptionForecast;
216
+ function formatSlotKey(slotMs) {
217
+ const d = new Date(slotMs);
218
+ return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes()
219
+ .toString()
220
+ .padStart(2, '0')}`;
221
+ }
@@ -0,0 +1,94 @@
1
+ import { EnergyApp } from '../../energy-app.cjs';
2
+ import { EnyoDataBusAirConditioningConsumptionForecastV1 } from '../../types/enyo-data-bus-value.cjs';
3
+ import { EnyoSourceEnum } from '../../types/enyo-source.enum.cjs';
4
+ import { BaseForecast, Forecaster, ForecastConfig } from './forecast-types.cjs';
5
+ /**
6
+ * Forecast result returned by
7
+ * {@link AirConditioningConsumptionForecast.getForecast}, shaped to match
8
+ * `EnyoDataBusAirConditioningConsumptionForecastV1.data`.
9
+ */
10
+ export type AirConditioningConsumptionForecastResult = BaseForecast<EnyoDataBusAirConditioningConsumptionForecastV1['data']>;
11
+ /**
12
+ * Builds a 24-hour air conditioning electrical consumption forecast for a
13
+ * single air conditioning appliance (cooling and heating combined). Historical
14
+ * buckets are sourced from the dedicated
15
+ * `useTimeseries().getAirConditioningPowerTimeseries()` endpoint, and live
16
+ * `AirConditioningValuesUpdateV1` data-bus messages are merged in as new
17
+ * readings arrive.
18
+ *
19
+ * Air conditioning load follows the household's daily occupancy rhythm, so the
20
+ * algorithm uses a same-weekday recency-weighted average over the configured
21
+ * history window, with an all-weekday fallback. The default history window is
22
+ * 14 days (longer than the heatpump's 7): AC operation is weather-driven and
23
+ * bursty, so a wider window yields a more stable per-slot profile.
24
+ *
25
+ * Note: `powerW` is optional on `AirConditioningValuesUpdateV1`. Messages
26
+ * without a power reading are ignored rather than counted as `0 W`, so that an
27
+ * appliance that only reports its operation mode does not drag the forecast
28
+ * towards zero.
29
+ *
30
+ * Like the heatpump forecaster, this implementation does not adjust for
31
+ * forecasted outdoor temperature; layer a correction on top of the returned
32
+ * `powerW` values using the weather forecast for the same horizon if you need
33
+ * that.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * const forecast = new AirConditioningConsumptionForecast(app, 'ac-1', {
38
+ * source: EnyoSourceEnum.Device,
39
+ * });
40
+ * await forecast.initialize();
41
+ * const result = forecast.getForecast();
42
+ * ```
43
+ */
44
+ export declare class AirConditioningConsumptionForecast implements Forecaster {
45
+ private readonly app;
46
+ readonly applianceId: string;
47
+ /**
48
+ * Default history window. AC load is weather-driven and bursty, so a longer
49
+ * window than the heatpump's is used to stabilise the per-slot profile.
50
+ */
51
+ static readonly DEFAULT_HISTORY_DAYS = 14;
52
+ private readonly config;
53
+ private readonly history;
54
+ private listenerId;
55
+ private currentSlotMs;
56
+ private currentSlotPowerSum;
57
+ private currentSlotSamples;
58
+ private initialized;
59
+ private readonly source;
60
+ /**
61
+ * @param app - The energy-app SDK instance.
62
+ * @param applianceId - Air conditioning appliance ID.
63
+ * @param options.source - Source identifier used for outgoing forecast messages.
64
+ * @param options.config - Optional config overrides; `historyDays` defaults to 14.
65
+ */
66
+ constructor(app: EnergyApp, applianceId: string, options: {
67
+ source: EnyoSourceEnum;
68
+ config?: ForecastConfig;
69
+ });
70
+ /**
71
+ * Loads historical air conditioning power buckets via
72
+ * `useTimeseries().getAirConditioningPowerTimeseries()` and starts listening
73
+ * to live `AirConditioningValuesUpdateV1` events for ongoing updates.
74
+ * Idempotent.
75
+ */
76
+ initialize(): Promise<void>;
77
+ /**
78
+ * Returns the current forecast as an
79
+ * `EnyoDataBusAirConditioningConsumptionForecastV1`-shaped payload over the
80
+ * configured horizon.
81
+ */
82
+ getForecast(): AirConditioningConsumptionForecastResult;
83
+ /**
84
+ * Computes and explicitly publishes the current forecast on the data bus.
85
+ */
86
+ publishForecast(): void;
87
+ /**
88
+ * Detaches data-bus listeners and clears in-memory history.
89
+ */
90
+ dispose(): void;
91
+ private onAirConditioningValues;
92
+ private flushCurrentSlot;
93
+ private publish;
94
+ }
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AirConditioningRoomTemperatureForecast = void 0;
4
+ const enyo_data_bus_value_js_1 = require("../../types/enyo-data-bus-value.cjs");
5
+ const forecast_types_js_1 = require("./forecast-types.cjs");
6
+ const forecast_utils_js_1 = require("./forecast-utils.cjs");
7
+ /**
8
+ * Builds a 24-hour room temperature forecast for a single air conditioning
9
+ * appliance from historical air conditioning temperature timeseries data, with
10
+ * live updates merged in from `AirConditioningTemperaturesUpdateV1` data-bus
11
+ * events.
12
+ *
13
+ * Room temperature follows the household's daily occupancy and setpoint
14
+ * rhythm, which is strongly weekday-cyclic, so the algorithm uses a
15
+ * same-weekday recency-weighted average over the configured history window
16
+ * (default 7 d), with an all-weekday fallback. A single room can be selected
17
+ * via {@link AirConditioningRoomTemperatureForecastConfig.roomIndex};
18
+ * otherwise all reported rooms are averaged per timestamp.
19
+ *
20
+ * The forecast is the primary input for planning AC flexibility — for example
21
+ * pre-cooling on forecasted PV surplus — since it says where the room
22
+ * temperature is heading if nothing changes.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const forecast = new AirConditioningRoomTemperatureForecast(app, 'ac-1', {
27
+ * source: EnyoSourceEnum.Device,
28
+ * config: { roomIndex: 0 },
29
+ * });
30
+ * await forecast.initialize();
31
+ * const result = forecast.getForecast();
32
+ * ```
33
+ */
34
+ class AirConditioningRoomTemperatureForecast {
35
+ app;
36
+ applianceId;
37
+ /** Default history window. Room temperature cycles are weekday-cyclic. */
38
+ static DEFAULT_HISTORY_DAYS = 7;
39
+ config;
40
+ history = [];
41
+ roomIndex;
42
+ listenerId;
43
+ currentSlotMs;
44
+ currentSlotTempSum = 0;
45
+ currentSlotSamples = 0;
46
+ initialized = false;
47
+ source;
48
+ /**
49
+ * @param app - The energy-app SDK instance.
50
+ * @param applianceId - Air conditioning appliance ID.
51
+ * @param options.source - Source identifier used for outgoing forecast messages.
52
+ * @param options.config - Optional config overrides; `historyDays` defaults to 7.
53
+ */
54
+ constructor(app, applianceId, options) {
55
+ this.app = app;
56
+ this.applianceId = applianceId;
57
+ this.config = (0, forecast_types_js_1.resolveForecastConfig)({ historyDays: AirConditioningRoomTemperatureForecast.DEFAULT_HISTORY_DAYS }, options.config);
58
+ this.source = options.source;
59
+ this.roomIndex = options.config?.roomIndex;
60
+ }
61
+ /**
62
+ * Pulls the air conditioning temperature timeseries history and starts
63
+ * listening to live `AirConditioningTemperaturesUpdateV1` events.
64
+ * Idempotent.
65
+ */
66
+ async initialize() {
67
+ if (this.initialized)
68
+ return;
69
+ this.initialized = true;
70
+ const now = Date.now();
71
+ const startMs = now - this.config.historyDays * 24 * 60 * 60 * 1000;
72
+ const response = await this.app.useTimeseries().getAirConditioningTemperatureTimeseries({
73
+ startDateIso: new Date(startMs).toISOString(),
74
+ endDateIso: new Date(now).toISOString(),
75
+ applianceIds: [this.applianceId],
76
+ resolution: this.config.resolution,
77
+ });
78
+ for (const entry of response.entries) {
79
+ const temperature = this.extractHistoricalTemperature(entry.rooms);
80
+ if (temperature === undefined)
81
+ continue;
82
+ this.history.push({
83
+ timestampIso: entry.timestampIso,
84
+ values: { temperatureC: temperature, samples: 1 },
85
+ });
86
+ }
87
+ this.listenerId = this.app.useDataBus().listenForMessages([enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.AirConditioningTemperaturesUpdateV1], (msg) => this.onAirConditioningTemperatures(msg));
88
+ }
89
+ /**
90
+ * Returns the current forecast as an
91
+ * `EnyoDataBusAirConditioningRoomTemperatureForecastV1`-shaped payload over
92
+ * the configured horizon.
93
+ */
94
+ getForecast() {
95
+ const now = Date.now();
96
+ this.flushCurrentSlot(now);
97
+ const parsed = (0, forecast_utils_js_1.parseHistory)(this.history);
98
+ const todayWeekday = new Date(now).getUTCDay();
99
+ const sameWeekdayMap = (0, forecast_utils_js_1.buildSlotMap)(parsed, (b) => b.weekday === todayWeekday);
100
+ const fallbackMap = (0, forecast_utils_js_1.buildSlotMap)(parsed);
101
+ const isoIndex = (0, forecast_utils_js_1.buildIsoIndex)(parsed, (v) => v.temperatureC);
102
+ const buckets = (0, forecast_utils_js_1.buildForecastSlots)(now, this.config.horizonHours, this.config.resolution).map((slotMs) => {
103
+ const key = formatSlotKey(slotMs);
104
+ const slotBuckets = sameWeekdayMap.get(key) ?? fallbackMap.get(key);
105
+ const avg = (0, forecast_utils_js_1.weightedAverageBySlot)(slotBuckets, now, this.config.historyDays, {
106
+ temperatureC: (v) => v.temperatureC,
107
+ });
108
+ return {
109
+ timestampIso: new Date(slotMs).toISOString(),
110
+ payload: { temperatureC: avg.temperatureC },
111
+ };
112
+ });
113
+ if (this.config.alignToRecentActuals) {
114
+ (0, forecast_utils_js_1.alignForecastToRecentActuals)(buckets, (iso) => isoIndex.get(iso), 'temperatureC', ['temperatureC']);
115
+ }
116
+ const entries = buckets.map((b) => ({
117
+ timestampIso: b.timestampIso,
118
+ temperatureC: round1(b.payload.temperatureC),
119
+ }));
120
+ const result = {
121
+ generatedAtIso: new Date(now).toISOString(),
122
+ data: {
123
+ resolution: this.config.resolution,
124
+ ...(this.roomIndex !== undefined ? { roomIndex: this.roomIndex } : {}),
125
+ entries,
126
+ },
127
+ };
128
+ if (this.config.publishToBus) {
129
+ this.publish(result);
130
+ }
131
+ return result;
132
+ }
133
+ /**
134
+ * Computes and explicitly publishes the current forecast on the data bus.
135
+ */
136
+ publishForecast() {
137
+ const result = this.getForecast();
138
+ if (!this.config.publishToBus) {
139
+ this.publish(result);
140
+ }
141
+ }
142
+ /**
143
+ * Detaches data-bus listeners and clears in-memory history.
144
+ */
145
+ dispose() {
146
+ if (this.listenerId) {
147
+ this.app.useDataBus().unsubscribe(this.listenerId);
148
+ this.listenerId = undefined;
149
+ }
150
+ this.history.length = 0;
151
+ this.initialized = false;
152
+ }
153
+ onAirConditioningTemperatures(message) {
154
+ if (message.applianceId !== this.applianceId)
155
+ return;
156
+ const rooms = message.data.rooms;
157
+ if (!rooms || rooms.length === 0)
158
+ return;
159
+ const temperature = this.extractLiveTemperature(rooms);
160
+ if (temperature === undefined)
161
+ return;
162
+ const ts = new Date(message.timestampIso).getTime();
163
+ const slotMs = (0, forecast_utils_js_1.roundDownTo15Minutes)(ts);
164
+ if (this.currentSlotMs === undefined || slotMs !== this.currentSlotMs) {
165
+ this.flushCurrentSlot(ts);
166
+ this.currentSlotMs = slotMs;
167
+ this.currentSlotTempSum = 0;
168
+ this.currentSlotSamples = 0;
169
+ }
170
+ this.currentSlotTempSum += temperature;
171
+ this.currentSlotSamples += 1;
172
+ }
173
+ flushCurrentSlot(nowMs) {
174
+ if (this.currentSlotMs === undefined || this.currentSlotSamples === 0)
175
+ return;
176
+ const avg = this.currentSlotTempSum / this.currentSlotSamples;
177
+ (0, forecast_utils_js_1.upsertLiveBucket)(this.history, this.currentSlotMs, { temperatureC: avg, samples: this.currentSlotSamples }, (existing, incoming) => {
178
+ const total = existing.samples + incoming.samples;
179
+ return {
180
+ temperatureC: (existing.temperatureC * existing.samples + incoming.temperatureC * incoming.samples) / total,
181
+ samples: total,
182
+ };
183
+ });
184
+ if (this.currentSlotMs < (0, forecast_utils_js_1.roundDownTo15Minutes)(nowMs)) {
185
+ this.currentSlotMs = undefined;
186
+ this.currentSlotTempSum = 0;
187
+ this.currentSlotSamples = 0;
188
+ }
189
+ (0, forecast_utils_js_1.trimHistory)(this.history, nowMs, this.config.historyDays * 24 * 60 * 60 * 1000 + forecast_utils_js_1.FIFTEEN_MIN_MS);
190
+ }
191
+ extractHistoricalTemperature(rooms) {
192
+ if (!rooms || rooms.length === 0)
193
+ return undefined;
194
+ const matching = this.roomIndex !== undefined
195
+ ? rooms.filter((r) => r.index === this.roomIndex)
196
+ : rooms;
197
+ if (matching.length === 0)
198
+ return undefined;
199
+ const sum = matching.reduce((acc, r) => acc + r.averageTemperatureC, 0);
200
+ return sum / matching.length;
201
+ }
202
+ extractLiveTemperature(rooms) {
203
+ const matching = this.roomIndex !== undefined
204
+ ? rooms.filter((r) => r.index === this.roomIndex)
205
+ : rooms;
206
+ if (matching.length === 0)
207
+ return undefined;
208
+ const sum = matching.reduce((acc, r) => acc + r.temperatureC, 0);
209
+ return sum / matching.length;
210
+ }
211
+ publish(result) {
212
+ const message = {
213
+ id: globalThis.crypto.randomUUID(),
214
+ type: 'message',
215
+ message: enyo_data_bus_value_js_1.EnyoDataBusMessageEnum.AirConditioningRoomTemperatureForecastV1,
216
+ source: this.source,
217
+ applianceId: this.applianceId,
218
+ timestampIso: result.generatedAtIso,
219
+ resolution: 'dynamic',
220
+ data: result.data,
221
+ };
222
+ this.app.useDataBus().sendMessage([message]);
223
+ }
224
+ }
225
+ exports.AirConditioningRoomTemperatureForecast = AirConditioningRoomTemperatureForecast;
226
+ function round1(value) {
227
+ return Math.round(value * 10) / 10;
228
+ }
229
+ function formatSlotKey(slotMs) {
230
+ const d = new Date(slotMs);
231
+ return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes()
232
+ .toString()
233
+ .padStart(2, '0')}`;
234
+ }