3535 DHW_MIN_AMOUNT_DEFAULT ,
3636 DHW_READY_THRESHOLD ,
3737 DHW_SAFETY_MIN ,
38+ DHW_WEATHER_COOLDOWN_MINUTES ,
3839 DM_THRESHOLD_START ,
3940 DOMAIN ,
4041 MIN_DHW_TARGET_TEMP ,
6263from .optimization .weather_learning import WeatherPatternLearner
6364from .utils .compressor_monitor import CompressorHealthMonitor
6465from .utils .time_utils import get_current_quarter
66+ from .utils .volatile_helpers import OffsetVolatilityTracker
6567
6668_LOGGER = logging .getLogger (__name__ )
6769
@@ -267,18 +269,22 @@ def __init__(
267269 minutes = UPDATE_INTERVAL_MINUTES
268270 ) # Throttle to coordinator update interval
269271
272+ # Offset volatility tracking (prevents rapid back-and-forth offset changes)
273+ # Uses same min duration as price volatility (45 min) for consistency
274+ self ._offset_volatility_tracker = OffsetVolatilityTracker ()
275+
270276 # Peak tracking metadata (for sensor attributes)
271277 self .peak_today_time : datetime | None = None # When today's peak occurred
272278 self .peak_today_source : str = "unknown" # external_meter, nibe_currents, estimate
273279 self .peak_today_quarter : int | None = None # 15-min quarter (0-95) for effect tariff
274280 self .yesterday_peak : float = 0.0 # Yesterday's peak for comparison
275281
276- # DHW tracking
282+ # DHW tracking (unified: is_hot_water OR temp_lux active)
277283 self .last_dhw_heated = None # Last time DHW was in heating mode
278284 self .last_dhw_temp = None # Last BT7 temperature for trend analysis
279285 self .dhw_heating_start = None # When current/last DHW cycle started
280286 self .dhw_heating_end = None # When last DHW cycle ended
281- self .dhw_was_heating = False # Track state changes
287+ self .dhw_was_active = False # Track DHW state (is_hot_water OR temp_lux)
282288
283289 # Spot price savings tracking (per-cycle accumulation)
284290 self ._daily_spot_savings : float = 0.0 # Accumulates during day, recorded at midnight
@@ -497,14 +503,8 @@ async def async_initialize_learning(self) -> None:
497503 # Restore DHW optimizer state (critical for Legionella safety tracking)
498504 if "dhw_state" in learned_data and self .dhw_optimizer :
499505 dhw_state = learned_data ["dhw_state" ]
500- if "last_legionella_boost" in dhw_state :
501- self .dhw_optimizer .last_legionella_boost = datetime .fromisoformat (
502- dhw_state ["last_legionella_boost" ]
503- )
504- _LOGGER .info (
505- "Restored last Legionella boost: %s" ,
506- self .dhw_optimizer .last_legionella_boost ,
507- )
506+ # Use the new restore method for all DHW state
507+ self .dhw_optimizer .restore_from_persistence (dhw_state )
508508
509509 # Initialize DHW history from Home Assistant recorder (resilience to restarts)
510510 # This checks past 14 days of BT7 data to detect recent Legionella cycles
@@ -825,21 +825,45 @@ async def _async_update_data(self) -> dict[str, Any]:
825825 else :
826826 current_power_for_decision = self .peak_today
827827
828- # Check if DHW temporary lux is active
828+ # Check if DHW is active (EITHER is_hot_water sensor OR temp_lux switch)
829829 # When NIBE heats DHW, flow temp reads charging temp (45-60°C), not space heating
830+ is_hot_water = (
831+ nibe_data .is_hot_water if hasattr (nibe_data , "is_hot_water" ) else False
832+ )
830833 temp_lux_active = False
831834 if self .temp_lux_entity :
832835 lux_state = self .hass .states .get (self .temp_lux_entity )
833836 temp_lux_active = lux_state is not None and lux_state .state == STATE_ON
834837
838+ # Unified DHW active state: either source means DHW is heating
839+ dhw_is_active = is_hot_water or temp_lux_active
840+
841+ # Track DHW transition: active → inactive triggers weather layer cooldown
842+ # When DHW stops, flow temp remains elevated - needs cooldown period
843+ # Uses DHW_WEATHER_COOLDOWN_MINUTES (30 min) before weather comp re-enables
844+ if self .dhw_was_active and not dhw_is_active :
845+ self .dhw_heating_end = dt_util .now ()
846+ _LOGGER .info (
847+ "DHW heating stopped - triggering weather layer cooldown (%d min)" ,
848+ DHW_WEATHER_COOLDOWN_MINUTES ,
849+ )
850+ elif not self .dhw_was_active and dhw_is_active :
851+ self .dhw_heating_start = dt_util .now ()
852+ _LOGGER .info (
853+ "DHW heating started (is_hot_water=%s, temp_lux=%s)" ,
854+ is_hot_water ,
855+ temp_lux_active ,
856+ )
857+ self .dhw_was_active = dhw_is_active
858+
835859 decision = await self .hass .async_add_executor_job (
836860 self .engine .calculate_decision ,
837861 nibe_data ,
838862 price_data ,
839863 weather_data ,
840864 self .peak_this_month , # Monthly peak threshold to protect
841865 current_power_for_decision , # Current whole-house power consumption
842- temp_lux_active , # DHW heating active - skip weather comp
866+ dhw_is_active , # DHW heating active - skip weather comp
843867 self .dhw_heating_end , # When DHW last stopped - for cooldown
844868 )
845869
@@ -884,6 +908,24 @@ async def _async_update_data(self) -> dict[str, Any]:
884908 # Fall back to safe operation (no offset)
885909 decision = get_safe_default_decision ()
886910
911+ # Check for volatile offset reversal before applying
912+ # Prevents rapid back-and-forth that the heat pump can't follow (same logic as price volatility)
913+ if self ._offset_volatility_tracker .is_reversal_volatile (decision .offset ):
914+ volatile_reason = self ._offset_volatility_tracker .get_volatile_reason (decision .offset )
915+ _LOGGER .info (
916+ "Offset change blocked: %s (keeping %.1f°C)" ,
917+ volatile_reason ,
918+ self ._offset_volatility_tracker .last_offset ,
919+ )
920+ # Keep the previous offset
921+ decision = OptimizationDecision (
922+ offset = self ._offset_volatility_tracker .last_offset ,
923+ reasoning = f"[{ volatile_reason } ] { decision .reasoning } " ,
924+ )
925+ else :
926+ # Record the new offset for volatility tracking
927+ self ._offset_volatility_tracker .record_change (decision .offset , decision .reasoning )
928+
887929 # Update current state
888930 self .current_offset = decision .offset
889931 self .last_decision_time = dt_util .utcnow ()
@@ -1052,29 +1094,9 @@ async def _async_update_data(self) -> dict[str, Any]:
10521094 if nibe_data and hasattr (nibe_data , "dhw_top_temp" ) and nibe_data .dhw_top_temp is not None :
10531095 current_dhw_temp = nibe_data .dhw_top_temp
10541096
1055- # Check if DHW is actively heating (from NIBE MyUplink sensor)
1056- # This is more accurate than temperature thresholds alone
1057- is_actively_heating = (
1058- nibe_data .is_hot_water if hasattr (nibe_data , "is_hot_water" ) else False
1059- )
1060-
1061- # Track DHW heating cycle transitions (start/stop)
1062- if is_actively_heating and not self .dhw_was_heating :
1063- # DHW heating just started
1064- self .dhw_heating_start = now_time
1065- _LOGGER .info ("DHW heating started at %s" , now_time .strftime ("%H:%M:%S" ))
1066- elif not is_actively_heating and self .dhw_was_heating :
1067- # DHW heating just stopped
1068- self .dhw_heating_end = now_time
1069- if self .dhw_heating_start :
1070- duration = (now_time - self .dhw_heating_start ).total_seconds () / 60
1071- _LOGGER .info (
1072- "DHW heating stopped at %s (duration: %.1f minutes)" ,
1073- now_time .strftime ("%H:%M:%S" ),
1074- duration ,
1075- )
1076-
1077- self .dhw_was_heating = is_actively_heating
1097+ # Use unified DHW active state (tracked earlier in update cycle)
1098+ # dhw_was_active combines is_hot_water sensor + temp_lux switch
1099+ is_actively_heating = self .dhw_was_active
10781100
10791101 # Determine status using actual heating status + temperature
10801102 if is_actively_heating :
@@ -1941,6 +1963,7 @@ async def async_update_config(self, options: dict[str, Any]) -> None:
19411963 "dhw_evening_hour" ,
19421964 "dhw_evening_enabled" ,
19431965 "dhw_target_temp" ,
1966+ CONF_DHW_MIN_AMOUNT , # Include min_amount to trigger rebuild when changed
19441967 }
19451968
19461969 if any (key in options for key in dhw_keys ):
@@ -1951,6 +1974,8 @@ async def async_update_config(self, options: dict[str, Any]) -> None:
19511974 from .optimization .dhw_optimizer import DHWDemandPeriod
19521975
19531976 dhw_target = float (options .get ("dhw_target_temp" , DEFAULT_DHW_TARGET_TEMP ))
1977+ # Get user-configured minimum hot water amount (default 5 minutes)
1978+ dhw_min_amount = int (options .get (CONF_DHW_MIN_AMOUNT , DHW_MIN_AMOUNT_DEFAULT ))
19541979 demand_periods = []
19551980
19561981 if options .get ("dhw_morning_enabled" , True ):
@@ -1960,6 +1985,7 @@ async def async_update_config(self, options: dict[str, Any]) -> None:
19601985 availability_hour = morning_hour ,
19611986 target_temp = dhw_target ,
19621987 duration_hours = 2 ,
1988+ min_amount_minutes = dhw_min_amount , # Apply user's configured min amount
19631989 )
19641990 )
19651991
@@ -1970,6 +1996,7 @@ async def async_update_config(self, options: dict[str, Any]) -> None:
19701996 availability_hour = evening_hour ,
19711997 target_temp = dhw_target ,
19721998 duration_hours = 3 ,
1999+ min_amount_minutes = dhw_min_amount , # Apply user's configured min amount
19732000 )
19742001 )
19752002
@@ -2150,14 +2177,9 @@ async def _save_learned_data(
21502177 summary = weather_learner .get_pattern_database_summary ()
21512178 learned_data ["weather_summary" ] = summary
21522179
2153- # Save DHW optimizer state (critical for Legionella safety and max wait tracking )
2180+ # Save DHW optimizer state (critical for Legionella safety, heating rate learning )
21542181 if self .dhw_optimizer :
2155- dhw_state = {}
2156- if self .dhw_optimizer .last_legionella_boost :
2157- dhw_state ["last_legionella_boost" ] = (
2158- self .dhw_optimizer .last_legionella_boost .isoformat ()
2159- )
2160- # Note: last_dhw_heating_time tracked by thermal debt tracker, not optimizer
2182+ dhw_state = self .dhw_optimizer .get_dhw_state_for_persistence ()
21612183 if dhw_state :
21622184 learned_data ["dhw_state" ] = dhw_state
21632185
0 commit comments