juham-automation 0.0.31__py3-none-any.whl → 0.0.33__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/automation/energybalancer.py +2 -4
- juham_automation/automation/heatingoptimizer.py +117 -46
- {juham_automation-0.0.31.dist-info → juham_automation-0.0.33.dist-info}/METADATA +1 -1
- {juham_automation-0.0.31.dist-info → juham_automation-0.0.33.dist-info}/RECORD +8 -8
- {juham_automation-0.0.31.dist-info → juham_automation-0.0.33.dist-info}/WHEEL +1 -1
- {juham_automation-0.0.31.dist-info → juham_automation-0.0.33.dist-info}/entry_points.txt +0 -0
- {juham_automation-0.0.31.dist-info → juham_automation-0.0.33.dist-info}/licenses/LICENSE.rst +0 -0
- {juham_automation-0.0.31.dist-info → juham_automation-0.0.33.dist-info}/top_level.txt +0 -0
@@ -14,7 +14,7 @@ class Consumer:
|
|
14
14
|
|
15
15
|
"""
|
16
16
|
|
17
|
-
def __init__(self, name: str, power) -> None:
|
17
|
+
def __init__(self, name: str, power: float) -> None:
|
18
18
|
"""Initialize the consumer
|
19
19
|
|
20
20
|
Args:
|
@@ -209,14 +209,13 @@ class EnergyBalancer(Juham):
|
|
209
209
|
}
|
210
210
|
self.publish(self.topic_out_diagnostics, json.dumps(m))
|
211
211
|
|
212
|
-
|
213
212
|
# publish consumer statuses to control consumers
|
214
213
|
num_consumers: int = len(self.consumers)
|
215
214
|
if num_consumers == 0:
|
216
215
|
return # If there are no consumers, we simply do nothing
|
217
216
|
interval_ts = ts % self.energy_balancing_interval
|
218
217
|
for consumer in self.consumers.values():
|
219
|
-
m
|
218
|
+
m = {
|
220
219
|
"EnergyBalancer": self.name,
|
221
220
|
"Unit": consumer.name,
|
222
221
|
"Power": consumer.power,
|
@@ -263,4 +262,3 @@ class EnergyBalancer(Juham):
|
|
263
262
|
self.net_energy_balancing_mode = False
|
264
263
|
self.info("Balance used, or the end of the interval reached, disable")
|
265
264
|
self.net_energy_balance = 0.0 # Reset the energy balance at the interval's end
|
266
|
-
|
@@ -1,3 +1,4 @@
|
|
1
|
+
from datetime import datetime
|
1
2
|
import json
|
2
3
|
from typing import Any
|
3
4
|
from typing_extensions import override
|
@@ -16,31 +17,63 @@ from juham_core.timeutils import (
|
|
16
17
|
|
17
18
|
class HeatingOptimizer(Juham):
|
18
19
|
"""Automation class for optimized control of temperature driven home energy consumers e.g hot
|
19
|
-
water radiators. Reads spot prices, electricity forecast,
|
20
|
+
water radiators. Reads spot prices, solar electricity forecast, power meter and
|
21
|
+
temperature to minimize electricity bill.
|
20
22
|
|
21
23
|
Represents a heating system that knows the power rating of its radiator (e.g., 3kW).
|
24
|
+
Any number of heating devices can be controlled, each with its own temperature, schedule and electricity price ratings
|
25
|
+
|
22
26
|
The system subscribes to the 'power' topic to track the current power balance. If the solar panels
|
23
27
|
generate more energy than is being consumed, the optimizer activates a relay to ensure that all excess energy
|
24
|
-
produced within that
|
28
|
+
produced within that balancing interval is used for heating. The goal is to achieve a net zero energy balance for each hour,
|
25
29
|
ensuring that any surplus energy from the solar panels is fully utilized.
|
26
30
|
|
27
31
|
Computes also UOI - optimization utilization index for each hour, based on the spot price and the solar power forecast.
|
28
32
|
For negative energy balance this determines when energy is consumed. Value of 0 means the hour is expensive, value of 1 means
|
29
33
|
the hour is free. The UOI threshold determines the slots that are allowed to be consumed.
|
30
|
-
|
31
34
|
"""
|
32
35
|
|
33
|
-
maximum_boiler_temperature: float = 70
|
34
|
-
minimum_boiler_temperature: float = 40
|
35
36
|
energy_balancing_interval: float = 3600
|
36
|
-
|
37
|
-
|
37
|
+
"""Energy balancing interval, as regulated by the industry/converment. In seconds"""
|
38
|
+
|
39
|
+
radiator_power: float = 6000 # W
|
40
|
+
"""Radiator power in Watts. This is the maximum power that the radiator can consume."""
|
41
|
+
|
38
42
|
heating_hours_per_day: float = 4
|
43
|
+
""" Number of hours per day the radiator is allowed to heat."""
|
44
|
+
|
39
45
|
schedule_start_hour: float = 0
|
46
|
+
"""Start hour of the heating schedule."""
|
47
|
+
|
40
48
|
schedule_stop_hour: float = 0
|
49
|
+
"""Stop hour of the heating schedule. Heating is allowed only between these two hours."""
|
50
|
+
|
41
51
|
timezone: str = "Europe/Helsinki"
|
52
|
+
""" Timezone of the heating system. This is used to convert UTC timestamps to local time."""
|
53
|
+
|
42
54
|
expected_average_price: float = 0.2
|
55
|
+
"""Expected average price of electricity, beyond which the heating is avoided."""
|
56
|
+
|
43
57
|
uoi_threshold: float = 0.8
|
58
|
+
"""Utilization Optimization Index threshold. This is the minimum UOI value that is allowed for the heating to be activated."""
|
59
|
+
|
60
|
+
temperature_limits: dict[int, tuple[float, float]] = {
|
61
|
+
1: (20.0, 60.0), # January
|
62
|
+
2: (20.0, 60.0), # February
|
63
|
+
3: (20.0, 60.0), # March
|
64
|
+
4: (20.0, 50.0), # April
|
65
|
+
5: (20.0, 40.0), # May
|
66
|
+
6: (20.0, 22.0), # June
|
67
|
+
7: (20.0, 22.0), # July
|
68
|
+
8: (20.0, 22.0), # August
|
69
|
+
9: (20.0, 50.0), # September
|
70
|
+
10: (20.0, 60.0), # October
|
71
|
+
11: (20.0, 60.0), # November
|
72
|
+
12: (20.0, 60.0), # December
|
73
|
+
}
|
74
|
+
"""Temperature limits for each month. The minimum temperature is maintained regardless of the cost.
|
75
|
+
The limits are defined as a dictionary where the keys are month numbers (1-12)
|
76
|
+
and the values are tuples of (min_temp, max_temp). The min_temp and max_temp values are in degrees Celsius."""
|
44
77
|
|
45
78
|
def __init__(
|
46
79
|
self,
|
@@ -84,9 +117,9 @@ class HeatingOptimizer(Juham):
|
|
84
117
|
self.start_hour = start_hour
|
85
118
|
self.spot_limit = spot_limit
|
86
119
|
|
87
|
-
self.
|
88
|
-
self.
|
89
|
-
self.
|
120
|
+
self.topic_in_spot = self.make_topic_name("spot")
|
121
|
+
self.topic_in_forecast = self.make_topic_name("forecast")
|
122
|
+
self.topic_in_temperature = self.make_topic_name(temperature_sensor)
|
90
123
|
self.topic_powerplan = self.make_topic_name("powerplan")
|
91
124
|
self.topic_in_energybalance = self.make_topic_name("energybalance/status")
|
92
125
|
self.topic_out_energybalance = self.make_topic_name("energybalance/consumers")
|
@@ -107,9 +140,9 @@ class HeatingOptimizer(Juham):
|
|
107
140
|
def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
|
108
141
|
super().on_connect(client, userdata, flags, rc)
|
109
142
|
if rc == 0:
|
110
|
-
self.subscribe(self.
|
111
|
-
self.subscribe(self.
|
112
|
-
self.subscribe(self.
|
143
|
+
self.subscribe(self.topic_in_spot)
|
144
|
+
self.subscribe(self.topic_in_forecast)
|
145
|
+
self.subscribe(self.topic_in_temperature)
|
113
146
|
self.subscribe(self.topic_in_energybalance)
|
114
147
|
self.register_as_consumer()
|
115
148
|
|
@@ -122,7 +155,22 @@ class HeatingOptimizer(Juham):
|
|
122
155
|
"Power": self.radiator_power,
|
123
156
|
}
|
124
157
|
self.publish(self.topic_out_energybalance, json.dumps(consumer), 1, False)
|
125
|
-
self.info(
|
158
|
+
self.info(
|
159
|
+
f"Registered {self.name} as consumer with {self.radiator_power}W power",
|
160
|
+
"",
|
161
|
+
)
|
162
|
+
|
163
|
+
# Function to get the temperature limits based on the current month
|
164
|
+
def get_temperature_limits_for_current_month(self) -> tuple[float, float]:
|
165
|
+
"""Get the temperature limits for the current month.
|
166
|
+
The limits are defined in a dictionary where the keys are month numbers (1-12)
|
167
|
+
and the values are tuples of (min_temp, max_temp).
|
168
|
+
Returns: tuple: (min_temp, max_temp)
|
169
|
+
"""
|
170
|
+
current_month: int = datetime.now().month
|
171
|
+
# Get the min and max temperatures for the current month
|
172
|
+
min_temp, max_temp = self.temperature_limits[current_month]
|
173
|
+
return min_temp, max_temp
|
126
174
|
|
127
175
|
def sort_by_rank(
|
128
176
|
self, hours: list[dict[str, Any]], ts_utc_now: float
|
@@ -170,7 +218,7 @@ class HeatingOptimizer(Juham):
|
|
170
218
|
reverse=True,
|
171
219
|
)
|
172
220
|
self.debug(
|
173
|
-
f"
|
221
|
+
f"{self.name} sorted {len(sh)} days of forecast starting at {timestampstr(ts_utc)}"
|
174
222
|
)
|
175
223
|
ranked_hours: list[dict[str, Any]] = []
|
176
224
|
|
@@ -178,7 +226,9 @@ class HeatingOptimizer(Juham):
|
|
178
226
|
utc_ts: float = float(h["ts"])
|
179
227
|
if utc_ts >= ts_utc:
|
180
228
|
ranked_hours.append(h)
|
181
|
-
self.debug(
|
229
|
+
self.debug(
|
230
|
+
f"{self.name} forecast sorted for the next {str(len(ranked_hours))} hours"
|
231
|
+
)
|
182
232
|
return ranked_hours
|
183
233
|
|
184
234
|
def on_spot(self, m: list[dict[str, Any]], ts_quantized: float) -> None:
|
@@ -206,7 +256,7 @@ class HeatingOptimizer(Juham):
|
|
206
256
|
|
207
257
|
self.ranked_solarpower = self.sort_by_power(forecast, ts_utc_quantized)
|
208
258
|
self.debug(
|
209
|
-
f"
|
259
|
+
f"{self.name} solar energy forecast received and ranked for {len(self.ranked_solarpower)} hours"
|
210
260
|
)
|
211
261
|
self.power_plan = [] # reset power plan, it depends on forecast
|
212
262
|
|
@@ -215,13 +265,13 @@ class HeatingOptimizer(Juham):
|
|
215
265
|
m = None
|
216
266
|
ts: float = timestamp()
|
217
267
|
ts_utc_quantized: float = quantize(3600, ts - 3600)
|
218
|
-
if msg.topic == self.
|
268
|
+
if msg.topic == self.topic_in_spot:
|
219
269
|
self.on_spot(json.loads(msg.payload.decode()), ts_utc_quantized)
|
220
270
|
return
|
221
|
-
elif msg.topic == self.
|
271
|
+
elif msg.topic == self.topic_in_forecast:
|
222
272
|
self.on_forecast(json.loads(msg.payload.decode()), ts_utc_quantized)
|
223
273
|
return
|
224
|
-
elif msg.topic == self.
|
274
|
+
elif msg.topic == self.topic_in_temperature:
|
225
275
|
m = json.loads(msg.payload.decode())
|
226
276
|
self.current_temperature = m["temperature"]
|
227
277
|
elif msg.topic == self.topic_in_energybalance:
|
@@ -250,24 +300,24 @@ class HeatingOptimizer(Juham):
|
|
250
300
|
self.relay_started_ts = ts_utc_now
|
251
301
|
|
252
302
|
if not self.ranked_spot_prices:
|
253
|
-
self.debug("
|
303
|
+
self.debug("{self.name} waiting spot prices...", "")
|
254
304
|
return
|
255
305
|
|
256
306
|
if not self.power_plan:
|
257
307
|
self.power_plan = self.create_power_plan()
|
258
308
|
self.heating_plan = []
|
259
309
|
self.info(
|
260
|
-
f"
|
310
|
+
f"{self.name} power plan of length {len(self.power_plan)} created",
|
261
311
|
str(self.power_plan),
|
262
312
|
)
|
263
313
|
|
264
314
|
if not self.power_plan:
|
265
|
-
self.error("
|
315
|
+
self.error("{self.name} failed to create a power plan", "")
|
266
316
|
return
|
267
317
|
|
268
318
|
if len(self.power_plan) < 3:
|
269
319
|
self.warning(
|
270
|
-
f"
|
320
|
+
f"{self.name} has suspiciously short {len(self.power_plan)} power plan, waiting for more data ..",
|
271
321
|
"",
|
272
322
|
)
|
273
323
|
self.heating_plan = []
|
@@ -276,21 +326,25 @@ class HeatingOptimizer(Juham):
|
|
276
326
|
|
277
327
|
if not self.ranked_solarpower or len(self.ranked_solarpower) < 4:
|
278
328
|
self.warning(
|
279
|
-
f"
|
329
|
+
f"{self.name} short of forecast {len(self.ranked_solarpower)}, optimization compromised..",
|
280
330
|
"",
|
281
331
|
)
|
282
332
|
|
283
333
|
if not self.heating_plan:
|
284
334
|
self.heating_plan = self.create_heating_plan()
|
285
335
|
if not self.heating_plan:
|
286
|
-
self.error("
|
336
|
+
self.error("{self.name} failed to create heating plan")
|
287
337
|
return
|
288
338
|
else:
|
289
339
|
self.info(
|
290
|
-
f"
|
340
|
+
f"{self.name} heating plan of length {len(self.heating_plan)} created",
|
341
|
+
"",
|
291
342
|
)
|
292
343
|
if len(self.heating_plan) < 3:
|
293
|
-
self.
|
344
|
+
self.warning(
|
345
|
+
f"{self.name} has too short heating plan {len(self.heating_plan)}, no can do",
|
346
|
+
"",
|
347
|
+
)
|
294
348
|
self.heating_plan = []
|
295
349
|
self.power_plan = []
|
296
350
|
return
|
@@ -304,7 +358,7 @@ class HeatingOptimizer(Juham):
|
|
304
358
|
}
|
305
359
|
self.publish(self.topic_out_power, json.dumps(heat), 1, False)
|
306
360
|
self.info(
|
307
|
-
f"
|
361
|
+
f"{self.name} relay changed to {relay} at {timestampstr(ts_utc_now)}",
|
308
362
|
"",
|
309
363
|
)
|
310
364
|
self.current_relay_state = relay
|
@@ -322,7 +376,6 @@ class HeatingOptimizer(Juham):
|
|
322
376
|
if m["Unit"] == self.name:
|
323
377
|
self.net_energy_balance_mode = m["Mode"]
|
324
378
|
|
325
|
-
|
326
379
|
def consider_heating(self, ts: float) -> int:
|
327
380
|
"""Consider whether the target boiler needs heating. Check first if the solar
|
328
381
|
energy is enough to heat the water the remaining time in the current slot.
|
@@ -337,28 +390,45 @@ class HeatingOptimizer(Juham):
|
|
337
390
|
|
338
391
|
# check if we have excess energy to spent within the current slot
|
339
392
|
if self.net_energy_balance_mode:
|
340
|
-
self.
|
393
|
+
self.debug(
|
394
|
+
"{self.name} with positive net energy balance, spend it for heating"
|
395
|
+
)
|
341
396
|
return 1
|
342
397
|
|
343
|
-
# no free energy available, don't spend if the current temperature is already high enough
|
344
|
-
if self.current_temperature > self.maximum_boiler_temperature:
|
345
|
-
self.info(
|
346
|
-
f"Current temperature {self.current_temperature}C already beyond max {self.maximum_boiler_temperature}C"
|
347
|
-
)
|
348
|
-
return 0
|
349
398
|
hour = timestamp_hour(ts)
|
399
|
+
state: int = -1
|
350
400
|
|
351
401
|
# check if we are within the heating plan and see what the plan says
|
352
402
|
for pp in self.heating_plan:
|
353
403
|
ppts: float = pp["Timestamp"]
|
354
404
|
h: float = timestamp_hour(ppts)
|
355
405
|
if h == hour:
|
356
|
-
|
406
|
+
state = pp["State"]
|
407
|
+
break
|
357
408
|
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
409
|
+
if state == -1:
|
410
|
+
self.error(f"{self.name} cannot find heating plan for hour {hour}")
|
411
|
+
return 0
|
412
|
+
|
413
|
+
min_temp, max_temp = self.get_temperature_limits_for_current_month()
|
414
|
+
self.debug(
|
415
|
+
f"{self.name} month's temperature limits: Min = {min_temp}°C, Max = {max_temp}°C"
|
416
|
+
)
|
417
|
+
|
418
|
+
# don't heat if the current temperature is already high enough
|
419
|
+
if self.current_temperature > max_temp:
|
420
|
+
self.debug(
|
421
|
+
f"{self.name} plan {state}, temp {self.current_temperature}°C already beyond max {max_temp}°C"
|
422
|
+
)
|
423
|
+
return 0
|
424
|
+
# heat if the current temperature is below the required minimum
|
425
|
+
if self.current_temperature < min_temp:
|
426
|
+
self.debug(
|
427
|
+
f"{self.name} plan {state}, temp {self.current_temperature}°C below min {min_temp}°C"
|
428
|
+
)
|
429
|
+
return 1
|
430
|
+
|
431
|
+
return state # 1 = heating, 0 = not heating
|
362
432
|
|
363
433
|
# compute utilization optimization index
|
364
434
|
def compute_uoi(
|
@@ -425,7 +495,7 @@ class HeatingOptimizer(Juham):
|
|
425
495
|
ts_utc_quantized = quantize(3600, timestamp() - 3600)
|
426
496
|
starts: str = timestampstr(ts_utc_quantized)
|
427
497
|
self.info(
|
428
|
-
f"
|
498
|
+
f"{self.name} created power plan starting at {starts} with {len(self.ranked_spot_prices)} hours of spot prices",
|
429
499
|
"",
|
430
500
|
)
|
431
501
|
|
@@ -438,10 +508,11 @@ class HeatingOptimizer(Juham):
|
|
438
508
|
)
|
439
509
|
|
440
510
|
if len(spots) == 0:
|
441
|
-
self.
|
511
|
+
self.info(
|
442
512
|
f"No spot prices initialized yet, can't proceed",
|
443
513
|
"",
|
444
514
|
)
|
515
|
+
return []
|
445
516
|
self.info(
|
446
517
|
f"Have spot prices for the next {len(spots)} hours",
|
447
518
|
"",
|
@@ -497,7 +568,7 @@ class HeatingOptimizer(Juham):
|
|
497
568
|
|
498
569
|
shplan = sorted(hplan, key=lambda x: x["FOM"], reverse=True)
|
499
570
|
|
500
|
-
self.debug(f"
|
571
|
+
self.debug(f"{self.name} powerplan starts {starts} up to {len(shplan)} hours")
|
501
572
|
return shplan
|
502
573
|
|
503
574
|
def enable_relay(
|
@@ -547,5 +618,5 @@ class HeatingOptimizer(Juham):
|
|
547
618
|
heating_plan.append(heat)
|
548
619
|
hour = hour + 1
|
549
620
|
|
550
|
-
self.info(f"
|
621
|
+
self.info(f"{self.name} heating plan of {len(heating_plan)} hours created", "")
|
551
622
|
return heating_plan
|
@@ -2,9 +2,9 @@ juham_automation/__init__.py,sha256=32BL36bhT7OaSw22H7st-7-3IXcFM2Pf5js80hNA8W0,
|
|
2
2
|
juham_automation/japp.py,sha256=L2u1mfKvun2fiXhB3AEJD9zMDcdFZ3_doXZYJJzu9tg,1646
|
3
3
|
juham_automation/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
4
4
|
juham_automation/automation/__init__.py,sha256=uxkIrcRSp1cFikn-oBRtQ8XiT9cSf7xjm3CS1RN7lAQ,522
|
5
|
-
juham_automation/automation/energybalancer.py,sha256=
|
5
|
+
juham_automation/automation/energybalancer.py,sha256=2WnnajZdZZjHqUT9onCQCdkAIy_1nom8-y3u_pv2GM0,10844
|
6
6
|
juham_automation/automation/energycostcalculator.py,sha256=v30wxRpuY2gGBSMJifrFRTjsRU9t-iCiq33Vds7s3O8,10877
|
7
|
-
juham_automation/automation/heatingoptimizer.py,sha256=
|
7
|
+
juham_automation/automation/heatingoptimizer.py,sha256=DU-VvEHEZAj3C6CkT6K-7f-LhkAXRBYdf6RoxKbgydc,24412
|
8
8
|
juham_automation/automation/powermeter_simulator.py,sha256=3WZcjByRTdqnC77l7LjP-TEjmZ8XBEO4hClYsrjxmBE,4549
|
9
9
|
juham_automation/automation/spothintafi.py,sha256=cZbi7w2fVweHX_fh1r5MTjGdesX9wDQta2mfVjtiwvw,4331
|
10
10
|
juham_automation/automation/watercirculator.py,sha256=a8meMNaONbHcIH3y0vP0UulJc1-gZiLZpw7H8kAOreY,6410
|
@@ -17,9 +17,9 @@ juham_automation/ts/log_ts.py,sha256=XsNaazuPmRUZLUqxU0DZae_frtT6kAFcXJTc598CtOA
|
|
17
17
|
juham_automation/ts/power_ts.py,sha256=e7bSeZjitY4C_gLup9L0NjvU_WnQsl3ayDhVShj32KY,1399
|
18
18
|
juham_automation/ts/powermeter_ts.py,sha256=gXzfK2S4SzrQ9GqM0tsLaV6z_vYmTkBatTcaivASSXs,2188
|
19
19
|
juham_automation/ts/powerplan_ts.py,sha256=LZeE7TnzPCDaugggKlaV-K48lDwwnC1ZNum50JYAWaY,1482
|
20
|
-
juham_automation-0.0.
|
21
|
-
juham_automation-0.0.
|
22
|
-
juham_automation-0.0.
|
23
|
-
juham_automation-0.0.
|
24
|
-
juham_automation-0.0.
|
25
|
-
juham_automation-0.0.
|
20
|
+
juham_automation-0.0.33.dist-info/licenses/LICENSE.rst,sha256=QVHD5V5_HSys2PdPdig_xKggDj8cGX33ALKqRsYyjtI,1089
|
21
|
+
juham_automation-0.0.33.dist-info/METADATA,sha256=paKzowab6A0iDnu5qduBVXOWofockK1LhXp2KzcSnX4,6837
|
22
|
+
juham_automation-0.0.33.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
23
|
+
juham_automation-0.0.33.dist-info/entry_points.txt,sha256=h-KzuKjmGPd4_iX_oiGvxx4IEc97dVbGGlhdh5ctbpI,605
|
24
|
+
juham_automation-0.0.33.dist-info/top_level.txt,sha256=jfohvtocvX_gfT21AhJk7Iay5ZiQsS3HzrDjF7S4Qp0,17
|
25
|
+
juham_automation-0.0.33.dist-info/RECORD,,
|
File without changes
|
{juham_automation-0.0.31.dist-info → juham_automation-0.0.33.dist-info}/licenses/LICENSE.rst
RENAMED
File without changes
|
File without changes
|