juham-automation 0.0.2__py3-none-any.whl → 0.2.8__py3-none-any.whl

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.
@@ -0,0 +1,310 @@
1
+ from typing import Any
2
+ from typing_extensions import override
3
+ import json
4
+ from masterpiece.mqtt import MqttMsg
5
+ from juham_core import Juham
6
+ from juham_core.timeutils import (
7
+ elapsed_seconds_in_day,
8
+ elapsed_seconds_in_hour,
9
+ elapsed_seconds_in_interval,
10
+ quantize,
11
+ timestamp,
12
+ )
13
+
14
+
15
+ class EnergyCostCalculator(Juham):
16
+ """The EnergyCostCalculator class calculates the net energy balance and cost between produced
17
+ (or consumed) energy for Time-Based Settlement (TBS). It performs the following functions:
18
+
19
+ * Subscribes to 'spot' and 'power' MQTT topics.
20
+ * Calculates the net energy and the rate of change of the net energy per hour and per day (24h)
21
+ * Calculates the cost of energy consumed/produced based on the spot prices.
22
+ * Publishes the calculated values to the MQTT net energy balance and cost topics.
23
+
24
+
25
+ This information helps other home automation components optimize energy usage and
26
+ minimize electricity bills.
27
+ """
28
+
29
+ _kwh_to_joule_coeff: float = 1000.0 * 3600
30
+ _joule_to_kwh_coeff: float = 1.0 / _kwh_to_joule_coeff
31
+
32
+ energy_balancing_interval: int = 900 # in seconds (15 minutes)
33
+
34
+ def __init__(self, name: str = "ecc") -> None:
35
+ super().__init__(name)
36
+ self.current_ts: float = 0
37
+ self.total_balance_interval : float = 0
38
+ self.total_balance_hour: float = 0
39
+ self.total_balance_day: float = 0
40
+ self.net_energy_balance_cost_interval: float = 0
41
+ self.net_energy_balance_cost_hour: float = 0
42
+ self.net_energy_balance_cost_day: float = 0
43
+ self.net_energy_balance_start_interval : float = elapsed_seconds_in_interval(timestamp(), self.energy_balancing_interval)
44
+ self.net_energy_balance_start_hour : float = elapsed_seconds_in_hour(timestamp())
45
+ self.net_energy_balance_start_day : float = elapsed_seconds_in_day(timestamp())
46
+ self.spots: list[dict[str, float]] = []
47
+ self.init_topics()
48
+
49
+ def init_topics(self) -> None:
50
+ self.topic_in_spot = self.make_topic_name("spot")
51
+ self.topic_in_powerconsumption = self.make_topic_name("powerconsumption")
52
+ self.topic_out_net_energy_balance = self.make_topic_name("net_energy_balance")
53
+ self.topic_out_energy_cost = self.make_topic_name("net_energy_cost")
54
+
55
+ @override
56
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
57
+ super().on_connect(client, userdata, flags, rc)
58
+ if rc == 0:
59
+ self.subscribe(self.topic_in_spot)
60
+ self.subscribe(self.topic_in_powerconsumption)
61
+
62
+ @override
63
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
64
+ ts_now = timestamp()
65
+
66
+ m = json.loads(msg.payload.decode())
67
+ if msg.topic == self.topic_in_spot:
68
+ self.on_spot(m)
69
+ elif msg.topic == self.topic_in_powerconsumption:
70
+ self.on_powerconsumption(ts_now, m)
71
+ else:
72
+ super().on_message(client, userdata, msg)
73
+
74
+ def on_spot(self, spot: dict[Any, Any]) -> None:
75
+ """Stores the received per slot electricity prices to spots list.
76
+
77
+ Args:
78
+ spot (list): list of spot prices
79
+ """
80
+
81
+ for s in spot:
82
+ self.spots.append(
83
+ {"Timestamp": s["Timestamp"], "PriceWithTax": s["PriceWithTax"]}
84
+ )
85
+
86
+ def map_kwh_prices_to_joules(self, price: float) -> float:
87
+ """Convert the given electricity price in kWh to Watt seconds (J)
88
+ Args:
89
+ price (float): electricity price given as kWh
90
+ Returns:
91
+ Electricity price per watt second (J)
92
+ """
93
+ return price * self._joule_to_kwh_coeff
94
+
95
+
96
+ def get_price_at(self, ts: float) -> float:
97
+ """Return the spot price applicable at the given timestamp.
98
+
99
+ Args:
100
+ ts (float): current time (epoch seconds)
101
+
102
+ Returns:
103
+ float: PriceWithTax for the slot that contains ts. Returns the last
104
+ known price if ts is equal/after the last spot timestamp.
105
+ Returns 0.0 and logs an error if no matching slot is found.
106
+ """
107
+ if not self.spots:
108
+ self.error(f"PANIC: no spot prices available; lookup ts={ts}")
109
+ return 0.0
110
+
111
+ # ensure spots sorted by timestamp (defensive)
112
+ try:
113
+ # cheap check — assumes list of dicts with "Timestamp"
114
+ if any(self.spots[i]["Timestamp"] > self.spots[i + 1]["Timestamp"] for i in range(len(self.spots) - 1)):
115
+ self.spots.sort(key=lambda r: r["Timestamp"])
116
+ except Exception:
117
+ # if unexpected structure, still try safe path below and log
118
+ self.debug("get_price_at: spot list structure unexpected while checking sort order", "")
119
+
120
+ for i in range(0, len(self.spots) - 1):
121
+ r0 = self.spots[i]
122
+ r1 = self.spots[i + 1]
123
+ ts0 = r0["Timestamp"]
124
+ ts1 = r1["Timestamp"]
125
+ if ts >= ts0 and ts < ts1:
126
+ return r0["PriceWithTax"]
127
+
128
+ # If timestamp is exactly equal to the last spot timestamp or beyond
129
+ last = self.spots[-1]
130
+ if ts >= last["Timestamp"]:
131
+ return last["PriceWithTax"]
132
+
133
+ # If we get here, ts is before the first spot timestamp
134
+ first = self.spots[0]
135
+ self.error(
136
+ f"PANIC: Timestamp {ts} out of bounds for spot price lookup; "
137
+ f"first=(ts={first['Timestamp']}, price={first.get('PriceWithTax')}), "
138
+ f"last=(ts={last['Timestamp']}, price={last.get('PriceWithTax')}), "
139
+ f"len(spots)={len(self.spots)}"
140
+ )
141
+ return 0.0
142
+
143
+
144
+
145
+ def calculate_net_energy_cost(
146
+ self, ts_prev: float, ts_now: float, energy: float
147
+ ) -> float:
148
+ """
149
+ Calculate the cost (or revenue) of energy consumed/produced over the given time interval.
150
+ Positive values indicate revenue, negative values indicate cost.
151
+
152
+ Args:
153
+ ts_prev (float): Start timestamp of the interval
154
+ ts_now (float): End timestamp of the interval
155
+ energy (float): Energy consumed during the interval (in watts or Joules)
156
+
157
+ Returns:
158
+ float: Total cost/revenue for the interval
159
+ """
160
+ cost = 0.0
161
+ current = ts_prev
162
+ interval = self.energy_balancing_interval
163
+
164
+ while current < ts_now:
165
+ next_ts = min(ts_now, current + interval)
166
+ # Get spot price at start and end of interval
167
+ price_start = self.map_kwh_prices_to_joules(self.get_price_at(current))
168
+ price_end = self.map_kwh_prices_to_joules(self.get_price_at(next_ts))
169
+
170
+ # Trapezoidal integration: average price over interval
171
+ avg_price = (price_start + price_end) / 2.0
172
+ dt = next_ts - current
173
+ cost += energy * avg_price * dt
174
+
175
+ current = next_ts
176
+
177
+ return cost
178
+
179
+
180
+ def on_powerconsumption(self, ts_now: float, m: dict[Any, Any]) -> None:
181
+ """Calculate net energy cost and update the hourly consumption attribute
182
+ accordingly.
183
+
184
+ Args:
185
+ ts_now (float): time stamp of the energy consumed
186
+ m (dict): Juham MQTT message holding energy reading
187
+ """
188
+ power = m["real_total"]
189
+ if not self.spots:
190
+ self.info("Waiting for electricity prices...")
191
+ elif self.current_ts == 0:
192
+ self.net_energy_balance_cost_interval = 0.0
193
+ self.net_energy_balance_cost_hour = 0.0
194
+ self.net_energy_balance_cost_day = 0.0
195
+ self.current_ts = ts_now
196
+ self.net_energy_balance_start_interval = quantize(
197
+ self.energy_balancing_interval, ts_now
198
+ )
199
+ self.net_energy_balance_start_hour = quantize(
200
+ 3600, ts_now
201
+ )
202
+ else:
203
+ # calculate cost of energy consumed/produced
204
+ dp: float = self.calculate_net_energy_cost(self.current_ts, ts_now, power)
205
+ self.net_energy_balance_cost_interval = self.net_energy_balance_cost_interval + dp
206
+ self.net_energy_balance_cost_hour = self.net_energy_balance_cost_hour + dp
207
+ self.net_energy_balance_cost_day = self.net_energy_balance_cost_day + dp
208
+
209
+ # calculate and publish energy balance
210
+ dt = ts_now - self.current_ts # time elapsed since previous call
211
+ balance = dt * power # energy consumed/produced in this slot in Joules
212
+ self.total_balance_interval = (
213
+ self.total_balance_interval + balance * self._joule_to_kwh_coeff
214
+ )
215
+ self.total_balance_hour = (
216
+ self.total_balance_hour + balance * self._joule_to_kwh_coeff
217
+ )
218
+ self.total_balance_day = (
219
+ self.total_balance_day + balance * self._joule_to_kwh_coeff
220
+ )
221
+ self.publish_net_energy_balance(ts_now, self.name, balance, power)
222
+ self.publish_energy_cost(
223
+ ts_now,
224
+ self.name,
225
+ self.net_energy_balance_cost_interval,
226
+ self.net_energy_balance_cost_hour,
227
+ self.net_energy_balance_cost_day,
228
+ )
229
+
230
+ # Check if the current energy balancing interval has ended
231
+ # If so, reset the net_energy_balance attribute for the next interval
232
+ if ts_now - self.net_energy_balance_start_interval > self.energy_balancing_interval:
233
+ # publish average energy cost per hour
234
+ if abs(self.total_balance_interval) > 0:
235
+ msg = {
236
+ "name": self.name,
237
+ "average_interval": self.net_energy_balance_cost_interval
238
+ / self.total_balance_interval,
239
+ "ts": ts_now,
240
+ }
241
+ self.publish(self.topic_out_energy_cost, json.dumps(msg), 0, False)
242
+
243
+ # reset for the next hour
244
+ self.total_balance_interval = 0
245
+ self.net_energy_balance_cost_interval = 0.0
246
+ self.net_energy_balance_start_interval = ts_now
247
+
248
+ # Check if the current energy balancing interval has ended
249
+ # If so, reset the net_energy_balance attribute for the next interval
250
+ if ts_now - self.net_energy_balance_start_hour > 3600:
251
+ # publish average energy cost per hour
252
+ if abs(self.total_balance_hour) > 0:
253
+ msg = {
254
+ "name": self.name,
255
+ "average_hour": self.net_energy_balance_cost_hour
256
+ / self.total_balance_hour,
257
+ "ts": ts_now,
258
+ }
259
+ self.publish(self.topic_out_energy_cost, json.dumps(msg), 0, False)
260
+
261
+ # reset for the next hour
262
+ self.total_balance_hour = 0
263
+ self.net_energy_balance_cost_hour = 0.0
264
+ self.net_energy_balance_start_hour = ts_now
265
+
266
+ if ts_now - self.net_energy_balance_start_day > 24 * 3600:
267
+ if abs(self.total_balance_day) > 0:
268
+ msg = {
269
+ "name": self.name,
270
+ "average_day": self.net_energy_balance_cost_day
271
+ / self.total_balance_day,
272
+ "ts": ts_now,
273
+ }
274
+ self.publish(self.topic_out_energy_cost, json.dumps(msg), 0, False)
275
+ # reset for the next day
276
+ self.total_balance_day = 0
277
+ self.net_energy_balance_cost_day = 0.0
278
+ self.net_energy_balance_start_day = ts_now
279
+
280
+ self.current_ts = ts_now
281
+
282
+ def publish_net_energy_balance(
283
+ self, ts_now: float, site: str, energy: float, power: float
284
+ ) -> None:
285
+ """Publish the net energy balance for the current energy balancing interval, as well as
286
+ the real-time power at which energy is currently being produced or consumed (the
287
+ rate of change of net energy).
288
+
289
+ Args:
290
+ ts_now (float): timestamp
291
+ site (str): site
292
+ energy (float): cost or revenue.
293
+ power (float) : momentary power (rage of change of energy)
294
+ """
295
+ msg = {"site": site, "power": power, "energy": energy, "ts": ts_now}
296
+ self.publish(self.topic_out_net_energy_balance, json.dumps(msg), 1, True)
297
+
298
+ def publish_energy_cost(
299
+ self, ts_now: float, site: str, cost_interval : float, cost_hour: float, cost_day: float
300
+ ) -> None:
301
+ """Publish daily, hourly and per interval energy cost/revenue
302
+
303
+ Args:
304
+ ts_now (float): timestamp
305
+ site (str): site
306
+ cost_hour (float): cost or revenue per hour.
307
+ cost_day (float) : cost or revenue per day
308
+ """
309
+ msg = {"name": site, "cost_interval": cost_interval, "cost_hour": cost_hour, "cost_day": cost_day, "ts": ts_now}
310
+ self.publish(self.topic_out_energy_cost, json.dumps(msg), 1, True)