Skip to content

Commit 65af0fe

Browse files
committed
Add support for underheat and saturation detection in heating control
1 parent 966a3fe commit 65af0fe

File tree

11 files changed

+320
-50
lines changed

11 files changed

+320
-50
lines changed

custom_components/sat/const.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
DEADBAND = 0.1
1717
BOILER_DEADBAND = 2
1818
HEATER_STARTUP_TIMEFRAME = 180
19+
DHW_OVERSHOOT_GUARD_SECONDS = 300.0
20+
21+
OVERSHOOT_SUSTAIN_SECONDS = 60.0
22+
UNDERHEAT_SUSTAIN_SECONDS = 180.0
23+
SATURATION_SUSTAIN_SECONDS = 300.0
24+
1925
PWM_ENABLE_MARGIN_CELSIUS = 0.5
2026
PWM_DISABLE_MARGIN_CELSIUS = 1.5
2127
PWM_ENABLE_LOW_MODULATION_PERCENT = 10

custom_components/sat/coordinator/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import logging
44
from abc import abstractmethod
5-
from datetime import datetime
65
from typing import Optional, Any
76

87
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN

custom_components/sat/cycles/const.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
# Flow vs. setpoint classification margins
1010
OVERSHOOT_MARGIN_CELSIUS: float = 3.0
1111
UNDERSHOOT_MARGIN_CELSIUS: float = -3.0
12-
OVERSHOOT_SUSTAIN_SECONDS: float = 60.0
1312

1413
# Timeouts
1514
LAST_CYCLE_MAX_AGE_SECONDS: float = 6 * 3600

custom_components/sat/cycles/tracker.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
from homeassistant.core import HomeAssistant
88

99
from .classifier import CycleClassifier
10-
from .const import IN_BAND_MARGIN_CELSIUS, OVERSHOOT_MARGIN_CELSIUS, OVERSHOOT_SUSTAIN_SECONDS
10+
from .const import IN_BAND_MARGIN_CELSIUS, OVERSHOOT_MARGIN_CELSIUS
1111
from .history import CycleHistory
1212
from .types import Cycle, CycleMetrics, CycleShapeMetrics
13-
from ..const import EVENT_SAT_CYCLE_ENDED, EVENT_SAT_CYCLE_STARTED
13+
from ..const import EVENT_SAT_CYCLE_ENDED, EVENT_SAT_CYCLE_STARTED, OVERSHOOT_SUSTAIN_SECONDS
1414
from ..helpers import min_max, percentile_interpolated
1515
from ..types import CycleControlMode, CycleKind, Percentiles
1616

custom_components/sat/device/__init__.py

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ def __init__(self) -> None:
3232
self._last_flame_on_at: Optional[float] = None
3333
self._last_flame_off_at: Optional[float] = None
3434
self._last_flame_off_was_overshoot: bool = False
35+
self._last_hot_water_on_at: Optional[float] = None
36+
self._last_hot_water_off_at: Optional[float] = None
3537

3638
self._modulation_tracker = ModulationReliabilityTracker()
3739

@@ -59,13 +61,44 @@ def modulation_reliable(self) -> Optional[bool]:
5961
return self._modulation_tracker.reliable
6062

6163
@property
62-
def flame_on_since(self) -> Optional[int]:
64+
def status_snapshot(self) -> Optional[DeviceStatusSnapshot]:
65+
"""Expose the latest status snapshot context used by status evaluation."""
66+
if (state := self._current_state) is None:
67+
return None
68+
69+
return DeviceStatusSnapshot(
70+
state=state,
71+
last_cycle=self._last_cycle,
72+
last_update_at=self._last_update_at,
73+
74+
last_flame_on_at=self._last_flame_on_at,
75+
last_flame_off_at=self._last_flame_off_at,
76+
last_flame_off_was_overshoot=self._last_flame_off_was_overshoot,
77+
78+
last_hot_water_on_at=self.hot_water_on_since,
79+
last_hot_water_off_at=self.hot_water_off_since,
80+
81+
previous_state=self._previous_state,
82+
previous_update_at=self._previous_update_at,
83+
modulation_direction=self._determine_modulation_direction(),
84+
)
85+
86+
@property
87+
def flame_on_since(self) -> Optional[float]:
6388
return self._last_flame_on_at
6489

6590
@property
66-
def flame_off_since(self) -> Optional[int]:
91+
def flame_off_since(self) -> Optional[float]:
6792
return self._last_flame_off_at
6893

94+
@property
95+
def hot_water_on_since(self) -> Optional[float]:
96+
return self._last_hot_water_on_at
97+
98+
@property
99+
def hot_water_off_since(self) -> Optional[float]:
100+
return self._last_hot_water_off_at
101+
69102
async def async_added_to_hass(self, hass: HomeAssistant, device_id: str) -> None:
70103
"""Restore device state from storage when the integration loads."""
71104
self._hass = hass
@@ -112,33 +145,19 @@ def update(self, state: DeviceState, last_cycle: Optional["Cycle"], timestamp: f
112145
self._last_flame_off_at = None
113146

114147
self._record_flame_transitions(self._previous_state, state)
148+
self._record_hot_water_transitions(self._previous_state, state)
115149

116150
if self._modulation_tracker.update(state) and self._hass is not None:
117151
self._hass.create_task(self.async_save_data())
118152

119153
self._current_status = self._determine_status()
120154

121155
def _determine_status(self) -> BoilerStatus:
122-
state = self._current_state
123-
previous = self._previous_state
124-
125-
if state is None:
156+
if (snapshot := self.status_snapshot) is None:
126157
# Should not happen in normal usage; treat as inactive.
127158
return BoilerStatus.OFF
128159

129-
return DeviceStatusEvaluator.evaluate(DeviceStatusSnapshot(
130-
state=state,
131-
132-
last_cycle=self._last_cycle,
133-
last_update_at=self._last_update_at,
134-
last_flame_on_at=self._last_flame_on_at,
135-
last_flame_off_at=self._last_flame_off_at,
136-
last_flame_off_was_overshoot=self._last_flame_off_was_overshoot,
137-
138-
previous_state=previous,
139-
previous_update_at=self._previous_update_at,
140-
modulation_direction=self._determine_modulation_direction(),
141-
))
160+
return DeviceStatusEvaluator.evaluate(snapshot)
142161

143162
def _determine_modulation_direction(self) -> int:
144163
"""Determine modulation direction."""
@@ -192,9 +211,25 @@ def _record_flame_transitions(self, previous: Optional[DeviceState], current: De
192211
self._last_flame_on_at = self._last_update_at
193212
self._last_flame_off_was_overshoot = False
194213

214+
def _record_hot_water_transitions(self, previous: Optional[DeviceState], current: DeviceState) -> None:
215+
"""Track domestic hot water ON/OFF timestamps."""
216+
if previous is None:
217+
if current.hot_water_active:
218+
self._last_hot_water_on_at = self._last_update_at
219+
220+
return
221+
222+
if previous.hot_water_active and not current.hot_water_active:
223+
# DHW ON -> OFF
224+
self._last_hot_water_off_at = self._last_update_at
225+
226+
elif not previous.hot_water_active and current.hot_water_active:
227+
# DHW OFF -> ON
228+
self._last_hot_water_on_at = self._last_update_at
229+
195230

196231
__all__ = [
197-
"DeviceTracker",
198232
"DeviceState",
233+
"DeviceTracker",
199234
"DeviceCapabilities",
200235
]

custom_components/sat/device/status.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class DeviceStatusSnapshot:
2121
last_flame_on_at: Optional[float]
2222
last_flame_off_at: Optional[float]
2323
last_flame_off_was_overshoot: bool
24+
last_hot_water_on_at: Optional[float]
25+
last_hot_water_off_at: Optional[float]
2426

2527
modulation_direction: int
2628
previous_update_at: Optional[float]

custom_components/sat/heating_control.py

Lines changed: 107 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,24 @@
1212

1313
from .const import (
1414
COLD_SETPOINT,
15+
DHW_OVERSHOOT_GUARD_SECONDS,
1516
MINIMUM_RELATIVE_MODULATION,
16-
MINIMUM_SETPOINT, EVENT_SAT_CYCLE_ENDED,
17+
MINIMUM_SETPOINT, EVENT_SAT_CYCLE_ENDED, UNDERHEAT_SUSTAIN_SECONDS, SATURATION_SUSTAIN_SECONDS, OVERSHOOT_SUSTAIN_SECONDS,
1718
)
1819
from .coordinator import SatDataUpdateCoordinator
1920
from .cycles import Cycle, CycleHistory, CycleStatistics, CycleTracker
20-
from .cycles.const import OVERSHOOT_MARGIN_CELSIUS, OVERSHOOT_SUSTAIN_SECONDS
21+
from .cycles.const import OVERSHOOT_MARGIN_CELSIUS, UNDERSHOOT_MARGIN_CELSIUS
2122
from .device import DeviceState, DeviceTracker
2223
from .entry_data import SatConfig
23-
from .helpers import event_timestamp, float_value, int_value, timestamp, clamp
24+
from .helpers import (
25+
clamp,
26+
event_timestamp,
27+
float_value,
28+
int_value,
29+
is_within_elapsed_window,
30+
sustained_runtime,
31+
timestamp,
32+
)
2433
from .manufacturers.geminox import Geminox
2534
from .pwm import PWM, PWMState
2635
from .types import BoilerStatus, CycleControlMode, HeaterState, PWMStatus, RelativeModulationState
@@ -84,9 +93,12 @@ def __init__(self, hass: HomeAssistant, coordinator: SatDataUpdateCoordinator, c
8493
self._last_outside_temperature: Optional[float] = None
8594

8695
self._flame_off_hold_setpoint: Optional[float] = None
87-
self._sustained_overshoot_started_at: Optional[float] = None
8896
self._coordinator_listener_remove: Optional[Callable[[], None]] = None
8997

98+
self._sustained_overshoot_started_at: Optional[float] = None
99+
self._sustained_underheat_started_at: Optional[float] = None
100+
self._sustained_saturation_started_at: Optional[float] = None
101+
90102
@property
91103
def device_status(self) -> BoilerStatus:
92104
"""Report the current boiler status."""
@@ -172,8 +184,8 @@ def restore(self, old_state: State) -> None:
172184

173185
def reset(self) -> None:
174186
"""Reset heating control state on major changes."""
175-
self._sustained_overshoot_started_at = None
176187
self._pwm.reset()
188+
self._reset_pwm_disable_guards()
177189

178190
async def update(self, demand: HeatingDemand) -> None:
179191
"""Apply a new demand update and push commands to the coordinator."""
@@ -195,6 +207,11 @@ async def update(self, demand: HeatingDemand) -> None:
195207
requested_setpoint=demand.requested_setpoint,
196208
)
197209

210+
self._maybe_disable_pwm_runtime(
211+
demand=demand,
212+
device_state=self._coordinator.state
213+
)
214+
198215
self._compute_relative_modulation_value()
199216

200217
if self.control_mode == CycleControlMode.PWM:
@@ -204,8 +221,9 @@ async def update(self, demand: HeatingDemand) -> None:
204221
self._compute_continuous_control_setpoint(demand.requested_setpoint)
205222
else:
206223
self._pwm.disable()
224+
self._reset_pwm_disable_guards()
225+
207226
self._control_setpoint = MINIMUM_SETPOINT
208-
self._sustained_overshoot_started_at = None
209227
self._relative_modulation_value = self._config.pwm.maximum_relative_modulation
210228

211229
await self._coordinator.async_set_control_setpoint(self._control_setpoint)
@@ -228,9 +246,9 @@ def _handle_coordinator_update(self, time: Optional[datetime] = None) -> None:
228246
pwm=self._pwm.state,
229247
device_state=self._coordinator.state,
230248
control_setpoint=self._control_setpoint,
231-
relative_modulation=self._relative_modulation_value,
232249
requested_setpoint=self._last_requested_setpoint,
233250
outside_temperature=self._last_outside_temperature,
251+
relative_modulation=self._relative_modulation_value,
234252
)
235253
)
236254

@@ -244,6 +262,10 @@ def _maybe_enable_pwm_on_sustained_overshoot(self, demand: HeatingDemand, device
244262
self._sustained_overshoot_started_at = None
245263
return
246264

265+
if is_within_elapsed_window(demand.timestamp, self._device_tracker.hot_water_off_since, DHW_OVERSHOOT_GUARD_SECONDS):
266+
self._sustained_overshoot_started_at = None
267+
return
268+
247269
if demand.heater_state != HeaterState.ON:
248270
self._sustained_overshoot_started_at = None
249271
return
@@ -261,28 +283,88 @@ def _maybe_enable_pwm_on_sustained_overshoot(self, demand: HeatingDemand, device
261283
self._sustained_overshoot_started_at = None
262284
return
263285

264-
if self._sustained_overshoot_started_at is None:
265-
self._sustained_overshoot_started_at = demand.timestamp
286+
runtime = sustained_runtime(demand.timestamp, self._sustained_overshoot_started_at)
287+
self._sustained_overshoot_started_at = runtime.started_at
288+
289+
if runtime.initialized or runtime.elapsed_seconds < OVERSHOOT_SUSTAIN_SECONDS:
266290
return
267291

268-
elapsed = demand.timestamp - self._sustained_overshoot_started_at
269-
if elapsed < 0:
270-
self._sustained_overshoot_started_at = demand.timestamp
292+
_LOGGER.info(
293+
"Sustained overshoot detected (flow=%.1f°C >= requested=%.1f°C + %.1f°C for %.0fs).",
294+
device_state.flow_temperature, demand.requested_setpoint, OVERSHOOT_MARGIN_CELSIUS, runtime.elapsed_seconds
295+
)
296+
297+
self._pwm.enable()
298+
self._reset_pwm_disable_guards()
299+
300+
def _maybe_disable_pwm_runtime(self, demand: HeatingDemand, device_state: DeviceState) -> None:
301+
if not self._pwm.enabled:
302+
self._sustained_underheat_started_at = None
303+
self._sustained_saturation_started_at = None
271304
return
272305

273-
if elapsed < OVERSHOOT_SUSTAIN_SECONDS:
306+
if device_state.hot_water_active or is_within_elapsed_window(demand.timestamp, self._device_tracker.hot_water_off_since, DHW_OVERSHOOT_GUARD_SECONDS):
307+
self._reset_pwm_disable_guards()
274308
return
275309

310+
if demand.heater_state != HeaterState.ON or demand.requested_setpoint <= COLD_SETPOINT:
311+
self._reset_pwm_disable_guards()
312+
return
313+
314+
if self._maybe_disable_pwm_on_sustained_underheat(demand=demand, device_state=device_state):
315+
return
316+
317+
if self._maybe_disable_pwm_on_sustained_saturation(demand=demand):
318+
return
319+
320+
def _maybe_disable_pwm_on_sustained_underheat(self, demand: HeatingDemand, device_state: DeviceState) -> bool:
321+
flow_temperature = device_state.flow_temperature
322+
if flow_temperature is None:
323+
self._sustained_underheat_started_at = None
324+
return False
325+
326+
underheat_threshold = demand.requested_setpoint + UNDERSHOOT_MARGIN_CELSIUS
327+
if flow_temperature > underheat_threshold:
328+
self._sustained_underheat_started_at = None
329+
return False
330+
331+
runtime = sustained_runtime(demand.timestamp, self._sustained_underheat_started_at)
332+
self._sustained_underheat_started_at = runtime.started_at
333+
334+
if runtime.initialized or runtime.elapsed_seconds < UNDERHEAT_SUSTAIN_SECONDS:
335+
return False
336+
276337
_LOGGER.info(
277-
"Sustained overshoot detected (flow=%.1f°C >= requested=%.1f°C + %.1f°C for %.0fs).",
278-
device_state.flow_temperature,
279-
demand.requested_setpoint,
280-
OVERSHOOT_MARGIN_CELSIUS,
281-
elapsed,
338+
"Disabling PWM due to sustained underheat (flow=%.1f°C <= requested=%.1f°C + %.1f°C for %.0fs).",
339+
flow_temperature, demand.requested_setpoint, UNDERSHOOT_MARGIN_CELSIUS, runtime.elapsed_seconds,
282340
)
283341

284-
self._sustained_overshoot_started_at = None
285-
self._pwm.enable()
342+
self._pwm.disable()
343+
self._reset_pwm_disable_guards()
344+
345+
return True
346+
347+
def _maybe_disable_pwm_on_sustained_saturation(self, demand: HeatingDemand) -> bool:
348+
off_time_seconds = self._pwm.state.off_time_seconds
349+
if off_time_seconds is None or off_time_seconds > 0:
350+
self._sustained_saturation_started_at = None
351+
return False
352+
353+
runtime = sustained_runtime(demand.timestamp, self._sustained_saturation_started_at)
354+
self._sustained_saturation_started_at = runtime.started_at
355+
356+
if runtime.initialized or runtime.elapsed_seconds < SATURATION_SUSTAIN_SECONDS:
357+
return False
358+
359+
_LOGGER.info(
360+
"Disabling PWM due to sustained saturation (off_time=%ds for %.0fs).",
361+
off_time_seconds, runtime.elapsed_seconds
362+
)
363+
364+
self._pwm.disable()
365+
self._reset_pwm_disable_guards()
366+
367+
return True
286368

287369
def _compute_pwm_control_setpoint(self, requested_setpoint: float) -> None:
288370
"""Apply the PWM setpoint override based on the current device state."""
@@ -428,3 +510,8 @@ def _compute_relative_modulation_value(self) -> None:
428510

429511
if isinstance(self._coordinator.manufacturer, Geminox):
430512
self._relative_modulation_value = max(10, self._relative_modulation_value)
513+
514+
def _reset_pwm_disable_guards(self) -> None:
515+
self._sustained_overshoot_started_at = None
516+
self._sustained_underheat_started_at = None
517+
self._sustained_saturation_started_at = None

0 commit comments

Comments
 (0)