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

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)