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