@@ -673,22 +673,23 @@ def should_start_dhw(
673673 should_block_for_thermal_debt = self .emergency_layer .should_block_dhw (
674674 thermal_debt_dm , outdoor_temp
675675 )
676- # Get thresholds from emergency layer's climate detector for logging/abort conditions
677- if self .emergency_layer .climate_detector :
678- dm_thresholds = self .emergency_layer .climate_detector .get_expected_dm_range (
679- outdoor_temp
680- )
681- dm_block_threshold = dm_thresholds ["warning" ]
682- dm_abort_threshold = dm_thresholds ["warning" ] - 80
683- else :
684- dm_block_threshold = DM_DHW_BLOCK_FALLBACK
685- dm_abort_threshold = DM_DHW_ABORT_FALLBACK
676+ # Get THERMAL MASS ADJUSTED thresholds for abort conditions
677+ # CRITICAL: Must use same adjusted thresholds as should_block_dhw() uses internally
678+ # to prevent start-then-abort cycles when block passes but abort fails
679+ dm_thresholds = self .emergency_layer .get_adjusted_dm_thresholds (outdoor_temp )
680+ dm_block_threshold = dm_thresholds ["warning" ]
681+ # Abort threshold should be LESS strict (more negative) than block threshold
682+ # to avoid immediate abort after starting. Use 80 DM buffer beyond warning.
683+ dm_abort_threshold = dm_thresholds ["warning" ] - 80
686684
687685 _LOGGER .debug (
688- "DHW using shared EmergencyLayer: should_block=%s, DM=%.0f, outdoor=%.1f°C" ,
686+ "DHW using shared EmergencyLayer: should_block=%s, DM=%.0f, outdoor=%.1f°C, "
687+ "adjusted_warning=%.0f, abort_threshold=%.0f" ,
689688 should_block_for_thermal_debt ,
690689 thermal_debt_dm ,
691690 outdoor_temp ,
691+ dm_thresholds ["warning" ],
692+ dm_abort_threshold ,
692693 )
693694 elif self .climate_detector :
694695 # Fallback to local climate detector
@@ -1149,7 +1150,7 @@ def should_start_dhw(
11491150 f"thermal_debt < { dm_abort_threshold :.0f} " ,
11501151 f"indoor_temp < { target_indoor_temp - 0.5 } " ,
11511152 ],
1152- recommended_start_time = current_time , # Heat NOW
1153+ recommended_start_time = None , # Heating now - no future time needed
11531154 )
11541155
11551156 # === RULE 2.3: HYGIENE BOOST (HIGH-TEMP CYCLE FOR LEGIONELLA PREVENTION) ===
@@ -1216,7 +1217,7 @@ def should_start_dhw(
12161217 f"thermal_debt < { dm_abort_threshold :.0f} " ,
12171218 f"indoor_temp < { target_indoor_temp - 0.5 } " ,
12181219 ],
1219- recommended_start_time = current_time ,
1220+ recommended_start_time = None , # Heating now - no future time needed
12201221 )
12211222 elif is_cheap_price and is_volatile :
12221223 # Cheap but volatile - wait for stable window
@@ -1254,7 +1255,7 @@ def should_start_dhw(
12541255 f"thermal_debt < { dm_abort_threshold :.0f} " ,
12551256 f"indoor_temp < { target_indoor_temp - 0.5 } " ,
12561257 ],
1257- recommended_start_time = current_time ,
1258+ recommended_start_time = None , # Heating now - no future time needed
12581259 )
12591260
12601261 # === RULE 3.5: MAXIMUM WAIT TIME EXCEEDED ===
@@ -1276,7 +1277,7 @@ def should_start_dhw(
12761277 f"thermal_debt < { dm_abort_threshold :.0f} " ,
12771278 f"indoor_temp < { target_indoor_temp - 0.5 } " ,
12781279 ],
1279- recommended_start_time = current_time ,
1280+ recommended_start_time = None , # Heating now - no future time needed
12801281 )
12811282
12821283 # === RULE 4: HIGH SPACE HEATING DEMAND - DELAY DHW ===
@@ -1361,7 +1362,7 @@ def should_start_dhw(
13611362 f"indoor_temp < { target_indoor_temp - 0.5 } " ,
13621363 f"dhw_temp >= { self .user_target_temp } " ,
13631364 ],
1364- recommended_start_time = current_time ,
1365+ recommended_start_time = None , # Heating now - no future time needed
13651366 )
13661367 elif optimal_window :
13671368 # Best window is coming up - wait for it
@@ -1400,7 +1401,7 @@ def should_start_dhw(
14001401 f"indoor_temp < { target_indoor_temp - 0.5 } " ,
14011402 f"dhw_temp >= { self .user_target_temp } " ,
14021403 ],
1403- recommended_start_time = current_time ,
1404+ recommended_start_time = None , # Heating now - no future time needed
14041405 )
14051406
14061407 # === RULE 5: NORMAL PRICE OPTIMIZATION (LANE 2) ===
@@ -1410,9 +1411,10 @@ def should_start_dhw(
14101411 #
14111412 # Logic: Heat to target temp during cheapest available window
14121413 # If DHW is adequate (≥45°C), wait for CHEAP prices
1414+
14131415 if current_dhw_temp >= MIN_DHW_TARGET_TEMP and price_classification != "cheap" :
14141416 _LOGGER .info (
1415- "DHW: Adequate (%.1f°C ≥ %.1f°C), price %s - no heating needed " ,
1417+ "DHW: Adequate (%.1f°C ≥ %.1f°C), price %s - waiting for cheap " ,
14161418 current_dhw_temp ,
14171419 MIN_DHW_TARGET_TEMP ,
14181420 price_classification ,
@@ -1462,7 +1464,7 @@ def should_start_dhw(
14621464 f"thermal_debt < { dm_abort_threshold :.0f} " ,
14631465 f"indoor_temp < { target_indoor_temp - 0.5 } " ,
14641466 ],
1465- recommended_start_time = current_time ,
1467+ recommended_start_time = None , # Heating now - no future time needed
14661468 )
14671469
14681470 # STEP 1: Check if we're in the optimal window (within 15 min of start)
@@ -1531,15 +1533,100 @@ def should_start_dhw(
15311533 f"thermal_debt < { dm_abort_threshold :.0f} " ,
15321534 f"indoor_temp < { target_indoor_temp - 0.5 } " ,
15331535 ],
1534- recommended_start_time = current_time ,
1536+ recommended_start_time = None , # Heating now - no future time needed
15351537 )
15361538
15371539 # === RULE 7: COMFORT HEATING (DHW GETTING LOW) ===
15381540 # Heat below minimum user target if price is not expensive/peak
15391541 # This prevents stopping mid-heating-cycle just because price isn't "cheap"
15401542 # User comfort requires minimum temperature - only expensive prices should defer
1543+ #
1544+ # PRICE LOOKAHEAD: Before heating at normal price, check if cheaper window coming
1545+ # that would save significant money without risking comfort.
15411546 is_acceptable_price = price_classification in ["cheap" , "normal" ]
15421547 if current_dhw_temp < MIN_DHW_TARGET_TEMP and is_acceptable_price :
1548+ # Check for cheaper window in the coming hours (use scheduled window hours)
1549+ comfort_lookahead_hours = DHW_SCHEDULED_WINDOW_HOURS
1550+ optimal_window = None
1551+ can_wait_for_optimal = False
1552+
1553+ if price_periods and self .price_analyzer and price_classification == "normal" :
1554+ # Only look for cheaper windows when at "normal" price (not already cheap)
1555+ optimal_window = self .price_analyzer .find_cheapest_window (
1556+ current_time = current_time ,
1557+ price_periods = price_periods ,
1558+ duration_minutes = DHW_NORMAL_RUNTIME_MINUTES ,
1559+ lookahead_hours = comfort_lookahead_hours ,
1560+ )
1561+
1562+ if optimal_window :
1563+ # Get current price for comparison
1564+ current_quarter_price = next (
1565+ (
1566+ p .price
1567+ for p in price_periods
1568+ if p .start_time <= current_time < p .start_time + timedelta (minutes = 15 )
1569+ ),
1570+ None ,
1571+ )
1572+
1573+ if current_quarter_price and optimal_window .avg_price < current_quarter_price :
1574+ price_savings_pct = (
1575+ current_quarter_price - optimal_window .avg_price
1576+ ) / current_quarter_price
1577+
1578+ # Can wait if:
1579+ # 1. Savings significant (≥15%)
1580+ # 2. Optimal window is not too far away (within lookahead)
1581+ # 3. Temperature won't drop too low (still above safety minimum)
1582+ if (
1583+ price_savings_pct >= DHW_OPTIMAL_WINDOW_MIN_SAVINGS
1584+ and optimal_window .hours_until <= comfort_lookahead_hours
1585+ and current_dhw_temp >= DHW_SAFETY_MIN # Don't wait if already critical
1586+ ):
1587+ can_wait_for_optimal = True
1588+ _LOGGER .info (
1589+ "DHW comfort: temp low (%.1f°C) but waiting for cheaper window - "
1590+ "%.1f%% savings (%.1före→%.1före), window in %.1fh at %s" ,
1591+ current_dhw_temp ,
1592+ price_savings_pct * 100 ,
1593+ current_quarter_price ,
1594+ optimal_window .avg_price ,
1595+ optimal_window .hours_until ,
1596+ optimal_window .start_time .strftime ("%H:%M" ),
1597+ )
1598+
1599+ if can_wait_for_optimal and optimal_window :
1600+ # Check if optimal window is NOW (within 15 min)
1601+ if optimal_window .hours_until <= DHW_OPTIMAL_WINDOW_MIN_TIME_BUFFER :
1602+ # We're in the optimal window - heat NOW
1603+ _LOGGER .info (
1604+ "DHW comfort: IN optimal window now (%.1före) - heating" ,
1605+ optimal_window .avg_price ,
1606+ )
1607+ return DHWScheduleDecision (
1608+ should_heat = True ,
1609+ priority_reason = f"DHW_COMFORT_OPTIMAL_@{ optimal_window .avg_price :.1f} öre" ,
1610+ target_temp = self .user_target_temp ,
1611+ max_runtime_minutes = DHW_SAFETY_RUNTIME_MINUTES ,
1612+ abort_conditions = [
1613+ f"thermal_debt < { dm_abort_threshold :.0f} " ,
1614+ f"indoor_temp < { target_indoor_temp - 0.5 } " ,
1615+ ],
1616+ recommended_start_time = None , # Heating now - no future time needed
1617+ )
1618+ else :
1619+ # Wait for upcoming optimal window
1620+ return DHWScheduleDecision (
1621+ should_heat = False ,
1622+ priority_reason = f"DHW_COMFORT_WAITING_OPTIMAL_{ optimal_window .hours_until :.1f} H_@{ optimal_window .avg_price :.1f} " ,
1623+ target_temp = self .user_target_temp ,
1624+ max_runtime_minutes = 0 ,
1625+ abort_conditions = [],
1626+ recommended_start_time = optimal_window .start_time ,
1627+ )
1628+
1629+ # No optimal window worth waiting for - heat now
15431630 _LOGGER .info (
15441631 "DHW comfort heating: %.1f°C < %.1f°C, price %s - heating to MIN_DHW_TARGET_TEMP" ,
15451632 current_dhw_temp ,
@@ -1555,7 +1642,7 @@ def should_start_dhw(
15551642 f"thermal_debt < { dm_abort_threshold :.0f} " ,
15561643 f"indoor_temp < { target_indoor_temp - 0.5 } " ,
15571644 ],
1558- recommended_start_time = current_time ,
1645+ recommended_start_time = None , # Heating now - no future time needed
15591646 )
15601647
15611648 # === RULE 8: ALL CONDITIONS FAIL - DON'T HEAT ===
0 commit comments