Skip to content

Commit 18d7344

Browse files
authored
Fix DHW sensor spam and improve status display
Bug fixes: - Fix recommended_start_time spam: Set to None when heating now (should_heat=True), keep future timestamp when scheduled (should_heat=False). Prevents sensor updates every 5 minutes. - Fix misleading dhw_status: Show heating_reason when heating, blocking_reason only when blocked - Use thermal-mass-adjusted DM thresholds for DHW abort conditions to prevent start-then-abort cycles Improvements: - Add price lookahead to RULE 7 comfort heating (wait for cheaper window if ≥15% savings within lookahead period) - Expose get_adjusted_dm_thresholds() in EmergencyLayer for consistent threshold usage - Simplify coordinator schedule_status logic with dataclass guarantees Test updates: - Rename test functions from *_has_current_time to *_heats_immediately - Update assertions to verify recommended_start_time=None when heating
1 parent 49e446e commit 18d7344

File tree

5 files changed

+180
-56
lines changed

5 files changed

+180
-56
lines changed

custom_components/effektguard/coordinator.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,7 +1164,21 @@ async def _async_update_data(self) -> dict[str, Any]:
11641164
dhw_planning_details = dhw_result.details
11651165

11661166
# Use the optimizer's recommended start time (timezone-aware from spot price)
1167+
# When should_heat=True, recommended_start_time is None (heating now)
1168+
# When should_heat=False, recommended_start_time is a future timestamp
1169+
# (guaranteed by DHWScheduleDecision dataclass validation)
11671170
dhw_next_boost = dhw_planning_details.get("recommended_start_time")
1171+
1172+
# Set schedule_status for UI display
1173+
if dhw_result.decision:
1174+
if dhw_result.decision.should_heat:
1175+
dhw_planning_details["schedule_status"] = "heating_now"
1176+
else:
1177+
# Dataclass validation guarantees recommended_start_time exists
1178+
dhw_planning_details["schedule_status"] = "scheduled"
1179+
else:
1180+
# Edge case: no decision (shouldn't happen in normal operation)
1181+
dhw_planning_details["schedule_status"] = "unknown"
11681182
except (AttributeError, KeyError, ValueError, TypeError, ZeroDivisionError) as e:
11691183
_LOGGER.error(
11701184
"DHW recommendation calculation failed: %s. "

custom_components/effektguard/optimization/dhw_optimizer.py

Lines changed: 108 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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 ===

custom_components/effektguard/optimization/thermal_layer.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,22 @@ def should_block_dhw(self, degree_minutes: float, outdoor_temp: float) -> bool:
10371037

10381038
return False
10391039

1040+
def get_adjusted_dm_thresholds(self, outdoor_temp: float) -> dict:
1041+
"""Get thermal mass adjusted DM thresholds for DHW decisions.
1042+
1043+
Provides consistent thresholds for DHW optimizer to use for both
1044+
blocking AND abort conditions, ensuring no mismatch.
1045+
1046+
Args:
1047+
outdoor_temp: Current outdoor temperature
1048+
1049+
Returns:
1050+
Dict with 'warning', 'critical', 'normal_min', 'normal_max' keys,
1051+
all adjusted for thermal mass.
1052+
"""
1053+
expected_dm_range = self.climate_detector.get_expected_dm_range(outdoor_temp)
1054+
return self._get_thermal_mass_adjusted_thresholds(expected_dm_range)
1055+
10401056

10411057
class ProactiveLayer:
10421058
"""Proactive thermal debt prevention with climate-aware thresholds.

custom_components/effektguard/sensor.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -617,13 +617,18 @@ def extra_state_attributes(self) -> dict[str, Any]:
617617
if "dhw_planning" in self.coordinator.data:
618618
planning = self.coordinator.data.get("dhw_planning", {})
619619

620-
# Add blocking reason if available
621-
if "priority_reason" in planning:
622-
attrs["blocking_reason"] = planning["priority_reason"]
623-
624-
# Add DHW status
620+
# Add DHW status and reason (only show blocking_reason when NOT heating)
625621
if "should_heat" in planning:
626-
attrs["dhw_status"] = "heating" if planning["should_heat"] else "blocked"
622+
if planning["should_heat"]:
623+
attrs["dhw_status"] = "heating"
624+
# When heating, show the reason as heating_reason (not blocking)
625+
if "priority_reason" in planning:
626+
attrs["heating_reason"] = planning["priority_reason"]
627+
else:
628+
attrs["dhw_status"] = "blocked"
629+
# When blocked, show why we're blocked
630+
if "priority_reason" in planning:
631+
attrs["blocking_reason"] = planning["priority_reason"]
627632

628633
# Add human-readable summary
629634
if "dhw_planning_summary" in self.coordinator.data:

0 commit comments

Comments
 (0)