New apps Added
This commit is contained in:
@@ -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.")
|
||||
+101
@@ -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}"
|
||||
)
|
||||
+58
@@ -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
|
||||
)
|
||||
+32
@@ -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)")
|
||||
Reference in New Issue
Block a user