New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,7 @@
"""Application layer - use case orchestration."""
from __future__ import annotations
from .orchestrator import HeatingOrchestrator
__all__ = ["HeatingOrchestrator"]
@@ -0,0 +1,191 @@
"""Factory for HeatingCycleLifecycleManager - wires dependencies with DDD compliance."""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import TYPE_CHECKING
from ..domain.interfaces.device_config_reader_interface import DeviceConfig
from ..domain.interfaces.historical_data_adapter_interface import IHistoricalDataAdapter
from ..infrastructure.adapters.climate_data_reader import HAClimateDataReader
from ..infrastructure.adapters.entity_attribute_mapper_registry import (
EntityAttributeMapperRegistry,
)
from ..infrastructure.adapters.vtherm_attribute_mapper import VThermAttributeMapper
from ..infrastructure.recorder_queue import get_extraction_semaphore, get_recorder_queue
from .heating_cycle_lifecycle_manager import HeatingCycleLifecycleManager
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from ..domain.interfaces import IHeatingCycleStorage, ILhsStorage, ITimerScheduler
from ..domain.interfaces.heating_cycle_service_interface import IHeatingCycleService
from .lhs_lifecycle_manager import LhsLifecycleManager
_LOGGER = logging.getLogger(__name__)
class HeatingCycleLifecycleManagerFactory:
"""Factory for creating HeatingCycleLifecycleManager with singleton pattern.
Singleton Pattern:
- **One instance per device_id**: Each IHP device gets its own manager instance
- **Shared across requests**: Multiple calls with same device_id return same instance
- **Thread-safe**: Factory maintains internal registry to track instances
Dependency Wiring:
- Injects all required dependencies (heating_cycle_service, adapters, caches)
- Optionally injects LhsLifecycleManager for cascade updates
- Ensures DDD compliance (domain services, infrastructure adapters)
Usage:
```python
# First call creates instance
manager1 = factory.create(hass, device_config, ...)
# Second call with same device_id returns same instance
manager2 = factory.create(hass, device_config, ...)
assert manager1 is manager2 # True
```
"""
# Class-level registry: device_id -> HeatingCycleLifecycleManager instance
_instances: dict[str, HeatingCycleLifecycleManager] = {}
@classmethod
def create(
cls,
hass: HomeAssistant,
device_config: DeviceConfig,
heating_cycle_service: IHeatingCycleService,
cycle_cache: IHeatingCycleStorage | None = None,
timer_scheduler: ITimerScheduler | None = None,
model_storage: ILhsStorage | None = None,
lhs_lifecycle_manager: LhsLifecycleManager | None = None,
dead_time_updated_callback: Callable[[float], None] | None = None,
on_extraction_complete_callback: Callable[[], None] | None = None,
) -> HeatingCycleLifecycleManager:
"""Create or return existing HeatingCycleLifecycleManager for device_id.
Singleton Behavior:
- If instance exists for device_config.device_id, returns existing instance
- If no instance exists, creates new one and stores in registry
- Registry is class-level, shared across all factory instances
Dependency Injection:
- heating_cycle_service: Domain service for extracting cycles from historical data
- cycle_cache: Infrastructure adapter for persistent cycle storage
- timer_scheduler: Infrastructure adapter for scheduling 24h refresh
- model_storage: Infrastructure adapter for persisting individual cycles
- lhs_lifecycle_manager: Application service for cascade LHS updates
Args:
hass: Home Assistant instance (used for historical data adapters).
device_config: Device configuration (contains device_id for singleton key).
heating_cycle_service: Service for extracting heating cycles.
cycle_cache: Optional persistent cache for incremental cycle storage.
timer_scheduler: Optional scheduler for periodic 24h refresh.
model_storage: Optional persistent storage for individual cycle records.
lhs_lifecycle_manager: Optional LHS manager for cascade updates.
dead_time_updated_callback: Optional callback fired after dead time persistence.
on_extraction_complete_callback: Optional callback fired after cycle extraction completes.
Returns:
Singleton HeatingCycleLifecycleManager instance for the device_id.
"""
device_id = device_config.device_id
# Check if instance already exists
if device_id in cls._instances:
_LOGGER.debug(
"Returning existing HeatingCycleLifecycleManager for device_id=%s", device_id
)
return cls._instances[device_id]
# Create new instance
_LOGGER.debug("Creating new HeatingCycleLifecycleManager for device_id=%s", device_id)
# Wire historical data adapters from hass infrastructure
# Dynamically detect entity type (VTherm vs generic climate) for diagnostics
_LOGGER.debug(
"Setting up historical data adapters for device_id=%s", device_config.device_id
)
vtherm_entity_id = device_config.vtherm_entity_id
_LOGGER.debug("Configured VTherm entity_id: %s", vtherm_entity_id)
# Detect entity type (VTherm or generic climate) for diagnostic logging
entity_type = cls._detect_entity_type(hass, vtherm_entity_id)
_LOGGER.info(
"Detected entity type for %s: %s (auto-detection)",
vtherm_entity_id,
entity_type,
)
climate_adapter = HAClimateDataReader(hass, get_recorder_queue(hass), vtherm_entity_id)
historical_adapters: list[IHistoricalDataAdapter] = [climate_adapter]
_LOGGER.debug(
"Configured %d historical adapter(s) for device_id=%s with dynamic entity detection",
len(historical_adapters),
device_config.device_id,
)
manager = HeatingCycleLifecycleManager(
device_config=device_config,
heating_cycle_service=heating_cycle_service,
historical_adapters=historical_adapters,
heating_cycle_storage=cycle_cache,
timer_scheduler=timer_scheduler,
lhs_storage=model_storage,
lhs_lifecycle_manager=lhs_lifecycle_manager,
dead_time_updated_callback=dead_time_updated_callback,
extraction_semaphore=get_extraction_semaphore(hass),
on_extraction_complete_callback=on_extraction_complete_callback,
)
# Store in registry
cls._instances[device_id] = manager
_LOGGER.debug("Registered HeatingCycleLifecycleManager for device_id=%s", device_id)
return manager
@classmethod
def _detect_entity_type(cls, hass: HomeAssistant, vtherm_entity_id: str) -> str:
"""Detect the type of climate entity (VTherm or generic climate).
Uses EntityAttributeMapperRegistry to auto-detect the entity type.
Used for diagnostic logging and validation.
Args:
hass: Home Assistant instance
vtherm_entity_id: Entity ID to detect
Returns:
String describing the entity type (e.g., "VTherm v8.0+", "Generic Climate")
"""
try:
mapper_registry = EntityAttributeMapperRegistry(hass)
mapper = mapper_registry.get_mapper_for_entity(vtherm_entity_id)
# Identify mapper type for logging
if isinstance(mapper, VThermAttributeMapper):
return "VTherm (Versatile Thermostat)"
return "Generic Climate"
except ValueError as e:
_LOGGER.warning(
"Could not detect entity type for %s: %s",
vtherm_entity_id,
str(e),
)
return "Unknown"
@classmethod
def reset_instances(cls) -> None:
"""Clear singleton registry (for testing only).
Use Case:
Called in test teardown to ensure clean state between tests.
Should NOT be used in production code.
"""
cls._instances = {}
_LOGGER.debug("Reset HeatingCycleLifecycleManager singleton registry")
@@ -0,0 +1,752 @@
"""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
@@ -0,0 +1,113 @@
"""Factory for LhsLifecycleManager - wires dependencies with DDD compliance."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .lhs_lifecycle_manager import LhsLifecycleManager
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__)
class LhsLifecycleManagerFactory:
"""Factory for creating LhsLifecycleManager with singleton pattern.
Singleton Pattern:
- **One instance per device_id**: Each IHP device gets its own manager instance
- **Shared across requests**: Multiple calls with same device_id return same instance
- **Thread-safe**: Factory maintains internal registry to track instances
Note on device_id:
Since LhsLifecycleManager doesn't directly hold device_id, the singleton key
is derived from the model_storage instance (assuming one storage per device).
For proper device-level isolation, ensure each device has its own ILhsStorage.
Dependency Wiring:
- Injects all required dependencies (calculators, storage, scheduler)
- Ensures DDD compliance (domain services, infrastructure adapters)
Usage:
```python
# First call creates instance
manager1 = factory.create(storage, global_calc, contextual_calc, ...)
# Second call with same storage returns same instance
manager2 = factory.create(storage, global_calc, contextual_calc, ...)
assert manager1 is manager2 # True
```
"""
# Class-level registry: storage_id -> LhsLifecycleManager instance
# Using id(model_storage) as key since LHS manager doesn't have device_id directly
_instances: dict[int, LhsLifecycleManager] = {}
@classmethod
def create(
cls,
model_storage: ILhsStorage,
global_lhs_calculator: GlobalLHSCalculatorService,
contextual_lhs_calculator: ContextualLHSCalculatorService,
timer_scheduler: ITimerScheduler | None = None,
) -> LhsLifecycleManager:
"""Create or return existing LhsLifecycleManager for model_storage.
Singleton Behavior:
- Uses id(model_storage) as singleton key (assumes one storage per device)
- If instance exists for this storage, returns existing instance
- If no instance exists, creates new one and stores in registry
- Registry is class-level, shared across all factory instances
Dependency Injection:
- model_storage: Infrastructure adapter for persistent LHS storage
- global_lhs_calculator: Domain service for computing global LHS from cycles
- contextual_lhs_calculator: Domain service for computing contextual LHS by hour
- timer_scheduler: Infrastructure adapter for scheduling 24h refresh
Args:
model_storage: Persistent storage adapter for cached LHS values.
global_lhs_calculator: Service for computing global LHS.
contextual_lhs_calculator: Service for computing contextual LHS by hour.
timer_scheduler: Optional scheduler for periodic 24h refresh.
Returns:
Singleton LhsLifecycleManager instance for the model_storage.
"""
storage_id = id(model_storage)
# Check if instance already exists
if storage_id in cls._instances:
_LOGGER.debug("Returning existing LhsLifecycleManager for storage_id=%s", storage_id)
return cls._instances[storage_id]
# Create new instance
_LOGGER.debug("Creating new LhsLifecycleManager for storage_id=%s", storage_id)
manager = LhsLifecycleManager(
model_storage=model_storage,
global_lhs_calculator=global_lhs_calculator,
contextual_lhs_calculator=contextual_lhs_calculator,
timer_scheduler=timer_scheduler,
)
# Store in registry
cls._instances[storage_id] = manager
_LOGGER.debug("Registered LhsLifecycleManager for storage_id=%s", storage_id)
return manager
@classmethod
def reset_instances(cls) -> None:
"""Clear singleton registry (for testing only).
Use Case:
Called in test teardown to ensure clean state between tests.
Should NOT be used in production code.
"""
cls._instances = {}
_LOGGER.debug("Reset LhsLifecycleManager singleton registry")
@@ -0,0 +1,185 @@
"""Heating Orchestrator.
The orchestrator composes use cases to implement complex workflows.
It coordinates multiple use cases but contains NO business logic itself.
This is the main entry point for heating operations from the infrastructure layer.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .use_cases import (
CalculateAnticipationUseCase,
CheckOvershootRiskUseCase,
ControlPreheatingUseCase,
ScheduleAnticipationActionUseCase,
UpdateCacheDataUseCase,
)
if TYPE_CHECKING:
from datetime import datetime
from typing import Any
_LOGGER = logging.getLogger(__name__)
class HeatingOrchestrator:
"""Orchestrates heating use cases.
The orchestrator is responsible for:
1. Composing use cases to implement workflows
2. Coordinating the execution order of use cases
3. Managing cross-use-case dependencies
NO business logic - pure composition and coordination.
"""
def __init__(
self,
calculate_anticipation: CalculateAnticipationUseCase,
control_preheating: ControlPreheatingUseCase,
schedule_anticipation_action: ScheduleAnticipationActionUseCase,
check_overshoot_risk: CheckOvershootRiskUseCase,
update_cache: UpdateCacheDataUseCase,
) -> None:
"""Initialize the orchestrator with use cases.
Args:
calculate_anticipation: Use case for calculating anticipation data
control_preheating: Use case for controlling preheating
schedule_anticipation_action: Use case for scheduling anticipation actions
check_overshoot_risk: Use case for checking overshoot risk
update_cache: Use case for updating cache
"""
_LOGGER.debug("Initializing HeatingOrchestrator")
self._calculate_anticipation = calculate_anticipation
self._control_preheating = control_preheating
self._schedule_anticipation_action = schedule_anticipation_action
self._check_overshoot_risk = check_overshoot_risk
self._update_cache = update_cache
async def calculate_anticipation_only(
self,
target_time: datetime | None = None,
target_temp: float | None = None,
) -> dict:
"""Calculate anticipation data only (no scheduling).
For users who use the service without a scheduler (via REST API).
Args:
target_time: Target time for heating
target_temp: Target temperature
Returns:
Dict with anticipation data
"""
_LOGGER.debug("Entering HeatingOrchestrator.calculate_anticipation_only()")
result = await self._calculate_anticipation.calculate_anticipation_datas(
target_time=target_time,
target_temp=target_temp,
)
_LOGGER.debug("Exiting calculate_anticipation_only() -> data")
return result
async def disable_preheating(self, scheduler_entity_id: str) -> None:
"""Disable preheating (cancel timer and active preheating).
Args:
scheduler_entity_id: Scheduler entity to cancel
"""
_LOGGER.debug("Entering HeatingOrchestrator.disable_preheating()")
# Delegate to schedule_anticipation_action to cancel timer
await self._schedule_anticipation_action.cancel_action()
# Cancel active preheating if any
await self._control_preheating.cancel_preheating(scheduler_entity_id)
_LOGGER.info("Preheating disabled")
_LOGGER.debug("Exiting HeatingOrchestrator.disable_preheating()")
async def reset_all_learning_data(self, device_id: str) -> None:
"""Reset all learned data (LHS + cycles).
Args:
device_id: Device identifier
"""
_LOGGER.debug("Entering HeatingOrchestrator.reset_all_learning_data()")
await self._update_cache.reset_cache(device_id)
_LOGGER.debug("Exiting HeatingOrchestrator.reset_all_learning_data()")
def is_preheating_active(self) -> bool:
"""Check if preheating is currently active.
Returns:
True if preheating is active, False otherwise
"""
return self._control_preheating.is_preheating_active()
async def calculate_and_schedule_anticipation(self, ihp_enabled: bool = True) -> dict[str, Any]:
"""Calculate anticipation and handle scheduling based on IHP status.
This orchestrator method:
1. Calculates anticipation data
2. Delegates scheduling decisions to ScheduleAnticipationActionUseCase
3. Always returns anticipation data structure
Args:
ihp_enabled: Whether IHP preheating is enabled
Returns:
Dict with anticipation data for sensors (always returns structure)
"""
_LOGGER.debug(
"Entering HeatingOrchestrator.calculate_and_schedule_anticipation(ihp_enabled=%s)",
ihp_enabled,
)
# Step 1: Always calculate anticipation data regardless of IHP state.
# The switch only controls whether preheating is scheduled, not whether
# calculations happen. This ensures sensors and LHS caches stay up-to-date.
anticipation_data = await self._calculate_anticipation.calculate_anticipation_datas()
# Step 2: Delegate scheduling decisions to use case (respects ihp_enabled)
await self._schedule_anticipation_action.handle_anticipation_scheduling(
anticipation_data,
ihp_enabled,
)
_LOGGER.debug("Exiting HeatingOrchestrator.calculate_and_schedule_anticipation() -> data")
return anticipation_data
async def check_and_prevent_overshoot(self, scheduler_entity_id: str) -> bool:
"""Check overshoot risk and cancel preheating if needed.
Args:
scheduler_entity_id: Scheduler entity to cancel if overshoot detected
Returns:
True if overshoot detected and cancellation triggered, False otherwise
"""
_LOGGER.debug(
"Entering HeatingOrchestrator.check_and_prevent_overshoot(scheduler=%s)",
scheduler_entity_id,
)
overshoot_detected = await self._check_overshoot_risk.check_and_prevent_overshoot(
scheduler_entity_id=scheduler_entity_id
)
if overshoot_detected:
await self._schedule_anticipation_action.cancel_action()
_LOGGER.debug(
"Exiting HeatingOrchestrator.check_and_prevent_overshoot() -> %s",
overshoot_detected,
)
return overshoot_detected
@@ -0,0 +1,24 @@
"""Use cases for Intelligent Heating Pilot.
This package contains use cases that encapsulate single business operations.
Each use case is a focused, testable unit of business logic.
Use cases follow the Single Responsibility Principle and are composed by
the HeatingOrchestrator to implement complex workflows.
"""
from __future__ import annotations
from .calculate_anticipation_use_case import CalculateAnticipationUseCase
from .check_overshoot_risk_use_case import CheckOvershootRiskUseCase
from .control_preheating_use_case import ControlPreheatingUseCase
from .schedule_anticipation_action_use_case import ScheduleAnticipationActionUseCase
from .update_cache_data_use_case import UpdateCacheDataUseCase
__all__ = [
"CalculateAnticipationUseCase",
"CheckOvershootRiskUseCase",
"ControlPreheatingUseCase",
"ScheduleAnticipationActionUseCase",
"UpdateCacheDataUseCase",
]
@@ -0,0 +1,314 @@
"""Calculate Anticipation Data Use Case.
This use case calculates the anticipated start time for preheating
based on current conditions and learned heating slopes.
This is a PURE CALCULATION use case - it does NOT schedule or trigger preheating.
For scheduling, use SchedulePreheatingUseCase.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import (
IClimateDataReader,
IEnvironmentReader,
ILhsStorage,
ISchedulerReader,
)
from ...domain.services import DeadTimeCalculationService, PredictionService
from ..heating_cycle_lifecycle_manager import HeatingCycleLifecycleManager
from ..lhs_lifecycle_manager import LhsLifecycleManager
_LOGGER = logging.getLogger(__name__)
class CalculateAnticipationUseCase:
"""Use case for calculating anticipation data (NO preheating scheduling).
This use case encapsulates the pure calculation logic for:
1. Reading the next scheduled timeslot (or using provided target_time)
2. Getting heating cycles for LHS calculation
3. Calculating the anticipated start time based on learned heating slope
4. Returning anticipation data for display/sensors
This does NOT schedule or trigger any preheating action.
"""
def __init__(
self,
scheduler_reader: ISchedulerReader | None,
environment_reader: IEnvironmentReader,
climate_data_reader: IClimateDataReader,
heating_cycle_manager: HeatingCycleLifecycleManager,
lhs_lifecycle_manager: LhsLifecycleManager,
prediction_service: PredictionService,
dead_time_calculator: DeadTimeCalculationService,
auto_learning: bool = True,
default_dead_time_minutes: float = 0.0,
lhs_storage: ILhsStorage | None = None,
) -> None:
"""Initialize the use case.
Args:
scheduler_reader: Reads scheduled timeslots (optional for API-only usage)
environment_reader: Reads current environment conditions
climate_data_reader: Unified reader for VTherm metadata, slope and heating state
heating_cycle_manager: Manages heating cycle lifecycle
lhs_lifecycle_manager: Manages learned heating slopes
prediction_service: Predicts heating time
dead_time_calculator: Calculates dead time from cycles
auto_learning: Whether auto-learning is enabled
default_dead_time_minutes: Default dead time when not learned
lhs_storage: Optional persistent storage for learned values. When provided
and auto_learning is True, the stored learned dead time is used as a
fallback before the configured default, so that the persisted value
is restored immediately after a Home Assistant restart.
"""
_LOGGER.debug("Initializing CalculateAnticipationUseCase")
self._scheduler_reader = scheduler_reader
self._environment_reader = environment_reader
self._climate_data_reader = climate_data_reader
self._heating_cycle_manager = heating_cycle_manager
self._lhs_manager = lhs_lifecycle_manager
self._prediction_service = prediction_service
self._dead_time_calculator = dead_time_calculator
self._auto_learning = auto_learning
self._default_dead_time_minutes = default_dead_time_minutes
self._lhs_storage = lhs_storage
async def calculate_anticipation_datas(
self,
target_time: datetime | None = None,
target_temp: float | None = None,
) -> dict:
"""Calculate anticipation data without scheduling preheating.
Args:
target_time: Target time for heating. If None, uses next scheduled timeslot.
target_temp: Target temperature. If None, uses value from timeslot.
Returns:
Dict with anticipation data. Returns structure with None values for fields
that cannot be calculated.
"""
_LOGGER.debug(
"Entering CalculateAnticipationUseCase.calculate_anticipation_datas(target_time=%s, target_temp=%s)",
target_time.isoformat() if target_time else "None",
target_temp,
)
# Import default constant for LHS validation
from ...domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
# Determine target time and temp
timeslot = None
scheduler_entity = None
timeslot_id = None
# Always get environment data (for minimal return structure)
environment = await self._environment_reader.get_current_environment()
current_temp = environment.indoor_temperature if environment else None
# Always get global LHS (for minimal return structure)
global_lhs = await self._lhs_manager.get_global_lhs()
# Validate global LHS: must be realistically positive (>= 0.5°C/h)
if global_lhs is None or global_lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Invalid global LHS (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
global_lhs or 0,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
global_lhs = DEFAULT_LEARNED_SLOPE
if target_time is None:
# Use scheduler to get next timeslot
if self._scheduler_reader is None:
_LOGGER.debug("No scheduler reader configured and no target_time provided")
# Return minimal data structure
return self._create_data_structure(
current_temp=current_temp,
learned_heating_slope=global_lhs,
)
timeslot = await self._scheduler_reader.get_next_timeslot()
if not timeslot:
_LOGGER.debug("No scheduled timeslot found")
# Return minimal data structure
return self._create_data_structure(
current_temp=current_temp,
learned_heating_slope=global_lhs,
)
target_time = timeslot.target_time
target_temp = timeslot.target_temp
scheduler_entity = timeslot.scheduler_entity
timeslot_id = timeslot.timeslot_id
else:
# Using provided target_time (API/REST usage without scheduler)
if target_temp is None:
_LOGGER.warning("target_time provided but target_temp is None")
# Return minimal data structure
return self._create_data_structure(
current_temp=current_temp,
learned_heating_slope=global_lhs,
)
# Get remaining environment data
outdoor_temp = environment.outdoor_temp if environment else None
humidity = environment.indoor_humidity if environment else None
cloud_coverage = environment.cloud_coverage if environment else None
# Get device ID
vtherm_id = self._climate_data_reader.get_vtherm_entity_id()
# Get heating cycles for LHS calculation
heating_cycles = await self._heating_cycle_manager.get_cycles_for_target_time(
device_id=vtherm_id,
target_time=target_time,
)
# Get contextual LHS
lhs = await self._lhs_manager.get_contextual_lhs(
target_time=target_time,
cycles=heating_cycles,
)
# Validate LHS: must be realistically positive (>= 0.5°C/h)
if lhs is None or lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Invalid contextual LHS (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
lhs or 0,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
lhs = DEFAULT_LEARNED_SLOPE
# Calculate effective dead_time
if self._auto_learning and heating_cycles:
avg_dead_time = self._dead_time_calculator.calculate_average_dead_time(heating_cycles)
if avg_dead_time is not None and avg_dead_time > 0:
dead_time = avg_dead_time
_LOGGER.info(
"Learned dead_time from %d cycles: %.1f minutes",
len(heating_cycles),
dead_time,
)
else:
dead_time = await self._get_persisted_dead_time_or_default()
elif self._auto_learning:
# No cycles available yet (e.g. immediately after restart before extraction).
# Fall back to the persisted learned dead time so the correct value is used
# before the first cycle extraction completes.
dead_time = await self._get_persisted_dead_time_or_default()
else:
dead_time = self._default_dead_time_minutes
# Calculate prediction - let prediction_service handle None values
prediction = self._prediction_service.predict_heating_time(
current_temp=current_temp, # Pass None if unavailable
target_temp=target_temp,
outdoor_temp=outdoor_temp,
humidity=humidity,
learned_slope=lhs,
target_time=target_time,
cloud_coverage=cloud_coverage,
dead_time_minutes=dead_time,
)
_LOGGER.info(
"Calculated anticipation: start at %s (%.1f min) for target %.1f°C at %s (LHS: %.2f°C/h)",
prediction.anticipated_start_time.isoformat(),
prediction.estimated_duration_minutes,
target_temp,
target_time.isoformat(),
prediction.learned_heating_slope,
)
result = self._create_data_structure(
anticipated_start_time=prediction.anticipated_start_time,
next_schedule_time=target_time,
next_target_temperature=target_temp,
anticipation_minutes=prediction.estimated_duration_minutes,
current_temp=current_temp,
learned_heating_slope=prediction.learned_heating_slope,
confidence_level=prediction.confidence_level,
timeslot_id=timeslot_id,
scheduler_entity=scheduler_entity,
dead_time=dead_time,
)
_LOGGER.debug("Exiting calculate_anticipation_datas() -> %s", "data")
return result
def _create_data_structure(
self,
anticipated_start_time: datetime | None = None,
next_schedule_time: datetime | None = None,
next_target_temperature: float | None = None,
anticipation_minutes: float | None = None,
current_temp: float | None = None,
learned_heating_slope: float | None = None,
confidence_level: float | None = None,
timeslot_id: str | None = None,
scheduler_entity: str | None = None,
dead_time: float | None = None,
) -> dict:
"""Create data structure with provided values or None defaults.
Returns consistent structure with each field having a value or None.
"""
return {
"anticipated_start_time": anticipated_start_time,
"next_schedule_time": next_schedule_time,
"next_target_temperature": next_target_temperature,
"anticipation_minutes": anticipation_minutes,
"current_temp": current_temp,
"learned_heating_slope": learned_heating_slope,
"confidence_level": confidence_level,
"timeslot_id": timeslot_id,
"scheduler_entity": scheduler_entity,
"dead_time": dead_time,
}
async def _get_persisted_dead_time_or_default(self) -> float:
"""Return the persisted learned dead time or the configured default.
When auto_learning is enabled, this method tries to restore the last
learned dead time from persistent storage. This is the correct fallback
during startup (before cycle extraction completes) or when cycles do not
yield a valid dead time.
Returns:
Persisted learned dead time if available and positive, otherwise the
configured default dead time.
"""
if self._lhs_storage is not None:
try:
stored = await self._lhs_storage.get_learned_dead_time()
if stored is not None and stored > 0:
_LOGGER.debug(
"Using persisted learned dead_time: %.1f minutes", stored
)
return stored
if stored is not None and stored <= 0:
_LOGGER.debug(
"Ignoring non-positive persisted dead_time %.1f minutes; "
"falling back to configured default",
stored,
)
except Exception: # noqa: BLE001
_LOGGER.warning("Failed to read persisted dead time", exc_info=True)
_LOGGER.debug(
"No persisted dead_time found, using configured default: %.1f minutes",
self._default_dead_time_minutes,
)
return self._default_dead_time_minutes
@@ -0,0 +1,133 @@
"""Check Overshoot Risk Use Case.
This use case detects when heating will overshoot the target temperature
and cancels preheating to avoid overheating.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ...domain.interfaces import (
IClimateDataReader,
IEnvironmentReader,
ISchedulerReader,
)
from .control_preheating_use_case import ControlPreheatingUseCase
_LOGGER = logging.getLogger(__name__)
class CheckOvershootRiskUseCase:
"""Use case for detecting overshoot risk.
This use case encapsulates the logic for:
1. Reading current environment and slope
2. Estimating temperature at target time
3. Canceling preheating if overshoot risk is detected
"""
def __init__(
self,
scheduler_reader: ISchedulerReader,
environment_reader: IEnvironmentReader,
climate_data_reader: IClimateDataReader,
control_preheating: ControlPreheatingUseCase,
overshoot_threshold_celsius: float = 0.5,
) -> None:
"""Initialize the use case.
Args:
scheduler_reader: Reads scheduled timeslots
environment_reader: Reads current environment conditions
climate_data_reader: Reads current heating slope
control_preheating: Cancels preheating when risk detected
overshoot_threshold_celsius: Temperature margin above target to consider as overshoot (°C)
"""
_LOGGER.debug(
"Initializing CheckOvershootRiskUseCase with threshold %.1f°C",
overshoot_threshold_celsius,
)
self._scheduler_reader = scheduler_reader
self._environment_reader = environment_reader
self._climate_data_reader = climate_data_reader
self._control_preheating = control_preheating
self._overshoot_threshold = overshoot_threshold_celsius
async def check_and_prevent_overshoot(self, scheduler_entity_id: str) -> bool:
"""Check for overshoot risk and cancel preheating if needed.
Args:
scheduler_entity_id: Scheduler entity tied to preheating
Returns:
True if overshoot detected and preheating canceled, False otherwise
"""
_LOGGER.debug(
"Entering CheckOvershootRiskUseCase.check_and_prevent_overshoot(scheduler=%s)",
scheduler_entity_id,
)
if not self._control_preheating.is_preheating_active():
_LOGGER.debug("Skipping overshoot check - preheating not active")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
timeslot = await self._scheduler_reader.get_next_timeslot()
if not timeslot:
_LOGGER.debug("Skipping overshoot check - no timeslot available")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
environment = await self._environment_reader.get_current_environment()
if not environment:
# Safety first: If we can't read temperature, assume overshoot risk
_LOGGER.warning("No environment data available - assuming overshoot risk for safety")
await self._control_preheating.cancel_preheating(scheduler_entity_id)
_LOGGER.info("Cancelled preheating due to missing environment data")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> True")
return True
current_slope = self._climate_data_reader.get_current_slope()
if current_slope is None or current_slope <= 0.0:
# Cannot check overshoot without valid slope data
_LOGGER.debug(
"Current slope unavailable (%.2f°C/h) - skipping overshoot check",
current_slope or 0.0,
)
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
now = environment.timestamp
if now >= timeslot.target_time:
_LOGGER.debug("Skipping overshoot check - target time already passed")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
time_to_target_hours = (timeslot.target_time - now).total_seconds() / 3600.0
projected_temp = environment.indoor_temperature + (current_slope * time_to_target_hours)
overshoot_limit = timeslot.target_temp + self._overshoot_threshold
_LOGGER.debug(
"Overshoot check: current=%.1f°C projected=%.1f°C target=%.1f°C limit=%.1f°C",
environment.indoor_temperature,
projected_temp,
timeslot.target_temp,
overshoot_limit,
)
if projected_temp >= overshoot_limit:
_LOGGER.warning(
"Overshoot risk detected: projected %.1f°C exceeds limit %.1f°C",
projected_temp,
overshoot_limit,
)
await self._control_preheating.cancel_preheating(scheduler_entity_id)
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> True")
return True
_LOGGER.debug("No overshoot risk detected")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
@@ -0,0 +1,137 @@
"""Control Preheating Use Case.
This use case controls the preheating state (start/cancel).
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import ISchedulerCommander
_LOGGER = logging.getLogger(__name__)
class ControlPreheatingUseCase:
"""Use case for controlling preheating state.
This use case encapsulates operations for:
1. Starting preheating (trigger scheduler action)
2. Canceling preheating (revert to current scheduled state)
3. Tracking preheating state
"""
def __init__(
self,
scheduler_commander: ISchedulerCommander,
) -> None:
"""Initialize the use case.
Args:
scheduler_commander: Commands scheduler actions
"""
_LOGGER.debug("Initializing ControlPreheatingUseCase")
self._scheduler_commander = scheduler_commander
self._is_preheating_active = False
self._preheating_target_time: datetime | None = None
self._active_scheduler_entity: str | None = None
async def cancel_preheating(self, scheduler_entity_id: str | None = None) -> None:
"""Cancel active preheating and revert to current scheduled state.
Args:
scheduler_entity_id: Scheduler entity to cancel action on.
If None, uses the active scheduler entity.
"""
_LOGGER.debug(
"Entering ControlPreheatingUseCase.cancel_preheating(scheduler=%s)",
scheduler_entity_id,
)
effective_scheduler = scheduler_entity_id or self._active_scheduler_entity
if self._is_preheating_active:
if not effective_scheduler:
_LOGGER.warning("Cannot cancel preheating: no scheduler entity available")
_LOGGER.debug("Exiting ControlPreheatingUseCase.cancel_preheating() -> no-op")
return
_LOGGER.info(
"Canceling preheating for scheduler %s - reverting to current scheduled state",
effective_scheduler,
)
# Call cancel_action to revert thermostat to current time's preset/temperature
# Validation is handled by scheduler_commander
await self._scheduler_commander.cancel_action(effective_scheduler)
else:
_LOGGER.debug("No active preheating to cancel")
# Mark preheating as inactive (but keep target_time for state tracking)
# Target time may still be in future (e.g., cancelled due to overshoot)
self._is_preheating_active = False
# Note: _preheating_target_time and _active_scheduler_entity are NOT cleared
# They remain for state tracking and potential restart
_LOGGER.debug("Exiting ControlPreheatingUseCase.cancel_preheating()")
async def start_preheating(
self,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
) -> None:
"""Start preheating by triggering scheduler action.
Args:
target_time: Target schedule time
target_temp: Target temperature
scheduler_entity_id: Scheduler entity to trigger
"""
_LOGGER.debug(
"Entering ControlPreheatingUseCase.start_preheating(target_time=%s, temp=%.1f, scheduler=%s)",
target_time.isoformat(),
target_temp,
scheduler_entity_id,
)
_LOGGER.info(
"Starting preheating for target %s (%.1f°C)",
target_time.isoformat(),
target_temp,
)
# Use scheduler's run_action to trigger the action
await self._scheduler_commander.run_action(target_time, scheduler_entity_id)
# Mark pre-heating as active
self._is_preheating_active = True
self._preheating_target_time = target_time
self._active_scheduler_entity = scheduler_entity_id
_LOGGER.debug("Exiting ControlPreheatingUseCase.start_preheating()")
def is_preheating_active(self) -> bool:
"""Check if preheating is currently active.
Returns:
True if preheating is active, False otherwise
"""
return self._is_preheating_active
def get_preheating_target_time(self) -> datetime | None:
"""Get the target time for active preheating.
Returns:
Target time if preheating is active, None otherwise
"""
return self._preheating_target_time
def get_active_scheduler_entity(self) -> str | None:
"""Get the active scheduler entity.
Returns:
Scheduler entity ID if active, None otherwise
"""
return self._active_scheduler_entity
@@ -0,0 +1,381 @@
"""Schedule Anticipation Action Use Case.
This use case encapsulates the complex logic for scheduling preheating based on
anticipation calculations, including revert logic when conditions change.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Callable
from homeassistant.util import dt as dt_util
from ...const import DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import ISchedulerCommander, ISchedulerReader, ITimerScheduler
_LOGGER = logging.getLogger(__name__)
class ScheduleAnticipationActionUseCase:
"""Use case for scheduling anticipation actions.
This use case encapsulates the complex logic for:
1. Checking scheduler state
2. Handling revert logic when anticipated start is postponed significantly
3. Scheduling preheating timers
4. Triggering immediate preheating when needed
"""
def __init__(
self,
scheduler_reader: ISchedulerReader,
scheduler_commander: ISchedulerCommander,
timer_scheduler: ITimerScheduler,
control_preheating_use_case, # ControlPreheatingUseCase (avoid circular import)
anticipation_recalc_tolerance_minutes: int = DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
) -> None:
"""Initialize the use case.
Args:
scheduler_reader: Reads scheduler state
scheduler_commander: Triggers scheduler actions
timer_scheduler: Schedules timer callbacks
control_preheating_use_case: Use case for managing preheating state
anticipation_recalc_tolerance_minutes: Min absolute delta in anticipated
start time required to cancel active preheating and reschedule
"""
_LOGGER.debug("Initializing ScheduleAnticipationActionUseCase")
self._scheduler_reader = scheduler_reader
self._scheduler_commander = scheduler_commander
self._timer_scheduler = timer_scheduler
self._control_preheating = control_preheating_use_case
# Scheduling-specific state (not preheating state - delegated to ControlPreheatingUseCase)
self._last_scheduled_time: datetime | None = None
self._last_scheduled_lhs: float | None = None
self._anticipation_timer_cancel: Callable[[], None] | None = None
self._preheating_target_temp: float | None = None # Temp only (time/active delegated)
self._anticipation_recalc_tolerance_seconds = anticipation_recalc_tolerance_minutes * 60
def set_preheating_temp(self, target_temp: float | None) -> None:
"""Update preheating target temperature.
Note: Preheating state (active, target_time) is managed by ControlPreheatingUseCase.
Args:
target_temp: Target temperature
"""
_LOGGER.debug("Setting preheating target temp: %.1f", target_temp or 0.0)
self._preheating_target_temp = target_temp
async def handle_anticipation_scheduling(
self,
anticipation_data: dict,
ihp_enabled: bool,
) -> None:
"""Handle the complete scheduling workflow based on anticipation data and IHP status.
This method contains all the business logic for deciding when to schedule/cancel
preheating based on data availability, IHP status, and current state.
Args:
anticipation_data: Calculated anticipation data
ihp_enabled: Whether IHP is enabled
"""
_LOGGER.debug(
"Entering handle_anticipation_scheduling(ihp_enabled=%s, has_data=%s)",
ihp_enabled,
anticipation_data.get("anticipated_start_time") is not None,
)
# Decision 1: No valid data - cancel everything
if anticipation_data.get("anticipated_start_time") is None:
_LOGGER.debug("No valid anticipation data - cancelling any active scheduling")
await self.cancel_action()
await self._control_preheating.cancel_preheating(
self._control_preheating.get_active_scheduler_entity()
or anticipation_data.get("scheduler_entity")
)
return
# Decision 2: IHP disabled - cancel but don't schedule
if not ihp_enabled:
_LOGGER.debug("IHP disabled - cancelling preheating if active")
await self._control_preheating.cancel_preheating(
self._control_preheating.get_active_scheduler_entity()
or anticipation_data.get("scheduler_entity")
)
await self.cancel_action()
return
# Decision 3: Target already reached (anticipation_minutes == 0) - clear state
if anticipation_data.get("anticipation_minutes") == 0:
_LOGGER.debug("Target reached - clearing anticipation state")
await self.cancel_action()
await self._control_preheating.cancel_preheating(
self._control_preheating.get_active_scheduler_entity()
or anticipation_data.get("scheduler_entity")
)
return
# Decision 4: No scheduler entity - skip scheduling
scheduler_entity = anticipation_data.get("scheduler_entity")
if not scheduler_entity:
_LOGGER.debug("No scheduler entity - skipping scheduling")
return
# Decision 5: Valid data + IHP enabled + scheduler available - schedule
await self.schedule_action(
anticipated_start=anticipation_data["anticipated_start_time"],
target_time=anticipation_data["next_schedule_time"],
target_temp=anticipation_data["next_target_temperature"],
scheduler_entity_id=scheduler_entity,
lhs=float(anticipation_data.get("learned_heating_slope") or 0.0),
)
async def schedule_action(
self,
anticipated_start: datetime,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
lhs: float,
) -> None:
"""Schedule heating anticipation action.
This method handles all the scheduling logic including:
- Checking scheduler state
- Handling revert when anticipated time changes significantly
- Scheduling timers for future starts
- Triggering immediate preheating if start is in the past
Args:
anticipated_start: When to start preheating
target_time: Target schedule time
target_temp: Target temperature
scheduler_entity_id: Scheduler entity to trigger
lhs: Learned heating slope
"""
_LOGGER.debug(
"Entering ScheduleAnticipationActionUseCase.schedule_action("
"anticipated_start=%s, scheduler=%s)",
anticipated_start.isoformat(),
scheduler_entity_id,
)
now = dt_util.now()
# Check if scheduler is enabled
if not await self._scheduler_reader.is_scheduler_enabled(scheduler_entity_id):
_LOGGER.warning(
"Scheduler %s is disabled. Skipping anticipation scheduling.",
scheduler_entity_id,
)
active_scheduler = self._control_preheating.get_active_scheduler_entity()
if active_scheduler == scheduler_entity_id:
await self._clear_state()
return
# Handle revert logic: cancel active preheating only when anticipated start is pushed later
# by at least the configured tolerance.
if self._control_preheating.is_preheating_active():
preheating_target = self._control_preheating.get_preheating_target_time()
postponed_seconds = 0.0
if self._last_scheduled_time is not None:
postponed_seconds = (anticipated_start - self._last_scheduled_time).total_seconds()
if (
preheating_target == target_time
and self._last_scheduled_time is not None
and postponed_seconds >= self._anticipation_recalc_tolerance_seconds
):
delta_minutes = postponed_seconds / 60.0
_LOGGER.info(
"Anticipated start moved later significantly (delta: %.1f min, "
"threshold: %.1f min). Reverting active preheating and rescheduling.",
delta_minutes,
self._anticipation_recalc_tolerance_seconds / 60.0,
)
# Delegate cancellation to ControlPreheatingUseCase
await self._control_preheating.cancel_preheating(scheduler_entity_id)
# If target time reached, mark complete
if now >= target_time:
_LOGGER.info("Target time reached, preheating complete")
await self._clear_state()
return
# Update tracking
self._last_scheduled_time = anticipated_start
self._last_scheduled_lhs = lhs
# If anticipated start is in past but target is future, trigger now
if anticipated_start <= now < target_time:
if not self._control_preheating.is_preheating_active():
_LOGGER.info(
"Anticipated start %s is past, triggering preheating immediately",
anticipated_start.isoformat(),
)
# Delegate to ControlPreheatingUseCase
await self._control_preheating.start_preheating(
target_time, target_temp, scheduler_entity_id
)
else:
_LOGGER.debug(
"Already preheating, continuing through target time %s", target_time.isoformat()
)
_LOGGER.debug("Exiting schedule_action() -> immediate trigger")
return
# Both times in past - skip
if anticipated_start <= now and target_time <= now:
_LOGGER.debug("Both times are in past, skipping")
await self._cancel_timer()
return
# Schedule timer for future start only if preheating is not already active.
if not self._control_preheating.is_preheating_active():
await self._schedule_timer(
anticipated_start,
target_time,
target_temp,
scheduler_entity_id,
)
_LOGGER.debug("Exiting schedule_action() -> timer scheduled")
async def _schedule_timer(
self,
anticipated_start: datetime,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
) -> None:
"""Schedule a timer to trigger preheating at anticipated start time.
Args:
anticipated_start: When timer should fire
target_time: Target schedule time
target_temp: Target temperature
scheduler_entity_id: Scheduler to trigger
"""
_LOGGER.debug(
"Scheduling timer: anticipated_start=%s, scheduler=%s",
anticipated_start.isoformat(),
scheduler_entity_id,
)
# Cancel existing timer
await self._cancel_timer()
# Create callback
async def _timer_callback() -> None:
"""Execute when timer fires."""
_LOGGER.info(
"Anticipation timer fired at %s for target %s (%.1f°C)",
dt_util.now().isoformat(),
target_time.isoformat(),
target_temp,
)
# Clear timer reference
self._anticipation_timer_cancel = None
# Trigger the action
await self._trigger_action(target_time, target_temp, scheduler_entity_id)
# Schedule the timer
self._anticipation_timer_cancel = self._timer_scheduler.schedule_timer(
anticipated_start,
_timer_callback,
)
now = dt_util.now()
wait_minutes = (anticipated_start - now).total_seconds() / 60.0
_LOGGER.info(
"Anticipation timer scheduled: will trigger at %s (in %.1f minutes)",
anticipated_start.isoformat(),
wait_minutes,
)
async def _trigger_action(
self,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
) -> None:
"""Trigger the preheating action via scheduler.
Args:
target_time: Target time
target_temp: Target temperature
scheduler_entity_id: Scheduler entity
"""
_LOGGER.debug(
"Triggering anticipation action: target_time=%s, temp=%.1f°C",
target_time.isoformat(),
target_temp,
)
# Verify scheduler is still enabled
if not await self._scheduler_reader.is_scheduler_enabled(scheduler_entity_id):
_LOGGER.warning("Scheduler %s is disabled, cannot trigger action", scheduler_entity_id)
await self._clear_state()
return
# Delegate to ControlPreheatingUseCase
await self._control_preheating.start_preheating(
target_time, target_temp, scheduler_entity_id
)
self._preheating_target_temp = target_temp
_LOGGER.debug("Action triggered successfully")
async def _cancel_timer(self) -> None:
"""Cancel any active timer.
Note: Does NOT clear _preheating_target_time - that's preserved for state tracking.
"""
_LOGGER.debug("Entering cancel_timer")
if self._anticipation_timer_cancel:
_LOGGER.debug("Canceling active timer")
self._anticipation_timer_cancel()
self._anticipation_timer_cancel = None
# Note: Timer cancelled but target_time preserved (as per review feedback)
_LOGGER.debug("Exiting cancel_timer")
async def _clear_state(self) -> None:
"""Clear all tracking state."""
_LOGGER.debug("Clearing anticipation state")
await self._cancel_timer()
# Clear scheduling-specific state
self._preheating_target_temp = None
self._last_scheduled_time = None
self._last_scheduled_lhs = None
# Note: Preheating active/target_time managed by ControlPreheatingUseCase
async def cancel_action(self) -> None:
"""Cancel any active timer and clear state.
Called by orchestrator when disabling preheating or when conditions change.
"""
_LOGGER.debug("Canceling anticipation action")
await self._cancel_timer()
def get_preheating_state(self) -> tuple[bool, datetime | None, float | None]:
"""Get current preheating state.
Delegates to ControlPreheatingUseCase for state queries.
Returns:
Tuple of (is_active, target_time, target_temp)
"""
return (
self._control_preheating.is_preheating_active(),
self._control_preheating.get_preheating_target_time(),
self._preheating_target_temp,
)
@@ -0,0 +1,156 @@
"""Update Cache Data Use Case.
This use case manages the heating cycle cache:
- Get cache data
- Update/prune cache
- Reset cache completely
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import IHeatingCycleStorage, ILhsStorage
from ...domain.value_objects import HeatingCycle, HeatingCycleCacheData
from ..lhs_lifecycle_manager import LhsLifecycleManager
_LOGGER = logging.getLogger(__name__)
class UpdateCacheDataUseCase:
"""Use case for managing heating cycle cache.
This use case encapsulates the logic for:
1. Getting cycles from cache
2. Updating cache (append new cycles, then prune old ones)
3. Recalculating LHS when cycles change
4. Resetting cache completely
"""
def __init__(
self,
cycle_storage: IHeatingCycleStorage,
lhs_storage: ILhsStorage,
lhs_lifecycle_manager: LhsLifecycleManager,
) -> None:
"""Initialize the use case.
Args:
cycle_storage: Heating cycle cache storage
lhs_storage: LHS storage for clearing
lhs_lifecycle_manager: Manages LHS recalculation
"""
_LOGGER.debug("Initializing UpdateCacheDataUseCase")
self._cycle_storage = cycle_storage
self._lhs_storage = lhs_storage
self._lhs_manager = lhs_lifecycle_manager
async def get_cache_data(self, device_id: str) -> HeatingCycleCacheData | None:
"""Get cache data for a device.
Args:
device_id: Device identifier
Returns:
Cache data if exists, None otherwise
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.get_cache_data(device_id=%s)",
device_id,
)
cache_data = await self._cycle_storage.get_cache_data(device_id)
_LOGGER.debug(
"Exiting UpdateCacheDataUseCase.get_cache_data() -> %s",
"data" if cache_data else "None",
)
return cache_data
async def append_cycles(
self,
device_id: str,
cycles: list[HeatingCycle],
reference_time: datetime,
) -> None:
"""Append new cycles to cache and prune old ones.
Args:
device_id: Device identifier
cycles: New heating cycles to append
reference_time: Reference time for pruning old cycles
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.append_cycles(device_id=%s, cycles=%d)",
device_id,
len(cycles),
)
# Append cycles to storage (storage handles deduplication)
await self._cycle_storage.append_cycles(device_id, cycles, reference_time)
_LOGGER.info(
"Appended %d cycles to cache for device %s",
len(cycles),
device_id,
)
# Prune old cycles based on retention
await self.prune_old_cycles(device_id, reference_time)
_LOGGER.debug("Exiting UpdateCacheDataUseCase.append_cycles()")
async def prune_old_cycles(
self,
device_id: str,
reference_time: datetime,
) -> None:
"""Prune cycles older than retention period and recalculate LHS.
Args:
device_id: Device identifier
reference_time: Reference time for retention calculation
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.prune_old_cycles(device_id=%s, reference_time=%s)",
device_id,
reference_time.isoformat(),
)
# Prune old cycles
await self._cycle_storage.prune_old_cycles(device_id, reference_time)
# Note: LHS is automatically updated when cycles change via event listeners
# No need to explicitly recalculate here
_LOGGER.info("Old cycles pruned, LHS will be recalculated automatically")
_LOGGER.debug("Exiting UpdateCacheDataUseCase.prune_old_cycles()")
async def reset_cache(self, device_id: str) -> None:
"""Reset cache completely for a device (both cycles and LHS).
This deletes all cached cycles and LHS data.
Args:
device_id: Device identifier
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.reset_cache(device_id=%s)",
device_id,
)
_LOGGER.info("Resetting heating cycle cache and LHS for device %s", device_id)
# Clear all cycle cache data
await self._cycle_storage.clear_cache(device_id)
_LOGGER.info("Heating cycle cache has been reset")
# Clear LHS data
await self._lhs_storage.clear_slope_history()
_LOGGER.info("LHS data has been reset")
_LOGGER.info("Cache and LHS have been reset for device %s", device_id)
_LOGGER.debug("Exiting UpdateCacheDataUseCase.reset_cache()")