juham-automation 0.0.30__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 +77 -56
- juham_automation/automation/heatingoptimizer.py +117 -48
- juham_automation/ts/energybalancer_ts.py +35 -9
- {juham_automation-0.0.30.dist-info → juham_automation-0.0.33.dist-info}/METADATA +1 -1
- {juham_automation-0.0.30.dist-info → juham_automation-0.0.33.dist-info}/RECORD +9 -9
- {juham_automation-0.0.30.dist-info → juham_automation-0.0.33.dist-info}/WHEEL +1 -1
- {juham_automation-0.0.30.dist-info → juham_automation-0.0.33.dist-info}/entry_points.txt +0 -0
- {juham_automation-0.0.30.dist-info → juham_automation-0.0.33.dist-info}/licenses/LICENSE.rst +0 -0
- {juham_automation-0.0.30.dist-info → juham_automation-0.0.33.dist-info}/top_level.txt +0 -0
@@ -6,6 +6,27 @@ from juham_core import Juham, timestamp
|
|
6
6
|
from masterpiece import MqttMsg
|
7
7
|
|
8
8
|
|
9
|
+
class Consumer:
|
10
|
+
"""Class representing a consumer.
|
11
|
+
|
12
|
+
This class is used to represent a consumer in the energy balancer.
|
13
|
+
It contains the name of the consumer and its power consumption.
|
14
|
+
|
15
|
+
"""
|
16
|
+
|
17
|
+
def __init__(self, name: str, power: float) -> None:
|
18
|
+
"""Initialize the consumer
|
19
|
+
|
20
|
+
Args:
|
21
|
+
name (str): name of the consumer
|
22
|
+
power (float): power of the consumer in watts
|
23
|
+
"""
|
24
|
+
self.name = name
|
25
|
+
self.power: float = power
|
26
|
+
self.start: float = 0.0
|
27
|
+
self.stop: float = 0.0
|
28
|
+
|
29
|
+
|
9
30
|
class EnergyBalancer(Juham):
|
10
31
|
"""The energy balancer monitors the balance between produced and consumed energy
|
11
32
|
within the balancing interval to determine if there is enough energy available for
|
@@ -19,6 +40,8 @@ class EnergyBalancer(Juham):
|
|
19
40
|
|
20
41
|
The energy balancer is used in conjunction with a power meter that reads
|
21
42
|
the total power consumption of the house. The energy balancer uses the power meter
|
43
|
+
|
44
|
+
|
22
45
|
"""
|
23
46
|
|
24
47
|
#: Description of the attribute
|
@@ -35,15 +58,15 @@ class EnergyBalancer(Juham):
|
|
35
58
|
super().__init__(name)
|
36
59
|
|
37
60
|
self.topic_in_consumers = self.make_topic_name("energybalance/consumers")
|
38
|
-
self.
|
61
|
+
self.topic_out_status = self.make_topic_name("energybalance/status")
|
62
|
+
self.topic_out_diagnostics = self.make_topic_name("energybalance/diagnostics")
|
39
63
|
self.topic_in_power = self.make_topic_name("net_energy_balance")
|
40
64
|
|
41
65
|
self.net_energy_balance: float = 0.0 # Energy balance in joules (watt-seconds)
|
42
66
|
self.current_interval_ts: float = -1
|
43
67
|
self.needed_energy: float = 0.0 # Energy needed in joules (watt-seconds)
|
44
68
|
self.net_energy_balancing_mode: bool = False
|
45
|
-
self.consumers: dict[str,
|
46
|
-
self.active_consumers: dict[str, dict[float, float]] = {}
|
69
|
+
self.consumers: dict[str, Consumer] = {}
|
47
70
|
|
48
71
|
@override
|
49
72
|
def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
|
@@ -79,7 +102,7 @@ class EnergyBalancer(Juham):
|
|
79
102
|
m (dict[str, Any]): power consumer message
|
80
103
|
ts (float): current time
|
81
104
|
"""
|
82
|
-
self.consumers[m["Unit"]] = m["Power"]
|
105
|
+
self.consumers[m["Unit"]] = Consumer(m["Unit"], m["Power"])
|
83
106
|
self.info(f"Consumer {m['Unit']} added, power: {m['Power']}")
|
84
107
|
|
85
108
|
def update_energy_balance(self, power: float, ts: float) -> None:
|
@@ -98,6 +121,7 @@ class EnergyBalancer(Juham):
|
|
98
121
|
"""
|
99
122
|
|
100
123
|
# regardless of the mode, if we hit the end of the interval, reset the balance
|
124
|
+
|
101
125
|
interval_ts: float = ts % self.energy_balancing_interval
|
102
126
|
if self.current_interval_ts < 0 or interval_ts <= self.current_interval_ts:
|
103
127
|
# time runs backwards, must be a new interval
|
@@ -118,7 +142,7 @@ class EnergyBalancer(Juham):
|
|
118
142
|
# if we have enough energy to power the radiator for the rest of the time slot
|
119
143
|
if self.net_energy_balance >= self.needed_energy:
|
120
144
|
self.net_energy_balancing_mode = True
|
121
|
-
self.
|
145
|
+
self.initialize_consumer_timelines(ts)
|
122
146
|
self.publish_energybalance(ts)
|
123
147
|
|
124
148
|
def calculate_needed_energy(self, interval_ts: float) -> float:
|
@@ -141,10 +165,10 @@ class EnergyBalancer(Juham):
|
|
141
165
|
self.energy_balancing_interval - interval_ts
|
142
166
|
) / num_consumers
|
143
167
|
for consumer in self.consumers.values():
|
144
|
-
required_power += consumer * remaining_ts_consumer
|
168
|
+
required_power += consumer.power * remaining_ts_consumer
|
145
169
|
return required_power
|
146
170
|
|
147
|
-
def
|
171
|
+
def initialize_consumer_timelines(self, ts: float) -> None:
|
148
172
|
"""Initialize the list of active consumers with their start and stop times.
|
149
173
|
|
150
174
|
Args:
|
@@ -161,46 +185,58 @@ class EnergyBalancer(Juham):
|
|
161
185
|
self.energy_balancing_interval - interval_ts
|
162
186
|
) / num_consumers
|
163
187
|
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
start: float = interval_ts
|
169
|
-
stop: float = start + secs_per_consumer
|
170
|
-
|
171
|
-
# Add the consumer to the active consumers dictionary with its start and stop times
|
172
|
-
self.active_consumers[consumer_name] = {start: stop}
|
173
|
-
|
174
|
-
# Update interval_ts to the stop time for the next consumer
|
175
|
-
interval_ts = stop
|
188
|
+
for consumer in self.consumers.values():
|
189
|
+
consumer.start = interval_ts
|
190
|
+
consumer.stop = interval_ts + secs_per_consumer
|
191
|
+
interval_ts += secs_per_consumer
|
176
192
|
|
177
|
-
def
|
178
|
-
"""
|
179
|
-
the water in the remaining time within the balancing interval.
|
193
|
+
def publish_energybalance(self, ts: float) -> None:
|
194
|
+
"""Publish diagnostics and status.
|
180
195
|
|
181
196
|
Args:
|
182
|
-
|
183
|
-
ts (float): current time
|
197
|
+
ts (float): current time.
|
184
198
|
|
185
199
|
Returns:
|
186
|
-
|
200
|
+
None
|
187
201
|
"""
|
188
|
-
# Check if the consumer exists in the active_consumers dictionary
|
189
|
-
if unit not in self.active_consumers:
|
190
|
-
return False # The consumer is not found, so return False
|
191
202
|
|
192
|
-
#
|
193
|
-
|
203
|
+
# publish diagnostics
|
204
|
+
m: dict[str, Any] = {
|
205
|
+
"EnergyBalancer": self.name,
|
206
|
+
"CurrentBalance": self.net_energy_balance,
|
207
|
+
"NeededBalance": self.needed_energy,
|
208
|
+
"Timestamp": ts,
|
209
|
+
}
|
210
|
+
self.publish(self.topic_out_diagnostics, json.dumps(m))
|
194
211
|
|
195
|
-
#
|
196
|
-
|
212
|
+
# publish consumer statuses to control consumers
|
213
|
+
num_consumers: int = len(self.consumers)
|
214
|
+
if num_consumers == 0:
|
215
|
+
return # If there are no consumers, we simply do nothing
|
216
|
+
interval_ts = ts % self.energy_balancing_interval
|
217
|
+
for consumer in self.consumers.values():
|
218
|
+
m = {
|
219
|
+
"EnergyBalancer": self.name,
|
220
|
+
"Unit": consumer.name,
|
221
|
+
"Power": consumer.power,
|
222
|
+
"Mode": consumer.start <= interval_ts < consumer.stop,
|
223
|
+
"Timestamp": ts,
|
224
|
+
}
|
225
|
+
self.publish(self.topic_out_status, json.dumps(m))
|
226
|
+
|
227
|
+
def detect_consumer_status(self, name: str, ts: float) -> bool:
|
228
|
+
"""Detect consumer status
|
197
229
|
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
return True # If the current time is within the range, the consumer is active
|
230
|
+
Args:
|
231
|
+
name (str): name of the consumer
|
232
|
+
ts (float): current time.
|
202
233
|
|
203
|
-
|
234
|
+
Returns:
|
235
|
+
True if the consumer is active, False otherwise
|
236
|
+
"""
|
237
|
+
consumer: Consumer = self.consumers[name]
|
238
|
+
interval_ts = ts % self.energy_balancing_interval
|
239
|
+
return consumer.start <= interval_ts < consumer.stop
|
204
240
|
|
205
241
|
def reset_net_energy_balance(self, interval_ts: float) -> None:
|
206
242
|
"""Reset the net energy balance at the end of the interval."""
|
@@ -208,7 +244,10 @@ class EnergyBalancer(Juham):
|
|
208
244
|
self.current_interval_ts = interval_ts
|
209
245
|
self.needed_energy = self.calculate_needed_energy(interval_ts)
|
210
246
|
self.net_energy_balancing_mode = False
|
211
|
-
self.
|
247
|
+
for consumer in self.consumers.values():
|
248
|
+
consumer.start = 0.0
|
249
|
+
consumer.stop = 0.0
|
250
|
+
|
212
251
|
self.info("Energy balance reset, interval ended")
|
213
252
|
|
214
253
|
def activate_balancing_mode(self, ts: float) -> None:
|
@@ -223,21 +262,3 @@ class EnergyBalancer(Juham):
|
|
223
262
|
self.net_energy_balancing_mode = False
|
224
263
|
self.info("Balance used, or the end of the interval reached, disable")
|
225
264
|
self.net_energy_balance = 0.0 # Reset the energy balance at the interval's end
|
226
|
-
|
227
|
-
def publish_energybalance(self, ts: float) -> None:
|
228
|
-
"""Publish energy balance information.
|
229
|
-
|
230
|
-
Args:
|
231
|
-
ts (float): current time
|
232
|
-
Returns:
|
233
|
-
dict: diagnostics information
|
234
|
-
"""
|
235
|
-
m: dict[str, Any] = {
|
236
|
-
"Unit": self.name,
|
237
|
-
"Mode": self.net_energy_balancing_mode,
|
238
|
-
"Rc": self.net_energy_balancing_mode,
|
239
|
-
"CurrentBalance": self.net_energy_balance,
|
240
|
-
"NeededBalance": self.needed_energy,
|
241
|
-
"Timestamp": ts,
|
242
|
-
}
|
243
|
-
self.publish(self.topic_out_energybalance, json.dumps(m))
|
@@ -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
|
@@ -321,9 +375,6 @@ class HeatingOptimizer(Juham):
|
|
321
375
|
"""
|
322
376
|
if m["Unit"] == self.name:
|
323
377
|
self.net_energy_balance_mode = m["Mode"]
|
324
|
-
self.info(
|
325
|
-
"Net energy balance mode for {self.name} set to {m['Mode']}", str(m)
|
326
|
-
)
|
327
378
|
|
328
379
|
def consider_heating(self, ts: float) -> int:
|
329
380
|
"""Consider whether the target boiler needs heating. Check first if the solar
|
@@ -339,28 +390,45 @@ class HeatingOptimizer(Juham):
|
|
339
390
|
|
340
391
|
# check if we have excess energy to spent within the current slot
|
341
392
|
if self.net_energy_balance_mode:
|
342
|
-
self.
|
393
|
+
self.debug(
|
394
|
+
"{self.name} with positive net energy balance, spend it for heating"
|
395
|
+
)
|
343
396
|
return 1
|
344
397
|
|
345
|
-
# no free energy available, don't spend if the current temperature is already high enough
|
346
|
-
if self.current_temperature > self.maximum_boiler_temperature:
|
347
|
-
self.info(
|
348
|
-
f"Current temperature {self.current_temperature}C already beyond max {self.maximum_boiler_temperature}C"
|
349
|
-
)
|
350
|
-
return 0
|
351
398
|
hour = timestamp_hour(ts)
|
399
|
+
state: int = -1
|
352
400
|
|
353
401
|
# check if we are within the heating plan and see what the plan says
|
354
402
|
for pp in self.heating_plan:
|
355
403
|
ppts: float = pp["Timestamp"]
|
356
404
|
h: float = timestamp_hour(ppts)
|
357
405
|
if h == hour:
|
358
|
-
|
406
|
+
state = pp["State"]
|
407
|
+
break
|
408
|
+
|
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
|
359
430
|
|
360
|
-
#
|
361
|
-
# this should not happen, but just in case
|
362
|
-
self.error(f"Cannot find heating plan for hour {hour}")
|
363
|
-
return 0
|
431
|
+
return state # 1 = heating, 0 = not heating
|
364
432
|
|
365
433
|
# compute utilization optimization index
|
366
434
|
def compute_uoi(
|
@@ -427,7 +495,7 @@ class HeatingOptimizer(Juham):
|
|
427
495
|
ts_utc_quantized = quantize(3600, timestamp() - 3600)
|
428
496
|
starts: str = timestampstr(ts_utc_quantized)
|
429
497
|
self.info(
|
430
|
-
f"
|
498
|
+
f"{self.name} created power plan starting at {starts} with {len(self.ranked_spot_prices)} hours of spot prices",
|
431
499
|
"",
|
432
500
|
)
|
433
501
|
|
@@ -440,10 +508,11 @@ class HeatingOptimizer(Juham):
|
|
440
508
|
)
|
441
509
|
|
442
510
|
if len(spots) == 0:
|
443
|
-
self.
|
511
|
+
self.info(
|
444
512
|
f"No spot prices initialized yet, can't proceed",
|
445
513
|
"",
|
446
514
|
)
|
515
|
+
return []
|
447
516
|
self.info(
|
448
517
|
f"Have spot prices for the next {len(spots)} hours",
|
449
518
|
"",
|
@@ -499,7 +568,7 @@ class HeatingOptimizer(Juham):
|
|
499
568
|
|
500
569
|
shplan = sorted(hplan, key=lambda x: x["FOM"], reverse=True)
|
501
570
|
|
502
|
-
self.debug(f"
|
571
|
+
self.debug(f"{self.name} powerplan starts {starts} up to {len(shplan)} hours")
|
503
572
|
return shplan
|
504
573
|
|
505
574
|
def enable_relay(
|
@@ -549,5 +618,5 @@ class HeatingOptimizer(Juham):
|
|
549
618
|
heating_plan.append(heat)
|
550
619
|
hour = hour + 1
|
551
620
|
|
552
|
-
self.info(f"
|
621
|
+
self.info(f"{self.name} heating plan of {len(heating_plan)} hours created", "")
|
553
622
|
return heating_plan
|
@@ -9,7 +9,7 @@ from juham_core.timeutils import epoc2utc
|
|
9
9
|
|
10
10
|
|
11
11
|
class EnergyBalancerTs(JuhamTs):
|
12
|
-
"""
|
12
|
+
"""Record energy balance data to time series database.
|
13
13
|
|
14
14
|
This class listens the "energybalance" MQTT topic and records the
|
15
15
|
messages to time series database.
|
@@ -19,27 +19,53 @@ class EnergyBalancerTs(JuhamTs):
|
|
19
19
|
"""Construct record object with the given name."""
|
20
20
|
|
21
21
|
super().__init__(name)
|
22
|
-
self.
|
22
|
+
self.topic_in_status = self.make_topic_name("energybalance/status")
|
23
|
+
self.topic_in_diagnostics = self.make_topic_name("energybalance/diagnostics")
|
23
24
|
|
24
25
|
@override
|
25
26
|
def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
|
26
27
|
super().on_connect(client, userdata, flags, rc)
|
27
|
-
|
28
|
-
|
28
|
+
if rc == 0:
|
29
|
+
self.subscribe(self.topic_in_status)
|
30
|
+
self.subscribe(self.topic_in_diagnostics)
|
29
31
|
|
30
32
|
@override
|
31
33
|
def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
|
32
|
-
|
34
|
+
if msg.topic == self.topic_in_status:
|
35
|
+
self.on_status(json.loads(msg.payload.decode()))
|
36
|
+
elif msg.topic == self.topic_in_diagnostics:
|
37
|
+
self.on_diagnostics(json.loads(msg.payload.decode()))
|
38
|
+
else:
|
39
|
+
super().on_message(client, userdata, msg)
|
33
40
|
|
34
|
-
|
41
|
+
def on_status(self, m: dict[str, Any]) -> None:
|
42
|
+
"""Handle energybalance message.
|
43
|
+
Args:
|
44
|
+
m (dict[str, Any]): Message from energybalance topic.
|
35
45
|
"""
|
36
|
-
|
37
|
-
|
46
|
+
if not "Power" in m or not "Timestamp" in m:
|
47
|
+
self.error(f"INVALID STATUS msg {m}")
|
48
|
+
return
|
38
49
|
point = (
|
39
50
|
self.measurement("energybalance")
|
40
51
|
.tag("Unit", m["Unit"])
|
41
52
|
.field("Mode", m["Mode"])
|
42
|
-
.field("
|
53
|
+
.field("Power", float(m["Power"]))
|
54
|
+
.time(epoc2utc(m["Timestamp"]))
|
55
|
+
)
|
56
|
+
self.write(point)
|
57
|
+
|
58
|
+
def on_diagnostics(self, m: dict[str, Any]) -> None:
|
59
|
+
"""Handle energybalance diagnostics.
|
60
|
+
Args:
|
61
|
+
m (dict[str, Any]): Message from energybalance topic.
|
62
|
+
"""
|
63
|
+
if not "Timestamp" in m:
|
64
|
+
self.error(f"INVALID DIAGNOSTICS msg {m}")
|
65
|
+
return
|
66
|
+
point = (
|
67
|
+
self.measurement("energybalance")
|
68
|
+
.tag("EnergyBalancer", m["EnergyBalancer"])
|
43
69
|
.field("CurrentBalance", m["CurrentBalance"])
|
44
70
|
.field("NeededBalance", m["NeededBalance"])
|
45
71
|
.time(epoc2utc(m["Timestamp"]))
|
@@ -2,24 +2,24 @@ 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
|
11
11
|
juham_automation/ts/__init__.py,sha256=kTEzVkDi6ednH4-fxKxrY6enlTuTXmSw09pPAQX3CMc,612
|
12
12
|
juham_automation/ts/electricityprice_ts.py,sha256=BYs120V4teVjSqPc8PpPDjOTc5dOrVM9Maqse7E8cvk,1684
|
13
|
-
juham_automation/ts/energybalancer_ts.py,sha256=
|
13
|
+
juham_automation/ts/energybalancer_ts.py,sha256=XHl56G5fBjJOCSIJtdjzGpSTMAQN1xsnXpC4Ipl7ynw,2585
|
14
14
|
juham_automation/ts/energycostcalculator_ts.py,sha256=MbeYEGlziVgq4zI40Tk71zxeDPeKafEG3s0LqDRiz0g,1277
|
15
15
|
juham_automation/ts/forecast_ts.py,sha256=Gk46hIlS8ijxs-zyy8fBvXrhI7J-8e5Gt2QEe6gFB6s,3158
|
16
16
|
juham_automation/ts/log_ts.py,sha256=XsNaazuPmRUZLUqxU0DZae_frtT6kAFcXJTc598CtOA,1750
|
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.30.dist-info → juham_automation-0.0.33.dist-info}/licenses/LICENSE.rst
RENAMED
File without changes
|
File without changes
|