Skip to content

Commit 95e3673

Browse files
authored
Fix DHW transition volatility and add generic offset volatility tracker
DHW Transition Fix: - Unify DHW active tracking: is_hot_water OR temp_lux (was separate) - Trigger weather layer cooldown when DHW stops (30 min) - Prevents offset flip when flow temp still elevated after DHW Generic Offset Volatility Tracker: - Add OffsetVolatilityTracker class in volatile_helpers.py - Blocks large offset reversals (>2°C) within 45 min window - Applied at coordinator level for ALL layer decisions - Prevents rapid back-and-forth that heat pump can't follow Consolidate Volatile Duration Constants (DRY): - VOLATILE_MIN_DURATION_MINUTES = 45 (single source of truth) - VOLATILE_MIN_DURATION_QUARTERS = 3 (derived for price logic) - MINUTES_PER_QUARTER = 15 (replaces hardcoded * 15) - Remove PRICE_FORECAST_MIN_DURATION (redundant) - Rename COMPRESSOR_MIN_CYCLE_MINUTES → VOLATILE_MIN_DURATION_MINUTES Tests: 1093 passed (17 new for OffsetVolatilityTracker)
1 parent f21b6e9 commit 95e3673

File tree

9 files changed

+486
-93
lines changed

9 files changed

+486
-93
lines changed

custom_components/effektguard/const.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -627,9 +627,18 @@ class OptimizationModeConfig:
627627
# Based on typical NIBE F-series compressor behavior
628628
COMPRESSOR_RAMP_UP_MINUTES: Final = 30 # Minutes to reach full speed from idle
629629
COMPRESSOR_COOL_DOWN_MINUTES: Final = 15 # Minutes for thermal stabilization after reduction
630-
COMPRESSOR_MIN_CYCLE_MINUTES: Final = (
630+
631+
# Minimum duration for volatility detection (Jan 4, 2026)
632+
# Used by both price volatility (short price runs) and offset volatility (rapid reversals)
633+
# Based on compressor dynamics: 30 min ramp-up + 15 min cool-down = 45 min
634+
# Single source of truth - use MINUTES for offset tracking, QUARTERS for price logic
635+
MINUTES_PER_QUARTER: Final = 15 # 15-minute price quarters
636+
VOLATILE_MIN_DURATION_MINUTES: Final = (
631637
COMPRESSOR_RAMP_UP_MINUTES + COMPRESSOR_COOL_DOWN_MINUTES
632638
) # 45min total
639+
VOLATILE_MIN_DURATION_QUARTERS: Final = (
640+
VOLATILE_MIN_DURATION_MINUTES // MINUTES_PER_QUARTER
641+
) # 3 quarters (45min / 15min)
633642

634643
# Price forecast lookahead (Nov 27, 2025)
635644
# Forward-looking price optimization: reduce heating when cheaper period coming soon
@@ -646,9 +655,6 @@ class OptimizationModeConfig:
646655
PRICE_FORECAST_EXPENSIVE_THRESHOLD: Final = (
647656
1.5 # Price ratio - upcoming > 150% of current = "much more expensive"
648657
)
649-
PRICE_FORECAST_MIN_DURATION: Final = (
650-
COMPRESSOR_MIN_CYCLE_MINUTES // 15
651-
) # quarters - derived from compressor dynamics (45min / 15min = 3)
652658
PRICE_FORECAST_REDUCTION_OFFSET: Final = (
653659
-1.5
654660
) # °C - reduce heating when cheap period coming (Dec 5, 2025: strengthened from -1.0)

custom_components/effektguard/coordinator.py

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
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,
@@ -62,6 +63,7 @@
6263
from .optimization.weather_learning import WeatherPatternLearner
6364
from .utils.compressor_monitor import CompressorHealthMonitor
6465
from .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
@@ -819,21 +825,45 @@ async def _async_update_data(self) -> dict[str, Any]:
819825
else:
820826
current_power_for_decision = self.peak_today
821827

822-
# Check if DHW temporary lux is active
828+
# Check if DHW is active (EITHER is_hot_water sensor OR temp_lux switch)
823829
# 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+
)
824833
temp_lux_active = False
825834
if self.temp_lux_entity:
826835
lux_state = self.hass.states.get(self.temp_lux_entity)
827836
temp_lux_active = lux_state is not None and lux_state.state == STATE_ON
828837

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+
829859
decision = await self.hass.async_add_executor_job(
830860
self.engine.calculate_decision,
831861
nibe_data,
832862
price_data,
833863
weather_data,
834864
self.peak_this_month, # Monthly peak threshold to protect
835865
current_power_for_decision, # Current whole-house power consumption
836-
temp_lux_active, # DHW heating active - skip weather comp
866+
dhw_is_active, # DHW heating active - skip weather comp
837867
self.dhw_heating_end, # When DHW last stopped - for cooldown
838868
)
839869

@@ -878,6 +908,24 @@ async def _async_update_data(self) -> dict[str, Any]:
878908
# Fall back to safe operation (no offset)
879909
decision = get_safe_default_decision()
880910

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+
881929
# Update current state
882930
self.current_offset = decision.offset
883931
self.last_decision_time = dt_util.utcnow()
@@ -1046,29 +1094,9 @@ async def _async_update_data(self) -> dict[str, Any]:
10461094
if nibe_data and hasattr(nibe_data, "dhw_top_temp") and nibe_data.dhw_top_temp is not None:
10471095
current_dhw_temp = nibe_data.dhw_top_temp
10481096

1049-
# Check if DHW is actively heating (from NIBE MyUplink sensor)
1050-
# This is more accurate than temperature thresholds alone
1051-
is_actively_heating = (
1052-
nibe_data.is_hot_water if hasattr(nibe_data, "is_hot_water") else False
1053-
)
1054-
1055-
# Track DHW heating cycle transitions (start/stop)
1056-
if is_actively_heating and not self.dhw_was_heating:
1057-
# DHW heating just started
1058-
self.dhw_heating_start = now_time
1059-
_LOGGER.info("DHW heating started at %s", now_time.strftime("%H:%M:%S"))
1060-
elif not is_actively_heating and self.dhw_was_heating:
1061-
# DHW heating just stopped
1062-
self.dhw_heating_end = now_time
1063-
if self.dhw_heating_start:
1064-
duration = (now_time - self.dhw_heating_start).total_seconds() / 60
1065-
_LOGGER.info(
1066-
"DHW heating stopped at %s (duration: %.1f minutes)",
1067-
now_time.strftime("%H:%M:%S"),
1068-
duration,
1069-
)
1070-
1071-
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
10721100

10731101
# Determine status using actual heating status + temperature
10741102
if is_actively_heating:

custom_components/effektguard/optimization/price_layer.py

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@
1515

1616
from ..const import (
1717
BENEFICIAL_CLASSIFICATIONS,
18-
OptimizationModeConfig,
1918
LAYER_WEIGHT_PRICE,
19+
MINUTES_PER_QUARTER,
20+
OptimizationModeConfig,
2021
PRICE_DAYTIME_MULTIPLIER,
2122
PRICE_FORECAST_BASE_HORIZON,
2223
PRICE_FORECAST_CHEAP_THRESHOLD,
2324
PRICE_FORECAST_DM_DEBT_OFFSET,
2425
PRICE_FORECAST_EXPENSIVE_THRESHOLD,
25-
PRICE_FORECAST_MIN_DURATION,
2626
PRICE_FORECAST_PREHEAT_OFFSET,
2727
PRICE_FORECAST_REDUCTION_OFFSET,
2828
PRICE_OFFSET_CHEAP,
@@ -40,6 +40,8 @@
4040
PRICE_TOLERANCE_MAX,
4141
PRICE_TOLERANCE_MIN,
4242
QuarterClassification,
43+
VOLATILE_MIN_DURATION_MINUTES,
44+
VOLATILE_MIN_DURATION_QUARTERS,
4345
VOLATILE_WEIGHT_REDUCTION,
4446
WEATHER_COMP_DEFER_DM_CRITICAL,
4547
)
@@ -131,7 +133,7 @@ def get_fallback_prices() -> PriceData:
131133

132134
for quarter in range(96): # 96 quarters per day (15-min intervals)
133135
hour = quarter // 4
134-
minute = (quarter % 4) * 15
136+
minute = (quarter % 4) * MINUTES_PER_QUARTER
135137
start_time = base_date.replace(hour=hour, minute=minute)
136138
fallback_periods.append(QuarterPeriod(start_time=start_time, price=1.0))
137139

@@ -824,8 +826,8 @@ def evaluate_layer(
824826
if (
825827
expensive_quarters_away is not None
826828
and expensive_ratio > PRICE_FORECAST_EXPENSIVE_THRESHOLD
827-
and expensive_duration >= PRICE_FORECAST_MIN_DURATION
828-
and expensive_quarters_away >= PRICE_FORECAST_MIN_DURATION
829+
and expensive_duration >= VOLATILE_MIN_DURATION_QUARTERS
830+
and expensive_quarters_away >= VOLATILE_MIN_DURATION_QUARTERS
829831
):
830832
increase_percent = int((expensive_ratio - 1) * 100)
831833
forecast_adjustment = PRICE_FORECAST_PREHEAT_OFFSET
@@ -838,21 +840,21 @@ def evaluate_layer(
838840
# Period meets price threshold but fails duration/lead-time requirements.
839841
# Common cause: lookahead window boundary cuts off part of the period
840842
# (e.g., we see 15min of a 45min peak because the rest is beyond horizon).
841-
if expensive_duration < PRICE_FORECAST_MIN_DURATION:
843+
if expensive_duration < VOLATILE_MIN_DURATION_QUARTERS:
842844
_LOGGER.debug(
843845
"Forecast: Expensive period too brief "
844846
"(%dmin < %dmin) - may extend beyond lookahead",
845-
expensive_duration * 15,
846-
PRICE_FORECAST_MIN_DURATION * 15,
847+
expensive_duration * MINUTES_PER_QUARTER,
848+
VOLATILE_MIN_DURATION_MINUTES,
847849
)
848850
elif (
849851
expensive_quarters_away is not None
850-
and expensive_quarters_away < PRICE_FORECAST_MIN_DURATION
852+
and expensive_quarters_away < VOLATILE_MIN_DURATION_QUARTERS
851853
):
852854
_LOGGER.debug(
853855
"Forecast: Expensive period too soon " "(%dmin < %dmin lookahead)",
854-
expensive_quarters_away * 15,
855-
PRICE_FORECAST_MIN_DURATION * 15,
856+
expensive_quarters_away * MINUTES_PER_QUARTER,
857+
VOLATILE_MIN_DURATION_MINUTES,
856858
)
857859

858860
elif classification in [
@@ -863,8 +865,8 @@ def evaluate_layer(
863865
if (
864866
cheap_quarters_away is not None
865867
and cheap_ratio < PRICE_FORECAST_CHEAP_THRESHOLD
866-
and cheap_duration >= PRICE_FORECAST_MIN_DURATION
867-
and cheap_quarters_away >= PRICE_FORECAST_MIN_DURATION
868+
and cheap_duration >= VOLATILE_MIN_DURATION_QUARTERS
869+
and cheap_quarters_away >= VOLATILE_MIN_DURATION_QUARTERS
868870
):
869871
savings_percent = int((1 - cheap_ratio) * 100)
870872
forecast_adjustment = PRICE_FORECAST_REDUCTION_OFFSET
@@ -877,35 +879,35 @@ def evaluate_layer(
877879
# Period meets price threshold but fails duration/lead-time requirements.
878880
# Common cause: lookahead window boundary cuts off part of the period
879881
# (e.g., we see 15min of a 45min cheap period beyond horizon).
880-
if cheap_duration < PRICE_FORECAST_MIN_DURATION:
882+
if cheap_duration < VOLATILE_MIN_DURATION_QUARTERS:
881883
_LOGGER.debug(
882884
"Forecast: Cheap period too brief "
883885
"(%dmin < %dmin) - may extend beyond lookahead",
884-
cheap_duration * 15,
885-
PRICE_FORECAST_MIN_DURATION * 15,
886+
cheap_duration * MINUTES_PER_QUARTER,
887+
VOLATILE_MIN_DURATION_MINUTES,
886888
)
887889
elif (
888890
cheap_quarters_away is not None
889-
and cheap_quarters_away < PRICE_FORECAST_MIN_DURATION
891+
and cheap_quarters_away < VOLATILE_MIN_DURATION_QUARTERS
890892
):
891893
_LOGGER.debug(
892894
"Forecast: Cheap period too soon " "(%dmin < %dmin lookahead)",
893-
cheap_quarters_away * 15,
894-
PRICE_FORECAST_MIN_DURATION * 15,
895+
cheap_quarters_away * MINUTES_PER_QUARTER,
896+
VOLATILE_MIN_DURATION_MINUTES,
895897
)
896898

897899
else: # NORMAL - check both directions, take most significant sustained change
898900
expensive_valid = (
899901
expensive_quarters_away is not None
900902
and expensive_ratio > PRICE_FORECAST_EXPENSIVE_THRESHOLD
901-
and expensive_duration >= PRICE_FORECAST_MIN_DURATION
902-
and expensive_quarters_away >= PRICE_FORECAST_MIN_DURATION
903+
and expensive_duration >= VOLATILE_MIN_DURATION_QUARTERS
904+
and expensive_quarters_away >= VOLATILE_MIN_DURATION_QUARTERS
903905
)
904906
cheap_valid = (
905907
cheap_quarters_away is not None
906908
and cheap_ratio < PRICE_FORECAST_CHEAP_THRESHOLD
907-
and cheap_duration >= PRICE_FORECAST_MIN_DURATION
908-
and cheap_quarters_away >= PRICE_FORECAST_MIN_DURATION
909+
and cheap_duration >= VOLATILE_MIN_DURATION_QUARTERS
910+
and cheap_quarters_away >= VOLATILE_MIN_DURATION_QUARTERS
909911
)
910912

911913
if expensive_valid and cheap_valid:
@@ -944,39 +946,39 @@ def evaluate_layer(
944946
# Common cause: lookahead window boundary cuts off part of the period
945947
# (e.g., we see 15min of a 45min peak beyond horizon). Expected behavior.
946948
if expensive_ratio > PRICE_FORECAST_EXPENSIVE_THRESHOLD:
947-
if expensive_duration < PRICE_FORECAST_MIN_DURATION:
949+
if expensive_duration < VOLATILE_MIN_DURATION_QUARTERS:
948950
_LOGGER.debug(
949951
"Forecast NORMAL: Expensive period too brief "
950952
"(%dmin < %dmin) - may extend beyond lookahead",
951-
expensive_duration * 15,
952-
PRICE_FORECAST_MIN_DURATION * 15,
953+
expensive_duration * MINUTES_PER_QUARTER,
954+
VOLATILE_MIN_DURATION_MINUTES,
953955
)
954956
elif (
955957
expensive_quarters_away is not None
956-
and expensive_quarters_away < PRICE_FORECAST_MIN_DURATION
958+
and expensive_quarters_away < VOLATILE_MIN_DURATION_QUARTERS
957959
):
958960
_LOGGER.debug(
959961
"Forecast NORMAL: Expensive period too soon "
960962
"(%dmin < %dmin lookahead)",
961-
expensive_quarters_away * 15,
962-
PRICE_FORECAST_MIN_DURATION * 15,
963+
expensive_quarters_away * MINUTES_PER_QUARTER,
964+
VOLATILE_MIN_DURATION_MINUTES,
963965
)
964966
if cheap_ratio < PRICE_FORECAST_CHEAP_THRESHOLD:
965-
if cheap_duration < PRICE_FORECAST_MIN_DURATION:
967+
if cheap_duration < VOLATILE_MIN_DURATION_QUARTERS:
966968
_LOGGER.debug(
967969
"Forecast NORMAL: Cheap period too brief "
968970
"(%dmin < %dmin) - may extend beyond lookahead",
969-
cheap_duration * 15,
970-
PRICE_FORECAST_MIN_DURATION * 15,
971+
cheap_duration * MINUTES_PER_QUARTER,
972+
VOLATILE_MIN_DURATION_MINUTES,
971973
)
972974
elif (
973975
cheap_quarters_away is not None
974-
and cheap_quarters_away < PRICE_FORECAST_MIN_DURATION
976+
and cheap_quarters_away < VOLATILE_MIN_DURATION_QUARTERS
975977
):
976978
_LOGGER.debug(
977979
"Forecast NORMAL: Cheap period too soon " "(%dmin < %dmin lookahead)",
978-
cheap_quarters_away * 15,
979-
PRICE_FORECAST_MIN_DURATION * 15,
980+
cheap_quarters_away * MINUTES_PER_QUARTER,
981+
VOLATILE_MIN_DURATION_MINUTES,
980982
)
981983

982984
# DM debt gate: Don't suppress heating when thermal debt exists (Dec 13, 2025)

0 commit comments

Comments
 (0)