Skip to content

Commit d205dff

Browse files
authored
Merge pull request #15 from enoch85/fix/dm-spiral-anti-windup-jan2026
Fix DM spiral by preventing offset raises during anti-windup
2 parents 78d5907 + ab8ee49 commit d205dff

35 files changed

+791
-400
lines changed

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.23-blue)
8+
![Version](https://img.shields.io/badge/version-0.4.24-beta2-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: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,38 @@ class OptimizationModeConfig:
417417
# This allows some recovery but prevents escalation
418418
ANTI_WINDUP_OFFSET_CAP_MULTIPLIER: Final = 0.7 # Cap at 70% of normal recovery offset
419419

420+
# Anti-windup cooldown period (Jan 2026 DM spiral analysis)
421+
# After anti-windup prevents an offset raise, wait this long before trying again.
422+
# Gives pump time to stabilize at achievable targets.
423+
# Problem: Raising offset when DM is dropping makes DM drop FASTER (S1 increases but BT25 can't catch up).
424+
# Solution: Prevent the raise AND wait before retrying to avoid oscillation.
425+
ANTI_WINDUP_COOLDOWN_MINUTES: Final = 30
426+
427+
# Anti-windup active offset REDUCTION (Jan 2026 enhancement)
428+
# When DM is dropping severely despite positive offset, actively REDUCE offset
429+
# to give the pump achievable targets. Reduction is proportional to spiral severity.
430+
#
431+
# Based on debug.log analysis showing spirals from -53/h (mild) to -456/h (severe):
432+
# -50 to -100/h: Mild spiral - just prevent raise (existing behavior)
433+
# -100/h and worse: Active reduction needed
434+
#
435+
# Reduction formula: reduction = 1.0°C × (|dm_rate| / 100)
436+
# -100/h: reduce by 1.0°C
437+
# -200/h: reduce by 2.0°C
438+
# -300/h: reduce by 3.0°C
439+
# -400/h: reduce by 4.0°C
440+
# -456/h (observed max): reduce by 4.6°C
441+
ANTI_WINDUP_REDUCTION_THRESHOLD: Final = -100.0 # DM/h - start reducing when spiral this bad
442+
ANTI_WINDUP_REDUCTION_RATE_DIVISOR: Final = 100.0 # DM/h - divisor for proportional reduction
443+
ANTI_WINDUP_REDUCTION_MULTIPLIER: Final = 1.0 # °C per unit of (|dm_rate| / divisor)
444+
445+
# Anti-windup causation window (Jan 2026 enhancement)
446+
# Only trigger anti-windup if we raised offset recently.
447+
# If offset has been stable for longer than this, DM drop is likely environmental
448+
# (e.g., forecasted cold snap arrived), not a self-induced spiral.
449+
# 90 min = long enough to see effect of raise, short enough to allow weather response.
450+
ANTI_WINDUP_CAUSATION_WINDOW_MINUTES: Final = 90
451+
420452
# ============================================================================
421453
# Thermal Mass Buffer Multipliers (DM Threshold Adjustment)
422454
# ============================================================================
@@ -505,7 +537,7 @@ class OptimizationModeConfig:
505537
# DESIGN: All proactive zones trigger BEFORE warning threshold!
506538
# Z1-Z5 are PREVENTION layers. T1-T3 are RECOVERY layers (after warning).
507539
#
508-
PROACTIVE_ZONE1_THRESHOLD_PERCENT: Final = 0.10 # 10% of normal max (early warning)
540+
PROACTIVE_ZONE1_THRESHOLD_PERCENT: Final = 0.02 # 2% of normal max (ultra-early warning, Jan 2026)
509541
PROACTIVE_ZONE2_THRESHOLD_PERCENT: Final = 0.30 # 30% of normal max (moderate)
510542
PROACTIVE_ZONE3_THRESHOLD_PERCENT: Final = 0.50 # 50% of normal max (significant)
511543
PROACTIVE_ZONE4_THRESHOLD_PERCENT: Final = 0.75 # 75% of normal max (strong)
@@ -1022,8 +1054,10 @@ class OptimizationModeConfig:
10221054
# Compressor threshold calculation
10231055
# Minimum compressor % needed for enhanced flow to be beneficial
10241056
# Colder outside → need higher compressor output to justify extra ventilation
1025-
# Formula: threshold = 50 + slope * outdoor_temp (for outdoor_temp < 0)
1026-
AIRFLOW_COMPRESSOR_BASE_THRESHOLD: Final = 50.0 # % base threshold at 0°C
1057+
# Formula: threshold = 61 + slope * outdoor_temp (for outdoor_temp < 0)
1058+
# Base raised from 50% to 61% (81 Hz) based on real-world observations showing
1059+
# enhancement at lower Hz caused cooling during periods when pump was struggling
1060+
AIRFLOW_COMPRESSOR_BASE_THRESHOLD: Final = 61.0 # % base threshold at 0°C (81 Hz)
10271061
AIRFLOW_COMPRESSOR_SLOPE: Final = -2.5 # Increase by 2.5% per degree below 0°C
10281062

10291063
# Temperature deficit thresholds for duration calculation (°C)
@@ -1045,7 +1079,7 @@ class OptimizationModeConfig:
10451079

10461080
# Indoor temperature trend threshold for enhancement decision
10471081
AIRFLOW_TREND_WARMING_THRESHOLD: Final = 0.1 # °C/h - already warming, let stabilize
1048-
AIRFLOW_TREND_COOLING_THRESHOLD: Final = -0.15 # °C/h - cooling despite enhanced = stop
1082+
AIRFLOW_TREND_COOLING_THRESHOLD: Final = -0.10 # °C/h - cooling despite enhanced = stop
10491083

10501084
# Configuration keys
10511085
CONF_ENABLE_AIRFLOW_OPTIMIZATION: Final = "enable_airflow_optimization"

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.23"
12+
"version": "v0.4.24-beta2"
1313
}

custom_components/effektguard/optimization/thermal_layer.py

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,20 @@
99
import logging
1010
from collections import deque
1111
from dataclasses import dataclass
12-
from datetime import datetime
12+
from datetime import datetime, timedelta
1313
from typing import Callable, Optional, Protocol
1414

1515
from ..const import (
16+
ANTI_WINDUP_CAUSATION_WINDOW_MINUTES,
17+
ANTI_WINDUP_COOLDOWN_MINUTES,
1618
ANTI_WINDUP_DM_DROPPING_RATE,
1719
ANTI_WINDUP_DM_HISTORY_BASE_SIZE,
1820
ANTI_WINDUP_MIN_POSITIVE_OFFSET,
1921
ANTI_WINDUP_MIN_SAMPLES,
2022
ANTI_WINDUP_OFFSET_CAP_MULTIPLIER,
23+
ANTI_WINDUP_REDUCTION_MULTIPLIER,
24+
ANTI_WINDUP_REDUCTION_RATE_DIVISOR,
25+
ANTI_WINDUP_REDUCTION_THRESHOLD,
2126
DM_CRITICAL_T1_MARGIN,
2227
DM_CRITICAL_T1_OFFSET,
2328
DM_CRITICAL_T1_WEIGHT,
@@ -350,6 +355,17 @@ def __init__(
350355
history_size = self._get_dm_history_size_for_heating_type(heating_type)
351356
self._dm_history: deque[tuple[datetime, float]] = deque(maxlen=history_size)
352357

358+
# Anti-windup cooldown: After detecting spiral, wait before trying to raise again
359+
# Jan 2026: Raising offset when DM is dropping makes DM drop FASTER
360+
# (S1 increases but BT25 can't catch up). Wait for pump to stabilize.
361+
self._anti_windup_cooldown_until: Optional[datetime] = None
362+
363+
# Track when we last raised offset (for causation detection)
364+
# Anti-windup should only trigger if we CAUSED the spiral by raising recently
365+
# Jan 2026: Distinguish self-induced spiral vs environmental DM drop
366+
self._last_offset_raise_time: Optional[datetime] = None
367+
self._last_offset_value: Optional[float] = None
368+
353369
def _get_dm_history_size_for_heating_type(self, heating_type: str) -> int:
354370
"""Get DM history size based on heating system thermal lag.
355371
@@ -408,6 +424,79 @@ def _calculate_dm_rate(self) -> tuple[float, bool]:
408424

409425
return dm_rate, True
410426

427+
def _is_in_cooldown(self, timestamp: datetime) -> bool:
428+
"""Check if still in cooldown from previous anti-windup activation.
429+
430+
Args:
431+
timestamp: Current timestamp
432+
433+
Returns:
434+
True if in cooldown period, False otherwise
435+
"""
436+
if self._anti_windup_cooldown_until is None:
437+
return False
438+
return timestamp < self._anti_windup_cooldown_until
439+
440+
def _start_cooldown(self, timestamp: datetime) -> None:
441+
"""Start cooldown period after anti-windup activation.
442+
443+
Prevents oscillation by waiting before trying to raise offset again.
444+
445+
Args:
446+
timestamp: Current timestamp
447+
"""
448+
self._anti_windup_cooldown_until = timestamp + timedelta(
449+
minutes=ANTI_WINDUP_COOLDOWN_MINUTES
450+
)
451+
_LOGGER.info(
452+
"Anti-windup cooldown started: no offset raises until %s",
453+
self._anti_windup_cooldown_until.strftime("%H:%M"),
454+
)
455+
456+
def _track_offset_change(self, timestamp: datetime, new_offset: float) -> None:
457+
"""Track offset changes to detect self-induced spirals.
458+
459+
Only records RAISES (new_offset > previous). Decreases don't cause spirals.
460+
Called at the end of evaluate_layer to track the final offset decision.
461+
462+
Args:
463+
timestamp: Current timestamp
464+
new_offset: The offset value being returned
465+
"""
466+
if self._last_offset_value is None:
467+
self._last_offset_value = new_offset
468+
return
469+
470+
if new_offset > self._last_offset_value:
471+
# This is a RAISE - record the time
472+
self._last_offset_raise_time = timestamp
473+
_LOGGER.debug(
474+
"Offset raised: %.1f → %.1f at %s",
475+
self._last_offset_value,
476+
new_offset,
477+
timestamp.strftime("%H:%M"),
478+
)
479+
480+
self._last_offset_value = new_offset
481+
482+
def _raised_offset_recently(self, timestamp: datetime) -> bool:
483+
"""Check if we raised offset within the causation window.
484+
485+
Returns True if we raised offset recently (potential self-induced spiral).
486+
Returns False if offset has been stable (likely environmental DM drop).
487+
488+
Args:
489+
timestamp: Current timestamp
490+
491+
Returns:
492+
True if offset was raised within ANTI_WINDUP_CAUSATION_WINDOW_MINUTES
493+
"""
494+
if self._last_offset_raise_time is None:
495+
return False
496+
497+
minutes_since_raise = (timestamp - self._last_offset_raise_time).total_seconds() / 60
498+
return minutes_since_raise <= ANTI_WINDUP_CAUSATION_WINDOW_MINUTES
499+
411500
def _check_anti_windup(
412501
self, current_offset: float, dm_rate: float, has_valid_rate: bool
413502
) -> tuple[bool, str]:
@@ -514,6 +603,11 @@ def evaluate_layer(
514603
current_offset = getattr(nibe_state, "current_offset", 0.0)
515604
timestamp = getattr(nibe_state, "timestamp", datetime.now())
516605

606+
# Track offset changes for causation detection (Jan 2026)
607+
# This records when offset was raised to distinguish self-induced spirals
608+
# from environmental DM drops (e.g., cold snap arriving hours later)
609+
self._track_offset_change(timestamp, current_offset)
610+
517611
# Update DM history for anti-windup tracking
518612
self._update_dm_history(degree_minutes, timestamp)
519613

@@ -525,6 +619,87 @@ def evaluate_layer(
525619

526620
temp_deviation = indoor_temp - target_temp
527621

622+
# ========================================
623+
# ANTI-WINDUP: Prevent offset raises that make DM worse (Jan 2026 fix)
624+
# ========================================
625+
# Physics: Raising offset increases S1 (target), but BT25 (actual) can't catch up
626+
# → Gap (BT25 - S1) becomes MORE negative → DM drops FASTER
627+
# Solution: If anti-windup detects this pattern, don't calculate higher offsets at all
628+
629+
# Check 1: Are we in cooldown from a previous anti-windup activation?
630+
if self._is_in_cooldown(timestamp):
631+
remaining_min = int((self._anti_windup_cooldown_until - timestamp).total_seconds() / 60)
632+
return EmergencyLayerDecision(
633+
name="Anti-windup Cooldown",
634+
offset=current_offset,
635+
weight=0.7,
636+
reason=f"In cooldown ({remaining_min} min remaining) - not raising offset",
637+
tier="COOLDOWN",
638+
degree_minutes=degree_minutes,
639+
anti_windup_active=True,
640+
dm_rate=dm_rate,
641+
)
642+
643+
# Check 2: Is anti-windup detecting a spiral (DM dropping while offset positive)?
644+
# Jan 2026: Only trigger if we CAUSED the spiral by raising offset recently
645+
# If offset has been stable for >90 min, DM drop is likely environmental
646+
if anti_windup_active and current_offset >= ANTI_WINDUP_MIN_POSITIVE_OFFSET:
647+
# Check causation window - did we raise offset recently?
648+
if not self._raised_offset_recently(timestamp):
649+
# Offset has been stable - this is likely environmental, not self-induced
650+
_LOGGER.info(
651+
"Anti-windup skipped: offset stable for >%d min, "
652+
"DM drop (%.0f/h) likely environmental",
653+
ANTI_WINDUP_CAUSATION_WINDOW_MINUTES,
654+
dm_rate,
655+
)
656+
# Continue to tier calculations - allow response to environmental change
657+
else:
658+
# We raised recently and DM is dropping - self-induced spiral
659+
# Start cooldown to prevent oscillation
660+
self._start_cooldown(timestamp)
661+
662+
# Calculate reduction based on severity (Jan 2026 enhancement)
663+
# If dm_rate is worse than REDUCTION_THRESHOLD, actively reduce offset
664+
# Formula: reduction = 1.0°C × (|dm_rate| / 100)
665+
if dm_rate < ANTI_WINDUP_REDUCTION_THRESHOLD:
666+
# Severe spiral - actively reduce offset
667+
reduction = (
668+
abs(dm_rate) / ANTI_WINDUP_REDUCTION_RATE_DIVISOR
669+
) * ANTI_WINDUP_REDUCTION_MULTIPLIER
670+
new_offset = max(-10.0, current_offset - reduction) # Floor at MIN_OFFSET
671+
reason = (
672+
f"DM dropping {dm_rate:.0f}/h - reducing offset by {reduction:.1f}°C "
673+
f"(from +{current_offset:.0f}°C to {new_offset:.1f}°C)"
674+
)
675+
_LOGGER.info(
676+
"Anti-windup REDUCTION: dm_rate=%d/h, reduction=%.1f°C, "
677+
"offset %+.1f → %+.1f",
678+
dm_rate,
679+
reduction,
680+
current_offset,
681+
new_offset,
682+
)
683+
else:
684+
# Mild spiral - just keep current offset, don't raise
685+
new_offset = current_offset
686+
reason = (
687+
f"DM dropping {dm_rate:.0f}/h while offset +{current_offset:.0f}°C - "
688+
f"not raising"
689+
)
690+
691+
return EmergencyLayerDecision(
692+
name="Anti-windup",
693+
offset=new_offset,
694+
weight=0.7,
695+
reason=reason,
696+
tier="ANTI_WINDUP",
697+
degree_minutes=degree_minutes,
698+
threshold_used=0.0,
699+
anti_windup_active=True,
700+
dm_rate=dm_rate,
701+
)
702+
528703
# Case 1: Too warm (above tolerance)
529704
if temp_deviation > tolerance_range:
530705
return EmergencyLayerDecision(

0 commit comments

Comments
 (0)