juham-automation 0.0.4__py3-none-any.whl → 0.0.7__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.
@@ -1,527 +1,573 @@
1
- import json
2
- from typing import Any
3
- from typing_extensions import override
4
-
5
- from masterpiece.mqtt import MqttMsg
6
- from juham_core import Juham
7
- from juham_core.timeutils import (
8
- quantize,
9
- timestamp,
10
- timestamp_hour,
11
- timestampstr,
12
- is_hour_within_schedule,
13
- timestamp_hour_local,
14
- )
15
-
16
-
17
- class HotWaterOptimizer(Juham):
18
- """Automation class for optimized control of temperature driven home energy consumers e.g hot
19
- water radiators. Reads spot prices, boiler water temperatures to minimize electricity bill.
20
- Additional control over heating is provided by the following attributes
21
-
22
- Attributes:
23
- schedule_start_hour (float): hour heating can start, in local time
24
- schedule_stop_hour (float): hour heating is forced to stop
25
- start_hour (float): start_hour (int): ordinal of the first allowed electricity price slot to be consumed
26
- num_hours (int): the number of hour slots allowed
27
-
28
- """
29
-
30
- maximum_boiler_temperature = 70
31
- minimum_boiler_temperature = 40
32
- energy_balancing_interval: float = 3600
33
- radiator_power = 3000 # 3 kW
34
- operation_threshold = 5 * 60
35
- heating_hours_per_day = 4 # use the four cheapest hours per day
36
- schedule_start_hour: float = 0
37
- schedule_stop_hour: float = 0
38
- timezone = "Europe/Helsinki"
39
-
40
- def __init__(
41
- self,
42
- name: str,
43
- temperature_sensor: str,
44
- start_hour: int,
45
- num_hours: int,
46
- spot_limit: float,
47
- ) -> None:
48
- """Create power plan for automating temperature driven systems, e.g. heating radiators
49
- to optimize energy consumption based on electricity prices.
50
-
51
- Electricity Price MQTT Topic: This specifies the MQTT topic through which the controller receives
52
- hourly electricity price forecasts for the next day or two.
53
- Radiator Control Topic: The MQTT topic used to control the radiator relay.
54
- Temperature Sensor Topic: The MQTT topic where the temperature sensor publishes its readings.
55
- Electricity Price Slot Range: A pair of integers determining which electricity price slots the
56
- controller uses. The slots are ranked from the cheapest to the most expensive. For example:
57
- - A range of 0, 3 directs the controller to use electricity during the three cheapest hours.
58
- - A second controller with a range of 3, 2 would target the next two cheapest hours, and so on.
59
- Maximum Electricity Price Threshold: An upper limit for the electricity price, serving as an additional control.
60
- The controller only operates within its designated price slots if the prices are below this threshold.
61
-
62
- The maximum price threshold reflects the criticality of the radiator's operation:
63
-
64
- High thresholds indicate that the radiator should remain operational regardless of the price.
65
- Low thresholds imply the radiator can be turned off during expensive periods, suggesting it has a less critical role.
66
-
67
- By combining these attributes, the controller ensures efficient energy usage while maintaining desired heating levels.
68
-
69
- Args:
70
- name (str): name of the heating radiator
71
- temperature_sensor (str): temperature sensor of the heating radiator
72
- start_hour (int): ordinal of the first allowed electricity price slot to be consumed
73
- num_hours (int): the number of slots allowed
74
- spot_limit (float): maximum price allowed
75
- """
76
- super().__init__(name)
77
-
78
- self.heating_hours_per_day = num_hours
79
- self.start_hour = start_hour
80
- self.spot_limit = spot_limit
81
-
82
- self.topic_spot = self.make_topic_name("spot")
83
- self.topic_forecast = self.make_topic_name("forecast")
84
- self.topic_temperature = self.make_topic_name(temperature_sensor)
85
- self.topic_powerplan = self.make_topic_name("powerplan")
86
- self.topic_power = self.make_topic_name("power")
87
- self.topic_in_powerconsumption = self.make_topic_name("powerconsumption")
88
- self.topic_in_net_energy_balance = self.make_topic_name("net_energy_balance")
89
-
90
- self.current_temperature = 100
91
- self.current_heating_plan = 0
92
- self.current_relay_state = -1
93
- self.heating_plan: list[dict[str, int]] = []
94
- self.power_plan: list[dict[str, Any]] = []
95
- self.ranked_spot_prices: list[dict[Any, Any]] = []
96
- self.ranked_solarpower: list[dict[Any, Any]] = []
97
- self.relay: bool = False
98
- self.relay_started_ts: float = 0
99
- self.current_power: float = 0
100
- self.net_energy_balance: float = 0.0
101
- self.net_energy_power: float = 0
102
- self.net_energy_balance_ts: float = 0
103
- self.net_energy_balancing_rc: bool = False
104
- self.net_energy_balancing_mode = False
105
-
106
- @override
107
- def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
108
- super().on_connect(client, userdata, flags, rc)
109
- if rc == 0:
110
- self.subscribe(self.topic_spot)
111
- self.subscribe(self.topic_forecast)
112
- self.subscribe(self.topic_temperature)
113
- self.subscribe(self.topic_in_powerconsumption)
114
- self.subscribe(self.topic_in_net_energy_balance)
115
-
116
- def sort_by_rank(
117
- self, hours: list[dict[str, Any]], ts_utc_now: float
118
- ) -> list[dict[str, Any]]:
119
- """Sort the given electricity prices by their rank value. Given a list
120
- of electricity prices, return a sorted list from the cheapest to the
121
- most expensive hours. Entries that represent electricity prices in the
122
- past are excluded.
123
-
124
- Args:
125
- hours (list): list of hourly electricity prices
126
- ts_utc_now (float): current time
127
-
128
- Returns:
129
- list: sorted list of electricity prices
130
- """
131
- sh = sorted(hours, key=lambda x: x["Rank"])
132
- ranked_hours = []
133
- for h in sh:
134
- utc_ts = h["Timestamp"]
135
- if utc_ts > ts_utc_now:
136
- ranked_hours.append(h)
137
-
138
- return ranked_hours
139
-
140
- def sort_by_power(
141
- self, solarpower: list[dict[Any, Any]], ts_utc: float
142
- ) -> list[dict[Any, Any]]:
143
- """Sort forecast of solarpower to decreasing order.
144
-
145
- Args:
146
- solarpower (list): list of entries describing hourly solar energy forecast
147
- ts_utc(float): start time, for exluding entries that are in the past
148
-
149
- Returns:
150
- list: list from the highest solarenergy to lowest.
151
- """
152
-
153
- # if all items have solarenergy key then
154
- # sh = sorted(solarpower, key=lambda x: x["solarenergy"], reverse=True)
155
- # else skip items that don't have solarenergy key
156
- sh = sorted(
157
- [item for item in solarpower if "solarenergy" in item],
158
- key=lambda x: x["solarenergy"],
159
- reverse=True,
160
- )
161
- self.debug(
162
- f"Sorted {len(sh)} days of forecast starting at {timestampstr(ts_utc)}"
163
- )
164
- ranked_hours = []
165
-
166
- for h in sh:
167
- utc_ts: float = float(h["ts"])
168
- if utc_ts >= ts_utc:
169
- ranked_hours.append(h)
170
- self.debug(f"Forecast sorted for the next {str(len(ranked_hours))} hours")
171
- return ranked_hours
172
-
173
- @override
174
- def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
175
- m = None
176
- ts: float = timestamp()
177
- ts_utc_quantized: float = quantize(3600, ts - 3600)
178
- if msg.topic == self.topic_spot:
179
- self.ranked_spot_prices = self.sort_by_rank(
180
- json.loads(msg.payload.decode()), ts_utc_quantized
181
- )
182
- self.debug(
183
- f"Spot prices received and ranked for {len(self.ranked_spot_prices)} hours"
184
- )
185
- self.power_plan = [] # reset power plan, it depends on spot prices
186
- return
187
- elif msg.topic == self.topic_forecast:
188
- forecast = json.loads(msg.payload.decode())
189
- # reject messages that don't have solarenergy forecast
190
-
191
- for f in forecast:
192
- if not "solarenergy" in f:
193
- return
194
-
195
- self.ranked_solarpower = self.sort_by_power(forecast, ts_utc_quantized)
196
- self.debug(
197
- f"Solar energy forecast received and ranked for {len(self.ranked_solarpower)} hours"
198
- )
199
- self.power_plan = [] # reset power plan, it depends on forecast
200
- return
201
- elif msg.topic == self.topic_temperature:
202
- m = json.loads(msg.payload.decode())
203
- self.current_temperature = m["temperature"]
204
- elif msg.topic == self.topic_in_net_energy_balance:
205
- m = json.loads(msg.payload.decode())
206
- self.net_energy_balance = m["energy"]
207
- self.net_energy_power = m["power"]
208
- elif msg.topic == self.topic_in_powerconsumption:
209
- m = json.loads(msg.payload.decode())
210
- self.current_power = m["real_total"]
211
- else:
212
- super().on_message(client, userdata, msg)
213
- return
214
- self.on_powerplan(ts)
215
-
216
- def on_powerplan(self, ts_utc_now: float) -> None:
217
- """Apply power plan.
218
-
219
- Args:
220
- ts_utc_now (float): utc time
221
- """
222
-
223
- # optimization, check only once a minute
224
- elapsed: float = ts_utc_now - self.relay_started_ts
225
- if elapsed < 60:
226
- return
227
- self.relay_started_ts = ts_utc_now
228
-
229
- if not self.ranked_spot_prices:
230
- self.debug("Waiting spot prices...", "")
231
- return
232
-
233
- if not self.power_plan:
234
- self.power_plan = self.create_power_plan()
235
- self.heating_plan = []
236
- self.info(
237
- f"Power plan of length {len(self.power_plan)} created",
238
- str(self.power_plan),
239
- )
240
-
241
- if not self.power_plan:
242
- self.error("Failed to create a power plan", "")
243
- return
244
-
245
- if len(self.power_plan) < 3:
246
- self.warning(
247
- f"Suspiciously short {len(self.power_plan)} power plan, wait more data ..",
248
- "",
249
- )
250
- self.heating_plan = []
251
- self.power_plan = []
252
- return
253
-
254
- if not self.ranked_solarpower or len(self.ranked_solarpower) < 4:
255
- self.warning(
256
- f"Short of forecast {len(self.ranked_solarpower)}, optimization compromised..",
257
- "",
258
- )
259
-
260
- if not self.heating_plan:
261
- self.heating_plan = self.create_heating_plan()
262
- if not self.heating_plan:
263
- self.error("Failed to create heating plan")
264
- return
265
- else:
266
- self.info(
267
- f"Heating plan of length {len(self.heating_plan)} created", ""
268
- )
269
- if len(self.heating_plan) < 3:
270
- self.info(f"Short heating plan {len(self.heating_plan)}, no can do", "")
271
- self.heating_plan = []
272
- self.power_plan = []
273
- return
274
-
275
- relay = self.consider_heating(ts_utc_now)
276
- if self.current_relay_state != relay:
277
- heat = {"Unit": self.name, "Timestamp": ts_utc_now, "State": relay}
278
- self.publish(self.topic_power, json.dumps(heat), 1, False)
279
- self.info(
280
- f"Relay state {self.name} changed to {relay} at {timestampstr(ts_utc_now)}",
281
- "",
282
- )
283
- self.current_relay_state = relay
284
-
285
- def consider_net_energy_balance(self, ts: float) -> bool:
286
- """Check when there is enough energy available for the radiators heat
287
- the water the remaining time within the balancing interval,
288
- and switch the balancing mode on. If the remaining time in the
289
- current balancing slot is less than the threshold then
290
- optimize out.
291
-
292
-
293
- Args:
294
- ts (float): current time
295
-
296
- Returns:
297
- bool: true if production exceeds the consumption
298
- """
299
-
300
- # elapsed and remaining time within the current balancing slot
301
- elapsed_ts = ts - quantize(self.energy_balancing_interval, ts)
302
- remaining_ts = self.energy_balancing_interval - elapsed_ts
303
-
304
- # don't bother to switch the relay on for small intervals, to avoid
305
- # wearing contactors out
306
- if remaining_ts < self.operation_threshold:
307
- return False
308
-
309
- # check if the balance is sufficient for heating the next half of the energy balancing interval
310
- # if yes then switch heating on for the next half an hour
311
- needed_energy = 0.5 * self.radiator_power * remaining_ts
312
- elapsed_interval = ts - self.net_energy_balance_ts
313
- if (
314
- self.net_energy_balance > needed_energy
315
- ) and not self.net_energy_balancing_rc:
316
- self.net_energy_balance_ts = ts
317
- self.net_energy_balancing_rc = True # heat
318
- # self.info("Enough to supply the radiator, enable")
319
- self.net_energy_balancing_mode = True # balancing mode indicator on
320
- else:
321
- # check if we have reach the end of the interval, or consumed all the energy
322
- # of the current slot. If so switch the energy balancer mode off
323
- if (
324
- elapsed_interval > self.energy_balancing_interval / 2.0
325
- or self.net_energy_balance < 0
326
- ):
327
- self.net_energy_balancing_rc = False # heating off
328
- # self.info("Balance used, or the end of the interval reached, disable")
329
- return self.net_energy_balancing_rc
330
-
331
- def consider_heating(self, ts: float) -> int:
332
- """Consider whether the target boiler needs heating.
333
-
334
- Args:
335
- ts (float): current UTC time
336
-
337
- Returns:
338
- int: 1 if heating is needed, 0 if not
339
- """
340
-
341
- # check if we have energy to consume, if so return 1
342
- if self.consider_net_energy_balance(ts):
343
- self.warning("TODO: Net energy balance positive, but disabled for now")
344
- # return 1
345
- elif self.net_energy_balancing_mode:
346
- balancing_slot_start_ts = quantize(self.energy_balancing_interval, ts)
347
- elapsed_b = ts - balancing_slot_start_ts
348
- if elapsed_b > self.energy_balancing_interval:
349
- self.net_energy_balancing_mode = False
350
- self.info(
351
- f"TODO: Net energy balancing mode because elapsed {elapsed_b}s is less than balancing interval {self.energy_balancing_interval}s"
352
- )
353
- else:
354
- self.info(
355
- f"TODO: Net energy balance waiting interval {elapsed_b}s to end"
356
- )
357
- # return 0
358
-
359
- if self.current_temperature > self.maximum_boiler_temperature:
360
- self.info(
361
- f"Current temperature {self.current_temperature}C already beyond max {self.maximum_boiler_temperature}C"
362
- )
363
- return 0
364
- hour = timestamp_hour(ts)
365
-
366
- for pp in self.heating_plan:
367
- ppts: float = pp["Timestamp"]
368
- h: float = timestamp_hour(ppts)
369
- if h == hour:
370
- return pp["State"]
371
-
372
- self.error(f"Cannot find heating plan for hour {hour}")
373
- return 0
374
-
375
- # compute figure of merit (FOM) for each hour
376
- # the higher the solarenergy and the lower the spot the higher the FOM
377
-
378
- # compute fom
379
- def compute_fom(self, solpower: float, spot: float, hour: float) -> float:
380
- """Compute UOI - utilization optimization index.
381
-
382
- Args:
383
- solpower (float): current solar power forecast
384
- spot (float): spot price
385
- hour (float) : the hour of the day
386
-
387
- Returns:
388
- float: utilization optimization index
389
- """
390
-
391
- if not is_hour_within_schedule(
392
- hour, self.schedule_start_hour, self.schedule_stop_hour
393
- ):
394
- return 0.0
395
-
396
- # total solar power is 6kW and max pow consumption about twice as much
397
- # so when sun is shining with full power nearly half of the energy comes for free
398
-
399
- if spot < 0.001:
400
- return 2.0 # use
401
- elif spot > 0.2:
402
- return 0.0 # try not to use
403
- else:
404
- fom = 2 * (0.101 - spot) / 0.1
405
- return fom
406
-
407
- def create_power_plan(self) -> list[dict[Any, Any]]:
408
- """Create power plan.
409
-
410
- Returns:
411
- list: list of utilization entries
412
- """
413
- ts_utc_quantized = quantize(3600, timestamp() - 3600)
414
- starts: str = timestampstr(ts_utc_quantized)
415
- self.info(
416
- f"Trying to create power plan starting at {starts} with {len(self.ranked_spot_prices)} hourly spot prices",
417
- "",
418
- )
419
-
420
- # syncronize spot and solarenergy by timestamp
421
- spots = []
422
- for s in self.ranked_spot_prices:
423
- if s["Timestamp"] > ts_utc_quantized:
424
- spots.append(
425
- {"Timestamp": s["Timestamp"], "PriceWithTax": s["PriceWithTax"]}
426
- )
427
- self.info(
428
- f"Have spot prices for the next {len(spots)} hours",
429
- "",
430
- )
431
- powers = []
432
- for s in self.ranked_solarpower:
433
- if s["ts"] >= ts_utc_quantized:
434
- powers.append({"Timestamp": s["ts"], "Solarenergy": s["solarenergy"]})
435
-
436
- num_powers: int = len(powers)
437
- if num_powers == 0:
438
- self.debug(
439
- f"No solar forecast initialized yet, can't proceed",
440
- "",
441
- )
442
- return []
443
- self.debug(
444
- f"Have solar forecast for the next {num_powers} hours",
445
- "",
446
- )
447
- hplan = []
448
- hour: float = 0
449
- if len(powers) >= 8:
450
- for spot, solar in zip(spots, powers):
451
- ts = spot["Timestamp"]
452
- solarenergy = solar["Solarenergy"]
453
- spotprice = spot["PriceWithTax"]
454
- hour = timestamp_hour_local(ts, self.timezone)
455
- fom = self.compute_fom(solarenergy, spotprice, hour)
456
- plan = {
457
- "Timestamp": ts,
458
- "FOM": fom,
459
- "Spot": spotprice,
460
- }
461
- hplan.append(plan)
462
- else:
463
- for spot in spots:
464
- ts = spot["Timestamp"]
465
- solarenergy = 0.0
466
- spotprice = spot["PriceWithTax"]
467
- hour = timestamp_hour_local(ts, self.timezone)
468
- fom = self.compute_fom(solarenergy, spotprice, hour)
469
- plan = {"Timestamp": spot["Timestamp"], "FOM": fom, "Spot": spotprice}
470
- hplan.append(plan)
471
-
472
- shplan = sorted(hplan, key=lambda x: x["FOM"], reverse=True)
473
-
474
- self.debug(f"Powerplan starts {starts} up to {len(shplan)} hours")
475
- return shplan
476
-
477
- def enable_relay(
478
- self, hour: float, spot: float, fom: float, end_hour: float
479
- ) -> bool:
480
- return (
481
- hour >= self.start_hour
482
- and hour < end_hour
483
- and float(spot) < self.spot_limit
484
- and fom
485
- > (self.current_temperature - self.minimum_boiler_temperature)
486
- / (self.maximum_boiler_temperature - self.minimum_boiler_temperature)
487
- )
488
-
489
- def create_heating_plan(self) -> list[dict[str, Any]]:
490
- """Create heating plan.
491
-
492
- Returns:
493
- int: list of heating entries
494
- """
495
-
496
- state = 0
497
- heating_plan = []
498
- hour: int = 0
499
- for hp in self.power_plan:
500
- ts: float = hp["Timestamp"]
501
- fom = hp["FOM"]
502
- spot = hp["Spot"]
503
- end_hour: float = self.start_hour + self.heating_hours_per_day
504
- local_hour: float = timestamp_hour_local(ts, self.timezone)
505
- schedule_on: bool = is_hour_within_schedule(
506
- local_hour, self.schedule_start_hour, self.schedule_stop_hour
507
- )
508
-
509
- if self.enable_relay(hour, spot, fom, end_hour) and schedule_on:
510
- state = 1
511
- else:
512
- state = 0
513
- heat = {
514
- "Unit": self.name,
515
- "Timestamp": ts,
516
- "State": state,
517
- "Schedule": schedule_on,
518
- "UOI": fom,
519
- "Spot": spot,
520
- }
521
-
522
- self.publish(self.topic_powerplan, json.dumps(heat), 1, False)
523
- heating_plan.append(heat)
524
- hour = hour + 1
525
-
526
- self.info(f"Heating plan of {len(heating_plan)} hours created", "")
527
- return heating_plan
1
+ import json
2
+ from typing import Any
3
+ from typing_extensions import override
4
+
5
+ from masterpiece.mqtt import MqttMsg
6
+ from juham_core import Juham
7
+ from juham_core.timeutils import (
8
+ quantize,
9
+ timestamp,
10
+ timestamp_hour,
11
+ timestampstr,
12
+ is_hour_within_schedule,
13
+ timestamp_hour_local,
14
+ )
15
+
16
+
17
+ class HotWaterOptimizer(Juham):
18
+ """Automation class for optimized control of temperature driven home energy consumers e.g hot
19
+ water radiators. Reads spot prices, electricity forecast and temperatures to minimize electricity bill.
20
+ Additional control over heating is provided by the following attributes
21
+
22
+ Attributes:
23
+ schedule_start_hour (float): hour heating can start, in local time
24
+ schedule_stop_hour (float): hour heating is forced to stop
25
+ start_hour (float): start_hour (int): ordinal of the first allowed electricity price slot to be consumed
26
+ num_hours (int): the number of hour slots allowed
27
+
28
+ Computes UOI - optimization utilization index for each hour, based on the spot price and the solar power forecast.
29
+ Value of 0 means the hour is expensive, value of 1 means the hour is free. The UOI threshold determines the slots
30
+ that are allowed to be consumed.
31
+
32
+
33
+ """
34
+
35
+ maximum_boiler_temperature = 70
36
+ minimum_boiler_temperature = 40
37
+ energy_balancing_interval: float = 3600
38
+ radiator_power = 3000 # 3 kW
39
+ operation_threshold = 5 * 60
40
+ heating_hours_per_day = 4 # use the four cheapest hours per day
41
+ schedule_start_hour: float = 0
42
+ schedule_stop_hour: float = 0
43
+ timezone = "Europe/Helsinki"
44
+ expected_average_price = 0.2 # 20 eur per kWh
45
+ uoi_threshold = 0.8
46
+
47
+ def __init__(
48
+ self,
49
+ name: str,
50
+ temperature_sensor: str,
51
+ start_hour: int,
52
+ num_hours: int,
53
+ spot_limit: float,
54
+ ) -> None:
55
+ """Create power plan for automating temperature driven systems, e.g. heating radiators
56
+ to optimize energy consumption based on electricity prices.
57
+
58
+ Electricity Price MQTT Topic: This specifies the MQTT topic through which the controller receives
59
+ hourly electricity price forecasts for the next day or two.
60
+ Radiator Control Topic: The MQTT topic used to control the radiator relay.
61
+ Temperature Sensor Topic: The MQTT topic where the temperature sensor publishes its readings.
62
+ Electricity Price Slot Range: A pair of integers determining which electricity price slots the
63
+ controller uses. The slots are ranked from the cheapest to the most expensive. For example:
64
+ - A range of 0, 3 directs the controller to use electricity during the three cheapest hours.
65
+ - A second controller with a range of 3, 2 would target the next two cheapest hours, and so on.
66
+ Maximum Electricity Price Threshold: An upper limit for the electricity price, serving as an additional control.
67
+ The controller only operates within its designated price slots if the prices are below this threshold.
68
+
69
+ The maximum price threshold reflects the criticality of the radiator's operation:
70
+
71
+ High thresholds indicate that the radiator should remain operational regardless of the price.
72
+ Low thresholds imply the radiator can be turned off during expensive periods, suggesting it has a less critical role.
73
+
74
+ By combining these attributes, the controller ensures efficient energy usage while maintaining desired heating levels.
75
+
76
+ Args:
77
+ name (str): name of the heating radiator
78
+ temperature_sensor (str): temperature sensor of the heating radiator
79
+ start_hour (int): ordinal of the first allowed electricity price slot to be consumed
80
+ num_hours (int): the number of slots allowed
81
+ spot_limit (float): maximum price allowed
82
+ """
83
+ super().__init__(name)
84
+
85
+ self.heating_hours_per_day = num_hours
86
+ self.start_hour = start_hour
87
+ self.spot_limit = spot_limit
88
+
89
+ self.topic_spot = self.make_topic_name("spot")
90
+ self.topic_forecast = self.make_topic_name("forecast")
91
+ self.topic_temperature = self.make_topic_name(temperature_sensor)
92
+ self.topic_powerplan = self.make_topic_name("powerplan")
93
+ self.topic_power = self.make_topic_name("power")
94
+ self.topic_in_powerconsumption = self.make_topic_name("powerconsumption")
95
+ self.topic_in_net_energy_balance = self.make_topic_name("net_energy_balance")
96
+
97
+ self.current_temperature = 100
98
+ self.current_heating_plan = 0
99
+ self.current_relay_state = -1
100
+ self.heating_plan: list[dict[str, int]] = []
101
+ self.power_plan: list[dict[str, Any]] = []
102
+ self.ranked_spot_prices: list[dict[Any, Any]] = []
103
+ self.ranked_solarpower: list[dict[Any, Any]] = []
104
+ self.relay: bool = False
105
+ self.relay_started_ts: float = 0
106
+ self.current_power: float = 0
107
+ self.net_energy_balance: float = 0.0
108
+ self.net_energy_power: float = 0
109
+ self.net_energy_balance_ts: float = 0
110
+ self.net_energy_balancing_rc: bool = False
111
+ self.net_energy_balancing_mode = False
112
+
113
+ @override
114
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
115
+ super().on_connect(client, userdata, flags, rc)
116
+ if rc == 0:
117
+ self.subscribe(self.topic_spot)
118
+ self.subscribe(self.topic_forecast)
119
+ self.subscribe(self.topic_temperature)
120
+ self.subscribe(self.topic_in_powerconsumption)
121
+ self.subscribe(self.topic_in_net_energy_balance)
122
+
123
+ def sort_by_rank(
124
+ self, hours: list[dict[str, Any]], ts_utc_now: float
125
+ ) -> list[dict[str, Any]]:
126
+ """Sort the given electricity prices by their rank value. Given a list
127
+ of electricity prices, return a sorted list from the cheapest to the
128
+ most expensive hours. Entries that represent electricity prices in the
129
+ past are excluded.
130
+
131
+ Args:
132
+ hours (list): list of hourly electricity prices
133
+ ts_utc_now (float): current time
134
+
135
+ Returns:
136
+ list: sorted list of electricity prices
137
+ """
138
+ sh = sorted(hours, key=lambda x: x["Rank"])
139
+ ranked_hours = []
140
+ for h in sh:
141
+ utc_ts = h["Timestamp"]
142
+ if utc_ts > ts_utc_now:
143
+ ranked_hours.append(h)
144
+
145
+ return ranked_hours
146
+
147
+ def sort_by_power(
148
+ self, solarpower: list[dict[Any, Any]], ts_utc: float
149
+ ) -> list[dict[Any, Any]]:
150
+ """Sort forecast of solarpower to decreasing order.
151
+
152
+ Args:
153
+ solarpower (list): list of entries describing hourly solar energy forecast
154
+ ts_utc(float): start time, for exluding entries that are in the past
155
+
156
+ Returns:
157
+ list: list from the highest solarenergy to lowest.
158
+ """
159
+
160
+ # if all items have solarenergy key then
161
+ # sh = sorted(solarpower, key=lambda x: x["solarenergy"], reverse=True)
162
+ # else skip items that don't have solarenergy key
163
+ sh = sorted(
164
+ [item for item in solarpower if "solarenergy" in item],
165
+ key=lambda x: x["solarenergy"],
166
+ reverse=True,
167
+ )
168
+ self.debug(
169
+ f"Sorted {len(sh)} days of forecast starting at {timestampstr(ts_utc)}"
170
+ )
171
+ ranked_hours = []
172
+
173
+ for h in sh:
174
+ utc_ts: float = float(h["ts"])
175
+ if utc_ts >= ts_utc:
176
+ ranked_hours.append(h)
177
+ self.debug(f"Forecast sorted for the next {str(len(ranked_hours))} hours")
178
+ return ranked_hours
179
+
180
+ @override
181
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
182
+ m = None
183
+ ts: float = timestamp()
184
+ ts_utc_quantized: float = quantize(3600, ts - 3600)
185
+ if msg.topic == self.topic_spot:
186
+ self.ranked_spot_prices = self.sort_by_rank(
187
+ json.loads(msg.payload.decode()), ts_utc_quantized
188
+ )
189
+ self.debug(
190
+ f"Spot prices received and ranked for {len(self.ranked_spot_prices)} hours"
191
+ )
192
+ self.power_plan = [] # reset power plan, it depends on spot prices
193
+ return
194
+ elif msg.topic == self.topic_forecast:
195
+ forecast = json.loads(msg.payload.decode())
196
+ # reject messages that don't have solarenergy forecast
197
+
198
+ for f in forecast:
199
+ if not "solarenergy" in f:
200
+ return
201
+
202
+ self.ranked_solarpower = self.sort_by_power(forecast, ts_utc_quantized)
203
+ self.debug(
204
+ f"Solar energy forecast received and ranked for {len(self.ranked_solarpower)} hours"
205
+ )
206
+ self.power_plan = [] # reset power plan, it depends on forecast
207
+ return
208
+ elif msg.topic == self.topic_temperature:
209
+ m = json.loads(msg.payload.decode())
210
+ self.current_temperature = m["temperature"]
211
+ elif msg.topic == self.topic_in_net_energy_balance:
212
+ m = json.loads(msg.payload.decode())
213
+ self.net_energy_balance = m["energy"]
214
+ self.net_energy_power = m["power"]
215
+ elif msg.topic == self.topic_in_powerconsumption:
216
+ m = json.loads(msg.payload.decode())
217
+ self.current_power = m["real_total"]
218
+ else:
219
+ super().on_message(client, userdata, msg)
220
+ return
221
+ self.on_powerplan(ts)
222
+
223
+ def on_powerplan(self, ts_utc_now: float) -> None:
224
+ """Apply power plan.
225
+
226
+ Args:
227
+ ts_utc_now (float): utc time
228
+ """
229
+
230
+ # optimization, check only once a minute
231
+ elapsed: float = ts_utc_now - self.relay_started_ts
232
+ if elapsed < 60:
233
+ return
234
+ self.relay_started_ts = ts_utc_now
235
+
236
+ if not self.ranked_spot_prices:
237
+ self.debug("Waiting spot prices...", "")
238
+ return
239
+
240
+ if not self.power_plan:
241
+ self.power_plan = self.create_power_plan()
242
+ self.heating_plan = []
243
+ self.info(
244
+ f"Power plan of length {len(self.power_plan)} created",
245
+ str(self.power_plan),
246
+ )
247
+
248
+ if not self.power_plan:
249
+ self.error("Failed to create a power plan", "")
250
+ return
251
+
252
+ if len(self.power_plan) < 3:
253
+ self.warning(
254
+ f"Suspiciously short {len(self.power_plan)} power plan, wait more data ..",
255
+ "",
256
+ )
257
+ self.heating_plan = []
258
+ self.power_plan = []
259
+ return
260
+
261
+ if not self.ranked_solarpower or len(self.ranked_solarpower) < 4:
262
+ self.warning(
263
+ f"Short of forecast {len(self.ranked_solarpower)}, optimization compromised..",
264
+ "",
265
+ )
266
+
267
+ if not self.heating_plan:
268
+ self.heating_plan = self.create_heating_plan()
269
+ if not self.heating_plan:
270
+ self.error("Failed to create heating plan")
271
+ return
272
+ else:
273
+ self.info(
274
+ f"Heating plan of length {len(self.heating_plan)} created", ""
275
+ )
276
+ if len(self.heating_plan) < 3:
277
+ self.info(f"Short heating plan {len(self.heating_plan)}, no can do", "")
278
+ self.heating_plan = []
279
+ self.power_plan = []
280
+ return
281
+
282
+ relay = self.consider_heating(ts_utc_now)
283
+ if self.current_relay_state != relay:
284
+ heat = {"Unit": self.name, "Timestamp": ts_utc_now, "State": relay}
285
+ self.publish(self.topic_power, json.dumps(heat), 1, False)
286
+ self.info(
287
+ f"Relay state {self.name} changed to {relay} at {timestampstr(ts_utc_now)}",
288
+ "",
289
+ )
290
+ self.current_relay_state = relay
291
+
292
+ def consider_net_energy_balance(self, ts: float) -> bool:
293
+ """Check when there is enough energy available for the radiators heat
294
+ the water the remaining time within the balancing interval,
295
+ and switch the balancing mode on. If the remaining time in the
296
+ current balancing slot is less than the threshold then
297
+ optimize out.
298
+
299
+
300
+ Args:
301
+ ts (float): current time
302
+
303
+ Returns:
304
+ bool: true if production exceeds the consumption
305
+ """
306
+
307
+ # elapsed and remaining time within the current balancing slot
308
+ elapsed_ts = ts - quantize(self.energy_balancing_interval, ts)
309
+ remaining_ts = self.energy_balancing_interval - elapsed_ts
310
+
311
+ # don't bother to switch the relay on for small intervals, to avoid
312
+ # wearing contactors out
313
+ if remaining_ts < self.operation_threshold:
314
+ return False
315
+
316
+ # check if the balance is sufficient for heating the next half of the energy balancing interval
317
+ # if yes then switch heating on for the next half an hour
318
+ needed_energy = 0.5 * self.radiator_power * remaining_ts
319
+ elapsed_interval = ts - self.net_energy_balance_ts
320
+ if (
321
+ self.net_energy_balance > needed_energy
322
+ ) and not self.net_energy_balancing_rc:
323
+ self.net_energy_balance_ts = ts
324
+ self.net_energy_balancing_rc = True # heat
325
+ # self.info("Enough to supply the radiator, enable")
326
+ self.net_energy_balancing_mode = True # balancing mode indicator on
327
+ else:
328
+ # check if we have reach the end of the interval, or consumed all the energy
329
+ # of the current slot. If so switch the energy balancer mode off
330
+ if (
331
+ elapsed_interval > self.energy_balancing_interval / 2.0
332
+ or self.net_energy_balance < 0
333
+ ):
334
+ self.net_energy_balancing_rc = False # heating off
335
+ # self.info("Balance used, or the end of the interval reached, disable")
336
+ return self.net_energy_balancing_rc
337
+
338
+ def consider_heating(self, ts: float) -> int:
339
+ """Consider whether the target boiler needs heating.
340
+
341
+ Args:
342
+ ts (float): current UTC time
343
+
344
+ Returns:
345
+ int: 1 if heating is needed, 0 if not
346
+ """
347
+
348
+ # check if we have energy to consume, if so return 1
349
+ if self.consider_net_energy_balance(ts):
350
+ self.warning("TODO: Net energy balance positive, but disabled for now")
351
+ # return 1
352
+ elif self.net_energy_balancing_mode:
353
+ balancing_slot_start_ts = quantize(self.energy_balancing_interval, ts)
354
+ elapsed_b = ts - balancing_slot_start_ts
355
+ if elapsed_b > self.energy_balancing_interval:
356
+ self.net_energy_balancing_mode = False
357
+ self.info(
358
+ f"TODO: Net energy balancing mode because elapsed {elapsed_b}s is less than balancing interval {self.energy_balancing_interval}s"
359
+ )
360
+ else:
361
+ self.info(
362
+ f"TODO: Net energy balance waiting interval {elapsed_b}s to end"
363
+ )
364
+ # return 0
365
+
366
+ if self.current_temperature > self.maximum_boiler_temperature:
367
+ self.info(
368
+ f"Current temperature {self.current_temperature}C already beyond max {self.maximum_boiler_temperature}C"
369
+ )
370
+ return 0
371
+ hour = timestamp_hour(ts)
372
+
373
+ for pp in self.heating_plan:
374
+ ppts: float = pp["Timestamp"]
375
+ h: float = timestamp_hour(ppts)
376
+ if h == hour:
377
+ return pp["State"]
378
+
379
+ self.error(f"Cannot find heating plan for hour {hour}")
380
+ return 0
381
+
382
+ # compute figure of merit (FOM) for each hour
383
+ # the higher the solarenergy and the lower the spot the higher the FOM
384
+
385
+ # compute utilization optimization index
386
+ def compute_uoi(
387
+ self,
388
+ price: float,
389
+ hour: float,
390
+ ) -> float:
391
+ """Compute UOI - utilization optimization index.
392
+
393
+ Args:
394
+ price (float): effective price for this device
395
+ hour (float) : the hour of the day
396
+
397
+ Returns:
398
+ float: utilization optimization index
399
+ """
400
+
401
+ if not is_hour_within_schedule(
402
+ hour, self.schedule_start_hour, self.schedule_stop_hour
403
+ ):
404
+ return 0.0
405
+
406
+ if price < 0.0001:
407
+ return 1.0 # use
408
+ elif price > self.expected_average_price:
409
+ return 0.0 # try not to use
410
+ else:
411
+ fom = self.expected_average_price / price
412
+ return fom
413
+
414
+ def compute_effective_price(
415
+ self, requested_power: float, available_solpower: float, spot: float
416
+ ) -> float:
417
+ """Compute effective electricity price. If there is enough solar power then
418
+ electricity price is zero.
419
+
420
+ Args:
421
+ requested_power (float): requested power
422
+ available_solpower (float): current solar power forecast
423
+ spot (float): spot price
424
+ hour (float) : the hour of the day
425
+
426
+ Returns:
427
+ float: effective price for the requested power
428
+ """
429
+
430
+ # if we have enough solar power, use it
431
+ if requested_power < available_solpower:
432
+ return 0.0
433
+
434
+ # check how much of the power is solar and how much is from the grid
435
+ solar_factor: float = available_solpower / requested_power
436
+
437
+ effective_spot: float = spot * (1 - solar_factor)
438
+
439
+ return effective_spot
440
+
441
+ def create_power_plan(self) -> list[dict[Any, Any]]:
442
+ """Create power plan.
443
+
444
+ Returns:
445
+ list: list of utilization entries
446
+ """
447
+ ts_utc_quantized = quantize(3600, timestamp() - 3600)
448
+ starts: str = timestampstr(ts_utc_quantized)
449
+ self.info(
450
+ f"Trying to create power plan starting at {starts} with {len(self.ranked_spot_prices)} hourly spot prices",
451
+ "",
452
+ )
453
+
454
+ # syncronize spot and solarenergy by timestamp
455
+ spots = []
456
+ for s in self.ranked_spot_prices:
457
+ if s["Timestamp"] > ts_utc_quantized:
458
+ spots.append(
459
+ {"Timestamp": s["Timestamp"], "PriceWithTax": s["PriceWithTax"]}
460
+ )
461
+
462
+ if len(spots) == 0:
463
+ self.debug(
464
+ f"No spot prices initialized yet, can't proceed",
465
+ "",
466
+ )
467
+ self.info(
468
+ f"Have spot prices for the next {len(spots)} hours",
469
+ "",
470
+ )
471
+ powers = []
472
+ for s in self.ranked_solarpower:
473
+ if s["ts"] >= ts_utc_quantized:
474
+ powers.append({"Timestamp": s["ts"], "Solarenergy": s["solarenergy"]})
475
+
476
+ num_powers: int = len(powers)
477
+ if num_powers == 0:
478
+ self.debug(
479
+ f"No solar forecast initialized yet, proceed without solar forecast",
480
+ "",
481
+ )
482
+ else:
483
+ self.debug(
484
+ f"Have solar forecast for the next {num_powers} hours",
485
+ "",
486
+ )
487
+ hplan = []
488
+ hour: float = 0
489
+ if len(powers) >= 8: # at least 8 hours of solar energy forecast
490
+ for spot, solar in zip(spots, powers):
491
+ ts = spot["Timestamp"]
492
+ solarenergy = solar["Solarenergy"]
493
+ spotprice = spot["PriceWithTax"]
494
+ effective_price: float = self.compute_effective_price(
495
+ self.radiator_power, solarenergy, spotprice
496
+ )
497
+ hour = timestamp_hour_local(ts, self.timezone)
498
+ fom = self.compute_uoi(spotprice, hour)
499
+ plan = {
500
+ "Timestamp": ts,
501
+ "FOM": fom,
502
+ "Spot": effective_price,
503
+ }
504
+ hplan.append(plan)
505
+ else: # no solar forecast available, assume no free energy available
506
+ for spot in spots:
507
+ ts = spot["Timestamp"]
508
+ solarenergy = 0.0
509
+ spotprice = spot["PriceWithTax"]
510
+ effective_price = spotprice # no free energy available
511
+ hour = timestamp_hour_local(ts, self.timezone)
512
+ fom = self.compute_uoi(effective_price, hour)
513
+ plan = {
514
+ "Timestamp": spot["Timestamp"],
515
+ "FOM": fom,
516
+ "Spot": effective_price,
517
+ }
518
+ hplan.append(plan)
519
+
520
+ shplan = sorted(hplan, key=lambda x: x["FOM"], reverse=True)
521
+
522
+ self.debug(f"Powerplan starts {starts} up to {len(shplan)} hours")
523
+ return shplan
524
+
525
+ def enable_relay(
526
+ self, hour: float, spot: float, fom: float, end_hour: float
527
+ ) -> bool:
528
+ return (
529
+ hour >= self.start_hour
530
+ and hour < end_hour
531
+ and float(spot) < self.spot_limit
532
+ and fom > self.uoi_threshold
533
+ )
534
+
535
+ def create_heating_plan(self) -> list[dict[str, Any]]:
536
+ """Create heating plan.
537
+
538
+ Returns:
539
+ int: list of heating entries
540
+ """
541
+
542
+ state = 0
543
+ heating_plan = []
544
+ hour: int = 0
545
+ for hp in self.power_plan:
546
+ ts: float = hp["Timestamp"]
547
+ fom = hp["FOM"]
548
+ spot = hp["Spot"]
549
+ end_hour: float = self.start_hour + self.heating_hours_per_day
550
+ local_hour: float = timestamp_hour_local(ts, self.timezone)
551
+ schedule_on: bool = is_hour_within_schedule(
552
+ local_hour, self.schedule_start_hour, self.schedule_stop_hour
553
+ )
554
+
555
+ if self.enable_relay(hour, spot, fom, end_hour) and schedule_on:
556
+ state = 1
557
+ else:
558
+ state = 0
559
+ heat = {
560
+ "Unit": self.name,
561
+ "Timestamp": ts,
562
+ "State": state,
563
+ "Schedule": schedule_on,
564
+ "UOI": fom,
565
+ "Spot": spot,
566
+ }
567
+
568
+ self.publish(self.topic_powerplan, json.dumps(heat), 1, False)
569
+ heating_plan.append(heat)
570
+ hour = hour + 1
571
+
572
+ self.info(f"Heating plan of {len(heating_plan)} hours created", "")
573
+ return heating_plan