New apps Added
This commit is contained in:
@@ -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())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
"""Application layer - use case orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .orchestrator import HeatingOrchestrator
|
||||
|
||||
__all__ = ["HeatingOrchestrator"]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1458
File diff suppressed because it is too large
Load Diff
+191
@@ -0,0 +1,191 @@
|
||||
"""Factory for HeatingCycleLifecycleManager - wires dependencies with DDD compliance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..domain.interfaces.device_config_reader_interface import DeviceConfig
|
||||
from ..domain.interfaces.historical_data_adapter_interface import IHistoricalDataAdapter
|
||||
from ..infrastructure.adapters.climate_data_reader import HAClimateDataReader
|
||||
from ..infrastructure.adapters.entity_attribute_mapper_registry import (
|
||||
EntityAttributeMapperRegistry,
|
||||
)
|
||||
from ..infrastructure.adapters.vtherm_attribute_mapper import VThermAttributeMapper
|
||||
from ..infrastructure.recorder_queue import get_extraction_semaphore, get_recorder_queue
|
||||
from .heating_cycle_lifecycle_manager import HeatingCycleLifecycleManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..domain.interfaces import IHeatingCycleStorage, ILhsStorage, ITimerScheduler
|
||||
from ..domain.interfaces.heating_cycle_service_interface import IHeatingCycleService
|
||||
from .lhs_lifecycle_manager import LhsLifecycleManager
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeatingCycleLifecycleManagerFactory:
|
||||
"""Factory for creating HeatingCycleLifecycleManager with singleton pattern.
|
||||
|
||||
Singleton Pattern:
|
||||
- **One instance per device_id**: Each IHP device gets its own manager instance
|
||||
- **Shared across requests**: Multiple calls with same device_id return same instance
|
||||
- **Thread-safe**: Factory maintains internal registry to track instances
|
||||
|
||||
Dependency Wiring:
|
||||
- Injects all required dependencies (heating_cycle_service, adapters, caches)
|
||||
- Optionally injects LhsLifecycleManager for cascade updates
|
||||
- Ensures DDD compliance (domain services, infrastructure adapters)
|
||||
|
||||
Usage:
|
||||
```python
|
||||
# First call creates instance
|
||||
manager1 = factory.create(hass, device_config, ...)
|
||||
|
||||
# Second call with same device_id returns same instance
|
||||
manager2 = factory.create(hass, device_config, ...)
|
||||
assert manager1 is manager2 # True
|
||||
```
|
||||
"""
|
||||
|
||||
# Class-level registry: device_id -> HeatingCycleLifecycleManager instance
|
||||
_instances: dict[str, HeatingCycleLifecycleManager] = {}
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
hass: HomeAssistant,
|
||||
device_config: DeviceConfig,
|
||||
heating_cycle_service: IHeatingCycleService,
|
||||
cycle_cache: IHeatingCycleStorage | None = None,
|
||||
timer_scheduler: ITimerScheduler | None = None,
|
||||
model_storage: ILhsStorage | None = None,
|
||||
lhs_lifecycle_manager: LhsLifecycleManager | None = None,
|
||||
dead_time_updated_callback: Callable[[float], None] | None = None,
|
||||
on_extraction_complete_callback: Callable[[], None] | None = None,
|
||||
) -> HeatingCycleLifecycleManager:
|
||||
"""Create or return existing HeatingCycleLifecycleManager for device_id.
|
||||
|
||||
Singleton Behavior:
|
||||
- If instance exists for device_config.device_id, returns existing instance
|
||||
- If no instance exists, creates new one and stores in registry
|
||||
- Registry is class-level, shared across all factory instances
|
||||
|
||||
Dependency Injection:
|
||||
- heating_cycle_service: Domain service for extracting cycles from historical data
|
||||
- cycle_cache: Infrastructure adapter for persistent cycle storage
|
||||
- timer_scheduler: Infrastructure adapter for scheduling 24h refresh
|
||||
- model_storage: Infrastructure adapter for persisting individual cycles
|
||||
- lhs_lifecycle_manager: Application service for cascade LHS updates
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance (used for historical data adapters).
|
||||
device_config: Device configuration (contains device_id for singleton key).
|
||||
heating_cycle_service: Service for extracting heating cycles.
|
||||
cycle_cache: Optional persistent cache for incremental cycle storage.
|
||||
timer_scheduler: Optional scheduler for periodic 24h refresh.
|
||||
model_storage: Optional persistent storage for individual cycle records.
|
||||
lhs_lifecycle_manager: Optional LHS manager for cascade updates.
|
||||
dead_time_updated_callback: Optional callback fired after dead time persistence.
|
||||
on_extraction_complete_callback: Optional callback fired after cycle extraction completes.
|
||||
|
||||
Returns:
|
||||
Singleton HeatingCycleLifecycleManager instance for the device_id.
|
||||
"""
|
||||
device_id = device_config.device_id
|
||||
|
||||
# Check if instance already exists
|
||||
if device_id in cls._instances:
|
||||
_LOGGER.debug(
|
||||
"Returning existing HeatingCycleLifecycleManager for device_id=%s", device_id
|
||||
)
|
||||
return cls._instances[device_id]
|
||||
|
||||
# Create new instance
|
||||
_LOGGER.debug("Creating new HeatingCycleLifecycleManager for device_id=%s", device_id)
|
||||
|
||||
# Wire historical data adapters from hass infrastructure
|
||||
# Dynamically detect entity type (VTherm vs generic climate) for diagnostics
|
||||
_LOGGER.debug(
|
||||
"Setting up historical data adapters for device_id=%s", device_config.device_id
|
||||
)
|
||||
vtherm_entity_id = device_config.vtherm_entity_id
|
||||
_LOGGER.debug("Configured VTherm entity_id: %s", vtherm_entity_id)
|
||||
|
||||
# Detect entity type (VTherm or generic climate) for diagnostic logging
|
||||
entity_type = cls._detect_entity_type(hass, vtherm_entity_id)
|
||||
_LOGGER.info(
|
||||
"Detected entity type for %s: %s (auto-detection)",
|
||||
vtherm_entity_id,
|
||||
entity_type,
|
||||
)
|
||||
|
||||
climate_adapter = HAClimateDataReader(hass, get_recorder_queue(hass), vtherm_entity_id)
|
||||
historical_adapters: list[IHistoricalDataAdapter] = [climate_adapter]
|
||||
_LOGGER.debug(
|
||||
"Configured %d historical adapter(s) for device_id=%s with dynamic entity detection",
|
||||
len(historical_adapters),
|
||||
device_config.device_id,
|
||||
)
|
||||
|
||||
manager = HeatingCycleLifecycleManager(
|
||||
device_config=device_config,
|
||||
heating_cycle_service=heating_cycle_service,
|
||||
historical_adapters=historical_adapters,
|
||||
heating_cycle_storage=cycle_cache,
|
||||
timer_scheduler=timer_scheduler,
|
||||
lhs_storage=model_storage,
|
||||
lhs_lifecycle_manager=lhs_lifecycle_manager,
|
||||
dead_time_updated_callback=dead_time_updated_callback,
|
||||
extraction_semaphore=get_extraction_semaphore(hass),
|
||||
on_extraction_complete_callback=on_extraction_complete_callback,
|
||||
)
|
||||
|
||||
# Store in registry
|
||||
cls._instances[device_id] = manager
|
||||
_LOGGER.debug("Registered HeatingCycleLifecycleManager for device_id=%s", device_id)
|
||||
|
||||
return manager
|
||||
|
||||
@classmethod
|
||||
def _detect_entity_type(cls, hass: HomeAssistant, vtherm_entity_id: str) -> str:
|
||||
"""Detect the type of climate entity (VTherm or generic climate).
|
||||
|
||||
Uses EntityAttributeMapperRegistry to auto-detect the entity type.
|
||||
Used for diagnostic logging and validation.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
vtherm_entity_id: Entity ID to detect
|
||||
|
||||
Returns:
|
||||
String describing the entity type (e.g., "VTherm v8.0+", "Generic Climate")
|
||||
"""
|
||||
try:
|
||||
mapper_registry = EntityAttributeMapperRegistry(hass)
|
||||
mapper = mapper_registry.get_mapper_for_entity(vtherm_entity_id)
|
||||
|
||||
# Identify mapper type for logging
|
||||
if isinstance(mapper, VThermAttributeMapper):
|
||||
return "VTherm (Versatile Thermostat)"
|
||||
return "Generic Climate"
|
||||
except ValueError as e:
|
||||
_LOGGER.warning(
|
||||
"Could not detect entity type for %s: %s",
|
||||
vtherm_entity_id,
|
||||
str(e),
|
||||
)
|
||||
return "Unknown"
|
||||
|
||||
@classmethod
|
||||
def reset_instances(cls) -> None:
|
||||
"""Clear singleton registry (for testing only).
|
||||
|
||||
Use Case:
|
||||
Called in test teardown to ensure clean state between tests.
|
||||
Should NOT be used in production code.
|
||||
"""
|
||||
cls._instances = {}
|
||||
_LOGGER.debug("Reset HeatingCycleLifecycleManager singleton registry")
|
||||
@@ -0,0 +1,752 @@
|
||||
"""Lifecycle manager for learned heating slope (LHS) caching and refresh."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from ..domain.value_objects.heating import HeatingCycle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..domain.interfaces import ILhsStorage, ITimerScheduler
|
||||
from ..domain.services.contextual_lhs_calculator_service import ContextualLHSCalculatorService
|
||||
from ..domain.services.global_lhs_calculator_service import GlobalLHSCalculatorService
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from homeassistant.util import dt as dt_util
|
||||
except ImportError:
|
||||
dt_util = None # For testing without HA
|
||||
|
||||
|
||||
class LhsLifecycleManager:
|
||||
"""Manage global and contextual LHS lifecycle, caching, and refresh.
|
||||
|
||||
This manager is a singleton per IHP device (identified by device_id).
|
||||
It orchestrates:
|
||||
1. In-memory cache management (_cached_global_lhs, _cached_contextual_lhs)
|
||||
2. Persistent storage via ILhsStorage
|
||||
3. Periodic refresh scheduling (24h timer)
|
||||
4. Cascade updates triggered by HeatingCycleLifecycleManager
|
||||
|
||||
Architecture:
|
||||
- **In-memory cache**: Fast lookups for global and contextual LHS values
|
||||
- **Model storage (ILhsStorage)**: Persistent storage for LHS values with timestamps
|
||||
- **Triggered by**: HeatingCycleLifecycleManager when cycles change
|
||||
|
||||
Lazy Loading Strategy:
|
||||
========================
|
||||
This class implements LAZY LOADING for contextual LHS to optimize startup performance:
|
||||
|
||||
**Startup Phase:**
|
||||
- Global LHS: Loaded eagerly (single read from storage)
|
||||
- Contextual LHS: Loaded ONLY for current hour (datetime.now().hour)
|
||||
- Other 23 hours: NOT loaded at startup (deferred to on-demand)
|
||||
|
||||
**On-Demand Loading:**
|
||||
- get_contextual_lhs(): Loads requested hour from storage (if not in memory)
|
||||
- ensure_contextual_lhs_populated(): Same lazy-load behavior with optional force_recalculate
|
||||
- Per-hour memory caching: Each hour cached independently after first load
|
||||
|
||||
**Bulk Update Methods (intentionally load all 24 hours):**
|
||||
- on_retention_change(): Recalculates and caches all 24 hours when retention changes
|
||||
- on_24h_timer(): Recalculates and caches all 24 hours on periodic refresh
|
||||
- update_contextual_lhs_from_cycles(): Persists all 24 hours when called
|
||||
|
||||
Cache Hierarchy (fastest to slowest):
|
||||
1. Memory cache (_cached_contextual_lhs[hour]): O(1) lookup
|
||||
2. Storage cache (ILhsStorage.get_cached_contextual_lhs): Single disk read
|
||||
3. Computation cache (calculate_contextual_lhs_for_hour): On-demand calculation
|
||||
|
||||
Lazy Loading Strategy:
|
||||
========================
|
||||
This class implements LAZY LOADING for contextual LHS to optimize startup performance:
|
||||
|
||||
**Startup Phase:**
|
||||
- Global LHS: Loaded eagerly (single read from storage)
|
||||
- Contextual LHS: Loaded ONLY for current hour (datetime.now().hour)
|
||||
- Other 23 hours: NOT loaded at startup (deferred to on-demand)
|
||||
|
||||
**On-Demand Loading:**
|
||||
- get_contextual_lhs(): Loads requested hour from storage (if not in memory)
|
||||
- ensure_contextual_lhs_populated(): Same lazy-load behavior with optional force_recalculate
|
||||
- Per-hour memory caching: Each hour cached independently after first load
|
||||
|
||||
**Bulk Update Methods (intentionally load all 24 hours):**
|
||||
- on_retention_change(): Recalculates and caches all 24 hours when retention changes
|
||||
- on_24h_timer(): Recalculates and caches all 24 hours on periodic refresh
|
||||
- update_contextual_lhs_from_cycles(): Persists all 24 hours when called
|
||||
|
||||
Cache Hierarchy (fastest to slowest):
|
||||
1. Memory cache (_cached_contextual_lhs[hour]): O(1) lookup
|
||||
2. Storage cache (ILhsStorage.get_cached_contextual_lhs): Single disk read
|
||||
3. Computation cache (calculate_contextual_lhs_for_hour): On-demand calculation
|
||||
|
||||
Lifecycle Events:
|
||||
- startup(): Load global LHS + current hour contextual (lazy loading)
|
||||
- on_retention_change(cycles): Recalculate ALL 24 hours with new retention window
|
||||
- on_24h_timer(cycles): Recalculate ALL 24 hours on timer event
|
||||
- update_global_lhs_from_cycles(cycles): Persist global LHS from cycles
|
||||
- update_contextual_lhs_from_cycles(cycles): Persist ALL 24 contextual hours from cycles
|
||||
- cancel(): Cleanup timers and release resources (keep cached data)
|
||||
- startup(): Load global LHS + current hour contextual (lazy loading)
|
||||
- on_retention_change(cycles): Recalculate ALL 24 hours with new retention window
|
||||
- on_24h_timer(cycles): Recalculate ALL 24 hours on timer event
|
||||
- update_global_lhs_from_cycles(cycles): Persist global LHS from cycles
|
||||
- update_contextual_lhs_from_cycles(cycles): Persist ALL 24 contextual hours from cycles
|
||||
- cancel(): Cleanup timers and release resources (keep cached data)
|
||||
|
||||
Cascade Pattern:
|
||||
HeatingCycleLifecycleManager calls update_*_lhs_from_cycles() when:
|
||||
- startup(): Initial cycles extracted
|
||||
- on_retention_change(): Cycles re-extracted with new retention
|
||||
- on_24h_timer(): Cycles refreshed with latest data
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_storage: ILhsStorage,
|
||||
global_lhs_calculator: GlobalLHSCalculatorService,
|
||||
contextual_lhs_calculator: ContextualLHSCalculatorService,
|
||||
timer_scheduler: ITimerScheduler | None = None,
|
||||
) -> None:
|
||||
"""Initialize the lifecycle manager.
|
||||
|
||||
Note: This should be instantiated via LhsLifecycleManagerFactory
|
||||
to ensure singleton behavior per device_id.
|
||||
|
||||
Args:
|
||||
model_storage: Persistent storage adapter for cached LHS values.
|
||||
global_lhs_calculator: Domain service for computing global LHS from cycles.
|
||||
contextual_lhs_calculator: Domain service for computing contextual LHS by hour from cycles.
|
||||
timer_scheduler: Optional scheduler for periodic 24h refresh tasks.
|
||||
"""
|
||||
self._model_storage = model_storage
|
||||
self._global_lhs_calculator = global_lhs_calculator
|
||||
self._contextual_lhs_calculator = contextual_lhs_calculator
|
||||
self._timer_scheduler = timer_scheduler
|
||||
|
||||
# In-memory caches for fast repeated lookups (avoids disk I/O)
|
||||
# These are loaded from storage on startup and invalidated on updates
|
||||
self._cached_global_lhs: float | None = None # Global LHS value
|
||||
self._cached_contextual_lhs: dict[int, float] = {} # hour (0-23) -> LHS value
|
||||
self._timer_cancel_func: Callable[[], None] | None = None
|
||||
|
||||
async def startup(self) -> None:
|
||||
"""Initialize LHS caches and schedule periodic refresh.
|
||||
|
||||
Lifecycle Event Flow:
|
||||
1. Load cached global LHS from storage → memory cache
|
||||
2. Load cached contextual LHS for CURRENT HOUR ONLY (lazy loading)
|
||||
3. Schedule 24h timer for automatic refresh (if scheduler provided)
|
||||
|
||||
Lazy Loading Strategy:
|
||||
This method implements lazy loading to optimize startup performance:
|
||||
- **Current hour**: Loaded eagerly during startup (most frequently used)
|
||||
- **Other 23 hours**: Loaded on-demand via get_contextual_lhs() or ensure_contextual_lhs_populated()
|
||||
- **Memory cache**: Per-hour caching in _cached_contextual_lhs (fast lookups)
|
||||
- **Storage cache**: Persistent on-disk cache (fallback if not in memory)
|
||||
- **Computation cache**: Computed on-demand when no cached value exists
|
||||
|
||||
Cache Strategy:
|
||||
- **Reads from storage**: model_storage.get_cached_global_lhs()
|
||||
- **Reads from storage**: model_storage.get_cached_contextual_lhs() for CURRENT HOUR ONLY
|
||||
- **Writes to memory**: Populates _cached_global_lhs and _cached_contextual_lhs[current_hour]
|
||||
- **Does NOT write to storage**: Only loads existing cached values
|
||||
- **Does NOT compute**: Uses cached values or returns defaults via get_* methods
|
||||
|
||||
Note:
|
||||
- Initial LHS values are computed and stored by HeatingCycleLifecycleManager during its startup
|
||||
- Bulk updates (on_retention_change(), on_24h_timer()) intentionally load all 24 hours
|
||||
- On-demand loads (get_contextual_lhs(), ensure_contextual_lhs_populated()) load per-hour
|
||||
|
||||
Returns:
|
||||
None.
|
||||
"""
|
||||
_LOGGER.debug("Entering LhsLifecycleManager.startup")
|
||||
|
||||
try:
|
||||
# Load cached global LHS
|
||||
cached_global_lhs = await self._model_storage.get_cached_global_lhs()
|
||||
if cached_global_lhs is not None:
|
||||
# Extract value from LHSCacheEntry
|
||||
lhs_value = cached_global_lhs.value
|
||||
self._cached_global_lhs = lhs_value
|
||||
_LOGGER.debug("Loaded cached global LHS: %.2f °C/h", lhs_value)
|
||||
|
||||
# Lazy Loading: Load contextual LHS for current hour ONLY
|
||||
# Other hours will be loaded on-demand via get_contextual_lhs() or ensure_contextual_lhs_populated()
|
||||
current_hour = datetime.now().hour if dt_util is None else dt_util.now().hour
|
||||
_LOGGER.debug(
|
||||
"Loading contextual LHS for current hour: %d (lazy loading enabled)", current_hour
|
||||
)
|
||||
|
||||
cached_contextual = await self._model_storage.get_cached_contextual_lhs(current_hour)
|
||||
if cached_contextual is not None:
|
||||
# Extract value from LHSCacheEntry
|
||||
lhs_value = cached_contextual.value
|
||||
self._cached_contextual_lhs[current_hour] = lhs_value
|
||||
_LOGGER.debug(
|
||||
"Loaded cached contextual LHS for current hour %d: %.2f °C/h",
|
||||
current_hour,
|
||||
lhs_value,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"No cached contextual LHS for current hour %d; will load on-demand",
|
||||
current_hour,
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"LHS startup complete: loaded global LHS and current hour contextual LHS (lazy loading enabled)"
|
||||
)
|
||||
except Exception as exc:
|
||||
_LOGGER.error("Error during startup: %s", exc)
|
||||
# Don't re-raise - continue with defaults
|
||||
|
||||
# Schedule 24h timer for automatic refresh
|
||||
if self._timer_scheduler is not None:
|
||||
if dt_util is not None:
|
||||
next_refresh = dt_util.now() + timedelta(hours=24)
|
||||
else:
|
||||
next_refresh = datetime.now() + timedelta(hours=24)
|
||||
|
||||
# Timer callback: no-op placeholder (cycles provided by HeatingCycleLifecycleManager)
|
||||
async def noop_callback() -> None:
|
||||
"""Placeholder timer callback - actual update comes from HCLM."""
|
||||
pass
|
||||
|
||||
self._timer_cancel_func = self._timer_scheduler.schedule_timer(
|
||||
next_refresh, noop_callback
|
||||
)
|
||||
_LOGGER.debug("Scheduled 24h LHS refresh timer for %s", next_refresh.isoformat())
|
||||
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.startup")
|
||||
|
||||
async def on_retention_change(self, cycles: list[HeatingCycle]) -> None:
|
||||
"""Handle retention configuration changes.
|
||||
|
||||
Lifecycle Event Flow (triggered by HeatingCycleLifecycleManager):
|
||||
1. HeatingCycleLifecycleManager re-extracts cycles for new retention window
|
||||
2. HeatingCycleLifecycleManager calls this method with the new cycles
|
||||
3. Invalidate in-memory caches FIRST (prevents use of stale data)
|
||||
4. Recalculate global LHS from provided cycles
|
||||
5. Recalculate contextual LHS (by hour) from provided cycles
|
||||
6. Persist new LHS values to storage
|
||||
|
||||
Cache Strategy:
|
||||
- **Invalidates memory FIRST**: Clears _cached_global_lhs and _cached_contextual_lhs
|
||||
- **Receives cycles from**: HeatingCycleLifecycleManager.on_retention_change()
|
||||
- **Computes**: global_lhs_calculator.calculate_global_lhs(cycles)
|
||||
- **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles)
|
||||
- **Writes to storage**: model_storage.set_cached_global_lhs()
|
||||
- **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value)
|
||||
|
||||
Args:
|
||||
cycles: Heating cycles extracted for the new retention window.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
"""
|
||||
from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
|
||||
|
||||
_LOGGER.debug("Entering LhsLifecycleManager.on_retention_change")
|
||||
_LOGGER.debug("Recalculating LHS from %d cycles after retention change", len(cycles))
|
||||
|
||||
# Step 1: Invalidate in-memory caches FIRST (before any computation)
|
||||
self._cached_global_lhs = None
|
||||
self._cached_contextual_lhs = {}
|
||||
_LOGGER.debug("Invalidated in-memory caches before recalculation")
|
||||
|
||||
# Step 2: Recalculate global LHS from provided cycles
|
||||
global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles)
|
||||
|
||||
# Validate: LHS must be realistically positive (>= 0.5°C/h)
|
||||
if global_lhs < MINIMUM_REALISTIC_LHS:
|
||||
_LOGGER.warning(
|
||||
"Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
|
||||
global_lhs,
|
||||
MINIMUM_REALISTIC_LHS,
|
||||
DEFAULT_LEARNED_SLOPE,
|
||||
)
|
||||
global_lhs = DEFAULT_LEARNED_SLOPE
|
||||
|
||||
updated_at = dt_util.now() if dt_util is not None else datetime.now()
|
||||
await self._model_storage.set_cached_global_lhs(global_lhs, updated_at)
|
||||
_LOGGER.info("Recalculated global LHS: %.2f °C/h", global_lhs)
|
||||
|
||||
# Step 3: Recalculate contextual LHS from provided cycles
|
||||
contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs(
|
||||
cycles
|
||||
)
|
||||
for hour, lhs_value in contextual_lhs_by_hour.items():
|
||||
if lhs_value is not None and lhs_value > 0:
|
||||
await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at)
|
||||
_LOGGER.debug("Recalculated contextual LHS for %d hours", len(contextual_lhs_by_hour))
|
||||
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.on_retention_change")
|
||||
|
||||
async def on_24h_timer(self, cycles: list[HeatingCycle]) -> None:
|
||||
"""Handle periodic 24h refresh execution.
|
||||
|
||||
Lifecycle Event Flow (triggered by HeatingCycleLifecycleManager):
|
||||
1. HeatingCycleLifecycleManager extracts fresh cycles for retention window
|
||||
2. HeatingCycleLifecycleManager calls this method with the fresh cycles
|
||||
3. Recalculate global LHS from provided cycles
|
||||
4. Recalculate contextual LHS (by hour) from provided cycles
|
||||
5. Persist new LHS values to storage
|
||||
6. Invalidate in-memory caches (will reload from storage on next access)
|
||||
|
||||
Cache Strategy:
|
||||
- **Receives cycles from**: HeatingCycleLifecycleManager.on_24h_timer()
|
||||
- **Computes**: global_lhs_calculator.calculate_global_lhs(cycles)
|
||||
- **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles)
|
||||
- **Writes to storage**: model_storage.set_cached_global_lhs()
|
||||
- **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value)
|
||||
- **Invalidates memory**: Sets _cached_global_lhs = None, _cached_contextual_lhs = {}
|
||||
|
||||
Args:
|
||||
cycles: Heating cycles extracted for the current retention window.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
"""
|
||||
from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
|
||||
|
||||
_LOGGER.debug("Entering LhsLifecycleManager.on_24h_timer")
|
||||
_LOGGER.info("24h LHS refresh timer triggered")
|
||||
|
||||
# Recalculate global LHS from provided cycles
|
||||
global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles)
|
||||
|
||||
# Validate: LHS must be realistically positive (>= 0.5°C/h)
|
||||
if global_lhs < MINIMUM_REALISTIC_LHS:
|
||||
_LOGGER.warning(
|
||||
"Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
|
||||
global_lhs,
|
||||
MINIMUM_REALISTIC_LHS,
|
||||
DEFAULT_LEARNED_SLOPE,
|
||||
)
|
||||
global_lhs = DEFAULT_LEARNED_SLOPE
|
||||
|
||||
updated_at = dt_util.now() if dt_util is not None else datetime.now()
|
||||
await self._model_storage.set_cached_global_lhs(global_lhs, updated_at)
|
||||
# Invalidate cache to ensure fresh load
|
||||
self._cached_global_lhs = None
|
||||
_LOGGER.info("Refreshed global LHS: %.2f °C/h", global_lhs)
|
||||
|
||||
# Recalculate contextual LHS from provided cycles
|
||||
contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs(
|
||||
cycles
|
||||
)
|
||||
for hour, lhs_value in contextual_lhs_by_hour.items():
|
||||
if lhs_value is not None and lhs_value > 0:
|
||||
await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at)
|
||||
# Invalidate cache to ensure fresh load
|
||||
self._cached_contextual_lhs = {}
|
||||
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.on_24h_timer")
|
||||
|
||||
async def get_global_lhs(self) -> float:
|
||||
"""Return the global learned heating slope (LHS).
|
||||
|
||||
Cache Strategy (read-only, optimized with memory cache):
|
||||
- **Reads from memory first**: Checks _cached_global_lhs
|
||||
- **On memory cache hit**: Returns immediately (fast path)
|
||||
- **On memory cache miss**: Loads from model_storage.get_cached_global_lhs()
|
||||
- **Writes to memory**: Caches loaded value in _cached_global_lhs
|
||||
- **Validation**: Returns default if cached value is invalid (<=0 or None)
|
||||
- **Does NOT write to storage**: Read-only operation
|
||||
|
||||
Use Case:
|
||||
Called frequently during anticipation calculations to get the baseline heating rate.
|
||||
Memory cache ensures fast lookups without repeated disk I/O.
|
||||
|
||||
Returns:
|
||||
The cached or default global LHS in C/hour.
|
||||
"""
|
||||
from ..domain.constants import DEFAULT_LEARNED_SLOPE
|
||||
|
||||
_LOGGER.debug("Entering LhsLifecycleManager.get_global_lhs")
|
||||
|
||||
# Check in-memory cache first (fast path)
|
||||
if self._cached_global_lhs is not None:
|
||||
_LOGGER.debug(
|
||||
"Returning in-memory cached global LHS: %.2f °C/h", self._cached_global_lhs
|
||||
)
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs")
|
||||
return self._cached_global_lhs
|
||||
|
||||
# Not in memory cache, load from storage
|
||||
cached_entry = await self._model_storage.get_cached_global_lhs()
|
||||
|
||||
# Validate cached value is positive
|
||||
if cached_entry is not None:
|
||||
cached_lhs = cached_entry.value
|
||||
if cached_lhs > 0:
|
||||
# Cache in memory for subsequent calls
|
||||
self._cached_global_lhs = cached_lhs
|
||||
_LOGGER.debug("Loaded from storage and cached global LHS: %.2f °C/h", cached_lhs)
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs")
|
||||
return cached_lhs
|
||||
|
||||
# Return default if cache invalid or missing
|
||||
_LOGGER.debug(
|
||||
"No valid cached global LHS, returning default: %.2f °C/h", DEFAULT_LEARNED_SLOPE
|
||||
)
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.get_global_lhs")
|
||||
return DEFAULT_LEARNED_SLOPE
|
||||
|
||||
async def cancel(self) -> None:
|
||||
"""Cancel any scheduled refresh work and release resources.
|
||||
|
||||
Cache Strategy:
|
||||
- **Memory cache**: NOT cleared (remains valid until next update)
|
||||
- **Storage cache**: NOT cleared (persistent data remains)
|
||||
- **Timers**: Cancelled to stop periodic refresh
|
||||
|
||||
Use Case:
|
||||
Called when the IHP device is being shut down or removed.
|
||||
Does NOT clear learned data, only stops active timers.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
"""
|
||||
_LOGGER.debug("Entering LhsLifecycleManager.cancel")
|
||||
|
||||
# Cancel timer if scheduled
|
||||
if self._timer_cancel_func is not None:
|
||||
try:
|
||||
self._timer_cancel_func()
|
||||
_LOGGER.debug("Cancelled scheduled timer")
|
||||
except Exception as exc:
|
||||
_LOGGER.error("Error cancelling timer: %s", exc)
|
||||
finally:
|
||||
self._timer_cancel_func = None
|
||||
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.cancel")
|
||||
|
||||
async def get_contextual_lhs(
|
||||
self,
|
||||
target_time: datetime,
|
||||
cycles: list[HeatingCycle],
|
||||
) -> float:
|
||||
"""Return contextual LHS for a target time.
|
||||
|
||||
Cache Strategy (read-only, optimized with memory cache per hour):
|
||||
- **Reads from memory first**: Checks _cached_contextual_lhs[target_hour]
|
||||
- **On memory cache hit**: Returns immediately (fast path)
|
||||
- **On memory cache miss**: Loads from model_storage.get_cached_contextual_lhs(hour)
|
||||
- **If no storage cache**: Computes from provided cycles
|
||||
- **Writes to memory**: Caches loaded/computed value in _cached_contextual_lhs[hour]
|
||||
- **Falls back to global LHS**: If no contextual data exists for the hour
|
||||
- **Does NOT write to storage**: Read-only operation (use update_contextual_lhs_from_cycles)
|
||||
|
||||
Use Case:
|
||||
Called during anticipation calculations to get hour-specific heating rates.
|
||||
Memory cache per hour ensures fast lookups without repeated disk I/O.
|
||||
|
||||
Args:
|
||||
target_time: Target datetime used to select the contextual hour (0-23).
|
||||
cycles: Heating cycles used to compute contextual LHS when not cached.
|
||||
|
||||
Returns:
|
||||
Contextual LHS for the target hour in C/hour, or global LHS fallback.
|
||||
"""
|
||||
_LOGGER.debug("Entering LhsLifecycleManager.get_contextual_lhs")
|
||||
|
||||
target_hour = target_time.hour
|
||||
_LOGGER.debug("Getting contextual LHS for hour %d", target_hour)
|
||||
|
||||
# Check in-memory cache first (fast path)
|
||||
if target_hour in self._cached_contextual_lhs:
|
||||
cached_value = self._cached_contextual_lhs[target_hour]
|
||||
_LOGGER.debug(
|
||||
"Returning in-memory cached contextual LHS for hour %d: %.2f °C/h",
|
||||
target_hour,
|
||||
cached_value,
|
||||
)
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs")
|
||||
return cached_value
|
||||
|
||||
# Not in memory cache, try to get from storage
|
||||
cached_entry = await self._model_storage.get_cached_contextual_lhs(target_hour)
|
||||
|
||||
if cached_entry is not None:
|
||||
cached_contextual = cached_entry.value
|
||||
# Cache in memory for subsequent calls
|
||||
self._cached_contextual_lhs[target_hour] = cached_contextual
|
||||
_LOGGER.debug(
|
||||
"Loaded from storage and cached contextual LHS for hour %d: %.2f °C/h",
|
||||
target_hour,
|
||||
cached_contextual,
|
||||
)
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs")
|
||||
return cached_contextual
|
||||
|
||||
# No cache, compute contextual LHS for THIS HOUR ONLY (not all 24)
|
||||
_LOGGER.debug("No cached contextual LHS for hour %d, computing from cycles", target_hour)
|
||||
computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour(
|
||||
cycles, target_hour
|
||||
)
|
||||
|
||||
# If contextual LHS is None or invalid (< 0.5°C/h), fallback to global LHS
|
||||
from ..domain.constants import MINIMUM_REALISTIC_LHS
|
||||
|
||||
if computed_lhs is None or computed_lhs < MINIMUM_REALISTIC_LHS:
|
||||
_LOGGER.debug(
|
||||
"Contextual LHS for hour %d is invalid (%.2f°C/h < %.2f°C/h), falling back to global LHS",
|
||||
target_hour,
|
||||
computed_lhs or 0,
|
||||
MINIMUM_REALISTIC_LHS,
|
||||
)
|
||||
return await self.get_global_lhs()
|
||||
|
||||
# Cache computed value in memory
|
||||
self._cached_contextual_lhs[target_hour] = computed_lhs
|
||||
_LOGGER.debug(
|
||||
"Computed and cached contextual LHS for hour %d: %.2f °C/h",
|
||||
target_hour,
|
||||
computed_lhs,
|
||||
)
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.get_contextual_lhs")
|
||||
return computed_lhs
|
||||
|
||||
async def update_global_lhs_from_cycles(self, cycles: list[HeatingCycle]) -> float:
|
||||
"""Recalculate and persist global LHS from cycles.
|
||||
|
||||
Lifecycle Event (triggered by HeatingCycleLifecycleManager):
|
||||
This is called when:
|
||||
- HeatingCycleLifecycleManager.startup(): Initial cycles extracted
|
||||
- HeatingCycleLifecycleManager.on_retention_change(): Cycles re-extracted
|
||||
- HeatingCycleLifecycleManager.on_24h_timer(): Cycles refreshed
|
||||
|
||||
Cache Strategy (write operation):
|
||||
- **Receives cycles from**: HeatingCycleLifecycleManager
|
||||
- **Computes**: global_lhs_calculator.calculate_global_lhs(cycles)
|
||||
- **Writes to storage**: model_storage.set_cached_global_lhs(lhs, timestamp)
|
||||
- **Invalidates memory**: Sets _cached_global_lhs = None
|
||||
- **Next read**: get_global_lhs() will reload from storage into memory
|
||||
|
||||
Args:
|
||||
cycles: Heating cycles used to compute the global LHS.
|
||||
|
||||
Returns:
|
||||
The computed and persisted global LHS in C/hour.
|
||||
"""
|
||||
from ..domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
|
||||
|
||||
_LOGGER.debug("Entering LhsLifecycleManager.update_global_lhs_from_cycles")
|
||||
_LOGGER.debug("Updating global LHS from %d cycles", len(cycles))
|
||||
|
||||
try:
|
||||
global_lhs = self._global_lhs_calculator.calculate_global_lhs(cycles)
|
||||
|
||||
# Validate: LHS must be realistically positive (>= 0.5°C/h)
|
||||
if global_lhs < MINIMUM_REALISTIC_LHS:
|
||||
_LOGGER.warning(
|
||||
"Calculated global LHS is invalid (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
|
||||
global_lhs,
|
||||
MINIMUM_REALISTIC_LHS,
|
||||
DEFAULT_LEARNED_SLOPE,
|
||||
)
|
||||
global_lhs = DEFAULT_LEARNED_SLOPE
|
||||
|
||||
updated_at = dt_util.now() if dt_util is not None else datetime.now()
|
||||
await self._model_storage.set_cached_global_lhs(global_lhs, updated_at)
|
||||
|
||||
# Update in-memory cache with new value for fast subsequent access
|
||||
self._cached_global_lhs = global_lhs
|
||||
_LOGGER.debug("Updated global LHS in-memory cache: %.2f °C/h", global_lhs)
|
||||
|
||||
_LOGGER.info("Updated global LHS: %.2f °C/h", global_lhs)
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.update_global_lhs_from_cycles")
|
||||
return global_lhs
|
||||
except Exception as exc:
|
||||
_LOGGER.error("Error calculating global LHS: %s", exc)
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.update_global_lhs_from_cycles")
|
||||
return DEFAULT_LEARNED_SLOPE
|
||||
|
||||
async def update_contextual_lhs_from_cycles(
|
||||
self,
|
||||
cycles: list[HeatingCycle],
|
||||
) -> dict[int, float | None]:
|
||||
"""Recalculate and persist contextual LHS for all hours.
|
||||
|
||||
Lifecycle Event (triggered by HeatingCycleLifecycleManager):
|
||||
This is called when:
|
||||
- HeatingCycleLifecycleManager.startup(): Initial cycles extracted
|
||||
- HeatingCycleLifecycleManager.on_retention_change(): Cycles re-extracted
|
||||
- HeatingCycleLifecycleManager.on_24h_timer(): Cycles refreshed
|
||||
|
||||
Cache Strategy (write operation):
|
||||
- **Receives cycles from**: HeatingCycleLifecycleManager
|
||||
- **Computes**: contextual_lhs_calculator.calculate_contextual_lhs(cycles)
|
||||
- **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, lhs, timestamp) for each hour
|
||||
- **Invalidates memory**: Sets _cached_contextual_lhs = {}
|
||||
- **Next read**: get_contextual_lhs() will reload from storage into memory per hour
|
||||
|
||||
Args:
|
||||
cycles: Heating cycles used to compute contextual LHS by hour.
|
||||
|
||||
Returns:
|
||||
Mapping of hour (0-23) to LHS in C/hour, or None when no data exists.
|
||||
"""
|
||||
_LOGGER.debug("Entering LhsLifecycleManager.update_contextual_lhs_from_cycles")
|
||||
_LOGGER.debug("Updating contextual LHS from %d cycles", len(cycles))
|
||||
|
||||
contextual_lhs_by_hour = self._contextual_lhs_calculator.calculate_all_contextual_lhs(
|
||||
cycles
|
||||
)
|
||||
|
||||
# Persist non-None values with timestamp
|
||||
updated_at = dt_util.now() if dt_util is not None else datetime.now()
|
||||
|
||||
persisted_count = 0
|
||||
for hour, lhs_value in contextual_lhs_by_hour.items():
|
||||
if lhs_value is not None:
|
||||
await self._model_storage.set_cached_contextual_lhs(hour, lhs_value, updated_at)
|
||||
persisted_count += 1
|
||||
|
||||
# Update in-memory cache with new values for fast subsequent access
|
||||
# Only cache non-None values
|
||||
self._cached_contextual_lhs = {
|
||||
hour: lhs for hour, lhs in contextual_lhs_by_hour.items() if lhs is not None
|
||||
}
|
||||
_LOGGER.debug(
|
||||
"Updated contextual LHS in-memory cache for %d hours", len(self._cached_contextual_lhs)
|
||||
)
|
||||
|
||||
_LOGGER.info("Updated contextual LHS for %d hours", persisted_count)
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.update_contextual_lhs_from_cycles")
|
||||
|
||||
return contextual_lhs_by_hour
|
||||
|
||||
async def ensure_contextual_lhs_populated(
|
||||
self,
|
||||
target_hour: int,
|
||||
cycles: list[HeatingCycle],
|
||||
force_recalculate: bool = False,
|
||||
) -> float:
|
||||
"""Ensure contextual LHS is populated for a specific hour.
|
||||
|
||||
Lazy Population Strategy:
|
||||
This method is called when anticipation calculation needs LHS for a specific hour
|
||||
but it's not in the cache (memory or storage).
|
||||
|
||||
Cache Strategy (write operation with lazy loading):
|
||||
- **If force_recalculate=True**: Skip cache checks, recompute immediately
|
||||
- **Reads from memory first**: Checks _cached_contextual_lhs[target_hour]
|
||||
- **On memory cache hit**: Returns immediately (fast path)
|
||||
- **Reads from storage**: Tries model_storage.get_cached_contextual_lhs(hour)
|
||||
- **On storage cache hit**: Loads into memory, returns
|
||||
- **On cache miss**: Computes from provided cycles
|
||||
- **Writes to storage**: model_storage.set_cached_contextual_lhs(hour, value, timestamp)
|
||||
- **Writes to memory**: Updates _cached_contextual_lhs[hour]
|
||||
- **Fallback**: Returns global LHS if no contextual data exists
|
||||
|
||||
Use Case:
|
||||
Called during anticipation calculations when:
|
||||
- Cache is cold (first run after startup)
|
||||
- Hour-specific LHS was never calculated
|
||||
- Explicit refresh requested (force_recalculate=True)
|
||||
|
||||
Args:
|
||||
target_hour: Hour to ensure LHS is populated for (0-23).
|
||||
cycles: Heating cycles to use if computation is needed.
|
||||
force_recalculate: If True, bypass cache and recompute from cycles.
|
||||
|
||||
Returns:
|
||||
Contextual LHS for the target hour in C/hour, or global LHS fallback.
|
||||
"""
|
||||
_LOGGER.debug("Entering LhsLifecycleManager.ensure_contextual_lhs_populated")
|
||||
_LOGGER.debug(
|
||||
"Ensuring contextual LHS populated for hour %d (force_recalculate=%s)",
|
||||
target_hour,
|
||||
force_recalculate,
|
||||
)
|
||||
|
||||
# If force_recalculate, skip cache checks and recompute THIS HOUR ONLY
|
||||
if force_recalculate:
|
||||
_LOGGER.debug("Force recalculate enabled, bypassing cache for hour %d", target_hour)
|
||||
computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour(
|
||||
cycles, target_hour
|
||||
)
|
||||
|
||||
# Persist if value exists
|
||||
if computed_lhs is not None:
|
||||
updated_at = dt_util.now() if dt_util is not None else datetime.now()
|
||||
await self._model_storage.set_cached_contextual_lhs(
|
||||
target_hour, computed_lhs, updated_at
|
||||
)
|
||||
# Update memory cache
|
||||
self._cached_contextual_lhs[target_hour] = computed_lhs
|
||||
_LOGGER.debug(
|
||||
"Forced recalculation: contextual LHS for hour %d: %.2f °C/h",
|
||||
target_hour,
|
||||
computed_lhs,
|
||||
)
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
|
||||
return computed_lhs
|
||||
|
||||
# No contextual data, fallback to global LHS
|
||||
_LOGGER.debug("No contextual LHS for hour %d, falling back to global", target_hour)
|
||||
global_lhs = await self.get_global_lhs()
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
|
||||
return global_lhs
|
||||
|
||||
# Check memory cache first (fast path)
|
||||
if target_hour in self._cached_contextual_lhs:
|
||||
cached_value = self._cached_contextual_lhs[target_hour]
|
||||
_LOGGER.debug(
|
||||
"Contextual LHS already in memory cache for hour %d: %.2f °C/h",
|
||||
target_hour,
|
||||
cached_value,
|
||||
)
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
|
||||
return cached_value
|
||||
|
||||
# Check storage cache
|
||||
cached_entry = await self._model_storage.get_cached_contextual_lhs(target_hour)
|
||||
|
||||
if cached_entry is not None:
|
||||
cached_contextual = cached_entry.value
|
||||
# Load into memory cache for subsequent calls
|
||||
self._cached_contextual_lhs[target_hour] = cached_contextual
|
||||
_LOGGER.debug(
|
||||
"Loaded contextual LHS from storage for hour %d: %.2f °C/h",
|
||||
target_hour,
|
||||
cached_contextual,
|
||||
)
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
|
||||
return cached_contextual
|
||||
|
||||
# No cache, compute contextual LHS for THIS HOUR ONLY (not all 24)
|
||||
_LOGGER.debug("No cached contextual LHS for hour %d, computing from cycles", target_hour)
|
||||
computed_lhs = self._contextual_lhs_calculator.calculate_contextual_lhs_for_hour(
|
||||
cycles, target_hour
|
||||
)
|
||||
|
||||
# If contextual LHS exists, persist and cache
|
||||
if computed_lhs is not None:
|
||||
updated_at = dt_util.now() if dt_util is not None else datetime.now()
|
||||
await self._model_storage.set_cached_contextual_lhs(
|
||||
target_hour, computed_lhs, updated_at
|
||||
)
|
||||
# Cache in memory for subsequent calls
|
||||
self._cached_contextual_lhs[target_hour] = computed_lhs
|
||||
_LOGGER.debug(
|
||||
"Computed and cached contextual LHS for hour %d: %.2f °C/h",
|
||||
target_hour,
|
||||
computed_lhs,
|
||||
)
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
|
||||
return computed_lhs
|
||||
|
||||
# No contextual data, fallback to global LHS
|
||||
_LOGGER.debug("No contextual LHS for hour %d, falling back to global LHS", target_hour)
|
||||
global_lhs = await self.get_global_lhs()
|
||||
_LOGGER.debug("Exiting LhsLifecycleManager.ensure_contextual_lhs_populated")
|
||||
return global_lhs
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
"""Factory for LhsLifecycleManager - wires dependencies with DDD compliance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .lhs_lifecycle_manager import LhsLifecycleManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..domain.interfaces import ILhsStorage, ITimerScheduler
|
||||
from ..domain.services.contextual_lhs_calculator_service import ContextualLHSCalculatorService
|
||||
from ..domain.services.global_lhs_calculator_service import GlobalLHSCalculatorService
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LhsLifecycleManagerFactory:
|
||||
"""Factory for creating LhsLifecycleManager with singleton pattern.
|
||||
|
||||
Singleton Pattern:
|
||||
- **One instance per device_id**: Each IHP device gets its own manager instance
|
||||
- **Shared across requests**: Multiple calls with same device_id return same instance
|
||||
- **Thread-safe**: Factory maintains internal registry to track instances
|
||||
|
||||
Note on device_id:
|
||||
Since LhsLifecycleManager doesn't directly hold device_id, the singleton key
|
||||
is derived from the model_storage instance (assuming one storage per device).
|
||||
For proper device-level isolation, ensure each device has its own ILhsStorage.
|
||||
|
||||
Dependency Wiring:
|
||||
- Injects all required dependencies (calculators, storage, scheduler)
|
||||
- Ensures DDD compliance (domain services, infrastructure adapters)
|
||||
|
||||
Usage:
|
||||
```python
|
||||
# First call creates instance
|
||||
manager1 = factory.create(storage, global_calc, contextual_calc, ...)
|
||||
|
||||
# Second call with same storage returns same instance
|
||||
manager2 = factory.create(storage, global_calc, contextual_calc, ...)
|
||||
assert manager1 is manager2 # True
|
||||
```
|
||||
"""
|
||||
|
||||
# Class-level registry: storage_id -> LhsLifecycleManager instance
|
||||
# Using id(model_storage) as key since LHS manager doesn't have device_id directly
|
||||
_instances: dict[int, LhsLifecycleManager] = {}
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
model_storage: ILhsStorage,
|
||||
global_lhs_calculator: GlobalLHSCalculatorService,
|
||||
contextual_lhs_calculator: ContextualLHSCalculatorService,
|
||||
timer_scheduler: ITimerScheduler | None = None,
|
||||
) -> LhsLifecycleManager:
|
||||
"""Create or return existing LhsLifecycleManager for model_storage.
|
||||
|
||||
Singleton Behavior:
|
||||
- Uses id(model_storage) as singleton key (assumes one storage per device)
|
||||
- If instance exists for this storage, returns existing instance
|
||||
- If no instance exists, creates new one and stores in registry
|
||||
- Registry is class-level, shared across all factory instances
|
||||
|
||||
Dependency Injection:
|
||||
- model_storage: Infrastructure adapter for persistent LHS storage
|
||||
- global_lhs_calculator: Domain service for computing global LHS from cycles
|
||||
- contextual_lhs_calculator: Domain service for computing contextual LHS by hour
|
||||
- timer_scheduler: Infrastructure adapter for scheduling 24h refresh
|
||||
|
||||
Args:
|
||||
model_storage: Persistent storage adapter for cached LHS values.
|
||||
global_lhs_calculator: Service for computing global LHS.
|
||||
contextual_lhs_calculator: Service for computing contextual LHS by hour.
|
||||
timer_scheduler: Optional scheduler for periodic 24h refresh.
|
||||
|
||||
Returns:
|
||||
Singleton LhsLifecycleManager instance for the model_storage.
|
||||
"""
|
||||
storage_id = id(model_storage)
|
||||
|
||||
# Check if instance already exists
|
||||
if storage_id in cls._instances:
|
||||
_LOGGER.debug("Returning existing LhsLifecycleManager for storage_id=%s", storage_id)
|
||||
return cls._instances[storage_id]
|
||||
|
||||
# Create new instance
|
||||
_LOGGER.debug("Creating new LhsLifecycleManager for storage_id=%s", storage_id)
|
||||
|
||||
manager = LhsLifecycleManager(
|
||||
model_storage=model_storage,
|
||||
global_lhs_calculator=global_lhs_calculator,
|
||||
contextual_lhs_calculator=contextual_lhs_calculator,
|
||||
timer_scheduler=timer_scheduler,
|
||||
)
|
||||
|
||||
# Store in registry
|
||||
cls._instances[storage_id] = manager
|
||||
_LOGGER.debug("Registered LhsLifecycleManager for storage_id=%s", storage_id)
|
||||
|
||||
return manager
|
||||
|
||||
@classmethod
|
||||
def reset_instances(cls) -> None:
|
||||
"""Clear singleton registry (for testing only).
|
||||
|
||||
Use Case:
|
||||
Called in test teardown to ensure clean state between tests.
|
||||
Should NOT be used in production code.
|
||||
"""
|
||||
cls._instances = {}
|
||||
_LOGGER.debug("Reset LhsLifecycleManager singleton registry")
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Heating Orchestrator.
|
||||
|
||||
The orchestrator composes use cases to implement complex workflows.
|
||||
It coordinates multiple use cases but contains NO business logic itself.
|
||||
|
||||
This is the main entry point for heating operations from the infrastructure layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .use_cases import (
|
||||
CalculateAnticipationUseCase,
|
||||
CheckOvershootRiskUseCase,
|
||||
ControlPreheatingUseCase,
|
||||
ScheduleAnticipationActionUseCase,
|
||||
UpdateCacheDataUseCase,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeatingOrchestrator:
|
||||
"""Orchestrates heating use cases.
|
||||
|
||||
The orchestrator is responsible for:
|
||||
1. Composing use cases to implement workflows
|
||||
2. Coordinating the execution order of use cases
|
||||
3. Managing cross-use-case dependencies
|
||||
|
||||
NO business logic - pure composition and coordination.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
calculate_anticipation: CalculateAnticipationUseCase,
|
||||
control_preheating: ControlPreheatingUseCase,
|
||||
schedule_anticipation_action: ScheduleAnticipationActionUseCase,
|
||||
check_overshoot_risk: CheckOvershootRiskUseCase,
|
||||
update_cache: UpdateCacheDataUseCase,
|
||||
) -> None:
|
||||
"""Initialize the orchestrator with use cases.
|
||||
|
||||
Args:
|
||||
calculate_anticipation: Use case for calculating anticipation data
|
||||
control_preheating: Use case for controlling preheating
|
||||
schedule_anticipation_action: Use case for scheduling anticipation actions
|
||||
check_overshoot_risk: Use case for checking overshoot risk
|
||||
update_cache: Use case for updating cache
|
||||
"""
|
||||
_LOGGER.debug("Initializing HeatingOrchestrator")
|
||||
self._calculate_anticipation = calculate_anticipation
|
||||
self._control_preheating = control_preheating
|
||||
self._schedule_anticipation_action = schedule_anticipation_action
|
||||
self._check_overshoot_risk = check_overshoot_risk
|
||||
self._update_cache = update_cache
|
||||
|
||||
async def calculate_anticipation_only(
|
||||
self,
|
||||
target_time: datetime | None = None,
|
||||
target_temp: float | None = None,
|
||||
) -> dict:
|
||||
"""Calculate anticipation data only (no scheduling).
|
||||
|
||||
For users who use the service without a scheduler (via REST API).
|
||||
|
||||
Args:
|
||||
target_time: Target time for heating
|
||||
target_temp: Target temperature
|
||||
|
||||
Returns:
|
||||
Dict with anticipation data
|
||||
"""
|
||||
_LOGGER.debug("Entering HeatingOrchestrator.calculate_anticipation_only()")
|
||||
|
||||
result = await self._calculate_anticipation.calculate_anticipation_datas(
|
||||
target_time=target_time,
|
||||
target_temp=target_temp,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Exiting calculate_anticipation_only() -> data")
|
||||
return result
|
||||
|
||||
async def disable_preheating(self, scheduler_entity_id: str) -> None:
|
||||
"""Disable preheating (cancel timer and active preheating).
|
||||
|
||||
Args:
|
||||
scheduler_entity_id: Scheduler entity to cancel
|
||||
"""
|
||||
_LOGGER.debug("Entering HeatingOrchestrator.disable_preheating()")
|
||||
|
||||
# Delegate to schedule_anticipation_action to cancel timer
|
||||
await self._schedule_anticipation_action.cancel_action()
|
||||
|
||||
# Cancel active preheating if any
|
||||
await self._control_preheating.cancel_preheating(scheduler_entity_id)
|
||||
|
||||
_LOGGER.info("Preheating disabled")
|
||||
_LOGGER.debug("Exiting HeatingOrchestrator.disable_preheating()")
|
||||
|
||||
async def reset_all_learning_data(self, device_id: str) -> None:
|
||||
"""Reset all learned data (LHS + cycles).
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
"""
|
||||
_LOGGER.debug("Entering HeatingOrchestrator.reset_all_learning_data()")
|
||||
|
||||
await self._update_cache.reset_cache(device_id)
|
||||
|
||||
_LOGGER.debug("Exiting HeatingOrchestrator.reset_all_learning_data()")
|
||||
|
||||
def is_preheating_active(self) -> bool:
|
||||
"""Check if preheating is currently active.
|
||||
|
||||
Returns:
|
||||
True if preheating is active, False otherwise
|
||||
"""
|
||||
return self._control_preheating.is_preheating_active()
|
||||
|
||||
async def calculate_and_schedule_anticipation(self, ihp_enabled: bool = True) -> dict[str, Any]:
|
||||
"""Calculate anticipation and handle scheduling based on IHP status.
|
||||
|
||||
This orchestrator method:
|
||||
1. Calculates anticipation data
|
||||
2. Delegates scheduling decisions to ScheduleAnticipationActionUseCase
|
||||
3. Always returns anticipation data structure
|
||||
|
||||
Args:
|
||||
ihp_enabled: Whether IHP preheating is enabled
|
||||
|
||||
Returns:
|
||||
Dict with anticipation data for sensors (always returns structure)
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Entering HeatingOrchestrator.calculate_and_schedule_anticipation(ihp_enabled=%s)",
|
||||
ihp_enabled,
|
||||
)
|
||||
|
||||
# Step 1: Always calculate anticipation data regardless of IHP state.
|
||||
# The switch only controls whether preheating is scheduled, not whether
|
||||
# calculations happen. This ensures sensors and LHS caches stay up-to-date.
|
||||
anticipation_data = await self._calculate_anticipation.calculate_anticipation_datas()
|
||||
|
||||
# Step 2: Delegate scheduling decisions to use case (respects ihp_enabled)
|
||||
await self._schedule_anticipation_action.handle_anticipation_scheduling(
|
||||
anticipation_data,
|
||||
ihp_enabled,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Exiting HeatingOrchestrator.calculate_and_schedule_anticipation() -> data")
|
||||
return anticipation_data
|
||||
|
||||
async def check_and_prevent_overshoot(self, scheduler_entity_id: str) -> bool:
|
||||
"""Check overshoot risk and cancel preheating if needed.
|
||||
|
||||
Args:
|
||||
scheduler_entity_id: Scheduler entity to cancel if overshoot detected
|
||||
|
||||
Returns:
|
||||
True if overshoot detected and cancellation triggered, False otherwise
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Entering HeatingOrchestrator.check_and_prevent_overshoot(scheduler=%s)",
|
||||
scheduler_entity_id,
|
||||
)
|
||||
|
||||
overshoot_detected = await self._check_overshoot_risk.check_and_prevent_overshoot(
|
||||
scheduler_entity_id=scheduler_entity_id
|
||||
)
|
||||
|
||||
if overshoot_detected:
|
||||
await self._schedule_anticipation_action.cancel_action()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Exiting HeatingOrchestrator.check_and_prevent_overshoot() -> %s",
|
||||
overshoot_detected,
|
||||
)
|
||||
return overshoot_detected
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Use cases for Intelligent Heating Pilot.
|
||||
|
||||
This package contains use cases that encapsulate single business operations.
|
||||
Each use case is a focused, testable unit of business logic.
|
||||
|
||||
Use cases follow the Single Responsibility Principle and are composed by
|
||||
the HeatingOrchestrator to implement complex workflows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .calculate_anticipation_use_case import CalculateAnticipationUseCase
|
||||
from .check_overshoot_risk_use_case import CheckOvershootRiskUseCase
|
||||
from .control_preheating_use_case import ControlPreheatingUseCase
|
||||
from .schedule_anticipation_action_use_case import ScheduleAnticipationActionUseCase
|
||||
from .update_cache_data_use_case import UpdateCacheDataUseCase
|
||||
|
||||
__all__ = [
|
||||
"CalculateAnticipationUseCase",
|
||||
"CheckOvershootRiskUseCase",
|
||||
"ControlPreheatingUseCase",
|
||||
"ScheduleAnticipationActionUseCase",
|
||||
"UpdateCacheDataUseCase",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+314
@@ -0,0 +1,314 @@
|
||||
"""Calculate Anticipation Data Use Case.
|
||||
|
||||
This use case calculates the anticipated start time for preheating
|
||||
based on current conditions and learned heating slopes.
|
||||
|
||||
This is a PURE CALCULATION use case - it does NOT schedule or trigger preheating.
|
||||
For scheduling, use SchedulePreheatingUseCase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from ...domain.interfaces import (
|
||||
IClimateDataReader,
|
||||
IEnvironmentReader,
|
||||
ILhsStorage,
|
||||
ISchedulerReader,
|
||||
)
|
||||
from ...domain.services import DeadTimeCalculationService, PredictionService
|
||||
from ..heating_cycle_lifecycle_manager import HeatingCycleLifecycleManager
|
||||
from ..lhs_lifecycle_manager import LhsLifecycleManager
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CalculateAnticipationUseCase:
|
||||
"""Use case for calculating anticipation data (NO preheating scheduling).
|
||||
|
||||
This use case encapsulates the pure calculation logic for:
|
||||
1. Reading the next scheduled timeslot (or using provided target_time)
|
||||
2. Getting heating cycles for LHS calculation
|
||||
3. Calculating the anticipated start time based on learned heating slope
|
||||
4. Returning anticipation data for display/sensors
|
||||
|
||||
This does NOT schedule or trigger any preheating action.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scheduler_reader: ISchedulerReader | None,
|
||||
environment_reader: IEnvironmentReader,
|
||||
climate_data_reader: IClimateDataReader,
|
||||
heating_cycle_manager: HeatingCycleLifecycleManager,
|
||||
lhs_lifecycle_manager: LhsLifecycleManager,
|
||||
prediction_service: PredictionService,
|
||||
dead_time_calculator: DeadTimeCalculationService,
|
||||
auto_learning: bool = True,
|
||||
default_dead_time_minutes: float = 0.0,
|
||||
lhs_storage: ILhsStorage | None = None,
|
||||
) -> None:
|
||||
"""Initialize the use case.
|
||||
|
||||
Args:
|
||||
scheduler_reader: Reads scheduled timeslots (optional for API-only usage)
|
||||
environment_reader: Reads current environment conditions
|
||||
climate_data_reader: Unified reader for VTherm metadata, slope and heating state
|
||||
heating_cycle_manager: Manages heating cycle lifecycle
|
||||
lhs_lifecycle_manager: Manages learned heating slopes
|
||||
prediction_service: Predicts heating time
|
||||
dead_time_calculator: Calculates dead time from cycles
|
||||
auto_learning: Whether auto-learning is enabled
|
||||
default_dead_time_minutes: Default dead time when not learned
|
||||
lhs_storage: Optional persistent storage for learned values. When provided
|
||||
and auto_learning is True, the stored learned dead time is used as a
|
||||
fallback before the configured default, so that the persisted value
|
||||
is restored immediately after a Home Assistant restart.
|
||||
"""
|
||||
_LOGGER.debug("Initializing CalculateAnticipationUseCase")
|
||||
self._scheduler_reader = scheduler_reader
|
||||
self._environment_reader = environment_reader
|
||||
self._climate_data_reader = climate_data_reader
|
||||
self._heating_cycle_manager = heating_cycle_manager
|
||||
self._lhs_manager = lhs_lifecycle_manager
|
||||
self._prediction_service = prediction_service
|
||||
self._dead_time_calculator = dead_time_calculator
|
||||
self._auto_learning = auto_learning
|
||||
self._default_dead_time_minutes = default_dead_time_minutes
|
||||
self._lhs_storage = lhs_storage
|
||||
|
||||
async def calculate_anticipation_datas(
|
||||
self,
|
||||
target_time: datetime | None = None,
|
||||
target_temp: float | None = None,
|
||||
) -> dict:
|
||||
"""Calculate anticipation data without scheduling preheating.
|
||||
|
||||
Args:
|
||||
target_time: Target time for heating. If None, uses next scheduled timeslot.
|
||||
target_temp: Target temperature. If None, uses value from timeslot.
|
||||
|
||||
Returns:
|
||||
Dict with anticipation data. Returns structure with None values for fields
|
||||
that cannot be calculated.
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Entering CalculateAnticipationUseCase.calculate_anticipation_datas(target_time=%s, target_temp=%s)",
|
||||
target_time.isoformat() if target_time else "None",
|
||||
target_temp,
|
||||
)
|
||||
|
||||
# Import default constant for LHS validation
|
||||
from ...domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
|
||||
|
||||
# Determine target time and temp
|
||||
timeslot = None
|
||||
scheduler_entity = None
|
||||
timeslot_id = None
|
||||
|
||||
# Always get environment data (for minimal return structure)
|
||||
environment = await self._environment_reader.get_current_environment()
|
||||
current_temp = environment.indoor_temperature if environment else None
|
||||
|
||||
# Always get global LHS (for minimal return structure)
|
||||
global_lhs = await self._lhs_manager.get_global_lhs()
|
||||
|
||||
# Validate global LHS: must be realistically positive (>= 0.5°C/h)
|
||||
if global_lhs is None or global_lhs < MINIMUM_REALISTIC_LHS:
|
||||
_LOGGER.warning(
|
||||
"Invalid global LHS (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
|
||||
global_lhs or 0,
|
||||
MINIMUM_REALISTIC_LHS,
|
||||
DEFAULT_LEARNED_SLOPE,
|
||||
)
|
||||
global_lhs = DEFAULT_LEARNED_SLOPE
|
||||
|
||||
if target_time is None:
|
||||
# Use scheduler to get next timeslot
|
||||
if self._scheduler_reader is None:
|
||||
_LOGGER.debug("No scheduler reader configured and no target_time provided")
|
||||
# Return minimal data structure
|
||||
return self._create_data_structure(
|
||||
current_temp=current_temp,
|
||||
learned_heating_slope=global_lhs,
|
||||
)
|
||||
|
||||
timeslot = await self._scheduler_reader.get_next_timeslot()
|
||||
if not timeslot:
|
||||
_LOGGER.debug("No scheduled timeslot found")
|
||||
# Return minimal data structure
|
||||
return self._create_data_structure(
|
||||
current_temp=current_temp,
|
||||
learned_heating_slope=global_lhs,
|
||||
)
|
||||
|
||||
target_time = timeslot.target_time
|
||||
target_temp = timeslot.target_temp
|
||||
scheduler_entity = timeslot.scheduler_entity
|
||||
timeslot_id = timeslot.timeslot_id
|
||||
else:
|
||||
# Using provided target_time (API/REST usage without scheduler)
|
||||
if target_temp is None:
|
||||
_LOGGER.warning("target_time provided but target_temp is None")
|
||||
# Return minimal data structure
|
||||
return self._create_data_structure(
|
||||
current_temp=current_temp,
|
||||
learned_heating_slope=global_lhs,
|
||||
)
|
||||
|
||||
# Get remaining environment data
|
||||
outdoor_temp = environment.outdoor_temp if environment else None
|
||||
humidity = environment.indoor_humidity if environment else None
|
||||
cloud_coverage = environment.cloud_coverage if environment else None
|
||||
|
||||
# Get device ID
|
||||
vtherm_id = self._climate_data_reader.get_vtherm_entity_id()
|
||||
|
||||
# Get heating cycles for LHS calculation
|
||||
heating_cycles = await self._heating_cycle_manager.get_cycles_for_target_time(
|
||||
device_id=vtherm_id,
|
||||
target_time=target_time,
|
||||
)
|
||||
|
||||
# Get contextual LHS
|
||||
lhs = await self._lhs_manager.get_contextual_lhs(
|
||||
target_time=target_time,
|
||||
cycles=heating_cycles,
|
||||
)
|
||||
|
||||
# Validate LHS: must be realistically positive (>= 0.5°C/h)
|
||||
if lhs is None or lhs < MINIMUM_REALISTIC_LHS:
|
||||
_LOGGER.warning(
|
||||
"Invalid contextual LHS (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
|
||||
lhs or 0,
|
||||
MINIMUM_REALISTIC_LHS,
|
||||
DEFAULT_LEARNED_SLOPE,
|
||||
)
|
||||
lhs = DEFAULT_LEARNED_SLOPE
|
||||
|
||||
# Calculate effective dead_time
|
||||
if self._auto_learning and heating_cycles:
|
||||
avg_dead_time = self._dead_time_calculator.calculate_average_dead_time(heating_cycles)
|
||||
if avg_dead_time is not None and avg_dead_time > 0:
|
||||
dead_time = avg_dead_time
|
||||
_LOGGER.info(
|
||||
"Learned dead_time from %d cycles: %.1f minutes",
|
||||
len(heating_cycles),
|
||||
dead_time,
|
||||
)
|
||||
else:
|
||||
dead_time = await self._get_persisted_dead_time_or_default()
|
||||
elif self._auto_learning:
|
||||
# No cycles available yet (e.g. immediately after restart before extraction).
|
||||
# Fall back to the persisted learned dead time so the correct value is used
|
||||
# before the first cycle extraction completes.
|
||||
dead_time = await self._get_persisted_dead_time_or_default()
|
||||
else:
|
||||
dead_time = self._default_dead_time_minutes
|
||||
|
||||
# Calculate prediction - let prediction_service handle None values
|
||||
prediction = self._prediction_service.predict_heating_time(
|
||||
current_temp=current_temp, # Pass None if unavailable
|
||||
target_temp=target_temp,
|
||||
outdoor_temp=outdoor_temp,
|
||||
humidity=humidity,
|
||||
learned_slope=lhs,
|
||||
target_time=target_time,
|
||||
cloud_coverage=cloud_coverage,
|
||||
dead_time_minutes=dead_time,
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"Calculated anticipation: start at %s (%.1f min) for target %.1f°C at %s (LHS: %.2f°C/h)",
|
||||
prediction.anticipated_start_time.isoformat(),
|
||||
prediction.estimated_duration_minutes,
|
||||
target_temp,
|
||||
target_time.isoformat(),
|
||||
prediction.learned_heating_slope,
|
||||
)
|
||||
|
||||
result = self._create_data_structure(
|
||||
anticipated_start_time=prediction.anticipated_start_time,
|
||||
next_schedule_time=target_time,
|
||||
next_target_temperature=target_temp,
|
||||
anticipation_minutes=prediction.estimated_duration_minutes,
|
||||
current_temp=current_temp,
|
||||
learned_heating_slope=prediction.learned_heating_slope,
|
||||
confidence_level=prediction.confidence_level,
|
||||
timeslot_id=timeslot_id,
|
||||
scheduler_entity=scheduler_entity,
|
||||
dead_time=dead_time,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Exiting calculate_anticipation_datas() -> %s", "data")
|
||||
return result
|
||||
|
||||
def _create_data_structure(
|
||||
self,
|
||||
anticipated_start_time: datetime | None = None,
|
||||
next_schedule_time: datetime | None = None,
|
||||
next_target_temperature: float | None = None,
|
||||
anticipation_minutes: float | None = None,
|
||||
current_temp: float | None = None,
|
||||
learned_heating_slope: float | None = None,
|
||||
confidence_level: float | None = None,
|
||||
timeslot_id: str | None = None,
|
||||
scheduler_entity: str | None = None,
|
||||
dead_time: float | None = None,
|
||||
) -> dict:
|
||||
"""Create data structure with provided values or None defaults.
|
||||
|
||||
Returns consistent structure with each field having a value or None.
|
||||
"""
|
||||
return {
|
||||
"anticipated_start_time": anticipated_start_time,
|
||||
"next_schedule_time": next_schedule_time,
|
||||
"next_target_temperature": next_target_temperature,
|
||||
"anticipation_minutes": anticipation_minutes,
|
||||
"current_temp": current_temp,
|
||||
"learned_heating_slope": learned_heating_slope,
|
||||
"confidence_level": confidence_level,
|
||||
"timeslot_id": timeslot_id,
|
||||
"scheduler_entity": scheduler_entity,
|
||||
"dead_time": dead_time,
|
||||
}
|
||||
|
||||
async def _get_persisted_dead_time_or_default(self) -> float:
|
||||
"""Return the persisted learned dead time or the configured default.
|
||||
|
||||
When auto_learning is enabled, this method tries to restore the last
|
||||
learned dead time from persistent storage. This is the correct fallback
|
||||
during startup (before cycle extraction completes) or when cycles do not
|
||||
yield a valid dead time.
|
||||
|
||||
Returns:
|
||||
Persisted learned dead time if available and positive, otherwise the
|
||||
configured default dead time.
|
||||
"""
|
||||
if self._lhs_storage is not None:
|
||||
try:
|
||||
stored = await self._lhs_storage.get_learned_dead_time()
|
||||
if stored is not None and stored > 0:
|
||||
_LOGGER.debug(
|
||||
"Using persisted learned dead_time: %.1f minutes", stored
|
||||
)
|
||||
return stored
|
||||
if stored is not None and stored <= 0:
|
||||
_LOGGER.debug(
|
||||
"Ignoring non-positive persisted dead_time %.1f minutes; "
|
||||
"falling back to configured default",
|
||||
stored,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.warning("Failed to read persisted dead time", exc_info=True)
|
||||
|
||||
_LOGGER.debug(
|
||||
"No persisted dead_time found, using configured default: %.1f minutes",
|
||||
self._default_dead_time_minutes,
|
||||
)
|
||||
return self._default_dead_time_minutes
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
"""Check Overshoot Risk Use Case.
|
||||
|
||||
This use case detects when heating will overshoot the target temperature
|
||||
and cancels preheating to avoid overheating.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...domain.interfaces import (
|
||||
IClimateDataReader,
|
||||
IEnvironmentReader,
|
||||
ISchedulerReader,
|
||||
)
|
||||
from .control_preheating_use_case import ControlPreheatingUseCase
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CheckOvershootRiskUseCase:
|
||||
"""Use case for detecting overshoot risk.
|
||||
|
||||
This use case encapsulates the logic for:
|
||||
1. Reading current environment and slope
|
||||
2. Estimating temperature at target time
|
||||
3. Canceling preheating if overshoot risk is detected
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scheduler_reader: ISchedulerReader,
|
||||
environment_reader: IEnvironmentReader,
|
||||
climate_data_reader: IClimateDataReader,
|
||||
control_preheating: ControlPreheatingUseCase,
|
||||
overshoot_threshold_celsius: float = 0.5,
|
||||
) -> None:
|
||||
"""Initialize the use case.
|
||||
|
||||
Args:
|
||||
scheduler_reader: Reads scheduled timeslots
|
||||
environment_reader: Reads current environment conditions
|
||||
climate_data_reader: Reads current heating slope
|
||||
control_preheating: Cancels preheating when risk detected
|
||||
overshoot_threshold_celsius: Temperature margin above target to consider as overshoot (°C)
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Initializing CheckOvershootRiskUseCase with threshold %.1f°C",
|
||||
overshoot_threshold_celsius,
|
||||
)
|
||||
self._scheduler_reader = scheduler_reader
|
||||
self._environment_reader = environment_reader
|
||||
self._climate_data_reader = climate_data_reader
|
||||
self._control_preheating = control_preheating
|
||||
self._overshoot_threshold = overshoot_threshold_celsius
|
||||
|
||||
async def check_and_prevent_overshoot(self, scheduler_entity_id: str) -> bool:
|
||||
"""Check for overshoot risk and cancel preheating if needed.
|
||||
|
||||
Args:
|
||||
scheduler_entity_id: Scheduler entity tied to preheating
|
||||
|
||||
Returns:
|
||||
True if overshoot detected and preheating canceled, False otherwise
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Entering CheckOvershootRiskUseCase.check_and_prevent_overshoot(scheduler=%s)",
|
||||
scheduler_entity_id,
|
||||
)
|
||||
|
||||
if not self._control_preheating.is_preheating_active():
|
||||
_LOGGER.debug("Skipping overshoot check - preheating not active")
|
||||
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
|
||||
return False
|
||||
|
||||
timeslot = await self._scheduler_reader.get_next_timeslot()
|
||||
if not timeslot:
|
||||
_LOGGER.debug("Skipping overshoot check - no timeslot available")
|
||||
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
|
||||
return False
|
||||
|
||||
environment = await self._environment_reader.get_current_environment()
|
||||
if not environment:
|
||||
# Safety first: If we can't read temperature, assume overshoot risk
|
||||
_LOGGER.warning("No environment data available - assuming overshoot risk for safety")
|
||||
await self._control_preheating.cancel_preheating(scheduler_entity_id)
|
||||
_LOGGER.info("Cancelled preheating due to missing environment data")
|
||||
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> True")
|
||||
return True
|
||||
|
||||
current_slope = self._climate_data_reader.get_current_slope()
|
||||
if current_slope is None or current_slope <= 0.0:
|
||||
# Cannot check overshoot without valid slope data
|
||||
_LOGGER.debug(
|
||||
"Current slope unavailable (%.2f°C/h) - skipping overshoot check",
|
||||
current_slope or 0.0,
|
||||
)
|
||||
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
|
||||
return False
|
||||
|
||||
now = environment.timestamp
|
||||
if now >= timeslot.target_time:
|
||||
_LOGGER.debug("Skipping overshoot check - target time already passed")
|
||||
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
|
||||
return False
|
||||
|
||||
time_to_target_hours = (timeslot.target_time - now).total_seconds() / 3600.0
|
||||
projected_temp = environment.indoor_temperature + (current_slope * time_to_target_hours)
|
||||
overshoot_limit = timeslot.target_temp + self._overshoot_threshold
|
||||
|
||||
_LOGGER.debug(
|
||||
"Overshoot check: current=%.1f°C projected=%.1f°C target=%.1f°C limit=%.1f°C",
|
||||
environment.indoor_temperature,
|
||||
projected_temp,
|
||||
timeslot.target_temp,
|
||||
overshoot_limit,
|
||||
)
|
||||
|
||||
if projected_temp >= overshoot_limit:
|
||||
_LOGGER.warning(
|
||||
"Overshoot risk detected: projected %.1f°C exceeds limit %.1f°C",
|
||||
projected_temp,
|
||||
overshoot_limit,
|
||||
)
|
||||
await self._control_preheating.cancel_preheating(scheduler_entity_id)
|
||||
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> True")
|
||||
return True
|
||||
|
||||
_LOGGER.debug("No overshoot risk detected")
|
||||
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
|
||||
return False
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
"""Control Preheating Use Case.
|
||||
|
||||
This use case controls the preheating state (start/cancel).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from ...domain.interfaces import ISchedulerCommander
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ControlPreheatingUseCase:
|
||||
"""Use case for controlling preheating state.
|
||||
|
||||
This use case encapsulates operations for:
|
||||
1. Starting preheating (trigger scheduler action)
|
||||
2. Canceling preheating (revert to current scheduled state)
|
||||
3. Tracking preheating state
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scheduler_commander: ISchedulerCommander,
|
||||
) -> None:
|
||||
"""Initialize the use case.
|
||||
|
||||
Args:
|
||||
scheduler_commander: Commands scheduler actions
|
||||
"""
|
||||
_LOGGER.debug("Initializing ControlPreheatingUseCase")
|
||||
self._scheduler_commander = scheduler_commander
|
||||
self._is_preheating_active = False
|
||||
self._preheating_target_time: datetime | None = None
|
||||
self._active_scheduler_entity: str | None = None
|
||||
|
||||
async def cancel_preheating(self, scheduler_entity_id: str | None = None) -> None:
|
||||
"""Cancel active preheating and revert to current scheduled state.
|
||||
|
||||
Args:
|
||||
scheduler_entity_id: Scheduler entity to cancel action on.
|
||||
If None, uses the active scheduler entity.
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Entering ControlPreheatingUseCase.cancel_preheating(scheduler=%s)",
|
||||
scheduler_entity_id,
|
||||
)
|
||||
|
||||
effective_scheduler = scheduler_entity_id or self._active_scheduler_entity
|
||||
|
||||
if self._is_preheating_active:
|
||||
if not effective_scheduler:
|
||||
_LOGGER.warning("Cannot cancel preheating: no scheduler entity available")
|
||||
_LOGGER.debug("Exiting ControlPreheatingUseCase.cancel_preheating() -> no-op")
|
||||
return
|
||||
_LOGGER.info(
|
||||
"Canceling preheating for scheduler %s - reverting to current scheduled state",
|
||||
effective_scheduler,
|
||||
)
|
||||
# Call cancel_action to revert thermostat to current time's preset/temperature
|
||||
# Validation is handled by scheduler_commander
|
||||
await self._scheduler_commander.cancel_action(effective_scheduler)
|
||||
else:
|
||||
_LOGGER.debug("No active preheating to cancel")
|
||||
|
||||
# Mark preheating as inactive (but keep target_time for state tracking)
|
||||
# Target time may still be in future (e.g., cancelled due to overshoot)
|
||||
self._is_preheating_active = False
|
||||
# Note: _preheating_target_time and _active_scheduler_entity are NOT cleared
|
||||
# They remain for state tracking and potential restart
|
||||
|
||||
_LOGGER.debug("Exiting ControlPreheatingUseCase.cancel_preheating()")
|
||||
|
||||
async def start_preheating(
|
||||
self,
|
||||
target_time: datetime,
|
||||
target_temp: float,
|
||||
scheduler_entity_id: str,
|
||||
) -> None:
|
||||
"""Start preheating by triggering scheduler action.
|
||||
|
||||
Args:
|
||||
target_time: Target schedule time
|
||||
target_temp: Target temperature
|
||||
scheduler_entity_id: Scheduler entity to trigger
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Entering ControlPreheatingUseCase.start_preheating(target_time=%s, temp=%.1f, scheduler=%s)",
|
||||
target_time.isoformat(),
|
||||
target_temp,
|
||||
scheduler_entity_id,
|
||||
)
|
||||
_LOGGER.info(
|
||||
"Starting preheating for target %s (%.1f°C)",
|
||||
target_time.isoformat(),
|
||||
target_temp,
|
||||
)
|
||||
|
||||
# Use scheduler's run_action to trigger the action
|
||||
await self._scheduler_commander.run_action(target_time, scheduler_entity_id)
|
||||
|
||||
# Mark pre-heating as active
|
||||
self._is_preheating_active = True
|
||||
self._preheating_target_time = target_time
|
||||
self._active_scheduler_entity = scheduler_entity_id
|
||||
|
||||
_LOGGER.debug("Exiting ControlPreheatingUseCase.start_preheating()")
|
||||
|
||||
def is_preheating_active(self) -> bool:
|
||||
"""Check if preheating is currently active.
|
||||
|
||||
Returns:
|
||||
True if preheating is active, False otherwise
|
||||
"""
|
||||
return self._is_preheating_active
|
||||
|
||||
def get_preheating_target_time(self) -> datetime | None:
|
||||
"""Get the target time for active preheating.
|
||||
|
||||
Returns:
|
||||
Target time if preheating is active, None otherwise
|
||||
"""
|
||||
return self._preheating_target_time
|
||||
|
||||
def get_active_scheduler_entity(self) -> str | None:
|
||||
"""Get the active scheduler entity.
|
||||
|
||||
Returns:
|
||||
Scheduler entity ID if active, None otherwise
|
||||
"""
|
||||
return self._active_scheduler_entity
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
"""Schedule Anticipation Action Use Case.
|
||||
|
||||
This use case encapsulates the complex logic for scheduling preheating based on
|
||||
anticipation calculations, including revert logic when conditions change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ...const import DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from ...domain.interfaces import ISchedulerCommander, ISchedulerReader, ITimerScheduler
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScheduleAnticipationActionUseCase:
|
||||
"""Use case for scheduling anticipation actions.
|
||||
|
||||
This use case encapsulates the complex logic for:
|
||||
1. Checking scheduler state
|
||||
2. Handling revert logic when anticipated start is postponed significantly
|
||||
3. Scheduling preheating timers
|
||||
4. Triggering immediate preheating when needed
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scheduler_reader: ISchedulerReader,
|
||||
scheduler_commander: ISchedulerCommander,
|
||||
timer_scheduler: ITimerScheduler,
|
||||
control_preheating_use_case, # ControlPreheatingUseCase (avoid circular import)
|
||||
anticipation_recalc_tolerance_minutes: int = DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
|
||||
) -> None:
|
||||
"""Initialize the use case.
|
||||
|
||||
Args:
|
||||
scheduler_reader: Reads scheduler state
|
||||
scheduler_commander: Triggers scheduler actions
|
||||
timer_scheduler: Schedules timer callbacks
|
||||
control_preheating_use_case: Use case for managing preheating state
|
||||
anticipation_recalc_tolerance_minutes: Min absolute delta in anticipated
|
||||
start time required to cancel active preheating and reschedule
|
||||
"""
|
||||
_LOGGER.debug("Initializing ScheduleAnticipationActionUseCase")
|
||||
self._scheduler_reader = scheduler_reader
|
||||
self._scheduler_commander = scheduler_commander
|
||||
self._timer_scheduler = timer_scheduler
|
||||
self._control_preheating = control_preheating_use_case
|
||||
|
||||
# Scheduling-specific state (not preheating state - delegated to ControlPreheatingUseCase)
|
||||
self._last_scheduled_time: datetime | None = None
|
||||
self._last_scheduled_lhs: float | None = None
|
||||
self._anticipation_timer_cancel: Callable[[], None] | None = None
|
||||
self._preheating_target_temp: float | None = None # Temp only (time/active delegated)
|
||||
self._anticipation_recalc_tolerance_seconds = anticipation_recalc_tolerance_minutes * 60
|
||||
|
||||
def set_preheating_temp(self, target_temp: float | None) -> None:
|
||||
"""Update preheating target temperature.
|
||||
|
||||
Note: Preheating state (active, target_time) is managed by ControlPreheatingUseCase.
|
||||
|
||||
Args:
|
||||
target_temp: Target temperature
|
||||
"""
|
||||
_LOGGER.debug("Setting preheating target temp: %.1f", target_temp or 0.0)
|
||||
self._preheating_target_temp = target_temp
|
||||
|
||||
async def handle_anticipation_scheduling(
|
||||
self,
|
||||
anticipation_data: dict,
|
||||
ihp_enabled: bool,
|
||||
) -> None:
|
||||
"""Handle the complete scheduling workflow based on anticipation data and IHP status.
|
||||
|
||||
This method contains all the business logic for deciding when to schedule/cancel
|
||||
preheating based on data availability, IHP status, and current state.
|
||||
|
||||
Args:
|
||||
anticipation_data: Calculated anticipation data
|
||||
ihp_enabled: Whether IHP is enabled
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Entering handle_anticipation_scheduling(ihp_enabled=%s, has_data=%s)",
|
||||
ihp_enabled,
|
||||
anticipation_data.get("anticipated_start_time") is not None,
|
||||
)
|
||||
|
||||
# Decision 1: No valid data - cancel everything
|
||||
if anticipation_data.get("anticipated_start_time") is None:
|
||||
_LOGGER.debug("No valid anticipation data - cancelling any active scheduling")
|
||||
await self.cancel_action()
|
||||
await self._control_preheating.cancel_preheating(
|
||||
self._control_preheating.get_active_scheduler_entity()
|
||||
or anticipation_data.get("scheduler_entity")
|
||||
)
|
||||
return
|
||||
|
||||
# Decision 2: IHP disabled - cancel but don't schedule
|
||||
if not ihp_enabled:
|
||||
_LOGGER.debug("IHP disabled - cancelling preheating if active")
|
||||
await self._control_preheating.cancel_preheating(
|
||||
self._control_preheating.get_active_scheduler_entity()
|
||||
or anticipation_data.get("scheduler_entity")
|
||||
)
|
||||
await self.cancel_action()
|
||||
return
|
||||
|
||||
# Decision 3: Target already reached (anticipation_minutes == 0) - clear state
|
||||
if anticipation_data.get("anticipation_minutes") == 0:
|
||||
_LOGGER.debug("Target reached - clearing anticipation state")
|
||||
await self.cancel_action()
|
||||
await self._control_preheating.cancel_preheating(
|
||||
self._control_preheating.get_active_scheduler_entity()
|
||||
or anticipation_data.get("scheduler_entity")
|
||||
)
|
||||
return
|
||||
|
||||
# Decision 4: No scheduler entity - skip scheduling
|
||||
scheduler_entity = anticipation_data.get("scheduler_entity")
|
||||
if not scheduler_entity:
|
||||
_LOGGER.debug("No scheduler entity - skipping scheduling")
|
||||
return
|
||||
|
||||
# Decision 5: Valid data + IHP enabled + scheduler available - schedule
|
||||
await self.schedule_action(
|
||||
anticipated_start=anticipation_data["anticipated_start_time"],
|
||||
target_time=anticipation_data["next_schedule_time"],
|
||||
target_temp=anticipation_data["next_target_temperature"],
|
||||
scheduler_entity_id=scheduler_entity,
|
||||
lhs=float(anticipation_data.get("learned_heating_slope") or 0.0),
|
||||
)
|
||||
|
||||
async def schedule_action(
|
||||
self,
|
||||
anticipated_start: datetime,
|
||||
target_time: datetime,
|
||||
target_temp: float,
|
||||
scheduler_entity_id: str,
|
||||
lhs: float,
|
||||
) -> None:
|
||||
"""Schedule heating anticipation action.
|
||||
|
||||
This method handles all the scheduling logic including:
|
||||
- Checking scheduler state
|
||||
- Handling revert when anticipated time changes significantly
|
||||
- Scheduling timers for future starts
|
||||
- Triggering immediate preheating if start is in the past
|
||||
|
||||
Args:
|
||||
anticipated_start: When to start preheating
|
||||
target_time: Target schedule time
|
||||
target_temp: Target temperature
|
||||
scheduler_entity_id: Scheduler entity to trigger
|
||||
lhs: Learned heating slope
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Entering ScheduleAnticipationActionUseCase.schedule_action("
|
||||
"anticipated_start=%s, scheduler=%s)",
|
||||
anticipated_start.isoformat(),
|
||||
scheduler_entity_id,
|
||||
)
|
||||
|
||||
now = dt_util.now()
|
||||
|
||||
# Check if scheduler is enabled
|
||||
if not await self._scheduler_reader.is_scheduler_enabled(scheduler_entity_id):
|
||||
_LOGGER.warning(
|
||||
"Scheduler %s is disabled. Skipping anticipation scheduling.",
|
||||
scheduler_entity_id,
|
||||
)
|
||||
active_scheduler = self._control_preheating.get_active_scheduler_entity()
|
||||
if active_scheduler == scheduler_entity_id:
|
||||
await self._clear_state()
|
||||
return
|
||||
|
||||
# Handle revert logic: cancel active preheating only when anticipated start is pushed later
|
||||
# by at least the configured tolerance.
|
||||
if self._control_preheating.is_preheating_active():
|
||||
preheating_target = self._control_preheating.get_preheating_target_time()
|
||||
postponed_seconds = 0.0
|
||||
if self._last_scheduled_time is not None:
|
||||
postponed_seconds = (anticipated_start - self._last_scheduled_time).total_seconds()
|
||||
if (
|
||||
preheating_target == target_time
|
||||
and self._last_scheduled_time is not None
|
||||
and postponed_seconds >= self._anticipation_recalc_tolerance_seconds
|
||||
):
|
||||
delta_minutes = postponed_seconds / 60.0
|
||||
_LOGGER.info(
|
||||
"Anticipated start moved later significantly (delta: %.1f min, "
|
||||
"threshold: %.1f min). Reverting active preheating and rescheduling.",
|
||||
delta_minutes,
|
||||
self._anticipation_recalc_tolerance_seconds / 60.0,
|
||||
)
|
||||
|
||||
# Delegate cancellation to ControlPreheatingUseCase
|
||||
await self._control_preheating.cancel_preheating(scheduler_entity_id)
|
||||
|
||||
# If target time reached, mark complete
|
||||
if now >= target_time:
|
||||
_LOGGER.info("Target time reached, preheating complete")
|
||||
await self._clear_state()
|
||||
return
|
||||
|
||||
# Update tracking
|
||||
self._last_scheduled_time = anticipated_start
|
||||
self._last_scheduled_lhs = lhs
|
||||
|
||||
# If anticipated start is in past but target is future, trigger now
|
||||
if anticipated_start <= now < target_time:
|
||||
if not self._control_preheating.is_preheating_active():
|
||||
_LOGGER.info(
|
||||
"Anticipated start %s is past, triggering preheating immediately",
|
||||
anticipated_start.isoformat(),
|
||||
)
|
||||
# Delegate to ControlPreheatingUseCase
|
||||
await self._control_preheating.start_preheating(
|
||||
target_time, target_temp, scheduler_entity_id
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Already preheating, continuing through target time %s", target_time.isoformat()
|
||||
)
|
||||
_LOGGER.debug("Exiting schedule_action() -> immediate trigger")
|
||||
return
|
||||
|
||||
# Both times in past - skip
|
||||
if anticipated_start <= now and target_time <= now:
|
||||
_LOGGER.debug("Both times are in past, skipping")
|
||||
await self._cancel_timer()
|
||||
return
|
||||
|
||||
# Schedule timer for future start only if preheating is not already active.
|
||||
if not self._control_preheating.is_preheating_active():
|
||||
await self._schedule_timer(
|
||||
anticipated_start,
|
||||
target_time,
|
||||
target_temp,
|
||||
scheduler_entity_id,
|
||||
)
|
||||
_LOGGER.debug("Exiting schedule_action() -> timer scheduled")
|
||||
|
||||
async def _schedule_timer(
|
||||
self,
|
||||
anticipated_start: datetime,
|
||||
target_time: datetime,
|
||||
target_temp: float,
|
||||
scheduler_entity_id: str,
|
||||
) -> None:
|
||||
"""Schedule a timer to trigger preheating at anticipated start time.
|
||||
|
||||
Args:
|
||||
anticipated_start: When timer should fire
|
||||
target_time: Target schedule time
|
||||
target_temp: Target temperature
|
||||
scheduler_entity_id: Scheduler to trigger
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Scheduling timer: anticipated_start=%s, scheduler=%s",
|
||||
anticipated_start.isoformat(),
|
||||
scheduler_entity_id,
|
||||
)
|
||||
|
||||
# Cancel existing timer
|
||||
await self._cancel_timer()
|
||||
|
||||
# Create callback
|
||||
async def _timer_callback() -> None:
|
||||
"""Execute when timer fires."""
|
||||
_LOGGER.info(
|
||||
"Anticipation timer fired at %s for target %s (%.1f°C)",
|
||||
dt_util.now().isoformat(),
|
||||
target_time.isoformat(),
|
||||
target_temp,
|
||||
)
|
||||
|
||||
# Clear timer reference
|
||||
self._anticipation_timer_cancel = None
|
||||
|
||||
# Trigger the action
|
||||
await self._trigger_action(target_time, target_temp, scheduler_entity_id)
|
||||
|
||||
# Schedule the timer
|
||||
self._anticipation_timer_cancel = self._timer_scheduler.schedule_timer(
|
||||
anticipated_start,
|
||||
_timer_callback,
|
||||
)
|
||||
|
||||
now = dt_util.now()
|
||||
wait_minutes = (anticipated_start - now).total_seconds() / 60.0
|
||||
_LOGGER.info(
|
||||
"Anticipation timer scheduled: will trigger at %s (in %.1f minutes)",
|
||||
anticipated_start.isoformat(),
|
||||
wait_minutes,
|
||||
)
|
||||
|
||||
async def _trigger_action(
|
||||
self,
|
||||
target_time: datetime,
|
||||
target_temp: float,
|
||||
scheduler_entity_id: str,
|
||||
) -> None:
|
||||
"""Trigger the preheating action via scheduler.
|
||||
|
||||
Args:
|
||||
target_time: Target time
|
||||
target_temp: Target temperature
|
||||
scheduler_entity_id: Scheduler entity
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Triggering anticipation action: target_time=%s, temp=%.1f°C",
|
||||
target_time.isoformat(),
|
||||
target_temp,
|
||||
)
|
||||
|
||||
# Verify scheduler is still enabled
|
||||
if not await self._scheduler_reader.is_scheduler_enabled(scheduler_entity_id):
|
||||
_LOGGER.warning("Scheduler %s is disabled, cannot trigger action", scheduler_entity_id)
|
||||
await self._clear_state()
|
||||
return
|
||||
|
||||
# Delegate to ControlPreheatingUseCase
|
||||
await self._control_preheating.start_preheating(
|
||||
target_time, target_temp, scheduler_entity_id
|
||||
)
|
||||
self._preheating_target_temp = target_temp
|
||||
|
||||
_LOGGER.debug("Action triggered successfully")
|
||||
|
||||
async def _cancel_timer(self) -> None:
|
||||
"""Cancel any active timer.
|
||||
|
||||
Note: Does NOT clear _preheating_target_time - that's preserved for state tracking.
|
||||
"""
|
||||
_LOGGER.debug("Entering cancel_timer")
|
||||
if self._anticipation_timer_cancel:
|
||||
_LOGGER.debug("Canceling active timer")
|
||||
self._anticipation_timer_cancel()
|
||||
self._anticipation_timer_cancel = None
|
||||
# Note: Timer cancelled but target_time preserved (as per review feedback)
|
||||
_LOGGER.debug("Exiting cancel_timer")
|
||||
|
||||
async def _clear_state(self) -> None:
|
||||
"""Clear all tracking state."""
|
||||
_LOGGER.debug("Clearing anticipation state")
|
||||
await self._cancel_timer()
|
||||
# Clear scheduling-specific state
|
||||
self._preheating_target_temp = None
|
||||
self._last_scheduled_time = None
|
||||
self._last_scheduled_lhs = None
|
||||
# Note: Preheating active/target_time managed by ControlPreheatingUseCase
|
||||
|
||||
async def cancel_action(self) -> None:
|
||||
"""Cancel any active timer and clear state.
|
||||
|
||||
Called by orchestrator when disabling preheating or when conditions change.
|
||||
"""
|
||||
_LOGGER.debug("Canceling anticipation action")
|
||||
await self._cancel_timer()
|
||||
|
||||
def get_preheating_state(self) -> tuple[bool, datetime | None, float | None]:
|
||||
"""Get current preheating state.
|
||||
|
||||
Delegates to ControlPreheatingUseCase for state queries.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_active, target_time, target_temp)
|
||||
"""
|
||||
return (
|
||||
self._control_preheating.is_preheating_active(),
|
||||
self._control_preheating.get_preheating_target_time(),
|
||||
self._preheating_target_temp,
|
||||
)
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
"""Update Cache Data Use Case.
|
||||
|
||||
This use case manages the heating cycle cache:
|
||||
- Get cache data
|
||||
- Update/prune cache
|
||||
- Reset cache completely
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from ...domain.interfaces import IHeatingCycleStorage, ILhsStorage
|
||||
from ...domain.value_objects import HeatingCycle, HeatingCycleCacheData
|
||||
from ..lhs_lifecycle_manager import LhsLifecycleManager
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateCacheDataUseCase:
|
||||
"""Use case for managing heating cycle cache.
|
||||
|
||||
This use case encapsulates the logic for:
|
||||
1. Getting cycles from cache
|
||||
2. Updating cache (append new cycles, then prune old ones)
|
||||
3. Recalculating LHS when cycles change
|
||||
4. Resetting cache completely
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cycle_storage: IHeatingCycleStorage,
|
||||
lhs_storage: ILhsStorage,
|
||||
lhs_lifecycle_manager: LhsLifecycleManager,
|
||||
) -> None:
|
||||
"""Initialize the use case.
|
||||
|
||||
Args:
|
||||
cycle_storage: Heating cycle cache storage
|
||||
lhs_storage: LHS storage for clearing
|
||||
lhs_lifecycle_manager: Manages LHS recalculation
|
||||
"""
|
||||
_LOGGER.debug("Initializing UpdateCacheDataUseCase")
|
||||
self._cycle_storage = cycle_storage
|
||||
self._lhs_storage = lhs_storage
|
||||
self._lhs_manager = lhs_lifecycle_manager
|
||||
|
||||
async def get_cache_data(self, device_id: str) -> HeatingCycleCacheData | None:
|
||||
"""Get cache data for a device.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
|
||||
Returns:
|
||||
Cache data if exists, None otherwise
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Entering UpdateCacheDataUseCase.get_cache_data(device_id=%s)",
|
||||
device_id,
|
||||
)
|
||||
|
||||
cache_data = await self._cycle_storage.get_cache_data(device_id)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Exiting UpdateCacheDataUseCase.get_cache_data() -> %s",
|
||||
"data" if cache_data else "None",
|
||||
)
|
||||
return cache_data
|
||||
|
||||
async def append_cycles(
|
||||
self,
|
||||
device_id: str,
|
||||
cycles: list[HeatingCycle],
|
||||
reference_time: datetime,
|
||||
) -> None:
|
||||
"""Append new cycles to cache and prune old ones.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
cycles: New heating cycles to append
|
||||
reference_time: Reference time for pruning old cycles
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Entering UpdateCacheDataUseCase.append_cycles(device_id=%s, cycles=%d)",
|
||||
device_id,
|
||||
len(cycles),
|
||||
)
|
||||
|
||||
# Append cycles to storage (storage handles deduplication)
|
||||
await self._cycle_storage.append_cycles(device_id, cycles, reference_time)
|
||||
|
||||
_LOGGER.info(
|
||||
"Appended %d cycles to cache for device %s",
|
||||
len(cycles),
|
||||
device_id,
|
||||
)
|
||||
|
||||
# Prune old cycles based on retention
|
||||
await self.prune_old_cycles(device_id, reference_time)
|
||||
|
||||
_LOGGER.debug("Exiting UpdateCacheDataUseCase.append_cycles()")
|
||||
|
||||
async def prune_old_cycles(
|
||||
self,
|
||||
device_id: str,
|
||||
reference_time: datetime,
|
||||
) -> None:
|
||||
"""Prune cycles older than retention period and recalculate LHS.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
reference_time: Reference time for retention calculation
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Entering UpdateCacheDataUseCase.prune_old_cycles(device_id=%s, reference_time=%s)",
|
||||
device_id,
|
||||
reference_time.isoformat(),
|
||||
)
|
||||
|
||||
# Prune old cycles
|
||||
await self._cycle_storage.prune_old_cycles(device_id, reference_time)
|
||||
|
||||
# Note: LHS is automatically updated when cycles change via event listeners
|
||||
# No need to explicitly recalculate here
|
||||
_LOGGER.info("Old cycles pruned, LHS will be recalculated automatically")
|
||||
|
||||
_LOGGER.debug("Exiting UpdateCacheDataUseCase.prune_old_cycles()")
|
||||
|
||||
async def reset_cache(self, device_id: str) -> None:
|
||||
"""Reset cache completely for a device (both cycles and LHS).
|
||||
|
||||
This deletes all cached cycles and LHS data.
|
||||
|
||||
Args:
|
||||
device_id: Device identifier
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Entering UpdateCacheDataUseCase.reset_cache(device_id=%s)",
|
||||
device_id,
|
||||
)
|
||||
_LOGGER.info("Resetting heating cycle cache and LHS for device %s", device_id)
|
||||
|
||||
# Clear all cycle cache data
|
||||
await self._cycle_storage.clear_cache(device_id)
|
||||
_LOGGER.info("Heating cycle cache has been reset")
|
||||
|
||||
# Clear LHS data
|
||||
await self._lhs_storage.clear_slope_history()
|
||||
_LOGGER.info("LHS data has been reset")
|
||||
|
||||
_LOGGER.info("Cache and LHS have been reset for device %s", device_id)
|
||||
_LOGGER.debug("Exiting UpdateCacheDataUseCase.reset_cache()")
|
||||
@@ -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)
|
||||
"""
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+72
@@ -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.
|
||||
"""
|
||||
+39
@@ -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
|
||||
+52
@@ -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
|
||||
+154
@@ -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
|
||||
+80
@@ -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
|
||||
+52
@@ -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
|
||||
+37
@@ -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
|
||||
+131
@@ -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
|
||||
+78
@@ -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)
|
||||
+109
@@ -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
|
||||
+37
@@ -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
|
||||
+38
@@ -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",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+134
@@ -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
|
||||
+66
@@ -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
|
||||
+115
@@ -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
|
||||
+69
@@ -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
|
||||
+1022
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
+201
@@ -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",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+55
@@ -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"
|
||||
+160
@@ -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 000–200 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
Reference in New Issue
Block a user