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.
- juham_automation/__init__.py +42 -37
- juham_automation/automation/__init__.py +23 -0
- juham_automation/automation/energybalancer.py +281 -0
- juham_automation/automation/energycostcalculator.py +310 -0
- juham_automation/automation/heatingoptimizer.py +971 -0
- juham_automation/automation/leakdetector.py +162 -0
- juham_automation/automation/spothintafi.py +140 -0
- juham_automation/automation/watercirculator.py +140 -0
- juham_automation/japp.py +53 -55
- juham_automation/ts/__init__.py +27 -0
- juham_automation/ts/electricityprice_ts.py +51 -0
- juham_automation/ts/energybalancer_ts.py +73 -0
- juham_automation/ts/energycostcalculator_ts.py +45 -0
- juham_automation/ts/forecast_ts.py +97 -0
- juham_automation/ts/log_ts.py +60 -0
- juham_automation/ts/power_ts.py +52 -0
- juham_automation/ts/powermeter_ts.py +68 -0
- juham_automation/ts/powerplan_ts.py +64 -0
- juham_automation-0.2.8.dist-info/METADATA +199 -0
- juham_automation-0.2.8.dist-info/RECORD +25 -0
- {juham_automation-0.0.2.dist-info → juham_automation-0.2.8.dist-info}/WHEEL +1 -1
- {juham_automation-0.0.2.dist-info → juham_automation-0.2.8.dist-info}/entry_points.txt +4 -2
- {juham_automation-0.0.2.dist-info → juham_automation-0.2.8.dist-info/licenses}/LICENSE.rst +25 -25
- juham_automation-0.0.2.dist-info/METADATA +0 -103
- juham_automation-0.0.2.dist-info/RECORD +0 -9
- {juham_automation-0.0.2.dist-info → juham_automation-0.2.8.dist-info}/top_level.txt +0 -0
|
@@ -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)
|