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.
@@ -1,37 +1,42 @@
1
- """
2
- Description
3
- ===========
4
-
5
- Juham - Juha's Ultimate Home Automation Masterpiece
6
-
7
- """
8
-
9
- from .automation.energycostcalculator import EnergyCostCalculator
10
- from .ts.energycostcalculator_ts import EnergyCostCalculatorTs
11
- from .ts.forecast_ts import ForecastTs
12
- from .ts.log_ts import LogTs
13
- from .ts.power_ts import PowerTs
14
- from .ts.powerplan_ts import PowerPlanTs
15
- from .ts.powermeter_ts import PowerMeterTs
16
- from .ts.electricityprice_ts import ElectricityPriceTs
17
- from .automation.spothintafi import SpotHintaFi
18
- from .automation.watercirculator import WaterCirculator
19
- from .automation.hotwateroptimizer import HotWaterOptimizer
20
- from .japp import JApp
21
- from .automation.powermeter_simulator import PowerMeterSimulator
22
-
23
- __all__ = [
24
- "EnergyCostCalculator",
25
- "EnergyCostCalculatorTs",
26
- "ForecastTs",
27
- "HotWaterOptimizer",
28
- "LogTs",
29
- "PowerTs",
30
- "PowerPlanTs",
31
- "PowerMeterTs",
32
- "SpotHintaFi",
33
- "WaterCirculator",
34
- "JApp",
35
- "PowerMeterSimulator",
36
- "ElectricityPriceTs",
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