New apps Added
This commit is contained in:
@@ -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.")
|
||||
Reference in New Issue
Block a user