juham-automation 0.0.12__py3-none-any.whl → 0.2.11__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- juham_automation/__init__.py +8 -6
- juham_automation/automation/__init__.py +6 -6
- 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 -2
- 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.11.dist-info/METADATA +198 -0
- juham_automation-0.2.11.dist-info/RECORD +24 -0
- {juham_automation-0.0.12.dist-info → juham_automation-0.2.11.dist-info}/WHEEL +1 -1
- {juham_automation-0.0.12.dist-info → juham_automation-0.2.11.dist-info}/entry_points.txt +3 -2
- juham_automation/automation/hotwateroptimizer.py +0 -567
- juham_automation/automation/powermeter_simulator.py +0 -139
- juham_automation/automation/spothintafi.py +0 -140
- 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.11.dist-info/licenses}/LICENSE.rst +0 -0
- {juham_automation-0.0.12.dist-info → juham_automation-0.2.11.dist-info}/top_level.txt +0 -0
juham_automation/__init__.py
CHANGED
|
@@ -7,10 +7,10 @@ Juham - Juha's Ultimate Home Automation Masterpiece
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from .automation import EnergyCostCalculator
|
|
10
|
-
from .automation import PowerMeterSimulator
|
|
11
|
-
from .automation import SpotHintaFi
|
|
12
10
|
from .automation import WaterCirculator
|
|
13
|
-
from .automation import
|
|
11
|
+
from .automation import HeatingOptimizer
|
|
12
|
+
from .automation import EnergyBalancer
|
|
13
|
+
from .automation import LeakDetector
|
|
14
14
|
from .ts import EnergyCostCalculatorTs
|
|
15
15
|
from .ts import ForecastTs
|
|
16
16
|
from .ts import LogTs
|
|
@@ -18,6 +18,7 @@ from .ts import PowerTs
|
|
|
18
18
|
from .ts import PowerPlanTs
|
|
19
19
|
from .ts import PowerMeterTs
|
|
20
20
|
from .ts import ElectricityPriceTs
|
|
21
|
+
from .ts import EnergyBalancerTs
|
|
21
22
|
from .japp import JApp
|
|
22
23
|
|
|
23
24
|
|
|
@@ -25,14 +26,15 @@ __all__ = [
|
|
|
25
26
|
"EnergyCostCalculator",
|
|
26
27
|
"EnergyCostCalculatorTs",
|
|
27
28
|
"ForecastTs",
|
|
28
|
-
"
|
|
29
|
+
"HeatingOptimizer",
|
|
30
|
+
"EnergyBalancer",
|
|
31
|
+
"LeakDetector",
|
|
29
32
|
"LogTs",
|
|
30
33
|
"PowerTs",
|
|
31
34
|
"PowerPlanTs",
|
|
32
35
|
"PowerMeterTs",
|
|
33
|
-
"SpotHintaFi",
|
|
34
36
|
"WaterCirculator",
|
|
35
37
|
"JApp",
|
|
36
|
-
"PowerMeterSimulator",
|
|
37
38
|
"ElectricityPriceTs",
|
|
39
|
+
"EnergyBalancerTs",
|
|
38
40
|
]
|
|
@@ -7,15 +7,15 @@ Juham - Juha's Ultimate Home Automation classes
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from .energycostcalculator import EnergyCostCalculator
|
|
10
|
-
from .spothintafi import SpotHintaFi
|
|
11
10
|
from .watercirculator import WaterCirculator
|
|
12
|
-
from .
|
|
13
|
-
from .
|
|
11
|
+
from .heatingoptimizer import HeatingOptimizer
|
|
12
|
+
from .energybalancer import EnergyBalancer
|
|
13
|
+
from .leakdetector import LeakDetector
|
|
14
14
|
|
|
15
15
|
__all__ = [
|
|
16
16
|
"EnergyCostCalculator",
|
|
17
|
-
"
|
|
18
|
-
"SpotHintaFi",
|
|
17
|
+
"HeatingOptimizer",
|
|
19
18
|
"WaterCirculator",
|
|
20
|
-
"
|
|
19
|
+
"EnergyBalancer",
|
|
20
|
+
"LeakDetector",
|
|
21
21
|
]
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any
|
|
3
|
+
from typing_extensions import override
|
|
4
|
+
|
|
5
|
+
from juham_core import Juham, timestamp
|
|
6
|
+
from masterpiece import MqttMsg
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Consumer:
|
|
10
|
+
"""Class representing a consumer.
|
|
11
|
+
|
|
12
|
+
This class is used to represent a consumer in the energy balancer.
|
|
13
|
+
It contains the name of the consumer and its power consumption.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, name: str, power: float, weight: float) -> None:
|
|
18
|
+
"""Initialize the consumer
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
name (str): name of the consumer
|
|
22
|
+
power (float): power of the consumer in watts
|
|
23
|
+
"""
|
|
24
|
+
self.name = name
|
|
25
|
+
self.power: float = power
|
|
26
|
+
self.start: float = 0.0
|
|
27
|
+
self.stop: float = 0.0
|
|
28
|
+
self.weight: float = weight
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EnergyBalancer(Juham):
|
|
32
|
+
"""The energy balancer monitors the balance between produced and consumed energy
|
|
33
|
+
within the balancing interval to determine if there is enough energy available for
|
|
34
|
+
a given energy-consuming device, such as heating radiators, to operate within the
|
|
35
|
+
remaining time of the interval.
|
|
36
|
+
|
|
37
|
+
Any number of energy-consuming devices can be connected to the energy balancer, with
|
|
38
|
+
without any restrictions on the power consumption. The energy balancer will monitor the
|
|
39
|
+
power consumption of the devices and determine if there is enough energy available for
|
|
40
|
+
the devices to operate within the remaining time of the balancing interval.
|
|
41
|
+
|
|
42
|
+
The energy balancer is used in conjunction with a power meter that reads
|
|
43
|
+
the total power consumption of the house. The energy balancer uses the power meter
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
#: Description of the attribute
|
|
49
|
+
energy_balancing_interval: int = 900
|
|
50
|
+
"""The time interval in seconds for energy balancing."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, name: str = "energybalancer") -> None:
|
|
53
|
+
"""Initialize the energy balancer.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
name (str): name of the heating radiator
|
|
57
|
+
power (float): power of the consumer in watts
|
|
58
|
+
"""
|
|
59
|
+
super().__init__(name)
|
|
60
|
+
|
|
61
|
+
self.topic_in_consumers = self.make_topic_name("energybalance/consumers")
|
|
62
|
+
self.topic_out_status = self.make_topic_name("energybalance/status")
|
|
63
|
+
self.topic_out_diagnostics = self.make_topic_name("energybalance/diagnostics")
|
|
64
|
+
self.topic_in_power = self.make_topic_name("net_energy_balance")
|
|
65
|
+
|
|
66
|
+
self.net_energy_balance: float = 0.0 # Energy balance in joules (watt-seconds)
|
|
67
|
+
self.current_interval_ts: float = -1
|
|
68
|
+
self.needed_energy: float = 0.0 # Energy needed in joules (watt-seconds)
|
|
69
|
+
self.net_energy_balancing_mode: bool = False
|
|
70
|
+
self.consumers: dict[str, Consumer] = {}
|
|
71
|
+
|
|
72
|
+
@override
|
|
73
|
+
def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
|
|
74
|
+
super().on_connect(client, userdata, flags, rc)
|
|
75
|
+
if rc == 0:
|
|
76
|
+
self.subscribe(self.topic_in_power)
|
|
77
|
+
self.subscribe(self.topic_in_consumers)
|
|
78
|
+
|
|
79
|
+
@override
|
|
80
|
+
def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
|
|
81
|
+
ts: float = timestamp()
|
|
82
|
+
|
|
83
|
+
if msg.topic == self.topic_in_power:
|
|
84
|
+
self.on_power(json.loads(msg.payload.decode()), ts)
|
|
85
|
+
elif msg.topic == self.topic_in_consumers:
|
|
86
|
+
self.on_consumer(json.loads(msg.payload.decode()))
|
|
87
|
+
else:
|
|
88
|
+
super().on_message(client, userdata, msg)
|
|
89
|
+
|
|
90
|
+
def on_power(self, m: dict[str, Any], ts: float) -> None:
|
|
91
|
+
"""Handle the power consumption. Read the current power balance and accumulate
|
|
92
|
+
to the net energy balance to reflect the energy produced (or consumed) within the
|
|
93
|
+
current time slot.
|
|
94
|
+
Args:
|
|
95
|
+
m (dict[str, Any]): power consumption message
|
|
96
|
+
ts (float): current time
|
|
97
|
+
"""
|
|
98
|
+
self.update_energy_balance(m["power"], ts)
|
|
99
|
+
|
|
100
|
+
def on_consumer(self, m: dict[str, Any]) -> None:
|
|
101
|
+
"""Add consumer, e.g. heating radiator to be controlled.
|
|
102
|
+
Args:
|
|
103
|
+
m (dict[str, Any]): power consumer message
|
|
104
|
+
ts (float): current time
|
|
105
|
+
"""
|
|
106
|
+
self.consumers[m["Unit"]] = Consumer(m["Unit"], m["Power"], m["Weight"])
|
|
107
|
+
self.info(
|
|
108
|
+
f"Consumer {m['Unit']} added, power: {m['Power']}, weight: {m['Weight']}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def update_energy_balance(self, power: float, ts: float) -> None:
|
|
112
|
+
"""Update the current net net energy balance. The change in the balance is calculate the
|
|
113
|
+
energy balance, which the time elapsed since the last update, multiplied by the
|
|
114
|
+
power. Positive energy balance means we have produced energy that can be consumed
|
|
115
|
+
at the end of the interval. The target is to use all the energy produced during the
|
|
116
|
+
balancing interval. This method is called by the powermeter reading the
|
|
117
|
+
total power consumption of the house.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
power (float): power reading from the powermeter. Positive value means
|
|
121
|
+
energy produced, negative value means energy consumed. The value of 0 means
|
|
122
|
+
the house is not consuming or producing energy.
|
|
123
|
+
ts (float): current time in utc seconds
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
# regardless of the mode, if we hit the end of the interval, reset the balance
|
|
127
|
+
|
|
128
|
+
interval_ts: float = ts % self.energy_balancing_interval
|
|
129
|
+
if self.current_interval_ts < 0 or interval_ts <= self.current_interval_ts:
|
|
130
|
+
# time runs backwards, must be a new interval
|
|
131
|
+
self.reset_net_energy_balance(interval_ts)
|
|
132
|
+
else:
|
|
133
|
+
# update the energy balance with the elapsed time and the power
|
|
134
|
+
elapsed_ts = interval_ts - self.current_interval_ts
|
|
135
|
+
balance: float = elapsed_ts * power # joules i.e. watt-seconds
|
|
136
|
+
self.net_energy_balance = self.net_energy_balance + balance
|
|
137
|
+
self.current_interval_ts = interval_ts
|
|
138
|
+
self.needed_energy = self.calculate_needed_energy(interval_ts)
|
|
139
|
+
if self.net_energy_balancing_mode:
|
|
140
|
+
if self.net_energy_balance <= 0:
|
|
141
|
+
# if we have used all the energy, disable the balancing mode
|
|
142
|
+
self.reset_net_energy_balance(0.0)
|
|
143
|
+
else:
|
|
144
|
+
# consider enabling the balancing mode
|
|
145
|
+
# if we have enough energy to power the radiator for the rest of the time slot
|
|
146
|
+
if self.net_energy_balance >= self.needed_energy:
|
|
147
|
+
self.net_energy_balancing_mode = True
|
|
148
|
+
self.initialize_consumer_timelines(ts)
|
|
149
|
+
self.publish_energybalance(ts)
|
|
150
|
+
|
|
151
|
+
def calculate_needed_energy(self, interval_ts: float) -> float:
|
|
152
|
+
"""Calculate the energy needed to power the consumer for the rest of the time slot.
|
|
153
|
+
Assumes consumers run one at a time in serialized manner and are evenly distributed over the time slot
|
|
154
|
+
to minimze the power peak.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
interval_ts (float): current elapsed seconds within the balancing interval.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
float: energy needed in joules
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
required_power: float = 0.0
|
|
164
|
+
num_consumers: int = len(self.consumers)
|
|
165
|
+
if num_consumers == 0:
|
|
166
|
+
return 0.0
|
|
167
|
+
remaining_ts_consumer: float = (
|
|
168
|
+
self.energy_balancing_interval - interval_ts
|
|
169
|
+
) / num_consumers
|
|
170
|
+
total_weight: float = 0.0
|
|
171
|
+
for consumer in self.consumers.values():
|
|
172
|
+
total_weight += consumer.weight
|
|
173
|
+
equal_weight: float = total_weight / num_consumers
|
|
174
|
+
for consumer in self.consumers.values():
|
|
175
|
+
required_power += (
|
|
176
|
+
(consumer.weight / equal_weight)
|
|
177
|
+
* consumer.power
|
|
178
|
+
* remaining_ts_consumer
|
|
179
|
+
)
|
|
180
|
+
return required_power
|
|
181
|
+
|
|
182
|
+
def initialize_consumer_timelines(self, ts: float) -> None:
|
|
183
|
+
"""Initialize the list of active consumers with their start and stop times.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
ts (float): current time.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
None
|
|
190
|
+
"""
|
|
191
|
+
num_consumers: int = len(self.consumers)
|
|
192
|
+
if num_consumers == 0:
|
|
193
|
+
return # If there are no consumers, we simply do nothing
|
|
194
|
+
interval_ts: float = ts % self.energy_balancing_interval
|
|
195
|
+
secs_per_consumer: float = (
|
|
196
|
+
self.energy_balancing_interval - interval_ts
|
|
197
|
+
) / num_consumers
|
|
198
|
+
|
|
199
|
+
total_weight: float = 0.0
|
|
200
|
+
for consumer in self.consumers.values():
|
|
201
|
+
total_weight += consumer.weight
|
|
202
|
+
equal_weight: float = total_weight / num_consumers
|
|
203
|
+
|
|
204
|
+
for consumer in self.consumers.values():
|
|
205
|
+
consumer.start = interval_ts
|
|
206
|
+
consumer.stop = (
|
|
207
|
+
interval_ts + secs_per_consumer * consumer.weight / equal_weight
|
|
208
|
+
)
|
|
209
|
+
interval_ts += secs_per_consumer
|
|
210
|
+
|
|
211
|
+
def publish_energybalance(self, ts: float) -> None:
|
|
212
|
+
"""Publish diagnostics and status.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
ts (float): current time.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
None
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
# publish diagnostics
|
|
222
|
+
m: dict[str, Any] = {
|
|
223
|
+
"EnergyBalancer": self.name,
|
|
224
|
+
"CurrentBalance": self.net_energy_balance,
|
|
225
|
+
"NeededBalance": self.needed_energy,
|
|
226
|
+
"Timestamp": ts,
|
|
227
|
+
}
|
|
228
|
+
self.publish(self.topic_out_diagnostics, json.dumps(m))
|
|
229
|
+
|
|
230
|
+
# publish consumer statuses to control consumers
|
|
231
|
+
num_consumers: int = len(self.consumers)
|
|
232
|
+
if num_consumers == 0:
|
|
233
|
+
return # If there are no consumers, we simply do nothing
|
|
234
|
+
interval_ts = ts % self.energy_balancing_interval
|
|
235
|
+
for consumer in self.consumers.values():
|
|
236
|
+
m = {
|
|
237
|
+
"EnergyBalancer": self.name,
|
|
238
|
+
"Unit": consumer.name,
|
|
239
|
+
"Power": consumer.power,
|
|
240
|
+
"Mode": consumer.start <= interval_ts < consumer.stop,
|
|
241
|
+
"Weight": consumer.weight,
|
|
242
|
+
"Timestamp": ts,
|
|
243
|
+
}
|
|
244
|
+
self.publish(self.topic_out_status, json.dumps(m))
|
|
245
|
+
|
|
246
|
+
def detect_consumer_status(self, name: str, ts: float) -> bool:
|
|
247
|
+
"""Detect consumer status
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
name (str): name of the consumer
|
|
251
|
+
ts (float): current time.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
True if the consumer is active, False otherwise
|
|
255
|
+
"""
|
|
256
|
+
consumer: Consumer = self.consumers[name]
|
|
257
|
+
interval_ts = ts % self.energy_balancing_interval
|
|
258
|
+
return consumer.start <= interval_ts < consumer.stop
|
|
259
|
+
|
|
260
|
+
def reset_net_energy_balance(self, interval_ts: float) -> None:
|
|
261
|
+
"""Reset the net energy balance at the end of the interval."""
|
|
262
|
+
self.net_energy_balance = 0.0
|
|
263
|
+
self.current_interval_ts = interval_ts
|
|
264
|
+
self.needed_energy = self.calculate_needed_energy(interval_ts)
|
|
265
|
+
self.net_energy_balancing_mode = False
|
|
266
|
+
for consumer in self.consumers.values():
|
|
267
|
+
consumer.start = 0.0
|
|
268
|
+
consumer.stop = 0.0
|
|
269
|
+
|
|
270
|
+
def activate_balancing_mode(self, ts: float) -> None:
|
|
271
|
+
"""Activate balancing mode when enough energy is available."""
|
|
272
|
+
self.net_energy_balancing_mode = True
|
|
273
|
+
self.info(
|
|
274
|
+
f"{int(self.net_energy_balance/3600)} Wh is enough to supply the radiator, enable"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def deactivate_balancing_mode(self) -> None:
|
|
278
|
+
"""Deactivate balancing mode when energy is depleted or interval ends."""
|
|
279
|
+
self.net_energy_balancing_mode = False
|
|
280
|
+
self.info("Balance used, or the end of the interval reached, disable")
|
|
281
|
+
self.net_energy_balance = 0.0 # Reset the energy balance at the interval's end
|
|
@@ -6,19 +6,21 @@ from juham_core import Juham
|
|
|
6
6
|
from juham_core.timeutils import (
|
|
7
7
|
elapsed_seconds_in_day,
|
|
8
8
|
elapsed_seconds_in_hour,
|
|
9
|
+
elapsed_seconds_in_interval,
|
|
9
10
|
quantize,
|
|
10
11
|
timestamp,
|
|
11
12
|
)
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class EnergyCostCalculator(Juham):
|
|
15
|
-
"""The EnergyCostCalculator class calculates the net energy balance between produced
|
|
16
|
-
|
|
16
|
+
"""The EnergyCostCalculator class calculates the net energy balance and cost between produced
|
|
17
|
+
(or consumed) energy for Time-Based Settlement (TBS). It performs the following functions:
|
|
17
18
|
|
|
18
19
|
* Subscribes to 'spot' and 'power' MQTT topics.
|
|
19
20
|
* Calculates the net energy and the rate of change of the net energy per hour and per day (24h)
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* Calculates the cost of energy consumed/produced based on the spot prices.
|
|
22
|
+
* Publishes the calculated values to the MQTT net energy balance and cost topics.
|
|
23
|
+
|
|
22
24
|
|
|
23
25
|
This information helps other home automation components optimize energy usage and
|
|
24
26
|
minimize electricity bills.
|
|
@@ -27,17 +29,20 @@ class EnergyCostCalculator(Juham):
|
|
|
27
29
|
_kwh_to_joule_coeff: float = 1000.0 * 3600
|
|
28
30
|
_joule_to_kwh_coeff: float = 1.0 / _kwh_to_joule_coeff
|
|
29
31
|
|
|
30
|
-
energy_balancing_interval:
|
|
32
|
+
energy_balancing_interval: int = 900 # in seconds (15 minutes)
|
|
31
33
|
|
|
32
34
|
def __init__(self, name: str = "ecc") -> None:
|
|
33
35
|
super().__init__(name)
|
|
34
36
|
self.current_ts: float = 0
|
|
37
|
+
self.total_balance_interval : float = 0
|
|
35
38
|
self.total_balance_hour: float = 0
|
|
36
39
|
self.total_balance_day: float = 0
|
|
40
|
+
self.net_energy_balance_cost_interval: float = 0
|
|
37
41
|
self.net_energy_balance_cost_hour: float = 0
|
|
38
42
|
self.net_energy_balance_cost_day: float = 0
|
|
39
|
-
self.
|
|
40
|
-
self.
|
|
43
|
+
self.net_energy_balance_start_interval : float = elapsed_seconds_in_interval(timestamp(), self.energy_balancing_interval)
|
|
44
|
+
self.net_energy_balance_start_hour : float = elapsed_seconds_in_hour(timestamp())
|
|
45
|
+
self.net_energy_balance_start_day : float = elapsed_seconds_in_day(timestamp())
|
|
41
46
|
self.spots: list[dict[str, float]] = []
|
|
42
47
|
self.init_topics()
|
|
43
48
|
|
|
@@ -64,13 +69,13 @@ class EnergyCostCalculator(Juham):
|
|
|
64
69
|
elif msg.topic == self.topic_in_powerconsumption:
|
|
65
70
|
self.on_powerconsumption(ts_now, m)
|
|
66
71
|
else:
|
|
67
|
-
|
|
72
|
+
super().on_message(client, userdata, msg)
|
|
68
73
|
|
|
69
74
|
def on_spot(self, spot: dict[Any, Any]) -> None:
|
|
70
|
-
"""Stores the received per
|
|
75
|
+
"""Stores the received per slot electricity prices to spots list.
|
|
71
76
|
|
|
72
77
|
Args:
|
|
73
|
-
spot (list): list of
|
|
78
|
+
spot (list): list of spot prices
|
|
74
79
|
"""
|
|
75
80
|
|
|
76
81
|
for s in spot:
|
|
@@ -87,76 +92,91 @@ class EnergyCostCalculator(Juham):
|
|
|
87
92
|
"""
|
|
88
93
|
return price * self._joule_to_kwh_coeff
|
|
89
94
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
|
|
96
|
+
def get_price_at(self, ts: float) -> float:
|
|
97
|
+
"""Return the spot price applicable at the given timestamp.
|
|
93
98
|
|
|
94
99
|
Args:
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
ts (float): current time (epoch seconds)
|
|
101
|
+
|
|
97
102
|
Returns:
|
|
98
|
-
|
|
103
|
+
float: PriceWithTax for the slot that contains ts. Returns the last
|
|
104
|
+
known price if ts is equal/after the last spot timestamp.
|
|
105
|
+
Returns 0.0 and logs an error if no matching slot is found.
|
|
99
106
|
"""
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
if not self.spots:
|
|
108
|
+
self.error(f"PANIC: no spot prices available; lookup ts={ts}")
|
|
109
|
+
return 0.0
|
|
110
|
+
|
|
111
|
+
# ensure spots sorted by timestamp (defensive)
|
|
112
|
+
try:
|
|
113
|
+
# cheap check — assumes list of dicts with "Timestamp"
|
|
114
|
+
if any(self.spots[i]["Timestamp"] > self.spots[i + 1]["Timestamp"] for i in range(len(self.spots) - 1)):
|
|
115
|
+
self.spots.sort(key=lambda r: r["Timestamp"])
|
|
116
|
+
except Exception:
|
|
117
|
+
# if unexpected structure, still try safe path below and log
|
|
118
|
+
self.debug("get_price_at: spot list structure unexpected while checking sort order", "")
|
|
102
119
|
|
|
103
120
|
for i in range(0, len(self.spots) - 1):
|
|
104
121
|
r0 = self.spots[i]
|
|
105
122
|
r1 = self.spots[i + 1]
|
|
106
123
|
ts0 = r0["Timestamp"]
|
|
107
124
|
ts1 = r1["Timestamp"]
|
|
108
|
-
if
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
125
|
+
if ts >= ts0 and ts < ts1:
|
|
126
|
+
return r0["PriceWithTax"]
|
|
127
|
+
|
|
128
|
+
# If timestamp is exactly equal to the last spot timestamp or beyond
|
|
129
|
+
last = self.spots[-1]
|
|
130
|
+
if ts >= last["Timestamp"]:
|
|
131
|
+
return last["PriceWithTax"]
|
|
132
|
+
|
|
133
|
+
# If we get here, ts is before the first spot timestamp
|
|
134
|
+
first = self.spots[0]
|
|
135
|
+
self.error(
|
|
136
|
+
f"PANIC: Timestamp {ts} out of bounds for spot price lookup; "
|
|
137
|
+
f"first=(ts={first['Timestamp']}, price={first.get('PriceWithTax')}), "
|
|
138
|
+
f"last=(ts={last['Timestamp']}, price={last.get('PriceWithTax')}), "
|
|
139
|
+
f"len(spots)={len(self.spots)}"
|
|
140
|
+
)
|
|
141
|
+
return 0.0
|
|
142
|
+
|
|
143
|
+
|
|
116
144
|
|
|
117
145
|
def calculate_net_energy_cost(
|
|
118
146
|
self, ts_prev: float, ts_now: float, energy: float
|
|
119
147
|
) -> float:
|
|
120
|
-
"""
|
|
121
|
-
|
|
148
|
+
"""
|
|
149
|
+
Calculate the cost (or revenue) of energy consumed/produced over the given time interval.
|
|
150
|
+
Positive values indicate revenue, negative values indicate cost.
|
|
122
151
|
|
|
123
152
|
Args:
|
|
124
|
-
ts_prev (
|
|
125
|
-
ts_now (
|
|
126
|
-
energy (float):
|
|
153
|
+
ts_prev (float): Start timestamp of the interval
|
|
154
|
+
ts_now (float): End timestamp of the interval
|
|
155
|
+
energy (float): Energy consumed during the interval (in watts or Joules)
|
|
156
|
+
|
|
127
157
|
Returns:
|
|
128
|
-
|
|
158
|
+
float: Total cost/revenue for the interval
|
|
129
159
|
"""
|
|
130
|
-
cost
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
ts_0 = quantize(self.energy_balancing_interval, now)
|
|
148
|
-
t1 = (ts_0 - prev) / elapsed
|
|
149
|
-
t2 = (now - ts_0) / elapsed
|
|
150
|
-
cost = (
|
|
151
|
-
cost
|
|
152
|
-
+ energy
|
|
153
|
-
* ((1.0 - t1) * start_price + t2 * stop_price)
|
|
154
|
-
* elapsed_seconds
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
prev = prev + elapsed_seconds
|
|
160
|
+
cost = 0.0
|
|
161
|
+
current = ts_prev
|
|
162
|
+
interval = self.energy_balancing_interval
|
|
163
|
+
|
|
164
|
+
while current < ts_now:
|
|
165
|
+
next_ts = min(ts_now, current + interval)
|
|
166
|
+
# Get spot price at start and end of interval
|
|
167
|
+
price_start = self.map_kwh_prices_to_joules(self.get_price_at(current))
|
|
168
|
+
price_end = self.map_kwh_prices_to_joules(self.get_price_at(next_ts))
|
|
169
|
+
|
|
170
|
+
# Trapezoidal integration: average price over interval
|
|
171
|
+
avg_price = (price_start + price_end) / 2.0
|
|
172
|
+
dt = next_ts - current
|
|
173
|
+
cost += energy * avg_price * dt
|
|
174
|
+
|
|
175
|
+
current = next_ts
|
|
176
|
+
|
|
158
177
|
return cost
|
|
159
178
|
|
|
179
|
+
|
|
160
180
|
def on_powerconsumption(self, ts_now: float, m: dict[Any, Any]) -> None:
|
|
161
181
|
"""Calculate net energy cost and update the hourly consumption attribute
|
|
162
182
|
accordingly.
|
|
@@ -169,21 +189,29 @@ class EnergyCostCalculator(Juham):
|
|
|
169
189
|
if not self.spots:
|
|
170
190
|
self.info("Waiting for electricity prices...")
|
|
171
191
|
elif self.current_ts == 0:
|
|
192
|
+
self.net_energy_balance_cost_interval = 0.0
|
|
172
193
|
self.net_energy_balance_cost_hour = 0.0
|
|
173
194
|
self.net_energy_balance_cost_day = 0.0
|
|
174
195
|
self.current_ts = ts_now
|
|
175
|
-
self.
|
|
196
|
+
self.net_energy_balance_start_interval = quantize(
|
|
176
197
|
self.energy_balancing_interval, ts_now
|
|
177
198
|
)
|
|
199
|
+
self.net_energy_balance_start_hour = quantize(
|
|
200
|
+
3600, ts_now
|
|
201
|
+
)
|
|
178
202
|
else:
|
|
179
203
|
# calculate cost of energy consumed/produced
|
|
180
204
|
dp: float = self.calculate_net_energy_cost(self.current_ts, ts_now, power)
|
|
205
|
+
self.net_energy_balance_cost_interval = self.net_energy_balance_cost_interval + dp
|
|
181
206
|
self.net_energy_balance_cost_hour = self.net_energy_balance_cost_hour + dp
|
|
182
207
|
self.net_energy_balance_cost_day = self.net_energy_balance_cost_day + dp
|
|
183
208
|
|
|
184
209
|
# calculate and publish energy balance
|
|
185
210
|
dt = ts_now - self.current_ts # time elapsed since previous call
|
|
186
211
|
balance = dt * power # energy consumed/produced in this slot in Joules
|
|
212
|
+
self.total_balance_interval = (
|
|
213
|
+
self.total_balance_interval + balance * self._joule_to_kwh_coeff
|
|
214
|
+
)
|
|
187
215
|
self.total_balance_hour = (
|
|
188
216
|
self.total_balance_hour + balance * self._joule_to_kwh_coeff
|
|
189
217
|
)
|
|
@@ -194,16 +222,32 @@ class EnergyCostCalculator(Juham):
|
|
|
194
222
|
self.publish_energy_cost(
|
|
195
223
|
ts_now,
|
|
196
224
|
self.name,
|
|
225
|
+
self.net_energy_balance_cost_interval,
|
|
197
226
|
self.net_energy_balance_cost_hour,
|
|
198
227
|
self.net_energy_balance_cost_day,
|
|
199
228
|
)
|
|
200
229
|
|
|
201
230
|
# Check if the current energy balancing interval has ended
|
|
202
231
|
# If so, reset the net_energy_balance attribute for the next interval
|
|
203
|
-
if
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
232
|
+
if ts_now - self.net_energy_balance_start_interval > self.energy_balancing_interval:
|
|
233
|
+
# publish average energy cost per hour
|
|
234
|
+
if abs(self.total_balance_interval) > 0:
|
|
235
|
+
msg = {
|
|
236
|
+
"name": self.name,
|
|
237
|
+
"average_interval": self.net_energy_balance_cost_interval
|
|
238
|
+
/ self.total_balance_interval,
|
|
239
|
+
"ts": ts_now,
|
|
240
|
+
}
|
|
241
|
+
self.publish(self.topic_out_energy_cost, json.dumps(msg), 0, False)
|
|
242
|
+
|
|
243
|
+
# reset for the next hour
|
|
244
|
+
self.total_balance_interval = 0
|
|
245
|
+
self.net_energy_balance_cost_interval = 0.0
|
|
246
|
+
self.net_energy_balance_start_interval = ts_now
|
|
247
|
+
|
|
248
|
+
# Check if the current energy balancing interval has ended
|
|
249
|
+
# If so, reset the net_energy_balance attribute for the next interval
|
|
250
|
+
if ts_now - self.net_energy_balance_start_hour > 3600:
|
|
207
251
|
# publish average energy cost per hour
|
|
208
252
|
if abs(self.total_balance_hour) > 0:
|
|
209
253
|
msg = {
|
|
@@ -252,9 +296,9 @@ class EnergyCostCalculator(Juham):
|
|
|
252
296
|
self.publish(self.topic_out_net_energy_balance, json.dumps(msg), 1, True)
|
|
253
297
|
|
|
254
298
|
def publish_energy_cost(
|
|
255
|
-
self, ts_now: float, site: str, cost_hour: float, cost_day: float
|
|
299
|
+
self, ts_now: float, site: str, cost_interval : float, cost_hour: float, cost_day: float
|
|
256
300
|
) -> None:
|
|
257
|
-
"""Publish daily and
|
|
301
|
+
"""Publish daily, hourly and per interval energy cost/revenue
|
|
258
302
|
|
|
259
303
|
Args:
|
|
260
304
|
ts_now (float): timestamp
|
|
@@ -262,5 +306,5 @@ class EnergyCostCalculator(Juham):
|
|
|
262
306
|
cost_hour (float): cost or revenue per hour.
|
|
263
307
|
cost_day (float) : cost or revenue per day
|
|
264
308
|
"""
|
|
265
|
-
msg = {"name": site, "cost_hour": cost_hour, "cost_day": cost_day, "ts": ts_now}
|
|
309
|
+
msg = {"name": site, "cost_interval": cost_interval, "cost_hour": cost_hour, "cost_day": cost_day, "ts": ts_now}
|
|
266
310
|
self.publish(self.topic_out_energy_cost, json.dumps(msg), 1, True)
|