New apps Added
This commit is contained in:
@@ -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