New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,32 @@
"""Value objects for the domain layer.
Value objects are immutable data carriers that represent concepts in the domain.
"""
from __future__ import annotations
from .environment_state import EnvironmentState
from .heating import HeatingAction, HeatingCycle, HeatingDecision, TariffPeriodDetail
from .heating_cycle_cache_data import HeatingCycleCacheData
from .historical_data import HistoricalDataKey, HistoricalDataSet, HistoricalMeasurement
from .prediction_result import PredictionResult
from .recording_extraction_task import ExtractionTaskState, RecordingExtractionTask
from .scheduled_timeslot import ScheduledTimeslot
from .slope_data import SlopeData
__all__ = [
"EnvironmentState",
"ScheduledTimeslot",
"PredictionResult",
"HeatingDecision",
"HeatingAction",
"HeatingCycle",
"TariffPeriodDetail",
"SlopeData",
"HistoricalDataKey",
"HistoricalDataSet",
"HistoricalMeasurement",
"HeatingCycleCacheData",
"RecordingExtractionTask",
"ExtractionTaskState",
]
@@ -0,0 +1,55 @@
"""Value object for contextual LHS calculation results."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class ContextualLHSData:
"""Result of contextual LHS calculation for a specific hour.
Represents the outcome of calculating average LHS for cycles
that started at a particular hour of the day.
Attributes:
hour: Hour of day (0-23)
lhs: The calculated LHS value in °C/hour, or None if insufficient data
cycle_count: Number of cycles used in calculation
calculated_at: When this calculation was performed
reason: Human-readable explanation if lhs is None
(e.g., "insufficient_data", "calculation_failed")
"""
hour: int
lhs: float | None
cycle_count: int
calculated_at: datetime
reason: str = ""
def __post_init__(self) -> None:
"""Validate the contextual LHS data."""
if not 0 <= self.hour <= 23:
raise ValueError(f"hour must be 0-23, got {self.hour}")
if self.lhs is not None and self.lhs < 0:
raise ValueError(f"lhs must be positive or None, got {self.lhs}")
if self.cycle_count < 0:
raise ValueError(f"cycle_count must be >= 0, got {self.cycle_count}")
@property
def is_available(self) -> bool:
"""Check if this hour has valid LHS data."""
return self.lhs is not None and self.cycle_count > 0
def get_display_value(self) -> str | float:
"""Get value suitable for user display.
Returns:
LHS value as float if available, "unknown" string otherwise.
"""
if self.lhs is not None:
return round(self.lhs, 2)
return "unknown"
@@ -0,0 +1,160 @@
"""Value objects for entity attribute mapping and abstraction.
This module provides domain-level abstractions for mapping domain concepts
(like "current temperature") to actual entity attributes, enabling support
for multiple entity types with different attribute structures.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class AttributeConcept(Enum):
"""Domain-level concepts that need to be extracted from entities.
These represent the semantic meaning of data (what we're looking for),
independent of any specific entity type or Home Assistant attribute name.
"""
# Climate state attributes
CURRENT_TEMPERATURE = "current_temperature"
TARGET_TEMPERATURE = "target_temperature"
HEATING_ACTIVE = "heating_active" # Boolean: is heating currently active?
# For entities that expose hvac_action as a string
HVAC_ACTION = "hvac_action"
# Environmental sensor data
INDOOR_HUMIDITY = "indoor_humidity"
OUTDOOR_TEMPERATURE = "outdoor_temperature"
OUTDOOR_HUMIDITY = "outdoor_humidity"
CLOUD_COVERAGE = "cloud_coverage"
@dataclass(frozen=True)
class AttributePath:
"""Describes where to find a value in an entity's attributes.
Supports nested paths like "specific_states.temperature_slope".
Attributes:
path: Dot-separated path to the attribute (e.g., "specific_states.temperature")
fallback_path: Optional fallback path if primary path not found
required: If True, missing this attribute is an error
"""
path: str
fallback_path: str | None = None
required: bool = True
@dataclass(frozen=True)
class EntityAttributeMapping:
"""Maps domain concepts to actual entity attributes.
This allows flexible support for different entity types:
- VTherm with nested "specific_states"
- Standard Home Assistant climate entities
- Custom climate entities
Attributes:
entity_type: Type of entity (e.g., "climate", "sensor")
entity_name: Human-readable entity type name (e.g., "VTherm", "Generic Climate")
mappings: Dict mapping AttributeConcept → list of AttributePath candidates
"""
entity_type: str
entity_name: str
mappings: dict[AttributeConcept, list[AttributePath]]
def get_attribute_paths(
self,
concept: AttributeConcept,
) -> list[AttributePath]:
"""Get all possible attribute paths for a concept.
Returns paths in priority order - first valid path is used.
Args:
concept: The domain concept to look up
Returns:
List of AttributePath objects (may be empty if concept not supported)
"""
return self.mappings.get(concept, [])
def supports_concept(self, concept: AttributeConcept) -> bool:
"""Check if this mapping supports a given concept.
Args:
concept: The concept to check
Returns:
True if this mapping has at least one path for the concept
"""
return bool(self.get_attribute_paths(concept))
@dataclass(frozen=True)
class EntityAttributeDescriptor:
"""Describes the attribute structure of an entity instance.
This is used during setup to identify which entity type we're working with
and what attributes it actually provides.
Attributes:
entity_id: The Home Assistant entity_id (e.g., "climate.living_room")
entity_type: Type classification (e.g., "climate")
detected_attributes: Set of attribute names found on this entity
mapping: The EntityAttributeMapping to use for this entity
"""
entity_id: str
entity_type: str
detected_attributes: set[str]
mapping: EntityAttributeMapping
def has_required_attributes(
self,
required_concepts: list[AttributeConcept],
) -> tuple[bool, list[str]]:
"""Check if entity has attributes needed for required concepts.
Args:
required_concepts: List of AttributeConcept needed
Returns:
Tuple of (all_present: bool, missing_attributes: list[str])
"""
missing = []
for concept in required_concepts:
paths = self.mapping.get_attribute_paths(concept)
if not paths:
# Concept not supported by this mapping
missing.append(f"{concept.value} (not supported in mapping)")
continue
# Check if at least one path exists in detected attributes
found = False
for path in paths:
# Check exact path
if path.path in self.detected_attributes:
found = True
break
# Check fallback path
if path.fallback_path and path.fallback_path in self.detected_attributes:
found = True
break
if not found:
missing_paths = [p.path for p in paths if not p.fallback_path] + [
p.fallback_path for p in paths if p.fallback_path
]
missing.append(
f"{concept.value} (expected in {missing_paths}, "
f"but only found: {', '.join(self.detected_attributes)})"
)
return len(missing) == 0, missing
@@ -0,0 +1,49 @@
"""Environment state value object."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class EnvironmentState:
"""Represents current environmental conditions.
This value object captures all environmental factors that influence
heating decisions at a specific point in time.
Attributes:
indoor_temperature: Current room temperature in Celsius
timestamp: When these measurements were taken
indoor_humidity: Indoor humidity percentage (0-100)
outdoor_temp: Outdoor temperature in Celsius
outdoor_humidity: Optional outdoor humidity percentage (0-100)
cloud_coverage: Optional cloud coverage percentage (0-100, 0=clear sky)
"""
timestamp: datetime
indoor_temperature: float
indoor_humidity: float | None = None
outdoor_temp: float | None = None
outdoor_humidity: float | None = None
cloud_coverage: float | None = None
def __post_init__(self) -> None:
"""Validate the environmental state data."""
if self.indoor_humidity is not None and (
self.indoor_humidity < 0 or self.indoor_humidity > 100
):
raise ValueError(f"Humidity must be between 0 and 100, got {self.indoor_humidity}")
if self.outdoor_humidity is not None and (
self.outdoor_humidity < 0 or self.outdoor_humidity > 100
):
raise ValueError(
f"Outdoor humidity must be between 0 and 100, got {self.outdoor_humidity}"
)
if self.cloud_coverage is not None and (
self.cloud_coverage < 0 or self.cloud_coverage > 100
):
raise ValueError(f"Cloud coverage must be between 0 and 100, got {self.cloud_coverage}")
@@ -0,0 +1,169 @@
"""Heating decision value object."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
class HeatingAction(Enum):
"""Types of heating actions that can be taken."""
START_HEATING = "start_heating"
STOP_HEATING = "stop_heating"
SET_TEMPERATURE = "set_temperature"
NO_ACTION = "no_action"
@dataclass(frozen=True)
class HeatingDecision:
"""Represents a decision about heating control.
This value object encapsulates what action should be taken and why.
Attributes:
action: The type of action to take
target_temp: Target temperature if starting heating (None otherwise)
reason: Human-readable explanation for the decision
"""
action: HeatingAction
target_temp: float | None = None
reason: str = ""
def __post_init__(self) -> None:
"""Validate the heating decision data."""
if self.action == HeatingAction.START_HEATING and self.target_temp is None:
raise ValueError("START_HEATING action requires a target temperature")
if self.action == HeatingAction.SET_TEMPERATURE and self.target_temp is None:
raise ValueError("SET_TEMPERATURE action requires a target temperature")
@dataclass(frozen=True)
class TariffPeriodDetail:
"""Represents energy consumption and cost details for a specific tariff period."""
tariff_price_eur_per_kwh: float
energy_kwh: float
heating_duration_minutes: float
cost_euro: float
@dataclass(frozen=True)
class HeatingCycle:
"""Represents a single heating cycle, encapsulating all its relevant data.
This value object provides a complete and immutable snapshot of a heating period,
including its duration, temperature changes, and energy consumption details.
Attributes:
start_time: The exact datetime when the heating cycle started.
end_time: The exact datetime when the heating cycle ended.
target_temp: The target temperature set for this heating cycle.
end_temp: The actual temperature reached at the end of the heating cycle.
start_temp: The temperature at the beginning of the heating cycle.
tariff_details: A list of TariffDetail objects, breaking down energy, duration,
and cost by specific TariffPeriodDetail periods within the cycle.
dead_time_cycle_minutes: Dead time for this specific cycle in minutes. Time from
cycle start to first measurable temperature change.
None if cannot be determined.
min_effective_duration_minutes: Minimum effective heating duration (in minutes) required
to compute a valid slope. Effective duration is
total_duration dead_time. Cycles whose effective window
is shorter than this threshold return 0.0 for
``avg_heating_slope`` to prevent aberrant values caused by
near-zero denominators. Defaults to 5.0 minutes.
"""
device_id: str
start_time: datetime
end_time: datetime
target_temp: float
end_temp: float
start_temp: float
tariff_details: list[TariffPeriodDetail] | None = None
dead_time_cycle_minutes: float | None = None
min_effective_duration_minutes: float = 5.0
@property
def avg_heating_slope(self) -> float:
"""Calculates the average heating slope in °C/hour for the heating cycle.
Excludes the dead_time_cycle period to get the true heating slope once
the system is actively heating (without initial inertia).
Returns 0.0 when the effective heating duration (after subtracting dead_time) is shorter
than ``min_effective_duration_minutes``. This guards against aberrant slope values that
arise when dead_time ≈ total_duration, leaving an effective duration of only a few
microseconds and producing slopes in the range of 100 000200 000 °C/h.
"""
# Calculate effective start time (after dead_time_cycle)
if self.dead_time_cycle_minutes and self.dead_time_cycle_minutes > 0:
effective_start_time = self.start_time + timedelta(minutes=self.dead_time_cycle_minutes)
duration_hours = (self.end_time - effective_start_time).total_seconds() / 3600
else:
duration_hours = (self.end_time - self.start_time).total_seconds() / 3600
if duration_hours <= 0:
return 0.0
# Guard: reject cycles whose effective heating window is too narrow.
# When dead_time ≈ total_duration the slope formula amplifies noise by orders of magnitude.
effective_duration_minutes = duration_hours * 60.0
if effective_duration_minutes < self.min_effective_duration_minutes:
return 0.0
temp_increase = self.end_temp - self.start_temp
return temp_increase / duration_hours
@property
def duration_minutes(self) -> float:
"""Calculates the total duration of the heating cycle in minutes."""
return (self.end_time - self.start_time).total_seconds() / 60
@property
def temp_delta(self) -> float:
"""Calculates the difference between the target temperature and the end temperature."""
return self.target_temp - self.end_temp
@property
def start_hour(self) -> int:
"""Returns the hour (0-23) when the heating cycle started."""
return self.start_time.hour
@property
def end_hour(self) -> int:
"""Returns the hour (0-23) when the heating cycle ended."""
return self.end_time.hour
@property
def start_weekday(self) -> int:
"""Returns the weekday (0=Monday, 6=Sunday) when the heating cycle started."""
return self.start_time.weekday()
@property
def end_weekday(self) -> int:
"""Returns the weekday (0=Monday, 6=Sunday) when the heating cycle ended."""
return self.end_time.weekday()
@property
def total_energy_kwh(self) -> float:
"""Calculates the total energy consumed during the cycle in kWh from tariff details."""
return sum(detail.energy_kwh for detail in (self.tariff_details or []))
@property
def total_heating_duration_minutes(self) -> float:
"""Calculates the total heating duration in minutes from tariff details."""
return sum(detail.heating_duration_minutes for detail in (self.tariff_details or []))
@property
def total_cost_euro(self) -> float:
"""Calculates the total cost in euros from tariff details."""
return sum(detail.cost_euro for detail in (self.tariff_details or []))
def __post_init__(self) -> None:
"""Validate the heating cycle data."""
if self.start_time >= self.end_time:
raise ValueError("Start time must be before end time for a heating cycle.")
@@ -0,0 +1,101 @@
"""Value object for heating cycle cache data.
This value object represents cached heating cycle data with metadata
about when the cache was last updated. Designed to enable incremental
cycle extraction without re-scanning entire recorder history.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from .heating import HeatingCycle
@dataclass(frozen=True)
class HeatingCycleCacheData:
"""Immutable record of cached heating cycles with metadata.
This value object stores a collection of heating cycles along with
metadata about when the cache was last updated, enabling incremental
cache refresh strategies, and tracks which dates have been explored
even if they contained no cycles.
Attributes:
device_id: Device identifier these cycles belong to
cycles: List of cached HeatingCycle objects
last_search_time: UTC timestamp of the last history search
retention_days: Number of days to retain cycles in cache
explored_dates: Set of dates that have been extracted/explored
(even if no cycles were found). Used to avoid
re-extracting empty days indefinitely.
"""
device_id: str
cycles: tuple[HeatingCycle, ...] # Use tuple for immutability
last_search_time: datetime
retention_days: int
explored_dates: frozenset[date] = field(default_factory=frozenset)
def __post_init__(self) -> None:
"""Validate cache data after initialization."""
if not self.device_id:
raise ValueError("device_id cannot be empty")
if self.retention_days <= 0:
raise ValueError(f"retention_days must be positive, got {self.retention_days}")
# Ensure timestamp is timezone-aware
if self.last_search_time.tzinfo is None:
raise ValueError("last_search_time must be timezone-aware (UTC)")
@property
def cycle_count(self) -> int:
"""Return the number of cycles in the cache."""
return len(self.cycles)
def get_cycles_since(self, start_time: datetime) -> list[HeatingCycle]:
"""Get cycles that started on or after the specified time.
Args:
start_time: Minimum start time for cycles to return
Returns:
List of cycles starting at or after start_time
"""
return [cycle for cycle in self.cycles if cycle.start_time >= start_time]
def get_cycles_within_retention(self, reference_time: datetime) -> list[HeatingCycle]:
"""Get cycles within the retention period from a reference time.
Args:
reference_time: Time to calculate retention from
Returns:
List of cycles within retention period
"""
from datetime import timedelta
cutoff_time = reference_time - timedelta(days=self.retention_days)
return [cycle for cycle in self.cycles if cycle.start_time >= cutoff_time]
def with_explored_dates(self, explored_dates: set[date]) -> HeatingCycleCacheData:
"""Return a new cache instance with updated explored_dates.
Since this dataclass is immutable (frozen=True), this method creates
a new instance rather than modifying the existing one.
Args:
explored_dates: Set of dates to mark as explored
Returns:
A new HeatingCycleCacheData instance with updated explored_dates
"""
return HeatingCycleCacheData(
device_id=self.device_id,
cycles=self.cycles,
last_search_time=self.last_search_time,
retention_days=self.retention_days,
explored_dates=frozenset(explored_dates),
)
@@ -0,0 +1,58 @@
"""Value objects for historical data within the heating domain."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Any
class HistoricalDataKey(Enum):
"""Keys to identify different types of historical data within a dataset."""
INDOOR_TEMP = "indoor_temp"
INDOOR_HUMIDITY = "indoor_humidity"
OUTDOOR_TEMP = "outdoor_temp"
OUTDOOR_HUMIDITY = "outdoor_humidity"
CLOUD_COVERAGE = "cloud_coverage"
TARGET_TEMP = "target_temp"
# Optional instrumentation for energy & tariff calculations
HEATING_STATE = "heating_state"
HEATING_ENERGY_KWH = "heating_energy_kwh" # Cumulative energy meter in kWh
HEATING_RUNTIME_SECONDS = "heating_runtime_seconds" # Cumulative runtime in seconds
TARIFF_PRICE_EUR_PER_KWH = "tariff_price_eur_per_kwh" # Tariff price time series
# Ajoutez d'autres clés au besoin
@dataclass(frozen=True)
class HistoricalMeasurement:
"""Represents a single historical measurement for an entity at a specific timestamp.
Attributes:
timestamp: The datetime when the measurement was recorded.
value: The main state value of the entity (e.g., temperature, 'on'/'off').
attributes: A dictionary of additional attributes (e.g., for climate entities like 'hvac_action').
entity_id: The entity_id from Home Assistant (e.g., 'climate.living_room', 'sensor.outdoor_temp').
"""
timestamp: datetime
value: float | str | bool
attributes: dict[str, Any]
entity_id: str
@dataclass(frozen=True)
class HistoricalDataSet:
"""A collection of historical measurements, categorized by a HistoricalDataKey.
This serves as a domain-agnostic representation of raw historical sensor data
before it's processed into domain-specific concepts like heating cycles.
Attributes:
data: A dictionary where keys are HistoricalDataKey and values are lists of HistoricalMeasurement.
"""
data: dict[HistoricalDataKey, list[HistoricalMeasurement]]
@@ -0,0 +1,24 @@
"""Cached LHS entry value object.
This immutable object carries a cached Learning Heating Slope (LHS) value
along with its last update timestamp and optional contextual hour.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class LHSCacheEntry:
"""Represents a cached LHS value and its metadata."""
value: float
updated_at: datetime
hour: int | None = None
def is_for_hour(self, hour: int) -> bool:
"""Check if the cache entry matches the requested hour."""
return self.hour == hour
@@ -0,0 +1,42 @@
"""Prediction result value object."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class PredictionResult:
"""Result of heating time prediction.
Represents when heating should start to reach the target temperature
at the scheduled time.
Attributes:
anticipated_start_time: When heating should begin
estimated_duration_minutes: How long heating is expected to take
confidence_level: Confidence in prediction (0.0-1.0)
learned_heating_slope: The heating slope used for prediction (°C/h)
"""
anticipated_start_time: datetime
estimated_duration_minutes: float
confidence_level: float
learned_heating_slope: float
def __post_init__(self) -> None:
"""Validate the prediction result data."""
if self.estimated_duration_minutes < 0:
raise ValueError(
f"Duration must be non-negative, got {self.estimated_duration_minutes}"
)
if not 0.0 <= self.confidence_level <= 1.0:
raise ValueError(f"Confidence must be between 0 and 1, got {self.confidence_level}")
# Allow zero slope only when confidence is also zero (invalid prediction)
if self.learned_heating_slope <= 0 and self.confidence_level > 0:
raise ValueError(
f"Heating slope must be positive when confidence > 0, got {self.learned_heating_slope}"
)
@@ -0,0 +1,58 @@
"""Recording extraction task value object."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from enum import Enum
class ExtractionTaskState(Enum):
"""States of a recording extraction task."""
PENDING = "pending"
EXTRACTING = "extracting"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
@dataclass(frozen=True)
class RecordingExtractionTask:
"""Represents a period extraction task from the Home Assistant Recorder.
This value object encapsulates all state and metadata for extracting data
for a specific date range. Tasks are queued and executed sequentially to
avoid overwhelming the Home Assistant Recorder with concurrent queries.
The actual extracted cycles are NOT stored in this object; instead they are
passed to a callback function (on_cycles_extracted) for progressive cache
population. This keeps the value object lightweight and immutable.
Attributes:
start_date: The first day (inclusive) of the extraction period (YYYY-MM-DD).
end_date: The last day (inclusive) of the extraction period (YYYY-MM-DD).
device_id: The IHP device identifier for which to extract data.
state: Current state of the task (PENDING, EXTRACTING, COMPLETED, FAILED).
error: Error message if extraction failed, None otherwise.
"""
start_date: date
end_date: date
device_id: str
state: ExtractionTaskState = ExtractionTaskState.PENDING
error: str | None = None
def __hash__(self) -> int:
"""Make task hashable based on start_date, end_date, and device_id."""
return hash((self.start_date, self.end_date, self.device_id))
def __eq__(self, other: object) -> bool:
"""Compare tasks by start_date, end_date, and device_id."""
if not isinstance(other, RecordingExtractionTask):
return False
return (
self.start_date == other.start_date
and self.end_date == other.end_date
and self.device_id == other.device_id
)
@@ -0,0 +1,32 @@
"""Schedule timeslot value object."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class ScheduledTimeslot:
"""Represents a scheduled heating timeslot.
A schedule timeslot defines when the room should reach a specific
target temperature, following the scheduler component data format.
See: https://github.com/nielsfaber/scheduler-component/#data-format
Attributes:
target_time: When the target temperature should be reached
target_temp: Desired temperature in Celsius
timeslot_id: Unique identifier for this schedule timeslot
scheduler_entity: The scheduler entity ID that provided this timeslot
"""
target_time: datetime
target_temp: float
timeslot_id: str
scheduler_entity: str = ""
def __post_init__(self) -> None:
"""Validate the schedule timeslot data."""
if not self.timeslot_id:
raise ValueError("Timeslot ID cannot be empty")
@@ -0,0 +1,32 @@
"""Value object for slope data with timestamp.
This value object represents a recorded heating slope measurement
with associated metadata. Designed to be extensible for future ML features.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class SlopeData:
"""Immutable record of a heating slope measurement.
Attributes:
slope_value: Heating slope in °C/hour
timestamp: UTC timestamp when the slope was recorded
"""
slope_value: float
timestamp: datetime
def __post_init__(self) -> None:
"""Validate slope data after initialization."""
if self.slope_value <= 0:
raise ValueError(f"Slope value must be positive, got {self.slope_value}")
# Ensure timestamp is timezone-aware
if self.timestamp.tzinfo is None:
raise ValueError("Timestamp must be timezone-aware (UTC)")