Skip to content

Commit 8235e70

Browse files
authored
Merge fix/dhw-scheduling-and-heating-rate-learning: DHW volatility fix, offset tracker, constants consolidation
2 parents 329d7cf + 95e3673 commit 8235e70

File tree

15 files changed

+1026
-149
lines changed

15 files changed

+1026
-149
lines changed

.github/copilot-instructions.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ source .venv/bin/activate
3737
14. **Use Black formatting** - All Python code must be formatted with Black (line length 100)
3838
15. **No `Any` type imports** - Use specific types (dataclasses, TypedDict, Protocol, etc.)
3939
16. **All imports at file top** - Place all imports at the top of files to avoid circular imports
40+
17. **Fetch real prices for debugging** - When investigating price volatility or any price-related issue:
41+
- First check which price region is configured (SE1-SE4, NO1-NO5, DK1-DK2, FI, etc.) from logs or config
42+
- Fetch current prices from Nordpool API for that **same region**
43+
- Use: `curl "https://www.nordpoolgroup.com/api/marketdata/page/10?currency=SEK&endDate=YYYY-MM-DD"` or fetch via `fetch_webpage` tool
44+
- Alternative: `curl "https://dataportal-api.nordpoolgroup.com/api/DayAheadPrices?date=YYYY-MM-DD&market=DayAhead&deliveryArea=SE4&currency=SEK"`
45+
- Compare actual API prices with values in debug logs to verify correct behavior
4046

4147
---
4248

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<img src="icons/logo.png" alt="EffektGuard Logo" width="200"/>
66

77
[![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration)
8-
![Version](https://img.shields.io/badge/version-0.4.19-blue)
8+
![Version](https://img.shields.io/badge/version-0.4.20-beta.1-blue)
99
![HA](https://img.shields.io/badge/Home%20Assistant-2025.10%2B-blue)
1010
[![Sponsor on GitHub](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-1f425f?logo=github&style=for-the-badge)](https://github.com/sponsors/enoch85)
1111

custom_components/effektguard/const.py

Lines changed: 17 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)
@@ -929,6 +935,13 @@ class OptimizationModeConfig:
929935
DHW_MIN_AMOUNT_MAX: Final = 30 # Config maximum: 30 minutes
930936
CONF_DHW_MIN_AMOUNT: Final = "dhw_min_amount" # Config key for min hot water minutes
931937

938+
# DHW temperature-based heating rate (measured from real F750 data 2025-12-22)
939+
# 19:00 → 20:00: 23.8°C → 37.8°C = 14°C in 60 min = 14°C/hour
940+
# Used as fallback when insufficient history for dynamic calculation
941+
# The dhw_optimizer uses calculate_heating_rate() for dynamic estimation from BT7 history
942+
DHW_DEFAULT_HEATING_RATE: Final = 14.0 # °C/hour (measured from debug log)
943+
DHW_AMOUNT_HEATING_BUFFER: Final = 0.5 # Hours buffer for scheduling (arrive early, not late)
944+
932945
# DHW thermal debt fallback thresholds (used only if climate detector unavailable)
933946
# These are balanced fixed values for rare fallback scenarios
934947
DM_DHW_BLOCK_FALLBACK: Final = -340.0 # Fallback: Never start DHW below this DM

custom_components/effektguard/coordinator.py

Lines changed: 64 additions & 42 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
@@ -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

custom_components/effektguard/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
"iot_class": "calculated",
1010
"issue_tracker": "https://github.com/enoch85/EffektGuard/issues",
1111
"requirements": ["numpy>=1.21.0"],
12-
"version": "v0.4.19"
12+
"version": "v0.4.20-beta.1"
1313
}

0 commit comments

Comments
 (0)