Skip to content

Commit 7beaaf5

Browse files
committed
Improve pressure sensor
1 parent 4076758 commit 7beaaf5

File tree

2 files changed

+176
-46
lines changed

2 files changed

+176
-46
lines changed

custom_components/sat/binary_sensor.py

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
from .types import BoilerStatus, CycleClassification
2424

2525
PRESSURE_DROP_RATE_SETTLE_SECONDS = 600
26+
PRESSURE_EMA_ALPHA = 0.05
27+
PRESSURE_PROBLEM_CONFIRMATION_SECONDS = 120
28+
PRESSURE_DROP_RATE_MIN_WINDOW_SECONDS = 300
2629

2730

2831
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
@@ -205,6 +208,8 @@ def __init__(self, coordinator, config: SatConfig, heating_control: SatHeatingCo
205208
self._last_drop_rate: Optional[float] = None
206209
self._last_seen_pressure: Optional[float] = None
207210
self._last_pressure_timestamp: Optional[float] = None
211+
self._smoothed_pressure: Optional[float] = None
212+
self._problem_first_detected: Optional[float] = None
208213
self._pressure_samples: deque[tuple[float, float]] = deque()
209214

210215
async def async_added_to_hass(self) -> None:
@@ -218,6 +223,7 @@ async def async_added_to_hass(self) -> None:
218223
self._last_pressure = float_value(attributes.get("last_pressure"))
219224
self._last_pressure_timestamp = float_value(attributes.get("last_pressure_timestamp"))
220225
self._last_seen_pressure = float_value(attributes.get("last_seen_pressure_timestamp"))
226+
self._smoothed_pressure = float_value(attributes.get("smoothed_pressure"))
221227

222228
if self._last_pressure is not None and self._last_pressure_timestamp is not None:
223229
self._pressure_samples.append((self._last_pressure_timestamp, self._last_pressure))
@@ -247,15 +253,15 @@ def available(self) -> bool:
247253
def is_on(self) -> bool:
248254
"""Return the state of the sensor."""
249255
now = timestamp()
250-
pressure = self._coordinator.boiler_pressure
256+
raw_pressure = self._coordinator.boiler_pressure
251257
minimum_pressure = self._pressure_config.minimum_pressure_bar
252258
maximum_pressure = self._pressure_config.maximum_pressure_bar
253259
maximum_age_seconds = self._pressure_config.maximum_age_seconds
254260
maximum_drop_rate = self._pressure_config.maximum_drop_rate_bar_per_hour
255261

256262
self._track_active_state(now)
257263

258-
if pressure is None:
264+
if raw_pressure is None:
259265
if self._last_seen_pressure is None:
260266
return False
261267

@@ -265,15 +271,16 @@ def is_on(self) -> bool:
265271
return (now - self._last_seen_pressure) > maximum_age_seconds
266272

267273
self._last_seen_pressure = now
268-
self._record_pressure_sample(now, pressure, maximum_age_seconds)
274+
smoothed = self._update_smoothed_pressure(raw_pressure)
275+
self._record_pressure_sample(now, raw_pressure, maximum_age_seconds)
269276

270277
drop_rate = self._calculate_drop_rate()
271278

272-
self._last_pressure = pressure
279+
self._last_pressure = raw_pressure
273280
self._last_pressure_timestamp = now
274281

275-
pressure_low = pressure < minimum_pressure
276-
pressure_high = pressure > maximum_pressure
282+
pressure_low = smoothed < minimum_pressure
283+
pressure_high = smoothed > maximum_pressure
277284
drop_rate_allowed = self._drop_rate_allowed(now)
278285

279286
if not drop_rate_allowed:
@@ -283,14 +290,16 @@ def is_on(self) -> bool:
283290
self._last_drop_rate = round(drop_rate, 3)
284291

285292
drop_rate_high = drop_rate is not None and drop_rate > maximum_drop_rate
293+
raw_problem = pressure_low or pressure_high or drop_rate_high
286294

287-
return pressure_low or pressure_high or drop_rate_high
295+
return self._confirm_problem(now, raw_problem)
288296

289297
@property
290298
def extra_state_attributes(self) -> dict[str, Optional[float]]:
291299
"""Return extra attributes for debugging pressure health decisions."""
292300
return {
293301
"pressure": self._coordinator.boiler_pressure,
302+
"smoothed_pressure": self._smoothed_pressure,
294303
"pressure_drop_rate_bar_per_hour": self._last_drop_rate,
295304

296305
"last_pressure": self._last_pressure,
@@ -303,13 +312,33 @@ def unique_id(self) -> str:
303312
"""Return a unique ID to use for this entity."""
304313
return f"{self._config.entry_id}-pressure-health"
305314

315+
def _update_smoothed_pressure(self, raw_pressure: float) -> float:
316+
if self._smoothed_pressure is None:
317+
self._smoothed_pressure = raw_pressure
318+
else:
319+
self._smoothed_pressure = (
320+
PRESSURE_EMA_ALPHA * raw_pressure
321+
+ (1 - PRESSURE_EMA_ALPHA) * self._smoothed_pressure
322+
)
323+
return self._smoothed_pressure
324+
325+
def _confirm_problem(self, now: float, condition: bool) -> bool:
326+
if not condition:
327+
self._problem_first_detected = None
328+
return False
329+
330+
if self._problem_first_detected is None:
331+
self._problem_first_detected = now
332+
333+
return (now - self._problem_first_detected) >= PRESSURE_PROBLEM_CONFIRMATION_SECONDS
334+
306335
def _track_active_state(self, timestamp_seconds: float) -> None:
307336
active = self._coordinator.active
308337
if self._last_active is None:
309338
self._last_active = active
310339
return
311340

312-
if self._last_active and not active:
341+
if self._last_active != active:
313342
self._drop_rate_suspended_until = timestamp_seconds + PRESSURE_DROP_RATE_SETTLE_SECONDS
314343
self._pressure_samples.clear()
315344

@@ -336,17 +365,27 @@ def _record_pressure_sample(self, timestamp_seconds: float, pressure: float, max
336365
self._pressure_samples.popleft()
337366

338367
def _calculate_drop_rate(self) -> Optional[float]:
339-
if len(self._pressure_samples) < 2:
368+
if len(self._pressure_samples) < 3:
369+
return None
370+
371+
elapsed = self._pressure_samples[-1][0] - self._pressure_samples[0][0]
372+
if elapsed < PRESSURE_DROP_RATE_MIN_WINDOW_SECONDS:
340373
return None
341374

342-
oldest_time, oldest_pressure = self._pressure_samples[0]
343-
newest_time, newest_pressure = self._pressure_samples[-1]
344-
elapsed = newest_time - oldest_time
375+
n = len(self._pressure_samples)
376+
sum_t = sum_p = sum_tp = sum_t2 = 0.0
377+
for t, p in self._pressure_samples:
378+
sum_t += t
379+
sum_p += p
380+
sum_tp += t * p
381+
sum_t2 += t * t
345382

346-
if elapsed <= 0:
383+
denom = n * sum_t2 - sum_t * sum_t
384+
if denom == 0:
347385
return None
348386

349-
return ((oldest_pressure - newest_pressure) / elapsed) * 3600
387+
slope = (n * sum_tp - sum_t * sum_p) / denom
388+
return -slope * 3600
350389

351390

352391
class SatDeviceHealthSensor(SatEntity, BinarySensorEntity):

tests/test_binary_sensor.py

Lines changed: 123 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,33 @@
2121
)
2222

2323

24-
async def test_pressure_health_low_pressure(hass, coordinator, entry, domains, data, options, config):
24+
def _get_pressure_entity_id(hass, entry):
25+
registry = er.async_get(hass)
26+
return registry.async_get_entity_id("binary_sensor", "sat", f"{entry.entry_id}-pressure-health")
27+
28+
29+
async def test_pressure_health_low_pressure(hass, coordinator, entry, domains, data, options, config, monkeypatch):
30+
current_time = 0.0
31+
32+
def fake_timestamp():
33+
return current_time
34+
35+
monkeypatch.setattr(sat_binary_sensor, "timestamp", fake_timestamp)
36+
2537
await coordinator.async_set_boiler_pressure(0.6)
2638
coordinator.async_update_listeners()
2739
await hass.async_block_till_done()
2840

29-
registry = er.async_get(hass)
30-
entity_id = registry.async_get_entity_id("binary_sensor", "sat", f"{entry.entry_id}-pressure-health")
31-
state = hass.states.get(entity_id)
41+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
3242
assert state is not None
43+
assert state.state == "off"
44+
45+
current_time = 130.0
46+
await coordinator.async_set_boiler_pressure(0.6)
47+
coordinator.async_update_listeners()
48+
await hass.async_block_till_done()
49+
50+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
3351
assert state.state == "on"
3452

3553

@@ -38,9 +56,7 @@ async def test_pressure_health_normal_pressure(hass, coordinator, entry, domains
3856
coordinator.async_update_listeners()
3957
await hass.async_block_till_done()
4058

41-
registry = er.async_get(hass)
42-
entity_id = registry.async_get_entity_id("binary_sensor", "sat", f"{entry.entry_id}-pressure-health")
43-
state = hass.states.get(entity_id)
59+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
4460
assert state is not None
4561
assert state.state == "off"
4662

@@ -53,23 +69,14 @@ def fake_timestamp():
5369

5470
monkeypatch.setattr(sat_binary_sensor, "timestamp", fake_timestamp)
5571

56-
await coordinator.async_set_boiler_pressure(1.8)
57-
coordinator.async_update_listeners()
58-
await hass.async_block_till_done()
59-
60-
current_time = 1800.0
61-
await coordinator.async_set_boiler_pressure(1.6)
62-
coordinator.async_update_listeners()
63-
await hass.async_block_till_done()
64-
65-
current_time = 3600.0
66-
await coordinator.async_set_boiler_pressure(1.2)
67-
coordinator.async_update_listeners()
68-
await hass.async_block_till_done()
72+
for i in range(60):
73+
current_time = float(i * 10)
74+
pressure = 1.8 - (i * 0.01)
75+
await coordinator.async_set_boiler_pressure(pressure)
76+
coordinator.async_update_listeners()
77+
await hass.async_block_till_done()
6978

70-
registry = er.async_get(hass)
71-
entity_id = registry.async_get_entity_id("binary_sensor", "sat", f"{entry.entry_id}-pressure-health")
72-
state = hass.states.get(entity_id)
79+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
7380
assert state is not None
7481
assert state.state == "on"
7582
assert state.attributes["pressure_drop_rate_bar_per_hour"] is not None
@@ -94,9 +101,7 @@ def fake_timestamp():
94101
coordinator.async_update_listeners()
95102
await hass.async_block_till_done()
96103

97-
registry = er.async_get(hass)
98-
entity_id = registry.async_get_entity_id("binary_sensor", "sat", f"{entry.entry_id}-pressure-health")
99-
state = hass.states.get(entity_id)
104+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
100105
assert state is not None
101106
assert state.state == "off"
102107
assert state.attributes["pressure_drop_rate_bar_per_hour"] is None
@@ -119,9 +124,7 @@ def fake_timestamp():
119124
coordinator.async_update_listeners()
120125
await hass.async_block_till_done()
121126

122-
registry = er.async_get(hass)
123-
entity_id = registry.async_get_entity_id("binary_sensor", "sat", f"{entry.entry_id}-pressure-health")
124-
state = hass.states.get(entity_id)
127+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
125128
assert state is not None
126129
assert state.state == "on"
127130

@@ -141,6 +144,7 @@ def fake_timestamp():
141144
"last_pressure": 1.4,
142145
"last_pressure_timestamp": 100.0,
143146
"last_seen_pressure_timestamp": 100.0,
147+
"smoothed_pressure": 1.4,
144148
}
145149

146150
async def fake_async_get_last_state(self):
@@ -155,8 +159,95 @@ async def fake_async_get_last_state(self):
155159
coordinator.async_update_listeners()
156160
await hass.async_block_till_done()
157161

158-
registry = er.async_get(hass)
159-
entity_id = registry.async_get_entity_id("binary_sensor", "sat", f"{entry.entry_id}-pressure-health")
160-
state = hass.states.get(entity_id)
162+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
161163
assert state is not None
162164
assert state.attributes["last_pressure"] == 1.4
165+
assert state.attributes["smoothed_pressure"] is not None
166+
167+
168+
async def test_pressure_health_normal_oscillations_no_false_positive(hass, coordinator, entry, domains, data, options, config, monkeypatch):
169+
current_time = 0.0
170+
171+
def fake_timestamp():
172+
return current_time
173+
174+
monkeypatch.setattr(sat_binary_sensor, "timestamp", fake_timestamp)
175+
176+
for i in range(60):
177+
current_time = float(i * 30)
178+
pressure = 2.1 + (0.2 if i % 2 == 0 else -0.2)
179+
await coordinator.async_set_boiler_pressure(pressure)
180+
coordinator.async_update_listeners()
181+
await hass.async_block_till_done()
182+
183+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
184+
assert state.state == "off", f"False positive at iteration {i}, pressure={pressure}"
185+
186+
187+
async def test_pressure_health_confirmation_delay_resets(hass, coordinator, entry, domains, data, options, config, monkeypatch):
188+
current_time = 0.0
189+
190+
def fake_timestamp():
191+
return current_time
192+
193+
monkeypatch.setattr(sat_binary_sensor, "timestamp", fake_timestamp)
194+
195+
await coordinator.async_set_boiler_pressure(0.6)
196+
coordinator.async_update_listeners()
197+
await hass.async_block_till_done()
198+
199+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
200+
assert state.state == "off"
201+
202+
current_time = 100.0
203+
await coordinator.async_set_boiler_pressure(0.6)
204+
coordinator.async_update_listeners()
205+
await hass.async_block_till_done()
206+
207+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
208+
assert state.state == "off"
209+
210+
current_time = 110.0
211+
await coordinator.async_set_boiler_pressure(1.5)
212+
coordinator.async_update_listeners()
213+
await hass.async_block_till_done()
214+
215+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
216+
assert state.state == "off"
217+
218+
current_time = 120.0
219+
await coordinator.async_set_boiler_pressure(0.6)
220+
coordinator.async_update_listeners()
221+
await hass.async_block_till_done()
222+
223+
current_time = 250.0
224+
await coordinator.async_set_boiler_pressure(0.6)
225+
coordinator.async_update_listeners()
226+
await hass.async_block_till_done()
227+
228+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
229+
assert state.state == "on"
230+
231+
232+
async def test_pressure_health_high_pressure_with_delay(hass, coordinator, entry, domains, data, options, config, monkeypatch):
233+
current_time = 0.0
234+
235+
def fake_timestamp():
236+
return current_time
237+
238+
monkeypatch.setattr(sat_binary_sensor, "timestamp", fake_timestamp)
239+
240+
await coordinator.async_set_boiler_pressure(3.0)
241+
coordinator.async_update_listeners()
242+
await hass.async_block_till_done()
243+
244+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
245+
assert state.state == "off"
246+
247+
current_time = 130.0
248+
await coordinator.async_set_boiler_pressure(3.0)
249+
coordinator.async_update_listeners()
250+
await hass.async_block_till_done()
251+
252+
state = hass.states.get(_get_pressure_entity_id(hass, entry))
253+
assert state.state == "on"

0 commit comments

Comments
 (0)