New apps Added
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""Application layer - use case orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .orchestrator import HeatingOrchestrator
|
||||
|
||||
__all__ = ["HeatingOrchestrator"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1458
File diff suppressed because it is too large
Load Diff
+191
@@ -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
|
||||
+113
@@ -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",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+314
@@ -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
|
||||
+133
@@ -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
|
||||
+137
@@ -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
|
||||
+381
@@ -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,
|
||||
)
|
||||
+156
@@ -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()")
|
||||
Reference in New Issue
Block a user