753 lines
34 KiB
Python
753 lines
34 KiB
Python
"""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
|