New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,446 @@
"""The Intelligent Heating Pilot integration - DDD Architecture.
This module sets up the integration using a clean DDD architecture:
- Domain: Pure business logic (entities, value objects, services)
- Infrastructure: HA adapters (readers, commanders, event bridge)
- Application: Use case orchestration
The coordinator here is reduced to a thin setup/teardown manager.
"""
from __future__ import annotations
import hashlib
import logging
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, Platform
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.event import async_track_point_in_time
from homeassistant.util import dt as dt_util
from .const import CONF_IHP_ENABLED, DOMAIN, SERVICE_CALCULATE_ANTICIPATED_START_TIME
from .heating_application import HeatingApplication
from .utils.config_helpers import as_bool
from .view import async_register_http_views
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[str] = [Platform.SENSOR, Platform.SWITCH]
_STARTUP_UPDATE_BASE_DELAY_SECONDS = 5
_STARTUP_UPDATE_JITTER_SECONDS = 55
_STARTUP_EXTRACTION_BASE_DELAY_SECONDS = 120
_STARTUP_EXTRACTION_JITTER_SECONDS = 600
_LATE_UPDATE_BASE_DELAY_SECONDS = 30
_LATE_UPDATE_JITTER_SECONDS = 90
def _stable_jitter_seconds(seed: str, spread_seconds: int) -> int:
"""Return deterministic jitter in [0, spread_seconds] for a given seed."""
if spread_seconds <= 0:
return 0
digest = hashlib.blake2s(seed.encode("utf-8"), digest_size=4).digest()
value = int.from_bytes(digest, "big")
return value % (spread_seconds + 1)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the Intelligent Heating Pilot component."""
hass.data.setdefault(DOMAIN, {})
# Initialize shared recorder access queue (FIFO) for all IHP devices.
# This must be created before any device entry setup to ensure all adapters
# share the same queue and recorder access is serialized across instances.
from .infrastructure.recorder_queue import get_recorder_queue
get_recorder_queue(hass)
# Store hass in http app context for REST API views to access it
hass.http.app["hass"] = hass
# Register HTTP views once at the integration level (not per device)
await async_register_http_views(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Intelligent Heating Pilot from a config entry.
REFACTORED: Now uses HADeviceConfigReader to extract configuration
and injects DeviceConfig into the Coordinator instead of passing
config_entry directly.
Args:
hass: Home Assistant instance
entry: Config entry to set up
Returns:
True if setup succeeded
"""
_LOGGER.debug("Setting up IHP config entry: %s", entry.entry_id)
hass.data.setdefault(DOMAIN, {})
# ============================================================================
# REFACTORING: Create device config reader and extract configuration
# ============================================================================
# Instead of passing config_entry to the Coordinator, we:
# 1. Create HADeviceConfigReader (infrastructure adapter)
# 2. Read DeviceConfig from config_entry (data + options)
# 3. Inject DeviceConfig into Coordinator (dependency injection)
from .infrastructure.adapters.device_config_reader import HADeviceConfigReader
# Create device configuration reader (infrastructure layer)
device_config_reader = HADeviceConfigReader(hass, entry)
# Read complete device configuration (applies "options override data" logic)
try:
device_config = await device_config_reader.get_device_config(entry.entry_id)
except ValueError as err:
_LOGGER.error("Failed to load device configuration: %s", err)
return False
_LOGGER.debug(
"Loaded device configuration: vtherm=%s, schedulers=%d, ihp_enabled=%s",
device_config.vtherm_entity_id,
len(device_config.scheduler_entities),
device_config.ihp_enabled,
)
# ============================================================================
# Create and load coordinator with injected configuration
# ============================================================================
# NEW: Coordinator ONLY receives device_config (pure DDD)
# config_entry is passed later via setup_config_entry_access for options updates
coordinator = HeatingApplication(hass, device_config)
coordinator.setup_config_entry_access(entry)
coordinator._options_snapshot = dict(entry.options)
await coordinator.async_load()
# Store coordinator
hass.data[DOMAIN][entry.entry_id] = coordinator
# Setup event listeners
coordinator.setup_listeners()
def _schedule_startup_work() -> None:
"""Schedule startup work with deterministic per-entry staggering.
This avoids synchronized bursts when many IHP entries start together.
"""
extraction_delay = _STARTUP_EXTRACTION_BASE_DELAY_SECONDS + _stable_jitter_seconds(
f"{entry.entry_id}:extract", _STARTUP_EXTRACTION_JITTER_SECONDS
)
update_delay = _STARTUP_UPDATE_BASE_DELAY_SECONDS + _stable_jitter_seconds(
f"{entry.entry_id}:update", _STARTUP_UPDATE_JITTER_SECONDS
)
@callback
def _run_extraction(_now):
hass.async_create_task(coordinator.async_initialize_cycle_extraction())
@callback
def _run_update(_now):
hass.async_create_task(coordinator.async_update())
entry.async_on_unload(
async_track_point_in_time(
hass,
_run_update,
dt_util.now() + dt_util.dt.timedelta(seconds=update_delay),
)
)
entry.async_on_unload(
async_track_point_in_time(
hass,
_run_extraction,
dt_util.now() + dt_util.dt.timedelta(seconds=extraction_delay),
)
)
_LOGGER.info(
"[%s] Startup workload staggered: update in %ss, extraction in %ss",
entry.entry_id,
update_delay,
extraction_delay,
)
# Wait for HA to be fully started before initializing cycle extraction and first update
# This ensures all entities (especially VTherm and scheduler entities) are available
@callback
def _ha_started(_event):
_LOGGER.info(
"[%s] HA started, initializing cycle extraction and triggering updates", entry.entry_id
)
_schedule_startup_work()
# Schedule initial tasks asynchronously to avoid blocking config flow
# This prevents HA watchdog restart during device creation with scheduler
if hass.is_running:
_LOGGER.debug("[%s] HA already running, scheduling staggered startup work", entry.entry_id)
_schedule_startup_work()
else:
_LOGGER.debug("[%s] Waiting for HA start event before initialization", entry.entry_id)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _ha_started)
# Small delayed update for late attribute population
@callback
def _delayed_update(_now):
_LOGGER.debug("[%s] Delayed update", entry.entry_id)
hass.async_create_task(coordinator.async_update())
late_update_delay = _LATE_UPDATE_BASE_DELAY_SECONDS + _stable_jitter_seconds(
f"{entry.entry_id}:late_update", _LATE_UPDATE_JITTER_SECONDS
)
entry.async_on_unload(
async_track_point_in_time(
hass,
_delayed_update,
dt_util.now() + dt_util.dt.timedelta(seconds=late_update_delay),
)
)
# Register options update listener
entry.async_on_unload(entry.add_update_listener(async_update_options))
# Register services (once per integration, not per device)
def _resolve_coordinator(entity_id: str) -> HeatingApplication:
"""Resolve an IHP coordinator from an entity ID.
Looks up the entity in the entity registry to find its config entry,
then returns the corresponding HeatingApplication coordinator.
Raises:
ServiceValidationError: If the entity or its coordinator cannot be found,
so callers receive immediate actionable feedback in the HA UI.
"""
entity_reg = er.async_get(hass)
entity_entry = entity_reg.async_get(entity_id)
if not entity_entry:
raise ServiceValidationError(
f"Entity '{entity_id}' was not found in the Home Assistant entity registry. "
"Please provide a valid IHP sensor entity ID, for example: "
"sensor.intelligent_heating_pilot_<device_name>_anticipated_start_time."
)
coord = hass.data[DOMAIN].get(entity_entry.config_entry_id)
if not coord or not isinstance(coord, HeatingApplication):
raise ServiceValidationError(
f"No IHP device found for entity '{entity_id}' "
f"(config_entry_id={entity_entry.config_entry_id}). "
"Make sure the IHP integration is properly configured."
)
return coord
if not hass.services.has_service(DOMAIN, "reset_learning"):
async def handle_reset_learning(call: ServiceCall) -> None:
"""Handle reset_learning service.
Resolves the target IHP device from the entity_id provided in the service
call, then delegates to that device's orchestrator. This allows callers
to choose which device to reset when multiple IHP config entries exist.
"""
_LOGGER.debug("Entering handle_reset_learning")
coord = _resolve_coordinator(call.data["entity_id"])
await coord._orchestrator.reset_all_learning_data(
device_id=coord.get_device_id()
)
_LOGGER.info(
"reset_learning: learning data cleared for device %s", coord.get_device_id()
)
reset_learning_schema = vol.Schema(
{
vol.Required("entity_id"): cv.entity_id,
}
)
hass.services.async_register(
DOMAIN,
"reset_learning",
handle_reset_learning,
schema=reset_learning_schema,
)
if not hass.services.has_service(DOMAIN, SERVICE_CALCULATE_ANTICIPATED_START_TIME):
async def handle_calculate_anticipated_start_time(call: ServiceCall):
"""Handle calculate_anticipated_start_time service.
This service calculates the anticipated start time for a given IHP device
to reach a target temperature at a specified time.
Delegates to orchestrator's calculate_anticipation_only() - no business logic here.
"""
_LOGGER.debug("Entering handle_calculate_anticipated_start_time")
# Extract service call parameters
entity_id = call.data["entity_id"]
target_time = call.data["target_time"]
target_temp = call.data.get("target_temp")
# Ensure target_time has timezone
if target_time.tzinfo is None:
target_time = dt_util.as_local(target_time)
# Resolve the coordinator for this device
device_coordinator = _resolve_coordinator(entity_id)
# Get target_temp from service call or VTherm
if target_temp is None:
# Try to get target temp from VTherm using public accessor
vtherm_entity_id = device_coordinator.get_vtherm_entity()
if vtherm_entity_id:
vtherm_state = hass.states.get(vtherm_entity_id)
if vtherm_state:
target_temp = vtherm_state.attributes.get("temperature")
if target_temp is not None:
target_temp = float(target_temp)
# Delegate to orchestrator (pure routing - no business logic)
anticipation_data = await device_coordinator._orchestrator.calculate_anticipation_only(
target_time=target_time,
target_temp=target_temp,
)
# Extract fields from anticipation_data (which is already structured)
anticipated_start_time = anticipation_data.get("anticipated_start_time")
response_target_time = anticipation_data.get("next_schedule_time") or target_time
response_target_temp = anticipation_data.get("next_target_temperature")
if response_target_temp is None:
response_target_temp = target_temp
if anticipated_start_time is None:
_LOGGER.warning("Could not calculate anticipated start time (insufficient data)")
# Return structure with None values
return {
"anticipated_start_time": None,
"target_time": response_target_time.isoformat(),
"target_temp": response_target_temp,
"current_temp": anticipation_data.get("current_temp"),
"estimated_duration_minutes": None,
"learned_heating_slope": anticipation_data.get("learned_heating_slope"),
"confidence_level": None,
}
_LOGGER.info(
"Service calculate_anticipated_start_time: "
"anticipated_start=%s, target_time=%s, target_temp=%.1f°C, "
"current_temp=%.1f°C, LHS=%.2f°C/h, confidence=%.2f",
anticipated_start_time.isoformat(),
response_target_time.isoformat(),
response_target_temp or 0.0,
anticipation_data.get("current_temp") or 0.0,
anticipation_data.get("learned_heating_slope") or 0.0,
anticipation_data.get("confidence_level") or 0.0,
)
# Return the result as service response data
return {
"anticipated_start_time": anticipated_start_time.isoformat(),
"target_time": response_target_time.isoformat(),
"target_temp": response_target_temp,
"current_temp": anticipation_data.get("current_temp"),
"estimated_duration_minutes": anticipation_data.get("estimated_duration_minutes"),
"learned_heating_slope": anticipation_data.get("learned_heating_slope"),
"confidence_level": anticipation_data.get("confidence_level"),
}
# Define service schema
calculate_anticipated_start_time_schema = vol.Schema(
{
vol.Required("entity_id"): cv.entity_id,
vol.Required("target_time"): cv.datetime,
vol.Optional("target_temp"): vol.Coerce(float),
}
)
hass.services.async_register(
DOMAIN,
SERVICE_CALCULATE_ANTICIPATED_START_TIME,
handle_calculate_anticipated_start_time,
schema=calculate_anticipated_start_time_schema,
supports_response=SupportsResponse.ONLY,
)
# Forward setup to platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
coordinator = hass.data[DOMAIN].get(entry.entry_id)
if coordinator:
await coordinator.async_cleanup()
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return bool(unload_ok)
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update options and reload integration."""
from .const import CONF_DATA_RETENTION_DAYS
coordinator: HeatingApplication | None = hass.data[DOMAIN].get(entry.entry_id)
previous_options = (
dict(getattr(coordinator, "_options_snapshot", {}) or {}) if coordinator else {}
)
previous_no_toggle = {k: v for k, v in previous_options.items() if k != CONF_IHP_ENABLED}
current_no_toggle = {k: v for k, v in entry.options.items() if k != CONF_IHP_ENABLED}
ihp_enabled = as_bool(entry.options.get(CONF_IHP_ENABLED), default=True)
# Check if only retention days changed (reconfiguration of cycle refresh)
retention_only_changed = (
coordinator is not None
and previous_no_toggle != current_no_toggle
and len(set(previous_no_toggle.keys()) ^ set(current_no_toggle.keys())) == 1
and CONF_DATA_RETENTION_DAYS
in (set(previous_no_toggle.keys()) ^ set(current_no_toggle.keys()))
)
if retention_only_changed:
# Handle retention change without reload
new_retention_days = int(entry.options.get(CONF_DATA_RETENTION_DAYS, 10))
_LOGGER.info(
"[%s] Data retention changed to %d days, updating cycle refresh",
entry.entry_id,
new_retention_days,
)
if coordinator:
coordinator._options_snapshot = dict(entry.options)
coordinator._ihp_enabled = ihp_enabled
await coordinator.async_notify_retention_change(new_retention_days)
return
# If only the ihp_enabled flag changed, skip full reload
if coordinator and previous_no_toggle == current_no_toggle:
_LOGGER.info("[%s] Options updated (ihp_enabled only), skipping reload", entry.entry_id)
coordinator._options_snapshot = dict(entry.options)
coordinator._ihp_enabled = ihp_enabled
await coordinator.async_update()
return
_LOGGER.info("[%s] Options updated, reloading", entry.entry_id)
if coordinator:
coordinator._options_snapshot = dict(entry.options)
await hass.config_entries.async_reload(entry.entry_id)
# Schedule async update after reload (non-blocking)
coordinator = hass.data[DOMAIN].get(entry.entry_id)
if coordinator:
hass.async_create_task(coordinator.async_update())
@@ -0,0 +1,7 @@
"""Application layer - use case orchestration."""
from __future__ import annotations
from .orchestrator import HeatingOrchestrator
__all__ = ["HeatingOrchestrator"]
@@ -0,0 +1,191 @@
"""Factory for HeatingCycleLifecycleManager - wires dependencies with DDD compliance."""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import TYPE_CHECKING
from ..domain.interfaces.device_config_reader_interface import DeviceConfig
from ..domain.interfaces.historical_data_adapter_interface import IHistoricalDataAdapter
from ..infrastructure.adapters.climate_data_reader import HAClimateDataReader
from ..infrastructure.adapters.entity_attribute_mapper_registry import (
EntityAttributeMapperRegistry,
)
from ..infrastructure.adapters.vtherm_attribute_mapper import VThermAttributeMapper
from ..infrastructure.recorder_queue import get_extraction_semaphore, get_recorder_queue
from .heating_cycle_lifecycle_manager import HeatingCycleLifecycleManager
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from ..domain.interfaces import IHeatingCycleStorage, ILhsStorage, ITimerScheduler
from ..domain.interfaces.heating_cycle_service_interface import IHeatingCycleService
from .lhs_lifecycle_manager import LhsLifecycleManager
_LOGGER = logging.getLogger(__name__)
class HeatingCycleLifecycleManagerFactory:
"""Factory for creating HeatingCycleLifecycleManager with singleton pattern.
Singleton Pattern:
- **One instance per device_id**: Each IHP device gets its own manager instance
- **Shared across requests**: Multiple calls with same device_id return same instance
- **Thread-safe**: Factory maintains internal registry to track instances
Dependency Wiring:
- Injects all required dependencies (heating_cycle_service, adapters, caches)
- Optionally injects LhsLifecycleManager for cascade updates
- Ensures DDD compliance (domain services, infrastructure adapters)
Usage:
```python
# First call creates instance
manager1 = factory.create(hass, device_config, ...)
# Second call with same device_id returns same instance
manager2 = factory.create(hass, device_config, ...)
assert manager1 is manager2 # True
```
"""
# Class-level registry: device_id -> HeatingCycleLifecycleManager instance
_instances: dict[str, HeatingCycleLifecycleManager] = {}
@classmethod
def create(
cls,
hass: HomeAssistant,
device_config: DeviceConfig,
heating_cycle_service: IHeatingCycleService,
cycle_cache: IHeatingCycleStorage | None = None,
timer_scheduler: ITimerScheduler | None = None,
model_storage: ILhsStorage | None = None,
lhs_lifecycle_manager: LhsLifecycleManager | None = None,
dead_time_updated_callback: Callable[[float], None] | None = None,
on_extraction_complete_callback: Callable[[], None] | None = None,
) -> HeatingCycleLifecycleManager:
"""Create or return existing HeatingCycleLifecycleManager for device_id.
Singleton Behavior:
- If instance exists for device_config.device_id, returns existing instance
- If no instance exists, creates new one and stores in registry
- Registry is class-level, shared across all factory instances
Dependency Injection:
- heating_cycle_service: Domain service for extracting cycles from historical data
- cycle_cache: Infrastructure adapter for persistent cycle storage
- timer_scheduler: Infrastructure adapter for scheduling 24h refresh
- model_storage: Infrastructure adapter for persisting individual cycles
- lhs_lifecycle_manager: Application service for cascade LHS updates
Args:
hass: Home Assistant instance (used for historical data adapters).
device_config: Device configuration (contains device_id for singleton key).
heating_cycle_service: Service for extracting heating cycles.
cycle_cache: Optional persistent cache for incremental cycle storage.
timer_scheduler: Optional scheduler for periodic 24h refresh.
model_storage: Optional persistent storage for individual cycle records.
lhs_lifecycle_manager: Optional LHS manager for cascade updates.
dead_time_updated_callback: Optional callback fired after dead time persistence.
on_extraction_complete_callback: Optional callback fired after cycle extraction completes.
Returns:
Singleton HeatingCycleLifecycleManager instance for the device_id.
"""
device_id = device_config.device_id
# Check if instance already exists
if device_id in cls._instances:
_LOGGER.debug(
"Returning existing HeatingCycleLifecycleManager for device_id=%s", device_id
)
return cls._instances[device_id]
# Create new instance
_LOGGER.debug("Creating new HeatingCycleLifecycleManager for device_id=%s", device_id)
# Wire historical data adapters from hass infrastructure
# Dynamically detect entity type (VTherm vs generic climate) for diagnostics
_LOGGER.debug(
"Setting up historical data adapters for device_id=%s", device_config.device_id
)
vtherm_entity_id = device_config.vtherm_entity_id
_LOGGER.debug("Configured VTherm entity_id: %s", vtherm_entity_id)
# Detect entity type (VTherm or generic climate) for diagnostic logging
entity_type = cls._detect_entity_type(hass, vtherm_entity_id)
_LOGGER.info(
"Detected entity type for %s: %s (auto-detection)",
vtherm_entity_id,
entity_type,
)
climate_adapter = HAClimateDataReader(hass, get_recorder_queue(hass), vtherm_entity_id)
historical_adapters: list[IHistoricalDataAdapter] = [climate_adapter]
_LOGGER.debug(
"Configured %d historical adapter(s) for device_id=%s with dynamic entity detection",
len(historical_adapters),
device_config.device_id,
)
manager = HeatingCycleLifecycleManager(
device_config=device_config,
heating_cycle_service=heating_cycle_service,
historical_adapters=historical_adapters,
heating_cycle_storage=cycle_cache,
timer_scheduler=timer_scheduler,
lhs_storage=model_storage,
lhs_lifecycle_manager=lhs_lifecycle_manager,
dead_time_updated_callback=dead_time_updated_callback,
extraction_semaphore=get_extraction_semaphore(hass),
on_extraction_complete_callback=on_extraction_complete_callback,
)
# Store in registry
cls._instances[device_id] = manager
_LOGGER.debug("Registered HeatingCycleLifecycleManager for device_id=%s", device_id)
return manager
@classmethod
def _detect_entity_type(cls, hass: HomeAssistant, vtherm_entity_id: str) -> str:
"""Detect the type of climate entity (VTherm or generic climate).
Uses EntityAttributeMapperRegistry to auto-detect the entity type.
Used for diagnostic logging and validation.
Args:
hass: Home Assistant instance
vtherm_entity_id: Entity ID to detect
Returns:
String describing the entity type (e.g., "VTherm v8.0+", "Generic Climate")
"""
try:
mapper_registry = EntityAttributeMapperRegistry(hass)
mapper = mapper_registry.get_mapper_for_entity(vtherm_entity_id)
# Identify mapper type for logging
if isinstance(mapper, VThermAttributeMapper):
return "VTherm (Versatile Thermostat)"
return "Generic Climate"
except ValueError as e:
_LOGGER.warning(
"Could not detect entity type for %s: %s",
vtherm_entity_id,
str(e),
)
return "Unknown"
@classmethod
def reset_instances(cls) -> None:
"""Clear singleton registry (for testing only).
Use Case:
Called in test teardown to ensure clean state between tests.
Should NOT be used in production code.
"""
cls._instances = {}
_LOGGER.debug("Reset HeatingCycleLifecycleManager singleton registry")
@@ -0,0 +1,752 @@
"""Lifecycle manager for learned heating slope (LHS) caching and refresh."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Callable
from ..domain.value_objects.heating import HeatingCycle
if TYPE_CHECKING:
from ..domain.interfaces import ILhsStorage, ITimerScheduler
from ..domain.services.contextual_lhs_calculator_service import ContextualLHSCalculatorService
from ..domain.services.global_lhs_calculator_service import GlobalLHSCalculatorService
_LOGGER = logging.getLogger(__name__)
try:
from homeassistant.util import dt as dt_util
except ImportError:
dt_util = None # For testing without HA
class LhsLifecycleManager:
"""Manage global and contextual LHS lifecycle, caching, and refresh.
This manager is a singleton per IHP device (identified by device_id).
It orchestrates:
1. In-memory cache management (_cached_global_lhs, _cached_contextual_lhs)
2. Persistent storage via ILhsStorage
3. Periodic refresh scheduling (24h timer)
4. Cascade updates triggered by HeatingCycleLifecycleManager
Architecture:
- **In-memory cache**: Fast lookups for global and contextual LHS values
- **Model storage (ILhsStorage)**: Persistent storage for LHS values with timestamps
- **Triggered by**: HeatingCycleLifecycleManager when cycles change
Lazy Loading Strategy:
========================
This class implements LAZY LOADING for contextual LHS to optimize startup performance:
**Startup Phase:**
- Global LHS: Loaded eagerly (single read from storage)
- Contextual LHS: Loaded ONLY for current hour (datetime.now().hour)
- Other 23 hours: NOT loaded at startup (deferred to on-demand)
**On-Demand Loading:**
- get_contextual_lhs(): Loads requested hour from storage (if not in memory)
- ensure_contextual_lhs_populated(): Same lazy-load behavior with optional force_recalculate
- Per-hour memory caching: Each hour cached independently after first load
**Bulk Update Methods (intentionally load all 24 hours):**
- on_retention_change(): Recalculates and caches all 24 hours when retention changes
- on_24h_timer(): Recalculates and caches all 24 hours on periodic refresh
- update_contextual_lhs_from_cycles(): Persists all 24 hours when called
Cache Hierarchy (fastest to slowest):
1. Memory cache (_cached_contextual_lhs[hour]): O(1) lookup
2. Storage cache (ILhsStorage.get_cached_contextual_lhs): Single disk read
3. Computation cache (calculate_contextual_lhs_for_hour): On-demand calculation
Lazy Loading Strategy:
========================
This class implements LAZY LOADING for contextual LHS to optimize startup performance:
**Startup Phase:**
- Global LHS: Loaded eagerly (single read from storage)
- Contextual LHS: Loaded ONLY for current hour (datetime.now().hour)
- Other 23 hours: NOT loaded at startup (deferred to on-demand)
**On-Demand Loading:**
- get_contextual_lhs(): Loads requested hour from storage (if not in memory)
- ensure_contextual_lhs_populated(): Same lazy-load behavior with optional force_recalculate
- Per-hour memory caching: Each hour cached independently after first load
**Bulk Update Methods (intentionally load all 24 hours):**
- on_retention_change(): Recalculates and caches all 24 hours when retention changes
- on_24h_timer(): Recalculates and caches all 24 hours on periodic refresh
- update_contextual_lhs_from_cycles(): Persists all 24 hours when called
Cache Hierarchy (fastest to slowest):
1. Memory cache (_cached_contextual_lhs[hour]): O(1) lookup
2. Storage cache (ILhsStorage.get_cached_contextual_lhs): Single disk read
3. Computation cache (calculate_contextual_lhs_for_hour): On-demand calculation
Lifecycle Events:
- startup(): Load global LHS + current hour contextual (lazy loading)
- on_retention_change(cycles): Recalculate ALL 24 hours with new retention window
- on_24h_timer(cycles): Recalculate ALL 24 hours on timer event
- update_global_lhs_from_cycles(cycles): Persist global LHS from cycles
- update_contextual_lhs_from_cycles(cycles): Persist ALL 24 contextual hours from cycles
- cancel(): Cleanup timers and release resources (keep cached data)
- startup(): Load global LHS + current hour contextual (lazy loading)
- on_retention_change(cycles): Recalculate ALL 24 hours with new retention window
- on_24h_timer(cycles): Recalculate ALL 24 hours on timer event
- update_global_lhs_from_cycles(cycles): Persist global LHS from cycles
- update_contextual_lhs_from_cycles(cycles): Persist ALL 24 contextual hours from cycles
- cancel(): Cleanup timers and release resources (keep cached data)
Cascade Pattern:
HeatingCycleLifecycleManager calls update_*_lhs_from_cycles() when:
- startup(): Initial cycles extracted
- on_retention_change(): Cycles re-extracted with new retention
- on_24h_timer(): Cycles refreshed with latest data
"""
def __init__(
self,
model_storage: ILhsStorage,
global_lhs_calculator: GlobalLHSCalculatorService,
contextual_lhs_calculator: ContextualLHSCalculatorService,
timer_scheduler: ITimerScheduler | None = None,
) -> None:
"""Initialize the lifecycle manager.
Note: This should be instantiated via LhsLifecycleManagerFactory
to ensure singleton behavior per device_id.
Args:
model_storage: Persistent storage adapter for cached LHS values.
global_lhs_calculator: Domain service for computing global LHS from cycles.
contextual_lhs_calculator: Domain service for computing contextual LHS by hour from cycles.
timer_scheduler: Optional scheduler for periodic 24h refresh tasks.
"""
self._model_storage = model_storage
self._global_lhs_calculator = global_lhs_calculator
self._contextual_lhs_calculator = contextual_lhs_calculator
self._timer_scheduler = timer_scheduler
# In-memory caches for fast repeated lookups (avoids disk I/O)
# These are loaded from storage on startup and invalidated on updates
self._cached_global_lhs: float | None = None # Global LHS value
self._cached_contextual_lhs: dict[int, float] = {} # hour (0-23) -> LHS value
self._timer_cancel_func: Callable[[], None] | None = None
async def startup(self) -> None:
"""Initialize LHS caches and schedule periodic refresh.
Lifecycle Event Flow:
1. Load cached global LHS from storage → memory cache
2. Load cached contextual LHS for CURRENT HOUR ONLY (lazy loading)
3. Schedule 24h timer for automatic refresh (if scheduler provided)
Lazy Loading Strategy:
This method implements lazy loading to optimize startup performance:
- **Current hour**: Loaded eagerly during startup (most frequently used)
- **Other 23 hours**: Loaded on-demand via get_contextual_lhs() or ensure_contextual_lhs_populated()
- **Memory cache**: Per-hour caching in _cached_contextual_lhs (fast lookups)
- **Storage cache**: Persistent on-disk cache (fallback if not in memory)
- **Computation cache**: Computed on-demand when no cached value exists
Cache Strategy:
- **Reads from storage**: model_storage.get_cached_global_lhs()
- **Reads from storage**: model_storage.get_cached_contextual_lhs() for CURRENT HOUR ONLY
- **Writes to memory**: Populates _cached_global_lhs and _cached_contextual_lhs[current_hour]
- **Does NOT write to storage**: Only loads existing cached values
- **Does NOT compute**: Uses cached values or returns defaults via get_* methods
Note:
- Initial LHS values are computed and stored by HeatingCycleLifecycleManager during its startup
- Bulk updates (on_retention_change(), on_24h_timer()) intentionally load all 24 hours
- On-demand loads (get_contextual_lhs(), ensure_contextual_lhs_populated()) load per-hour
Returns:
None.
"""
_LOGGER.debug("Entering LhsLifecycleManager.startup")
try:
# Load cached global LHS
cached_global_lhs = await self._model_storage.get_cached_global_lhs()
if cached_global_lhs is not None:
# Extract value from LHSCacheEntry
lhs_value = cached_global_lhs.value
self._cached_global_lhs = lhs_value
_LOGGER.debug("Loaded cached global LHS: %.2f °C/h", lhs_value)
# Lazy Loading: Load contextual LHS for current hour ONLY
# Other hours will be loaded on-demand via get_contextual_lhs() or ensure_contextual_lhs_populated()
current_hour = datetime.now().hour if dt_util is None else dt_util.now().hour
_LOGGER.debug(
"Loading contextual LHS for current hour: %d (lazy loading enabled)", current_hour
)
cached_contextual = await self._model_storage.get_cached_contextual_lhs(current_hour)
if cached_contextual is not None:
# Extract value from LHSCacheEntry
lhs_value = cached_contextual.value
self._cached_contextual_lhs[current_hour] = lhs_value
_LOGGER.debug(
"Loaded cached contextual LHS for current hour %d: %.2f °C/h",
current_hour,
lhs_value,
)
else:
_LOGGER.debug(
"No cached contextual LHS for current hour %d; will load on-demand",
current_hour,
)
_LOGGER.info(
"LHS startup complete: loaded global LHS and current hour contextual LHS (lazy loading enabled)"
)
except Exception as exc:
_LOGGER.error("Error during startup: %s", exc)
# Don't re-raise - continue with defaults
# Schedule 24h timer for automatic refresh
if self._timer_scheduler is not None:
if dt_util is not None:
next_refresh = dt_util.now() + timedelta(hours=24)
else:
next_refresh = datetime.now() + timedelta(hours=24)
# Timer callback: no-op placeholder (cycles provided by HeatingCycleLifecycleManager)
async def noop_callback() -> None:
"""Placeholder timer callback - actual update comes from HCLM."""
pass
self._timer_cancel_func = self._timer_scheduler.schedule_timer(
next_refresh, noop_callback
)
_LOGGER.debug("Scheduled 24h LHS refresh timer for %s", next_refresh.isoformat())
_LOGGER.debug("Exiting LhsLifecycleManager.startup")
async def on_retention_change(self, cycles: list[HeatingCycle]) -> None:
"""Handle retention configuration changes.
Lifecycle Event Flow (triggered by HeatingCycleLifecycleManager):
1. HeatingCycleLifecycleManager re-extracts cycles for new retention window
2. HeatingCycleLifecycleManager calls this method with the new cycles
3. Invalidate in-memory caches FIRST (prevents use of stale data)
4. Recalculate global LHS from provided cycles
5. Recalculate contextual LHS (by hour) from provided cycles
6. Persist new LHS values to storage
Cache Strategy:
- **Invalidates memory FIRST**: Clears _cached_global_lhs and _cached_contextual_lhs
- **Receives cycles from**: HeatingCycleLifecycleManager.on_retention_change()
- **Computes**: global_lhs_calculator.calculate_global_lhs(cycles)
- **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles)
- **Writes to storage**: model_storage.set_cached_global_lhs()
- **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value)
Args:
cycles: Heating cycles extracted for the new retention window.
Returns:
None.
"""
from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
_LOGGER.debug("Entering LhsLifecycleManager.on_retention_change")
_LOGGER.debug("Recalculating LHS from %d cycles after retention change", len(cycles))
# Step 1: Invalidate in-memory caches FIRST (before any computation)
self._cached_global_lhs = None
self._cached_contextual_lhs = {}
_LOGGER.debug("Invalidated in-memory caches before recalculation")
# Step 2: Recalculate global LHS from provided cycles
global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles)
# Validate: LHS must be realistically positive (>= 0.5°C/h)
if global_lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
global_lhs,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
global_lhs = DEFAULT_LEARNED_SLOPE
updated_at = dt_util.now() if dt_util is not None else datetime.now()
await self._model_storage.set_cached_global_lhs(global_lhs, updated_at)
_LOGGER.info("Recalculated global LHS: %.2f °C/h", global_lhs)
# Step 3: Recalculate contextual LHS from provided cycles
contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs(
cycles
)
for hour, lhs_value in contextual_lhs_by_hour.items():
if lhs_value is not None and lhs_value > 0:
await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at)
_LOGGER.debug("Recalculated contextual LHS for %d hours", len(contextual_lhs_by_hour))
_LOGGER.debug("Exiting LhsLifecycleManager.on_retention_change")
async def on_24h_timer(self, cycles: list[HeatingCycle]) -> None:
"""Handle periodic 24h refresh execution.
Lifecycle Event Flow (triggered by HeatingCycleLifecycleManager):
1. HeatingCycleLifecycleManager extracts fresh cycles for retention window
2. HeatingCycleLifecycleManager calls this method with the fresh cycles
3. Recalculate global LHS from provided cycles
4. Recalculate contextual LHS (by hour) from provided cycles
5. Persist new LHS values to storage
6. Invalidate in-memory caches (will reload from storage on next access)
Cache Strategy:
- **Receives cycles from**: HeatingCycleLifecycleManager.on_24h_timer()
- **Computes**: global_lhs_calculator.calculate_global_lhs(cycles)
- **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles)
- **Writes to storage**: model_storage.set_cached_global_lhs()
- **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value)
- **Invalidates memory**: Sets _cached_global_lhs = None, _cached_contextual_lhs = {}
Args:
cycles: Heating cycles extracted for the current retention window.
Returns:
None.
"""
from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
_LOGGER.debug("Entering LhsLifecycleManager.on_24h_timer")
_LOGGER.info("24h LHS refresh timer triggered")
# Recalculate global LHS from provided cycles
global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles)
# Validate: LHS must be realistically positive (>= 0.5°C/h)
if global_lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
global_lhs,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
global_lhs = DEFAULT_LEARNED_SLOPE
updated_at = dt_util.now() if dt_util is not None else datetime.now()
await self._model_storage.set_cached_global_lhs(global_lhs, updated_at)
# Invalidate cache to ensure fresh load
self._cached_global_lhs = None
_LOGGER.info("Refreshed global LHS: %.2f °C/h", global_lhs)
# Recalculate contextual LHS from provided cycles
contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs(
cycles
)
for hour, lhs_value in contextual_lhs_by_hour.items():
if lhs_value is not None and lhs_value > 0:
await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at)
# Invalidate cache to ensure fresh load
self._cached_contextual_lhs = {}
_LOGGER.debug("Exiting LhsLifecycleManager.on_24h_timer")
async def get_global_lhs(self) -> float:
"""Return the global learned heating slope (LHS).
Cache Strategy (read-only, optimized with memory cache):
- **Reads from memory first**: Checks _cached_global_lhs
- **On memory cache hit**: Returns immediately (fast path)
- **On memory cache miss**: Loads from model_storage.get_cached_global_lhs()
- **Writes to memory**: Caches loaded value in _cached_global_lhs
- **Validation**: Returns default if cached value is invalid (<=0 or None)
- **Does NOT write to storage**: Read-only operation
Use Case:
Called frequently during anticipation calculations to get the baseline heating rate.
Memory cache ensures fast lookups without repeated disk I/O.
Returns:
The cached or default global LHS in C/hour.
"""
from ..domain.constants import DEFAULT_LEARNED_SLOPE
_LOGGER.debug("Entering LhsLifecycleManager.get_global_lhs")
# Check in-memory cache first (fast path)
if self._cached_global_lhs is not None:
_LOGGER.debug(
"Returning in-memory cached global LHS: %.2f °C/h", self._cached_global_lhs
)
_LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs")
return self._cached_global_lhs
# Not in memory cache, load from storage
cached_entry = await self._model_storage.get_cached_global_lhs()
# Validate cached value is positive
if cached_entry is not None:
cached_lhs = cached_entry.value
if cached_lhs > 0:
# Cache in memory for subsequent calls
self._cached_global_lhs = cached_lhs
_LOGGER.debug("Loaded from storage and cached global LHS: %.2f °C/h", cached_lhs)
_LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs")
return cached_lhs
# Return default if cache invalid or missing
_LOGGER.debug(
"No valid cached global LHS, returning default: %.2f °C/h", DEFAULT_LEARNED_SLOPE
)
_LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs")
return DEFAULT_LEARNED_SLOPE
async def cancel(self) -> None:
"""Cancel any scheduled refresh work and release resources.
Cache Strategy:
- **Memory cache**: NOT cleared (remains valid until next update)
- **Storage cache**: NOT cleared (persistent data remains)
- **Timers**: Cancelled to stop periodic refresh
Use Case:
Called when the IHP device is being shut down or removed.
Does NOT clear learned data, only stops active timers.
Returns:
None.
"""
_LOGGER.debug("Entering LhsLifecycleManager.cancel")
# Cancel timer if scheduled
if self._timer_cancel_func is not None:
try:
self._timer_cancel_func()
_LOGGER.debug("Cancelled scheduled timer")
except Exception as exc:
_LOGGER.error("Error cancelling timer: %s", exc)
finally:
self._timer_cancel_func = None
_LOGGER.debug("Exiting LhsLifecycleManager.cancel")
async def get_contextual_lhs(
self,
target_time: datetime,
cycles: list[HeatingCycle],
) -> float:
"""Return contextual LHS for a target time.
Cache Strategy (read-only, optimized with memory cache per hour):
- **Reads from memory first**: Checks _cached_contextual_lhs[target_hour]
- **On memory cache hit**: Returns immediately (fast path)
- **On memory cache miss**: Loads from model_storage.get_cached_contextual_lhs(hour)
- **If no storage cache**: Computes from provided cycles
- **Writes to memory**: Caches loaded/computed value in _cached_contextual_lhs[hour]
- **Falls back to global LHS**: If no contextual data exists for the hour
- **Does NOT write to storage**: Read-only operation (use update_contextual_lhs_from_cycles)
Use Case:
Called during anticipation calculations to get hour-specific heating rates.
Memory cache per hour ensures fast lookups without repeated disk I/O.
Args:
target_time: Target datetime used to select the contextual hour (0-23).
cycles: Heating cycles used to compute contextual LHS when not cached.
Returns:
Contextual LHS for the target hour in C/hour, or global LHS fallback.
"""
_LOGGER.debug("Entering LhsLifecycleManager.get_contextual_lhs")
target_hour = target_time.hour
_LOGGER.debug("Getting contextual LHS for hour %d", target_hour)
# Check in-memory cache first (fast path)
if target_hour in self._cached_contextual_lhs:
cached_value = self._cached_contextual_lhs[target_hour]
_LOGGER.debug(
"Returning in-memory cached contextual LHS for hour %d: %.2f °C/h",
target_hour,
cached_value,
)
_LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs")
return cached_value
# Not in memory cache, try to get from storage
cached_entry = await self._model_storage.get_cached_contextual_lhs(target_hour)
if cached_entry is not None:
cached_contextual = cached_entry.value
# Cache in memory for subsequent calls
self._cached_contextual_lhs[target_hour] = cached_contextual
_LOGGER.debug(
"Loaded from storage and cached contextual LHS for hour %d: %.2f °C/h",
target_hour,
cached_contextual,
)
_LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs")
return cached_contextual
# No cache, compute contextual LHS for THIS HOUR ONLY (not all 24)
_LOGGER.debug("No cached contextual LHS for hour %d, computing from cycles", target_hour)
computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour(
cycles, target_hour
)
# If contextual LHS is None or invalid (< 0.5°C/h), fallback to global LHS
from ..domain.constants import MINIMUM_REALISTIC_LHS
if computed_lhs is None or computed_lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.debug(
"Contextual LHS for hour %d is invalid (%.2f°C/h < %.2f°C/h), falling back to global LHS",
target_hour,
computed_lhs or 0,
MINIMUM_REALISTIC_LHS,
)
return await self.get_global_lhs()
# Cache computed value in memory
self._cached_contextual_lhs[target_hour] = computed_lhs
_LOGGER.debug(
"Computed and cached contextual LHS for hour %d: %.2f °C/h",
target_hour,
computed_lhs,
)
_LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs")
return computed_lhs
async def update_global_lhs_from_cycles(self, cycles: list[HeatingCycle]) -> float:
"""Recalculate and persist global LHS from cycles.
Lifecycle Event (triggered by HeatingCycleLifecycleManager):
This is called when:
- HeatingCycleLifecycleManager.startup(): Initial cycles extracted
- HeatingCycleLifecycleManager.on_retention_change(): Cycles re-extracted
- HeatingCycleLifecycleManager.on_24h_timer(): Cycles refreshed
Cache Strategy (write operation):
- **Receives cycles from**: HeatingCycleLifecycleManager
- **Computes**: global_lhs_calculator.calculate_global_lhs(cycles)
- **Writes to storage**: model_storage.set_cached_global_lhs(lhs, timestamp)
- **Invalidates memory**: Sets _cached_global_lhs = None
- **Next read**: get_global_lhs() will reload from storage into memory
Args:
cycles: Heating cycles used to compute the global LHS.
Returns:
The computed and persisted global LHS in C/hour.
"""
from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
_LOGGER.debug("Entering LhsLifecycleManager.update_global_lhs_from_cycles")
_LOGGER.debug("Updating global LHS from %d cycles", len(cycles))
try:
global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles)
# Validate: LHS must be realistically positive (>= 0.5°C/h)
if global_lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
global_lhs,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
global_lhs = DEFAULT_LEARNED_SLOPE
updated_at = dt_util.now() if dt_util is not None else datetime.now()
await self._model_storage.set_cached_global_lhs(global_lhs, updated_at)
# Update in-memory cache with new value for fast subsequent access
self._cached_global_lhs = global_lhs
_LOGGER.debug("Updated global LHS in-memory cache: %.2f °C/h", global_lhs)
_LOGGER.info("Updated global LHS: %.2f °C/h", global_lhs)
_LOGGER.debug("Exiting LhsLifecycleManager.update_global_lhs_from_cycles")
return global_lhs
except Exception as exc:
_LOGGER.error("Error calculating global LHS: %s", exc)
_LOGGER.debug("Exiting LhsLifecycleManager.update_global_lhs_from_cycles")
return DEFAULT_LEARNED_SLOPE
async def update_contextual_lhs_from_cycles(
self,
cycles: list[HeatingCycle],
) -> dict[int, float | None]:
"""Recalculate and persist contextual LHS for all hours.
Lifecycle Event (triggered by HeatingCycleLifecycleManager):
This is called when:
- HeatingCycleLifecycleManager.startup(): Initial cycles extracted
- HeatingCycleLifecycleManager.on_retention_change(): Cycles re-extracted
- HeatingCycleLifecycleManager.on_24h_timer(): Cycles refreshed
Cache Strategy (write operation):
- **Receives cycles from**: HeatingCycleLifecycleManager
- **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles)
- **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, lhs, timestamp) for each hour
- **Invalidates memory**: Sets _cached_contextual_lhs = {}
- **Next read**: get_contextual_lhs() will reload from storage into memory per hour
Args:
cycles: Heating cycles used to compute contextual LHS by hour.
Returns:
Mapping of hour (0-23) to LHS in C/hour, or None when no data exists.
"""
_LOGGER.debug("Entering LhsLifecycleManager.update_contextual_lhs_from_cycles")
_LOGGER.debug("Updating contextual LHS from %d cycles", len(cycles))
contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs(
cycles
)
# Persist non-None values with timestamp
updated_at = dt_util.now() if dt_util is not None else datetime.now()
persisted_count = 0
for hour, lhs_value in contextual_lhs_by_hour.items():
if lhs_value is not None:
await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at)
persisted_count += 1
# Update in-memory cache with new values for fast subsequent access
# Only cache non-None values
self._cached_contextual_lhs = {
hour: lhs for hour, lhs in contextual_lhs_by_hour.items() if lhs is not None
}
_LOGGER.debug(
"Updated contextual LHS in-memory cache for %d hours", len(self._cached_contextual_lhs)
)
_LOGGER.info("Updated contextual LHS for %d hours", persisted_count)
_LOGGER.debug("Exiting LhsLifecycleManager.update_contextual_lhs_from_cycles")
return contextual_lhs_by_hour
async def ensure_contextual_lhs_populated(
self,
target_hour: int,
cycles: list[HeatingCycle],
force_recalculate: bool = False,
) -> float:
"""Ensure contextual LHS is populated for a specific hour.
Lazy Population Strategy:
This method is called when anticipation calculation needs LHS for a specific hour
but it's not in the cache (memory or storage).
Cache Strategy (write operation with lazy loading):
- **If force_recalculate=True**: Skip cache checks, recompute immediately
- **Reads from memory first**: Checks _cached_contextual_lhs[target_hour]
- **On memory cache hit**: Returns immediately (fast path)
- **Reads from storage**: Tries model_storage.get_cached_contextual_lhs(hour)
- **On storage cache hit**: Loads into memory, returns
- **On cache miss**: Computes from provided cycles
- **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value, timestamp)
- **Writes to memory**: Updates _cached_contextual_lhs[hour]
- **Fallback**: Returns global LHS if no contextual data exists
Use Case:
Called during anticipation calculations when:
- Cache is cold (first run after startup)
- Hour-specific LHS was never calculated
- Explicit refresh requested (force_recalculate=True)
Args:
target_hour: Hour to ensure LHS is populated for (0-23).
cycles: Heating cycles to use if computation is needed.
force_recalculate: If True, bypass cache and recompute from cycles.
Returns:
Contextual LHS for the target hour in C/hour, or global LHS fallback.
"""
_LOGGER.debug("Entering LhsLifecycleManager.ensure_contextual_lhs_populated")
_LOGGER.debug(
"Ensuring contextual LHS populated for hour %d (force_recalculate=%s)",
target_hour,
force_recalculate,
)
# If force_recalculate, skip cache checks and recompute THIS HOUR ONLY
if force_recalculate:
_LOGGER.debug("Force recalculate enabled, bypassing cache for hour %d", target_hour)
computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour(
cycles, target_hour
)
# Persist if value exists
if computed_lhs is not None:
updated_at = dt_util.now() if dt_util is not None else datetime.now()
await self._model_storage.set_cached_contextual_lhs(
target_hour, computed_lhs, updated_at
)
# Update memory cache
self._cached_contextual_lhs[target_hour] = computed_lhs
_LOGGER.debug(
"Forced recalculation: contextual LHS for hour %d: %.2f °C/h",
target_hour,
computed_lhs,
)
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
return computed_lhs
# No contextual data, fallback to global LHS
_LOGGER.debug("No contextual LHS for hour %d, falling back to global", target_hour)
global_lhs = await self.get_global_lhs()
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
return global_lhs
# Check memory cache first (fast path)
if target_hour in self._cached_contextual_lhs:
cached_value = self._cached_contextual_lhs[target_hour]
_LOGGER.debug(
"Contextual LHS already in memory cache for hour %d: %.2f °C/h",
target_hour,
cached_value,
)
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
return cached_value
# Check storage cache
cached_entry = await self._model_storage.get_cached_contextual_lhs(target_hour)
if cached_entry is not None:
cached_contextual = cached_entry.value
# Load into memory cache for subsequent calls
self._cached_contextual_lhs[target_hour] = cached_contextual
_LOGGER.debug(
"Loaded contextual LHS from storage for hour %d: %.2f °C/h",
target_hour,
cached_contextual,
)
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
return cached_contextual
# No cache, compute contextual LHS for THIS HOUR ONLY (not all 24)
_LOGGER.debug("No cached contextual LHS for hour %d, computing from cycles", target_hour)
computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour(
cycles, target_hour
)
# If contextual LHS exists, persist and cache
if computed_lhs is not None:
updated_at = dt_util.now() if dt_util is not None else datetime.now()
await self._model_storage.set_cached_contextual_lhs(
target_hour, computed_lhs, updated_at
)
# Cache in memory for subsequent calls
self._cached_contextual_lhs[target_hour] = computed_lhs
_LOGGER.debug(
"Computed and cached contextual LHS for hour %d: %.2f °C/h",
target_hour,
computed_lhs,
)
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
return computed_lhs
# No contextual data, fallback to global LHS
_LOGGER.debug("No contextual LHS for hour %d, falling back to global LHS", target_hour)
global_lhs = await self.get_global_lhs()
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
return global_lhs
@@ -0,0 +1,113 @@
"""Factory for LhsLifecycleManager - wires dependencies with DDD compliance."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .lhs_lifecycle_manager import LhsLifecycleManager
if TYPE_CHECKING:
from ..domain.interfaces import ILhsStorage, ITimerScheduler
from ..domain.services.contextual_lhs_calculator_service import ContextualLHSCalculatorService
from ..domain.services.global_lhs_calculator_service import GlobalLHSCalculatorService
_LOGGER = logging.getLogger(__name__)
class LhsLifecycleManagerFactory:
"""Factory for creating LhsLifecycleManager with singleton pattern.
Singleton Pattern:
- **One instance per device_id**: Each IHP device gets its own manager instance
- **Shared across requests**: Multiple calls with same device_id return same instance
- **Thread-safe**: Factory maintains internal registry to track instances
Note on device_id:
Since LhsLifecycleManager doesn't directly hold device_id, the singleton key
is derived from the model_storage instance (assuming one storage per device).
For proper device-level isolation, ensure each device has its own ILhsStorage.
Dependency Wiring:
- Injects all required dependencies (calculators, storage, scheduler)
- Ensures DDD compliance (domain services, infrastructure adapters)
Usage:
```python
# First call creates instance
manager1 = factory.create(storage, global_calc, contextual_calc, ...)
# Second call with same storage returns same instance
manager2 = factory.create(storage, global_calc, contextual_calc, ...)
assert manager1 is manager2 # True
```
"""
# Class-level registry: storage_id -> LhsLifecycleManager instance
# Using id(model_storage) as key since LHS manager doesn't have device_id directly
_instances: dict[int, LhsLifecycleManager] = {}
@classmethod
def create(
cls,
model_storage: ILhsStorage,
global_lhs_calculator: GlobalLHSCalculatorService,
contextual_lhs_calculator: ContextualLHSCalculatorService,
timer_scheduler: ITimerScheduler | None = None,
) -> LhsLifecycleManager:
"""Create or return existing LhsLifecycleManager for model_storage.
Singleton Behavior:
- Uses id(model_storage) as singleton key (assumes one storage per device)
- If instance exists for this storage, returns existing instance
- If no instance exists, creates new one and stores in registry
- Registry is class-level, shared across all factory instances
Dependency Injection:
- model_storage: Infrastructure adapter for persistent LHS storage
- global_lhs_calculator: Domain service for computing global LHS from cycles
- contextual_lhs_calculator: Domain service for computing contextual LHS by hour
- timer_scheduler: Infrastructure adapter for scheduling 24h refresh
Args:
model_storage: Persistent storage adapter for cached LHS values.
global_lhs_calculator: Service for computing global LHS.
contextual_lhs_calculator: Service for computing contextual LHS by hour.
timer_scheduler: Optional scheduler for periodic 24h refresh.
Returns:
Singleton LhsLifecycleManager instance for the model_storage.
"""
storage_id = id(model_storage)
# Check if instance already exists
if storage_id in cls._instances:
_LOGGER.debug("Returning existing LhsLifecycleManager for storage_id=%s", storage_id)
return cls._instances[storage_id]
# Create new instance
_LOGGER.debug("Creating new LhsLifecycleManager for storage_id=%s", storage_id)
manager = LhsLifecycleManager(
model_storage=model_storage,
global_lhs_calculator=global_lhs_calculator,
contextual_lhs_calculator=contextual_lhs_calculator,
timer_scheduler=timer_scheduler,
)
# Store in registry
cls._instances[storage_id] = manager
_LOGGER.debug("Registered LhsLifecycleManager for storage_id=%s", storage_id)
return manager
@classmethod
def reset_instances(cls) -> None:
"""Clear singleton registry (for testing only).
Use Case:
Called in test teardown to ensure clean state between tests.
Should NOT be used in production code.
"""
cls._instances = {}
_LOGGER.debug("Reset LhsLifecycleManager singleton registry")
@@ -0,0 +1,185 @@
"""Heating Orchestrator.
The orchestrator composes use cases to implement complex workflows.
It coordinates multiple use cases but contains NO business logic itself.
This is the main entry point for heating operations from the infrastructure layer.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .use_cases import (
CalculateAnticipationUseCase,
CheckOvershootRiskUseCase,
ControlPreheatingUseCase,
ScheduleAnticipationActionUseCase,
UpdateCacheDataUseCase,
)
if TYPE_CHECKING:
from datetime import datetime
from typing import Any
_LOGGER = logging.getLogger(__name__)
class HeatingOrchestrator:
"""Orchestrates heating use cases.
The orchestrator is responsible for:
1. Composing use cases to implement workflows
2. Coordinating the execution order of use cases
3. Managing cross-use-case dependencies
NO business logic - pure composition and coordination.
"""
def __init__(
self,
calculate_anticipation: CalculateAnticipationUseCase,
control_preheating: ControlPreheatingUseCase,
schedule_anticipation_action: ScheduleAnticipationActionUseCase,
check_overshoot_risk: CheckOvershootRiskUseCase,
update_cache: UpdateCacheDataUseCase,
) -> None:
"""Initialize the orchestrator with use cases.
Args:
calculate_anticipation: Use case for calculating anticipation data
control_preheating: Use case for controlling preheating
schedule_anticipation_action: Use case for scheduling anticipation actions
check_overshoot_risk: Use case for checking overshoot risk
update_cache: Use case for updating cache
"""
_LOGGER.debug("Initializing HeatingOrchestrator")
self._calculate_anticipation = calculate_anticipation
self._control_preheating = control_preheating
self._schedule_anticipation_action = schedule_anticipation_action
self._check_overshoot_risk = check_overshoot_risk
self._update_cache = update_cache
async def calculate_anticipation_only(
self,
target_time: datetime | None = None,
target_temp: float | None = None,
) -> dict:
"""Calculate anticipation data only (no scheduling).
For users who use the service without a scheduler (via REST API).
Args:
target_time: Target time for heating
target_temp: Target temperature
Returns:
Dict with anticipation data
"""
_LOGGER.debug("Entering HeatingOrchestrator.calculate_anticipation_only()")
result = await self._calculate_anticipation.calculate_anticipation_datas(
target_time=target_time,
target_temp=target_temp,
)
_LOGGER.debug("Exiting calculate_anticipation_only() -> data")
return result
async def disable_preheating(self, scheduler_entity_id: str) -> None:
"""Disable preheating (cancel timer and active preheating).
Args:
scheduler_entity_id: Scheduler entity to cancel
"""
_LOGGER.debug("Entering HeatingOrchestrator.disable_preheating()")
# Delegate to schedule_anticipation_action to cancel timer
await self._schedule_anticipation_action.cancel_action()
# Cancel active preheating if any
await self._control_preheating.cancel_preheating(scheduler_entity_id)
_LOGGER.info("Preheating disabled")
_LOGGER.debug("Exiting HeatingOrchestrator.disable_preheating()")
async def reset_all_learning_data(self, device_id: str) -> None:
"""Reset all learned data (LHS + cycles).
Args:
device_id: Device identifier
"""
_LOGGER.debug("Entering HeatingOrchestrator.reset_all_learning_data()")
await self._update_cache.reset_cache(device_id)
_LOGGER.debug("Exiting HeatingOrchestrator.reset_all_learning_data()")
def is_preheating_active(self) -> bool:
"""Check if preheating is currently active.
Returns:
True if preheating is active, False otherwise
"""
return self._control_preheating.is_preheating_active()
async def calculate_and_schedule_anticipation(self, ihp_enabled: bool = True) -> dict[str, Any]:
"""Calculate anticipation and handle scheduling based on IHP status.
This orchestrator method:
1. Calculates anticipation data
2. Delegates scheduling decisions to ScheduleAnticipationActionUseCase
3. Always returns anticipation data structure
Args:
ihp_enabled: Whether IHP preheating is enabled
Returns:
Dict with anticipation data for sensors (always returns structure)
"""
_LOGGER.debug(
"Entering HeatingOrchestrator.calculate_and_schedule_anticipation(ihp_enabled=%s)",
ihp_enabled,
)
# Step 1: Always calculate anticipation data regardless of IHP state.
# The switch only controls whether preheating is scheduled, not whether
# calculations happen. This ensures sensors and LHS caches stay up-to-date.
anticipation_data = await self._calculate_anticipation.calculate_anticipation_datas()
# Step 2: Delegate scheduling decisions to use case (respects ihp_enabled)
await self._schedule_anticipation_action.handle_anticipation_scheduling(
anticipation_data,
ihp_enabled,
)
_LOGGER.debug("Exiting HeatingOrchestrator.calculate_and_schedule_anticipation() -> data")
return anticipation_data
async def check_and_prevent_overshoot(self, scheduler_entity_id: str) -> bool:
"""Check overshoot risk and cancel preheating if needed.
Args:
scheduler_entity_id: Scheduler entity to cancel if overshoot detected
Returns:
True if overshoot detected and cancellation triggered, False otherwise
"""
_LOGGER.debug(
"Entering HeatingOrchestrator.check_and_prevent_overshoot(scheduler=%s)",
scheduler_entity_id,
)
overshoot_detected = await self._check_overshoot_risk.check_and_prevent_overshoot(
scheduler_entity_id=scheduler_entity_id
)
if overshoot_detected:
await self._schedule_anticipation_action.cancel_action()
_LOGGER.debug(
"Exiting HeatingOrchestrator.check_and_prevent_overshoot() -> %s",
overshoot_detected,
)
return overshoot_detected
@@ -0,0 +1,24 @@
"""Use cases for Intelligent Heating Pilot.
This package contains use cases that encapsulate single business operations.
Each use case is a focused, testable unit of business logic.
Use cases follow the Single Responsibility Principle and are composed by
the HeatingOrchestrator to implement complex workflows.
"""
from __future__ import annotations
from .calculate_anticipation_use_case import CalculateAnticipationUseCase
from .check_overshoot_risk_use_case import CheckOvershootRiskUseCase
from .control_preheating_use_case import ControlPreheatingUseCase
from .schedule_anticipation_action_use_case import ScheduleAnticipationActionUseCase
from .update_cache_data_use_case import UpdateCacheDataUseCase
__all__ = [
"CalculateAnticipationUseCase",
"CheckOvershootRiskUseCase",
"ControlPreheatingUseCase",
"ScheduleAnticipationActionUseCase",
"UpdateCacheDataUseCase",
]
@@ -0,0 +1,314 @@
"""Calculate Anticipation Data Use Case.
This use case calculates the anticipated start time for preheating
based on current conditions and learned heating slopes.
This is a PURE CALCULATION use case - it does NOT schedule or trigger preheating.
For scheduling, use SchedulePreheatingUseCase.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import (
IClimateDataReader,
IEnvironmentReader,
ILhsStorage,
ISchedulerReader,
)
from ...domain.services import DeadTimeCalculationService, PredictionService
from ..heating_cycle_lifecycle_manager import HeatingCycleLifecycleManager
from ..lhs_lifecycle_manager import LhsLifecycleManager
_LOGGER = logging.getLogger(__name__)
class CalculateAnticipationUseCase:
"""Use case for calculating anticipation data (NO preheating scheduling).
This use case encapsulates the pure calculation logic for:
1. Reading the next scheduled timeslot (or using provided target_time)
2. Getting heating cycles for LHS calculation
3. Calculating the anticipated start time based on learned heating slope
4. Returning anticipation data for display/sensors
This does NOT schedule or trigger any preheating action.
"""
def __init__(
self,
scheduler_reader: ISchedulerReader | None,
environment_reader: IEnvironmentReader,
climate_data_reader: IClimateDataReader,
heating_cycle_manager: HeatingCycleLifecycleManager,
lhs_lifecycle_manager: LhsLifecycleManager,
prediction_service: PredictionService,
dead_time_calculator: DeadTimeCalculationService,
auto_learning: bool = True,
default_dead_time_minutes: float = 0.0,
lhs_storage: ILhsStorage | None = None,
) -> None:
"""Initialize the use case.
Args:
scheduler_reader: Reads scheduled timeslots (optional for API-only usage)
environment_reader: Reads current environment conditions
climate_data_reader: Unified reader for VTherm metadata, slope and heating state
heating_cycle_manager: Manages heating cycle lifecycle
lhs_lifecycle_manager: Manages learned heating slopes
prediction_service: Predicts heating time
dead_time_calculator: Calculates dead time from cycles
auto_learning: Whether auto-learning is enabled
default_dead_time_minutes: Default dead time when not learned
lhs_storage: Optional persistent storage for learned values. When provided
and auto_learning is True, the stored learned dead time is used as a
fallback before the configured default, so that the persisted value
is restored immediately after a Home Assistant restart.
"""
_LOGGER.debug("Initializing CalculateAnticipationUseCase")
self._scheduler_reader = scheduler_reader
self._environment_reader = environment_reader
self._climate_data_reader = climate_data_reader
self._heating_cycle_manager = heating_cycle_manager
self._lhs_manager = lhs_lifecycle_manager
self._prediction_service = prediction_service
self._dead_time_calculator = dead_time_calculator
self._auto_learning = auto_learning
self._default_dead_time_minutes = default_dead_time_minutes
self._lhs_storage = lhs_storage
async def calculate_anticipation_datas(
self,
target_time: datetime | None = None,
target_temp: float | None = None,
) -> dict:
"""Calculate anticipation data without scheduling preheating.
Args:
target_time: Target time for heating. If None, uses next scheduled timeslot.
target_temp: Target temperature. If None, uses value from timeslot.
Returns:
Dict with anticipation data. Returns structure with None values for fields
that cannot be calculated.
"""
_LOGGER.debug(
"Entering CalculateAnticipationUseCase.calculate_anticipation_datas(target_time=%s, target_temp=%s)",
target_time.isoformat() if target_time else "None",
target_temp,
)
# Import default constant for LHS validation
from ...domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
# Determine target time and temp
timeslot = None
scheduler_entity = None
timeslot_id = None
# Always get environment data (for minimal return structure)
environment = await self._environment_reader.get_current_environment()
current_temp = environment.indoor_temperature if environment else None
# Always get global LHS (for minimal return structure)
global_lhs = await self._lhs_manager.get_global_lhs()
# Validate global LHS: must be realistically positive (>= 0.5°C/h)
if global_lhs is None or global_lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Invalid global LHS (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
global_lhs or 0,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
global_lhs = DEFAULT_LEARNED_SLOPE
if target_time is None:
# Use scheduler to get next timeslot
if self._scheduler_reader is None:
_LOGGER.debug("No scheduler reader configured and no target_time provided")
# Return minimal data structure
return self._create_data_structure(
current_temp=current_temp,
learned_heating_slope=global_lhs,
)
timeslot = await self._scheduler_reader.get_next_timeslot()
if not timeslot:
_LOGGER.debug("No scheduled timeslot found")
# Return minimal data structure
return self._create_data_structure(
current_temp=current_temp,
learned_heating_slope=global_lhs,
)
target_time = timeslot.target_time
target_temp = timeslot.target_temp
scheduler_entity = timeslot.scheduler_entity
timeslot_id = timeslot.timeslot_id
else:
# Using provided target_time (API/REST usage without scheduler)
if target_temp is None:
_LOGGER.warning("target_time provided but target_temp is None")
# Return minimal data structure
return self._create_data_structure(
current_temp=current_temp,
learned_heating_slope=global_lhs,
)
# Get remaining environment data
outdoor_temp = environment.outdoor_temp if environment else None
humidity = environment.indoor_humidity if environment else None
cloud_coverage = environment.cloud_coverage if environment else None
# Get device ID
vtherm_id = self._climate_data_reader.get_vtherm_entity_id()
# Get heating cycles for LHS calculation
heating_cycles = await self._heating_cycle_manager.get_cycles_for_target_time(
device_id=vtherm_id,
target_time=target_time,
)
# Get contextual LHS
lhs = await self._lhs_manager.get_contextual_lhs(
target_time=target_time,
cycles=heating_cycles,
)
# Validate LHS: must be realistically positive (>= 0.5°C/h)
if lhs is None or lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Invalid contextual LHS (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
lhs or 0,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
lhs = DEFAULT_LEARNED_SLOPE
# Calculate effective dead_time
if self._auto_learning and heating_cycles:
avg_dead_time = self._dead_time_calculator.calculate_average_dead_time(heating_cycles)
if avg_dead_time is not None and avg_dead_time > 0:
dead_time = avg_dead_time
_LOGGER.info(
"Learned dead_time from %d cycles: %.1f minutes",
len(heating_cycles),
dead_time,
)
else:
dead_time = await self._get_persisted_dead_time_or_default()
elif self._auto_learning:
# No cycles available yet (e.g. immediately after restart before extraction).
# Fall back to the persisted learned dead time so the correct value is used
# before the first cycle extraction completes.
dead_time = await self._get_persisted_dead_time_or_default()
else:
dead_time = self._default_dead_time_minutes
# Calculate prediction - let prediction_service handle None values
prediction = self._prediction_service.predict_heating_time(
current_temp=current_temp, # Pass None if unavailable
target_temp=target_temp,
outdoor_temp=outdoor_temp,
humidity=humidity,
learned_slope=lhs,
target_time=target_time,
cloud_coverage=cloud_coverage,
dead_time_minutes=dead_time,
)
_LOGGER.info(
"Calculated anticipation: start at %s (%.1f min) for target %.1f°C at %s (LHS: %.2f°C/h)",
prediction.anticipated_start_time.isoformat(),
prediction.estimated_duration_minutes,
target_temp,
target_time.isoformat(),
prediction.learned_heating_slope,
)
result = self._create_data_structure(
anticipated_start_time=prediction.anticipated_start_time,
next_schedule_time=target_time,
next_target_temperature=target_temp,
anticipation_minutes=prediction.estimated_duration_minutes,
current_temp=current_temp,
learned_heating_slope=prediction.learned_heating_slope,
confidence_level=prediction.confidence_level,
timeslot_id=timeslot_id,
scheduler_entity=scheduler_entity,
dead_time=dead_time,
)
_LOGGER.debug("Exiting calculate_anticipation_datas() -> %s", "data")
return result
def _create_data_structure(
self,
anticipated_start_time: datetime | None = None,
next_schedule_time: datetime | None = None,
next_target_temperature: float | None = None,
anticipation_minutes: float | None = None,
current_temp: float | None = None,
learned_heating_slope: float | None = None,
confidence_level: float | None = None,
timeslot_id: str | None = None,
scheduler_entity: str | None = None,
dead_time: float | None = None,
) -> dict:
"""Create data structure with provided values or None defaults.
Returns consistent structure with each field having a value or None.
"""
return {
"anticipated_start_time": anticipated_start_time,
"next_schedule_time": next_schedule_time,
"next_target_temperature": next_target_temperature,
"anticipation_minutes": anticipation_minutes,
"current_temp": current_temp,
"learned_heating_slope": learned_heating_slope,
"confidence_level": confidence_level,
"timeslot_id": timeslot_id,
"scheduler_entity": scheduler_entity,
"dead_time": dead_time,
}
async def _get_persisted_dead_time_or_default(self) -> float:
"""Return the persisted learned dead time or the configured default.
When auto_learning is enabled, this method tries to restore the last
learned dead time from persistent storage. This is the correct fallback
during startup (before cycle extraction completes) or when cycles do not
yield a valid dead time.
Returns:
Persisted learned dead time if available and positive, otherwise the
configured default dead time.
"""
if self._lhs_storage is not None:
try:
stored = await self._lhs_storage.get_learned_dead_time()
if stored is not None and stored > 0:
_LOGGER.debug(
"Using persisted learned dead_time: %.1f minutes", stored
)
return stored
if stored is not None and stored <= 0:
_LOGGER.debug(
"Ignoring non-positive persisted dead_time %.1f minutes; "
"falling back to configured default",
stored,
)
except Exception: # noqa: BLE001
_LOGGER.warning("Failed to read persisted dead time", exc_info=True)
_LOGGER.debug(
"No persisted dead_time found, using configured default: %.1f minutes",
self._default_dead_time_minutes,
)
return self._default_dead_time_minutes
@@ -0,0 +1,133 @@
"""Check Overshoot Risk Use Case.
This use case detects when heating will overshoot the target temperature
and cancels preheating to avoid overheating.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ...domain.interfaces import (
IClimateDataReader,
IEnvironmentReader,
ISchedulerReader,
)
from .control_preheating_use_case import ControlPreheatingUseCase
_LOGGER = logging.getLogger(__name__)
class CheckOvershootRiskUseCase:
"""Use case for detecting overshoot risk.
This use case encapsulates the logic for:
1. Reading current environment and slope
2. Estimating temperature at target time
3. Canceling preheating if overshoot risk is detected
"""
def __init__(
self,
scheduler_reader: ISchedulerReader,
environment_reader: IEnvironmentReader,
climate_data_reader: IClimateDataReader,
control_preheating: ControlPreheatingUseCase,
overshoot_threshold_celsius: float = 0.5,
) -> None:
"""Initialize the use case.
Args:
scheduler_reader: Reads scheduled timeslots
environment_reader: Reads current environment conditions
climate_data_reader: Reads current heating slope
control_preheating: Cancels preheating when risk detected
overshoot_threshold_celsius: Temperature margin above target to consider as overshoot (°C)
"""
_LOGGER.debug(
"Initializing CheckOvershootRiskUseCase with threshold %.1f°C",
overshoot_threshold_celsius,
)
self._scheduler_reader = scheduler_reader
self._environment_reader = environment_reader
self._climate_data_reader = climate_data_reader
self._control_preheating = control_preheating
self._overshoot_threshold = overshoot_threshold_celsius
async def check_and_prevent_overshoot(self, scheduler_entity_id: str) -> bool:
"""Check for overshoot risk and cancel preheating if needed.
Args:
scheduler_entity_id: Scheduler entity tied to preheating
Returns:
True if overshoot detected and preheating canceled, False otherwise
"""
_LOGGER.debug(
"Entering CheckOvershootRiskUseCase.check_and_prevent_overshoot(scheduler=%s)",
scheduler_entity_id,
)
if not self._control_preheating.is_preheating_active():
_LOGGER.debug("Skipping overshoot check - preheating not active")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
timeslot = await self._scheduler_reader.get_next_timeslot()
if not timeslot:
_LOGGER.debug("Skipping overshoot check - no timeslot available")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
environment = await self._environment_reader.get_current_environment()
if not environment:
# Safety first: If we can't read temperature, assume overshoot risk
_LOGGER.warning("No environment data available - assuming overshoot risk for safety")
await self._control_preheating.cancel_preheating(scheduler_entity_id)
_LOGGER.info("Cancelled preheating due to missing environment data")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> True")
return True
current_slope = self._climate_data_reader.get_current_slope()
if current_slope is None or current_slope <= 0.0:
# Cannot check overshoot without valid slope data
_LOGGER.debug(
"Current slope unavailable (%.2f°C/h) - skipping overshoot check",
current_slope or 0.0,
)
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
now = environment.timestamp
if now >= timeslot.target_time:
_LOGGER.debug("Skipping overshoot check - target time already passed")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
time_to_target_hours = (timeslot.target_time - now).total_seconds() / 3600.0
projected_temp = environment.indoor_temperature + (current_slope * time_to_target_hours)
overshoot_limit = timeslot.target_temp + self._overshoot_threshold
_LOGGER.debug(
"Overshoot check: current=%.1f°C projected=%.1f°C target=%.1f°C limit=%.1f°C",
environment.indoor_temperature,
projected_temp,
timeslot.target_temp,
overshoot_limit,
)
if projected_temp >= overshoot_limit:
_LOGGER.warning(
"Overshoot risk detected: projected %.1f°C exceeds limit %.1f°C",
projected_temp,
overshoot_limit,
)
await self._control_preheating.cancel_preheating(scheduler_entity_id)
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> True")
return True
_LOGGER.debug("No overshoot risk detected")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
@@ -0,0 +1,137 @@
"""Control Preheating Use Case.
This use case controls the preheating state (start/cancel).
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import ISchedulerCommander
_LOGGER = logging.getLogger(__name__)
class ControlPreheatingUseCase:
"""Use case for controlling preheating state.
This use case encapsulates operations for:
1. Starting preheating (trigger scheduler action)
2. Canceling preheating (revert to current scheduled state)
3. Tracking preheating state
"""
def __init__(
self,
scheduler_commander: ISchedulerCommander,
) -> None:
"""Initialize the use case.
Args:
scheduler_commander: Commands scheduler actions
"""
_LOGGER.debug("Initializing ControlPreheatingUseCase")
self._scheduler_commander = scheduler_commander
self._is_preheating_active = False
self._preheating_target_time: datetime | None = None
self._active_scheduler_entity: str | None = None
async def cancel_preheating(self, scheduler_entity_id: str | None = None) -> None:
"""Cancel active preheating and revert to current scheduled state.
Args:
scheduler_entity_id: Scheduler entity to cancel action on.
If None, uses the active scheduler entity.
"""
_LOGGER.debug(
"Entering ControlPreheatingUseCase.cancel_preheating(scheduler=%s)",
scheduler_entity_id,
)
effective_scheduler = scheduler_entity_id or self._active_scheduler_entity
if self._is_preheating_active:
if not effective_scheduler:
_LOGGER.warning("Cannot cancel preheating: no scheduler entity available")
_LOGGER.debug("Exiting ControlPreheatingUseCase.cancel_preheating() -> no-op")
return
_LOGGER.info(
"Canceling preheating for scheduler %s - reverting to current scheduled state",
effective_scheduler,
)
# Call cancel_action to revert thermostat to current time's preset/temperature
# Validation is handled by scheduler_commander
await self._scheduler_commander.cancel_action(effective_scheduler)
else:
_LOGGER.debug("No active preheating to cancel")
# Mark preheating as inactive (but keep target_time for state tracking)
# Target time may still be in future (e.g., cancelled due to overshoot)
self._is_preheating_active = False
# Note: _preheating_target_time and _active_scheduler_entity are NOT cleared
# They remain for state tracking and potential restart
_LOGGER.debug("Exiting ControlPreheatingUseCase.cancel_preheating()")
async def start_preheating(
self,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
) -> None:
"""Start preheating by triggering scheduler action.
Args:
target_time: Target schedule time
target_temp: Target temperature
scheduler_entity_id: Scheduler entity to trigger
"""
_LOGGER.debug(
"Entering ControlPreheatingUseCase.start_preheating(target_time=%s, temp=%.1f, scheduler=%s)",
target_time.isoformat(),
target_temp,
scheduler_entity_id,
)
_LOGGER.info(
"Starting preheating for target %s (%.1f°C)",
target_time.isoformat(),
target_temp,
)
# Use scheduler's run_action to trigger the action
await self._scheduler_commander.run_action(target_time, scheduler_entity_id)
# Mark pre-heating as active
self._is_preheating_active = True
self._preheating_target_time = target_time
self._active_scheduler_entity = scheduler_entity_id
_LOGGER.debug("Exiting ControlPreheatingUseCase.start_preheating()")
def is_preheating_active(self) -> bool:
"""Check if preheating is currently active.
Returns:
True if preheating is active, False otherwise
"""
return self._is_preheating_active
def get_preheating_target_time(self) -> datetime | None:
"""Get the target time for active preheating.
Returns:
Target time if preheating is active, None otherwise
"""
return self._preheating_target_time
def get_active_scheduler_entity(self) -> str | None:
"""Get the active scheduler entity.
Returns:
Scheduler entity ID if active, None otherwise
"""
return self._active_scheduler_entity
@@ -0,0 +1,381 @@
"""Schedule Anticipation Action Use Case.
This use case encapsulates the complex logic for scheduling preheating based on
anticipation calculations, including revert logic when conditions change.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Callable
from homeassistant.util import dt as dt_util
from ...const import DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import ISchedulerCommander, ISchedulerReader, ITimerScheduler
_LOGGER = logging.getLogger(__name__)
class ScheduleAnticipationActionUseCase:
"""Use case for scheduling anticipation actions.
This use case encapsulates the complex logic for:
1. Checking scheduler state
2. Handling revert logic when anticipated start is postponed significantly
3. Scheduling preheating timers
4. Triggering immediate preheating when needed
"""
def __init__(
self,
scheduler_reader: ISchedulerReader,
scheduler_commander: ISchedulerCommander,
timer_scheduler: ITimerScheduler,
control_preheating_use_case, # ControlPreheatingUseCase (avoid circular import)
anticipation_recalc_tolerance_minutes: int = DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
) -> None:
"""Initialize the use case.
Args:
scheduler_reader: Reads scheduler state
scheduler_commander: Triggers scheduler actions
timer_scheduler: Schedules timer callbacks
control_preheating_use_case: Use case for managing preheating state
anticipation_recalc_tolerance_minutes: Min absolute delta in anticipated
start time required to cancel active preheating and reschedule
"""
_LOGGER.debug("Initializing ScheduleAnticipationActionUseCase")
self._scheduler_reader = scheduler_reader
self._scheduler_commander = scheduler_commander
self._timer_scheduler = timer_scheduler
self._control_preheating = control_preheating_use_case
# Scheduling-specific state (not preheating state - delegated to ControlPreheatingUseCase)
self._last_scheduled_time: datetime | None = None
self._last_scheduled_lhs: float | None = None
self._anticipation_timer_cancel: Callable[[], None] | None = None
self._preheating_target_temp: float | None = None # Temp only (time/active delegated)
self._anticipation_recalc_tolerance_seconds = anticipation_recalc_tolerance_minutes * 60
def set_preheating_temp(self, target_temp: float | None) -> None:
"""Update preheating target temperature.
Note: Preheating state (active, target_time) is managed by ControlPreheatingUseCase.
Args:
target_temp: Target temperature
"""
_LOGGER.debug("Setting preheating target temp: %.1f", target_temp or 0.0)
self._preheating_target_temp = target_temp
async def handle_anticipation_scheduling(
self,
anticipation_data: dict,
ihp_enabled: bool,
) -> None:
"""Handle the complete scheduling workflow based on anticipation data and IHP status.
This method contains all the business logic for deciding when to schedule/cancel
preheating based on data availability, IHP status, and current state.
Args:
anticipation_data: Calculated anticipation data
ihp_enabled: Whether IHP is enabled
"""
_LOGGER.debug(
"Entering handle_anticipation_scheduling(ihp_enabled=%s, has_data=%s)",
ihp_enabled,
anticipation_data.get("anticipated_start_time") is not None,
)
# Decision 1: No valid data - cancel everything
if anticipation_data.get("anticipated_start_time") is None:
_LOGGER.debug("No valid anticipation data - cancelling any active scheduling")
await self.cancel_action()
await self._control_preheating.cancel_preheating(
self._control_preheating.get_active_scheduler_entity()
or anticipation_data.get("scheduler_entity")
)
return
# Decision 2: IHP disabled - cancel but don't schedule
if not ihp_enabled:
_LOGGER.debug("IHP disabled - cancelling preheating if active")
await self._control_preheating.cancel_preheating(
self._control_preheating.get_active_scheduler_entity()
or anticipation_data.get("scheduler_entity")
)
await self.cancel_action()
return
# Decision 3: Target already reached (anticipation_minutes == 0) - clear state
if anticipation_data.get("anticipation_minutes") == 0:
_LOGGER.debug("Target reached - clearing anticipation state")
await self.cancel_action()
await self._control_preheating.cancel_preheating(
self._control_preheating.get_active_scheduler_entity()
or anticipation_data.get("scheduler_entity")
)
return
# Decision 4: No scheduler entity - skip scheduling
scheduler_entity = anticipation_data.get("scheduler_entity")
if not scheduler_entity:
_LOGGER.debug("No scheduler entity - skipping scheduling")
return
# Decision 5: Valid data + IHP enabled + scheduler available - schedule
await self.schedule_action(
anticipated_start=anticipation_data["anticipated_start_time"],
target_time=anticipation_data["next_schedule_time"],
target_temp=anticipation_data["next_target_temperature"],
scheduler_entity_id=scheduler_entity,
lhs=float(anticipation_data.get("learned_heating_slope") or 0.0),
)
async def schedule_action(
self,
anticipated_start: datetime,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
lhs: float,
) -> None:
"""Schedule heating anticipation action.
This method handles all the scheduling logic including:
- Checking scheduler state
- Handling revert when anticipated time changes significantly
- Scheduling timers for future starts
- Triggering immediate preheating if start is in the past
Args:
anticipated_start: When to start preheating
target_time: Target schedule time
target_temp: Target temperature
scheduler_entity_id: Scheduler entity to trigger
lhs: Learned heating slope
"""
_LOGGER.debug(
"Entering ScheduleAnticipationActionUseCase.schedule_action("
"anticipated_start=%s, scheduler=%s)",
anticipated_start.isoformat(),
scheduler_entity_id,
)
now = dt_util.now()
# Check if scheduler is enabled
if not await self._scheduler_reader.is_scheduler_enabled(scheduler_entity_id):
_LOGGER.warning(
"Scheduler %s is disabled. Skipping anticipation scheduling.",
scheduler_entity_id,
)
active_scheduler = self._control_preheating.get_active_scheduler_entity()
if active_scheduler == scheduler_entity_id:
await self._clear_state()
return
# Handle revert logic: cancel active preheating only when anticipated start is pushed later
# by at least the configured tolerance.
if self._control_preheating.is_preheating_active():
preheating_target = self._control_preheating.get_preheating_target_time()
postponed_seconds = 0.0
if self._last_scheduled_time is not None:
postponed_seconds = (anticipated_start - self._last_scheduled_time).total_seconds()
if (
preheating_target == target_time
and self._last_scheduled_time is not None
and postponed_seconds >= self._anticipation_recalc_tolerance_seconds
):
delta_minutes = postponed_seconds / 60.0
_LOGGER.info(
"Anticipated start moved later significantly (delta: %.1f min, "
"threshold: %.1f min). Reverting active preheating and rescheduling.",
delta_minutes,
self._anticipation_recalc_tolerance_seconds / 60.0,
)
# Delegate cancellation to ControlPreheatingUseCase
await self._control_preheating.cancel_preheating(scheduler_entity_id)
# If target time reached, mark complete
if now >= target_time:
_LOGGER.info("Target time reached, preheating complete")
await self._clear_state()
return
# Update tracking
self._last_scheduled_time = anticipated_start
self._last_scheduled_lhs = lhs
# If anticipated start is in past but target is future, trigger now
if anticipated_start <= now < target_time:
if not self._control_preheating.is_preheating_active():
_LOGGER.info(
"Anticipated start %s is past, triggering preheating immediately",
anticipated_start.isoformat(),
)
# Delegate to ControlPreheatingUseCase
await self._control_preheating.start_preheating(
target_time, target_temp, scheduler_entity_id
)
else:
_LOGGER.debug(
"Already preheating, continuing through target time %s", target_time.isoformat()
)
_LOGGER.debug("Exiting schedule_action() -> immediate trigger")
return
# Both times in past - skip
if anticipated_start <= now and target_time <= now:
_LOGGER.debug("Both times are in past, skipping")
await self._cancel_timer()
return
# Schedule timer for future start only if preheating is not already active.
if not self._control_preheating.is_preheating_active():
await self._schedule_timer(
anticipated_start,
target_time,
target_temp,
scheduler_entity_id,
)
_LOGGER.debug("Exiting schedule_action() -> timer scheduled")
async def _schedule_timer(
self,
anticipated_start: datetime,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
) -> None:
"""Schedule a timer to trigger preheating at anticipated start time.
Args:
anticipated_start: When timer should fire
target_time: Target schedule time
target_temp: Target temperature
scheduler_entity_id: Scheduler to trigger
"""
_LOGGER.debug(
"Scheduling timer: anticipated_start=%s, scheduler=%s",
anticipated_start.isoformat(),
scheduler_entity_id,
)
# Cancel existing timer
await self._cancel_timer()
# Create callback
async def _timer_callback() -> None:
"""Execute when timer fires."""
_LOGGER.info(
"Anticipation timer fired at %s for target %s (%.1f°C)",
dt_util.now().isoformat(),
target_time.isoformat(),
target_temp,
)
# Clear timer reference
self._anticipation_timer_cancel = None
# Trigger the action
await self._trigger_action(target_time, target_temp, scheduler_entity_id)
# Schedule the timer
self._anticipation_timer_cancel = self._timer_scheduler.schedule_timer(
anticipated_start,
_timer_callback,
)
now = dt_util.now()
wait_minutes = (anticipated_start - now).total_seconds() / 60.0
_LOGGER.info(
"Anticipation timer scheduled: will trigger at %s (in %.1f minutes)",
anticipated_start.isoformat(),
wait_minutes,
)
async def _trigger_action(
self,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
) -> None:
"""Trigger the preheating action via scheduler.
Args:
target_time: Target time
target_temp: Target temperature
scheduler_entity_id: Scheduler entity
"""
_LOGGER.debug(
"Triggering anticipation action: target_time=%s, temp=%.1f°C",
target_time.isoformat(),
target_temp,
)
# Verify scheduler is still enabled
if not await self._scheduler_reader.is_scheduler_enabled(scheduler_entity_id):
_LOGGER.warning("Scheduler %s is disabled, cannot trigger action", scheduler_entity_id)
await self._clear_state()
return
# Delegate to ControlPreheatingUseCase
await self._control_preheating.start_preheating(
target_time, target_temp, scheduler_entity_id
)
self._preheating_target_temp = target_temp
_LOGGER.debug("Action triggered successfully")
async def _cancel_timer(self) -> None:
"""Cancel any active timer.
Note: Does NOT clear _preheating_target_time - that's preserved for state tracking.
"""
_LOGGER.debug("Entering cancel_timer")
if self._anticipation_timer_cancel:
_LOGGER.debug("Canceling active timer")
self._anticipation_timer_cancel()
self._anticipation_timer_cancel = None
# Note: Timer cancelled but target_time preserved (as per review feedback)
_LOGGER.debug("Exiting cancel_timer")
async def _clear_state(self) -> None:
"""Clear all tracking state."""
_LOGGER.debug("Clearing anticipation state")
await self._cancel_timer()
# Clear scheduling-specific state
self._preheating_target_temp = None
self._last_scheduled_time = None
self._last_scheduled_lhs = None
# Note: Preheating active/target_time managed by ControlPreheatingUseCase
async def cancel_action(self) -> None:
"""Cancel any active timer and clear state.
Called by orchestrator when disabling preheating or when conditions change.
"""
_LOGGER.debug("Canceling anticipation action")
await self._cancel_timer()
def get_preheating_state(self) -> tuple[bool, datetime | None, float | None]:
"""Get current preheating state.
Delegates to ControlPreheatingUseCase for state queries.
Returns:
Tuple of (is_active, target_time, target_temp)
"""
return (
self._control_preheating.is_preheating_active(),
self._control_preheating.get_preheating_target_time(),
self._preheating_target_temp,
)
@@ -0,0 +1,156 @@
"""Update Cache Data Use Case.
This use case manages the heating cycle cache:
- Get cache data
- Update/prune cache
- Reset cache completely
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import IHeatingCycleStorage, ILhsStorage
from ...domain.value_objects import HeatingCycle, HeatingCycleCacheData
from ..lhs_lifecycle_manager import LhsLifecycleManager
_LOGGER = logging.getLogger(__name__)
class UpdateCacheDataUseCase:
"""Use case for managing heating cycle cache.
This use case encapsulates the logic for:
1. Getting cycles from cache
2. Updating cache (append new cycles, then prune old ones)
3. Recalculating LHS when cycles change
4. Resetting cache completely
"""
def __init__(
self,
cycle_storage: IHeatingCycleStorage,
lhs_storage: ILhsStorage,
lhs_lifecycle_manager: LhsLifecycleManager,
) -> None:
"""Initialize the use case.
Args:
cycle_storage: Heating cycle cache storage
lhs_storage: LHS storage for clearing
lhs_lifecycle_manager: Manages LHS recalculation
"""
_LOGGER.debug("Initializing UpdateCacheDataUseCase")
self._cycle_storage = cycle_storage
self._lhs_storage = lhs_storage
self._lhs_manager = lhs_lifecycle_manager
async def get_cache_data(self, device_id: str) -> HeatingCycleCacheData | None:
"""Get cache data for a device.
Args:
device_id: Device identifier
Returns:
Cache data if exists, None otherwise
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.get_cache_data(device_id=%s)",
device_id,
)
cache_data = await self._cycle_storage.get_cache_data(device_id)
_LOGGER.debug(
"Exiting UpdateCacheDataUseCase.get_cache_data() -> %s",
"data" if cache_data else "None",
)
return cache_data
async def append_cycles(
self,
device_id: str,
cycles: list[HeatingCycle],
reference_time: datetime,
) -> None:
"""Append new cycles to cache and prune old ones.
Args:
device_id: Device identifier
cycles: New heating cycles to append
reference_time: Reference time for pruning old cycles
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.append_cycles(device_id=%s, cycles=%d)",
device_id,
len(cycles),
)
# Append cycles to storage (storage handles deduplication)
await self._cycle_storage.append_cycles(device_id, cycles, reference_time)
_LOGGER.info(
"Appended %d cycles to cache for device %s",
len(cycles),
device_id,
)
# Prune old cycles based on retention
await self.prune_old_cycles(device_id, reference_time)
_LOGGER.debug("Exiting UpdateCacheDataUseCase.append_cycles()")
async def prune_old_cycles(
self,
device_id: str,
reference_time: datetime,
) -> None:
"""Prune cycles older than retention period and recalculate LHS.
Args:
device_id: Device identifier
reference_time: Reference time for retention calculation
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.prune_old_cycles(device_id=%s, reference_time=%s)",
device_id,
reference_time.isoformat(),
)
# Prune old cycles
await self._cycle_storage.prune_old_cycles(device_id, reference_time)
# Note: LHS is automatically updated when cycles change via event listeners
# No need to explicitly recalculate here
_LOGGER.info("Old cycles pruned, LHS will be recalculated automatically")
_LOGGER.debug("Exiting UpdateCacheDataUseCase.prune_old_cycles()")
async def reset_cache(self, device_id: str) -> None:
"""Reset cache completely for a device (both cycles and LHS).
This deletes all cached cycles and LHS data.
Args:
device_id: Device identifier
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.reset_cache(device_id=%s)",
device_id,
)
_LOGGER.info("Resetting heating cycle cache and LHS for device %s", device_id)
# Clear all cycle cache data
await self._cycle_storage.clear_cache(device_id)
_LOGGER.info("Heating cycle cache has been reset")
# Clear LHS data
await self._lhs_storage.clear_slope_history()
_LOGGER.info("LHS data has been reset")
_LOGGER.info("Cache and LHS have been reset for device %s", device_id)
_LOGGER.debug("Exiting UpdateCacheDataUseCase.reset_cache()")
@@ -0,0 +1,583 @@
"""Config flow for Intelligent Heating Pilot integration."""
from __future__ import annotations
import logging
from typing import Any, cast
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from .const import (
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
CONF_AUTO_LEARNING,
CONF_CLOUD_COVER_ENTITY,
CONF_CYCLE_SPLIT_DURATION_MINUTES,
CONF_DEAD_TIME_MINUTES,
CONF_HUMIDITY_IN_ENTITY,
CONF_HUMIDITY_OUT_ENTITY,
CONF_LHS_RETENTION_DAYS,
CONF_MAX_CYCLE_DURATION_MINUTES,
CONF_MIN_CYCLE_DURATION_MINUTES,
CONF_NAME,
CONF_SAFETY_SHUTOFF_GRACE_MINUTES,
CONF_SCHEDULER_ENTITIES,
CONF_TASK_RANGE_DAYS,
CONF_TEMP_DELTA_THRESHOLD,
CONF_VTHERM_ENTITY,
DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
DEFAULT_AUTO_LEARNING,
DEFAULT_CYCLE_SPLIT_DURATION_MINUTES,
DEFAULT_DEAD_TIME_MINUTES,
DEFAULT_LHS_RETENTION_DAYS,
DEFAULT_MAX_CYCLE_DURATION_MINUTES,
DEFAULT_MIN_CYCLE_DURATION_MINUTES,
DEFAULT_NAME,
DEFAULT_SAFETY_SHUTOFF_GRACE_MINUTES,
DEFAULT_TASK_RANGE_DAYS,
DEFAULT_TEMP_DELTA_THRESHOLD,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
class IntelligentHeatingPilotConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
"""Handle a config flow for Intelligent Heating Pilot."""
VERSION = 1
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> IntelligentHeatingPilotOptionsFlow:
"""Get the options flow for this handler."""
return IntelligentHeatingPilotOptionsFlow()
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
processed: dict[str, Any] = {**user_input}
_LOGGER.debug("Received user_input in async_step_user: %s", user_input)
# Schedulers already a list from SelectSelector (multiple=True)
# Just ensure it's always a list
sched_in = processed.get(CONF_SCHEDULER_ENTITIES, [])
if not isinstance(sched_in, list):
sched_in = [sched_in] if sched_in else []
processed[CONF_SCHEDULER_ENTITIES] = sched_in
# Note: EntitySelector optional fields are simply not present in user_input if not filled
# No need to remove them as they won't exist in the dict
_LOGGER.debug("Processed data before validation: %s", processed)
# Required validations
vtherm_val = processed.get(CONF_VTHERM_ENTITY)
if not vtherm_val or (isinstance(vtherm_val, str) and vtherm_val.strip() == ""):
errors[CONF_VTHERM_ENTITY] = "required"
# Scheduler is now optional - no validation needed
if not errors:
_LOGGER.info("Creating entry with data: %s", processed)
await self.async_set_unique_id(processed[CONF_NAME])
self._abort_if_unique_id_configured()
return cast(
FlowResult,
self.async_create_entry(
title=processed[CONF_NAME],
data=processed,
),
)
# Get all scheduler entities with their friendly names
scheduler_options = []
for state in self.hass.states.async_all():
if state.domain not in ("switch", "schedule"):
continue
# Filter for scheduler entities (they typically have "schedule_" prefix or scheduler attributes)
if (
"schedule" in state.entity_id.lower()
or state.attributes.get("next_trigger")
or state.attributes.get("next_event")
):
friendly_name = state.attributes.get("friendly_name", state.entity_id)
scheduler_options.append(
{"value": state.entity_id, "label": f"{friendly_name} ({state.entity_id})"}
)
# Sort by label for easier selection
scheduler_options.sort(key=lambda x: x["label"])
# Build the schema for the configuration form using Entity and Select selectors (same as options flow)
# Scheduler selector: use SelectSelector if options available, else EntitySelector
scheduler_selector = (
selector.SelectSelector(
selector.SelectSelectorConfig(
options=scheduler_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
if scheduler_options
else selector.EntitySelector(
selector.EntitySelectorConfig(domain=["switch", "schedule"], multiple=True)
)
)
data_schema = vol.Schema(
{
vol.Required(CONF_NAME, default=DEFAULT_NAME): str,
vol.Required(CONF_VTHERM_ENTITY): selector.EntitySelector(
selector.EntitySelectorConfig(
domain="climate", integration="versatile_thermostat"
)
),
vol.Optional(CONF_SCHEDULER_ENTITIES): scheduler_selector,
vol.Optional(CONF_HUMIDITY_IN_ENTITY): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class="humidity")
),
vol.Optional(CONF_HUMIDITY_OUT_ENTITY): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class="humidity")
),
vol.Optional(CONF_CLOUD_COVER_ENTITY): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor")
),
vol.Optional(
CONF_LHS_RETENTION_DAYS, default=DEFAULT_LHS_RETENTION_DAYS
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=7,
max=90,
step=1,
unit_of_measurement="days",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_DEAD_TIME_MINUTES, default=DEFAULT_DEAD_TIME_MINUTES
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.0,
max=60.0,
step=1.0,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_AUTO_LEARNING, default=DEFAULT_AUTO_LEARNING
): selector.BooleanSelector(),
vol.Optional(
CONF_TEMP_DELTA_THRESHOLD, default=DEFAULT_TEMP_DELTA_THRESHOLD
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.1,
max=1.0,
step=0.1,
unit_of_measurement="°C",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_CYCLE_SPLIT_DURATION_MINUTES, default=DEFAULT_CYCLE_SPLIT_DURATION_MINUTES
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=120,
step=5,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_MIN_CYCLE_DURATION_MINUTES, default=DEFAULT_MIN_CYCLE_DURATION_MINUTES
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=30,
step=1,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_MAX_CYCLE_DURATION_MINUTES, default=DEFAULT_MAX_CYCLE_DURATION_MINUTES
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=15,
max=360,
step=5,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_TASK_RANGE_DAYS, default=DEFAULT_TASK_RANGE_DAYS
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=30,
step=1,
unit_of_measurement="days",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
default=DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=60,
step=1,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Optional(
CONF_SAFETY_SHUTOFF_GRACE_MINUTES,
default=DEFAULT_SAFETY_SHUTOFF_GRACE_MINUTES,
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=30,
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
}
)
return cast(
FlowResult,
self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
),
)
class IntelligentHeatingPilotOptionsFlow(config_entries.OptionsFlow):
"""Handle options flow for Intelligent Heating Pilot."""
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Manage the options."""
errors: dict[str, str] = {}
normalized_input: dict[str, Any] = {}
if user_input is not None:
# Start with user input
normalized_input = {**user_input}
# Schedulers already a list from SelectSelector (multiple=True)
# Just ensure it's always a list
sched_in = normalized_input.get(CONF_SCHEDULER_ENTITIES, [])
if not isinstance(sched_in, list):
sched_in = [sched_in] if sched_in else []
normalized_input[CONF_SCHEDULER_ENTITIES] = sched_in
# For optional fields: if not in user_input OR empty, explicitly mark for deletion
optional_entity_fields = [
CONF_HUMIDITY_IN_ENTITY,
CONF_HUMIDITY_OUT_ENTITY,
CONF_CLOUD_COVER_ENTITY,
]
fields_to_delete = []
for field in optional_entity_fields:
if field not in user_input:
# Field not submitted = user cleared it
fields_to_delete.append(field)
elif not user_input[field] or (
isinstance(user_input[field], str) and user_input[field].strip() == ""
):
# Field submitted but empty
fields_to_delete.append(field)
# Validate required fields
if not normalized_input.get(CONF_VTHERM_ENTITY):
errors[CONF_VTHERM_ENTITY] = "required"
# Scheduler is now optional - no validation needed
if not errors:
# Merge with existing options
merged_options: dict[str, Any] = {**self.config_entry.options}
merged_options.update(normalized_input)
# For optional fields that were cleared: explicitly set to None to override data
for field in fields_to_delete:
merged_options[field] = None
return cast(
FlowResult,
self.async_create_entry(title="", data=merged_options),
)
# Prepare data sources for defaults
current_options = getattr(self.config_entry, "options", {})
if user_input is not None and errors:
# Re-display user's attempted values on validation errors
current_data = {**self.config_entry.data, **current_options, **normalized_input}
else:
current_data = {**self.config_entry.data, **current_options}
def _opt_or_data(key: str, default: Any = None) -> Any:
# First check options (overrides data)
if key in current_options:
val = current_options.get(key)
# None means explicitly deleted, don't fallback to data
if val is None:
return default
return val
# Fallback to data
return current_data.get(key, default)
# Get all scheduler entities for SelectSelector
scheduler_options = []
for state in self.hass.states.async_all():
if state.domain not in ("switch", "schedule"):
continue
if (
"schedule" in state.entity_id.lower()
or state.attributes.get("next_trigger")
or state.attributes.get("next_event")
):
friendly_name = state.attributes.get("friendly_name", state.entity_id)
scheduler_options.append(
{"value": state.entity_id, "label": f"{friendly_name} ({state.entity_id})"}
)
scheduler_options.sort(key=lambda x: x["label"])
# Compute defaults for entity selectors
default_schedulers_list = _opt_or_data(CONF_SCHEDULER_ENTITIES, [])
if isinstance(default_schedulers_list, str):
default_schedulers_list = [default_schedulers_list]
vtherm_val = _opt_or_data(CONF_VTHERM_ENTITY)
hum_in_val = _opt_or_data(CONF_HUMIDITY_IN_ENTITY)
hum_out_val = _opt_or_data(CONF_HUMIDITY_OUT_ENTITY)
cloud_val = _opt_or_data(CONF_CLOUD_COVER_ENTITY)
# Build schema dynamically: only set default if value exists and is non-empty
schema_dict: dict[Any, Any] = {}
# Required: VTherm (only set default if exists and non-empty)
vtherm_field = (
vol.Required(CONF_VTHERM_ENTITY, default=vtherm_val)
if vtherm_val and vtherm_val != ""
else vol.Required(CONF_VTHERM_ENTITY)
)
schema_dict[vtherm_field] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="climate", integration="versatile_thermostat")
)
# Optional: Schedulers (multiple, only set default if non-empty list)
scheduler_selector = (
selector.SelectSelector(
selector.SelectSelectorConfig(
options=scheduler_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
)
)
if scheduler_options
else selector.EntitySelector(
selector.EntitySelectorConfig(domain=["switch", "schedule"], multiple=True)
)
)
schedulers_field = (
vol.Optional(CONF_SCHEDULER_ENTITIES, default=default_schedulers_list)
if default_schedulers_list and len(default_schedulers_list) > 0
else vol.Optional(CONF_SCHEDULER_ENTITIES)
)
schema_dict[schedulers_field] = scheduler_selector
# Optional humidity/cloud fields: Use suggested_value to pre-fill while allowing clearing
schema_dict[
vol.Optional(
CONF_HUMIDITY_IN_ENTITY,
description={"suggested_value": hum_in_val}
if hum_in_val and hum_in_val != ""
else {},
)
] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class="humidity")
)
schema_dict[
vol.Optional(
CONF_HUMIDITY_OUT_ENTITY,
description={"suggested_value": hum_out_val}
if hum_out_val and hum_out_val != ""
else {},
)
] = selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class="humidity")
)
schema_dict[
vol.Optional(
CONF_CLOUD_COVER_ENTITY,
description={"suggested_value": cloud_val} if cloud_val and cloud_val != "" else {},
)
] = selector.EntitySelector(selector.EntitySelectorConfig(domain="sensor"))
# Numeric fields
schema_dict[
vol.Optional(
CONF_LHS_RETENTION_DAYS,
default=_opt_or_data(CONF_LHS_RETENTION_DAYS, DEFAULT_LHS_RETENTION_DAYS),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=7,
max=90,
step=1,
unit_of_measurement="days",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_DEAD_TIME_MINUTES,
default=_opt_or_data(CONF_DEAD_TIME_MINUTES, DEFAULT_DEAD_TIME_MINUTES),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.0,
max=60.0,
step=1.0,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_AUTO_LEARNING, default=_opt_or_data(CONF_AUTO_LEARNING, DEFAULT_AUTO_LEARNING)
)
] = selector.BooleanSelector()
schema_dict[
vol.Optional(
CONF_TEMP_DELTA_THRESHOLD,
default=_opt_or_data(CONF_TEMP_DELTA_THRESHOLD, DEFAULT_TEMP_DELTA_THRESHOLD),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.1,
max=1.0,
step=0.1,
unit_of_measurement="°C",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_CYCLE_SPLIT_DURATION_MINUTES,
default=_opt_or_data(
CONF_CYCLE_SPLIT_DURATION_MINUTES, DEFAULT_CYCLE_SPLIT_DURATION_MINUTES
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=120,
step=5,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_MIN_CYCLE_DURATION_MINUTES,
default=_opt_or_data(
CONF_MIN_CYCLE_DURATION_MINUTES, DEFAULT_MIN_CYCLE_DURATION_MINUTES
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=30,
step=1,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_MAX_CYCLE_DURATION_MINUTES,
default=_opt_or_data(
CONF_MAX_CYCLE_DURATION_MINUTES, DEFAULT_MAX_CYCLE_DURATION_MINUTES
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=15,
max=360,
step=5,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_TASK_RANGE_DAYS,
default=_opt_or_data(CONF_TASK_RANGE_DAYS, DEFAULT_TASK_RANGE_DAYS),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=30,
step=1,
unit_of_measurement="days",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
default=_opt_or_data(
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=60,
step=1,
unit_of_measurement="minutes",
mode=selector.NumberSelectorMode.BOX,
)
)
schema_dict[
vol.Optional(
CONF_SAFETY_SHUTOFF_GRACE_MINUTES,
default=_opt_or_data(
CONF_SAFETY_SHUTOFF_GRACE_MINUTES, DEFAULT_SAFETY_SHUTOFF_GRACE_MINUTES
),
)
] = selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=30,
step=1,
mode=selector.NumberSelectorMode.BOX,
)
)
data_schema = vol.Schema(schema_dict)
return cast(
FlowResult,
self.async_show_form(
step_id="init",
data_schema=data_schema,
errors=errors,
),
)
@@ -0,0 +1,103 @@
"""Constants for the Intelligent Heating Pilot integration."""
VERSION = "0.6.2"
DOMAIN = "intelligent_heating_pilot"
EVENT_DEAD_TIME_UPDATED = f"{DOMAIN}_dead_time_updated"
# Configuration keys
CONF_NAME = "name"
CONF_VTHERM_ENTITY = "vtherm_entity_id"
CONF_SCHEDULER_ENTITIES = "scheduler_entities"
CONF_HUMIDITY_IN_ENTITY = "humidity_in_entity_id"
CONF_HUMIDITY_OUT_ENTITY = "humidity_out_entity_id"
CONF_CLOUD_COVER_ENTITY = "cloud_cover_entity_id"
CONF_DATA_RETENTION_DAYS = (
"data_retention_days" # Retention period for all IHP data (cycles, slopes, etc.)
)
CONF_LHS_RETENTION_DAYS = "lhs_retention_days" # Deprecated: Use CONF_DATA_RETENTION_DAYS
CONF_DECISION_MODE = "decision_mode" # NEW: Choose between 'simple' and 'ml'
CONF_DEAD_TIME_MINUTES = "dead_time_minutes" # Dead time in minutes (initial heating delay)
CONF_AUTO_LEARNING = "auto_learning" # Auto-learn parameters from heating cycles
# Heating Cycle Detection Parameters
CONF_TEMP_DELTA_THRESHOLD = "temp_delta_threshold"
CONF_CYCLE_SPLIT_DURATION_MINUTES = "cycle_split_duration_minutes"
CONF_MIN_CYCLE_DURATION_MINUTES = "min_cycle_duration_minutes"
CONF_MAX_CYCLE_DURATION_MINUTES = "max_cycle_duration_minutes"
CONF_IHP_ENABLED = "ihp_enabled" # Enable/disable IHP preheating
CONF_TASK_RANGE_DAYS = "task_range_days" # Number of days covered by each Recorder extraction task
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES = (
"anticipation_recalc_tolerance_minutes" # Min delta for active preheating revert
)
# Legacy keys (kept for backward compatibility if needed)
CONF_THERMAL_SLOPE_ENTITY = "thermal_slope_entity"
CONF_CURRENT_TEMP_ENTITY = "current_temp_entity"
CONF_CURRENT_HUMIDITY_ENTITY = "current_humidity_entity"
CONF_TARGET_TEMP_ENTITY = "target_temp_entity"
CONF_OUTDOOR_TEMP_ENTITY = "outdoor_temp_entity"
CONF_OUTDOOR_HUMIDITY_ENTITY = "outdoor_humidity_entity"
CONF_CLOUD_COVERAGE_ENTITY = "cloud_coverage_entity"
CONF_SCHEDULER_ENTITY = "scheduler_entity"
# Decision modes
DECISION_MODE_SIMPLE = "simple" # Rule-based decisions (no ML)
DECISION_MODE_ML = "ml" # AI-powered decisions (requires IHP-ML-Models)
# Default values
DEFAULT_NAME = "Intelligent Heating Pilot"
DEFAULT_DATA_RETENTION_DAYS = 30 # Keep IHP data (cycles, slopes, etc.) for 30 days
DEFAULT_LHS_RETENTION_DAYS = 30 # Deprecated: Use DEFAULT_DATA_RETENTION_DAYS
DEFAULT_DECISION_MODE = DECISION_MODE_SIMPLE # Simple mode by default
DEFAULT_DEAD_TIME_MINUTES = 0.0 # Default dead time (0 = will be learned from cycles)
DEFAULT_AUTO_LEARNING = True # Auto-learning enabled by default
DEFAULT_TASK_RANGE_DAYS = 7 # Number of days per Recorder extraction task (tune to machine power)
DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES = 15 # Default revert threshold for time shifts
# Default values for Heating Cycle Detection
DEFAULT_TEMP_DELTA_THRESHOLD = 0.2 # °C threshold for cycle start/end detection
DEFAULT_CYCLE_SPLIT_DURATION_MINUTES = 0 # 0 = No splitting (disabled)
DEFAULT_MIN_CYCLE_DURATION_MINUTES = 5 # Minimum 5 minutes
DEFAULT_MAX_CYCLE_DURATION_MINUTES = 300 # Maximum 5 hours
# Safety shutoff grace period: brief interruptions shorter than this threshold (e.g. frost/safety mode)
# will NOT terminate an in-progress heating cycle, preventing bogus slope values.
# Note: 10 minutes is the recommended production default (enabled for all new/upgraded devices).
# HeatingCycleService.__init__ defaults to 0 for test-isolation purposes; production always reads
# this value from HA config (or falls back to DEFAULT_SAFETY_SHUTOFF_GRACE_MINUTES).
CONF_SAFETY_SHUTOFF_GRACE_MINUTES = "safety_shutoff_grace_minutes"
DEFAULT_SAFETY_SHUTOFF_GRACE_MINUTES = 10 # 10 minutes tolerance for safety/frost interruptions
# Service names
SERVICE_CALCULATE_ANTICIPATED_START_TIME = "calculate_anticipated_start_time"
SERVICE_SCHEDULER_RUN_ACTION = "run_action"
# Attributes
ATTR_START_TIME = "start_time"
ATTR_ANTICIPATED_START_TIME = "anticipated_start_time"
ATTR_NEXT_SCHEDULE_TIME = "next_schedule_time"
ATTR_NEXT_TARGET_TEMP = "next_target_temperature"
ATTR_PREHEAT_DURATION = "preheat_duration_minutes"
ATTR_CURRENT_TEMP = "current_temperature"
ATTR_CURRENT_HUMIDITY = "current_humidity"
ATTR_TARGET_TEMP = "target_temperature"
ATTR_OUTDOOR_TEMP = "outdoor_temperature"
ATTR_OUTDOOR_HUMIDITY = "outdoor_humidity"
ATTR_CLOUD_COVERAGE = "cloud_coverage"
ATTR_TARGET_TIME = "target_time"
ATTR_THERMAL_SLOPE = "thermal_slope"
ATTR_LEARNED_HEATING_SLOPE = "learned_heating_slope"
ATTR_MAX_HEATING_SLOPE = "max_heating_slope"
# VTherm specific attributes
VTHERM_ATTR_SLOPE = "temperature_slope"
VTHERM_ATTR_TEMPERATURE = "temperature"
VTHERM_ATTR_CURRENT_TEMPERATURE = "ema_temp" # Actual room temperature (exponential moving average)
# Scheduler specific attributes
SCHEDULER_ATTR_NEXT_SCHEDULE = "next_entries"
SCHEDULER_ATTR_NEXT_TIME = "next_time"
SCHEDULER_ATTR_NEXT_ACTION = "next_action"
# Storage
STORAGE_KEY = "intelligent_heating_pilot_storage"
STORAGE_VERSION = 1
@@ -0,0 +1,10 @@
"""Domain layer - Pure business logic for Intelligent Heating Pilot.
This layer contains the core intellectual property and is completely isolated
from Home Assistant and other infrastructure concerns.
Rules:
- NO homeassistant.* imports
- Only Python standard library and domain code
- All external interactions via Abstract Base Classes (interfaces)
"""
@@ -0,0 +1,33 @@
"""Domain constants for heating prediction business logic.
These constants define the business rules for heating anticipation calculations.
They are part of the domain layer and independent of infrastructure concerns.
"""
# Anticipation time constraints (in minutes)
MIN_ANTICIPATION_TIME = 10 # Minimum anticipation before heating starts
MAX_ANTICIPATION_TIME = 360 # Maximum anticipation (6 hours)
DEFAULT_ANTICIPATION_BUFFER = 5 # Safety buffer to ensure target is reached on time
# Heating slope thresholds (in °C/hour)
MIN_VALID_SLOPE = 0.1 # Minimum valid heating slope (technical threshold)
MINIMUM_REALISTIC_LHS = 0.2 # Minimum realistic LHS for effective heating (business threshold)
DEFAULT_LEARNED_SLOPE = 2.0 # Default slope when no learning data exists
# Dead time (in minutes)
DEFAULT_DEAD_TIME_MINUTES = 0.0 # Default dead time when no learning data exists
# Environmental correction factors
OUTDOOR_TEMP_REFERENCE = 20.0 # Reference outdoor temperature (°C)
OUTDOOR_TEMP_FACTOR = 0.05 # Impact factor per degree difference
HUMIDITY_REFERENCE = 50.0 # Reference humidity percentage
HUMIDITY_FACTOR = 0.002 # Impact factor per humidity percentage point
CLOUD_COVERAGE_FACTOR = 0.001 # Solar gain factor per cloud coverage percentage
# Confidence thresholds
HIGH_CONFIDENCE_SLOPE = 1.5 # Slope threshold for high confidence (°C/h)
MEDIUM_CONFIDENCE_SLOPE = 0.5 # Slope threshold for medium confidence (°C/h)
BASE_HIGH_CONFIDENCE = 0.9 # Base confidence with good slope
BASE_MEDIUM_CONFIDENCE = 0.75 # Base confidence with medium slope
BASE_LOW_CONFIDENCE = 0.6 # Base confidence with low slope
CONFIDENCE_BOOST_PER_SENSOR = 0.05 # Confidence increase per environmental sensor
@@ -0,0 +1,9 @@
"""Domain entities - objects with identity and lifecycle."""
from __future__ import annotations
from .heating_pilot import HeatingPilot
__all__ = [
"HeatingPilot",
]
@@ -0,0 +1,96 @@
"""Heating pilot - the aggregate root for heating decisions."""
from __future__ import annotations
import logging
from ..interfaces import IDecisionStrategy, ISchedulerCommander
from ..value_objects import (
EnvironmentState,
HeatingDecision,
)
_LOGGER = logging.getLogger(__name__)
class HeatingPilot:
"""Coordinates heating decisions for a single VTherm.
This is the aggregate root that orchestrates all domain logic
for intelligent heating control. It delegates decision-making to
a configurable strategy, allowing users to choose between:
- Simple rule-based decisions (no ML required)
- ML-powered decisions (requires IHP-ML-Models add-on)
This design follows the Strategy pattern, making the pilot
independent of the decision algorithm complexity.
Attributes:
_decision_strategy: Strategy for making heating decisions
_scheduler_commander: Interface to control scheduler actions
"""
def __init__(
self,
decision_strategy: IDecisionStrategy,
scheduler_commander: ISchedulerCommander,
) -> None:
"""Initialize the heating pilot.
Args:
decision_strategy: Strategy for making heating decisions
(simple rules or ML-based)
scheduler_commander: Implementation of scheduler control interface
"""
_LOGGER.debug("Initializing HeatingPilot")
self._decision_strategy = decision_strategy
self._scheduler_commander = scheduler_commander
_LOGGER.info(f"HeatingPilot initialized with strategy: {type(decision_strategy).__name__}")
async def decide_heating_action(
self,
environment: EnvironmentState,
) -> HeatingDecision:
"""Decide what heating action to take based on current conditions.
This method delegates the decision to the configured strategy,
which can be either simple rule-based or ML-powered.
Args:
environment: Current environmental conditions
Returns:
A heating decision with the action to take
"""
_LOGGER.debug("HeatingPilot.decide_heating_action called")
_LOGGER.debug(f"Delegating decision to {type(self._decision_strategy).__name__}")
decision = await self._decision_strategy.decide_heating_action(environment)
_LOGGER.info(f"HeatingPilot decision: {decision.action.value}")
return decision
async def check_overshoot_risk(
self,
environment: EnvironmentState,
current_slope: float,
) -> HeatingDecision:
"""Check if heating should stop to prevent overshooting target.
This method delegates the overshoot check to the configured strategy.
Args:
environment: Current environmental conditions
current_slope: Current heating rate in °C/hour
Returns:
Decision to stop heating if overshoot is detected
"""
_LOGGER.debug("HeatingPilot.check_overshoot_risk called")
_LOGGER.debug(f"Delegating overshoot check to {type(self._decision_strategy).__name__}")
decision = await self._decision_strategy.check_overshoot_risk(environment, current_slope)
_LOGGER.info(f"HeatingPilot overshoot check: {decision.action.value}")
return decision
@@ -0,0 +1,35 @@
"""Domain interfaces - contracts for external interactions.
These abstract base classes define how the domain interacts with
the outside world without coupling to specific implementations.
"""
from __future__ import annotations
from .climate_data_reader_interface import IClimateDataReader
from .context_reader_interface import IContextReader
from .decision_strategy_interface import IDecisionStrategy
from .device_config_reader_interface import IDeviceConfigReader
from .environment_reader_interface import IEnvironmentReader
from .heating_cycle_service_interface import IHeatingCycleService
from .heating_cycle_storage_interface import IHeatingCycleStorage
from .historical_data_adapter_interface import IHistoricalDataAdapter
from .lhs_storage_interface import ILhsStorage
from .scheduler_commander_interface import ISchedulerCommander
from .scheduler_reader_interface import ISchedulerReader
from .timer_scheduler import ITimerScheduler
__all__ = [
"IClimateDataReader",
"ISchedulerReader",
"IEnvironmentReader",
"IContextReader",
"ILhsStorage",
"ISchedulerCommander",
"IDecisionStrategy",
"IHeatingCycleService",
"IHeatingCycleStorage",
"IDeviceConfigReader",
"IHistoricalDataAdapter",
"ITimerScheduler",
]
@@ -0,0 +1,72 @@
"""Climate data reader interface.
Unified interface for reading VTherm climate data: entity identification,
current heating slope, and heating active state. All three concerns target
the *same* VTherm entity and are therefore grouped into a single contract
to avoid unnecessary fragmentation.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class IClimateDataReader(ABC):
"""Contract for reading climate data from a VTherm entity.
This interface unifies three previously separate readers
(IVThermMetadataReader, IHeatingSlopeReader, IHeatingStateReader)
that all operate on the same underlying VTherm climate entity.
Implementors must be stateless with respect to the returned values;
each call should read the current live state.
"""
@abstractmethod
def get_vtherm_entity_id(self) -> str:
"""Retrieve the VTherm (climate entity) ID.
Returns:
The VTherm entity ID (e.g., ``"climate.living_room_vtherm"``).
"""
@abstractmethod
def get_current_slope(self) -> float | None:
"""Retrieve the current heating slope in °C per hour.
The slope is read from the VTherm entity's attributes and represents
the instantaneous rate of indoor-temperature increase while heating
is active.
Returns:
Current heating slope as a float, or ``None`` when:
- The VTherm entity is unavailable.
- The ``slope`` attribute is missing or cannot be parsed.
"""
@abstractmethod
def is_heating_active(self) -> bool:
"""Return whether heating is currently active on the VTherm.
Heating is typically considered active when:
1. ``hvac_mode`` is ``"heat"``, **and**
2. ``current_temperature < target_temperature``.
Returns:
``True`` when the VTherm is actively heating, ``False`` otherwise
(including when the entity is unavailable).
"""
@abstractmethod
def get_current_target_temperature(self) -> float | None:
"""Retrieve the current target temperature from the VTherm entity.
Reads the real-time target temperature set on the VTherm climate entity.
This is used, for example, to resolve a target temperature for native HA
schedule entities that do not store a temperature themselves.
Returns:
Current target temperature in °C, or ``None`` when:
- The VTherm entity is unavailable.
- The temperature attribute is missing or cannot be parsed.
"""
@@ -0,0 +1,39 @@
"""Environment context reader interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class IContextReader(ABC):
"""Contract for accessing environment metadata and adapter context.
This interface is used by the application layer for historical data
adapter orchestration. It intentionally avoids Home Assistant imports.
"""
@abstractmethod
def get_hass(self) -> Any:
"""Retrieve the Home Assistant instance for adapter orchestration."""
pass
@abstractmethod
def get_humidity_in_entity_id(self) -> str | None:
"""Retrieve the indoor humidity sensor entity ID."""
pass
@abstractmethod
def get_humidity_out_entity_id(self) -> str | None:
"""Retrieve the outdoor humidity sensor entity ID."""
pass
@abstractmethod
def get_outdoor_temp_entity_id(self) -> str | None:
"""Retrieve the outdoor temperature sensor entity ID."""
pass
@abstractmethod
def get_cloud_cover_entity_id(self) -> str | None:
"""Retrieve the cloud coverage sensor entity ID."""
pass
@@ -0,0 +1,52 @@
"""Decision strategy interface for heating control."""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..value_objects import EnvironmentState, HeatingDecision
class IDecisionStrategy(ABC):
"""Contract for heating decision strategies.
This interface allows different decision-making approaches:
- Simple rule-based decisions (no ML required)
- ML-based decisions (requires IHP-ML-Models)
- Hybrid approaches combining both
By abstracting the decision logic, we make the HeatingPilot
agnostic to the complexity of the underlying decision process.
"""
@abstractmethod
async def decide_heating_action(
self,
environment: EnvironmentState,
) -> HeatingDecision:
"""Decide what heating action to take.
Args:
environment: Current environmental conditions
Returns:
A heating decision with the action to take
"""
pass
@abstractmethod
async def check_overshoot_risk(
self,
environment: EnvironmentState,
current_slope: float,
) -> HeatingDecision:
"""Check if heating should stop to prevent overshooting.
Args:
environment: Current environmental conditions
current_slope: Current heating rate in °C/hour
Returns:
Decision to stop heating if overshoot is detected
"""
pass
@@ -0,0 +1,154 @@
"""Device configuration reader interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass(frozen=True)
class DeviceConfig:
"""Complete configuration for an IHP device.
This is an immutable value object that holds all configuration parameters
for a single IHP device. It is created by HADeviceConfigReader from
Home Assistant config entries.
Attributes:
# Required fields
device_id: Unique identifier for the device (typically config_entry.entry_id)
vtherm_entity_id: Entity ID of the virtual thermostat (climate entity)
# Optional entity IDs (environmental sensors)
scheduler_entities: List of entity IDs for scheduled events (switches)
humidity_in_entity_id: Entity ID for indoor humidity sensor (optional)
humidity_out_entity_id: Entity ID for outdoor humidity sensor (optional)
cloud_cover_entity_id: Entity ID for cloud coverage sensor (optional)
# Learning and data retention parameters
lhs_retention_days: Number of days to retain learned heating slope data
dead_time_minutes: Dead time in minutes (delay before heating becomes effective)
auto_learning: If True, automatically learn parameters from heating cycles
# Cycle detection parameters
temp_delta_threshold: Temperature delta threshold for cycle detection (°C)
cycle_split_duration_minutes: Duration to split heating cycles (0 = disabled)
min_cycle_duration_minutes: Minimum valid cycle duration
max_cycle_duration_minutes: Maximum valid cycle duration
# IHP control state
ihp_enabled: If True, IHP preheating is active; if False, IHP is paused
task_range_days: Number of days covered by each Recorder extraction task (tune to machine power)
anticipation_recalc_tolerance_minutes: Absolute time delta threshold (minutes) used
to decide whether an active preheating should be
canceled and rescheduled.
safety_shutoff_grace_minutes: Duration in minutes of the grace period for brief heating
interruptions (safety/frost mode). Interruptions shorter than
this threshold do not terminate an in-progress cycle, avoiding
bogus dead-time and slope values. Set 0 to disable.
"""
# Required fields
device_id: str
vtherm_entity_id: str
# Optional entity IDs
scheduler_entities: list[str]
humidity_in_entity_id: str | None = None
humidity_out_entity_id: str | None = None
temperature_out_entity_id: str | None = None
cloud_cover_entity_id: str | None = None
# Learning and data retention
lhs_retention_days: int = 30
dead_time_minutes: float = 0.0
auto_learning: bool = True
# Cycle detection parameters
temp_delta_threshold: float = 0.2
cycle_split_duration_minutes: int = 0
min_cycle_duration_minutes: int = 5
max_cycle_duration_minutes: int = 300
# IHP enabled state
ihp_enabled: bool = True
task_range_days: int = 7
anticipation_recalc_tolerance_minutes: int = 15
safety_shutoff_grace_minutes: int = 10
def __post_init__(self) -> None:
"""Validate configuration values after initialization.
Raises:
ValueError: If any configuration value is invalid
"""
# Validate required fields
if not self.device_id or not isinstance(self.device_id, str):
raise ValueError("device_id must be a non-empty string")
if not self.vtherm_entity_id or not isinstance(self.vtherm_entity_id, str):
raise ValueError("vtherm_entity_id must be a non-empty string")
# Validate scheduler_entities is a list
if not isinstance(self.scheduler_entities, list):
raise ValueError("scheduler_entities must be a list")
# Validate numeric ranges
if self.lhs_retention_days < 0:
raise ValueError("lhs_retention_days must be at least 0")
if self.dead_time_minutes < 0:
raise ValueError("dead_time_minutes must be at least 0")
if self.temp_delta_threshold < 0:
raise ValueError("temp_delta_threshold must be at least 0")
if self.cycle_split_duration_minutes < 0:
raise ValueError("cycle_split_duration_minutes must be at least 0")
if self.min_cycle_duration_minutes < 1:
raise ValueError("min_cycle_duration_minutes must be at least 1")
if self.max_cycle_duration_minutes <= self.min_cycle_duration_minutes:
raise ValueError("max_cycle_duration_minutes must be > min_cycle_duration_minutes")
if self.task_range_days < 1:
raise ValueError("task_range_days must be at least 1")
if self.anticipation_recalc_tolerance_minutes < 1:
raise ValueError("anticipation_recalc_tolerance_minutes must be at least 1")
if self.safety_shutoff_grace_minutes < 0:
raise ValueError("safety_shutoff_grace_minutes must be at least 0")
class IDeviceConfigReader(ABC):
"""Contract for reading device configuration.
Implementations should retrieve configuration for a specific IHP device,
including entity IDs for climate control, scheduling, and environmental sensors.
"""
@abstractmethod
async def get_device_config(self, device_id: str) -> DeviceConfig:
"""Retrieve configuration for a specific device.
Args:
device_id: The device identifier to retrieve configuration for
Returns:
DeviceConfig with all necessary entity mappings
Raises:
ValueError: If device_id is not found or configuration is invalid
"""
pass
@abstractmethod
async def get_all_device_ids(self) -> list[str]:
"""Retrieve list of all configured device IDs.
Returns:
List of configured device IDs
"""
pass
@@ -0,0 +1,80 @@
"""Interface for entity attribute mapping and extraction.
This interface defines the contract for translating entity attributes
into domain value objects and concepts.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ..value_objects.entity_attribute_mapping import (
AttributeConcept,
EntityAttributeDescriptor,
)
class IEntityAttributeMapper(ABC):
"""Contract for mapping entity attributes to domain concepts.
Implementations handle the complexity of different entity types
(VTherm with nested structures, generic climate entities, etc.)
while presenting a unified interface to the domain layer.
"""
@abstractmethod
def detect_entity_type(
self,
entity_id: str,
) -> EntityAttributeDescriptor:
"""Detect and describe an entity's attribute structure.
This method inspects the entity and determines:
- What type of entity it is (VTherm, climate, etc.)
- What attributes it actually provides
- Which mapping should be used for it
Args:
entity_id: The Home Assistant entity_id to analyze
Returns:
EntityAttributeDescriptor with entity info and appropriate mapping
Raises:
ValueError: If entity type cannot be determined or is unsupported
"""
pass
@abstractmethod
def extract_attribute_value(
self,
attributes: dict[str, Any],
concept: AttributeConcept,
) -> Any | None:
"""Extract a value from entity attributes using the concept mapping.
Tries multiple possible attribute paths (in priority order) to find
the value, supporting different entity structures transparently.
Args:
attributes: The entity's attributes dict
concept: The domain concept to extract
Returns:
The extracted value (could be float, string, bool, etc.) or None if not found
Raises:
ValueError: If concept is required but not found
"""
pass
@abstractmethod
def get_supported_concepts(self) -> list[AttributeConcept]:
"""Get list of domain concepts this mapper can extract.
Returns:
List of AttributeConcept values supported by this mapper
"""
pass
@@ -0,0 +1,52 @@
"""Environment reader interface.
Contract for reading current environmental conditions from external
data sources (e.g., Home Assistant entities).
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..value_objects import EnvironmentState
class IEnvironmentReader(ABC):
"""Contract for reading environmental conditions.
This interface defines how the domain accesses environmental data
(temperatures, humidity, cloud coverage, etc.) without coupling to
Home Assistant.
Implementations of this interface translate Home Assistant entity states
into domain value objects (EnvironmentState), enabling pure business logic
testing and maintaining clear architectural separation.
Edge Cases:
- Missing sensors: Methods return None gracefully
- Stale data: Implementations should handle validation
- Entity not found: Return None, not raise exceptions
"""
@abstractmethod
async def get_current_environment(self) -> EnvironmentState | None:
"""Retrieve current environmental conditions.
Reads environmental data from entity states and converts to a
domain EnvironmentState value object.
Returns:
EnvironmentState with current conditions (indoor_temperature,
outdoor_temp, humidity, cloud_coverage, timestamp), or None
if required data (indoor_temperature, humidity, outdoor_temp)
is unavailable.
Edge Cases:
- Returns None if VTherm entity is missing
- Returns None if indoor_temperature cannot be read
- Sensor-specific fallbacks:
- outdoor_temp: Falls back to indoor_temperature if unavailable
- indoor_humidity: Uses 50% default if unavailable
- outdoor_humidity, cloud_coverage: Optional (can be None)
"""
pass
@@ -0,0 +1,37 @@
"""Interface for heating cycle extraction service."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from ..value_objects.heating import HeatingCycle
from ..value_objects.historical_data import HistoricalDataSet
class IHeatingCycleService(ABC):
"""Abstract interface for extracting heating cycles from historical data."""
@abstractmethod
async def extract_heating_cycles(
self,
device_id: str,
history_data_set: HistoricalDataSet,
start_time: datetime,
end_time: datetime,
cycle_split_duration_minutes: int | None = 0,
) -> list[HeatingCycle]:
"""Extract heating cycles from a HistoricalDataSet within a given time range.
Args:
device_id: The device identifier for the cycles
history_data_set: A HistoricalDataSet containing all necessary raw sensor data.
start_time: The start of the time range for cycle extraction.
end_time: The end of the time range for cycle extraction.
cycle_split_duration_minutes: Duration in minutes to split long cycles
into smaller sub-cycles for granular analysis. If 0 or None, no splitting.
Returns:
A list of HeatingCycle value objects.
"""
pass
@@ -0,0 +1,131 @@
"""Interface for heating cycle storage."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import date, datetime
from ..value_objects.heating import HeatingCycle
from ..value_objects.heating_cycle_cache_data import HeatingCycleCacheData
class IHeatingCycleStorage(ABC):
"""Contract for persisting and retrieving heating cycle data.
Implementations of this interface handle storage and retrieval of
heating cycles with incremental update support to avoid repeatedly
scanning the entire Home Assistant recorder history.
"""
@abstractmethod
async def get_cache_data(self, device_id: str) -> HeatingCycleCacheData | None:
"""Get cached cycle data for a device.
Returns the complete cache data including cycles and metadata.
Returns None if no cache exists for the device.
Args:
device_id: The device identifier
Returns:
HeatingCycleCacheData if cache exists, None otherwise
"""
pass
@abstractmethod
async def append_cycles(
self,
device_id: str,
new_cycles: list[HeatingCycle],
search_end_time: datetime,
retention_days: int | None = None,
) -> None:
"""Append new cycles to the cache and update search timestamp.
This method adds new cycles to the existing cache (if any) and updates
the last_search_time to track where the next incremental search should begin.
Automatically handles deduplication based on cycle start_time.
Args:
device_id: The device identifier
new_cycles: List of new cycles to append
search_end_time: Timestamp marking the end of this search period
retention_days: Optional retention days to store with cache metadata
"""
pass
@abstractmethod
async def prune_old_cycles(
self,
device_id: str,
reference_time: datetime,
) -> bool:
"""Remove cycles older than the retention period.
Args:
device_id: The device identifier
reference_time: Time to calculate retention from
Returns:
True if any cycles were actually removed, False otherwise
"""
pass
@abstractmethod
async def clear_cache(self, device_id: str) -> None:
"""Clear all cached cycles for a device.
This resets the learning system to its initial state for the device.
Args:
device_id: The device identifier
"""
pass
@abstractmethod
async def get_last_search_time(self, device_id: str) -> datetime | None:
"""Get the timestamp of the last cycle search.
This is used to determine the start time for the next incremental search.
Returns None if no previous search has been performed.
Args:
device_id: The device identifier
Returns:
UTC timestamp of last search, or None if no cache exists
"""
pass
@abstractmethod
async def append_explored_dates(
self,
device_id: str,
explored_dates: set[date],
) -> None:
"""Mark dates as explored (whether they contained cycles or not).
This prevents re-extracting days that have already been examined,
making explored_dates the single source of truth for coverage.
Args:
device_id: The device identifier
explored_dates: Set of dates to mark as explored
"""
pass
@abstractmethod
async def get_oldest_explored_date(self, device_id: str) -> date | None:
"""Return the oldest date in explored_dates for this device.
Used by the progressive backfill scheduler to determine the next
historical period to extract. Returns None if no dates have been
explored yet (e.g. first startup before any extraction completes).
Args:
device_id: The device identifier
Returns:
The oldest explored date, or None if explored_dates is empty
"""
pass
@@ -0,0 +1,78 @@
"""Historical data adapter interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from ..value_objects import HistoricalDataKey, HistoricalDataSet
class IHistoricalDataAdapter(ABC):
"""Contract for adapting Home Assistant historical data into HistoricalDataSet.
Implementations of this interface retrieve historical data from Home Assistant
for different entity types (climate, sensor, weather) and transform them into
a standardized HistoricalDataSet format.
"""
@abstractmethod
async def fetch_historical_data(
self,
entity_id: str,
data_key: HistoricalDataKey,
start_time: datetime,
end_time: datetime,
) -> HistoricalDataSet:
"""Fetch historical data for an entity and convert to HistoricalDataSet.
Args:
entity_id: The Home Assistant entity ID (e.g., "climate.living_room")
data_key: The HistoricalDataKey to use for categorizing the measurements
start_time: The start of the historical period
end_time: The end of the historical period
Returns:
A HistoricalDataSet containing the fetched and transformed data
Raises:
ValueError: If entity_id is invalid or data cannot be fetched
"""
pass
async def fetch_all_historical_data(
self,
entity_id: str,
start_time: datetime,
end_time: datetime,
) -> HistoricalDataSet:
"""Fetch historical data for all supported keys in a single call.
This default implementation calls fetch_historical_data once per
HistoricalDataKey, which may result in redundant recorder queries.
Subclasses should override this method to fetch the raw history once
and extract all supported keys from that single result.
Args:
entity_id: The Home Assistant entity ID (e.g., "climate.living_room")
start_time: The start of the historical period
end_time: The end of the historical period
Returns:
A HistoricalDataSet containing measurements for all supported keys
"""
combined_data: dict = {}
for data_key in HistoricalDataKey:
result = await self.fetch_historical_data(
entity_id=entity_id,
data_key=data_key,
start_time=start_time,
end_time=end_time,
)
if result and result.data:
for k, measurements in result.data.items():
if measurements:
if k not in combined_data:
combined_data[k] = []
combined_data[k].extend(measurements)
return HistoricalDataSet(data=combined_data)
@@ -0,0 +1,109 @@
"""LHS (Learned Heating Slope) storage interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from ..value_objects.lhs_cache_entry import LHSCacheEntry
class ILhsStorage(ABC):
"""Contract for persisting learned heating slope (LHS) data.
Implementations of this interface handle storage and retrieval
of learned heating slopes (both global and contextual).
NOTE: Direct slope data persistence (save_slope_*) has been removed.
Slopes are now extracted directly from Home Assistant recorder via
HeatingCycleService. This interface now only provides access to the
global learned heating slope (LHS) and cleanup operations.
"""
@abstractmethod
async def get_learned_heating_slope(self) -> float:
"""Get the current learned heating slope (LHS).
This represents the system's best estimate of the heating rate
based on all historical data.
Returns:
The learned heating slope in °C/hour.
"""
pass
@abstractmethod
async def clear_slope_history(self) -> None:
"""Clear all learned slope data from history.
This resets the learning system to its initial state.
"""
pass
@abstractmethod
async def get_cached_global_lhs(self) -> LHSCacheEntry | None:
"""Return cached global LHS if available.
Returns:
LHSCacheEntry with global LHS value and timestamp, or None if not cached.
"""
pass
@abstractmethod
async def set_cached_global_lhs(self, lhs: float, updated_at: datetime) -> None:
"""Persist global LHS cache with timestamp.
Args:
lhs: The learned heating slope value in °C/hour.
updated_at: Timestamp when the LHS was calculated.
"""
pass
@abstractmethod
async def get_cached_contextual_lhs(self, hour: int) -> LHSCacheEntry | None:
"""Return cached contextual LHS for the given hour if available.
Args:
hour: Hour of day (0-23) for which to retrieve contextual LHS.
Returns:
LHSCacheEntry with contextual LHS value and timestamp, or None if not cached.
"""
pass
@abstractmethod
async def set_cached_contextual_lhs(self, hour: int, lhs: float, updated_at: datetime) -> None:
"""Persist contextual LHS cache for the given hour with timestamp.
Args:
hour: Hour of day (0-23) for which to cache the LHS.
lhs: The learned heating slope value in °C/hour for this hour.
updated_at: Timestamp when the LHS was calculated.
"""
pass
@abstractmethod
async def clear_contextual_cache(self) -> None:
"""Clear all cached contextual LHS entries."""
pass
@abstractmethod
async def get_learned_dead_time(self) -> float | None:
"""Get the current learned dead time in minutes.
Dead time is the delay between starting heat output and when the
indoor temperature begins rising noticeably.
Returns:
The learned dead time in minutes, or None if not yet learned.
"""
pass
@abstractmethod
async def set_learned_dead_time(self, dead_time: float | None) -> None:
"""Persist the learned dead time value.
Args:
dead_time: The learned dead time in minutes, or None to clear.
"""
pass
@@ -0,0 +1,37 @@
"""Scheduler commander interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
class ISchedulerCommander(ABC):
"""Contract for scheduler control actions.
Implementations of this interface execute scheduler commands using
the scheduler component's run_action service.
See: https://github.com/nielsfaber/scheduler-component/#schedulerrun_action
"""
@abstractmethod
async def run_action(self, target_time: datetime, scheduler_entity_id: str) -> None:
"""Trigger a scheduler action for a specific timeslot.
This will start heating in the mode configured in the scheduler
for the timeslot at the given time.
Args:
target_time: The time of the scheduler timeslot to trigger
"""
pass
@abstractmethod
async def cancel_action(self, scheduler_entity_id: str) -> None:
"""Cancel current scheduler action and return to current timeslot.
This is used to stop overshoot by reverting to the mode configured
for the current time (now()).
"""
pass
@@ -0,0 +1,38 @@
"""Scheduler reader interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..value_objects import ScheduledTimeslot
class ISchedulerReader(ABC):
"""Contract for reading scheduled heating timeslots.
Implementations of this interface retrieve schedule information
from external scheduling systems (e.g., Home Assistant scheduler).
See: https://github.com/nielsfaber/scheduler-component/#data-format
"""
@abstractmethod
async def get_next_timeslot(self) -> ScheduledTimeslot | None:
"""Retrieve the next scheduled heating timeslot.
Returns:
The next schedule timeslot, or None if no timeslots are scheduled.
"""
pass
@abstractmethod
async def is_scheduler_enabled(self, scheduler_entity_id: str) -> bool:
"""Check if a specific scheduler is enabled.
Args:
scheduler_entity_id: The scheduler entity ID to check
Returns:
True if the scheduler is enabled (state != "off"), False otherwise
"""
pass
@@ -0,0 +1,36 @@
"""Timer scheduler interface for anticipation triggering.
This interface abstracts timer scheduling operations, allowing the domain
to schedule anticipation triggers without depending on Home Assistant.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any, Callable, Coroutine
class ITimerScheduler(ABC):
"""Interface for scheduling timer-based callbacks.
This contract allows the application layer to schedule callbacks
at specific times without coupling to Home Assistant's event loop.
"""
@abstractmethod
def schedule_timer(
self,
target_time: datetime,
callback: Callable[[], Coroutine[Any, Any, Any]],
) -> Callable[[], None]:
"""Schedule a callback to execute at a specific time.
Args:
target_time: When to execute the callback
callback: Async function to execute at target_time
Returns:
Cancel function that can be called to cancel the timer
"""
pass
@@ -0,0 +1,17 @@
"""Domain services - stateless operations on domain objects."""
from __future__ import annotations
from .contextual_lhs_calculator_service import ContextualLHSCalculatorService
from .dead_time_calculation_service import DeadTimeCalculationService
from .global_lhs_calculator_service import GlobalLHSCalculatorService
from .heating_cycle_service import HeatingCycleService
from .prediction_service import PredictionService
__all__ = [
"PredictionService",
"HeatingCycleService",
"GlobalLHSCalculatorService",
"ContextualLHSCalculatorService",
"DeadTimeCalculationService",
]
@@ -0,0 +1,134 @@
"""Service for calculating contextual Learning Heating Slope (LHS).
Pure domain logic for grouping cycles by start hour and calculating
average heating slopes per hour. No Home Assistant dependencies.
"""
from __future__ import annotations
import logging
from ..value_objects import HeatingCycle
_LOGGER = logging.getLogger(__name__)
class ContextualLHSCalculatorService:
"""Calculate contextual LHS grouped by start hour.
Responsibilities:
- Extract hour from cycle start_time
- Group cycles by start hour
- Calculate average LHS per hour
- Handle empty groups gracefully
Pure domain logic with no Home Assistant dependencies.
"""
def extract_hour_from_cycle(self, cycle: HeatingCycle) -> int:
"""Extract hour (0-23) from cycle start time.
Args:
cycle: The heating cycle
Returns:
Hour of day (0-23)
"""
_LOGGER.debug("Extracting hour from cycle started at %s", cycle.start_time)
hour = cycle.start_time.hour
_LOGGER.debug("Extracted hour: %d", hour)
return hour
def group_cycles_by_start_hour(
self, cycles: list[HeatingCycle]
) -> dict[int, list[HeatingCycle]]:
"""Group cycles by their start_time hour.
Args:
cycles: All extracted heating cycles
Returns:
Mapping {hour: [cycles_starting_at_hour]}
"""
_LOGGER.debug("Grouping %d cycles by start hour", len(cycles))
grouped: dict[int, list[HeatingCycle]] = {h: [] for h in range(24)}
for cycle in cycles:
hour = self.extract_hour_from_cycle(cycle)
grouped[hour].append(cycle)
# Log summary
non_empty = {h: len(c) for h, c in grouped.items() if c}
_LOGGER.info("Grouped cycles by hour: %s", non_empty)
return grouped
def calculate_contextual_lhs_for_hour(
self, cycles: list[HeatingCycle], target_hour: int
) -> float | None:
"""Calculate average LHS for cycles starting at target_hour.
Args:
cycles: All extracted cycles
target_hour: Hour (0-23) to filter by
Returns:
Average LHS value or None if no data for this hour
Raises:
ValueError: If target_hour not in 0-23
"""
if not 0 <= target_hour <= 23:
raise ValueError(f"target_hour must be 0-23, got {target_hour}")
_LOGGER.debug(
"Calculating contextual LHS for hour %d from %d cycles", target_hour, len(cycles)
)
# Filter cycles starting at target hour
matching_cycles = [c for c in cycles if self.extract_hour_from_cycle(c) == target_hour]
if not matching_cycles:
_LOGGER.debug("No cycles found for hour %d", target_hour)
return None
# Filter out non-positive slopes: cycles where temperature didn't rise
# carry no useful learning data about heating speed
lhs_values = [c.avg_heating_slope for c in matching_cycles if c.avg_heating_slope > 0]
if not lhs_values:
_LOGGER.debug("No cycles with positive heating slope for hour %d", target_hour)
return None
avg_lhs = sum(lhs_values) / len(lhs_values)
_LOGGER.info(
"Calculated contextual LHS for hour %d: %.2f°C/h from %d cycles",
target_hour,
avg_lhs,
len(matching_cycles),
)
return avg_lhs
def calculate_all_contextual_lhs(self, cycles: list[HeatingCycle]) -> dict[int, float | None]:
"""Calculate contextual LHS for all 24 hours.
Args:
cycles: All extracted cycles
Returns:
Mapping {hour: avg_lhs_value_or_none}
"""
_LOGGER.info("Calculating contextual LHS for all 24 hours")
result = {}
for hour in range(24):
lhs = self.calculate_contextual_lhs_for_hour(cycles, hour)
result[hour] = lhs
hours_with_data = sum(1 for v in result.values() if v is not None)
_LOGGER.info("Contextual LHS calculated for %d hours with data", hours_with_data)
return result
@@ -0,0 +1,66 @@
"""Service for calculating average dead time from heating cycles.
Pure domain logic for aggregating dead_time_cycle_minutes values
from HeatingCycle instances. No Home Assistant dependencies.
"""
from __future__ import annotations
import logging
from ..value_objects import HeatingCycle
_LOGGER = logging.getLogger(__name__)
class DeadTimeCalculationService:
"""Calculate average dead time from heating cycles.
Responsibilities:
- Filter cycles with valid dead_time_cycle_minutes
- Compute average dead time across valid cycles
- Return None when no valid data exists
Pure domain logic with no Home Assistant dependencies.
"""
def calculate_average_dead_time(self, heating_cycles: list[HeatingCycle]) -> float | None:
"""Calculate average dead_time from cycles with valid dead_time_cycle_minutes.
Args:
heating_cycles: List of heating cycles to analyze
Returns:
Average dead time in minutes, or None if no valid data
"""
_LOGGER.debug(
"Entering calculate_average_dead_time: cycles=%d",
len(heating_cycles),
)
cycles_with_dead_time = [
cycle
for cycle in heating_cycles
if cycle.dead_time_cycle_minutes is not None and cycle.dead_time_cycle_minutes > 0
]
if not cycles_with_dead_time:
_LOGGER.debug("No cycles with valid dead_time_cycle_minutes")
_LOGGER.debug("Exiting calculate_average_dead_time: result=None")
return None
total_dead_time = sum(
cycle.dead_time_cycle_minutes
for cycle in cycles_with_dead_time
if cycle.dead_time_cycle_minutes is not None
)
avg_dead_time = total_dead_time / len(cycles_with_dead_time)
_LOGGER.info(
"Calculated average dead_time from %d cycles: %.1f minutes",
len(cycles_with_dead_time),
avg_dead_time,
)
_LOGGER.debug("Exiting calculate_average_dead_time: result=%.1f", avg_dead_time)
return avg_dead_time
@@ -0,0 +1,115 @@
"""Extraction date range calculator for historical data loading."""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
_LOGGER = logging.getLogger(__name__)
class ExtractionDateRangeCalculator:
"""Utility service to calculate the date range for recording extraction.
This service encapsulates the business logic for determining which dates should
be extracted from the Recorder based on:
- The configured retention period (in days)
- The oldest cycle already in the cache
- The current datetime
The goal is to extract enough historical data to build machine learning models
while respecting the retention window and avoiding redundant extraction.
"""
@staticmethod
def calculate_extraction_range(
retention_days: int,
oldest_cycle_in_cache: datetime | None,
current_time: datetime | None = None,
) -> tuple[date, date]:
"""Calculate the date range for recording extraction.
Logic:
1. If oldest_cycle_in_cache is None (empty cache):
- Extract from (now - retention_days) to today
2. If oldest_cycle_in_cache exists:
- Calculate: oldest_cycle - 24 hours
- Extract from max(original_start_date, oldest_cycle - 24h) to today
- This ensures we have one full day of context before the oldest cycle
Args:
retention_days: The retention window in days (e.g., 90)
oldest_cycle_in_cache: The datetime of the oldest cycle currently in cache,
or None if cache is empty
current_time: Current datetime (for testing; defaults to now())
Returns:
A tuple of (start_date, end_date) both inclusive, both as datetime.date objects
Raises:
ValueError: If retention_days is negative.
"""
if current_time is None:
current_time = datetime.now()
if retention_days < 0:
_LOGGER.error("retention_days must be non-negative, got %d", retention_days)
raise ValueError(f"retention_days must be non-negative, got {retention_days}")
# Fixed boundary: start of retention window
retention_boundary = current_time - timedelta(days=retention_days)
if oldest_cycle_in_cache is None:
# Empty cache: extract full retention window
start_date = retention_boundary.date()
end_date = current_time.date()
_LOGGER.debug(
"Empty cache: extracting from %s to %s (retention=%d days)",
start_date,
end_date,
retention_days,
)
return start_date, end_date
# Cache has data: look one day before the oldest cycle
oldest_minus_24h = oldest_cycle_in_cache - timedelta(days=1)
# Don't extract before retention boundary
extraction_start = max(oldest_minus_24h, retention_boundary)
extraction_start_date = extraction_start.date()
extraction_end_date = current_time.date()
_LOGGER.debug(
"Cache has data (oldest=%s): extracting from %s to %s "
"(retention_boundary=%s, oldest_minus_24h=%s)",
oldest_cycle_in_cache.isoformat(),
extraction_start_date,
extraction_end_date,
retention_boundary.date(),
oldest_minus_24h.date(),
)
return extraction_start_date, extraction_end_date
@staticmethod
def calculate_refresh_range(
current_time: datetime | None = None,
) -> tuple[date, date]:
"""Calculate the date range for a 24h refresh (last day only).
This is used for periodic refresh to get the most recent data without
re-extracting the entire history.
Args:
current_time: Current datetime (for testing; defaults to now())
Returns:
A tuple of (yesterday_date, today_date) as datetime.date objects
"""
if current_time is None:
current_time = datetime.now()
yesterday = (current_time - timedelta(days=1)).date()
today = current_time.date()
_LOGGER.debug("24h refresh: extracting from %s to %s", yesterday, today)
return yesterday, today
@@ -0,0 +1,69 @@
"""Service for calculating global Learning Heating Slope (LHS).
Pure domain logic for calculating average heating slope from all cycles,
regardless of time of day. No Home Assistant dependencies.
"""
from __future__ import annotations
import logging
from ..constants import DEFAULT_LEARNED_SLOPE
from ..value_objects import HeatingCycle
_LOGGER = logging.getLogger(__name__)
class GlobalLHSCalculatorService:
"""Calculate global LHS from all heating cycles.
Responsibilities:
- Calculate average heating slope from all cycles
- Return default slope when no cycles available
- Handle edge cases gracefully
Pure domain logic with no Home Assistant dependencies.
"""
def calculate_global_lhs(self, cycles: list[HeatingCycle]) -> float:
"""Calculate average LHS from all heating cycles.
Args:
cycles: List of all heating cycles to analyze
Returns:
float: Average heating slope in °C/hour, or DEFAULT_LEARNED_SLOPE if no cycles
Calculation:
avg_lhs = sum(cycle.avg_heating_slope for cycle in cycles) / len(cycles)
"""
_LOGGER.debug("Calculating global LHS from %d cycles", len(cycles))
if not cycles:
_LOGGER.info(
"No cycles available for global LHS calculation, returning default slope: %.2f°C/h",
DEFAULT_LEARNED_SLOPE,
)
return DEFAULT_LEARNED_SLOPE
# Filter out non-positive slopes: cycles where temperature didn't rise
# carry no useful learning data about heating speed
lhs_values = [cycle.avg_heating_slope for cycle in cycles if cycle.avg_heating_slope > 0]
if not lhs_values:
_LOGGER.info(
"No cycles with positive heating slope, returning default: %.2f°C/h",
DEFAULT_LEARNED_SLOPE,
)
return DEFAULT_LEARNED_SLOPE
global_lhs = sum(lhs_values) / len(lhs_values)
_LOGGER.info(
"Calculated global LHS: %.2f°C/h from %d cycles",
global_lhs,
len(cycles),
)
_LOGGER.debug("Global LHS calculation complete")
return global_lhs
@@ -0,0 +1,159 @@
"""ML-based decision strategy using IHP-ML-Models."""
from __future__ import annotations
import logging
from ..interfaces import ISchedulerReader
from ..interfaces.decision_strategy_interface import IDecisionStrategy
from ..value_objects import (
EnvironmentState,
HeatingAction,
HeatingDecision,
)
_LOGGER = logging.getLogger(__name__)
class MLDecisionStrategy(IDecisionStrategy):
"""ML-powered heating decisions using IHP-ML-Models.
This strategy delegates heating decisions to an AI model trained
in the IHP-ML-Models add-on. It provides more sophisticated
predictions by learning from historical data.
Decision logic:
- Queries ML model API for action predictions
- Uses reinforcement learning for optimal timing
- Adapts to specific home characteristics over time
Requirements:
- IHP-ML-Models Home Assistant add-on must be installed
- ML model must be trained with historical heating data
Attributes:
_scheduler_reader: Interface to read scheduled timeslots
_ml_client: Interface to ML model API (to be implemented)
"""
def __init__(
self,
scheduler_reader: ISchedulerReader,
# ml_client: IMLClient, # TODO: Add interface for ML API
) -> None:
"""Initialize ML decision strategy.
Args:
scheduler_reader: Implementation of scheduler reading interface
# ml_client: Client to communicate with ML model API
"""
_LOGGER.debug("Initializing MLDecisionStrategy")
self._scheduler_reader = scheduler_reader
# self._ml_client = ml_client # TODO: Implement ML client
_LOGGER.warning(
"MLDecisionStrategy is not fully implemented yet. "
"Requires IMLClient interface and adapter."
)
async def decide_heating_action(
self,
environment: EnvironmentState,
) -> HeatingDecision:
"""Decide heating action using ML model.
Args:
environment: Current environmental conditions
Returns:
A heating decision predicted by the ML model
"""
_LOGGER.debug("MLDecisionStrategy.decide_heating_action called")
_LOGGER.debug(
f"Environment: indoor={environment.indoor_temperature}°C, "
f"outdoor={environment.outdoor_temp}°C, "
f"humidity={environment.indoor_humidity}%"
)
# Get next scheduled timeslot for context
next_timeslot = await self._scheduler_reader.get_next_timeslot()
if next_timeslot is None:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION, reason="No scheduled timeslots found"
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
# TODO: Query ML model API for action prediction
# Example:
# ml_prediction = await self._ml_client.predict_action(
# environment=environment,
# next_timeslot=next_timeslot,
# )
#
# return HeatingDecision(
# action=ml_prediction.action,
# target_temp=ml_prediction.target_temp,
# reason=f"ML prediction (confidence: {ml_prediction.confidence:.2%})"
# )
_LOGGER.warning(
"ML model integration not implemented yet. " "Returning NO_ACTION as placeholder."
)
return HeatingDecision(
action=HeatingAction.NO_ACTION,
reason="ML strategy not fully implemented (requires IHP-ML-Models integration)",
)
async def check_overshoot_risk(
self,
environment: EnvironmentState,
current_slope: float,
) -> HeatingDecision:
"""Check overshoot risk using ML model.
Args:
environment: Current environmental conditions
current_slope: Current heating rate in °C/hour
Returns:
Decision to stop heating if ML model predicts overshoot
"""
_LOGGER.debug("MLDecisionStrategy.check_overshoot_risk called")
_LOGGER.debug(
f"Current slope: {current_slope:.4f}°C/hour, "
f"indoor_temp={environment.indoor_temperature}°C"
)
next_timeslot = await self._scheduler_reader.get_next_timeslot()
if next_timeslot is None:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION, reason="No scheduled timeslot to check against"
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
# TODO: Query ML model for overshoot risk assessment
# Example:
# risk_assessment = await self._ml_client.assess_overshoot_risk(
# environment=environment,
# current_slope=current_slope,
# target_temp=next_timeslot.target_temp,
# target_time=next_timeslot.target_time,
# )
#
# if risk_assessment.should_stop:
# return HeatingDecision(
# action=HeatingAction.STOP_HEATING,
# reason=f"ML detected overshoot risk (confidence: {risk_assessment.confidence:.2%})"
# )
_LOGGER.warning(
"ML overshoot detection not implemented yet. " "Returning NO_ACTION as placeholder."
)
return HeatingDecision(
action=HeatingAction.NO_ACTION, reason="ML overshoot detection not fully implemented"
)
@@ -0,0 +1,248 @@
"""Prediction service for calculating heating times."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from ..constants import (
BASE_HIGH_CONFIDENCE,
BASE_LOW_CONFIDENCE,
BASE_MEDIUM_CONFIDENCE,
CLOUD_COVERAGE_FACTOR,
CONFIDENCE_BOOST_PER_SENSOR,
DEFAULT_ANTICIPATION_BUFFER,
HIGH_CONFIDENCE_SLOPE,
HUMIDITY_FACTOR,
HUMIDITY_REFERENCE,
MAX_ANTICIPATION_TIME,
MEDIUM_CONFIDENCE_SLOPE,
MIN_ANTICIPATION_TIME,
OUTDOOR_TEMP_FACTOR,
OUTDOOR_TEMP_REFERENCE,
)
from ..value_objects import PredictionResult
_LOGGER = logging.getLogger(__name__)
class PredictionService:
"""Service for predicting heating start times.
This service contains the core prediction algorithm that determines
when heating should start to reach target temperature at a scheduled time.
The calculation considers:
1. Temperature difference to heat
2. Outdoor temperature impact on heat loss
3. Humidity effects on heating efficiency
4. Solar gain from cloud coverage
5. Learned heating slope (heating rate) from historical data
"""
def predict_heating_time(
self,
current_temp: float | None,
target_temp: float,
learned_slope: float,
target_time: datetime,
outdoor_temp: float | None = None,
humidity: float | None = None,
cloud_coverage: float | None = None,
dead_time_minutes: float = 0.0,
) -> PredictionResult:
"""Calculate when heating should start.
Args:
current_temp: Current room temperature in Celsius (None = cannot calculate)
target_temp: Target temperature in Celsius
learned_slope: Learned heating slope in °C/hour
target_time: When target should be reached (mandatory)
outdoor_temp: Outdoor temperature in Celsius (optional)
humidity: Indoor humidity percentage (0-100) (optional)
cloud_coverage: Cloud coverage percentage (0-100, 0=clear sky) (optional)
dead_time_minutes: Dead time in minutes (initial period with minimal heating effect)
Returns:
Prediction result with start time and confidence
"""
# Handle missing current temperature
if current_temp is None:
_LOGGER.warning("Cannot calculate prediction: current_temp is None")
return PredictionResult(
anticipated_start_time=target_time,
estimated_duration_minutes=0.0,
confidence_level=0.0,
learned_heating_slope=learned_slope,
)
# Calculate temperature difference
temp_delta = target_temp - current_temp
if temp_delta <= 0:
# Already at target, anticipated start time = target time
_LOGGER.debug(
"Already at target temperature (%.1f°C >= %.1f°C), no heating needed",
current_temp,
target_temp,
)
return PredictionResult(
anticipated_start_time=target_time,
estimated_duration_minutes=0.0,
confidence_level=1.0,
learned_heating_slope=learned_slope,
)
# Protection against invalid slope (should not happen with proper validation)
if learned_slope is None or learned_slope <= 0:
_LOGGER.error(
"CRITICAL: Invalid learned heating slope (%.4f°C/h <= 0) reached prediction_service. "
"This indicates missing validation in calling code. Cannot calculate prediction.",
learned_slope or 0,
)
return PredictionResult(
anticipated_start_time=target_time,
estimated_duration_minutes=0.0,
confidence_level=0.0,
learned_heating_slope=learned_slope or 0,
)
# Calculate base anticipation time (in minutes)
# Formula: heating_time = dead_time + (temp_delta / slope) * 60
anticipation_minutes = dead_time_minutes + (temp_delta / learned_slope) * 60.0
# Apply environmental correction factors
correction_factor = self._calculate_environmental_correction(
outdoor_temp, humidity, cloud_coverage
)
anticipation_minutes *= correction_factor
# Apply buffer and limits
anticipation_minutes += DEFAULT_ANTICIPATION_BUFFER
anticipation_minutes = max(
MIN_ANTICIPATION_TIME, min(MAX_ANTICIPATION_TIME, anticipation_minutes)
)
# Calculate anticipated start time
anticipated_start = target_time - timedelta(minutes=anticipation_minutes)
# Calculate confidence level based on slope and available environmental data
confidence = self._calculate_confidence(learned_slope, outdoor_temp, humidity)
_LOGGER.debug(
"Prediction: ΔT=%.1f°C, slope=%.2f°C/h, dead_time=%.1f min, correction=%.2f, "
"duration=%.1f min, confidence=%.2f",
temp_delta,
learned_slope,
dead_time_minutes,
correction_factor,
anticipation_minutes,
confidence,
)
return PredictionResult(
anticipated_start_time=anticipated_start,
estimated_duration_minutes=anticipation_minutes,
confidence_level=confidence,
learned_heating_slope=learned_slope,
)
def _calculate_environmental_correction(
self,
outdoor_temp: float | None,
humidity: float | None,
cloud_coverage: float | None,
) -> float:
"""Calculate combined environmental correction factor.
This method combines multiple environmental factors that affect
heating efficiency:
- Outdoor temperature (heat loss)
- Indoor humidity (thermal mass effect)
- Cloud coverage (solar gain)
Args:
outdoor_temp: Outdoor temperature in Celsius
humidity: Indoor humidity percentage (0-100)
cloud_coverage: Cloud coverage percentage (0-100)
Returns:
Combined correction factor (>1 means slower heating)
"""
correction_factor = 1.0
# Outdoor temperature factor: colder outside means slower heating
# Formula: outdoor_factor = 1 + (OUTDOOR_TEMP_REFERENCE - outdoor_temp) * OUTDOOR_TEMP_FACTOR
# At outdoor_temp = 20°C: factor = 1.0 (no impact)
# At outdoor_temp = 0°C: factor = 2.0 (heating takes twice as long)
# At outdoor_temp = -10°C: factor = 2.5 (even slower)
if outdoor_temp is not None:
outdoor_factor = 1.0 + (OUTDOOR_TEMP_REFERENCE - outdoor_temp) * OUTDOOR_TEMP_FACTOR
outdoor_factor = max(0.5, outdoor_factor) # Minimum factor of 0.5
correction_factor *= outdoor_factor
_LOGGER.debug("Outdoor temp %.1f°C -> factor %.2f", outdoor_temp, outdoor_factor)
# Humidity factor: higher humidity makes heating feel slower
# Formula: humidity_factor = 1 + (humidity - HUMIDITY_REFERENCE) * HUMIDITY_FACTOR
# At 50% humidity: factor = 1.0 (neutral)
# At 80% humidity: factor = 1.06 (6% slower)
# At 20% humidity: factor = 0.94 (6% faster)
if humidity is not None:
humidity_factor = 1.0 + (humidity - HUMIDITY_REFERENCE) * HUMIDITY_FACTOR
humidity_factor = max(0.8, min(1.2, humidity_factor))
correction_factor *= humidity_factor
_LOGGER.debug("Humidity %.1f%% -> factor %.2f", humidity, humidity_factor)
# Solar gain factor: less cloud coverage means more solar heat gain
# Formula: solar_factor = 1 - (100 - cloud_coverage) * CLOUD_COVERAGE_FACTOR
# At 100% cloud: factor = 1.0 (no solar gain)
# At 0% cloud (clear sky): factor = 0.9 (10% faster due to sun)
# At 50% cloud: factor = 0.95 (5% faster)
if cloud_coverage is not None:
solar_factor = 1.0 - (100.0 - cloud_coverage) * CLOUD_COVERAGE_FACTOR
solar_factor = max(0.8, min(1.0, solar_factor))
correction_factor *= solar_factor
_LOGGER.debug("Cloud coverage %.1f%% -> factor %.2f", cloud_coverage, solar_factor)
return correction_factor
def _calculate_confidence(
self,
learned_slope: float,
outdoor_temp: float | None,
humidity: float | None,
) -> float:
"""Calculate confidence level in the prediction.
Confidence is based on:
- Slope validity (higher slope = better learning)
- Available environmental data (more data = better prediction)
Args:
learned_slope: Learned heating slope in °C/hour
outdoor_temp: Outdoor temperature (if available)
humidity: Indoor humidity (if available)
Returns:
Confidence level between 0.0 and 1.0
"""
# Base confidence from slope validity
if learned_slope > HIGH_CONFIDENCE_SLOPE:
confidence = BASE_HIGH_CONFIDENCE
elif learned_slope > MEDIUM_CONFIDENCE_SLOPE:
confidence = BASE_MEDIUM_CONFIDENCE
else:
confidence = BASE_LOW_CONFIDENCE
# Adjust confidence based on available environmental data
data_availability = 0
if outdoor_temp is not None:
data_availability += 1
if humidity is not None:
data_availability += 1
# Increase confidence slightly with more environmental data
confidence += data_availability * CONFIDENCE_BOOST_PER_SENSOR
# Cap at 1.0
return min(1.0, confidence)
@@ -0,0 +1,201 @@
"""Simple rule-based decision strategy."""
from __future__ import annotations
import logging
from ..interfaces import ILhsStorage, ISchedulerReader
from ..interfaces.decision_strategy_interface import IDecisionStrategy
from ..services.prediction_service import PredictionService
from ..value_objects import (
EnvironmentState,
HeatingAction,
HeatingDecision,
)
_LOGGER = logging.getLogger(__name__)
class SimpleDecisionStrategy(IDecisionStrategy):
"""Rule-based heating decisions without ML.
This strategy uses simple predictive calculations based on
learned heating slopes. It's easier to set up as it doesn't
require the IHP-ML-Models add-on.
Decision logic:
- Uses learned heating slope (LHS) from past heating cycles
- Calculates anticipated start time based on current conditions
- Prevents overshooting with conservative thresholds
Attributes:
_scheduler_reader: Interface to read scheduled timeslots
_storage: Interface to access learned data
_prediction_service: Service for prediction calculations
"""
def __init__(
self,
scheduler_reader: ISchedulerReader,
model_storage: ILhsStorage,
) -> None:
"""Initialize simple decision strategy.
Args:
scheduler_reader: Implementation of scheduler reading interface
model_storage: Implementation of model storage interface
"""
_LOGGER.debug("Initializing SimpleDecisionStrategy")
self._scheduler_reader = scheduler_reader
self._storage = model_storage
self._prediction_service = PredictionService()
_LOGGER.debug("SimpleDecisionStrategy initialized with scheduler and storage")
async def decide_heating_action(
self,
environment: EnvironmentState,
) -> HeatingDecision:
"""Decide heating action using simple rules.
Args:
environment: Current environmental conditions
Returns:
A heating decision based on simple predictive rules
"""
_LOGGER.debug("SimpleDecisionStrategy.decide_heating_action called")
_LOGGER.debug(
f"Environment: indoor={environment.indoor_temperature}°C, "
f"outdoor={environment.outdoor_temp}°C, "
f"humidity={environment.indoor_humidity}%"
)
# Get next scheduled timeslot
next_timeslot = await self._scheduler_reader.get_next_timeslot()
if next_timeslot is None:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION, reason="No scheduled timeslots found"
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
# Check if target temperature is already reached
current_temp = environment.indoor_temperature
if current_temp >= next_timeslot.target_temp:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION,
reason=f"Already at target temperature ({current_temp:.1f}°C >= {next_timeslot.target_temp:.1f}°C)",
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
# Get learned heating slope
lhs = await self._storage.get_learned_heating_slope()
_LOGGER.info(f"Learned heating slope: {lhs:.4f}°C/hour")
# Calculate prediction
prediction = self._prediction_service.predict_heating_time(
current_temp=environment.indoor_temperature,
target_temp=next_timeslot.target_temp,
outdoor_temp=environment.outdoor_temp,
humidity=environment.indoor_humidity,
learned_slope=lhs,
target_time=next_timeslot.target_time,
cloud_coverage=environment.cloud_coverage,
)
_LOGGER.debug(
f"Prediction: anticipated_start={prediction.anticipated_start_time.isoformat()}, "
f"duration={prediction.estimated_duration_minutes:.1f}min, "
f"confidence={prediction.confidence_level:.2f}"
)
# Decide based on anticipated start time
now = environment.timestamp
if prediction.anticipated_start_time <= now < next_timeslot.target_time:
decision = HeatingDecision(
action=HeatingAction.START_HEATING,
target_temp=next_timeslot.target_temp,
reason=f"Time to start heating (anticipated start: {prediction.anticipated_start_time.isoformat()})",
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
elif now >= next_timeslot.target_time:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION, reason="Schedule time has passed"
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
else:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION,
reason=f"Wait until {prediction.anticipated_start_time.isoformat()}",
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
async def check_overshoot_risk(
self,
environment: EnvironmentState,
current_slope: float,
) -> HeatingDecision:
"""Check overshoot risk using simple calculations.
Args:
environment: Current environmental conditions
current_slope: Current heating rate in °C/hour
Returns:
Decision to stop heating if overshoot is detected
"""
_LOGGER.debug("SimpleDecisionStrategy.check_overshoot_risk called")
_LOGGER.debug(
f"Current slope: {current_slope:.4f}°C/hour, "
f"indoor_temp={environment.indoor_temperature}°C"
)
next_timeslot = await self._scheduler_reader.get_next_timeslot()
if next_timeslot is None:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION, reason="No scheduled timeslot to check against"
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
# Calculate estimated temperature at target time
time_to_target = (
next_timeslot.target_time - environment.timestamp
).total_seconds() / 3600.0
if time_to_target <= 0:
decision = HeatingDecision(action=HeatingAction.NO_ACTION, reason="Target time reached")
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
estimated_temp = environment.indoor_temperature + (current_slope * time_to_target)
# Stop if we'll overshoot by more than 0.5°C
overshoot_threshold = next_timeslot.target_temp + 0.5
_LOGGER.debug(
f"Estimated temp at target time: {estimated_temp:.1f}°C, "
f"threshold: {overshoot_threshold:.1f}°C"
)
if estimated_temp > overshoot_threshold:
decision = HeatingDecision(
action=HeatingAction.STOP_HEATING,
reason=f"Overshoot risk detected (estimated: {estimated_temp:.1f}°C > threshold: {overshoot_threshold:.1f}°C)",
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
decision = HeatingDecision(
action=HeatingAction.NO_ACTION,
reason=f"No overshoot risk (estimated: {estimated_temp:.1f}°C)",
)
_LOGGER.debug(f"Decision: {decision.action.value} - {decision.reason}")
return decision
@@ -0,0 +1,32 @@
"""Value objects for the domain layer.
Value objects are immutable data carriers that represent concepts in the domain.
"""
from __future__ import annotations
from .environment_state import EnvironmentState
from .heating import HeatingAction, HeatingCycle, HeatingDecision, TariffPeriodDetail
from .heating_cycle_cache_data import HeatingCycleCacheData
from .historical_data import HistoricalDataKey, HistoricalDataSet, HistoricalMeasurement
from .prediction_result import PredictionResult
from .recording_extraction_task import ExtractionTaskState, RecordingExtractionTask
from .scheduled_timeslot import ScheduledTimeslot
from .slope_data import SlopeData
__all__ = [
"EnvironmentState",
"ScheduledTimeslot",
"PredictionResult",
"HeatingDecision",
"HeatingAction",
"HeatingCycle",
"TariffPeriodDetail",
"SlopeData",
"HistoricalDataKey",
"HistoricalDataSet",
"HistoricalMeasurement",
"HeatingCycleCacheData",
"RecordingExtractionTask",
"ExtractionTaskState",
]
@@ -0,0 +1,55 @@
"""Value object for contextual LHS calculation results."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class ContextualLHSData:
"""Result of contextual LHS calculation for a specific hour.
Represents the outcome of calculating average LHS for cycles
that started at a particular hour of the day.
Attributes:
hour: Hour of day (0-23)
lhs: The calculated LHS value in °C/hour, or None if insufficient data
cycle_count: Number of cycles used in calculation
calculated_at: When this calculation was performed
reason: Human-readable explanation if lhs is None
(e.g., "insufficient_data", "calculation_failed")
"""
hour: int
lhs: float | None
cycle_count: int
calculated_at: datetime
reason: str = ""
def __post_init__(self) -> None:
"""Validate the contextual LHS data."""
if not 0 <= self.hour <= 23:
raise ValueError(f"hour must be 0-23, got {self.hour}")
if self.lhs is not None and self.lhs < 0:
raise ValueError(f"lhs must be positive or None, got {self.lhs}")
if self.cycle_count < 0:
raise ValueError(f"cycle_count must be >= 0, got {self.cycle_count}")
@property
def is_available(self) -> bool:
"""Check if this hour has valid LHS data."""
return self.lhs is not None and self.cycle_count > 0
def get_display_value(self) -> str | float:
"""Get value suitable for user display.
Returns:
LHS value as float if available, "unknown" string otherwise.
"""
if self.lhs is not None:
return round(self.lhs, 2)
return "unknown"
@@ -0,0 +1,160 @@
"""Value objects for entity attribute mapping and abstraction.
This module provides domain-level abstractions for mapping domain concepts
(like "current temperature") to actual entity attributes, enabling support
for multiple entity types with different attribute structures.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class AttributeConcept(Enum):
"""Domain-level concepts that need to be extracted from entities.
These represent the semantic meaning of data (what we're looking for),
independent of any specific entity type or Home Assistant attribute name.
"""
# Climate state attributes
CURRENT_TEMPERATURE = "current_temperature"
TARGET_TEMPERATURE = "target_temperature"
HEATING_ACTIVE = "heating_active" # Boolean: is heating currently active?
# For entities that expose hvac_action as a string
HVAC_ACTION = "hvac_action"
# Environmental sensor data
INDOOR_HUMIDITY = "indoor_humidity"
OUTDOOR_TEMPERATURE = "outdoor_temperature"
OUTDOOR_HUMIDITY = "outdoor_humidity"
CLOUD_COVERAGE = "cloud_coverage"
@dataclass(frozen=True)
class AttributePath:
"""Describes where to find a value in an entity's attributes.
Supports nested paths like "specific_states.temperature_slope".
Attributes:
path: Dot-separated path to the attribute (e.g., "specific_states.temperature")
fallback_path: Optional fallback path if primary path not found
required: If True, missing this attribute is an error
"""
path: str
fallback_path: str | None = None
required: bool = True
@dataclass(frozen=True)
class EntityAttributeMapping:
"""Maps domain concepts to actual entity attributes.
This allows flexible support for different entity types:
- VTherm with nested "specific_states"
- Standard Home Assistant climate entities
- Custom climate entities
Attributes:
entity_type: Type of entity (e.g., "climate", "sensor")
entity_name: Human-readable entity type name (e.g., "VTherm", "Generic Climate")
mappings: Dict mapping AttributeConcept → list of AttributePath candidates
"""
entity_type: str
entity_name: str
mappings: dict[AttributeConcept, list[AttributePath]]
def get_attribute_paths(
self,
concept: AttributeConcept,
) -> list[AttributePath]:
"""Get all possible attribute paths for a concept.
Returns paths in priority order - first valid path is used.
Args:
concept: The domain concept to look up
Returns:
List of AttributePath objects (may be empty if concept not supported)
"""
return self.mappings.get(concept, [])
def supports_concept(self, concept: AttributeConcept) -> bool:
"""Check if this mapping supports a given concept.
Args:
concept: The concept to check
Returns:
True if this mapping has at least one path for the concept
"""
return bool(self.get_attribute_paths(concept))
@dataclass(frozen=True)
class EntityAttributeDescriptor:
"""Describes the attribute structure of an entity instance.
This is used during setup to identify which entity type we're working with
and what attributes it actually provides.
Attributes:
entity_id: The Home Assistant entity_id (e.g., "climate.living_room")
entity_type: Type classification (e.g., "climate")
detected_attributes: Set of attribute names found on this entity
mapping: The EntityAttributeMapping to use for this entity
"""
entity_id: str
entity_type: str
detected_attributes: set[str]
mapping: EntityAttributeMapping
def has_required_attributes(
self,
required_concepts: list[AttributeConcept],
) -> tuple[bool, list[str]]:
"""Check if entity has attributes needed for required concepts.
Args:
required_concepts: List of AttributeConcept needed
Returns:
Tuple of (all_present: bool, missing_attributes: list[str])
"""
missing = []
for concept in required_concepts:
paths = self.mapping.get_attribute_paths(concept)
if not paths:
# Concept not supported by this mapping
missing.append(f"{concept.value} (not supported in mapping)")
continue
# Check if at least one path exists in detected attributes
found = False
for path in paths:
# Check exact path
if path.path in self.detected_attributes:
found = True
break
# Check fallback path
if path.fallback_path and path.fallback_path in self.detected_attributes:
found = True
break
if not found:
missing_paths = [p.path for p in paths if not p.fallback_path] + [
p.fallback_path for p in paths if p.fallback_path
]
missing.append(
f"{concept.value} (expected in {missing_paths}, "
f"but only found: {', '.join(self.detected_attributes)})"
)
return len(missing) == 0, missing
@@ -0,0 +1,49 @@
"""Environment state value object."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class EnvironmentState:
"""Represents current environmental conditions.
This value object captures all environmental factors that influence
heating decisions at a specific point in time.
Attributes:
indoor_temperature: Current room temperature in Celsius
timestamp: When these measurements were taken
indoor_humidity: Indoor humidity percentage (0-100)
outdoor_temp: Outdoor temperature in Celsius
outdoor_humidity: Optional outdoor humidity percentage (0-100)
cloud_coverage: Optional cloud coverage percentage (0-100, 0=clear sky)
"""
timestamp: datetime
indoor_temperature: float
indoor_humidity: float | None = None
outdoor_temp: float | None = None
outdoor_humidity: float | None = None
cloud_coverage: float | None = None
def __post_init__(self) -> None:
"""Validate the environmental state data."""
if self.indoor_humidity is not None and (
self.indoor_humidity < 0 or self.indoor_humidity > 100
):
raise ValueError(f"Humidity must be between 0 and 100, got {self.indoor_humidity}")
if self.outdoor_humidity is not None and (
self.outdoor_humidity < 0 or self.outdoor_humidity > 100
):
raise ValueError(
f"Outdoor humidity must be between 0 and 100, got {self.outdoor_humidity}"
)
if self.cloud_coverage is not None and (
self.cloud_coverage < 0 or self.cloud_coverage > 100
):
raise ValueError(f"Cloud coverage must be between 0 and 100, got {self.cloud_coverage}")
@@ -0,0 +1,169 @@
"""Heating decision value object."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
class HeatingAction(Enum):
"""Types of heating actions that can be taken."""
START_HEATING = "start_heating"
STOP_HEATING = "stop_heating"
SET_TEMPERATURE = "set_temperature"
NO_ACTION = "no_action"
@dataclass(frozen=True)
class HeatingDecision:
"""Represents a decision about heating control.
This value object encapsulates what action should be taken and why.
Attributes:
action: The type of action to take
target_temp: Target temperature if starting heating (None otherwise)
reason: Human-readable explanation for the decision
"""
action: HeatingAction
target_temp: float | None = None
reason: str = ""
def __post_init__(self) -> None:
"""Validate the heating decision data."""
if self.action == HeatingAction.START_HEATING and self.target_temp is None:
raise ValueError("START_HEATING action requires a target temperature")
if self.action == HeatingAction.SET_TEMPERATURE and self.target_temp is None:
raise ValueError("SET_TEMPERATURE action requires a target temperature")
@dataclass(frozen=True)
class TariffPeriodDetail:
"""Represents energy consumption and cost details for a specific tariff period."""
tariff_price_eur_per_kwh: float
energy_kwh: float
heating_duration_minutes: float
cost_euro: float
@dataclass(frozen=True)
class HeatingCycle:
"""Represents a single heating cycle, encapsulating all its relevant data.
This value object provides a complete and immutable snapshot of a heating period,
including its duration, temperature changes, and energy consumption details.
Attributes:
start_time: The exact datetime when the heating cycle started.
end_time: The exact datetime when the heating cycle ended.
target_temp: The target temperature set for this heating cycle.
end_temp: The actual temperature reached at the end of the heating cycle.
start_temp: The temperature at the beginning of the heating cycle.
tariff_details: A list of TariffDetail objects, breaking down energy, duration,
and cost by specific TariffPeriodDetail periods within the cycle.
dead_time_cycle_minutes: Dead time for this specific cycle in minutes. Time from
cycle start to first measurable temperature change.
None if cannot be determined.
min_effective_duration_minutes: Minimum effective heating duration (in minutes) required
to compute a valid slope. Effective duration is
total_duration dead_time. Cycles whose effective window
is shorter than this threshold return 0.0 for
``avg_heating_slope`` to prevent aberrant values caused by
near-zero denominators. Defaults to 5.0 minutes.
"""
device_id: str
start_time: datetime
end_time: datetime
target_temp: float
end_temp: float
start_temp: float
tariff_details: list[TariffPeriodDetail] | None = None
dead_time_cycle_minutes: float | None = None
min_effective_duration_minutes: float = 5.0
@property
def avg_heating_slope(self) -> float:
"""Calculates the average heating slope in °C/hour for the heating cycle.
Excludes the dead_time_cycle period to get the true heating slope once
the system is actively heating (without initial inertia).
Returns 0.0 when the effective heating duration (after subtracting dead_time) is shorter
than ``min_effective_duration_minutes``. This guards against aberrant slope values that
arise when dead_time ≈ total_duration, leaving an effective duration of only a few
microseconds and producing slopes in the range of 100 000200 000 °C/h.
"""
# Calculate effective start time (after dead_time_cycle)
if self.dead_time_cycle_minutes and self.dead_time_cycle_minutes > 0:
effective_start_time = self.start_time + timedelta(minutes=self.dead_time_cycle_minutes)
duration_hours = (self.end_time - effective_start_time).total_seconds() / 3600
else:
duration_hours = (self.end_time - self.start_time).total_seconds() / 3600
if duration_hours <= 0:
return 0.0
# Guard: reject cycles whose effective heating window is too narrow.
# When dead_time ≈ total_duration the slope formula amplifies noise by orders of magnitude.
effective_duration_minutes = duration_hours * 60.0
if effective_duration_minutes < self.min_effective_duration_minutes:
return 0.0
temp_increase = self.end_temp - self.start_temp
return temp_increase / duration_hours
@property
def duration_minutes(self) -> float:
"""Calculates the total duration of the heating cycle in minutes."""
return (self.end_time - self.start_time).total_seconds() / 60
@property
def temp_delta(self) -> float:
"""Calculates the difference between the target temperature and the end temperature."""
return self.target_temp - self.end_temp
@property
def start_hour(self) -> int:
"""Returns the hour (0-23) when the heating cycle started."""
return self.start_time.hour
@property
def end_hour(self) -> int:
"""Returns the hour (0-23) when the heating cycle ended."""
return self.end_time.hour
@property
def start_weekday(self) -> int:
"""Returns the weekday (0=Monday, 6=Sunday) when the heating cycle started."""
return self.start_time.weekday()
@property
def end_weekday(self) -> int:
"""Returns the weekday (0=Monday, 6=Sunday) when the heating cycle ended."""
return self.end_time.weekday()
@property
def total_energy_kwh(self) -> float:
"""Calculates the total energy consumed during the cycle in kWh from tariff details."""
return sum(detail.energy_kwh for detail in (self.tariff_details or []))
@property
def total_heating_duration_minutes(self) -> float:
"""Calculates the total heating duration in minutes from tariff details."""
return sum(detail.heating_duration_minutes for detail in (self.tariff_details or []))
@property
def total_cost_euro(self) -> float:
"""Calculates the total cost in euros from tariff details."""
return sum(detail.cost_euro for detail in (self.tariff_details or []))
def __post_init__(self) -> None:
"""Validate the heating cycle data."""
if self.start_time >= self.end_time:
raise ValueError("Start time must be before end time for a heating cycle.")

Some files were not shown because too many files have changed in this diff Show More