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