170 lines
6.9 KiB
Python
170 lines
6.9 KiB
Python
"""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.")
|