Files
HomeAssistantVS/custom_components/intelligent_heating_pilot/domain/value_objects/heating.py
T
2026-07-08 10:43:39 -04:00

170 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.")