juham-automation 0.0.19__py3-none-any.whl → 0.0.27__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 -620
  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.27.dist-info/METADATA +152 -0
  20. juham_automation-0.0.27.dist-info/RECORD +25 -0
  21. {juham_automation-0.0.19.dist-info → juham_automation-0.0.27.dist-info}/entry_points.txt +3 -1
  22. {juham_automation-0.0.19.dist-info → juham_automation-0.0.27.dist-info}/licenses/LICENSE.rst +25 -25
  23. juham_automation-0.0.19.dist-info/METADATA +0 -106
  24. juham_automation-0.0.19.dist-info/RECORD +0 -23
  25. {juham_automation-0.0.19.dist-info → juham_automation-0.0.27.dist-info}/WHEEL +0 -0
  26. {juham_automation-0.0.19.dist-info → juham_automation-0.0.27.dist-info}/top_level.txt +0 -0
@@ -1,620 +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
- def on_spot(self, m: list[dict[str, Any]], ts_quantized: float) -> None:
180
- """Handle the spot prices.
181
-
182
- Args:
183
- list[dict[str, Any]]: list of spot prices
184
- ts_quantized (float): current time
185
- """
186
- self.ranked_spot_prices = self.sort_by_rank(m, ts_quantized)
187
-
188
- def on_forecast(
189
- self, forecast: list[dict[str, Any]], ts_utc_quantized: float
190
- ) -> None:
191
- """Handle the solar forecast.
192
-
193
- Args:
194
- m (list[dict[str, Any]]): list of forecast prices
195
- ts_quantized (float): current time
196
- """
197
- # reject forecasts that don't have solarenergy key
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
-
208
- def on_power(self, m: dict[str, Any], ts: float) -> None:
209
- """Handle the power consumption. Read the current power balance and accumulate
210
- to the net energy balance to reflect the energy produced (or consumed) within the
211
- current time slot.
212
- Args:
213
- m (dict[str, Any]): power consumption message
214
- ts (float): current time
215
- """
216
- self.net_energy_power = m["power"]
217
- balance: float = (ts - self.net_energy_balance_ts) * self.net_energy_power
218
- self.net_energy_balance = self.net_energy_balance + balance
219
- self.net_energy_balance_ts = ts
220
-
221
- @override
222
- def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
223
- m = None
224
- ts: float = timestamp()
225
- ts_utc_quantized: float = quantize(3600, ts - 3600)
226
- if msg.topic == self.topic_spot:
227
- self.on_spot(json.loads(msg.payload.decode()), ts_utc_quantized)
228
- return
229
- elif msg.topic == self.topic_forecast:
230
- self.on_forecast(json.loads(msg.payload.decode()), ts_utc_quantized)
231
- return
232
- elif msg.topic == self.topic_temperature:
233
- m = json.loads(msg.payload.decode())
234
- self.current_temperature = m["temperature"]
235
- elif msg.topic == self.topic_in_net_energy_balance:
236
- self.on_power(json.loads(msg.payload.decode()), ts)
237
- elif msg.topic == self.topic_in_powerconsumption:
238
- m = json.loads(msg.payload.decode())
239
- self.current_power = m["real_total"]
240
- else:
241
- super().on_message(client, userdata, msg)
242
- return
243
- self.on_powerplan(ts)
244
-
245
- def on_powerplan(self, ts_utc_now: float) -> None:
246
- """Apply the power plan. Check if the relay needs to be switched on or off.
247
- The relay is switched on if the current temperature is below the maximum
248
- temperature and the current time is within the heating plan. The relay is switched off
249
- if the current temperature is above the maximum temperature or the current time is outside.
250
-
251
- Args:
252
- ts_utc_now (float): utc time
253
- """
254
-
255
- # optimization, check only once a minute
256
- elapsed: float = ts_utc_now - self.relay_started_ts
257
- if elapsed < 60:
258
- return
259
- self.relay_started_ts = ts_utc_now
260
-
261
- if not self.ranked_spot_prices:
262
- self.debug("Waiting spot prices...", "")
263
- return
264
-
265
- if not self.power_plan:
266
- self.power_plan = self.create_power_plan()
267
- self.heating_plan = []
268
- self.info(
269
- f"Power plan of length {len(self.power_plan)} created",
270
- str(self.power_plan),
271
- )
272
-
273
- if not self.power_plan:
274
- self.error("Failed to create a power plan", "")
275
- return
276
-
277
- if len(self.power_plan) < 3:
278
- self.warning(
279
- f"Suspiciously short {len(self.power_plan)} power plan, wait more data ..",
280
- "",
281
- )
282
- self.heating_plan = []
283
- self.power_plan = []
284
- return
285
-
286
- if not self.ranked_solarpower or len(self.ranked_solarpower) < 4:
287
- self.warning(
288
- f"Short of forecast {len(self.ranked_solarpower)}, optimization compromised..",
289
- "",
290
- )
291
-
292
- if not self.heating_plan:
293
- self.heating_plan = self.create_heating_plan()
294
- if not self.heating_plan:
295
- self.error("Failed to create heating plan")
296
- return
297
- else:
298
- self.info(
299
- f"Heating plan of length {len(self.heating_plan)} created", ""
300
- )
301
- if len(self.heating_plan) < 3:
302
- self.info(f"Short heating plan {len(self.heating_plan)}, no can do", "")
303
- self.heating_plan = []
304
- self.power_plan = []
305
- return
306
-
307
- relay = self.consider_heating(ts_utc_now)
308
- if self.current_relay_state != relay:
309
- heat = {"Unit": self.name, "Timestamp": ts_utc_now, "State": relay}
310
- self.publish(self.topic_power, json.dumps(heat), 1, False)
311
- self.info(
312
- f"Relay state {self.name} changed to {relay} at {timestampstr(ts_utc_now)}",
313
- "",
314
- )
315
- self.current_relay_state = relay
316
-
317
- def consider_net_energy_balance(self, ts: float) -> bool:
318
- """Check when there is enough energy available for the radiators heat
319
- the water the remaining time within the balancing interval,
320
- and switch the balancing mode on. If the remaining time in the
321
- current balancing slot is less than the threshold then
322
- optimize out.
323
-
324
-
325
- Args:
326
- ts (float): current time
327
-
328
- Returns:
329
- bool: true if production exceeds the consumption
330
- """
331
-
332
- # if net energy balance is negative, then we are not in balancing mode
333
- if self.net_energy_balance < 0:
334
- return False
335
-
336
- # elapsed and remaining time within the current balancing slot
337
- elapsed_ts = ts - quantize(self.energy_balancing_interval, ts)
338
- remaining_ts = self.energy_balancing_interval - elapsed_ts
339
-
340
- # don't bother to switch the relay on for small intervals, to avoid
341
- # wearing contactors out
342
- if (
343
- not self.net_energy_balancing_mode
344
- and remaining_ts < self.operation_threshold
345
- ):
346
- print(
347
- f"Skipping balance, remaining time {remaining_ts}s < {self.operation_threshold}s"
348
- )
349
- return False
350
- elif remaining_ts <= 0:
351
- self.net_energy_balancing_rc = False # heating off
352
- self.info(
353
- f"End of the balancing interval reached, disabled with {self.net_energy_balance/3600}kWh left"
354
- )
355
- self.net_energy_balance = 0.0
356
- self.net_energy_balance_ts = ts
357
- return False
358
-
359
- # check if the balance is sufficient for heating the remainin interval
360
- # if yes then switch heating on
361
- needed_energy = self.radiator_power * remaining_ts
362
- elapsed_interval = ts - self.net_energy_balance_ts
363
- print(
364
- f"Needed energy {int(needed_energy)/3600}kWh, current balance {int(self.net_energy_balance/3600)}kWh"
365
- )
366
-
367
- if (
368
- self.net_energy_balance >= needed_energy
369
- ) and not self.net_energy_balancing_rc:
370
- self.net_energy_balance_ts = ts
371
- self.net_energy_balancing_rc = True # heat
372
- self.info("Enough to supply the radiator, enable")
373
- self.net_energy_balancing_mode = True # balancing mode indicator on
374
- else:
375
- # check if we have reach the end of the interval, or consumed all the energy
376
- # of the current slot. If so switch the energy balancer mode off
377
- if (
378
- elapsed_interval > self.energy_balancing_interval
379
- or self.net_energy_balance < 0
380
- ):
381
- self.net_energy_balancing_rc = False # heating off
382
- self.info("Balance used, or the end of the interval reached, disable")
383
- return self.net_energy_balancing_rc
384
-
385
- def consider_heating(self, ts: float) -> int:
386
- """Consider whether the target boiler needs heating. Check first if the solar
387
- energy is enough to heat the water the remaining time in the current slot.
388
- If not, follow the predefined heating plan computed earlier based on the cheapest spot prices.
389
-
390
- Args:
391
- ts (float): current UTC time
392
-
393
- Returns:
394
- int: 1 if heating is needed, 0 if not
395
- """
396
-
397
- # check if we have energy to consume, if so return 1
398
- if self.consider_net_energy_balance(ts):
399
- self.info("Net energy balance positive")
400
- return 1
401
- elif self.net_energy_balancing_mode:
402
- balancing_slot_start_ts = quantize(self.energy_balancing_interval, ts)
403
- elapsed_b = ts - balancing_slot_start_ts
404
- if elapsed_b > self.energy_balancing_interval:
405
- self.net_energy_balancing_mode = False
406
- self.info(
407
- f"Exiting net energy balancing mode: {elapsed_b}s > {self.energy_balancing_interval}s"
408
- )
409
- else:
410
- self.info(f"Net energy balance waiting interval {elapsed_b}s to end")
411
- return 0
412
-
413
- if self.current_temperature > self.maximum_boiler_temperature:
414
- self.info(
415
- f"Current temperature {self.current_temperature}C already beyond max {self.maximum_boiler_temperature}C"
416
- )
417
- return 0
418
- hour = timestamp_hour(ts)
419
-
420
- for pp in self.heating_plan:
421
- ppts: float = pp["Timestamp"]
422
- h: float = timestamp_hour(ppts)
423
- if h == hour:
424
- return pp["State"]
425
-
426
- self.error(f"Cannot find heating plan for hour {hour}")
427
- return 0
428
-
429
- # compute figure of merit (FOM) for each hour
430
- # the higher the solarenergy and the lower the spot the higher the FOM
431
-
432
- # compute utilization optimization index
433
- def compute_uoi(
434
- self,
435
- price: float,
436
- hour: float,
437
- ) -> float:
438
- """Compute UOI - utilization optimization index.
439
-
440
- Args:
441
- price (float): effective price for this device
442
- hour (float) : the hour of the day
443
-
444
- Returns:
445
- float: utilization optimization index
446
- """
447
-
448
- if not is_hour_within_schedule(
449
- hour, self.schedule_start_hour, self.schedule_stop_hour
450
- ):
451
- return 0.0
452
-
453
- if price < 0.0001:
454
- return 1.0 # use
455
- elif price > self.expected_average_price:
456
- return 0.0 # try not to use
457
- else:
458
- fom = self.expected_average_price / price
459
- return fom
460
-
461
- def compute_effective_price(
462
- self, requested_power: float, available_solpower: float, spot: float
463
- ) -> float:
464
- """Compute effective electricity price. If there is enough solar power then
465
- electricity price is zero.
466
-
467
- Args:
468
- requested_power (float): requested power
469
- available_solpower (float): current solar power forecast
470
- spot (float): spot price
471
- hour (float) : the hour of the day
472
-
473
- Returns:
474
- float: effective price for the requested power
475
- """
476
-
477
- # if we have enough solar power, use it
478
- if requested_power < available_solpower:
479
- return 0.0
480
-
481
- # check how much of the power is solar and how much is from the grid
482
- solar_factor: float = available_solpower / requested_power
483
-
484
- effective_spot: float = spot * (1 - solar_factor)
485
-
486
- return effective_spot
487
-
488
- def create_power_plan(self) -> list[dict[Any, Any]]:
489
- """Create power plan.
490
-
491
- Returns:
492
- list: list of utilization entries
493
- """
494
- ts_utc_quantized = quantize(3600, timestamp() - 3600)
495
- starts: str = timestampstr(ts_utc_quantized)
496
- self.info(
497
- f"Trying to create power plan starting at {starts} with {len(self.ranked_spot_prices)} hourly spot prices",
498
- "",
499
- )
500
-
501
- # syncronize spot and solarenergy by timestamp
502
- spots = []
503
- for s in self.ranked_spot_prices:
504
- if s["Timestamp"] > ts_utc_quantized:
505
- spots.append(
506
- {"Timestamp": s["Timestamp"], "PriceWithTax": s["PriceWithTax"]}
507
- )
508
-
509
- if len(spots) == 0:
510
- self.debug(
511
- f"No spot prices initialized yet, can't proceed",
512
- "",
513
- )
514
- self.info(
515
- f"Have spot prices for the next {len(spots)} hours",
516
- "",
517
- )
518
- powers = []
519
- for s in self.ranked_solarpower:
520
- if s["ts"] >= ts_utc_quantized:
521
- powers.append({"Timestamp": s["ts"], "Solarenergy": s["solarenergy"]})
522
-
523
- num_powers: int = len(powers)
524
- if num_powers == 0:
525
- self.debug(
526
- f"No solar forecast initialized yet, proceed without solar forecast",
527
- "",
528
- )
529
- else:
530
- self.debug(
531
- f"Have solar forecast for the next {num_powers} hours",
532
- "",
533
- )
534
- hplan = []
535
- hour: float = 0
536
- if len(powers) >= 8: # at least 8 hours of solar energy forecast
537
- for spot, solar in zip(spots, powers):
538
- ts = spot["Timestamp"]
539
- solarenergy = solar["Solarenergy"] * 1000 # argh, this is in kW
540
- spotprice = spot["PriceWithTax"]
541
- effective_price: float = self.compute_effective_price(
542
- self.radiator_power, solarenergy, spotprice
543
- )
544
- hour = timestamp_hour_local(ts, self.timezone)
545
- fom = self.compute_uoi(spotprice, hour)
546
- plan = {
547
- "Timestamp": ts,
548
- "FOM": fom,
549
- "Spot": effective_price,
550
- }
551
- hplan.append(plan)
552
- else: # no solar forecast available, assume no free energy available
553
- for spot in spots:
554
- ts = spot["Timestamp"]
555
- solarenergy = 0.0
556
- spotprice = spot["PriceWithTax"]
557
- effective_price = spotprice # no free energy available
558
- hour = timestamp_hour_local(ts, self.timezone)
559
- fom = self.compute_uoi(effective_price, hour)
560
- plan = {
561
- "Timestamp": spot["Timestamp"],
562
- "FOM": fom,
563
- "Spot": effective_price,
564
- }
565
- hplan.append(plan)
566
-
567
- shplan = sorted(hplan, key=lambda x: x["FOM"], reverse=True)
568
-
569
- self.debug(f"Powerplan starts {starts} up to {len(shplan)} hours")
570
- return shplan
571
-
572
- def enable_relay(
573
- self, hour: float, spot: float, fom: float, end_hour: float
574
- ) -> bool:
575
- return (
576
- hour >= self.start_hour
577
- and hour < end_hour
578
- and float(spot) < self.spot_limit
579
- and fom > self.uoi_threshold
580
- )
581
-
582
- def create_heating_plan(self) -> list[dict[str, Any]]:
583
- """Create heating plan.
584
-
585
- Returns:
586
- int: list of heating entries
587
- """
588
-
589
- state = 0
590
- heating_plan = []
591
- hour: int = 0
592
- for hp in self.power_plan:
593
- ts: float = hp["Timestamp"]
594
- fom = hp["FOM"]
595
- spot = hp["Spot"]
596
- end_hour: float = self.start_hour + self.heating_hours_per_day
597
- local_hour: float = timestamp_hour_local(ts, self.timezone)
598
- schedule_on: bool = is_hour_within_schedule(
599
- local_hour, self.schedule_start_hour, self.schedule_stop_hour
600
- )
601
-
602
- if self.enable_relay(hour, spot, fom, end_hour) and schedule_on:
603
- state = 1
604
- else:
605
- state = 0
606
- heat = {
607
- "Unit": self.name,
608
- "Timestamp": ts,
609
- "State": state,
610
- "Schedule": schedule_on,
611
- "UOI": fom,
612
- "Spot": spot,
613
- }
614
-
615
- self.publish(self.topic_powerplan, json.dumps(heat), 1, False)
616
- heating_plan.append(heat)
617
- hour = hour + 1
618
-
619
- self.info(f"Heating plan of {len(heating_plan)} hours created", "")
620
- 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