99import logging
1010from collections import deque
1111from dataclasses import dataclass
12- from datetime import datetime
12+ from datetime import datetime , timedelta
1313from typing import Callable , Optional , Protocol
1414
1515from ..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