juham-automation 0.0.26__tar.gz → 0.0.28__tar.gz

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.
Files changed (49) hide show
  1. {juham_automation-0.0.26/juham_automation.egg-info → juham_automation-0.0.28}/PKG-INFO +2 -2
  2. juham_automation-0.0.28/juham_automation/automation/energybalancer.py +239 -0
  3. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/automation/heatingoptimizer.py +18 -2
  4. {juham_automation-0.0.26 → juham_automation-0.0.28/juham_automation.egg-info}/PKG-INFO +2 -2
  5. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation.egg-info/requires.txt +1 -1
  6. {juham_automation-0.0.26 → juham_automation-0.0.28}/pyproject.toml +2 -2
  7. juham_automation-0.0.28/tests/automation/test_energybalancer.py +107 -0
  8. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/automation/test_heatingoptimizer.py +5 -4
  9. juham_automation-0.0.26/juham_automation/automation/energybalancer.py +0 -158
  10. juham_automation-0.0.26/tests/automation/test_energybalancer.py +0 -183
  11. {juham_automation-0.0.26 → juham_automation-0.0.28}/LICENSE.rst +0 -0
  12. {juham_automation-0.0.26 → juham_automation-0.0.28}/MANIFEST.in +0 -0
  13. {juham_automation-0.0.26 → juham_automation-0.0.28}/README.rst +0 -0
  14. {juham_automation-0.0.26 → juham_automation-0.0.28}/examples/myapp.py +0 -0
  15. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/__init__.py +0 -0
  16. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/automation/__init__.py +0 -0
  17. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/automation/energycostcalculator.py +0 -0
  18. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/automation/powermeter_simulator.py +0 -0
  19. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/automation/spothintafi.py +0 -0
  20. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/automation/watercirculator.py +0 -0
  21. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/japp.py +0 -0
  22. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/py.typed +0 -0
  23. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/ts/__init__.py +0 -0
  24. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/ts/electricityprice_ts.py +0 -0
  25. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/ts/energybalancer_ts.py +0 -0
  26. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/ts/energycostcalculator_ts.py +0 -0
  27. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/ts/forecast_ts.py +0 -0
  28. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/ts/log_ts.py +0 -0
  29. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/ts/power_ts.py +0 -0
  30. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/ts/powermeter_ts.py +0 -0
  31. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation/ts/powerplan_ts.py +0 -0
  32. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation.egg-info/SOURCES.txt +0 -0
  33. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation.egg-info/dependency_links.txt +0 -0
  34. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation.egg-info/entry_points.txt +0 -0
  35. {juham_automation-0.0.26 → juham_automation-0.0.28}/juham_automation.egg-info/top_level.txt +0 -0
  36. {juham_automation-0.0.26 → juham_automation-0.0.28}/setup.cfg +0 -0
  37. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/__init__.py +0 -0
  38. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/automation/__init__.py +0 -0
  39. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/automation/test_energycostcalculator.py +0 -0
  40. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/automation/test_juham.py +0 -0
  41. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/automation/test_spothintafi.py +0 -0
  42. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/test_japp.py +0 -0
  43. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/ts/__init__.py +0 -0
  44. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/ts/test_energycostcalculator_ts.py +0 -0
  45. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/ts/test_forecast_ts.py +0 -0
  46. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/ts/test_log_ts.py +0 -0
  47. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/ts/test_power_ts.py +0 -0
  48. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/ts/test_powermeter_ts.py +0 -0
  49. {juham_automation-0.0.26 → juham_automation-0.0.28}/tests/ts/test_powerplan_ts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: juham-automation
3
- Version: 0.0.26
3
+ Version: 0.0.28
4
4
  Summary: Juha's Ultimate Home Automation Masterpiece
5
5
  Author-email: J Meskanen <juham.api@gmail.com>
6
6
  Maintainer-email: "J. Meskanen" <juham.api@gmail.com>
@@ -44,7 +44,7 @@ Classifier: Programming Language :: Python :: 3.8
44
44
  Requires-Python: >=3.8
45
45
  Description-Content-Type: text/markdown
46
46
  License-File: LICENSE.rst
47
- Requires-Dist: juham_core>=0.1.3
47
+ Requires-Dist: juham_core>=0.1.5
48
48
  Provides-Extra: dev
49
49
  Requires-Dist: check-manifest; extra == "dev"
50
50
  Requires-Dist: types-pyz; extra == "dev"
@@ -0,0 +1,239 @@
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 EnergyBalancer(Juham):
10
+ """The energy balancer monitors the balance between produced and consumed energy
11
+ within the balancing interval to determine if there is enough energy available for
12
+ a given energy-consuming device, such as heating radiators, to operate within the
13
+ remaining time of the interval.
14
+
15
+ Any number of energy-consuming devices can be connected to the energy balancer, with
16
+ without any restrictions on the power consumption. The energy balancer will monitor the
17
+ power consumption of the devices and determine if there is enough energy available for
18
+ the devices to operate within the remaining time of the balancing interval.
19
+
20
+ The energy balancer is used in conjunction with a power meter that reads
21
+ the total power consumption of the house. The energy balancer uses the power meter
22
+ """
23
+
24
+ #: Description of the attribute
25
+ energy_balancing_interval: int = 3600
26
+ """The time interval in seconds for energy balancing."""
27
+
28
+ def __init__(self, name: str = "energybalancer") -> None:
29
+ """Initialize the energy balancer.
30
+
31
+ Args:
32
+ name (str): name of the heating radiator
33
+ power (float): power of the consumer in watts
34
+ """
35
+ super().__init__(name)
36
+
37
+ self.topic_in_consumers = self.make_topic_name("energybalance_consumers")
38
+ self.topic_in_net_energy_balance = self.make_topic_name("net_energy_balance")
39
+ self.topic_out_energybalance = self.make_topic_name("energybalance")
40
+ self.net_energy_balance: float = 0.0 # Energy balance in joules (watt-seconds)
41
+ self.current_interval_ts: float = -1
42
+ self.needed_energy: float = 0.0 # Energy needed in joules (watt-seconds)
43
+ self.net_energy_balancing_mode: bool = False
44
+ self.consumers: dict[str, float] = {}
45
+ self.active_consumers: dict[str, dict[float, float]] = {}
46
+
47
+ @override
48
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
49
+ super().on_connect(client, userdata, flags, rc)
50
+ if rc == 0:
51
+ self.subscribe(self.topic_in_net_energy_balance)
52
+ self.subscribe(self.topic_in_consumers)
53
+
54
+ @override
55
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
56
+ ts: float = timestamp()
57
+
58
+ if msg.topic == self.topic_in_net_energy_balance:
59
+ self.on_power(json.loads(msg.payload.decode()), ts)
60
+ elif msg.topic == self.topic_in_consumers:
61
+ self.on_consumer(json.loads(msg.payload.decode()))
62
+ else:
63
+ super().on_message(client, userdata, msg)
64
+
65
+ def on_power(self, m: dict[str, Any], ts: float) -> None:
66
+ """Handle the power consumption. Read the current power balance and accumulate
67
+ to the net energy balance to reflect the energy produced (or consumed) within the
68
+ current time slot.
69
+ Args:
70
+ m (dict[str, Any]): power consumption message
71
+ ts (float): current time
72
+ """
73
+ self.update_energy_balance(m["power"], ts)
74
+
75
+ def on_consumer(self, m: dict[str, Any]) -> None:
76
+ """Add consumer, e.g. heating radiator to be controlled.
77
+ Args:
78
+ m (dict[str, Any]): power consumer message
79
+ ts (float): current time
80
+ """
81
+ self.consumers[m["Unit"]] = m["Power"]
82
+ self.info(f"Consumer {m['Unit']} added, power: {m['Power']}")
83
+
84
+ def update_energy_balance(self, power: float, ts: float) -> None:
85
+ """Update the current net net energy balance. The change in the balance is calculate the
86
+ energy balance, which the time elapsed since the last update, multiplied by the
87
+ power. Positive energy balance means we have produced energy that can be consumed
88
+ at the end of the interval. The target is to use all the energy produced during the
89
+ balancing interval. This method is called by the powermeter reading the
90
+ total power consumption of the house.
91
+
92
+ Args:
93
+ power (float): power reading from the powermeter. Positive value means
94
+ energy produced, negative value means energy consumed. The value of 0 means
95
+ the house is not consuming or producing energy.
96
+ ts (float): current time in utc seconds
97
+ """
98
+
99
+ # regardless of the mode, if we hit the end of the interval, reset the balance
100
+ interval_ts: float = ts % self.energy_balancing_interval
101
+ if self.current_interval_ts < 0 or interval_ts <= self.current_interval_ts:
102
+ # time runs backwards, must be a new interval
103
+ self.reset_net_energy_balance(interval_ts)
104
+ else:
105
+ # update the energy balance with the elapsed time and the power
106
+ elapsed_ts = interval_ts - self.current_interval_ts
107
+ balance: float = elapsed_ts * power # joules i.e. watt-seconds
108
+ self.net_energy_balance = self.net_energy_balance + balance
109
+ self.current_interval_ts = interval_ts
110
+ self.needed_energy = self.calculate_needed_energy(interval_ts)
111
+ if self.net_energy_balancing_mode:
112
+ if self.net_energy_balance <= 0:
113
+ # if we have used all the energy, disable the balancing mode
114
+ self.reset_net_energy_balance(0.0)
115
+ else:
116
+ # consider enabling the balancing mode
117
+ # if we have enough energy to power the radiator for the rest of the time slot
118
+ if self.net_energy_balance >= self.needed_energy:
119
+ self.net_energy_balancing_mode = True
120
+ self.initialize_active_consumers(ts)
121
+ self.publish_energybalance(ts)
122
+
123
+ def calculate_needed_energy(self, interval_ts: float) -> float:
124
+ """Calculate the energy needed to power the consumer for the rest of the time slot.
125
+ Assumes consumers run one at a time in serialized manner and are evenly distributed over the time slot
126
+ to minimze the power peak.
127
+
128
+ Args:
129
+ interval_ts (float): current elapsed seconds within the balancing interval.
130
+
131
+ Returns:
132
+ float: energy needed in joules
133
+ """
134
+ required_power: float = 0.0
135
+ num_consumers: int = len(self.consumers)
136
+ remaining_ts_consumer: float = (
137
+ self.energy_balancing_interval - interval_ts
138
+ ) / num_consumers
139
+ for consumer in self.consumers.values():
140
+ required_power += consumer * remaining_ts_consumer
141
+ return required_power
142
+
143
+ def initialize_active_consumers(self, ts: float) -> None:
144
+ """Initialize the list of active consumers with their start and stop times.
145
+
146
+ Args:
147
+ ts (float): current time.
148
+
149
+ Returns:
150
+ None
151
+ """
152
+ num_consumers: int = len(self.consumers)
153
+ if num_consumers == 0:
154
+ return # If there are no consumers, we simply do nothing
155
+ interval_ts: float = ts % self.energy_balancing_interval
156
+ secs_per_consumer: float = (
157
+ self.energy_balancing_interval - interval_ts
158
+ ) / num_consumers
159
+
160
+ # Reset the active consumers dictionary
161
+ self.active_consumers.clear()
162
+
163
+ for consumer_name, consumer_data in self.consumers.items():
164
+ start: float = interval_ts
165
+ stop: float = start + secs_per_consumer
166
+
167
+ # Add the consumer to the active consumers dictionary with its start and stop times
168
+ self.active_consumers[consumer_name] = {start: stop}
169
+
170
+ # Update interval_ts to the stop time for the next consumer
171
+ interval_ts = stop
172
+
173
+ def consider_net_energy_balance(self, unit: str, ts: float) -> bool:
174
+ """Check if there is enough energy available for the consumer to heat
175
+ the water in the remaining time within the balancing interval.
176
+
177
+ Args:
178
+ unit (str): name of the consumer
179
+ ts (float): current time
180
+
181
+ Returns:
182
+ bool: true if the given consumer is active
183
+ """
184
+ # Check if the consumer exists in the active_consumers dictionary
185
+ if unit not in self.active_consumers:
186
+ return False # The consumer is not found, so return False
187
+
188
+ # Get the start and stop time dictionary for the consumer
189
+ consumer_times = self.active_consumers[unit]
190
+
191
+ # map the current time to the balancing interval time slot
192
+ interval_ts: float = ts % self.energy_balancing_interval
193
+
194
+ # Check if current time (ts) is within the active range
195
+ for start_ts, stop_ts in consumer_times.items():
196
+ if start_ts <= interval_ts < stop_ts:
197
+ return True # If the current time is within the range, the consumer is active
198
+
199
+ return False # If no matching time range was found, return False
200
+
201
+ def reset_net_energy_balance(self, interval_ts: float) -> None:
202
+ """Reset the net energy balance at the end of the interval."""
203
+ self.net_energy_balance = 0.0
204
+ self.current_interval_ts = interval_ts
205
+ self.needed_energy = self.calculate_needed_energy(interval_ts)
206
+ self.net_energy_balancing_mode = False
207
+ self.active_consumers.clear() # Clear the active consumers at the end of the interval
208
+ self.info("Energy balance reset, interval ended")
209
+
210
+ def activate_balancing_mode(self, ts: float) -> None:
211
+ """Activate balancing mode when enough energy is available."""
212
+ self.net_energy_balancing_mode = True
213
+ self.info(
214
+ f"{int(self.net_energy_balance/3600)} Wh is enough to supply the radiator, enable"
215
+ )
216
+
217
+ def deactivate_balancing_mode(self) -> None:
218
+ """Deactivate balancing mode when energy is depleted or interval ends."""
219
+ self.net_energy_balancing_mode = False
220
+ self.info("Balance used, or the end of the interval reached, disable")
221
+ self.net_energy_balance = 0.0 # Reset the energy balance at the interval's end
222
+
223
+ def publish_energybalance(self, ts: float) -> None:
224
+ """Publish energy balance information.
225
+
226
+ Args:
227
+ ts (float): current time
228
+ Returns:
229
+ dict: diagnostics information
230
+ """
231
+ m: dict[str, Any] = {
232
+ "Unit": self.name,
233
+ "Mode": self.net_energy_balancing_mode,
234
+ "Rc": self.net_energy_balancing_mode,
235
+ "CurrentBalance": self.net_energy_balance,
236
+ "NeededBalance": self.needed_energy,
237
+ "Timestamp": ts,
238
+ }
239
+ self.publish(self.topic_out_energybalance, json.dumps(m))
@@ -90,7 +90,7 @@ class HeatingOptimizer(Juham):
90
90
  self.topic_powerplan = self.make_topic_name("powerplan")
91
91
  self.topic_in_powerconsumption = self.make_topic_name("powerconsumption")
92
92
  self.topic_in_net_energy_balance = self.make_topic_name("net_energy_balance")
93
- self.topic_in_energybalance = self.make_topic_name("energybalance")
93
+ self.topic_out_energybalance = self.make_topic_name("energybalance_consumers")
94
94
  self.topic_out_power = self.make_topic_name("power") # controls the relay
95
95
 
96
96
  self.current_temperature = 100
@@ -113,6 +113,18 @@ class HeatingOptimizer(Juham):
113
113
  self.subscribe(self.topic_temperature)
114
114
  self.subscribe(self.topic_in_powerconsumption)
115
115
  self.subscribe(self.topic_in_net_energy_balance)
116
+ self.register_as_consumer()
117
+
118
+ def register_as_consumer(self) -> None:
119
+ """Register this device as a consumer to the energy balancer. The energy balancer will then add this device
120
+ to its list of consumers and will tell the device when to heat."""
121
+
122
+ consumer: dict[str, Any] = {
123
+ "Unit": self.name,
124
+ "Power": self.radiator_power,
125
+ }
126
+ self.publish(self.topic_out_energybalance, json.dumps(consumer), 1, False)
127
+ self.info(f"Registered {self.name} as consumer with {self.radiator_power}W", "")
116
128
 
117
129
  def sort_by_rank(
118
130
  self, hours: list[dict[str, Any]], ts_utc_now: float
@@ -309,7 +321,11 @@ class HeatingOptimizer(Juham):
309
321
  Returns:
310
322
  bool: true if production exceeds the consumption
311
323
  """
312
- self.net_energy_balance_mode = m["Mode"]
324
+ if m["Unit"] == self.name:
325
+ self.net_energy_balance_mode = m["Mode"]
326
+ self.info(
327
+ "Net energy balance mode for {self.name} set to {m['Mode']}", str(m)
328
+ )
313
329
 
314
330
  def consider_heating(self, ts: float) -> int:
315
331
  """Consider whether the target boiler needs heating. Check first if the solar
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: juham-automation
3
- Version: 0.0.26
3
+ Version: 0.0.28
4
4
  Summary: Juha's Ultimate Home Automation Masterpiece
5
5
  Author-email: J Meskanen <juham.api@gmail.com>
6
6
  Maintainer-email: "J. Meskanen" <juham.api@gmail.com>
@@ -44,7 +44,7 @@ Classifier: Programming Language :: Python :: 3.8
44
44
  Requires-Python: >=3.8
45
45
  Description-Content-Type: text/markdown
46
46
  License-File: LICENSE.rst
47
- Requires-Dist: juham_core>=0.1.3
47
+ Requires-Dist: juham_core>=0.1.5
48
48
  Provides-Extra: dev
49
49
  Requires-Dist: check-manifest; extra == "dev"
50
50
  Requires-Dist: types-pyz; extra == "dev"
@@ -1,4 +1,4 @@
1
- juham_core>=0.1.3
1
+ juham_core>=0.1.5
2
2
 
3
3
  [dev]
4
4
  check-manifest
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "juham-automation"
8
- version = "0.0.26"
8
+ version = "0.0.28"
9
9
  description = "Juha's Ultimate Home Automation Masterpiece"
10
10
  readme = {file = "README.rst", content-type = "text/markdown"}
11
11
  requires-python = ">=3.8"
@@ -27,7 +27,7 @@ classifiers = [
27
27
  ]
28
28
 
29
29
  dependencies = [
30
- "juham_core >= 0.1.3",
30
+ "juham_core >= 0.1.5",
31
31
  ]
32
32
 
33
33
 
@@ -0,0 +1,107 @@
1
+ import unittest
2
+ from typing import List, Any
3
+ from juham_automation.automation.energybalancer import EnergyBalancer
4
+ from juham_core.timeutils import (
5
+ quantize,
6
+ )
7
+ from masterpiece.mqtt import MqttMsg
8
+
9
+
10
+ class SimpleMqttMsg(MqttMsg):
11
+ def __init__(self, topic: str, payload: Any):
12
+ self._topic = topic
13
+ self._payload = payload
14
+
15
+ @property
16
+ def payload(self) -> Any:
17
+ return self._payload
18
+
19
+ @payload.setter
20
+ def payload(self, value: Any) -> None:
21
+ self._payload = value
22
+
23
+ @property
24
+ def topic(self) -> str:
25
+ return self._topic
26
+
27
+ @topic.setter
28
+ def topic(self, value: str) -> None:
29
+ self._topic = value
30
+
31
+
32
+ class TestEnergyBalancing(unittest.TestCase):
33
+
34
+ balancing_interval: int = EnergyBalancer.energy_balancing_interval
35
+ power: int = 3000 # Power of the radiator (in watts )
36
+
37
+ def setUp(self) -> None:
38
+ """Create balancer with two consumers"""
39
+ self.optimizer = EnergyBalancer("test_optimizer")
40
+ self.optimizer.on_consumer({"Unit": "main", "Power": 2000})
41
+ self.optimizer.on_consumer({"Unit": "sun", "Power": 1000})
42
+
43
+ def test_initial_state(self) -> None:
44
+ self.assertEqual(
45
+ self.optimizer.net_energy_balance, 0, "Initial energy balance should be 0"
46
+ )
47
+ self.assertEqual(
48
+ self.balancing_interval, self.optimizer.energy_balancing_interval
49
+ )
50
+ self.assertFalse(self.optimizer.net_energy_balancing_mode)
51
+
52
+ self.assertEqual(self.optimizer.needed_energy, 0.0)
53
+
54
+ # time within the interval should be zero
55
+ self.assertEqual(-1, self.optimizer.current_interval_ts)
56
+
57
+ def test_set_power(self) -> None:
58
+ """Test setting power consumption and check if the energy balance is updated."""
59
+ step: int = 60
60
+ for ts in range(0, self.balancing_interval, step):
61
+ self.optimizer.update_energy_balance(self.power, ts)
62
+ self.assertEqual(ts * self.power, self.optimizer.net_energy_balance)
63
+ self.assertEqual(ts, self.optimizer.current_interval_ts)
64
+
65
+ self.optimizer.update_energy_balance(self.power, ts + step)
66
+ self.assertEqual(0, self.optimizer.net_energy_balance)
67
+
68
+ def test_consider_net_energy_balance(self) -> None:
69
+ """Test case to simulate passing time and check energy balancing behavior.
70
+ Pass power consumption self.power, which should switch the heating on just
71
+ in the middle of the interval."""
72
+
73
+ balancing_interval: int = self.balancing_interval
74
+
75
+ step: int = balancing_interval // 10
76
+ for ts in range(0, self.balancing_interval * 10, step):
77
+ interval_ts: float = ts % balancing_interval # quantized timestamp
78
+ energy: float = self.power * interval_ts # in watt-seconds
79
+ self.optimizer.update_energy_balance(self.power, ts)
80
+
81
+ # make sure the optimizer is in the right state
82
+ self.assertEqual(energy, self.optimizer.net_energy_balance)
83
+
84
+ # Call the method to check if balancing mode should be activated
85
+ main_on: bool = self.optimizer.consider_net_energy_balance("main", ts)
86
+ sun_on: bool = self.optimizer.consider_net_energy_balance("sun", ts)
87
+
88
+ if energy >= self.optimizer.needed_energy:
89
+ self.assertTrue(main_on or sun_on, "One of the consumers must be ON")
90
+ self.assertFalse(sun_on and main_on, "Only one at a time")
91
+ else:
92
+ self.assertFalse(main_on, "Not enough energy, main should be OFF")
93
+ self.assertFalse(sun_on, "Not enough energy, sun should be OFF")
94
+
95
+ def test_quantization(self) -> None:
96
+ """Test that timestamps are quantized to the interval boundaries correctly."""
97
+ test_times: List[int] = [3601, 7200, 10800] # Slightly over boundaries
98
+ for ts in test_times:
99
+ quantized_ts = quantize(self.optimizer.energy_balancing_interval, ts)
100
+ expected_quantized_ts = (
101
+ ts // self.optimizer.energy_balancing_interval
102
+ ) * self.optimizer.energy_balancing_interval
103
+ self.assertEqual(
104
+ quantized_ts,
105
+ expected_quantized_ts,
106
+ f"Timestamp {ts} should be quantized to {expected_quantized_ts}",
107
+ )
@@ -107,7 +107,7 @@ class TestHeatingOptimizer(unittest.TestCase):
107
107
 
108
108
  def test_consider_net_energy_balance(self) -> None:
109
109
  """Test case to simulate passing time and check energy balancing behavior."""
110
- data: dict[str, Any] = {"Mode": False}
110
+ data: dict[str, Any] = {"Unit": "main", "Mode": False}
111
111
  mock_msg = SimpleMqttMsg(
112
112
  topic=self.optimizer.topic_in_net_energy_balance,
113
113
  payload=json.dumps(data).encode("utf-8"),
@@ -122,11 +122,12 @@ class TestHeatingOptimizer(unittest.TestCase):
122
122
  None,
123
123
  SimpleMqttMsg(
124
124
  topic=self.optimizer.topic_in_net_energy_balance,
125
- payload=json.dumps({"Mode": True}).encode("utf-8"),
125
+ payload=json.dumps({"Unit": "sun", "Mode": True}).encode("utf-8"),
126
126
  ),
127
127
  )
128
- self.assertTrue(
129
- self.optimizer.net_energy_balance_mode, f"At time {0}, heating should be ON"
128
+ self.assertFalse(
129
+ self.optimizer.net_energy_balance_mode,
130
+ f"At time {0}, heating should be OFF",
130
131
  )
131
132
 
132
133
 
@@ -1,158 +0,0 @@
1
- import json
2
- from typing import Any, Dict
3
- from typing_extensions import override
4
-
5
- from juham_core import Juham, timestamp
6
- from juham_core.timeutils import timestampstr, quantize
7
- from masterpiece import MqttMsg
8
-
9
-
10
- class EnergyBalancer(Juham):
11
- """The energy balancer monitors the balance between produced and consumed energy
12
- within the balancing interval to determine if there is enough energy available for
13
- a given energy-consuming device, such as heating radiators, to operate within the
14
- remaining time of the interval.
15
-
16
- Any number of energy-consuming devices can be connected to the energy balancer.
17
- The energy balancer is typically used in conjunction with a power meter that reads
18
- the total power consumption of the house. The energy balancer uses the power meter
19
- """
20
-
21
- #: Description of the attribute
22
- energy_balancing_interval: int = 3600
23
- radiator_power: float = 3000
24
- timezone: str = "Europe/Helsinki"
25
-
26
- def __init__(self, name: str = "energybalancer") -> None:
27
- """Initialize the energy balancer.
28
-
29
- Args:
30
- name (str): name of the heating radiator
31
- power (float): power of the consumer in watts
32
- """
33
- super().__init__(name)
34
-
35
- self.topic_in_powerconsumption = self.make_topic_name("powerconsumption")
36
- self.topic_in_net_energy_balance = self.make_topic_name("net_energy_balance")
37
- self.topic_out_energybalance = self.make_topic_name("energybalance")
38
- self.net_energy_balance: float = 0.0 # Energy balance in joules (watt-seconds)
39
- self.net_energy_balance_ts: float = -1
40
- self.needed_energy: float = self.energy_balancing_interval * self.radiator_power
41
- self.net_energy_balancing_mode: bool = False
42
-
43
- @override
44
- def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
45
- super().on_connect(client, userdata, flags, rc)
46
- if rc == 0:
47
- self.subscribe(self.topic_in_net_energy_balance)
48
-
49
- @override
50
- def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
51
- ts: float = timestamp()
52
-
53
- if msg.topic == self.topic_in_net_energy_balance:
54
- self.on_power(json.loads(msg.payload.decode()), ts)
55
- else:
56
- super().on_message(client, userdata, msg)
57
-
58
- def on_power(self, m: dict[str, Any], ts: float) -> None:
59
- """Handle the power consumption. Read the current power balance and accumulate
60
- to the net energy balance to reflect the energy produced (or consumed) within the
61
- current time slot.
62
- Args:
63
- m (dict[str, Any]): power consumption message
64
- ts (float): current time
65
- """
66
- self.update_energy_balance(m["power"], ts)
67
-
68
- def update_energy_balance(self, power: float, ts: float) -> None:
69
- """Update the current net net energy balance. The change in the balance is calculate the
70
- energy balance, which the time elapsed since the last update, multiplied by the
71
- power. Positive energy balance means we have produced energy that can be consumed
72
- at the end of the interval. The target is to use all the energy produced during the
73
- balancing interval. This method is typically called by the powermeter reading the
74
- total power consumption of the house
75
-
76
- Args:
77
- power (float): power reading from the powermeter. Positive value means
78
- energy produced, negative value means energy consumed. The value of 0 means
79
- the house is not consuming or producing energy.
80
- ts (float): current time in utc seconds
81
- """
82
-
83
- # regardless of the mode, if we hit the end of the interval, reset the balance
84
- quantized_ts: float = ts % self.energy_balancing_interval
85
- if self.net_energy_balance_ts < 0:
86
- self.net_energy_balance_ts = quantized_ts
87
- self.needed_energy = self.energy_balancing_interval - quantized_ts
88
- elif quantized_ts <= self.net_energy_balance_ts:
89
- self.reset_net_energy_balance()
90
- else:
91
- # update the energy balance with the elapsed time and the power
92
- elapsed_ts = quantized_ts - self.net_energy_balance_ts
93
- balance: float = elapsed_ts * power # joules i.e. watt-seconds
94
- self.net_energy_balance = self.net_energy_balance + balance
95
- self.net_energy_balance_ts = quantized_ts
96
- self.needed_energy = (
97
- self.energy_balancing_interval - quantized_ts
98
- ) * self.radiator_power
99
-
100
- if self.net_energy_balancing_mode:
101
- if self.net_energy_balance <= 0:
102
- # if we have used all the energy, disable the balancing mode
103
- self.reset_net_energy_balance()
104
- else:
105
- if self.net_energy_balance >= self.needed_energy:
106
- self.net_energy_balancing_mode = True
107
- self.publish_energybalance(ts)
108
-
109
- def consider_net_energy_balance(self, ts: float) -> bool:
110
- """Check if there is enough energy available for the consumer to heat
111
- the water in the remaining time within the balancing interval, and switch
112
- the balancing mode on if sufficient.
113
-
114
- Args:
115
- ts (float): current time
116
-
117
- Returns:
118
- bool: true if production exceeds the consumption
119
- """
120
- return self.net_energy_balancing_mode
121
-
122
- def reset_net_energy_balance(self) -> None:
123
- """Reset the net energy balance at the end of the interval."""
124
- self.net_energy_balance = 0.0
125
- self.needed_energy = self.energy_balancing_interval * self.radiator_power
126
- self.net_energy_balance_ts = 0
127
- self.net_energy_balancing_mode = False
128
-
129
- def activate_balancing_mode(self, ts: float) -> None:
130
- """Activate balancing mode when enough energy is available."""
131
- self.net_energy_balancing_mode = True
132
- self.info(
133
- f"{int(self.net_energy_balance/3600)} Wh is enough to supply the radiator, enable"
134
- )
135
-
136
- def deactivate_balancing_mode(self) -> None:
137
- """Deactivate balancing mode when energy is depleted or interval ends."""
138
- self.net_energy_balancing_mode = False
139
- self.info("Balance used, or the end of the interval reached, disable")
140
- self.net_energy_balance = 0.0 # Reset the energy balance at the interval's end
141
-
142
- def publish_energybalance(self, ts: float) -> None:
143
- """Publish energy balance information.
144
-
145
- Args:
146
- ts (float): current time
147
- Returns:
148
- dict: diagnostics information
149
- """
150
- m: dict[str, Any] = {
151
- "Unit": self.name,
152
- "Mode": self.net_energy_balancing_mode,
153
- "Rc": self.net_energy_balancing_mode,
154
- "CurrentBalance": self.net_energy_balance,
155
- "NeededBalance": self.needed_energy,
156
- "Timestamp": ts,
157
- }
158
- self.publish(self.topic_out_energybalance, json.dumps(m))
@@ -1,183 +0,0 @@
1
- import unittest
2
- from typing import List, Any
3
- from juham_automation.automation.energybalancer import EnergyBalancer
4
- from juham_core.timeutils import (
5
- quantize,
6
- timestamp,
7
- timestamp_hour,
8
- timestampstr,
9
- is_hour_within_schedule,
10
- timestamp_hour_local,
11
- )
12
- from masterpiece.mqtt import MqttMsg
13
- from juham_automation.automation.heatingoptimizer import HeatingOptimizer
14
-
15
-
16
- class SimpleMqttMsg(MqttMsg):
17
- def __init__(self, topic: str, payload: Any):
18
- self._topic = topic
19
- self._payload = payload
20
-
21
- @property
22
- def payload(self) -> Any:
23
- return self._payload
24
-
25
- @payload.setter
26
- def payload(self, value: Any) -> None:
27
- self._payload = value
28
-
29
- @property
30
- def topic(self) -> str:
31
- return self._topic
32
-
33
- @topic.setter
34
- def topic(self, value: str) -> None:
35
- self._topic = value
36
-
37
-
38
- class TestEnergyBalancing(unittest.TestCase):
39
-
40
- balancing_interval: int = EnergyBalancer.energy_balancing_interval
41
- power: int = 3000 # Power of the radiator (in watts )
42
-
43
- def setUp(self) -> None:
44
- self.optimizer = EnergyBalancer("test_optimizer")
45
- self.optimizer.radiator_power = 3000 # Set the radiator power
46
-
47
- def test_initial_state(self) -> None:
48
- self.assertEqual(
49
- self.optimizer.net_energy_balance, 0, "Initial energy balance should be 0"
50
- )
51
- self.assertEqual(
52
- self.balancing_interval, self.optimizer.energy_balancing_interval
53
- )
54
- self.assertFalse(self.optimizer.net_energy_balancing_mode)
55
-
56
- self.assertEqual(
57
- self.optimizer.needed_energy, self.balancing_interval * self.power
58
- )
59
-
60
- # time within the interval should be zero
61
- self.assertEqual(-1, self.optimizer.net_energy_balance_ts)
62
-
63
- def test_set_power(self) -> None:
64
- """Test setting power consumption and check if the energy balance is updated."""
65
- step: int = 60
66
- for ts in range(0, self.balancing_interval, step):
67
- self.optimizer.update_energy_balance(self.power, ts)
68
- self.assertEqual(ts * self.power, self.optimizer.net_energy_balance)
69
- self.assertEqual(ts, self.optimizer.net_energy_balance_ts)
70
-
71
- self.optimizer.update_energy_balance(self.power, ts + step)
72
- self.assertEqual(0, self.optimizer.net_energy_balance)
73
-
74
- def test_consider_net_energy_balance(self) -> None:
75
- """Test case to simulate passing time and check energy balancing behavior.
76
- Pass power consumption self.power, which should switch the heating on just
77
- in the middle of the interval."""
78
-
79
- balancing_interval: int = self.balancing_interval
80
- energy: float = 0
81
- step: int = balancing_interval // 10
82
- for ts in range(0, self.balancing_interval, step):
83
- energy = self.power * ts # in watt-seconds
84
- self.optimizer.update_energy_balance(self.power, ts)
85
-
86
- # make sure the optimizer is in the right state
87
- self.assertEqual(energy, self.optimizer.net_energy_balance)
88
-
89
- # Call the method to check if balancing mode should be activated
90
- heating_on: bool = self.optimizer.consider_net_energy_balance(ts)
91
-
92
- # Calculate the remaining energy needed to power the radiator for the rest of the time slot
93
- remaining_time: float = self.optimizer.energy_balancing_interval - ts
94
- required_energy: float = self.optimizer.radiator_power * remaining_time
95
-
96
- # Check if heating was enabled or not based on energy balance
97
- if energy >= required_energy and remaining_time > 0:
98
- self.assertTrue(heating_on, f"At time {ts}, heating should be ON")
99
- else:
100
- self.assertFalse(heating_on, f"At time {ts}, heating should be OFF")
101
-
102
- # Ensure heating state is correct
103
- if energy >= required_energy and remaining_time > 0:
104
- self.assertTrue(self.optimizer.net_energy_balancing_mode)
105
- else:
106
- self.assertFalse(self.optimizer.net_energy_balancing_mode)
107
-
108
- # Step 2: Testing energy balancing reset at the end of the interval
109
- # self.optimizer.net_energy_balance = 0 # Reset energy before boundary test
110
- self.optimizer.update_energy_balance(self.power, self.balancing_interval)
111
- heating_on = self.optimizer.consider_net_energy_balance(ts)
112
- self.assertFalse(heating_on, "Heating should be OFF after interval reset")
113
- self.assertEqual(
114
- self.optimizer.net_energy_balance,
115
- 0,
116
- "Energy balance should be reset to 0 at the end of the interval",
117
- )
118
- self.assertFalse(
119
- self.optimizer.net_energy_balancing_mode,
120
- "Balancing mode should be OFF after interval reset",
121
- )
122
-
123
- # Step 3: Testing activation when energy is sufficient
124
- self.optimizer.update_energy_balance(
125
- self.power * 1800, self.balancing_interval // 10
126
- )
127
-
128
- ts = 3600 + 60 # Start of a new interval
129
- heating_on = self.optimizer.consider_net_energy_balance(ts)
130
- self.assertTrue(
131
- heating_on, "Heating should be ON when enough energy is available"
132
- )
133
- self.assertTrue(
134
- self.optimizer.net_energy_balancing_mode,
135
- "Balancing mode should be ON when enough energy is available",
136
- )
137
-
138
- def test_consider_net_energy_balance_start_middle(self) -> None:
139
- """Start from the middle of the interval."""
140
-
141
- balancing_interval: int = self.balancing_interval
142
- energy: float = 0
143
- step: int = balancing_interval // 10
144
- for ts in range(self.balancing_interval // 2, self.balancing_interval, step):
145
-
146
- self.optimizer.update_energy_balance(self.power, ts)
147
-
148
- # make sure the optimizer is in the right state
149
- self.assertEqual(energy, self.optimizer.net_energy_balance)
150
-
151
- # Call the method to check if balancing mode should be activated
152
- heating_on: bool = self.optimizer.consider_net_energy_balance(ts)
153
-
154
- # Calculate the remaining energy needed to power the radiator for the rest of the time slot
155
- remaining_time: float = self.optimizer.energy_balancing_interval - ts
156
- required_energy: float = self.optimizer.radiator_power * remaining_time
157
-
158
- # Check if heating was enabled or not based on energy balance
159
- if energy >= required_energy and remaining_time > 0:
160
- self.assertTrue(heating_on, f"At time {ts}, heating should be ON")
161
- else:
162
- self.assertFalse(heating_on, f"At time {ts}, heating should be OFF")
163
-
164
- # Ensure heating state is correct
165
- if energy >= required_energy and remaining_time > 0:
166
- self.assertTrue(self.optimizer.net_energy_balancing_mode)
167
- else:
168
- self.assertFalse(self.optimizer.net_energy_balancing_mode)
169
- energy = energy + self.power * step # in watt-seconds
170
-
171
- def test_quantization(self) -> None:
172
- """Test that timestamps are quantized to the interval boundaries correctly."""
173
- test_times: List[int] = [3601, 7200, 10800] # Slightly over boundaries
174
- for ts in test_times:
175
- quantized_ts = quantize(self.optimizer.energy_balancing_interval, ts)
176
- expected_quantized_ts = (
177
- ts // self.optimizer.energy_balancing_interval
178
- ) * self.optimizer.energy_balancing_interval
179
- self.assertEqual(
180
- quantized_ts,
181
- expected_quantized_ts,
182
- f"Timestamp {ts} should be quantized to {expected_quantized_ts}",
183
- )