juham-automation 0.0.12__py3-none-any.whl → 0.2.11__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 (27) hide show
  1. juham_automation/__init__.py +8 -6
  2. juham_automation/automation/__init__.py +6 -6
  3. juham_automation/automation/energybalancer.py +281 -0
  4. juham_automation/automation/energycostcalculator.py +112 -68
  5. juham_automation/automation/heatingoptimizer.py +971 -0
  6. juham_automation/automation/leakdetector.py +162 -0
  7. juham_automation/automation/watercirculator.py +1 -20
  8. juham_automation/japp.py +4 -2
  9. juham_automation/ts/__init__.py +3 -1
  10. juham_automation/ts/electricityprice_ts.py +1 -1
  11. juham_automation/ts/energybalancer_ts.py +73 -0
  12. juham_automation/ts/energycostcalculator_ts.py +3 -1
  13. juham_automation/ts/log_ts.py +19 -16
  14. juham_automation/ts/power_ts.py +17 -14
  15. juham_automation/ts/powermeter_ts.py +3 -5
  16. juham_automation/ts/powerplan_ts.py +37 -18
  17. juham_automation-0.2.11.dist-info/METADATA +198 -0
  18. juham_automation-0.2.11.dist-info/RECORD +24 -0
  19. {juham_automation-0.0.12.dist-info → juham_automation-0.2.11.dist-info}/WHEEL +1 -1
  20. {juham_automation-0.0.12.dist-info → juham_automation-0.2.11.dist-info}/entry_points.txt +3 -2
  21. juham_automation/automation/hotwateroptimizer.py +0 -567
  22. juham_automation/automation/powermeter_simulator.py +0 -139
  23. juham_automation/automation/spothintafi.py +0 -140
  24. juham_automation-0.0.12.dist-info/METADATA +0 -109
  25. juham_automation-0.0.12.dist-info/RECORD +0 -23
  26. {juham_automation-0.0.12.dist-info → juham_automation-0.2.11.dist-info/licenses}/LICENSE.rst +0 -0
  27. {juham_automation-0.0.12.dist-info → juham_automation-0.2.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,971 @@
1
+ from datetime import datetime
2
+ import json
3
+ import time
4
+ import math
5
+ from typing import Any
6
+ from typing_extensions import override
7
+
8
+ from masterpiece.mqtt import MqttMsg
9
+ from juham_core import Juham
10
+ from juham_core.timeutils import (
11
+ quantize,
12
+ timestamp,
13
+ timestampstr,
14
+ )
15
+
16
+
17
+
18
+ class HeatingOptimizer(Juham):
19
+ """Automation class for optimized control of temperature driven home energy consumers e.g hot
20
+ water radiators. Reads spot prices, solar electricity forecast, temperature forecast, power meter and
21
+ the current temperature of the system to be heated to optimize energyc consumption and
22
+ minimize electricity bill.
23
+
24
+ Represents a heating system that knows the power rating of its radiator (e.g., 3kW).
25
+ Any number of heating devices can co-exist, each with its own heating optimizer with
26
+ different temperature, schedule and electricity price ratings.
27
+
28
+ The system subscribes to the 'power' topic to track the current power balance. If the solar panels
29
+ generate more energy than is being consumed, the optimizer activates a relay to ensure that all excess energy
30
+ produced within that balancing interval is used for heating. The goal is to achieve a net zero energy
31
+ balance for each slot, ensuring that any surplus energy from the solar panels is fully utilized.
32
+
33
+ The heating plan is published to 'topic_powerplan' topic for monitoring purposes.
34
+ Radiator relay state is published to 'power' topic, to actualize the heating plan.
35
+
36
+ Computes also UOI - optimization utilization index for each slot, based on the spot price and the solar power forecast.
37
+ For negative energy balance this determines when energy is consumed. Value of 0 means the slot is expensive,
38
+ value of 1 means the slot is free. The UOI threshold determines the slots that are allowed to be consumed.
39
+ """
40
+
41
+ energy_balancing_interval: float = 900
42
+ """Energy balancing interval, as regulated by the industry/converment. In seconds"""
43
+
44
+ radiator_power: float = 6000 # W
45
+ """Radiator power in Watts. This is the maximum power that the radiator can consume."""
46
+
47
+ heating_slots_per_day: float = 4
48
+ """ Number of slots per day the radiator is allowed to heat."""
49
+
50
+ schedule_start_slot: float = 0
51
+ """Start slots of the heating schedule."""
52
+
53
+ schedule_stop_slot: float = 0
54
+ """Stop slot of the heating schedule. Heating is allowed only between start-stop slots."""
55
+
56
+ timezone: str = "Europe/Helsinki"
57
+ """ Timezone of the heating system. This is used to convert UTC timestamps to local time."""
58
+
59
+ expected_average_price: float = 0.2
60
+ """Expected average price of electricity, beyond which the heating is avoided."""
61
+
62
+ uoi_threshold: float = 0.8
63
+ """Utilization Optimization Index threshold. This is the minimum UOI value that is allowed
64
+ for the heating to be activated."""
65
+
66
+ balancing_weight: float = 1.0
67
+ """Weight determining how large a share of the time slot a consumer receives compared to others ."""
68
+
69
+ spot_sensitivity: float = 20.0
70
+ """Sensitivity of the heating plan to spot price changes. Higher values mean more aggressive."""
71
+
72
+ spot_temp_offset: float = 20.0
73
+ """Safety limit to spot driven temp. adjustment, the maximum number of
74
+ degrees I’m allowed to adjust
75
+ """
76
+
77
+ temperature_limits: dict[int, tuple[float, float]] = {
78
+ 1: (60.0, 70.0), # January
79
+ 2: (55.0, 70.0), # February
80
+ 3: (50.0, 65.0), # March
81
+ 4: (20.0, 60.0), # April
82
+ 5: (10.0, 55.0), # May
83
+ 6: (10.0, 38.0), # June
84
+ 7: (10.0, 40.0), # July
85
+ 8: (35.0, 45.0), # August
86
+ 9: (40.0, 55.0), # September
87
+ 10: (45.0, 60.0), # October
88
+ 11: (50.0, 65.0), # November
89
+ 12: (55.0, 70.0), # December
90
+ }
91
+ """Temperature limits for each month. The minimum temperature is maintained regardless of the cost.
92
+ The limits are defined as a dictionary where the keys are month numbers (1-12)
93
+ and the values are tuples of (min_temp, max_temp). The min_temp and max_temp values are in
94
+ degrees Celsius."""
95
+
96
+ next_day_factor: float = 1.0
97
+ """Factor to adjust the temperature limits based on the next day's average temperature forecast.
98
+ A value of 0.0 means that the next day's temperature is irrelevant. A value of 1.0 means that the next
99
+ day's temperature fully affects the temperature limits."""
100
+
101
+ max_expected_temp_difference : float = 50.0
102
+ """Maximum expected temperature difference (Target - Forecast)
103
+ used for normalizing the heating need to a 0-1 scale."""
104
+
105
+ target_home_temperature : float = 22.0
106
+ """Target home temperature in degrees Celsius."""
107
+
108
+ def __init__(
109
+ self,
110
+ name: str,
111
+ temperature_sensor: str,
112
+ start_hour: int,
113
+ num_hours: int,
114
+ spot_limit: float,
115
+ ) -> None:
116
+ """Create power plan for automating temperature driven systems, e.g. heating radiators
117
+ to optimize energy consumption based on electricity prices.
118
+
119
+ Electricity Price MQTT Topic: This specifies the MQTT topic through which the controller receives
120
+ hourly electricity price forecasts for the next day or two.
121
+ Radiator Control Topic: The MQTT topic used to control the radiator relay.
122
+ Temperature Sensor Topic: The MQTT topic where the temperature sensor publishes its readings.
123
+ Electricity Price Slot Range: A pair of integers determining which electricity price slots the
124
+ controller uses. The slots are ranked from the cheapest to the most expensive. For example:
125
+ - A range of 0, 3 directs the controller to use electricity during the three cheapest hours.
126
+ - A second controller with a range of 3, 2 would target the next two cheapest hours, and so on.
127
+ Maximum Electricity Price Threshold: An upper limit for the electricity price, serving as an
128
+ additional control.
129
+ The controller only operates within its designated price slots if the prices are below this threshold.
130
+
131
+ The maximum price threshold reflects the criticality of the radiator's operation:
132
+
133
+ High thresholds indicate that the radiator should remain operational regardless of the price.
134
+ Low thresholds imply the radiator can be turned off during expensive periods, suggesting it
135
+ has a less critical role.
136
+
137
+ By combining these attributes, the controller ensures efficient energy usage while maintaining
138
+ desired heating levels.
139
+
140
+ Args:
141
+ name (str): name of the heating radiator
142
+ temperature_sensor (str): temperature sensor of the heating radiator
143
+ start_hour (int): ordinal of the first allowed electricity price slot to be consumed
144
+ num_hours (int): the number of slots allowed
145
+ spot_limit (float): maximum price allowed
146
+ """
147
+ super().__init__(name)
148
+
149
+ self.heating_slots_per_day = num_hours * ( 3600 / self.energy_balancing_interval)
150
+ self.start_slot = start_hour * (3600 / self.energy_balancing_interval)
151
+ self.spot_limit = spot_limit
152
+
153
+ self.topic_in_spot = self.make_topic_name("spot")
154
+ self.topic_in_forecast = self.make_topic_name("forecast")
155
+ self.topic_in_temperature = self.make_topic_name(temperature_sensor)
156
+ self.topic_powerplan = self.make_topic_name("powerplan")
157
+ self.topic_in_energybalance = self.make_topic_name("energybalance/status")
158
+ self.topic_out_energybalance = self.make_topic_name("energybalance/consumers")
159
+ self.topic_out_power = self.make_topic_name("power")
160
+
161
+ self.current_temperature : float = 100.0
162
+ self.current_relay_state : int = -1
163
+ self.heating_plan: list[dict[str, int]] = [] # in slots
164
+ self.power_plan: list[dict[str, Any]] = [] # in slots
165
+ self.ranked_spot_prices: list[dict[Any, Any]] = []
166
+ self.ranked_solarpower: list[dict[Any, Any]] = []
167
+ self.relay: bool = False
168
+ self.relay_started_ts: float = 0
169
+ self.net_energy_balance_mode: bool = False
170
+ self.next_day_mean_temp: float = 0.0
171
+ self.next_day_solar_energy: float = 0.0
172
+ self.min_temp : float = 0.0
173
+ self.max_temp : float = 0.0
174
+ self.new_power_plan: bool = True
175
+
176
+ @override
177
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
178
+ super().on_connect(client, userdata, flags, rc)
179
+ if rc == 0:
180
+ self.subscribe(self.topic_in_spot)
181
+ self.subscribe(self.topic_in_forecast)
182
+ self.subscribe(self.topic_in_temperature)
183
+ self.subscribe(self.topic_in_energybalance)
184
+ self.register_as_consumer()
185
+
186
+ def is_slot_within_schedule(self, slot: int, start_slot: int, stop_slot: int) -> bool:
187
+ """Check if the given slot is within the schedule.
188
+
189
+ Args:
190
+ slot (int): slot to check
191
+ start_slot (int): start slot of the schedule
192
+ stop_slot (int): stop slot of the schedule
193
+ Returns:
194
+ bool: true if the slot is within the schedule
195
+ """
196
+ if start_slot < stop_slot:
197
+ return slot >= start_slot and slot < stop_slot
198
+ else:
199
+ return slot >= start_slot or slot < stop_slot
200
+
201
+
202
+ def slots_per_day(self) -> int:
203
+ return int(24 * 3600 / self.energy_balancing_interval)
204
+
205
+
206
+ def timestamp_slot(self, ts: float) -> int:
207
+ """Get the time slot for the given timestamp and interval.
208
+
209
+ Args:
210
+ ts (float): timestamp
211
+ interval (float): interval in seconds
212
+
213
+ Returns:
214
+ float: time slot
215
+ """
216
+ dt = datetime.utcfromtimestamp(ts)
217
+ total_seconds = dt.hour * 3600 + dt.minute * 60 + dt.second
218
+ slot : int = total_seconds // self.energy_balancing_interval
219
+ return slot
220
+
221
+
222
+ def register_as_consumer(self) -> None:
223
+ """Register this device as a consumer to the energy balancer. The energy balancer will then add
224
+ this device to its list of consumers and will tell the device when to heat."""
225
+
226
+ consumer: dict[str, Any] = {
227
+ "Unit": self.name,
228
+ "Power": self.radiator_power,
229
+ "Weight": self.balancing_weight,
230
+ }
231
+ self.publish(self.topic_out_energybalance, json.dumps(consumer), 1, False)
232
+ self.info(
233
+ f"Registered {self.name} as consumer with {self.radiator_power}W power",
234
+ "",
235
+ )
236
+
237
+ def get_temperature_limits_for_current_month(self) -> tuple[float, float]:
238
+ current_month: int = datetime.now().month
239
+ # Get the min and max temperatures for the current month
240
+ min_temp, max_temp = self.temperature_limits[current_month]
241
+ return min_temp, max_temp
242
+
243
+
244
+ def compute_optimal_temp(self,
245
+ today_price: float,
246
+ tomorrow_price: float,
247
+ minTemp: float,
248
+ maxTemp: float,
249
+ k: float = 10.0,
250
+ max_offset: float = 5.0,
251
+ ) -> float:
252
+ """ Computes an optimal boiler target temperature based on electricity price
253
+ forecasts for today and tomorrow. The boiler is treated as a thermal
254
+ storage system: if electricity is cheaper today than tomorrow, the
255
+ controller increases the target temperature (preheating); if electricity
256
+ is cheaper tomorrow, the controller decreases the target temperature
257
+ (saving energy today).
258
+
259
+ The adjustment is computed from the price ratio (tomorrow/today) using
260
+ a sensitivity coefficient `k`. The resulting temperature deviation is
261
+ clamped to `[-max_offset, +max_offset]` to ensure safe and stable
262
+ operation.
263
+
264
+ Args:
265
+ today_price (float): Average price of the cheapest relevant heating
266
+ window during the current day (0-24 hours ahead).
267
+ tomorrow_price (float): Average price of the cheapest relevant
268
+ heating window for the next day (24-48 hours ahead).
269
+ minTemp (float): Minimum allowed boiler temperature in degrees Celsius.
270
+ maxTemp (float): Normal maximum boiler temperature in degrees Celsius.
271
+ k (float, optional): Sensitivity factor controlling how strongly
272
+ the temperature reacts to price differences. Higher values
273
+ produce more aggressive adjustments. Defaults to 10.0.
274
+ max_offset (float, optional): Maximum allowed degree offset applied
275
+ above or below `maxTemp`. Prevents overheating or excessive
276
+ underheating. Defaults to 5.0.
277
+
278
+ Returns:
279
+ float: The computed target temperature in degrees Celsius, clamped
280
+ within `[minTemp, maxTemp]`.
281
+
282
+ Raises:
283
+ ValueError: If input prices are non-positive or if temperature limits
284
+ are inconsistent.
285
+
286
+ """
287
+
288
+ price_ratio = tomorrow_price / max(0.001, today_price)
289
+
290
+ # Positive if tomorrow is more expensive
291
+ adjustment = k * (price_ratio - 1.0)
292
+
293
+ # Clamp to +/- max_offset degrees
294
+ adjustment = max(-max_offset, min(max_offset, adjustment))
295
+
296
+ # New temperature target
297
+ T_target = maxTemp + adjustment
298
+
299
+ # Clamp to allowed boiler range
300
+ T_target = max(minTemp, min(maxTemp, T_target))
301
+
302
+ return T_target
303
+
304
+
305
+ def get_forecast_optimized_temperature_limits(self) -> tuple[float, float]:
306
+ """Get the forecast optimized temperature limits for the current heating plan.
307
+ Returns: tuple: (min_temp, max_temp)
308
+ """
309
+
310
+ # get the monthly temperature limits
311
+ min_temp, max_temp = self.get_temperature_limits_for_current_month()
312
+ if not self.ranked_solarpower or len(self.ranked_solarpower) < 4:
313
+ self.warning(
314
+ f"{self.name} short forecast {len(self.ranked_solarpower)}, no forecast optimization applied.",
315
+ "",
316
+ )
317
+ return min_temp, max_temp
318
+
319
+ return self.calculate_target_temps(
320
+ min_temp, max_temp, self.next_day_mean_temp, self.target_home_temperature)
321
+
322
+
323
+ def sort_by_rank(
324
+ self, slot: list[dict[str, Any]], ts_utc_now: float
325
+ ) -> list[dict[str, Any]]:
326
+ """Sort the given electricity prices by their rank value. Given a list
327
+ of electricity prices, return a sorted list from the cheapest to the
328
+ most expensive hours. Entries that represent electricity prices in the
329
+ past are excluded.
330
+
331
+ Args:
332
+ hours (list): list of hourly electricity prices
333
+ ts_utc_now (float): current time
334
+
335
+ Returns:
336
+ list: sorted list of electricity prices
337
+ """
338
+ sh = sorted(slot, key=lambda x: x["Rank"])
339
+ ranked_hours: list[dict[str, Any]] = []
340
+ for h in sh:
341
+ utc_ts = h["Timestamp"]
342
+ if utc_ts >= ts_utc_now:
343
+ ranked_hours.append(h)
344
+
345
+ return ranked_hours
346
+
347
+ def sort_by_power(
348
+ self, forecast: list[dict[Any, Any]], ts_utc: float
349
+ ) -> list[dict[Any, Any]]:
350
+ """Sort forecast of solarpower to decreasing order.
351
+
352
+ Args:
353
+ solarpower (list): list of entries describing hourly solar energy forecast
354
+ ts_utc(float): start time, for exluding entries that are in the past
355
+
356
+ Returns:
357
+ list: list from the highest solarenergy to lowest.
358
+ """
359
+
360
+ # if all items have solarenergy key then
361
+ # sh = sorted(solarpower, key=lambda x: x["solarenergy"], reverse=True)
362
+ # else skip items that don't have solarenergy key
363
+ sh = sorted(
364
+ [item for item in forecast if "solarenergy" in item],
365
+ key=lambda x: x["solarenergy"],
366
+ reverse=True,
367
+ )
368
+
369
+ self.debug(
370
+ f"{self.name} sorted {len(sh)} days of forecast starting at {timestampstr(ts_utc)}"
371
+ )
372
+ ranked_slots: list[dict[str, Any]] = []
373
+
374
+ for h in sh:
375
+ utc_ts: float = float(h["ts"])
376
+ if utc_ts >= ts_utc:
377
+ ranked_slots.append(h)
378
+ self.debug(
379
+ f"{self.name} forecast sorted for the next {str(len(ranked_slots))} hours"
380
+ )
381
+ return ranked_slots
382
+
383
+
384
+ def next_day_mean_temperature_forecast(self, forecast: list[dict[Any, Any]], ts_utc: float) -> float:
385
+ """return the average temperature for the next day based on the forecast.
386
+
387
+ Args:
388
+ forecast (list): list of entries describing hourly solar energy forecast
389
+ ts_utc(float): start time, for exluding entries that are in the past
390
+
391
+ Returns:
392
+ list: list from the highest solarenergy to lowest.
393
+ """
394
+ total_temp: float = 0.0
395
+ count: int = 0
396
+ for item in forecast:
397
+ utc_ts: float = float(item["ts"])
398
+ if utc_ts >= ts_utc and "temp" in item:
399
+ total_temp = total_temp + item["temp"]
400
+ count = count + 1
401
+ average_temp : float = total_temp / count if count > 0 else 0
402
+ return average_temp
403
+
404
+ def next_day_solar_energy_forecast(self, forecast: list[dict[Any, Any]], ts_utc: float) -> float:
405
+ """Compute the expected solar energy based on the forecast. The more solar energy is expected
406
+ the more the heating can be allowed to lower temperatures.
407
+
408
+ Args:
409
+ forecast (list): list of entries describing solar energy forecast
410
+ ts_utc(float): start time, for exluding entries that are in the past
411
+
412
+ Returns:
413
+ list: expected solarenergy available during the heating period.
414
+ """
415
+ total_energy: float = 0.0
416
+ solarenergy_found: bool = False
417
+ for item in forecast:
418
+ if "solarenergy" in item:
419
+ solarenergy_found = True
420
+ utc_ts: float = float(item["ts"])
421
+ if utc_ts >= ts_utc:
422
+ total_energy = total_energy + item["solarenergy"]
423
+
424
+ if not solarenergy_found:
425
+ self.debug(f"No solarenergy forecast found")
426
+ else:
427
+ self.debug(f"Next day solar energy forecast is {total_energy:.1f} W")
428
+ return total_energy
429
+
430
+
431
+ def get_future_price(
432
+ self,
433
+ ts_utc_now: float,
434
+ num_hours: float,
435
+ start_hour: float,
436
+ stop_hour: float,
437
+ ) -> float:
438
+ slots_needed = int(num_hours * 4)
439
+ seconds_per_hour = 3600
440
+
441
+ window_start_ts = ts_utc_now + start_hour * seconds_per_hour
442
+ window_stop_ts = ts_utc_now + stop_hour * seconds_per_hour
443
+
444
+ window_slots = [
445
+ s for s in self.ranked_spot_prices
446
+ if window_start_ts <= s["Timestamp"] < window_stop_ts
447
+ ]
448
+
449
+ # already sorted by rank, so just take the first N
450
+ selected = window_slots[:slots_needed]
451
+
452
+ if not selected:
453
+ return float("nan")
454
+
455
+ return sum(s["PriceWithTax"] for s in selected) / len(selected)
456
+
457
+
458
+ def on_spot(self, m: list[dict[str, Any]], ts_quantized: float) -> None:
459
+ """Handle the spot prices.
460
+
461
+ Args:
462
+ list[dict[str, Any]]: list of spot prices
463
+ ts_quantized (float): current time
464
+ """
465
+ self.spot_prices = m
466
+ self.ranked_spot_prices = self.sort_by_rank(m, ts_quantized)
467
+
468
+ def on_forecast(
469
+ self, forecast: list[dict[str, Any]], ts_utc_quantized: float
470
+ ) -> None:
471
+ """Handle the solar forecast.
472
+
473
+ Args:
474
+ m (list[dict[str, Any]]): list of forecast prices
475
+ ts_quantized (float): current time
476
+ """
477
+ # reject forecasts that don't have solarenergy key
478
+ for f in forecast:
479
+ if not "solarenergy" in f:
480
+ return
481
+
482
+ self.ranked_solarpower = self.sort_by_power(forecast, ts_utc_quantized)
483
+ if(len(self.ranked_solarpower)) == 0:
484
+ self.warning(f"{self.name} no valid solar power forecast received")
485
+ return
486
+
487
+ self.info(
488
+ f"{self.name} solar energy forecast received and ranked for {len(self.ranked_solarpower)} slots"
489
+ )
490
+ self.power_plan = [] # reset power plan, it depends on forecast
491
+ self.next_day_mean_temp = self.next_day_mean_temperature_forecast(forecast, time.time() + 24 * 60 * 60)
492
+ self.next_day_solar_energy = self.next_day_solar_energy_forecast(forecast, time.time() + 24 * 60 * 60)
493
+ self.new_power_plan = True
494
+ self.min_temp, self.max_temp = self.get_forecast_optimized_temperature_limits()
495
+
496
+ self.info(
497
+ f"{self.name} Next day temp and solar forecasts are {self.next_day_mean_temp:.1f}°C and {self.next_day_solar_energy:.1f}°kW"
498
+ )
499
+
500
+ @override
501
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
502
+ m = None
503
+ ts: float = timestamp()
504
+ ts_utc_quantized: float = quantize(self.energy_balancing_interval, ts - self.energy_balancing_interval)
505
+ if msg.topic == self.topic_in_spot:
506
+ self.on_spot(json.loads(msg.payload.decode()), ts_utc_quantized)
507
+ return
508
+ elif msg.topic == self.topic_in_forecast:
509
+ self.on_forecast(json.loads(msg.payload.decode()), ts_utc_quantized)
510
+ return
511
+ elif msg.topic == self.topic_in_temperature:
512
+ m = json.loads(msg.payload.decode())
513
+ self.current_temperature = m["temperature"]
514
+ elif msg.topic == self.topic_in_energybalance:
515
+ decoded_payload = msg.payload.decode()
516
+ m = json.loads(decoded_payload)
517
+ self.on_netenergy_balance(m)
518
+ else:
519
+ super().on_message(client, userdata, msg)
520
+ return
521
+ self.on_powerplan(ts)
522
+
523
+ def on_powerplan(self, ts_utc_now: float) -> None:
524
+ """Apply the power plan. Check if the relay needs to be switched on or off.
525
+ The relay is switched on if the current temperature is below the maximum
526
+ temperature and the current time is within the heating plan. The relay is switched off
527
+ if the current temperature is above the maximum temperature or the current time is outside.
528
+
529
+ Args:
530
+ ts_utc_now (float): utc time
531
+ """
532
+
533
+ # optimization, check only once a minute
534
+ elapsed: float = ts_utc_now - self.relay_started_ts
535
+ if elapsed < 60:
536
+ return
537
+ self.relay_started_ts = ts_utc_now
538
+
539
+ if not self.ranked_spot_prices:
540
+ self.debug(f"{self.name} waiting spot prices...", "")
541
+ return
542
+
543
+ if not self.power_plan:
544
+ self.power_plan = self.create_power_plan()
545
+ self.heating_plan = []
546
+ self.info(
547
+ f"{self.name} power plan of length {len(self.power_plan)} created",
548
+ str(self.power_plan),
549
+ )
550
+
551
+ if not self.power_plan:
552
+ self.error(f"{self.name} failed to create a power plan", "")
553
+ return
554
+
555
+ if len(self.power_plan) < 3:
556
+ self.warning(
557
+ f"{self.name} has suspiciously short {len(self.power_plan)} power plan, waiting for more data ..",
558
+ "",
559
+ )
560
+ self.heating_plan = []
561
+ self.power_plan = []
562
+ return
563
+
564
+ if not self.ranked_solarpower or len(self.ranked_solarpower) < 4:
565
+ self.warning(
566
+ f"{self.name} short of forecast {len(self.ranked_solarpower)}, optimization compromised..",
567
+ "",
568
+ )
569
+
570
+ if not self.heating_plan:
571
+ self.heating_plan = self.create_heating_plan()
572
+ if not self.heating_plan:
573
+ self.error(f"{self.name} failed to create heating plan")
574
+ return
575
+ else:
576
+ self.info(
577
+ f"{self.name} heating plan of length {len(self.heating_plan)} created",
578
+ "",
579
+ )
580
+
581
+ self.publish_heating_plan(self.heating_plan)
582
+
583
+ if len(self.heating_plan) < 3:
584
+ self.warning(
585
+ f"{self.name} has too short heating plan {len(self.heating_plan)}, no can do",
586
+ "",
587
+ )
588
+ self.heating_plan = []
589
+ self.power_plan = []
590
+ return
591
+
592
+ relay: int = self.consider_heating(ts_utc_now)
593
+ if self.current_relay_state != relay:
594
+ heat: dict[str, Any] = {
595
+ "Unit": self.name,
596
+ "Timestamp": ts_utc_now,
597
+ "State": relay,
598
+ }
599
+ self.publish(self.topic_out_power, json.dumps(heat), 1, False)
600
+ self.info(
601
+ f"{self.name} relay changed to {relay} at {timestampstr(ts_utc_now)}",
602
+ "",
603
+ )
604
+ self.current_relay_state = relay
605
+
606
+ def on_netenergy_balance(self, m: dict[str, Any]) -> None:
607
+ """Check when there is enough energy available for the radiator to heat
608
+ in the remaining time within the balancing interval.
609
+
610
+ Args:
611
+ ts (float): current time
612
+
613
+ Returns:
614
+ bool: true if production exceeds the consumption
615
+ """
616
+ if m["Unit"] == self.name:
617
+ self.net_energy_balance_mode = m["Mode"]
618
+
619
+ def consider_heating(self, ts: float) -> int:
620
+ """Consider whether the target boiler needs heating. Check first if the solar
621
+ energy is enough to heat the water the remaining time in the current slot.
622
+ If not, follow the predefined heating plan computed earlier based on the cheapest spot prices.
623
+
624
+ Args:
625
+ ts (float): current UTC time
626
+
627
+ Returns:
628
+ int: 1 if heating is needed, 0 if not
629
+ """
630
+
631
+ # check if we have excess energy to spent within the current slot
632
+ if self.net_energy_balance_mode:
633
+ return 1
634
+
635
+ slot : int = self.timestamp_slot(ts)
636
+ state: int = -1
637
+
638
+ # check if we are within the heating plan and see what the plan says
639
+ for pp in self.heating_plan:
640
+ ppts: float = pp["Timestamp"]
641
+ h: float = self.timestamp_slot(ppts)
642
+ if h == slot:
643
+ state = pp["State"]
644
+ break
645
+
646
+ if state == -1:
647
+ self.error(f"{self.name} cannot find heating plan for slot {slot}")
648
+ return 0
649
+
650
+ # don't heat if the current temperature is already high enough
651
+ if self.current_temperature > self.max_temp:
652
+ return 0
653
+ # heat if the current temperature is below the required minimum
654
+ if self.current_temperature < self.min_temp:
655
+ return 1
656
+
657
+ return state # 1 = heating, 0 = not heating
658
+
659
+ # compute utilization optimization index
660
+ def compute_uoi(
661
+ self,
662
+ price: float,
663
+ slot: float,
664
+ ) -> float:
665
+ """Compute UOI - utilization optimization index.
666
+
667
+ Args:
668
+ price (float): effective price for this device
669
+ slot (float) : the slot of the day
670
+
671
+ Returns:
672
+ float: utilization optimization index
673
+ """
674
+
675
+ if not self.is_slot_within_schedule(
676
+ slot, self.schedule_start_slot, self.schedule_stop_slot
677
+ ):
678
+ return 0.0
679
+
680
+ if price < 0.0001:
681
+ return 1.0 # use
682
+ elif price > self.expected_average_price:
683
+ return 0.0 # try not to use
684
+ else:
685
+ fom = self.expected_average_price / price
686
+ return fom
687
+
688
+ def compute_effective_price(
689
+ self, requested_power: float, available_solpower: float, spot: float
690
+ ) -> float:
691
+ """Compute effective electricity price. If there is enough solar power then
692
+ electricity price is zero.
693
+
694
+ Args:
695
+ requested_power (float): requested power
696
+ available_solpower (float): current solar power forecast
697
+ spot (float): spot price
698
+
699
+ Returns:
700
+ float: effective price for the requested power
701
+ """
702
+
703
+ # if we have enough solar power, use it
704
+ if requested_power < available_solpower:
705
+ return 0.0
706
+
707
+ # check how much of the power is solar and how much is from the grid
708
+ solar_factor: float = available_solpower / requested_power
709
+
710
+ effective_spot: float = spot * (1 - solar_factor)
711
+
712
+ return effective_spot
713
+
714
+ def align_forecast_to_slots(self, solar_forecast: list[dict]) -> list[dict]:
715
+ """Resample hourly solar forecast to match slot interval."""
716
+ slots_per_hour = 3600 // self.energy_balancing_interval
717
+ expanded = []
718
+
719
+ for entry in solar_forecast: # each entry has "ts" (start of hour) and "solarenergy" (in kW)
720
+ start_ts = entry["Timestamp"]
721
+ for i in range(slots_per_hour):
722
+ slot_ts = start_ts + i * self.energy_balancing_interval
723
+ expanded.append({
724
+ "Timestamp": slot_ts,
725
+ "Solarenergy": entry["Solarenergy"] / slots_per_hour # split evenly
726
+ })
727
+
728
+ return expanded
729
+
730
+
731
+ def create_power_plan(self) -> list[dict[Any, Any]]:
732
+ """Create power plan.
733
+
734
+ Returns:
735
+ list: list of utilization entries
736
+ """
737
+ ts_utc_quantized = quantize(self.energy_balancing_interval, timestamp() - self.energy_balancing_interval)
738
+ starts: str = timestampstr(ts_utc_quantized)
739
+ self.info(
740
+ f"{self.name} created power plan starting at {starts} with {len(self.ranked_spot_prices)} slots of spot prices",
741
+ "",
742
+ )
743
+
744
+ # syncronize spot and solarenergy by timestamp
745
+ spots: list[dict[Any, Any]] = []
746
+ for s in self.ranked_spot_prices:
747
+ if s["Timestamp"] > ts_utc_quantized:
748
+ spots.append(
749
+ {"Timestamp": s["Timestamp"], "PriceWithTax": s["PriceWithTax"]}
750
+ )
751
+
752
+ if len(spots) == 0:
753
+ self.info(
754
+ f"No spot prices initialized yet, can't proceed",
755
+ "",
756
+ )
757
+ return []
758
+ self.info(
759
+ f"Have spot prices for the next {len(spots)} slots",
760
+ "",
761
+ )
762
+
763
+ # Expand solar forecast to match spot price resolution
764
+ raw_powers = [
765
+ {"Timestamp": s["ts"], "Solarenergy": s["solarenergy"]}
766
+ for s in self.ranked_solarpower
767
+ if s["ts"] >= ts_utc_quantized
768
+ ]
769
+
770
+ powers : list[dict[str, Any]] = self.align_forecast_to_slots(raw_powers)
771
+
772
+
773
+ num_powers: int = len(powers)
774
+ if num_powers == 0:
775
+ self.debug(
776
+ f"No solar forecast initialized yet, proceed without solar forecast",
777
+ "",
778
+ )
779
+ else:
780
+ self.debug(
781
+ f"Have solar forecast for the next {num_powers} slots",
782
+ "",
783
+ )
784
+ hplan: list[dict[str, Any]] = []
785
+ slot: int = 0
786
+ if len(powers) >= 8: # at least 8 slot of solar energy forecast
787
+ for spot, solar in zip(spots, powers):
788
+ ts = spot["Timestamp"]
789
+ solarenergy = solar["Solarenergy"] * 1000 # argh, this is in kW
790
+ spotprice = spot["PriceWithTax"]
791
+ effective_price: float = self.compute_effective_price(
792
+ self.radiator_power, solarenergy, spotprice
793
+ )
794
+ slot = self.timestamp_slot(ts)
795
+ fom = self.compute_uoi(spotprice, slot)
796
+ plan: dict[str, Any] = {
797
+ "Timestamp": ts,
798
+ "FOM": fom,
799
+ "Spot": effective_price,
800
+ }
801
+ hplan.append(plan)
802
+ else: # no solar forecast available, assume no free energy available
803
+ for spot in spots:
804
+ ts = spot["Timestamp"]
805
+ solarenergy = 0.0
806
+ spotprice = spot["PriceWithTax"]
807
+ effective_price = spotprice # no free energy available
808
+ slot = self.timestamp_slot(ts)
809
+ fom = self.compute_uoi(effective_price, slot)
810
+ plan = {
811
+ "Timestamp": spot["Timestamp"],
812
+ "FOM": fom,
813
+ "Spot": effective_price,
814
+ }
815
+ hplan.append(plan)
816
+
817
+ shplan = sorted(hplan, key=lambda x: x["FOM"], reverse=True)
818
+
819
+ self.debug(f"{self.name} powerplan starts {starts} up to {len(shplan)} slots")
820
+ return shplan
821
+
822
+
823
+ def calculate_target_temps(self,
824
+ monthly_temp_min: float,
825
+ monthly_temp_max: float,
826
+ next_day_mean_temp: float,
827
+ target_temperature: float
828
+ ) -> tuple[float, float]:
829
+ """
830
+ Calculates the minimum and maximum target temperatures for the current heating plan,
831
+ blending the fixed monthly limits with the next day's forecast.
832
+
833
+ The minimum temperature is fixed at the monthly minimum, as the system
834
+ must always prevent the temperature from dropping below this threshold.
835
+
836
+ The maximum temperature is adjusted based on the predicted heating demand
837
+ derived from the forecast and scaled by the next_day_factor.
838
+
839
+ Args:
840
+ monthly_temp_min (float): The default, absolute minimum boiler temperature (e.g., 60°C).
841
+ monthly_temp_max (float): The default, absolute maximum boiler temperature (e.g., 85°C).
842
+ next_day_mean_temp (float): The average temperature forecasted for tomorrow (e.g., 5°C).
843
+ target_temperature (float): The internal/reference temperature used to
844
+ determine the heating required for the next day (e.g., 20°C).
845
+
846
+ Returns:
847
+ tuple[float, float]: (min_temp_today, max_temp_today)
848
+ """
849
+
850
+ # Ensure the factor is within valid bounds
851
+ factor = max(0.0, min(1.0, self.next_day_factor))
852
+
853
+ # --- 2. Calculate Required Heating Need based on Forecast ---
854
+ # Calculate the raw heating demand proxy: (Baseline - Forecast)
855
+ # The greater this difference, the colder the next day, and the more energy (higher max temp) is needed today.
856
+ # We use max(0, ...) to ensure demand is not negative if the forecast is warmer than the target.
857
+ demand_difference = max(0.0, target_temperature - next_day_mean_temp)
858
+
859
+ # Normalize the demand difference into a 0.0 to 1.0 'Need Ratio'
860
+ # 0.0 = No extra heating needed (warm forecast)
861
+ # 1.0 = Maximal heating needed (very cold forecast)
862
+ need_ratio = min(1.0, demand_difference / self.max_expected_temp_difference)
863
+
864
+ # --- 3. Determine the Forecast-Driven Target Max Temperature ---
865
+ # The available range for heating capacity
866
+ boiler_range = monthly_temp_max - monthly_temp_min
867
+
868
+ # Calculate the max temperature required based purely on the forecast (if factor=1)
869
+ # If need_ratio is 1.0, T_max_target = monthly_temp_max
870
+ # If need_ratio is 0.0, T_max_target = monthly_temp_min
871
+ T_max_target = monthly_temp_min + (need_ratio * boiler_range)
872
+
873
+ # --- 4. Apply the Blending Factor ---
874
+ # The minimum temperature remains fixed (no drop below the minimum limit)
875
+ min_temp_today = monthly_temp_min
876
+
877
+ # The max temperature is a blend:
878
+ # (factor * T_max_target) + ((1 - factor) * monthly_temp_max)
879
+ # factor = 1 -> uses T_max_target (full forecast impact)
880
+ # factor = 0 -> uses monthly_temp_max (full monthly default capacity)
881
+ max_temp_today = (factor * T_max_target) + ((1 - factor) * monthly_temp_max)
882
+
883
+
884
+
885
+ # adjust the temperature limits based on the electricity prices
886
+ ts : float = timestamp()
887
+ num_hours : float = self.heating_slots_per_day * self.energy_balancing_interval / 3600.0
888
+ tomorrow_price : float = self.get_future_price(ts, num_hours, 24, 48)
889
+ today_price : float = self.get_future_price(ts, num_hours, 0,24)
890
+ if math.isnan(tomorrow_price) or math.isnan(today_price):
891
+ self.warning(f"{self.name} no future prices for temperature optimization, using unadjusted temps",
892
+ f"min:{min_temp_today}, max:{max_temp_today}")
893
+ return (min_temp_today, max_temp_today)
894
+ spot_adjusted_max : float = self.compute_optimal_temp(today_price, tomorrow_price, min_temp_today, max_temp_today,
895
+ self.spot_sensitivity, self.spot_temp_offset)
896
+ # Return the results
897
+ return (min_temp_today, spot_adjusted_max)
898
+
899
+
900
+ def enable_relay(
901
+ self, slot: int, spot: float, fom: float, end_slot: int
902
+ ) -> bool:
903
+ return (
904
+ slot >= self.start_slot
905
+ and slot < end_slot
906
+ and float(spot) < self.spot_limit
907
+ and fom > self.uoi_threshold
908
+ )
909
+
910
+ def create_heating_plan(self) -> list[dict[str, Any]]:
911
+ """Create heating plan.
912
+
913
+ Returns:
914
+ int: list[dict[str, Any]] of heating entries
915
+ """
916
+
917
+ state = 0
918
+ heating_plan: list[dict[str, Any]] = []
919
+ slot: int = 0
920
+ for hp in self.power_plan:
921
+ ts: float = hp["Timestamp"]
922
+ fom = hp["FOM"]
923
+ spot = hp["Spot"]
924
+ end_slot: float = self.start_slot + self.heating_slots_per_day
925
+ slot: float = self.timestamp_slot(ts)
926
+ schedule_on: bool = self.is_slot_within_schedule(
927
+ slot, self.schedule_start_slot, self.schedule_stop_slot
928
+ )
929
+
930
+ if self.enable_relay(slot, spot, fom, end_slot) and schedule_on:
931
+ state = 1
932
+ else:
933
+ state = 0
934
+ heat: dict[str, Any] = {
935
+ "Unit": self.name,
936
+ "Timestamp": ts,
937
+ "State": state,
938
+ "Schedule": schedule_on,
939
+ "UOI": fom,
940
+ "Spot": spot,
941
+ }
942
+
943
+ heating_plan.append(heat)
944
+ slot = slot + 1
945
+
946
+ self.info(f"{self.name} heating plan of {len(heating_plan)} slots created", "")
947
+ return heating_plan
948
+
949
+
950
+ def publish_heating_plan(self, heatingplan : list[dict[str, Any]]) -> None:
951
+ """Publish the heating plan. If new heating plan, then publish also the next day's
952
+ solar energy and temperature forecasts along with the min and max temperature limits.
953
+
954
+ Args:
955
+ heatingplan: list of heating entries
956
+ """
957
+
958
+ hplen : int = len(heatingplan)
959
+ for index, hp in enumerate(heatingplan):
960
+ if index == 0 or index >= hplen-1:
961
+ hp_to_publish = hp.copy()
962
+ hp_to_publish["NextDaySolarpower"] = self.next_day_solar_energy
963
+ hp_to_publish["NextDayTemperature"] = self.next_day_mean_temp
964
+ hp_to_publish["MinTempLimit"] = self.min_temp
965
+ hp_to_publish["MaxTempLimit"] = self.max_temp
966
+
967
+ self.publish(self.topic_powerplan, json.dumps(hp_to_publish), 1, False)
968
+ self.new_power_plan = False # Should be set here when metadata is sent
969
+ else:
970
+ self.publish(self.topic_powerplan, json.dumps(hp), 1, False)
971
+