"""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.")