New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,10 @@
"""Domain layer - Pure business logic for Intelligent Heating Pilot.
This layer contains the core intellectual property and is completely isolated
from Home Assistant and other infrastructure concerns.
Rules:
- NO homeassistant.* imports
- Only Python standard library and domain code
- All external interactions via Abstract Base Classes (interfaces)
"""
@@ -0,0 +1,33 @@
"""Domain constants for heating prediction business logic.
These constants define the business rules for heating anticipation calculations.
They are part of the domain layer and independent of infrastructure concerns.
"""
# Anticipation time constraints (in minutes)
MIN_ANTICIPATION_TIME = 10 # Minimum anticipation before heating starts
MAX_ANTICIPATION_TIME = 360 # Maximum anticipation (6 hours)
DEFAULT_ANTICIPATION_BUFFER = 5 # Safety buffer to ensure target is reached on time
# Heating slope thresholds (in °C/hour)
MIN_VALID_SLOPE = 0.1 # Minimum valid heating slope (technical threshold)
MINIMUM_REALISTIC_LHS = 0.2 # Minimum realistic LHS for effective heating (business threshold)
DEFAULT_LEARNED_SLOPE = 2.0 # Default slope when no learning data exists
# Dead time (in minutes)
DEFAULT_DEAD_TIME_MINUTES = 0.0 # Default dead time when no learning data exists
# Environmental correction factors
OUTDOOR_TEMP_REFERENCE = 20.0 # Reference outdoor temperature (°C)
OUTDOOR_TEMP_FACTOR = 0.05 # Impact factor per degree difference
HUMIDITY_REFERENCE = 50.0 # Reference humidity percentage
HUMIDITY_FACTOR = 0.002 # Impact factor per humidity percentage point
CLOUD_COVERAGE_FACTOR = 0.001 # Solar gain factor per cloud coverage percentage
# Confidence thresholds
HIGH_CONFIDENCE_SLOPE = 1.5 # Slope threshold for high confidence (°C/h)
MEDIUM_CONFIDENCE_SLOPE = 0.5 # Slope threshold for medium confidence (°C/h)
BASE_HIGH_CONFIDENCE = 0.9 # Base confidence with good slope
BASE_MEDIUM_CONFIDENCE = 0.75 # Base confidence with medium slope
BASE_LOW_CONFIDENCE = 0.6 # Base confidence with low slope
CONFIDENCE_BOOST_PER_SENSOR = 0.05 # Confidence increase per environmental sensor
@@ -0,0 +1,9 @@
"""Domain entities - objects with identity and lifecycle."""
from __future__ import annotations
from .heating_pilot import HeatingPilot
__all__ = [
"HeatingPilot",
]
@@ -0,0 +1,96 @@
"""Heating pilot - the aggregate root for heating decisions."""
from __future__ import annotations
import logging
from ..interfaces import IDecisionStrategy, ISchedulerCommander
from ..value_objects import (
EnvironmentState,
HeatingDecision,
)
_LOGGER = logging.getLogger(__name__)
class HeatingPilot:
"""Coordinates heating decisions for a single VTherm.
This is the aggregate root that orchestrates all domain logic
for intelligent heating control. It delegates decision-making to
a configurable strategy, allowing users to choose between:
- Simple rule-based decisions (no ML required)
- ML-powered decisions (requires IHP-ML-Models add-on)
This design follows the Strategy pattern, making the pilot
independent of the decision algorithm complexity.
Attributes:
_decision_strategy: Strategy for making heating decisions
_scheduler_commander: Interface to control scheduler actions
"""
def __init__(
self,
decision_strategy: IDecisionStrategy,
scheduler_commander: ISchedulerCommander,
) -> None:
"""Initialize the heating pilot.
Args:
decision_strategy: Strategy for making heating decisions
(simple rules or ML-based)
scheduler_commander: Implementation of scheduler control interface
"""
_LOGGER.debug("Initializing HeatingPilot")
self._decision_strategy = decision_strategy
self._scheduler_commander = scheduler_commander
_LOGGER.info(f"HeatingPilot initialized with strategy: {type(decision_strategy).__name__}")
async def decide_heating_action(
self,
environment: EnvironmentState,
) -> HeatingDecision:
"""Decide what heating action to take based on current conditions.
This method delegates the decision to the configured strategy,
which can be either simple rule-based or ML-powered.
Args:
environment: Current environmental conditions
Returns:
A heating decision with the action to take
"""
_LOGGER.debug("HeatingPilot.decide_heating_action called")
_LOGGER.debug(f"Delegating decision to {type(self._decision_strategy).__name__}")
decision = await self._decision_strategy.decide_heating_action(environment)
_LOGGER.info(f"HeatingPilot decision: {decision.action.value}")
return decision
async def check_overshoot_risk(
self,
environment: EnvironmentState,
current_slope: float,
) -> HeatingDecision:
"""Check if heating should stop to prevent overshooting target.
This method delegates the overshoot check to the configured strategy.
Args:
environment: Current environmental conditions
current_slope: Current heating rate in °C/hour
Returns:
Decision to stop heating if overshoot is detected
"""
_LOGGER.debug("HeatingPilot.check_overshoot_risk called")
_LOGGER.debug(f"Delegating overshoot check to {type(self._decision_strategy).__name__}")
decision = await self._decision_strategy.check_overshoot_risk(environment, current_slope)
_LOGGER.info(f"HeatingPilot overshoot check: {decision.action.value}")
return decision
@@ -0,0 +1,35 @@
"""Domain interfaces - contracts for external interactions.
These abstract base classes define how the domain interacts with
the outside world without coupling to specific implementations.
"""
from __future__ import annotations
from .climate_data_reader_interface import IClimateDataReader
from .context_reader_interface import IContextReader
from .decision_strategy_interface import IDecisionStrategy
from .device_config_reader_interface import IDeviceConfigReader
from .environment_reader_interface import IEnvironmentReader
from .heating_cycle_service_interface import IHeatingCycleService
from .heating_cycle_storage_interface import IHeatingCycleStorage
from .historical_data_adapter_interface import IHistoricalDataAdapter
from .lhs_storage_interface import ILhsStorage
from .scheduler_commander_interface import ISchedulerCommander
from .scheduler_reader_interface import ISchedulerReader
from .timer_scheduler import ITimerScheduler
__all__ = [
"IClimateDataReader",
"ISchedulerReader",
"IEnvironmentReader",
"IContextReader",
"ILhsStorage",
"ISchedulerCommander",
"IDecisionStrategy",
"IHeatingCycleService",
"IHeatingCycleStorage",
"IDeviceConfigReader",
"IHistoricalDataAdapter",
"ITimerScheduler",
]
@@ -0,0 +1,72 @@
"""Climate data reader interface.
Unified interface for reading VTherm climate data: entity identification,
current heating slope, and heating active state. All three concerns target
the *same* VTherm entity and are therefore grouped into a single contract
to avoid unnecessary fragmentation.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class IClimateDataReader(ABC):
"""Contract for reading climate data from a VTherm entity.
This interface unifies three previously separate readers
(IVThermMetadataReader, IHeatingSlopeReader, IHeatingStateReader)
that all operate on the same underlying VTherm climate entity.
Implementors must be stateless with respect to the returned values;
each call should read the current live state.
"""
@abstractmethod
def get_vtherm_entity_id(self) -> str:
"""Retrieve the VTherm (climate entity) ID.
Returns:
The VTherm entity ID (e.g., ``"climate.living_room_vtherm"``).
"""
@abstractmethod
def get_current_slope(self) -> float | None:
"""Retrieve the current heating slope in °C per hour.
The slope is read from the VTherm entity's attributes and represents
the instantaneous rate of indoor-temperature increase while heating
is active.
Returns:
Current heating slope as a float, or ``None`` when:
- The VTherm entity is unavailable.
- The ``slope`` attribute is missing or cannot be parsed.
"""
@abstractmethod
def is_heating_active(self) -> bool:
"""Return whether heating is currently active on the VTherm.
Heating is typically considered active when:
1. ``hvac_mode`` is ``"heat"``, **and**
2. ``current_temperature < target_temperature``.
Returns:
``True`` when the VTherm is actively heating, ``False`` otherwise
(including when the entity is unavailable).
"""
@abstractmethod
def get_current_target_temperature(self) -> float | None:
"""Retrieve the current target temperature from the VTherm entity.
Reads the real-time target temperature set on the VTherm climate entity.
This is used, for example, to resolve a target temperature for native HA
schedule entities that do not store a temperature themselves.
Returns:
Current target temperature in °C, or ``None`` when:
- The VTherm entity is unavailable.
- The temperature attribute is missing or cannot be parsed.
"""
@@ -0,0 +1,39 @@
"""Environment context reader interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class IContextReader(ABC):
"""Contract for accessing environment metadata and adapter context.
This interface is used by the application layer for historical data
adapter orchestration. It intentionally avoids Home Assistant imports.
"""
@abstractmethod
def get_hass(self) -> Any:
"""Retrieve the Home Assistant instance for adapter orchestration."""
pass
@abstractmethod
def get_humidity_in_entity_id(self) -> str | None:
"""Retrieve the indoor humidity sensor entity ID."""
pass
@abstractmethod
def get_humidity_out_entity_id(self) -> str | None:
"""Retrieve the outdoor humidity sensor entity ID."""
pass
@abstractmethod
def get_outdoor_temp_entity_id(self) -> str | None:
"""Retrieve the outdoor temperature sensor entity ID."""
pass
@abstractmethod
def get_cloud_cover_entity_id(self) -> str | None:
"""Retrieve the cloud coverage sensor entity ID."""
pass
@@ -0,0 +1,52 @@
"""Decision strategy interface for heating control."""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..value_objects import EnvironmentState, HeatingDecision
class IDecisionStrategy(ABC):
"""Contract for heating decision strategies.
This interface allows different decision-making approaches:
- Simple rule-based decisions (no ML required)
- ML-based decisions (requires IHP-ML-Models)
- Hybrid approaches combining both
By abstracting the decision logic, we make the HeatingPilot
agnostic to the complexity of the underlying decision process.
"""
@abstractmethod
async def decide_heating_action(
self,
environment: EnvironmentState,
) -> HeatingDecision:
"""Decide what heating action to take.
Args:
environment: Current environmental conditions
Returns:
A heating decision with the action to take
"""
pass
@abstractmethod
async def check_overshoot_risk(
self,
environment: EnvironmentState,
current_slope: float,
) -> HeatingDecision:
"""Check if heating should stop to prevent overshooting.
Args:
environment: Current environmental conditions
current_slope: Current heating rate in °C/hour
Returns:
Decision to stop heating if overshoot is detected
"""
pass
@@ -0,0 +1,154 @@
"""Device configuration reader interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass(frozen=True)
class DeviceConfig:
"""Complete configuration for an IHP device.
This is an immutable value object that holds all configuration parameters
for a single IHP device. It is created by HADeviceConfigReader from
Home Assistant config entries.
Attributes:
# Required fields
device_id: Unique identifier for the device (typically config_entry.entry_id)
vtherm_entity_id: Entity ID of the virtual thermostat (climate entity)
# Optional entity IDs (environmental sensors)
scheduler_entities: List of entity IDs for scheduled events (switches)
humidity_in_entity_id: Entity ID for indoor humidity sensor (optional)
humidity_out_entity_id: Entity ID for outdoor humidity sensor (optional)
cloud_cover_entity_id: Entity ID for cloud coverage sensor (optional)
# Learning and data retention parameters
lhs_retention_days: Number of days to retain learned heating slope data
dead_time_minutes: Dead time in minutes (delay before heating becomes effective)
auto_learning: If True, automatically learn parameters from heating cycles
# Cycle detection parameters
temp_delta_threshold: Temperature delta threshold for cycle detection (°C)
cycle_split_duration_minutes: Duration to split heating cycles (0 = disabled)
min_cycle_duration_minutes: Minimum valid cycle duration
max_cycle_duration_minutes: Maximum valid cycle duration
# IHP control state
ihp_enabled: If True, IHP preheating is active; if False, IHP is paused
task_range_days: Number of days covered by each Recorder extraction task (tune to machine power)
anticipation_recalc_tolerance_minutes: Absolute time delta threshold (minutes) used
to decide whether an active preheating should be
canceled and rescheduled.
safety_shutoff_grace_minutes: Duration in minutes of the grace period for brief heating
interruptions (safety/frost mode). Interruptions shorter than
this threshold do not terminate an in-progress cycle, avoiding
bogus dead-time and slope values. Set 0 to disable.
"""
# Required fields
device_id: str
vtherm_entity_id: str
# Optional entity IDs
scheduler_entities: list[str]
humidity_in_entity_id: str | None = None
humidity_out_entity_id: str | None = None
temperature_out_entity_id: str | None = None
cloud_cover_entity_id: str | None = None
# Learning and data retention
lhs_retention_days: int = 30
dead_time_minutes: float = 0.0
auto_learning: bool = True
# Cycle detection parameters
temp_delta_threshold: float = 0.2
cycle_split_duration_minutes: int = 0
min_cycle_duration_minutes: int = 5
max_cycle_duration_minutes: int = 300
# IHP enabled state
ihp_enabled: bool = True
task_range_days: int = 7
anticipation_recalc_tolerance_minutes: int = 15
safety_shutoff_grace_minutes: int = 10
def __post_init__(self) -> None:
"""Validate configuration values after initialization.
Raises:
ValueError: If any configuration value is invalid
"""
# Validate required fields
if not self.device_id or not isinstance(self.device_id, str):
raise ValueError("device_id must be a non-empty string")
if not self.vtherm_entity_id or not isinstance(self.vtherm_entity_id, str):
raise ValueError("vtherm_entity_id must be a non-empty string")
# Validate scheduler_entities is a list
if not isinstance(self.scheduler_entities, list):
raise ValueError("scheduler_entities must be a list")
# Validate numeric ranges
if self.lhs_retention_days < 0:
raise ValueError("lhs_retention_days must be at least 0")
if self.dead_time_minutes < 0:
raise ValueError("dead_time_minutes must be at least 0")
if self.temp_delta_threshold < 0:
raise ValueError("temp_delta_threshold must be at least 0")
if self.cycle_split_duration_minutes < 0:
raise ValueError("cycle_split_duration_minutes must be at least 0")
if self.min_cycle_duration_minutes < 1:
raise ValueError("min_cycle_duration_minutes must be at least 1")
if self.max_cycle_duration_minutes <= self.min_cycle_duration_minutes:
raise ValueError("max_cycle_duration_minutes must be > min_cycle_duration_minutes")
if self.task_range_days < 1:
raise ValueError("task_range_days must be at least 1")
if self.anticipation_recalc_tolerance_minutes < 1:
raise ValueError("anticipation_recalc_tolerance_minutes must be at least 1")
if self.safety_shutoff_grace_minutes < 0:
raise ValueError("safety_shutoff_grace_minutes must be at least 0")
class IDeviceConfigReader(ABC):
"""Contract for reading device configuration.
Implementations should retrieve configuration for a specific IHP device,
including entity IDs for climate control, scheduling, and environmental sensors.
"""
@abstractmethod
async def get_device_config(self, device_id: str) -> DeviceConfig:
"""Retrieve configuration for a specific device.
Args:
device_id: The device identifier to retrieve configuration for
Returns:
DeviceConfig with all necessary entity mappings
Raises:
ValueError: If device_id is not found or configuration is invalid
"""
pass
@abstractmethod
async def get_all_device_ids(self) -> list[str]:
"""Retrieve list of all configured device IDs.
Returns:
List of configured device IDs
"""
pass
@@ -0,0 +1,80 @@
"""Interface for entity attribute mapping and extraction.
This interface defines the contract for translating entity attributes
into domain value objects and concepts.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ..value_objects.entity_attribute_mapping import (
AttributeConcept,
EntityAttributeDescriptor,
)
class IEntityAttributeMapper(ABC):
"""Contract for mapping entity attributes to domain concepts.
Implementations handle the complexity of different entity types
(VTherm with nested structures, generic climate entities, etc.)
while presenting a unified interface to the domain layer.
"""
@abstractmethod
def detect_entity_type(
self,
entity_id: str,
) -> EntityAttributeDescriptor:
"""Detect and describe an entity's attribute structure.
This method inspects the entity and determines:
- What type of entity it is (VTherm, climate, etc.)
- What attributes it actually provides
- Which mapping should be used for it
Args:
entity_id: The Home Assistant entity_id to analyze
Returns:
EntityAttributeDescriptor with entity info and appropriate mapping
Raises:
ValueError: If entity type cannot be determined or is unsupported
"""
pass
@abstractmethod
def extract_attribute_value(
self,
attributes: dict[str, Any],
concept: AttributeConcept,
) -> Any | None:
"""Extract a value from entity attributes using the concept mapping.
Tries multiple possible attribute paths (in priority order) to find
the value, supporting different entity structures transparently.
Args:
attributes: The entity's attributes dict
concept: The domain concept to extract
Returns:
The extracted value (could be float, string, bool, etc.) or None if not found
Raises:
ValueError: If concept is required but not found
"""
pass
@abstractmethod
def get_supported_concepts(self) -> list[AttributeConcept]:
"""Get list of domain concepts this mapper can extract.
Returns:
List of AttributeConcept values supported by this mapper
"""
pass
@@ -0,0 +1,52 @@
"""Environment reader interface.
Contract for reading current environmental conditions from external
data sources (e.g., Home Assistant entities).
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..value_objects import EnvironmentState
class IEnvironmentReader(ABC):
"""Contract for reading environmental conditions.
This interface defines how the domain accesses environmental data
(temperatures, humidity, cloud coverage, etc.) without coupling to
Home Assistant.
Implementations of this interface translate Home Assistant entity states
into domain value objects (EnvironmentState), enabling pure business logic
testing and maintaining clear architectural separation.
Edge Cases:
- Missing sensors: Methods return None gracefully
- Stale data: Implementations should handle validation
- Entity not found: Return None, not raise exceptions
"""
@abstractmethod
async def get_current_environment(self) -> EnvironmentState | None:
"""Retrieve current environmental conditions.
Reads environmental data from entity states and converts to a
domain EnvironmentState value object.
Returns:
EnvironmentState with current conditions (indoor_temperature,
outdoor_temp, humidity, cloud_coverage, timestamp), or None
if required data (indoor_temperature, humidity, outdoor_temp)
is unavailable.
Edge Cases:
- Returns None if VTherm entity is missing
- Returns None if indoor_temperature cannot be read
- Sensor-specific fallbacks:
- outdoor_temp: Falls back to indoor_temperature if unavailable
- indoor_humidity: Uses 50% default if unavailable
- outdoor_humidity, cloud_coverage: Optional (can be None)
"""
pass
@@ -0,0 +1,37 @@
"""Interface for heating cycle extraction service."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from ..value_objects.heating import HeatingCycle
from ..value_objects.historical_data import HistoricalDataSet
class IHeatingCycleService(ABC):
"""Abstract interface for extracting heating cycles from historical data."""
@abstractmethod
async def extract_heating_cycles(
self,
device_id: str,
history_data_set: HistoricalDataSet,
start_time: datetime,
end_time: datetime,
cycle_split_duration_minutes: int | None = 0,
) -> list[HeatingCycle]:
"""Extract heating cycles from a HistoricalDataSet within a given time range.
Args:
device_id: The device identifier for the cycles
history_data_set: A HistoricalDataSet containing all necessary raw sensor data.
start_time: The start of the time range for cycle extraction.
end_time: The end of the time range for cycle extraction.
cycle_split_duration_minutes: Duration in minutes to split long cycles
into smaller sub-cycles for granular analysis. If 0 or None, no splitting.
Returns:
A list of HeatingCycle value objects.
"""
pass
@@ -0,0 +1,131 @@
"""Interface for heating cycle storage."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import date, datetime
from ..value_objects.heating import HeatingCycle
from ..value_objects.heating_cycle_cache_data import HeatingCycleCacheData
class IHeatingCycleStorage(ABC):
"""Contract for persisting and retrieving heating cycle data.
Implementations of this interface handle storage and retrieval of
heating cycles with incremental update support to avoid repeatedly
scanning the entire Home Assistant recorder history.
"""
@abstractmethod
async def get_cache_data(self, device_id: str) -> HeatingCycleCacheData | None:
"""Get cached cycle data for a device.
Returns the complete cache data including cycles and metadata.
Returns None if no cache exists for the device.
Args:
device_id: The device identifier
Returns:
HeatingCycleCacheData if cache exists, None otherwise
"""
pass
@abstractmethod
async def append_cycles(
self,
device_id: str,
new_cycles: list[HeatingCycle],
search_end_time: datetime,
retention_days: int | None = None,
) -> None:
"""Append new cycles to the cache and update search timestamp.
This method adds new cycles to the existing cache (if any) and updates
the last_search_time to track where the next incremental search should begin.
Automatically handles deduplication based on cycle start_time.
Args:
device_id: The device identifier
new_cycles: List of new cycles to append
search_end_time: Timestamp marking the end of this search period
retention_days: Optional retention days to store with cache metadata
"""
pass
@abstractmethod
async def prune_old_cycles(
self,
device_id: str,
reference_time: datetime,
) -> bool:
"""Remove cycles older than the retention period.
Args:
device_id: The device identifier
reference_time: Time to calculate retention from
Returns:
True if any cycles were actually removed, False otherwise
"""
pass
@abstractmethod
async def clear_cache(self, device_id: str) -> None:
"""Clear all cached cycles for a device.
This resets the learning system to its initial state for the device.
Args:
device_id: The device identifier
"""
pass
@abstractmethod
async def get_last_search_time(self, device_id: str) -> datetime | None:
"""Get the timestamp of the last cycle search.
This is used to determine the start time for the next incremental search.
Returns None if no previous search has been performed.
Args:
device_id: The device identifier
Returns:
UTC timestamp of last search, or None if no cache exists
"""
pass
@abstractmethod
async def append_explored_dates(
self,
device_id: str,
explored_dates: set[date],
) -> None:
"""Mark dates as explored (whether they contained cycles or not).
This prevents re-extracting days that have already been examined,
making explored_dates the single source of truth for coverage.
Args:
device_id: The device identifier
explored_dates: Set of dates to mark as explored
"""
pass
@abstractmethod
async def get_oldest_explored_date(self, device_id: str) -> date | None:
"""Return the oldest date in explored_dates for this device.
Used by the progressive backfill scheduler to determine the next
historical period to extract. Returns None if no dates have been
explored yet (e.g. first startup before any extraction completes).
Args:
device_id: The device identifier
Returns:
The oldest explored date, or None if explored_dates is empty
"""
pass
@@ -0,0 +1,78 @@
"""Historical data adapter interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from ..value_objects import HistoricalDataKey, HistoricalDataSet
class IHistoricalDataAdapter(ABC):
"""Contract for adapting Home Assistant historical data into HistoricalDataSet.
Implementations of this interface retrieve historical data from Home Assistant
for different entity types (climate, sensor, weather) and transform them into
a standardized HistoricalDataSet format.
"""
@abstractmethod
async def fetch_historical_data(
self,
entity_id: str,
data_key: HistoricalDataKey,
start_time: datetime,
end_time: datetime,
) -> HistoricalDataSet:
"""Fetch historical data for an entity and convert to HistoricalDataSet.
Args:
entity_id: The Home Assistant entity ID (e.g., "climate.living_room")
data_key: The HistoricalDataKey to use for categorizing the measurements
start_time: The start of the historical period
end_time: The end of the historical period
Returns:
A HistoricalDataSet containing the fetched and transformed data
Raises:
ValueError: If entity_id is invalid or data cannot be fetched
"""
pass
async def fetch_all_historical_data(
self,
entity_id: str,
start_time: datetime,
end_time: datetime,
) -> HistoricalDataSet:
"""Fetch historical data for all supported keys in a single call.
This default implementation calls fetch_historical_data once per
HistoricalDataKey, which may result in redundant recorder queries.
Subclasses should override this method to fetch the raw history once
and extract all supported keys from that single result.
Args:
entity_id: The Home Assistant entity ID (e.g., "climate.living_room")
start_time: The start of the historical period
end_time: The end of the historical period
Returns:
A HistoricalDataSet containing measurements for all supported keys
"""
combined_data: dict = {}
for data_key in HistoricalDataKey:
result = await self.fetch_historical_data(
entity_id=entity_id,
data_key=data_key,
start_time=start_time,
end_time=end_time,
)
if result and result.data:
for k, measurements in result.data.items():
if measurements:
if k not in combined_data:
combined_data[k] = []
combined_data[k].extend(measurements)
return HistoricalDataSet(data=combined_data)
@@ -0,0 +1,109 @@
"""LHS (Learned Heating Slope) storage interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from ..value_objects.lhs_cache_entry import LHSCacheEntry
class ILhsStorage(ABC):
"""Contract for persisting learned heating slope (LHS) data.
Implementations of this interface handle storage and retrieval
of learned heating slopes (both global and contextual).
NOTE: Direct slope data persistence (save_slope_*) has been removed.
Slopes are now extracted directly from Home Assistant recorder via
HeatingCycleService. This interface now only provides access to the
global learned heating slope (LHS) and cleanup operations.
"""
@abstractmethod
async def get_learned_heating_slope(self) -> float:
"""Get the current learned heating slope (LHS).
This represents the system's best estimate of the heating rate
based on all historical data.
Returns:
The learned heating slope in °C/hour.
"""
pass
@abstractmethod
async def clear_slope_history(self) -> None:
"""Clear all learned slope data from history.
This resets the learning system to its initial state.
"""
pass
@abstractmethod
async def get_cached_global_lhs(self) -> LHSCacheEntry | None:
"""Return cached global LHS if available.
Returns:
LHSCacheEntry with global LHS value and timestamp, or None if not cached.
"""
pass
@abstractmethod
async def set_cached_global_lhs(self, lhs: float, updated_at: datetime) -> None:
"""Persist global LHS cache with timestamp.
Args:
lhs: The learned heating slope value in °C/hour.
updated_at: Timestamp when the LHS was calculated.
"""
pass
@abstractmethod
async def get_cached_contextual_lhs(self, hour: int) -> LHSCacheEntry | None:
"""Return cached contextual LHS for the given hour if available.
Args:
hour: Hour of day (0-23) for which to retrieve contextual LHS.
Returns:
LHSCacheEntry with contextual LHS value and timestamp, or None if not cached.
"""
pass
@abstractmethod
async def set_cached_contextual_lhs(self, hour: int, lhs: float, updated_at: datetime) -> None:
"""Persist contextual LHS cache for the given hour with timestamp.
Args:
hour: Hour of day (0-23) for which to cache the LHS.
lhs: The learned heating slope value in °C/hour for this hour.
updated_at: Timestamp when the LHS was calculated.
"""
pass
@abstractmethod
async def clear_contextual_cache(self) -> None:
"""Clear all cached contextual LHS entries."""
pass
@abstractmethod
async def get_learned_dead_time(self) -> float | None:
"""Get the current learned dead time in minutes.
Dead time is the delay between starting heat output and when the
indoor temperature begins rising noticeably.
Returns:
The learned dead time in minutes, or None if not yet learned.
"""
pass
@abstractmethod
async def set_learned_dead_time(self, dead_time: float | None) -> None:
"""Persist the learned dead time value.
Args:
dead_time: The learned dead time in minutes, or None to clear.
"""
pass
@@ -0,0 +1,37 @@
"""Scheduler commander interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
class ISchedulerCommander(ABC):
"""Contract for scheduler control actions.
Implementations of this interface execute scheduler commands using
the scheduler component's run_action service.
See: https://github.com/nielsfaber/scheduler-component/#schedulerrun_action
"""
@abstractmethod
async def run_action(self, target_time: datetime, scheduler_entity_id: str) -> None:
"""Trigger a scheduler action for a specific timeslot.
This will start heating in the mode configured in the scheduler
for the timeslot at the given time.
Args:
target_time: The time of the scheduler timeslot to trigger
"""
pass
@abstractmethod
async def cancel_action(self, scheduler_entity_id: str) -> None:
"""Cancel current scheduler action and return to current timeslot.
This is used to stop overshoot by reverting to the mode configured
for the current time (now()).
"""
pass
@@ -0,0 +1,38 @@
"""Scheduler reader interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..value_objects import ScheduledTimeslot
class ISchedulerReader(ABC):
"""Contract for reading scheduled heating timeslots.
Implementations of this interface retrieve schedule information
from external scheduling systems (e.g., Home Assistant scheduler).
See: https://github.com/nielsfaber/scheduler-component/#data-format
"""
@abstractmethod
async def get_next_timeslot(self) -> ScheduledTimeslot | None:
"""Retrieve the next scheduled heating timeslot.
Returns:
The next schedule timeslot, or None if no timeslots are scheduled.
"""
pass
@abstractmethod
async def is_scheduler_enabled(self, scheduler_entity_id: str) -> bool:
"""Check if a specific scheduler is enabled.
Args:
scheduler_entity_id: The scheduler entity ID to check
Returns:
True if the scheduler is enabled (state != "off"), False otherwise
"""
pass
@@ -0,0 +1,36 @@
"""Timer scheduler interface for anticipation triggering.
This interface abstracts timer scheduling operations, allowing the domain
to schedule anticipation triggers without depending on Home Assistant.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any, Callable, Coroutine
class ITimerScheduler(ABC):
"""Interface for scheduling timer-based callbacks.
This contract allows the application layer to schedule callbacks
at specific times without coupling to Home Assistant's event loop.
"""
@abstractmethod
def schedule_timer(
self,
target_time: datetime,
callback: Callable[[], Coroutine[Any, Any, Any]],
) -> Callable[[], None]:
"""Schedule a callback to execute at a specific time.
Args:
target_time: When to execute the callback
callback: Async function to execute at target_time
Returns:
Cancel function that can be called to cancel the timer
"""
pass
@@ -0,0 +1,17 @@
"""Domain services - stateless operations on domain objects."""
from __future__ import annotations
from .contextual_lhs_calculator_service import ContextualLHSCalculatorService
from .dead_time_calculation_service import DeadTimeCalculationService
from .global_lhs_calculator_service import GlobalLHSCalculatorService
from .heating_cycle_service import HeatingCycleService
from .prediction_service import PredictionService
__all__ = [
"PredictionService",
"HeatingCycleService",
"GlobalLHSCalculatorService",
"ContextualLHSCalculatorService",
"DeadTimeCalculationService",
]
@@ -0,0 +1,134 @@
"""Service for calculating contextual Learning Heating Slope (LHS).
Pure domain logic for grouping cycles by start hour and calculating
average heating slopes per hour. No Home Assistant dependencies.
"""
from __future__ import annotations
import logging
from ..value_objects import HeatingCycle
_LOGGER = logging.getLogger(__name__)
class ContextualLHSCalculatorService:
"""Calculate contextual LHS grouped by start hour.
Responsibilities:
- Extract hour from cycle start_time
- Group cycles by start hour
- Calculate average LHS per hour
- Handle empty groups gracefully
Pure domain logic with no Home Assistant dependencies.
"""
def extract_hour_from_cycle(self, cycle: HeatingCycle) -> int:
"""Extract hour (0-23) from cycle start time.
Args:
cycle: The heating cycle
Returns:
Hour of day (0-23)
"""
_LOGGER.debug("Extracting hour from cycle started at %s", cycle.start_time)
hour = cycle.start_time.hour
_LOGGER.debug("Extracted hour: %d", hour)
return hour
def group_cycles_by_start_hour(
self, cycles: list[HeatingCycle]
) -> dict[int, list[HeatingCycle]]:
"""Group cycles by their start_time hour.
Args:
cycles: All extracted heating cycles
Returns:
Mapping {hour: [cycles_starting_at_hour]}
"""
_LOGGER.debug("Grouping %d cycles by start hour", len(cycles))
grouped: dict[int, list[HeatingCycle]] = {h: [] for h in range(24)}
for cycle in cycles:
hour = self.extract_hour_from_cycle(cycle)
grouped[hour].append(cycle)
# Log summary
non_empty = {h: len(c) for h, c in grouped.items() if c}
_LOGGER.info("Grouped cycles by hour: %s", non_empty)
return grouped
def calculate_contextual_lhs_for_hour(
self, cycles: list[HeatingCycle], target_hour: int
) -> float | None:
"""Calculate average LHS for cycles starting at target_hour.
Args:
cycles: All extracted cycles
target_hour: Hour (0-23) to filter by
Returns:
Average LHS value or None if no data for this hour
Raises:
ValueError: If target_hour not in 0-23
"""
if not 0 <= target_hour <= 23:
raise ValueError(f"target_hour must be 0-23, got {target_hour}")
_LOGGER.debug(
"Calculating contextual LHS for hour %d from %d cycles", target_hour, len(cycles)
)
# Filter cycles starting at target hour
matching_cycles = [c for c in cycles if self.extract_hour_from_cycle(c) == target_hour]
if not matching_cycles:
_LOGGER.debug("No cycles found for hour %d", target_hour)
return None
# Filter out non-positive slopes: cycles where temperature didn't rise
# carry no useful learning data about heating speed
lhs_values = [c.avg_heating_slope for c in matching_cycles if c.avg_heating_slope > 0]
if not lhs_values:
_LOGGER.debug("No cycles with positive heating slope for hour %d", target_hour)
return None
avg_lhs = sum(lhs_values) / len(lhs_values)
_LOGGER.info(
"Calculated contextual LHS for hour %d: %.2f°C/h from %d cycles",
target_hour,
avg_lhs,
len(matching_cycles),
)
return avg_lhs
def calculate_all_contextual_lhs(self, cycles: list[HeatingCycle]) -> dict[int, float | None]:
"""Calculate contextual LHS for all 24 hours.
Args:
cycles: All extracted cycles
Returns:
Mapping {hour: avg_lhs_value_or_none}
"""
_LOGGER.info("Calculating contextual LHS for all 24 hours")
result = {}
for hour in range(24):
lhs = self.calculate_contextual_lhs_for_hour(cycles, hour)
result[hour] = lhs
hours_with_data = sum(1 for v in result.values() if v is not None)
_LOGGER.info("Contextual LHS calculated for %d hours with data", hours_with_data)
return result
@@ -0,0 +1,66 @@
"""Service for calculating average dead time from heating cycles.
Pure domain logic for aggregating dead_time_cycle_minutes values
from HeatingCycle instances. No Home Assistant dependencies.
"""
from __future__ import annotations
import logging
from ..value_objects import HeatingCycle
_LOGGER = logging.getLogger(__name__)
class DeadTimeCalculationService:
"""Calculate average dead time from heating cycles.
Responsibilities:
- Filter cycles with valid dead_time_cycle_minutes
- Compute average dead time across valid cycles
- Return None when no valid data exists
Pure domain logic with no Home Assistant dependencies.
"""
def calculate_average_dead_time(self, heating_cycles: list[HeatingCycle]) -> float | None:
"""Calculate average dead_time from cycles with valid dead_time_cycle_minutes.
Args:
heating_cycles: List of heating cycles to analyze
Returns:
Average dead time in minutes, or None if no valid data
"""
_LOGGER.debug(
"Entering calculate_average_dead_time: cycles=%d",
len(heating_cycles),
)
cycles_with_dead_time = [
cycle
for cycle in heating_cycles
if cycle.dead_time_cycle_minutes is not None and cycle.dead_time_cycle_minutes > 0
]
if not cycles_with_dead_time:
_LOGGER.debug("No cycles with valid dead_time_cycle_minutes")
_LOGGER.debug("Exiting calculate_average_dead_time: result=None")
return None
total_dead_time = sum(
cycle.dead_time_cycle_minutes
for cycle in cycles_with_dead_time
if cycle.dead_time_cycle_minutes is not None
)
avg_dead_time = total_dead_time / len(cycles_with_dead_time)
_LOGGER.info(
"Calculated average dead_time from %d cycles: %.1f minutes",
len(cycles_with_dead_time),
avg_dead_time,
)
_LOGGER.debug("Exiting calculate_average_dead_time: result=%.1f", avg_dead_time)
return avg_dead_time
@@ -0,0 +1,115 @@
"""Extraction date range calculator for historical data loading."""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
_LOGGER = logging.getLogger(__name__)
class ExtractionDateRangeCalculator:
"""Utility service to calculate the date range for recording extraction.
This service encapsulates the business logic for determining which dates should
be extracted from the Recorder based on:
- The configured retention period (in days)
- The oldest cycle already in the cache
- The current datetime
The goal is to extract enough historical data to build machine learning models
while respecting the retention window and avoiding redundant extraction.
"""
@staticmethod
def calculate_extraction_range(
retention_days: int,
oldest_cycle_in_cache: datetime | None,
current_time: datetime | None = None,
) -> tuple[date, date]:
"""Calculate the date range for recording extraction.
Logic:
1. If oldest_cycle_in_cache is None (empty cache):
- Extract from (now - retention_days) to today
2. If oldest_cycle_in_cache exists:
- Calculate: oldest_cycle - 24 hours
- Extract from max(original_start_date, oldest_cycle - 24h) to today
- This ensures we have one full day of context before the oldest cycle
Args:
retention_days: The retention window in days (e.g., 90)
oldest_cycle_in_cache: The datetime of the oldest cycle currently in cache,
or None if cache is empty
current_time: Current datetime (for testing; defaults to now())
Returns:
A tuple of (start_date, end_date) both inclusive, both as datetime.date objects
Raises:
ValueError: If retention_days is negative.
"""
if current_time is None:
current_time = datetime.now()
if retention_days < 0:
_LOGGER.error("retention_days must be non-negative, got %d", retention_days)
raise ValueError(f"retention_days must be non-negative, got {retention_days}")
# Fixed boundary: start of retention window
retention_boundary = current_time - timedelta(days=retention_days)
if oldest_cycle_in_cache is None:
# Empty cache: extract full retention window
start_date = retention_boundary.date()
end_date = current_time.date()
_LOGGER.debug(
"Empty cache: extracting from %s to %s (retention=%d days)",
start_date,
end_date,
retention_days,
)
return start_date, end_date
# Cache has data: look one day before the oldest cycle
oldest_minus_24h = oldest_cycle_in_cache - timedelta(days=1)
# Don't extract before retention boundary
extraction_start = max(oldest_minus_24h, retention_boundary)
extraction_start_date = extraction_start.date()
extraction_end_date = current_time.date()
_LOGGER.debug(
"Cache has data (oldest=%s): extracting from %s to %s "
"(retention_boundary=%s, oldest_minus_24h=%s)",
oldest_cycle_in_cache.isoformat(),
extraction_start_date,
extraction_end_date,
retention_boundary.date(),
oldest_minus_24h.date(),
)
return extraction_start_date, extraction_end_date
@staticmethod
def calculate_refresh_range(
current_time: datetime | None = None,
) -> tuple[date, date]:
"""Calculate the date range for a 24h refresh (last day only).
This is used for periodic refresh to get the most recent data without
re-extracting the entire history.
Args:
current_time: Current datetime (for testing; defaults to now())
Returns:
A tuple of (yesterday_date, today_date) as datetime.date objects
"""
if current_time is None:
current_time = datetime.now()
yesterday = (current_time - timedelta(days=1)).date()
today = current_time.date()
_LOGGER.debug("24h refresh: extracting from %s to %s", yesterday, today)
return yesterday, today
@@ -0,0 +1,69 @@
"""Service for calculating global Learning Heating Slope (LHS).
Pure domain logic for calculating average heating slope from all cycles,
regardless of time of day. No Home Assistant dependencies.
"""
from __future__ import annotations
import logging
from ..constants import DEFAULT_LEARNED_SLOPE
from ..value_objects import HeatingCycle
_LOGGER = logging.getLogger(__name__)
class GlobalLHSCalculatorService:
"""Calculate global LHS from all heating cycles.
Responsibilities:
- Calculate average heating slope from all cycles
- Return default slope when no cycles available
- Handle edge cases gracefully
Pure domain logic with no Home Assistant dependencies.
"""
def calculate_global_lhs(self, cycles: list[HeatingCycle]) -> float:
"""Calculate average LHS from all heating cycles.
Args:
cycles: List of all heating cycles to analyze
Returns:
float: Average heating slope in °C/hour, or DEFAULT_LEARNED_SLOPE if no cycles
Calculation:
avg_lhs = sum(cycle.avg_heating_slope for cycle in cycles) / len(cycles)
"""
_LOGGER.debug("Calculating global LHS from %d cycles", len(cycles))
if not cycles:
_LOGGER.info(
"No cycles available for global LHS calculation, returning default slope: %.2f°C/h",
DEFAULT_LEARNED_SLOPE,
)
return DEFAULT_LEARNED_SLOPE
# Filter out non-positive slopes: cycles where temperature didn't rise
# carry no useful learning data about heating speed
lhs_values = [cycle.avg_heating_slope for cycle in cycles if cycle.avg_heating_slope > 0]
if not lhs_values:
_LOGGER.info(
"No cycles with positive heating slope, returning default: %.2f°C/h",
DEFAULT_LEARNED_SLOPE,
)
return DEFAULT_LEARNED_SLOPE
global_lhs = sum(lhs_values) / len(lhs_values)
_LOGGER.info(
"Calculated global LHS: %.2f°C/h from %d cycles",
global_lhs,
len(cycles),
)
_LOGGER.debug("Global LHS calculation complete")
return global_lhs
@@ -0,0 +1,159 @@
"""ML-based decision strategy using IHP-ML-Models."""
from __future__ import annotations
import logging
from ..interfaces import ISchedulerReader
from ..interfaces.decision_strategy_interface import IDecisionStrategy
from ..value_objects import (
EnvironmentState,
HeatingAction,
HeatingDecision,
)
_LOGGER = logging.getLogger(__name__)
class MLDecisionStrategy(IDecisionStrategy):
"""ML-powered heating decisions using IHP-ML-Models.
This strategy delegates heating decisions to an AI model trained
in the IHP-ML-Models add-on. It provides more sophisticated
predictions by learning from historical data.
Decision logic:
- Queries ML model API for action predictions
- Uses reinforcement learning for optimal timing
- Adapts to specific home characteristics over time
Requirements:
- IHP-ML-Models Home Assistant add-on must be installed
- ML model must be trained with historical heating data
Attributes:
_scheduler_reader: Interface to read scheduled timeslots
_ml_client: Interface to ML model API (to be implemented)
"""
def __init__(
self,
scheduler_reader: ISchedulerReader,
# ml_client: IMLClient, # TODO: Add interface for ML API
) -> None:
"""Initialize ML decision strategy.
Args:
scheduler_reader: Implementation of scheduler reading interface
# ml_client: Client to communicate with ML model API
"""
_LOGGER.debug("Initializing MLDecisionStrategy")
self._scheduler_reader = scheduler_reader
# self._ml_client = ml_client # TODO: Implement ML client
_LOGGER.warning(
"MLDecisionStrategy is not fully implemented yet. "
"Requires IMLClient interface and adapter."
)
async def decide_heating_action(
self,
environment: EnvironmentState,
) -> HeatingDecision:
"""Decide heating action using ML model.
Args:
environment: Current environmental conditions
Returns:
A heating decision predicted by the ML model
"""
_LOGGER.debug("MLDecisionStrategy.decide_heating_action called")
_LOGGER.debug(
f"Environment: indoor={environment.indoor_temperature}°C, "
f"outdoor={environment.outdoor_temp}°C, "
f"humidity={environment.indoor_humidity}%"
)
# Get next scheduled timeslot for context
next_timeslot = await self._scheduler_reader.get_next_timeslot()
if next_timeslot is None:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION, reason="No scheduled timeslots found"
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
# TODO: Query ML model API for action prediction
# Example:
# ml_prediction = await self._ml_client.predict_action(
# environment=environment,
# next_timeslot=next_timeslot,
# )
#
# return HeatingDecision(
# action=ml_prediction.action,
# target_temp=ml_prediction.target_temp,
# reason=f"ML prediction (confidence: {ml_prediction.confidence:.2%})"
# )
_LOGGER.warning(
"ML model integration not implemented yet. " "Returning NO_ACTION as placeholder."
)
return HeatingDecision(
action=HeatingAction.NO_ACTION,
reason="ML strategy not fully implemented (requires IHP-ML-Models integration)",
)
async def check_overshoot_risk(
self,
environment: EnvironmentState,
current_slope: float,
) -> HeatingDecision:
"""Check overshoot risk using ML model.
Args:
environment: Current environmental conditions
current_slope: Current heating rate in °C/hour
Returns:
Decision to stop heating if ML model predicts overshoot
"""
_LOGGER.debug("MLDecisionStrategy.check_overshoot_risk called")
_LOGGER.debug(
f"Current slope: {current_slope:.4f}°C/hour, "
f"indoor_temp={environment.indoor_temperature}°C"
)
next_timeslot = await self._scheduler_reader.get_next_timeslot()
if next_timeslot is None:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION, reason="No scheduled timeslot to check against"
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
# TODO: Query ML model for overshoot risk assessment
# Example:
# risk_assessment = await self._ml_client.assess_overshoot_risk(
# environment=environment,
# current_slope=current_slope,
# target_temp=next_timeslot.target_temp,
# target_time=next_timeslot.target_time,
# )
#
# if risk_assessment.should_stop:
# return HeatingDecision(
# action=HeatingAction.STOP_HEATING,
# reason=f"ML detected overshoot risk (confidence: {risk_assessment.confidence:.2%})"
# )
_LOGGER.warning(
"ML overshoot detection not implemented yet. " "Returning NO_ACTION as placeholder."
)
return HeatingDecision(
action=HeatingAction.NO_ACTION, reason="ML overshoot detection not fully implemented"
)
@@ -0,0 +1,248 @@
"""Prediction service for calculating heating times."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from ..constants import (
BASE_HIGH_CONFIDENCE,
BASE_LOW_CONFIDENCE,
BASE_MEDIUM_CONFIDENCE,
CLOUD_COVERAGE_FACTOR,
CONFIDENCE_BOOST_PER_SENSOR,
DEFAULT_ANTICIPATION_BUFFER,
HIGH_CONFIDENCE_SLOPE,
HUMIDITY_FACTOR,
HUMIDITY_REFERENCE,
MAX_ANTICIPATION_TIME,
MEDIUM_CONFIDENCE_SLOPE,
MIN_ANTICIPATION_TIME,
OUTDOOR_TEMP_FACTOR,
OUTDOOR_TEMP_REFERENCE,
)
from ..value_objects import PredictionResult
_LOGGER = logging.getLogger(__name__)
class PredictionService:
"""Service for predicting heating start times.
This service contains the core prediction algorithm that determines
when heating should start to reach target temperature at a scheduled time.
The calculation considers:
1. Temperature difference to heat
2. Outdoor temperature impact on heat loss
3. Humidity effects on heating efficiency
4. Solar gain from cloud coverage
5. Learned heating slope (heating rate) from historical data
"""
def predict_heating_time(
self,
current_temp: float | None,
target_temp: float,
learned_slope: float,
target_time: datetime,
outdoor_temp: float | None = None,
humidity: float | None = None,
cloud_coverage: float | None = None,
dead_time_minutes: float = 0.0,
) -> PredictionResult:
"""Calculate when heating should start.
Args:
current_temp: Current room temperature in Celsius (None = cannot calculate)
target_temp: Target temperature in Celsius
learned_slope: Learned heating slope in °C/hour
target_time: When target should be reached (mandatory)
outdoor_temp: Outdoor temperature in Celsius (optional)
humidity: Indoor humidity percentage (0-100) (optional)
cloud_coverage: Cloud coverage percentage (0-100, 0=clear sky) (optional)
dead_time_minutes: Dead time in minutes (initial period with minimal heating effect)
Returns:
Prediction result with start time and confidence
"""
# Handle missing current temperature
if current_temp is None:
_LOGGER.warning("Cannot calculate prediction: current_temp is None")
return PredictionResult(
anticipated_start_time=target_time,
estimated_duration_minutes=0.0,
confidence_level=0.0,
learned_heating_slope=learned_slope,
)
# Calculate temperature difference
temp_delta = target_temp - current_temp
if temp_delta <= 0:
# Already at target, anticipated start time = target time
_LOGGER.debug(
"Already at target temperature (%.1f°C >= %.1f°C), no heating needed",
current_temp,
target_temp,
)
return PredictionResult(
anticipated_start_time=target_time,
estimated_duration_minutes=0.0,
confidence_level=1.0,
learned_heating_slope=learned_slope,
)
# Protection against invalid slope (should not happen with proper validation)
if learned_slope is None or learned_slope <= 0:
_LOGGER.error(
"CRITICAL: Invalid learned heating slope (%.4f°C/h <= 0) reached prediction_service. "
"This indicates missing validation in calling code. Cannot calculate prediction.",
learned_slope or 0,
)
return PredictionResult(
anticipated_start_time=target_time,
estimated_duration_minutes=0.0,
confidence_level=0.0,
learned_heating_slope=learned_slope or 0,
)
# Calculate base anticipation time (in minutes)
# Formula: heating_time = dead_time + (temp_delta / slope) * 60
anticipation_minutes = dead_time_minutes + (temp_delta / learned_slope) * 60.0
# Apply environmental correction factors
correction_factor = self._calculate_environmental_correction(
outdoor_temp, humidity, cloud_coverage
)
anticipation_minutes *= correction_factor
# Apply buffer and limits
anticipation_minutes += DEFAULT_ANTICIPATION_BUFFER
anticipation_minutes = max(
MIN_ANTICIPATION_TIME, min(MAX_ANTICIPATION_TIME, anticipation_minutes)
)
# Calculate anticipated start time
anticipated_start = target_time - timedelta(minutes=anticipation_minutes)
# Calculate confidence level based on slope and available environmental data
confidence = self._calculate_confidence(learned_slope, outdoor_temp, humidity)
_LOGGER.debug(
"Prediction: ΔT=%.1f°C, slope=%.2f°C/h, dead_time=%.1f min, correction=%.2f, "
"duration=%.1f min, confidence=%.2f",
temp_delta,
learned_slope,
dead_time_minutes,
correction_factor,
anticipation_minutes,
confidence,
)
return PredictionResult(
anticipated_start_time=anticipated_start,
estimated_duration_minutes=anticipation_minutes,
confidence_level=confidence,
learned_heating_slope=learned_slope,
)
def _calculate_environmental_correction(
self,
outdoor_temp: float | None,
humidity: float | None,
cloud_coverage: float | None,
) -> float:
"""Calculate combined environmental correction factor.
This method combines multiple environmental factors that affect
heating efficiency:
- Outdoor temperature (heat loss)
- Indoor humidity (thermal mass effect)
- Cloud coverage (solar gain)
Args:
outdoor_temp: Outdoor temperature in Celsius
humidity: Indoor humidity percentage (0-100)
cloud_coverage: Cloud coverage percentage (0-100)
Returns:
Combined correction factor (>1 means slower heating)
"""
correction_factor = 1.0
# Outdoor temperature factor: colder outside means slower heating
# Formula: outdoor_factor = 1 + (OUTDOOR_TEMP_REFERENCE - outdoor_temp) * OUTDOOR_TEMP_FACTOR
# At outdoor_temp = 20°C: factor = 1.0 (no impact)
# At outdoor_temp = 0°C: factor = 2.0 (heating takes twice as long)
# At outdoor_temp = -10°C: factor = 2.5 (even slower)
if outdoor_temp is not None:
outdoor_factor = 1.0 + (OUTDOOR_TEMP_REFERENCE - outdoor_temp) * OUTDOOR_TEMP_FACTOR
outdoor_factor = max(0.5, outdoor_factor) # Minimum factor of 0.5
correction_factor *= outdoor_factor
_LOGGER.debug("Outdoor temp %.1f°C -> factor %.2f", outdoor_temp, outdoor_factor)
# Humidity factor: higher humidity makes heating feel slower
# Formula: humidity_factor = 1 + (humidity - HUMIDITY_REFERENCE) * HUMIDITY_FACTOR
# At 50% humidity: factor = 1.0 (neutral)
# At 80% humidity: factor = 1.06 (6% slower)
# At 20% humidity: factor = 0.94 (6% faster)
if humidity is not None:
humidity_factor = 1.0 + (humidity - HUMIDITY_REFERENCE) * HUMIDITY_FACTOR
humidity_factor = max(0.8, min(1.2, humidity_factor))
correction_factor *= humidity_factor
_LOGGER.debug("Humidity %.1f%% -> factor %.2f", humidity, humidity_factor)
# Solar gain factor: less cloud coverage means more solar heat gain
# Formula: solar_factor = 1 - (100 - cloud_coverage) * CLOUD_COVERAGE_FACTOR
# At 100% cloud: factor = 1.0 (no solar gain)
# At 0% cloud (clear sky): factor = 0.9 (10% faster due to sun)
# At 50% cloud: factor = 0.95 (5% faster)
if cloud_coverage is not None:
solar_factor = 1.0 - (100.0 - cloud_coverage) * CLOUD_COVERAGE_FACTOR
solar_factor = max(0.8, min(1.0, solar_factor))
correction_factor *= solar_factor
_LOGGER.debug("Cloud coverage %.1f%% -> factor %.2f", cloud_coverage, solar_factor)
return correction_factor
def _calculate_confidence(
self,
learned_slope: float,
outdoor_temp: float | None,
humidity: float | None,
) -> float:
"""Calculate confidence level in the prediction.
Confidence is based on:
- Slope validity (higher slope = better learning)
- Available environmental data (more data = better prediction)
Args:
learned_slope: Learned heating slope in °C/hour
outdoor_temp: Outdoor temperature (if available)
humidity: Indoor humidity (if available)
Returns:
Confidence level between 0.0 and 1.0
"""
# Base confidence from slope validity
if learned_slope > HIGH_CONFIDENCE_SLOPE:
confidence = BASE_HIGH_CONFIDENCE
elif learned_slope > MEDIUM_CONFIDENCE_SLOPE:
confidence = BASE_MEDIUM_CONFIDENCE
else:
confidence = BASE_LOW_CONFIDENCE
# Adjust confidence based on available environmental data
data_availability = 0
if outdoor_temp is not None:
data_availability += 1
if humidity is not None:
data_availability += 1
# Increase confidence slightly with more environmental data
confidence += data_availability * CONFIDENCE_BOOST_PER_SENSOR
# Cap at 1.0
return min(1.0, confidence)
@@ -0,0 +1,201 @@
"""Simple rule-based decision strategy."""
from __future__ import annotations
import logging
from ..interfaces import ILhsStorage, ISchedulerReader
from ..interfaces.decision_strategy_interface import IDecisionStrategy
from ..services.prediction_service import PredictionService
from ..value_objects import (
EnvironmentState,
HeatingAction,
HeatingDecision,
)
_LOGGER = logging.getLogger(__name__)
class SimpleDecisionStrategy(IDecisionStrategy):
"""Rule-based heating decisions without ML.
This strategy uses simple predictive calculations based on
learned heating slopes. It's easier to set up as it doesn't
require the IHP-ML-Models add-on.
Decision logic:
- Uses learned heating slope (LHS) from past heating cycles
- Calculates anticipated start time based on current conditions
- Prevents overshooting with conservative thresholds
Attributes:
_scheduler_reader: Interface to read scheduled timeslots
_storage: Interface to access learned data
_prediction_service: Service for prediction calculations
"""
def __init__(
self,
scheduler_reader: ISchedulerReader,
model_storage: ILhsStorage,
) -> None:
"""Initialize simple decision strategy.
Args:
scheduler_reader: Implementation of scheduler reading interface
model_storage: Implementation of model storage interface
"""
_LOGGER.debug("Initializing SimpleDecisionStrategy")
self._scheduler_reader = scheduler_reader
self._storage = model_storage
self._prediction_service = PredictionService()
_LOGGER.debug("SimpleDecisionStrategy initialized with scheduler and storage")
async def decide_heating_action(
self,
environment: EnvironmentState,
) -> HeatingDecision:
"""Decide heating action using simple rules.
Args:
environment: Current environmental conditions
Returns:
A heating decision based on simple predictive rules
"""
_LOGGER.debug("SimpleDecisionStrategy.decide_heating_action called")
_LOGGER.debug(
f"Environment: indoor={environment.indoor_temperature}°C, "
f"outdoor={environment.outdoor_temp}°C, "
f"humidity={environment.indoor_humidity}%"
)
# Get next scheduled timeslot
next_timeslot = await self._scheduler_reader.get_next_timeslot()
if next_timeslot is None:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION, reason="No scheduled timeslots found"
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
# Check if target temperature is already reached
current_temp = environment.indoor_temperature
if current_temp >= next_timeslot.target_temp:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION,
reason=f"Already at target temperature ({current_temp:.1f}°C >= {next_timeslot.target_temp:.1f}°C)",
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
# Get learned heating slope
lhs = await self._storage.get_learned_heating_slope()
_LOGGER.info(f"Learned heating slope: {lhs:.4f}°C/hour")
# Calculate prediction
prediction = self._prediction_service.predict_heating_time(
current_temp=environment.indoor_temperature,
target_temp=next_timeslot.target_temp,
outdoor_temp=environment.outdoor_temp,
humidity=environment.indoor_humidity,
learned_slope=lhs,
target_time=next_timeslot.target_time,
cloud_coverage=environment.cloud_coverage,
)
_LOGGER.debug(
f"Prediction: anticipated_start={prediction.anticipated_start_time.isoformat()}, "
f"duration={prediction.estimated_duration_minutes:.1f}min, "
f"confidence={prediction.confidence_level:.2f}"
)
# Decide based on anticipated start time
now = environment.timestamp
if prediction.anticipated_start_time <= now < next_timeslot.target_time:
decision = HeatingDecision(
action=HeatingAction.START_HEATING,
target_temp=next_timeslot.target_temp,
reason=f"Time to start heating (anticipated start: {prediction.anticipated_start_time.isoformat()})",
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
elif now >= next_timeslot.target_time:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION, reason="Schedule time has passed"
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
else:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION,
reason=f"Wait until {prediction.anticipated_start_time.isoformat()}",
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
async def check_overshoot_risk(
self,
environment: EnvironmentState,
current_slope: float,
) -> HeatingDecision:
"""Check overshoot risk using simple calculations.
Args:
environment: Current environmental conditions
current_slope: Current heating rate in °C/hour
Returns:
Decision to stop heating if overshoot is detected
"""
_LOGGER.debug("SimpleDecisionStrategy.check_overshoot_risk called")
_LOGGER.debug(
f"Current slope: {current_slope:.4f}°C/hour, "
f"indoor_temp={environment.indoor_temperature}°C"
)
next_timeslot = await self._scheduler_reader.get_next_timeslot()
if next_timeslot is None:
decision = HeatingDecision(
action=HeatingAction.NO_ACTION, reason="No scheduled timeslot to check against"
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
# Calculate estimated temperature at target time
time_to_target = (
next_timeslot.target_time - environment.timestamp
).total_seconds() / 3600.0
if time_to_target <= 0:
decision = HeatingDecision(action=HeatingAction.NO_ACTION, reason="Target time reached")
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
estimated_temp = environment.indoor_temperature + (current_slope * time_to_target)
# Stop if we'll overshoot by more than 0.5°C
overshoot_threshold = next_timeslot.target_temp + 0.5
_LOGGER.debug(
f"Estimated temp at target time: {estimated_temp:.1f}°C, "
f"threshold: {overshoot_threshold:.1f}°C"
)
if estimated_temp > overshoot_threshold:
decision = HeatingDecision(
action=HeatingAction.STOP_HEATING,
reason=f"Overshoot risk detected (estimated: {estimated_temp:.1f}°C > threshold: {overshoot_threshold:.1f}°C)",
)
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
return decision
decision = HeatingDecision(
action=HeatingAction.NO_ACTION,
reason=f"No overshoot risk (estimated: {estimated_temp:.1f}°C)",
)
_LOGGER.debug(f"Decision: {decision.action.value} - {decision.reason}")
return decision
@@ -0,0 +1,32 @@
"""Value objects for the domain layer.
Value objects are immutable data carriers that represent concepts in the domain.
"""
from __future__ import annotations
from .environment_state import EnvironmentState
from .heating import HeatingAction, HeatingCycle, HeatingDecision, TariffPeriodDetail
from .heating_cycle_cache_data import HeatingCycleCacheData
from .historical_data import HistoricalDataKey, HistoricalDataSet, HistoricalMeasurement
from .prediction_result import PredictionResult
from .recording_extraction_task import ExtractionTaskState, RecordingExtractionTask
from .scheduled_timeslot import ScheduledTimeslot
from .slope_data import SlopeData
__all__ = [
"EnvironmentState",
"ScheduledTimeslot",
"PredictionResult",
"HeatingDecision",
"HeatingAction",
"HeatingCycle",
"TariffPeriodDetail",
"SlopeData",
"HistoricalDataKey",
"HistoricalDataSet",
"HistoricalMeasurement",
"HeatingCycleCacheData",
"RecordingExtractionTask",
"ExtractionTaskState",
]
@@ -0,0 +1,55 @@
"""Value object for contextual LHS calculation results."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class ContextualLHSData:
"""Result of contextual LHS calculation for a specific hour.
Represents the outcome of calculating average LHS for cycles
that started at a particular hour of the day.
Attributes:
hour: Hour of day (0-23)
lhs: The calculated LHS value in °C/hour, or None if insufficient data
cycle_count: Number of cycles used in calculation
calculated_at: When this calculation was performed
reason: Human-readable explanation if lhs is None
(e.g., "insufficient_data", "calculation_failed")
"""
hour: int
lhs: float | None
cycle_count: int
calculated_at: datetime
reason: str = ""
def __post_init__(self) -> None:
"""Validate the contextual LHS data."""
if not 0 <= self.hour <= 23:
raise ValueError(f"hour must be 0-23, got {self.hour}")
if self.lhs is not None and self.lhs < 0:
raise ValueError(f"lhs must be positive or None, got {self.lhs}")
if self.cycle_count < 0:
raise ValueError(f"cycle_count must be >= 0, got {self.cycle_count}")
@property
def is_available(self) -> bool:
"""Check if this hour has valid LHS data."""
return self.lhs is not None and self.cycle_count > 0
def get_display_value(self) -> str | float:
"""Get value suitable for user display.
Returns:
LHS value as float if available, "unknown" string otherwise.
"""
if self.lhs is not None:
return round(self.lhs, 2)
return "unknown"
@@ -0,0 +1,160 @@
"""Value objects for entity attribute mapping and abstraction.
This module provides domain-level abstractions for mapping domain concepts
(like "current temperature") to actual entity attributes, enabling support
for multiple entity types with different attribute structures.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class AttributeConcept(Enum):
"""Domain-level concepts that need to be extracted from entities.
These represent the semantic meaning of data (what we're looking for),
independent of any specific entity type or Home Assistant attribute name.
"""
# Climate state attributes
CURRENT_TEMPERATURE = "current_temperature"
TARGET_TEMPERATURE = "target_temperature"
HEATING_ACTIVE = "heating_active" # Boolean: is heating currently active?
# For entities that expose hvac_action as a string
HVAC_ACTION = "hvac_action"
# Environmental sensor data
INDOOR_HUMIDITY = "indoor_humidity"
OUTDOOR_TEMPERATURE = "outdoor_temperature"
OUTDOOR_HUMIDITY = "outdoor_humidity"
CLOUD_COVERAGE = "cloud_coverage"
@dataclass(frozen=True)
class AttributePath:
"""Describes where to find a value in an entity's attributes.
Supports nested paths like "specific_states.temperature_slope".
Attributes:
path: Dot-separated path to the attribute (e.g., "specific_states.temperature")
fallback_path: Optional fallback path if primary path not found
required: If True, missing this attribute is an error
"""
path: str
fallback_path: str | None = None
required: bool = True
@dataclass(frozen=True)
class EntityAttributeMapping:
"""Maps domain concepts to actual entity attributes.
This allows flexible support for different entity types:
- VTherm with nested "specific_states"
- Standard Home Assistant climate entities
- Custom climate entities
Attributes:
entity_type: Type of entity (e.g., "climate", "sensor")
entity_name: Human-readable entity type name (e.g., "VTherm", "Generic Climate")
mappings: Dict mapping AttributeConcept → list of AttributePath candidates
"""
entity_type: str
entity_name: str
mappings: dict[AttributeConcept, list[AttributePath]]
def get_attribute_paths(
self,
concept: AttributeConcept,
) -> list[AttributePath]:
"""Get all possible attribute paths for a concept.
Returns paths in priority order - first valid path is used.
Args:
concept: The domain concept to look up
Returns:
List of AttributePath objects (may be empty if concept not supported)
"""
return self.mappings.get(concept, [])
def supports_concept(self, concept: AttributeConcept) -> bool:
"""Check if this mapping supports a given concept.
Args:
concept: The concept to check
Returns:
True if this mapping has at least one path for the concept
"""
return bool(self.get_attribute_paths(concept))
@dataclass(frozen=True)
class EntityAttributeDescriptor:
"""Describes the attribute structure of an entity instance.
This is used during setup to identify which entity type we're working with
and what attributes it actually provides.
Attributes:
entity_id: The Home Assistant entity_id (e.g., "climate.living_room")
entity_type: Type classification (e.g., "climate")
detected_attributes: Set of attribute names found on this entity
mapping: The EntityAttributeMapping to use for this entity
"""
entity_id: str
entity_type: str
detected_attributes: set[str]
mapping: EntityAttributeMapping
def has_required_attributes(
self,
required_concepts: list[AttributeConcept],
) -> tuple[bool, list[str]]:
"""Check if entity has attributes needed for required concepts.
Args:
required_concepts: List of AttributeConcept needed
Returns:
Tuple of (all_present: bool, missing_attributes: list[str])
"""
missing = []
for concept in required_concepts:
paths = self.mapping.get_attribute_paths(concept)
if not paths:
# Concept not supported by this mapping
missing.append(f"{concept.value} (not supported in mapping)")
continue
# Check if at least one path exists in detected attributes
found = False
for path in paths:
# Check exact path
if path.path in self.detected_attributes:
found = True
break
# Check fallback path
if path.fallback_path and path.fallback_path in self.detected_attributes:
found = True
break
if not found:
missing_paths = [p.path for p in paths if not p.fallback_path] + [
p.fallback_path for p in paths if p.fallback_path
]
missing.append(
f"{concept.value} (expected in {missing_paths}, "
f"but only found: {', '.join(self.detected_attributes)})"
)
return len(missing) == 0, missing
@@ -0,0 +1,49 @@
"""Environment state value object."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class EnvironmentState:
"""Represents current environmental conditions.
This value object captures all environmental factors that influence
heating decisions at a specific point in time.
Attributes:
indoor_temperature: Current room temperature in Celsius
timestamp: When these measurements were taken
indoor_humidity: Indoor humidity percentage (0-100)
outdoor_temp: Outdoor temperature in Celsius
outdoor_humidity: Optional outdoor humidity percentage (0-100)
cloud_coverage: Optional cloud coverage percentage (0-100, 0=clear sky)
"""
timestamp: datetime
indoor_temperature: float
indoor_humidity: float | None = None
outdoor_temp: float | None = None
outdoor_humidity: float | None = None
cloud_coverage: float | None = None
def __post_init__(self) -> None:
"""Validate the environmental state data."""
if self.indoor_humidity is not None and (
self.indoor_humidity < 0 or self.indoor_humidity > 100
):
raise ValueError(f"Humidity must be between 0 and 100, got {self.indoor_humidity}")
if self.outdoor_humidity is not None and (
self.outdoor_humidity < 0 or self.outdoor_humidity > 100
):
raise ValueError(
f"Outdoor humidity must be between 0 and 100, got {self.outdoor_humidity}"
)
if self.cloud_coverage is not None and (
self.cloud_coverage < 0 or self.cloud_coverage > 100
):
raise ValueError(f"Cloud coverage must be between 0 and 100, got {self.cloud_coverage}")
@@ -0,0 +1,169 @@
"""Heating decision value object."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
class HeatingAction(Enum):
"""Types of heating actions that can be taken."""
START_HEATING = "start_heating"
STOP_HEATING = "stop_heating"
SET_TEMPERATURE = "set_temperature"
NO_ACTION = "no_action"
@dataclass(frozen=True)
class HeatingDecision:
"""Represents a decision about heating control.
This value object encapsulates what action should be taken and why.
Attributes:
action: The type of action to take
target_temp: Target temperature if starting heating (None otherwise)
reason: Human-readable explanation for the decision
"""
action: HeatingAction
target_temp: float | None = None
reason: str = ""
def __post_init__(self) -> None:
"""Validate the heating decision data."""
if self.action == HeatingAction.START_HEATING and self.target_temp is None:
raise ValueError("START_HEATING action requires a target temperature")
if self.action == HeatingAction.SET_TEMPERATURE and self.target_temp is None:
raise ValueError("SET_TEMPERATURE action requires a target temperature")
@dataclass(frozen=True)
class TariffPeriodDetail:
"""Represents energy consumption and cost details for a specific tariff period."""
tariff_price_eur_per_kwh: float
energy_kwh: float
heating_duration_minutes: float
cost_euro: float
@dataclass(frozen=True)
class HeatingCycle:
"""Represents a single heating cycle, encapsulating all its relevant data.
This value object provides a complete and immutable snapshot of a heating period,
including its duration, temperature changes, and energy consumption details.
Attributes:
start_time: The exact datetime when the heating cycle started.
end_time: The exact datetime when the heating cycle ended.
target_temp: The target temperature set for this heating cycle.
end_temp: The actual temperature reached at the end of the heating cycle.
start_temp: The temperature at the beginning of the heating cycle.
tariff_details: A list of TariffDetail objects, breaking down energy, duration,
and cost by specific TariffPeriodDetail periods within the cycle.
dead_time_cycle_minutes: Dead time for this specific cycle in minutes. Time from
cycle start to first measurable temperature change.
None if cannot be determined.
min_effective_duration_minutes: Minimum effective heating duration (in minutes) required
to compute a valid slope. Effective duration is
total_duration dead_time. Cycles whose effective window
is shorter than this threshold return 0.0 for
``avg_heating_slope`` to prevent aberrant values caused by
near-zero denominators. Defaults to 5.0 minutes.
"""
device_id: str
start_time: datetime
end_time: datetime
target_temp: float
end_temp: float
start_temp: float
tariff_details: list[TariffPeriodDetail] | None = None
dead_time_cycle_minutes: float | None = None
min_effective_duration_minutes: float = 5.0
@property
def avg_heating_slope(self) -> float:
"""Calculates the average heating slope in °C/hour for the heating cycle.
Excludes the dead_time_cycle period to get the true heating slope once
the system is actively heating (without initial inertia).
Returns 0.0 when the effective heating duration (after subtracting dead_time) is shorter
than ``min_effective_duration_minutes``. This guards against aberrant slope values that
arise when dead_time ≈ total_duration, leaving an effective duration of only a few
microseconds and producing slopes in the range of 100 000200 000 °C/h.
"""
# Calculate effective start time (after dead_time_cycle)
if self.dead_time_cycle_minutes and self.dead_time_cycle_minutes > 0:
effective_start_time = self.start_time + timedelta(minutes=self.dead_time_cycle_minutes)
duration_hours = (self.end_time - effective_start_time).total_seconds() / 3600
else:
duration_hours = (self.end_time - self.start_time).total_seconds() / 3600
if duration_hours <= 0:
return 0.0
# Guard: reject cycles whose effective heating window is too narrow.
# When dead_time ≈ total_duration the slope formula amplifies noise by orders of magnitude.
effective_duration_minutes = duration_hours * 60.0
if effective_duration_minutes < self.min_effective_duration_minutes:
return 0.0
temp_increase = self.end_temp - self.start_temp
return temp_increase / duration_hours
@property
def duration_minutes(self) -> float:
"""Calculates the total duration of the heating cycle in minutes."""
return (self.end_time - self.start_time).total_seconds() / 60
@property
def temp_delta(self) -> float:
"""Calculates the difference between the target temperature and the end temperature."""
return self.target_temp - self.end_temp
@property
def start_hour(self) -> int:
"""Returns the hour (0-23) when the heating cycle started."""
return self.start_time.hour
@property
def end_hour(self) -> int:
"""Returns the hour (0-23) when the heating cycle ended."""
return self.end_time.hour
@property
def start_weekday(self) -> int:
"""Returns the weekday (0=Monday, 6=Sunday) when the heating cycle started."""
return self.start_time.weekday()
@property
def end_weekday(self) -> int:
"""Returns the weekday (0=Monday, 6=Sunday) when the heating cycle ended."""
return self.end_time.weekday()
@property
def total_energy_kwh(self) -> float:
"""Calculates the total energy consumed during the cycle in kWh from tariff details."""
return sum(detail.energy_kwh for detail in (self.tariff_details or []))
@property
def total_heating_duration_minutes(self) -> float:
"""Calculates the total heating duration in minutes from tariff details."""
return sum(detail.heating_duration_minutes for detail in (self.tariff_details or []))
@property
def total_cost_euro(self) -> float:
"""Calculates the total cost in euros from tariff details."""
return sum(detail.cost_euro for detail in (self.tariff_details or []))
def __post_init__(self) -> None:
"""Validate the heating cycle data."""
if self.start_time >= self.end_time:
raise ValueError("Start time must be before end time for a heating cycle.")
@@ -0,0 +1,101 @@
"""Value object for heating cycle cache data.
This value object represents cached heating cycle data with metadata
about when the cache was last updated. Designed to enable incremental
cycle extraction without re-scanning entire recorder history.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from .heating import HeatingCycle
@dataclass(frozen=True)
class HeatingCycleCacheData:
"""Immutable record of cached heating cycles with metadata.
This value object stores a collection of heating cycles along with
metadata about when the cache was last updated, enabling incremental
cache refresh strategies, and tracks which dates have been explored
even if they contained no cycles.
Attributes:
device_id: Device identifier these cycles belong to
cycles: List of cached HeatingCycle objects
last_search_time: UTC timestamp of the last history search
retention_days: Number of days to retain cycles in cache
explored_dates: Set of dates that have been extracted/explored
(even if no cycles were found). Used to avoid
re-extracting empty days indefinitely.
"""
device_id: str
cycles: tuple[HeatingCycle, ...] # Use tuple for immutability
last_search_time: datetime
retention_days: int
explored_dates: frozenset[date] = field(default_factory=frozenset)
def __post_init__(self) -> None:
"""Validate cache data after initialization."""
if not self.device_id:
raise ValueError("device_id cannot be empty")
if self.retention_days <= 0:
raise ValueError(f"retention_days must be positive, got {self.retention_days}")
# Ensure timestamp is timezone-aware
if self.last_search_time.tzinfo is None:
raise ValueError("last_search_time must be timezone-aware (UTC)")
@property
def cycle_count(self) -> int:
"""Return the number of cycles in the cache."""
return len(self.cycles)
def get_cycles_since(self, start_time: datetime) -> list[HeatingCycle]:
"""Get cycles that started on or after the specified time.
Args:
start_time: Minimum start time for cycles to return
Returns:
List of cycles starting at or after start_time
"""
return [cycle for cycle in self.cycles if cycle.start_time >= start_time]
def get_cycles_within_retention(self, reference_time: datetime) -> list[HeatingCycle]:
"""Get cycles within the retention period from a reference time.
Args:
reference_time: Time to calculate retention from
Returns:
List of cycles within retention period
"""
from datetime import timedelta
cutoff_time = reference_time - timedelta(days=self.retention_days)
return [cycle for cycle in self.cycles if cycle.start_time >= cutoff_time]
def with_explored_dates(self, explored_dates: set[date]) -> HeatingCycleCacheData:
"""Return a new cache instance with updated explored_dates.
Since this dataclass is immutable (frozen=True), this method creates
a new instance rather than modifying the existing one.
Args:
explored_dates: Set of dates to mark as explored
Returns:
A new HeatingCycleCacheData instance with updated explored_dates
"""
return HeatingCycleCacheData(
device_id=self.device_id,
cycles=self.cycles,
last_search_time=self.last_search_time,
retention_days=self.retention_days,
explored_dates=frozenset(explored_dates),
)
@@ -0,0 +1,58 @@
"""Value objects for historical data within the heating domain."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Any
class HistoricalDataKey(Enum):
"""Keys to identify different types of historical data within a dataset."""
INDOOR_TEMP = "indoor_temp"
INDOOR_HUMIDITY = "indoor_humidity"
OUTDOOR_TEMP = "outdoor_temp"
OUTDOOR_HUMIDITY = "outdoor_humidity"
CLOUD_COVERAGE = "cloud_coverage"
TARGET_TEMP = "target_temp"
# Optional instrumentation for energy & tariff calculations
HEATING_STATE = "heating_state"
HEATING_ENERGY_KWH = "heating_energy_kwh" # Cumulative energy meter in kWh
HEATING_RUNTIME_SECONDS = "heating_runtime_seconds" # Cumulative runtime in seconds
TARIFF_PRICE_EUR_PER_KWH = "tariff_price_eur_per_kwh" # Tariff price time series
# Ajoutez d'autres clés au besoin
@dataclass(frozen=True)
class HistoricalMeasurement:
"""Represents a single historical measurement for an entity at a specific timestamp.
Attributes:
timestamp: The datetime when the measurement was recorded.
value: The main state value of the entity (e.g., temperature, 'on'/'off').
attributes: A dictionary of additional attributes (e.g., for climate entities like 'hvac_action').
entity_id: The entity_id from Home Assistant (e.g., 'climate.living_room', 'sensor.outdoor_temp').
"""
timestamp: datetime
value: float | str | bool
attributes: dict[str, Any]
entity_id: str
@dataclass(frozen=True)
class HistoricalDataSet:
"""A collection of historical measurements, categorized by a HistoricalDataKey.
This serves as a domain-agnostic representation of raw historical sensor data
before it's processed into domain-specific concepts like heating cycles.
Attributes:
data: A dictionary where keys are HistoricalDataKey and values are lists of HistoricalMeasurement.
"""
data: dict[HistoricalDataKey, list[HistoricalMeasurement]]
@@ -0,0 +1,24 @@
"""Cached LHS entry value object.
This immutable object carries a cached Learning Heating Slope (LHS) value
along with its last update timestamp and optional contextual hour.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class LHSCacheEntry:
"""Represents a cached LHS value and its metadata."""
value: float
updated_at: datetime
hour: int | None = None
def is_for_hour(self, hour: int) -> bool:
"""Check if the cache entry matches the requested hour."""
return self.hour == hour
@@ -0,0 +1,42 @@
"""Prediction result value object."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class PredictionResult:
"""Result of heating time prediction.
Represents when heating should start to reach the target temperature
at the scheduled time.
Attributes:
anticipated_start_time: When heating should begin
estimated_duration_minutes: How long heating is expected to take
confidence_level: Confidence in prediction (0.0-1.0)
learned_heating_slope: The heating slope used for prediction (°C/h)
"""
anticipated_start_time: datetime
estimated_duration_minutes: float
confidence_level: float
learned_heating_slope: float
def __post_init__(self) -> None:
"""Validate the prediction result data."""
if self.estimated_duration_minutes < 0:
raise ValueError(
f"Duration must be non-negative, got {self.estimated_duration_minutes}"
)
if not 0.0 <= self.confidence_level <= 1.0:
raise ValueError(f"Confidence must be between 0 and 1, got {self.confidence_level}")
# Allow zero slope only when confidence is also zero (invalid prediction)
if self.learned_heating_slope <= 0 and self.confidence_level > 0:
raise ValueError(
f"Heating slope must be positive when confidence > 0, got {self.learned_heating_slope}"
)
@@ -0,0 +1,58 @@
"""Recording extraction task value object."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from enum import Enum
class ExtractionTaskState(Enum):
"""States of a recording extraction task."""
PENDING = "pending"
EXTRACTING = "extracting"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
@dataclass(frozen=True)
class RecordingExtractionTask:
"""Represents a period extraction task from the Home Assistant Recorder.
This value object encapsulates all state and metadata for extracting data
for a specific date range. Tasks are queued and executed sequentially to
avoid overwhelming the Home Assistant Recorder with concurrent queries.
The actual extracted cycles are NOT stored in this object; instead they are
passed to a callback function (on_cycles_extracted) for progressive cache
population. This keeps the value object lightweight and immutable.
Attributes:
start_date: The first day (inclusive) of the extraction period (YYYY-MM-DD).
end_date: The last day (inclusive) of the extraction period (YYYY-MM-DD).
device_id: The IHP device identifier for which to extract data.
state: Current state of the task (PENDING, EXTRACTING, COMPLETED, FAILED).
error: Error message if extraction failed, None otherwise.
"""
start_date: date
end_date: date
device_id: str
state: ExtractionTaskState = ExtractionTaskState.PENDING
error: str | None = None
def __hash__(self) -> int:
"""Make task hashable based on start_date, end_date, and device_id."""
return hash((self.start_date, self.end_date, self.device_id))
def __eq__(self, other: object) -> bool:
"""Compare tasks by start_date, end_date, and device_id."""
if not isinstance(other, RecordingExtractionTask):
return False
return (
self.start_date == other.start_date
and self.end_date == other.end_date
and self.device_id == other.device_id
)
@@ -0,0 +1,32 @@
"""Schedule timeslot value object."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class ScheduledTimeslot:
"""Represents a scheduled heating timeslot.
A schedule timeslot defines when the room should reach a specific
target temperature, following the scheduler component data format.
See: https://github.com/nielsfaber/scheduler-component/#data-format
Attributes:
target_time: When the target temperature should be reached
target_temp: Desired temperature in Celsius
timeslot_id: Unique identifier for this schedule timeslot
scheduler_entity: The scheduler entity ID that provided this timeslot
"""
target_time: datetime
target_temp: float
timeslot_id: str
scheduler_entity: str = ""
def __post_init__(self) -> None:
"""Validate the schedule timeslot data."""
if not self.timeslot_id:
raise ValueError("Timeslot ID cannot be empty")
@@ -0,0 +1,32 @@
"""Value object for slope data with timestamp.
This value object represents a recorded heating slope measurement
with associated metadata. Designed to be extensible for future ML features.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class SlopeData:
"""Immutable record of a heating slope measurement.
Attributes:
slope_value: Heating slope in °C/hour
timestamp: UTC timestamp when the slope was recorded
"""
slope_value: float
timestamp: datetime
def __post_init__(self) -> None:
"""Validate slope data after initialization."""
if self.slope_value <= 0:
raise ValueError(f"Slope value must be positive, got {self.slope_value}")
# Ensure timestamp is timezone-aware
if self.timestamp.tzinfo is None:
raise ValueError("Timestamp must be timezone-aware (UTC)")