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.
Files changed (27) hide show
  1. juham_automation/__init__.py +8 -6
  2. juham_automation/automation/__init__.py +6 -6
  3. juham_automation/automation/energybalancer.py +281 -0
  4. juham_automation/automation/energycostcalculator.py +112 -68
  5. juham_automation/automation/heatingoptimizer.py +971 -0
  6. juham_automation/automation/leakdetector.py +162 -0
  7. juham_automation/automation/watercirculator.py +1 -20
  8. juham_automation/japp.py +4 -2
  9. juham_automation/ts/__init__.py +3 -1
  10. juham_automation/ts/electricityprice_ts.py +1 -1
  11. juham_automation/ts/energybalancer_ts.py +73 -0
  12. juham_automation/ts/energycostcalculator_ts.py +3 -1
  13. juham_automation/ts/log_ts.py +19 -16
  14. juham_automation/ts/power_ts.py +17 -14
  15. juham_automation/ts/powermeter_ts.py +3 -5
  16. juham_automation/ts/powerplan_ts.py +37 -18
  17. juham_automation-0.2.11.dist-info/METADATA +198 -0
  18. juham_automation-0.2.11.dist-info/RECORD +24 -0
  19. {juham_automation-0.0.12.dist-info → juham_automation-0.2.11.dist-info}/WHEEL +1 -1
  20. {juham_automation-0.0.12.dist-info → juham_automation-0.2.11.dist-info}/entry_points.txt +3 -2
  21. juham_automation/automation/hotwateroptimizer.py +0 -567
  22. juham_automation/automation/powermeter_simulator.py +0 -139
  23. juham_automation/automation/spothintafi.py +0 -140
  24. juham_automation-0.0.12.dist-info/METADATA +0 -109
  25. juham_automation-0.0.12.dist-info/RECORD +0 -23
  26. {juham_automation-0.0.12.dist-info → juham_automation-0.2.11.dist-info/licenses}/LICENSE.rst +0 -0
  27. {juham_automation-0.0.12.dist-info → juham_automation-0.2.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,162 @@
1
+ """
2
+ Leak Detector based on Motion Detection and Water Meter Monitoring.
3
+
4
+ This class listens to water meter and motion sensor topics to detect potential water leaks.
5
+ If a leak is detected, the system triggers a leak alarm.
6
+ """
7
+ import json
8
+ from datetime import datetime
9
+ from typing import Any
10
+ from typing_extensions import override
11
+ from masterpiece.mqtt import MqttMsg
12
+ from juham_core.timeutils import timestamp
13
+ from juham_core import Juham
14
+
15
+
16
+ class LeakDetector(Juham):
17
+ """
18
+ Water Leak Detector Class
19
+
20
+ Listens to water meter and motion sensor topics to identify potential water leaks.
21
+ If water consumption is detected without corresponding motion, or if water usage
22
+ remains constant for prolonged periods, a leak alarm is triggered.
23
+
24
+ Detection considers the time since the last motion detection and compares it to
25
+ the configured leak detection period, which is the maximum runtime of water-consuming
26
+ appliances.
27
+ """
28
+
29
+ _LEAKDETECTOR: str = "leakdetector"
30
+ _LEAKDETECTOR_ATTRS: list[str] = [
31
+ "watermeter_topic",
32
+ "motion_topic",
33
+ "motion_last_detected_ts",
34
+ ]
35
+
36
+ watermeter_topic: str = "watermeter"
37
+ motion_topic: str = "motion"
38
+ leak_detection_period: float = (
39
+ 3 * 3600.0
40
+ ) # Maximum runtime for appliances, in seconds
41
+ location: str = "home"
42
+ conseq_zero_periods: int = (
43
+ 60 # this many subsequent zero flow reports imply no leak
44
+ )
45
+
46
+ def __init__(self, name: str = "leakdetector") -> None:
47
+ """
48
+ Initialize the leak detector.
49
+
50
+ Args:
51
+ name (str, optional): Name of the detector instance. Defaults to "leakdetector".
52
+ """
53
+ super().__init__(name)
54
+ self.motion_last_detected_ts: float = timestamp()
55
+ self.watermeter_full_topic: str = self.make_topic_name(self.watermeter_topic)
56
+ self.motion_full_topic: str = self.make_topic_name(self.motion_topic)
57
+ self.leak_detected: bool = False
58
+ self.zero_usage_periods_count: int = 0
59
+
60
+ @override
61
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
62
+ """
63
+ Handle MQTT connection. Subscribe to water meter and motion topics.
64
+ """
65
+ super().on_connect(client, userdata, flags, rc)
66
+ if rc == 0:
67
+ self.subscribe(self.watermeter_full_topic)
68
+ self.subscribe(self.motion_full_topic)
69
+
70
+ @override
71
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
72
+ """
73
+ Process incoming MQTT messages for water meter and motion topics.
74
+ """
75
+ payload = json.loads(msg.payload.decode())
76
+ if msg.topic == self.watermeter_full_topic:
77
+ self.process_water_meter_data(payload)
78
+ elif msg.topic == self.motion_full_topic:
79
+ self.process_motion_data(payload)
80
+ else:
81
+ super().on_message(client, userdata, msg)
82
+
83
+ def detect_activity(self, current_ts: float) -> bool:
84
+ """
85
+ Check if activity (motion) has been detected within the leak detection period.
86
+
87
+ Args:
88
+ current_ts (float): Current timestamp.
89
+
90
+ Returns:
91
+ bool: True if activity detected within the period, False otherwise.
92
+ """
93
+ elapsed_time = current_ts - self.motion_last_detected_ts
94
+ return elapsed_time < self.leak_detection_period
95
+
96
+ def publish_leak_status(self, current_ts: float, leak_suspected: bool) -> None:
97
+ """
98
+ Publish the leak detection status.
99
+
100
+ Args:
101
+ current_ts (float): Current timestamp.
102
+ leak_suspected (bool): Whether a leak is suspected.
103
+ """
104
+ status : dict[str, Any] = {
105
+ "location": self.location,
106
+ "sensor": self.name,
107
+ "leak_suspected": leak_suspected,
108
+ "ts": current_ts,
109
+ }
110
+ payload = json.dumps(status)
111
+ self.publish(self.watermeter_full_topic, payload, qos=1, retain=False)
112
+
113
+ def process_water_meter_data(self, data: dict[str, float]) -> None:
114
+ """
115
+ Handle water meter data and apply leak detection logic.
116
+
117
+ Args:
118
+ data (dict): Water meter data containing flow rate and timestamp.
119
+ """
120
+ if "active_lpm" in data and "ts" in data:
121
+ flow_rate = data["active_lpm"]
122
+ current_ts = data["ts"]
123
+
124
+ if flow_rate > 0.0:
125
+ if not self.detect_activity(current_ts):
126
+ if not self.leak_detected:
127
+ self.leak_detected = True
128
+ readable :str = datetime.fromtimestamp(self.motion_last_detected_ts).strftime("%Y-%m-%d %H:%M:%S")
129
+ self.warning(f"LEAK SUSPECT", f"Flow {flow_rate} lpm, last detected motion {readable}")
130
+
131
+ self.zero_usage_periods_count = 0
132
+ else:
133
+ self.zero_usage_periods_count += 1
134
+ if self.zero_usage_periods_count > self.conseq_zero_periods:
135
+ self.leak_detected = False
136
+ self.motion_last_detected_ts = current_ts
137
+ self.publish_leak_status(current_ts, self.leak_detected)
138
+
139
+ def process_motion_data(self, data: dict[str, float]) -> None:
140
+ """
141
+ Update the last detected motion timestamp.
142
+
143
+ Args:
144
+ data (dict): Motion sensor data containing timestamp.
145
+ """
146
+ if "motion" in data and data["motion"]:
147
+ self.motion_last_detected_ts = data["ts"]
148
+
149
+ @override
150
+ def to_dict(self) -> dict[str, Any]:
151
+ data = super().to_dict()
152
+ attributes = {attr: getattr(self, attr) for attr in self._LEAKDETECTOR_ATTRS}
153
+ data[self._LEAKDETECTOR] = attributes
154
+ return data
155
+
156
+ @override
157
+ def from_dict(self, data: dict[str, Any]) -> None:
158
+ super().from_dict(data)
159
+ if self._LEAKDETECTOR in data:
160
+ attributes = data[self._LEAKDETECTOR]
161
+ for attr in self._LEAKDETECTOR_ATTRS:
162
+ setattr(self, attr, attributes.get(attr, None))
@@ -33,7 +33,7 @@ class WaterCirculator(Juham):
33
33
 
34
34
  """
35
35
 
36
- uptime = 60 * 60 # one hour
36
+ uptime = 1800 # half an hour
37
37
  min_temperature = 37
38
38
 
39
39
  def __init__(self, name: str, temperature_sensor: str) -> None:
@@ -84,9 +84,6 @@ class WaterCirculator(Juham):
84
84
 
85
85
  self.water_temperature = m["temperature"]
86
86
  self.water_temperature_updated = ts_utc_now
87
- # self.info(
88
- # f"Temperature of circulating water updated to {self.water_temperature} C"
89
- # )
90
87
 
91
88
  def on_motion_sensor(self, m: dict[str, dict[str, Any]], ts_utc_now: float) -> None:
92
89
  """Control the water cirulator bump.
@@ -103,14 +100,9 @@ class WaterCirculator(Juham):
103
100
  motion: bool = bool(m["motion"])
104
101
 
105
102
  if motion or vibration:
106
- # self.debug(f"Life form detected in {sensor}")
107
- # honey I'm home
108
103
  if not self.current_motion:
109
104
  if self.water_temperature > self.min_temperature:
110
105
  self.publish_relay_state(0)
111
- # self.debug(
112
- # f"Circulator: motion detected but water warm already {self.water_temperature} > {self.min_temperature} C"
113
- # )
114
106
  else:
115
107
  self.current_motion = True
116
108
  self.relay_started_ts = ts_utc_now
@@ -122,10 +114,6 @@ class WaterCirculator(Juham):
122
114
  else:
123
115
  self.publish_relay_state(1)
124
116
  self.relay_started_ts = ts_utc_now
125
- # self.debug(
126
- # f"Circulator pump has been running for {int(ts_utc_now - self.relay_started_ts)/60} minutes",
127
- # " ",
128
- # )
129
117
  else:
130
118
  if self.current_motion or not self.initialized:
131
119
  elapsed: float = ts_utc_now - self.relay_started_ts
@@ -139,15 +127,8 @@ class WaterCirculator(Juham):
139
127
  self.initialized = True
140
128
  else:
141
129
  self.publish_relay_state(1)
142
- # self.debug(
143
- # f"Circulator bump stop countdown {int(self.uptime - (ts_utc_now - self.relay_started_ts ))/60} min"
144
- # )
145
130
  else:
146
131
  self.publish_relay_state(0)
147
- # self.debug(
148
- # f"Circulator bump off already, temperature {self.water_temperature} C",
149
- # "",
150
- # )
151
132
 
152
133
  def publish_relay_state(self, state: int) -> None:
153
134
  """Publish power status.
juham_automation/japp.py CHANGED
@@ -1,3 +1,4 @@
1
+ from juham_automation.automation.energybalancer import EnergyBalancer
1
2
  from masterpiece import Application
2
3
  from juham_core import Juham
3
4
 
@@ -5,10 +6,10 @@ from .ts import ForecastTs
5
6
  from .ts import PowerTs
6
7
  from .ts import PowerPlanTs
7
8
  from .ts import PowerMeterTs
9
+ from .ts import EnergyBalancerTs
8
10
  from .ts import LogTs
9
11
  from .ts import EnergyCostCalculatorTs
10
12
  from .ts import ElectricityPriceTs
11
- from .automation import SpotHintaFi
12
13
  from .automation import EnergyCostCalculator
13
14
 
14
15
 
@@ -38,10 +39,11 @@ class JApp(Application):
38
39
  self.add(PowerPlanTs())
39
40
  self.add(PowerMeterTs())
40
41
  self.add(LogTs())
41
- self.add(SpotHintaFi())
42
42
  self.add(EnergyCostCalculator())
43
43
  self.add(EnergyCostCalculatorTs())
44
44
  self.add(ElectricityPriceTs())
45
+ self.add(EnergyBalancer())
46
+ self.add(EnergyBalancerTs())
45
47
 
46
48
  @classmethod
47
49
  def register(cls) -> None:
@@ -2,7 +2,7 @@
2
2
  Description
3
3
  ===========
4
4
 
5
- Juham - Juha's Ultimate Home Automation Timeseries
5
+ Time series recorders for Juha's Ultimate Home Automation classes.
6
6
 
7
7
  """
8
8
 
@@ -13,6 +13,7 @@ from .powerplan_ts import PowerPlanTs
13
13
  from .powermeter_ts import PowerMeterTs
14
14
  from .electricityprice_ts import ElectricityPriceTs
15
15
  from .forecast_ts import ForecastTs
16
+ from .energybalancer_ts import EnergyBalancerTs
16
17
 
17
18
  __all__ = [
18
19
  "EnergyCostCalculatorTs",
@@ -22,4 +23,5 @@ __all__ = [
22
23
  "PowerPlanTs",
23
24
  "PowerMeterTs",
24
25
  "ElectricityPriceTs",
26
+ "EnergyBalancerTs",
25
27
  ]
@@ -32,7 +32,7 @@ class ElectricityPriceTs(JuhamTs):
32
32
  super().on_message(client, userdata, msg)
33
33
 
34
34
  def on_spot(self, m: dict[Any, Any]) -> None:
35
- """Write hourly spot electricity prices to time series database.
35
+ """Write spot electricity prices to time series database.
36
36
 
37
37
  Args:
38
38
  m (dict): holding hourlys spot electricity prices
@@ -0,0 +1,73 @@
1
+ import json
2
+ from typing import Any
3
+ from typing_extensions import override
4
+
5
+ from masterpiece.mqtt import MqttMsg
6
+
7
+ from juham_core import JuhamTs
8
+ from juham_core.timeutils import epoc2utc
9
+
10
+
11
+ class EnergyBalancerTs(JuhamTs):
12
+ """Record energy balance data to time series database.
13
+
14
+ This class listens the "energybalance" MQTT topic and records the
15
+ messages to time series database.
16
+ """
17
+
18
+ def __init__(self, name: str = "energybalancer_ts") -> None:
19
+ """Construct record object with the given name."""
20
+
21
+ super().__init__(name)
22
+ self.topic_in_status = self.make_topic_name("energybalance/status")
23
+ self.topic_in_diagnostics = self.make_topic_name("energybalance/diagnostics")
24
+
25
+ @override
26
+ def on_connect(self, client: object, userdata: Any, flags: int, rc: int) -> None:
27
+ super().on_connect(client, userdata, flags, rc)
28
+ if rc == 0:
29
+ self.subscribe(self.topic_in_status)
30
+ self.subscribe(self.topic_in_diagnostics)
31
+
32
+ @override
33
+ def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
34
+ if msg.topic == self.topic_in_status:
35
+ self.on_status(json.loads(msg.payload.decode()))
36
+ elif msg.topic == self.topic_in_diagnostics:
37
+ self.on_diagnostics(json.loads(msg.payload.decode()))
38
+ else:
39
+ super().on_message(client, userdata, msg)
40
+
41
+ def on_status(self, m: dict[str, Any]) -> None:
42
+ """Handle energybalance message.
43
+ Args:
44
+ m (dict[str, Any]): Message from energybalance topic.
45
+ """
46
+ if not "Power" in m or not "Timestamp" in m:
47
+ self.error(f"INVALID STATUS msg {m}")
48
+ return
49
+ point = (
50
+ self.measurement("energybalance")
51
+ .tag("Unit", m["Unit"])
52
+ .field("Mode", m["Mode"])
53
+ .field("Power", float(m["Power"]))
54
+ .time(epoc2utc(m["Timestamp"]))
55
+ )
56
+ self.write(point)
57
+
58
+ def on_diagnostics(self, m: dict[str, Any]) -> None:
59
+ """Handle energybalance diagnostics.
60
+ Args:
61
+ m (dict[str, Any]): Message from energybalance topic.
62
+ """
63
+ if not "Timestamp" in m:
64
+ self.error(f"INVALID DIAGNOSTICS msg {m}")
65
+ return
66
+ point = (
67
+ self.measurement("energybalance")
68
+ .tag("EnergyBalancer", m["EnergyBalancer"])
69
+ .field("CurrentBalance", m["CurrentBalance"])
70
+ .field("NeededBalance", m["NeededBalance"])
71
+ .time(epoc2utc(m["Timestamp"]))
72
+ )
73
+ self.write(point)
@@ -26,10 +26,12 @@ class EnergyCostCalculatorTs(JuhamTs):
26
26
 
27
27
  @override
28
28
  def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
29
- super().on_message(client, userdata, msg)
30
29
  if msg.topic == self.topic_net_energy_balance:
31
30
  m = json.loads(msg.payload.decode())
32
31
  self.record_powerconsumption(m)
32
+ else:
33
+ super().on_message(client, userdata, msg)
34
+
33
35
 
34
36
  def record_powerconsumption(self, m: dict[str, Any]) -> None:
35
37
  """Record powerconsumption
@@ -39,19 +39,22 @@ class LogTs(JuhamTs):
39
39
 
40
40
  @override
41
41
  def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
42
- m = json.loads(msg.payload.decode())
43
- ts = epoc2utc(m["Timestamp"])
44
-
45
- point = (
46
- self.measurement("log")
47
- .tag("class", m["Class"])
48
- .field("source", m["Source"])
49
- .field("msg", m["Msg"])
50
- .field("details", m["Details"])
51
- .field("Timestamp", m["Timestamp"])
52
- .time(ts)
53
- )
54
- try:
55
- self.write(point)
56
- except Exception as e:
57
- self.log_message("Error", f"Cannot write log event {m['Msg']}", str(e))
42
+ if msg.topic == self.topic_name:
43
+ m = json.loads(msg.payload.decode())
44
+ ts = epoc2utc(m["Timestamp"])
45
+
46
+ point = (
47
+ self.measurement("log")
48
+ .tag("class", m["Class"])
49
+ .field("source", m["Source"])
50
+ .field("msg", m["Msg"])
51
+ .field("details", m["Details"])
52
+ .field("Timestamp", m["Timestamp"])
53
+ .time(ts)
54
+ )
55
+ try:
56
+ self.write(point)
57
+ except Exception as e:
58
+ print(f"ERROR: Cannot write log event {m['Msg']} {e}")
59
+ else:
60
+ super().on_message(client, userdata, msg)
@@ -33,17 +33,20 @@ class PowerTs(JuhamTs):
33
33
 
34
34
  This method is called upon new arrived message.
35
35
  """
36
-
37
- m = json.loads(msg.payload.decode())
38
- if not "Unit" in m:
39
- return
40
- unit = m["Unit"]
41
- ts = m["Timestamp"]
42
- state = m["State"]
43
- point = (
44
- self.measurement("power")
45
- .tag("unit", unit)
46
- .field("state", state)
47
- .time(epoc2utc(ts))
48
- )
49
- self.write(point)
36
+ if msg.topic == self.topic_name:
37
+ m = json.loads(msg.payload.decode())
38
+ if not "Unit" in m:
39
+ return
40
+ unit = m["Unit"]
41
+ ts = m["Timestamp"]
42
+ state = m["State"]
43
+ point = (
44
+ self.measurement("power")
45
+ .tag("unit", unit)
46
+ .field("state", state)
47
+ .time(epoc2utc(ts))
48
+ )
49
+ self.write(point)
50
+ else:
51
+ super().on_message(client, userdata, msg)
52
+
@@ -26,11 +26,12 @@ class PowerMeterTs(JuhamTs):
26
26
 
27
27
  @override
28
28
  def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
29
- super().on_message(client, userdata, msg)
30
29
  if msg.topic == self.power_topic:
31
30
  m = json.loads(msg.payload.decode())
32
31
  self.record_power(m)
33
-
32
+ else:
33
+ super().on_message(client, userdata, msg)
34
+
34
35
  def record_power(self, em: dict[str, Any]) -> None:
35
36
  """Write from the power (energy) meter to the time
36
37
  series database accordingly.
@@ -50,9 +51,6 @@ class PowerMeterTs(JuhamTs):
50
51
  )
51
52
  try:
52
53
  self.write(point)
53
- # self.debug(
54
- # f"PowerMeter event recorded {epoc2utc(em['timestamp'])} - {em['real_a']} {em['real_b']} {em['real_c']}"
55
- # )
56
54
  except Exception as e:
57
55
  self.error(f"Writing to influx failed {str(e)}")
58
56
 
@@ -25,21 +25,40 @@ class PowerPlanTs(JuhamTs):
25
25
 
26
26
  @override
27
27
  def on_message(self, client: object, userdata: Any, msg: MqttMsg) -> None:
28
- super().on_message(client, userdata, msg)
29
- m = json.loads(msg.payload.decode())
30
- schedule = m["Schedule"]
31
- uoi = m["UOI"]
32
- ts = m["Timestamp"]
33
-
34
- point = (
35
- self.measurement("powerplan")
36
- .tag("unit", m["Unit"])
37
- .field("state", m["State"]) # 1 on, 0 off
38
- .field("name", m["Unit"]) # e.g main_boiler
39
- .field("type", "C") # C=consumption, S = supply
40
- .field("power", 16.0) # kW
41
- .field("Schedule", schedule) # figures of merit
42
- .field("UOI", float(uoi)) # Utilitzation Optimizing Index
43
- .time(epoc2utc(ts))
44
- )
45
- self.write(point)
28
+ if msg.topic == self.powerplan_topic:
29
+ m = json.loads(msg.payload.decode())
30
+ schedule = m["Schedule"]
31
+ uoi = m["UOI"]
32
+ ts = m["Timestamp"]
33
+
34
+ # optional fields
35
+ tempForecast = m.get("NextDayTemperature")
36
+ solarForecast = m.get("NextDaySolarpower")
37
+ minTemp = m.get("MinTempLimit")
38
+ maxTemp = m.get("MaxTempLimit")
39
+
40
+ point = (
41
+ self.measurement("powerplan")
42
+ .tag("unit", m["Unit"])
43
+ .field("state", m["State"]) # 1 on, 0 off
44
+ .field("name", m["Unit"]) # e.g main_boiler
45
+ .field("type", "C") # C=consumption, S = supply
46
+ .field("power", 16.0) # kW
47
+ .field("Schedule", schedule) # figures of merit
48
+ .field("UOI", float(uoi)) # Utilitzation Optimizing Index
49
+ .time(epoc2utc(ts))
50
+ )
51
+
52
+ # Add optional fields only if they are present in the message
53
+ if minTemp is not None:
54
+ point = point.field("MinTemp", float(minTemp))
55
+ if maxTemp is not None:
56
+ point = point.field("MaxTemp", float(maxTemp))
57
+ if tempForecast is not None:
58
+ point = point.field("TempForecast", float(tempForecast))
59
+ if solarForecast is not None:
60
+ point = point.field("SolarForecast", float(solarForecast))
61
+
62
+ self.write(point)
63
+ else:
64
+ super().on_message(client, userdata, msg)