juham-automation 0.0.12__py3-none-any.whl → 0.2.8__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.
- juham_automation/__init__.py +8 -4
- juham_automation/automation/__init__.py +6 -4
- juham_automation/automation/energybalancer.py +281 -0
- juham_automation/automation/energycostcalculator.py +112 -68
- juham_automation/automation/heatingoptimizer.py +971 -0
- juham_automation/automation/leakdetector.py +162 -0
- juham_automation/automation/watercirculator.py +1 -20
- juham_automation/japp.py +4 -0
- juham_automation/ts/__init__.py +3 -1
- juham_automation/ts/electricityprice_ts.py +1 -1
- juham_automation/ts/energybalancer_ts.py +73 -0
- juham_automation/ts/energycostcalculator_ts.py +3 -1
- juham_automation/ts/log_ts.py +19 -16
- juham_automation/ts/power_ts.py +17 -14
- juham_automation/ts/powermeter_ts.py +3 -5
- juham_automation/ts/powerplan_ts.py +37 -18
- juham_automation-0.2.8.dist-info/METADATA +199 -0
- juham_automation-0.2.8.dist-info/RECORD +25 -0
- {juham_automation-0.0.12.dist-info → juham_automation-0.2.8.dist-info}/WHEEL +1 -1
- {juham_automation-0.0.12.dist-info → juham_automation-0.2.8.dist-info}/entry_points.txt +3 -1
- juham_automation/automation/hotwateroptimizer.py +0 -567
- juham_automation/automation/powermeter_simulator.py +0 -139
- juham_automation-0.0.12.dist-info/METADATA +0 -109
- juham_automation-0.0.12.dist-info/RECORD +0 -23
- {juham_automation-0.0.12.dist-info → juham_automation-0.2.8.dist-info/licenses}/LICENSE.rst +0 -0
- {juham_automation-0.0.12.dist-info → juham_automation-0.2.8.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
|
+
|