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