1212
1313from .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)
1819from .coordinator import SatDataUpdateCoordinator
1920from .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
2122from .device import DeviceState , DeviceTracker
2223from .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+ )
2433from .manufacturers .geminox import Geminox
2534from .pwm import PWM , PWMState
2635from .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