"""Lifecycle manager for learned heating slope (LHS) caching and refresh.""" from __future__ import annotations import logging from datetime import datetime, timedelta from typing import TYPE_CHECKING, Callable from ..domain.value_objects.heating import HeatingCycle if TYPE_CHECKING: from ..domain.interfaces import ILhsStorage, ITimerScheduler from ..domain.services.contextual_lhs_calculator_service import ContextualLHSCalculatorService from ..domain.services.global_lhs_calculator_service import GlobalLHSCalculatorService _LOGGER = logging.getLogger(__name__) try: from homeassistant.util import dt as dt_util except ImportError: dt_util = None # For testing without HA class LhsLifecycleManager: """Manage global and contextual LHS lifecycle, caching, and refresh. This manager is a singleton per IHP device (identified by device_id). It orchestrates: 1. In-memory cache management (_cached_global_lhs, _cached_contextual_lhs) 2. Persistent storage via ILhsStorage 3. Periodic refresh scheduling (24h timer) 4. Cascade updates triggered by HeatingCycleLifecycleManager Architecture: - **In-memory cache**: Fast lookups for global and contextual LHS values - **Model storage (ILhsStorage)**: Persistent storage for LHS values with timestamps - **Triggered by**: HeatingCycleLifecycleManager when cycles change Lazy Loading Strategy: ======================== This class implements LAZY LOADING for contextual LHS to optimize startup performance: **Startup Phase:** - Global LHS: Loaded eagerly (single read from storage) - Contextual LHS: Loaded ONLY for current hour (datetime.now().hour) - Other 23 hours: NOT loaded at startup (deferred to on-demand) **On-Demand Loading:** - get_contextual_lhs(): Loads requested hour from storage (if not in memory) - ensure_contextual_lhs_populated(): Same lazy-load behavior with optional force_recalculate - Per-hour memory caching: Each hour cached independently after first load **Bulk Update Methods (intentionally load all 24 hours):** - on_retention_change(): Recalculates and caches all 24 hours when retention changes - on_24h_timer(): Recalculates and caches all 24 hours on periodic refresh - update_contextual_lhs_from_cycles(): Persists all 24 hours when called Cache Hierarchy (fastest to slowest): 1. Memory cache (_cached_contextual_lhs[hour]): O(1) lookup 2. Storage cache (ILhsStorage.get_cached_contextual_lhs): Single disk read 3. Computation cache (calculate_contextual_lhs_for_hour): On-demand calculation Lazy Loading Strategy: ======================== This class implements LAZY LOADING for contextual LHS to optimize startup performance: **Startup Phase:** - Global LHS: Loaded eagerly (single read from storage) - Contextual LHS: Loaded ONLY for current hour (datetime.now().hour) - Other 23 hours: NOT loaded at startup (deferred to on-demand) **On-Demand Loading:** - get_contextual_lhs(): Loads requested hour from storage (if not in memory) - ensure_contextual_lhs_populated(): Same lazy-load behavior with optional force_recalculate - Per-hour memory caching: Each hour cached independently after first load **Bulk Update Methods (intentionally load all 24 hours):** - on_retention_change(): Recalculates and caches all 24 hours when retention changes - on_24h_timer(): Recalculates and caches all 24 hours on periodic refresh - update_contextual_lhs_from_cycles(): Persists all 24 hours when called Cache Hierarchy (fastest to slowest): 1. Memory cache (_cached_contextual_lhs[hour]): O(1) lookup 2. Storage cache (ILhsStorage.get_cached_contextual_lhs): Single disk read 3. Computation cache (calculate_contextual_lhs_for_hour): On-demand calculation Lifecycle Events: - startup(): Load global LHS + current hour contextual (lazy loading) - on_retention_change(cycles): Recalculate ALL 24 hours with new retention window - on_24h_timer(cycles): Recalculate ALL 24 hours on timer event - update_global_lhs_from_cycles(cycles): Persist global LHS from cycles - update_contextual_lhs_from_cycles(cycles): Persist ALL 24 contextual hours from cycles - cancel(): Cleanup timers and release resources (keep cached data) - startup(): Load global LHS + current hour contextual (lazy loading) - on_retention_change(cycles): Recalculate ALL 24 hours with new retention window - on_24h_timer(cycles): Recalculate ALL 24 hours on timer event - update_global_lhs_from_cycles(cycles): Persist global LHS from cycles - update_contextual_lhs_from_cycles(cycles): Persist ALL 24 contextual hours from cycles - cancel(): Cleanup timers and release resources (keep cached data) Cascade Pattern: HeatingCycleLifecycleManager calls update_*_lhs_from_cycles() when: - startup(): Initial cycles extracted - on_retention_change(): Cycles re-extracted with new retention - on_24h_timer(): Cycles refreshed with latest data """ def __init__( self, model_storage: ILhsStorage, global_lhs_calculator: GlobalLHSCalculatorService, contextual_lhs_calculator: ContextualLHSCalculatorService, timer_scheduler: ITimerScheduler | None = None, ) -> None: """Initialize the lifecycle manager. Note: This should be instantiated via LhsLifecycleManagerFactory to ensure singleton behavior per device_id. Args: model_storage: Persistent storage adapter for cached LHS values. global_lhs_calculator: Domain service for computing global LHS from cycles. contextual_lhs_calculator: Domain service for computing contextual LHS by hour from cycles. timer_scheduler: Optional scheduler for periodic 24h refresh tasks. """ self._model_storage = model_storage self._global_lhs_calculator = global_lhs_calculator self._contextual_lhs_calculator = contextual_lhs_calculator self._timer_scheduler = timer_scheduler # In-memory caches for fast repeated lookups (avoids disk I/O) # These are loaded from storage on startup and invalidated on updates self._cached_global_lhs: float | None = None # Global LHS value self._cached_contextual_lhs: dict[int, float] = {} # hour (0-23) -> LHS value self._timer_cancel_func: Callable[[], None] | None = None async def startup(self) -> None: """Initialize LHS caches and schedule periodic refresh. Lifecycle Event Flow: 1. Load cached global LHS from storage → memory cache 2. Load cached contextual LHS for CURRENT HOUR ONLY (lazy loading) 3. Schedule 24h timer for automatic refresh (if scheduler provided) Lazy Loading Strategy: This method implements lazy loading to optimize startup performance: - **Current hour**: Loaded eagerly during startup (most frequently used) - **Other 23 hours**: Loaded on-demand via get_contextual_lhs() or ensure_contextual_lhs_populated() - **Memory cache**: Per-hour caching in _cached_contextual_lhs (fast lookups) - **Storage cache**: Persistent on-disk cache (fallback if not in memory) - **Computation cache**: Computed on-demand when no cached value exists Cache Strategy: - **Reads from storage**: model_storage.get_cached_global_lhs() - **Reads from storage**: model_storage.get_cached_contextual_lhs() for CURRENT HOUR ONLY - **Writes to memory**: Populates _cached_global_lhs and _cached_contextual_lhs[current_hour] - **Does NOT write to storage**: Only loads existing cached values - **Does NOT compute**: Uses cached values or returns defaults via get_* methods Note: - Initial LHS values are computed and stored by HeatingCycleLifecycleManager during its startup - Bulk updates (on_retention_change(), on_24h_timer()) intentionally load all 24 hours - On-demand loads (get_contextual_lhs(), ensure_contextual_lhs_populated()) load per-hour Returns: None. """ _LOGGER.debug("Entering LhsLifecycleManager.startup") try: # Load cached global LHS cached_global_lhs = await self._model_storage.get_cached_global_lhs() if cached_global_lhs is not None: # Extract value from LHSCacheEntry lhs_value = cached_global_lhs.value self._cached_global_lhs = lhs_value _LOGGER.debug("Loaded cached global LHS: %.2f °C/h", lhs_value) # Lazy Loading: Load contextual LHS for current hour ONLY # Other hours will be loaded on-demand via get_contextual_lhs() or ensure_contextual_lhs_populated() current_hour = datetime.now().hour if dt_util is None else dt_util.now().hour _LOGGER.debug( "Loading contextual LHS for current hour: %d (lazy loading enabled)", current_hour ) cached_contextual = await self._model_storage.get_cached_contextual_lhs(current_hour) if cached_contextual is not None: # Extract value from LHSCacheEntry lhs_value = cached_contextual.value self._cached_contextual_lhs[current_hour] = lhs_value _LOGGER.debug( "Loaded cached contextual LHS for current hour %d: %.2f °C/h", current_hour, lhs_value, ) else: _LOGGER.debug( "No cached contextual LHS for current hour %d; will load on-demand", current_hour, ) _LOGGER.info( "LHS startup complete: loaded global LHS and current hour contextual LHS (lazy loading enabled)" ) except Exception as exc: _LOGGER.error("Error during startup: %s", exc) # Don't re-raise - continue with defaults # Schedule 24h timer for automatic refresh if self._timer_scheduler is not None: if dt_util is not None: next_refresh = dt_util.now() + timedelta(hours=24) else: next_refresh = datetime.now() + timedelta(hours=24) # Timer callback: no-op placeholder (cycles provided by HeatingCycleLifecycleManager) async def noop_callback() -> None: """Placeholder timer callback - actual update comes from HCLM.""" pass self._timer_cancel_func = self._timer_scheduler.schedule_timer( next_refresh, noop_callback ) _LOGGER.debug("Scheduled 24h LHS refresh timer for %s", next_refresh.isoformat()) _LOGGER.debug("Exiting LhsLifecycleManager.startup") async def on_retention_change(self, cycles: list[HeatingCycle]) -> None: """Handle retention configuration changes. Lifecycle Event Flow (triggered by HeatingCycleLifecycleManager): 1. HeatingCycleLifecycleManager re-extracts cycles for new retention window 2. HeatingCycleLifecycleManager calls this method with the new cycles 3. Invalidate in-memory caches FIRST (prevents use of stale data) 4. Recalculate global LHS from provided cycles 5. Recalculate contextual LHS (by hour) from provided cycles 6. Persist new LHS values to storage Cache Strategy: - **Invalidates memory FIRST**: Clears _cached_global_lhs and _cached_contextual_lhs - **Receives cycles from**: HeatingCycleLifecycleManager.on_retention_change() - **Computes**: global_lhs_calculator.calculate_global_lhs(cycles) - **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles) - **Writes to storage**: model_storage.set_cached_global_lhs() - **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value) Args: cycles: Heating cycles extracted for the new retention window. Returns: None. """ from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS _LOGGER.debug("Entering LhsLifecycleManager.on_retention_change") _LOGGER.debug("Recalculating LHS from %d cycles after retention change", len(cycles)) # Step 1: Invalidate in-memory caches FIRST (before any computation) self._cached_global_lhs = None self._cached_contextual_lhs = {} _LOGGER.debug("Invalidated in-memory caches before recalculation") # Step 2: Recalculate global LHS from provided cycles global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles) # Validate: LHS must be realistically positive (>= 0.5°C/h) if global_lhs < MINIMUM_REALISTIC_LHS: _LOGGER.warning( "Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)", global_lhs, MINIMUM_REALISTIC_LHS, DEFAULT_LEARNED_SLOPE, ) global_lhs = DEFAULT_LEARNED_SLOPE updated_at = dt_util.now() if dt_util is not None else datetime.now() await self._model_storage.set_cached_global_lhs(global_lhs, updated_at) _LOGGER.info("Recalculated global LHS: %.2f °C/h", global_lhs) # Step 3: Recalculate contextual LHS from provided cycles contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs( cycles ) for hour, lhs_value in contextual_lhs_by_hour.items(): if lhs_value is not None and lhs_value > 0: await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at) _LOGGER.debug("Recalculated contextual LHS for %d hours", len(contextual_lhs_by_hour)) _LOGGER.debug("Exiting LhsLifecycleManager.on_retention_change") async def on_24h_timer(self, cycles: list[HeatingCycle]) -> None: """Handle periodic 24h refresh execution. Lifecycle Event Flow (triggered by HeatingCycleLifecycleManager): 1. HeatingCycleLifecycleManager extracts fresh cycles for retention window 2. HeatingCycleLifecycleManager calls this method with the fresh cycles 3. Recalculate global LHS from provided cycles 4. Recalculate contextual LHS (by hour) from provided cycles 5. Persist new LHS values to storage 6. Invalidate in-memory caches (will reload from storage on next access) Cache Strategy: - **Receives cycles from**: HeatingCycleLifecycleManager.on_24h_timer() - **Computes**: global_lhs_calculator.calculate_global_lhs(cycles) - **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles) - **Writes to storage**: model_storage.set_cached_global_lhs() - **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value) - **Invalidates memory**: Sets _cached_global_lhs = None, _cached_contextual_lhs = {} Args: cycles: Heating cycles extracted for the current retention window. Returns: None. """ from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS _LOGGER.debug("Entering LhsLifecycleManager.on_24h_timer") _LOGGER.info("24h LHS refresh timer triggered") # Recalculate global LHS from provided cycles global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles) # Validate: LHS must be realistically positive (>= 0.5°C/h) if global_lhs < MINIMUM_REALISTIC_LHS: _LOGGER.warning( "Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)", global_lhs, MINIMUM_REALISTIC_LHS, DEFAULT_LEARNED_SLOPE, ) global_lhs = DEFAULT_LEARNED_SLOPE updated_at = dt_util.now() if dt_util is not None else datetime.now() await self._model_storage.set_cached_global_lhs(global_lhs, updated_at) # Invalidate cache to ensure fresh load self._cached_global_lhs = None _LOGGER.info("Refreshed global LHS: %.2f °C/h", global_lhs) # Recalculate contextual LHS from provided cycles contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs( cycles ) for hour, lhs_value in contextual_lhs_by_hour.items(): if lhs_value is not None and lhs_value > 0: await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at) # Invalidate cache to ensure fresh load self._cached_contextual_lhs = {} _LOGGER.debug("Exiting LhsLifecycleManager.on_24h_timer") async def get_global_lhs(self) -> float: """Return the global learned heating slope (LHS). Cache Strategy (read-only, optimized with memory cache): - **Reads from memory first**: Checks _cached_global_lhs - **On memory cache hit**: Returns immediately (fast path) - **On memory cache miss**: Loads from model_storage.get_cached_global_lhs() - **Writes to memory**: Caches loaded value in _cached_global_lhs - **Validation**: Returns default if cached value is invalid (<=0 or None) - **Does NOT write to storage**: Read-only operation Use Case: Called frequently during anticipation calculations to get the baseline heating rate. Memory cache ensures fast lookups without repeated disk I/O. Returns: The cached or default global LHS in C/hour. """ from ..domain.constants import DEFAULT_LEARNED_SLOPE _LOGGER.debug("Entering LhsLifecycleManager.get_global_lhs") # Check in-memory cache first (fast path) if self._cached_global_lhs is not None: _LOGGER.debug( "Returning in-memory cached global LHS: %.2f °C/h", self._cached_global_lhs ) _LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs") return self._cached_global_lhs # Not in memory cache, load from storage cached_entry = await self._model_storage.get_cached_global_lhs() # Validate cached value is positive if cached_entry is not None: cached_lhs = cached_entry.value if cached_lhs > 0: # Cache in memory for subsequent calls self._cached_global_lhs = cached_lhs _LOGGER.debug("Loaded from storage and cached global LHS: %.2f °C/h", cached_lhs) _LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs") return cached_lhs # Return default if cache invalid or missing _LOGGER.debug( "No valid cached global LHS, returning default: %.2f °C/h", DEFAULT_LEARNED_SLOPE ) _LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs") return DEFAULT_LEARNED_SLOPE async def cancel(self) -> None: """Cancel any scheduled refresh work and release resources. Cache Strategy: - **Memory cache**: NOT cleared (remains valid until next update) - **Storage cache**: NOT cleared (persistent data remains) - **Timers**: Cancelled to stop periodic refresh Use Case: Called when the IHP device is being shut down or removed. Does NOT clear learned data, only stops active timers. Returns: None. """ _LOGGER.debug("Entering LhsLifecycleManager.cancel") # Cancel timer if scheduled if self._timer_cancel_func is not None: try: self._timer_cancel_func() _LOGGER.debug("Cancelled scheduled timer") except Exception as exc: _LOGGER.error("Error cancelling timer: %s", exc) finally: self._timer_cancel_func = None _LOGGER.debug("Exiting LhsLifecycleManager.cancel") async def get_contextual_lhs( self, target_time: datetime, cycles: list[HeatingCycle], ) -> float: """Return contextual LHS for a target time. Cache Strategy (read-only, optimized with memory cache per hour): - **Reads from memory first**: Checks _cached_contextual_lhs[target_hour] - **On memory cache hit**: Returns immediately (fast path) - **On memory cache miss**: Loads from model_storage.get_cached_contextual_lhs(hour) - **If no storage cache**: Computes from provided cycles - **Writes to memory**: Caches loaded/computed value in _cached_contextual_lhs[hour] - **Falls back to global LHS**: If no contextual data exists for the hour - **Does NOT write to storage**: Read-only operation (use update_contextual_lhs_from_cycles) Use Case: Called during anticipation calculations to get hour-specific heating rates. Memory cache per hour ensures fast lookups without repeated disk I/O. Args: target_time: Target datetime used to select the contextual hour (0-23). cycles: Heating cycles used to compute contextual LHS when not cached. Returns: Contextual LHS for the target hour in C/hour, or global LHS fallback. """ _LOGGER.debug("Entering LhsLifecycleManager.get_contextual_lhs") target_hour = target_time.hour _LOGGER.debug("Getting contextual LHS for hour %d", target_hour) # Check in-memory cache first (fast path) if target_hour in self._cached_contextual_lhs: cached_value = self._cached_contextual_lhs[target_hour] _LOGGER.debug( "Returning in-memory cached contextual LHS for hour %d: %.2f °C/h", target_hour, cached_value, ) _LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs") return cached_value # Not in memory cache, try to get from storage cached_entry = await self._model_storage.get_cached_contextual_lhs(target_hour) if cached_entry is not None: cached_contextual = cached_entry.value # Cache in memory for subsequent calls self._cached_contextual_lhs[target_hour] = cached_contextual _LOGGER.debug( "Loaded from storage and cached contextual LHS for hour %d: %.2f °C/h", target_hour, cached_contextual, ) _LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs") return cached_contextual # No cache, compute contextual LHS for THIS HOUR ONLY (not all 24) _LOGGER.debug("No cached contextual LHS for hour %d, computing from cycles", target_hour) computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour( cycles, target_hour ) # If contextual LHS is None or invalid (< 0.5°C/h), fallback to global LHS from ..domain.constants import MINIMUM_REALISTIC_LHS if computed_lhs is None or computed_lhs < MINIMUM_REALISTIC_LHS: _LOGGER.debug( "Contextual LHS for hour %d is invalid (%.2f°C/h < %.2f°C/h), falling back to global LHS", target_hour, computed_lhs or 0, MINIMUM_REALISTIC_LHS, ) return await self.get_global_lhs() # Cache computed value in memory self._cached_contextual_lhs[target_hour] = computed_lhs _LOGGER.debug( "Computed and cached contextual LHS for hour %d: %.2f °C/h", target_hour, computed_lhs, ) _LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs") return computed_lhs async def update_global_lhs_from_cycles(self, cycles: list[HeatingCycle]) -> float: """Recalculate and persist global LHS from cycles. Lifecycle Event (triggered by HeatingCycleLifecycleManager): This is called when: - HeatingCycleLifecycleManager.startup(): Initial cycles extracted - HeatingCycleLifecycleManager.on_retention_change(): Cycles re-extracted - HeatingCycleLifecycleManager.on_24h_timer(): Cycles refreshed Cache Strategy (write operation): - **Receives cycles from**: HeatingCycleLifecycleManager - **Computes**: global_lhs_calculator.calculate_global_lhs(cycles) - **Writes to storage**: model_storage.set_cached_global_lhs(lhs, timestamp) - **Invalidates memory**: Sets _cached_global_lhs = None - **Next read**: get_global_lhs() will reload from storage into memory Args: cycles: Heating cycles used to compute the global LHS. Returns: The computed and persisted global LHS in C/hour. """ from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS _LOGGER.debug("Entering LhsLifecycleManager.update_global_lhs_from_cycles") _LOGGER.debug("Updating global LHS from %d cycles", len(cycles)) try: global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles) # Validate: LHS must be realistically positive (>= 0.5°C/h) if global_lhs < MINIMUM_REALISTIC_LHS: _LOGGER.warning( "Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)", global_lhs, MINIMUM_REALISTIC_LHS, DEFAULT_LEARNED_SLOPE, ) global_lhs = DEFAULT_LEARNED_SLOPE updated_at = dt_util.now() if dt_util is not None else datetime.now() await self._model_storage.set_cached_global_lhs(global_lhs, updated_at) # Update in-memory cache with new value for fast subsequent access self._cached_global_lhs = global_lhs _LOGGER.debug("Updated global LHS in-memory cache: %.2f °C/h", global_lhs) _LOGGER.info("Updated global LHS: %.2f °C/h", global_lhs) _LOGGER.debug("Exiting LhsLifecycleManager.update_global_lhs_from_cycles") return global_lhs except Exception as exc: _LOGGER.error("Error calculating global LHS: %s", exc) _LOGGER.debug("Exiting LhsLifecycleManager.update_global_lhs_from_cycles") return DEFAULT_LEARNED_SLOPE async def update_contextual_lhs_from_cycles( self, cycles: list[HeatingCycle], ) -> dict[int, float | None]: """Recalculate and persist contextual LHS for all hours. Lifecycle Event (triggered by HeatingCycleLifecycleManager): This is called when: - HeatingCycleLifecycleManager.startup(): Initial cycles extracted - HeatingCycleLifecycleManager.on_retention_change(): Cycles re-extracted - HeatingCycleLifecycleManager.on_24h_timer(): Cycles refreshed Cache Strategy (write operation): - **Receives cycles from**: HeatingCycleLifecycleManager - **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles) - **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, lhs, timestamp) for each hour - **Invalidates memory**: Sets _cached_contextual_lhs = {} - **Next read**: get_contextual_lhs() will reload from storage into memory per hour Args: cycles: Heating cycles used to compute contextual LHS by hour. Returns: Mapping of hour (0-23) to LHS in C/hour, or None when no data exists. """ _LOGGER.debug("Entering LhsLifecycleManager.update_contextual_lhs_from_cycles") _LOGGER.debug("Updating contextual LHS from %d cycles", len(cycles)) contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs( cycles ) # Persist non-None values with timestamp updated_at = dt_util.now() if dt_util is not None else datetime.now() persisted_count = 0 for hour, lhs_value in contextual_lhs_by_hour.items(): if lhs_value is not None: await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at) persisted_count += 1 # Update in-memory cache with new values for fast subsequent access # Only cache non-None values self._cached_contextual_lhs = { hour: lhs for hour, lhs in contextual_lhs_by_hour.items() if lhs is not None } _LOGGER.debug( "Updated contextual LHS in-memory cache for %d hours", len(self._cached_contextual_lhs) ) _LOGGER.info("Updated contextual LHS for %d hours", persisted_count) _LOGGER.debug("Exiting LhsLifecycleManager.update_contextual_lhs_from_cycles") return contextual_lhs_by_hour async def ensure_contextual_lhs_populated( self, target_hour: int, cycles: list[HeatingCycle], force_recalculate: bool = False, ) -> float: """Ensure contextual LHS is populated for a specific hour. Lazy Population Strategy: This method is called when anticipation calculation needs LHS for a specific hour but it's not in the cache (memory or storage). Cache Strategy (write operation with lazy loading): - **If force_recalculate=True**: Skip cache checks, recompute immediately - **Reads from memory first**: Checks _cached_contextual_lhs[target_hour] - **On memory cache hit**: Returns immediately (fast path) - **Reads from storage**: Tries model_storage.get_cached_contextual_lhs(hour) - **On storage cache hit**: Loads into memory, returns - **On cache miss**: Computes from provided cycles - **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value, timestamp) - **Writes to memory**: Updates _cached_contextual_lhs[hour] - **Fallback**: Returns global LHS if no contextual data exists Use Case: Called during anticipation calculations when: - Cache is cold (first run after startup) - Hour-specific LHS was never calculated - Explicit refresh requested (force_recalculate=True) Args: target_hour: Hour to ensure LHS is populated for (0-23). cycles: Heating cycles to use if computation is needed. force_recalculate: If True, bypass cache and recompute from cycles. Returns: Contextual LHS for the target hour in C/hour, or global LHS fallback. """ _LOGGER.debug("Entering LhsLifecycleManager.ensure_contextual_lhs_populated") _LOGGER.debug( "Ensuring contextual LHS populated for hour %d (force_recalculate=%s)", target_hour, force_recalculate, ) # If force_recalculate, skip cache checks and recompute THIS HOUR ONLY if force_recalculate: _LOGGER.debug("Force recalculate enabled, bypassing cache for hour %d", target_hour) computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour( cycles, target_hour ) # Persist if value exists if computed_lhs is not None: updated_at = dt_util.now() if dt_util is not None else datetime.now() await self._model_storage.set_cached_contextual_lhs( target_hour, computed_lhs, updated_at ) # Update memory cache self._cached_contextual_lhs[target_hour] = computed_lhs _LOGGER.debug( "Forced recalculation: contextual LHS for hour %d: %.2f °C/h", target_hour, computed_lhs, ) _LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated") return computed_lhs # No contextual data, fallback to global LHS _LOGGER.debug("No contextual LHS for hour %d, falling back to global", target_hour) global_lhs = await self.get_global_lhs() _LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated") return global_lhs # Check memory cache first (fast path) if target_hour in self._cached_contextual_lhs: cached_value = self._cached_contextual_lhs[target_hour] _LOGGER.debug( "Contextual LHS already in memory cache for hour %d: %.2f °C/h", target_hour, cached_value, ) _LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated") return cached_value # Check storage cache cached_entry = await self._model_storage.get_cached_contextual_lhs(target_hour) if cached_entry is not None: cached_contextual = cached_entry.value # Load into memory cache for subsequent calls self._cached_contextual_lhs[target_hour] = cached_contextual _LOGGER.debug( "Loaded contextual LHS from storage for hour %d: %.2f °C/h", target_hour, cached_contextual, ) _LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated") return cached_contextual # No cache, compute contextual LHS for THIS HOUR ONLY (not all 24) _LOGGER.debug("No cached contextual LHS for hour %d, computing from cycles", target_hour) computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour( cycles, target_hour ) # If contextual LHS exists, persist and cache if computed_lhs is not None: updated_at = dt_util.now() if dt_util is not None else datetime.now() await self._model_storage.set_cached_contextual_lhs( target_hour, computed_lhs, updated_at ) # Cache in memory for subsequent calls self._cached_contextual_lhs[target_hour] = computed_lhs _LOGGER.debug( "Computed and cached contextual LHS for hour %d: %.2f °C/h", target_hour, computed_lhs, ) _LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated") return computed_lhs # No contextual data, fallback to global LHS _LOGGER.debug("No contextual LHS for hour %d, falling back to global LHS", target_hour) global_lhs = await self.get_global_lhs() _LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated") return global_lhs