juham-automation 0.0.2__py3-none-any.whl → 0.2.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- juham_automation/__init__.py +42 -37
- juham_automation/automation/__init__.py +23 -0
- juham_automation/automation/energybalancer.py +281 -0
- juham_automation/automation/energycostcalculator.py +310 -0
- juham_automation/automation/heatingoptimizer.py +971 -0
- juham_automation/automation/leakdetector.py +162 -0
- juham_automation/automation/spothintafi.py +140 -0
- juham_automation/automation/watercirculator.py +140 -0
- juham_automation/japp.py +53 -55
- juham_automation/ts/__init__.py +27 -0
- juham_automation/ts/electricityprice_ts.py +51 -0
- juham_automation/ts/energybalancer_ts.py +73 -0
- juham_automation/ts/energycostcalculator_ts.py +45 -0
- juham_automation/ts/forecast_ts.py +97 -0
- juham_automation/ts/log_ts.py +60 -0
- juham_automation/ts/power_ts.py +52 -0
- juham_automation/ts/powermeter_ts.py +68 -0
- juham_automation/ts/powerplan_ts.py +64 -0
- juham_automation-0.2.8.dist-info/METADATA +199 -0
- juham_automation-0.2.8.dist-info/RECORD +25 -0
- {juham_automation-0.0.2.dist-info → juham_automation-0.2.8.dist-info}/WHEEL +1 -1
- {juham_automation-0.0.2.dist-info → juham_automation-0.2.8.dist-info}/entry_points.txt +4 -2
- {juham_automation-0.0.2.dist-info → juham_automation-0.2.8.dist-info/licenses}/LICENSE.rst +25 -25
- juham_automation-0.0.2.dist-info/METADATA +0 -103
- juham_automation-0.0.2.dist-info/RECORD +0 -9
- {juham_automation-0.0.2.dist-info → juham_automation-0.2.8.dist-info}/top_level.txt +0 -0
juham_automation/__init__.py
CHANGED
|
@@ -1,37 +1,42 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Description
|
|
3
|
-
===========
|
|
4
|
-
|
|
5
|
-
Juham - Juha's Ultimate Home Automation Masterpiece
|
|
6
|
-
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from .automation
|
|
10
|
-
from .
|
|
11
|
-
from .
|
|
12
|
-
from .
|
|
13
|
-
from .
|
|
14
|
-
from .
|
|
15
|
-
from .ts
|
|
16
|
-
from .ts
|
|
17
|
-
from .
|
|
18
|
-
from .
|
|
19
|
-
from .
|
|
20
|
-
from .
|
|
21
|
-
from .
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
1
|
+
"""
|
|
2
|
+
Description
|
|
3
|
+
===========
|
|
4
|
+
|
|
5
|
+
Juham - Juha's Ultimate Home Automation Masterpiece
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .automation import EnergyCostCalculator
|
|
10
|
+
from .automation import SpotHintaFi
|
|
11
|
+
from .automation import WaterCirculator
|
|
12
|
+
from .automation import HeatingOptimizer
|
|
13
|
+
from .automation import EnergyBalancer
|
|
14
|
+
from .automation import LeakDetector
|
|
15
|
+
from .ts import EnergyCostCalculatorTs
|
|
16
|
+
from .ts import ForecastTs
|
|
17
|
+
from .ts import LogTs
|
|
18
|
+
from .ts import PowerTs
|
|
19
|
+
from .ts import PowerPlanTs
|
|
20
|
+
from .ts import PowerMeterTs
|
|
21
|
+
from .ts import ElectricityPriceTs
|
|
22
|
+
from .ts import EnergyBalancerTs
|
|
23
|
+
from .japp import JApp
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"EnergyCostCalculator",
|
|
28
|
+
"EnergyCostCalculatorTs",
|
|
29
|
+
"ForecastTs",
|
|
30
|
+
"HeatingOptimizer",
|
|
31
|
+
"EnergyBalancer",
|
|
32
|
+
"LeakDetector",
|
|
33
|
+
"LogTs",
|
|
34
|
+
"PowerTs",
|
|
35
|
+
"PowerPlanTs",
|
|
36
|
+
"PowerMeterTs",
|
|
37
|
+
"SpotHintaFi",
|
|
38
|
+
"WaterCirculator",
|
|
39
|
+
"JApp",
|
|
40
|
+
"ElectricityPriceTs",
|
|
41
|
+
"EnergyBalancerTs",
|
|
42
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Description
|
|
3
|
+
===========
|
|
4
|
+
|
|
5
|
+
Juham - Juha's Ultimate Home Automation classes
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .energycostcalculator import EnergyCostCalculator
|
|
10
|
+
from .spothintafi import SpotHintaFi
|
|
11
|
+
from .watercirculator import WaterCirculator
|
|
12
|
+
from .heatingoptimizer import HeatingOptimizer
|
|
13
|
+
from .energybalancer import EnergyBalancer
|
|
14
|
+
from .leakdetector import LeakDetector
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"EnergyCostCalculator",
|
|
18
|
+
"HeatingOptimizer",
|
|
19
|
+
"SpotHintaFi",
|
|
20
|
+
"WaterCirculator",
|
|
21
|
+
"EnergyBalancer",
|
|
22
|
+
"LeakDetector",
|
|
23
|
+
]
|
|
@@ -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
|