249 lines
9.5 KiB
Python
249 lines
9.5 KiB
Python
"""Prediction service for calculating heating times."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
|
|
from ..constants import (
|
|
BASE_HIGH_CONFIDENCE,
|
|
BASE_LOW_CONFIDENCE,
|
|
BASE_MEDIUM_CONFIDENCE,
|
|
CLOUD_COVERAGE_FACTOR,
|
|
CONFIDENCE_BOOST_PER_SENSOR,
|
|
DEFAULT_ANTICIPATION_BUFFER,
|
|
HIGH_CONFIDENCE_SLOPE,
|
|
HUMIDITY_FACTOR,
|
|
HUMIDITY_REFERENCE,
|
|
MAX_ANTICIPATION_TIME,
|
|
MEDIUM_CONFIDENCE_SLOPE,
|
|
MIN_ANTICIPATION_TIME,
|
|
OUTDOOR_TEMP_FACTOR,
|
|
OUTDOOR_TEMP_REFERENCE,
|
|
)
|
|
from ..value_objects import PredictionResult
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class PredictionService:
|
|
"""Service for predicting heating start times.
|
|
|
|
This service contains the core prediction algorithm that determines
|
|
when heating should start to reach target temperature at a scheduled time.
|
|
|
|
The calculation considers:
|
|
1. Temperature difference to heat
|
|
2. Outdoor temperature impact on heat loss
|
|
3. Humidity effects on heating efficiency
|
|
4. Solar gain from cloud coverage
|
|
5. Learned heating slope (heating rate) from historical data
|
|
"""
|
|
|
|
def predict_heating_time(
|
|
self,
|
|
current_temp: float | None,
|
|
target_temp: float,
|
|
learned_slope: float,
|
|
target_time: datetime,
|
|
outdoor_temp: float | None = None,
|
|
humidity: float | None = None,
|
|
cloud_coverage: float | None = None,
|
|
dead_time_minutes: float = 0.0,
|
|
) -> PredictionResult:
|
|
"""Calculate when heating should start.
|
|
|
|
Args:
|
|
current_temp: Current room temperature in Celsius (None = cannot calculate)
|
|
target_temp: Target temperature in Celsius
|
|
learned_slope: Learned heating slope in °C/hour
|
|
target_time: When target should be reached (mandatory)
|
|
outdoor_temp: Outdoor temperature in Celsius (optional)
|
|
humidity: Indoor humidity percentage (0-100) (optional)
|
|
cloud_coverage: Cloud coverage percentage (0-100, 0=clear sky) (optional)
|
|
dead_time_minutes: Dead time in minutes (initial period with minimal heating effect)
|
|
|
|
Returns:
|
|
Prediction result with start time and confidence
|
|
"""
|
|
# Handle missing current temperature
|
|
if current_temp is None:
|
|
_LOGGER.warning("Cannot calculate prediction: current_temp is None")
|
|
return PredictionResult(
|
|
anticipated_start_time=target_time,
|
|
estimated_duration_minutes=0.0,
|
|
confidence_level=0.0,
|
|
learned_heating_slope=learned_slope,
|
|
)
|
|
|
|
# Calculate temperature difference
|
|
temp_delta = target_temp - current_temp
|
|
|
|
if temp_delta <= 0:
|
|
# Already at target, anticipated start time = target time
|
|
_LOGGER.debug(
|
|
"Already at target temperature (%.1f°C >= %.1f°C), no heating needed",
|
|
current_temp,
|
|
target_temp,
|
|
)
|
|
return PredictionResult(
|
|
anticipated_start_time=target_time,
|
|
estimated_duration_minutes=0.0,
|
|
confidence_level=1.0,
|
|
learned_heating_slope=learned_slope,
|
|
)
|
|
|
|
# Protection against invalid slope (should not happen with proper validation)
|
|
if learned_slope is None or learned_slope <= 0:
|
|
_LOGGER.error(
|
|
"CRITICAL: Invalid learned heating slope (%.4f°C/h <= 0) reached prediction_service. "
|
|
"This indicates missing validation in calling code. Cannot calculate prediction.",
|
|
learned_slope or 0,
|
|
)
|
|
return PredictionResult(
|
|
anticipated_start_time=target_time,
|
|
estimated_duration_minutes=0.0,
|
|
confidence_level=0.0,
|
|
learned_heating_slope=learned_slope or 0,
|
|
)
|
|
|
|
# Calculate base anticipation time (in minutes)
|
|
# Formula: heating_time = dead_time + (temp_delta / slope) * 60
|
|
anticipation_minutes = dead_time_minutes + (temp_delta / learned_slope) * 60.0
|
|
# Apply environmental correction factors
|
|
correction_factor = self._calculate_environmental_correction(
|
|
outdoor_temp, humidity, cloud_coverage
|
|
)
|
|
|
|
anticipation_minutes *= correction_factor
|
|
|
|
# Apply buffer and limits
|
|
anticipation_minutes += DEFAULT_ANTICIPATION_BUFFER
|
|
anticipation_minutes = max(
|
|
MIN_ANTICIPATION_TIME, min(MAX_ANTICIPATION_TIME, anticipation_minutes)
|
|
)
|
|
|
|
# Calculate anticipated start time
|
|
anticipated_start = target_time - timedelta(minutes=anticipation_minutes)
|
|
|
|
# Calculate confidence level based on slope and available environmental data
|
|
confidence = self._calculate_confidence(learned_slope, outdoor_temp, humidity)
|
|
|
|
_LOGGER.debug(
|
|
"Prediction: ΔT=%.1f°C, slope=%.2f°C/h, dead_time=%.1f min, correction=%.2f, "
|
|
"duration=%.1f min, confidence=%.2f",
|
|
temp_delta,
|
|
learned_slope,
|
|
dead_time_minutes,
|
|
correction_factor,
|
|
anticipation_minutes,
|
|
confidence,
|
|
)
|
|
|
|
return PredictionResult(
|
|
anticipated_start_time=anticipated_start,
|
|
estimated_duration_minutes=anticipation_minutes,
|
|
confidence_level=confidence,
|
|
learned_heating_slope=learned_slope,
|
|
)
|
|
|
|
def _calculate_environmental_correction(
|
|
self,
|
|
outdoor_temp: float | None,
|
|
humidity: float | None,
|
|
cloud_coverage: float | None,
|
|
) -> float:
|
|
"""Calculate combined environmental correction factor.
|
|
|
|
This method combines multiple environmental factors that affect
|
|
heating efficiency:
|
|
- Outdoor temperature (heat loss)
|
|
- Indoor humidity (thermal mass effect)
|
|
- Cloud coverage (solar gain)
|
|
|
|
Args:
|
|
outdoor_temp: Outdoor temperature in Celsius
|
|
humidity: Indoor humidity percentage (0-100)
|
|
cloud_coverage: Cloud coverage percentage (0-100)
|
|
|
|
Returns:
|
|
Combined correction factor (>1 means slower heating)
|
|
"""
|
|
correction_factor = 1.0
|
|
|
|
# Outdoor temperature factor: colder outside means slower heating
|
|
# Formula: outdoor_factor = 1 + (OUTDOOR_TEMP_REFERENCE - outdoor_temp) * OUTDOOR_TEMP_FACTOR
|
|
# At outdoor_temp = 20°C: factor = 1.0 (no impact)
|
|
# At outdoor_temp = 0°C: factor = 2.0 (heating takes twice as long)
|
|
# At outdoor_temp = -10°C: factor = 2.5 (even slower)
|
|
if outdoor_temp is not None:
|
|
outdoor_factor = 1.0 + (OUTDOOR_TEMP_REFERENCE - outdoor_temp) * OUTDOOR_TEMP_FACTOR
|
|
outdoor_factor = max(0.5, outdoor_factor) # Minimum factor of 0.5
|
|
correction_factor *= outdoor_factor
|
|
_LOGGER.debug("Outdoor temp %.1f°C -> factor %.2f", outdoor_temp, outdoor_factor)
|
|
|
|
# Humidity factor: higher humidity makes heating feel slower
|
|
# Formula: humidity_factor = 1 + (humidity - HUMIDITY_REFERENCE) * HUMIDITY_FACTOR
|
|
# At 50% humidity: factor = 1.0 (neutral)
|
|
# At 80% humidity: factor = 1.06 (6% slower)
|
|
# At 20% humidity: factor = 0.94 (6% faster)
|
|
if humidity is not None:
|
|
humidity_factor = 1.0 + (humidity - HUMIDITY_REFERENCE) * HUMIDITY_FACTOR
|
|
humidity_factor = max(0.8, min(1.2, humidity_factor))
|
|
correction_factor *= humidity_factor
|
|
_LOGGER.debug("Humidity %.1f%% -> factor %.2f", humidity, humidity_factor)
|
|
|
|
# Solar gain factor: less cloud coverage means more solar heat gain
|
|
# Formula: solar_factor = 1 - (100 - cloud_coverage) * CLOUD_COVERAGE_FACTOR
|
|
# At 100% cloud: factor = 1.0 (no solar gain)
|
|
# At 0% cloud (clear sky): factor = 0.9 (10% faster due to sun)
|
|
# At 50% cloud: factor = 0.95 (5% faster)
|
|
if cloud_coverage is not None:
|
|
solar_factor = 1.0 - (100.0 - cloud_coverage) * CLOUD_COVERAGE_FACTOR
|
|
solar_factor = max(0.8, min(1.0, solar_factor))
|
|
correction_factor *= solar_factor
|
|
_LOGGER.debug("Cloud coverage %.1f%% -> factor %.2f", cloud_coverage, solar_factor)
|
|
|
|
return correction_factor
|
|
|
|
def _calculate_confidence(
|
|
self,
|
|
learned_slope: float,
|
|
outdoor_temp: float | None,
|
|
humidity: float | None,
|
|
) -> float:
|
|
"""Calculate confidence level in the prediction.
|
|
|
|
Confidence is based on:
|
|
- Slope validity (higher slope = better learning)
|
|
- Available environmental data (more data = better prediction)
|
|
|
|
Args:
|
|
learned_slope: Learned heating slope in °C/hour
|
|
outdoor_temp: Outdoor temperature (if available)
|
|
humidity: Indoor humidity (if available)
|
|
|
|
Returns:
|
|
Confidence level between 0.0 and 1.0
|
|
"""
|
|
# Base confidence from slope validity
|
|
if learned_slope > HIGH_CONFIDENCE_SLOPE:
|
|
confidence = BASE_HIGH_CONFIDENCE
|
|
elif learned_slope > MEDIUM_CONFIDENCE_SLOPE:
|
|
confidence = BASE_MEDIUM_CONFIDENCE
|
|
else:
|
|
confidence = BASE_LOW_CONFIDENCE
|
|
|
|
# Adjust confidence based on available environmental data
|
|
data_availability = 0
|
|
if outdoor_temp is not None:
|
|
data_availability += 1
|
|
if humidity is not None:
|
|
data_availability += 1
|
|
|
|
# Increase confidence slightly with more environmental data
|
|
confidence += data_availability * CONFIDENCE_BOOST_PER_SENSOR
|
|
|
|
# Cap at 1.0
|
|
return min(1.0, confidence)
|