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