New apps Added
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
"""Domain services - stateless operations on domain objects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .contextual_lhs_calculator_service import ContextualLHSCalculatorService
|
||||
from .dead_time_calculation_service import DeadTimeCalculationService
|
||||
from .global_lhs_calculator_service import GlobalLHSCalculatorService
|
||||
from .heating_cycle_service import HeatingCycleService
|
||||
from .prediction_service import PredictionService
|
||||
|
||||
__all__ = [
|
||||
"PredictionService",
|
||||
"HeatingCycleService",
|
||||
"GlobalLHSCalculatorService",
|
||||
"ContextualLHSCalculatorService",
|
||||
"DeadTimeCalculationService",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+134
@@ -0,0 +1,134 @@
|
||||
"""Service for calculating contextual Learning Heating Slope (LHS).
|
||||
|
||||
Pure domain logic for grouping cycles by start hour and calculating
|
||||
average heating slopes per hour. No Home Assistant dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..value_objects import HeatingCycle
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContextualLHSCalculatorService:
|
||||
"""Calculate contextual LHS grouped by start hour.
|
||||
|
||||
Responsibilities:
|
||||
- Extract hour from cycle start_time
|
||||
- Group cycles by start hour
|
||||
- Calculate average LHS per hour
|
||||
- Handle empty groups gracefully
|
||||
|
||||
Pure domain logic with no Home Assistant dependencies.
|
||||
"""
|
||||
|
||||
def extract_hour_from_cycle(self, cycle: HeatingCycle) -> int:
|
||||
"""Extract hour (0-23) from cycle start time.
|
||||
|
||||
Args:
|
||||
cycle: The heating cycle
|
||||
|
||||
Returns:
|
||||
Hour of day (0-23)
|
||||
"""
|
||||
_LOGGER.debug("Extracting hour from cycle started at %s", cycle.start_time)
|
||||
hour = cycle.start_time.hour
|
||||
_LOGGER.debug("Extracted hour: %d", hour)
|
||||
return hour
|
||||
|
||||
def group_cycles_by_start_hour(
|
||||
self, cycles: list[HeatingCycle]
|
||||
) -> dict[int, list[HeatingCycle]]:
|
||||
"""Group cycles by their start_time hour.
|
||||
|
||||
Args:
|
||||
cycles: All extracted heating cycles
|
||||
|
||||
Returns:
|
||||
Mapping {hour: [cycles_starting_at_hour]}
|
||||
"""
|
||||
_LOGGER.debug("Grouping %d cycles by start hour", len(cycles))
|
||||
|
||||
grouped: dict[int, list[HeatingCycle]] = {h: [] for h in range(24)}
|
||||
|
||||
for cycle in cycles:
|
||||
hour = self.extract_hour_from_cycle(cycle)
|
||||
grouped[hour].append(cycle)
|
||||
|
||||
# Log summary
|
||||
non_empty = {h: len(c) for h, c in grouped.items() if c}
|
||||
_LOGGER.info("Grouped cycles by hour: %s", non_empty)
|
||||
|
||||
return grouped
|
||||
|
||||
def calculate_contextual_lhs_for_hour(
|
||||
self, cycles: list[HeatingCycle], target_hour: int
|
||||
) -> float | None:
|
||||
"""Calculate average LHS for cycles starting at target_hour.
|
||||
|
||||
Args:
|
||||
cycles: All extracted cycles
|
||||
target_hour: Hour (0-23) to filter by
|
||||
|
||||
Returns:
|
||||
Average LHS value or None if no data for this hour
|
||||
|
||||
Raises:
|
||||
ValueError: If target_hour not in 0-23
|
||||
"""
|
||||
if not 0 <= target_hour <= 23:
|
||||
raise ValueError(f"target_hour must be 0-23, got {target_hour}")
|
||||
|
||||
_LOGGER.debug(
|
||||
"Calculating contextual LHS for hour %d from %d cycles", target_hour, len(cycles)
|
||||
)
|
||||
|
||||
# Filter cycles starting at target hour
|
||||
matching_cycles = [c for c in cycles if self.extract_hour_from_cycle(c) == target_hour]
|
||||
|
||||
if not matching_cycles:
|
||||
_LOGGER.debug("No cycles found for hour %d", target_hour)
|
||||
return None
|
||||
|
||||
# Filter out non-positive slopes: cycles where temperature didn't rise
|
||||
# carry no useful learning data about heating speed
|
||||
lhs_values = [c.avg_heating_slope for c in matching_cycles if c.avg_heating_slope > 0]
|
||||
|
||||
if not lhs_values:
|
||||
_LOGGER.debug("No cycles with positive heating slope for hour %d", target_hour)
|
||||
return None
|
||||
|
||||
avg_lhs = sum(lhs_values) / len(lhs_values)
|
||||
|
||||
_LOGGER.info(
|
||||
"Calculated contextual LHS for hour %d: %.2f°C/h from %d cycles",
|
||||
target_hour,
|
||||
avg_lhs,
|
||||
len(matching_cycles),
|
||||
)
|
||||
|
||||
return avg_lhs
|
||||
|
||||
def calculate_all_contextual_lhs(self, cycles: list[HeatingCycle]) -> dict[int, float | None]:
|
||||
"""Calculate contextual LHS for all 24 hours.
|
||||
|
||||
Args:
|
||||
cycles: All extracted cycles
|
||||
|
||||
Returns:
|
||||
Mapping {hour: avg_lhs_value_or_none}
|
||||
"""
|
||||
_LOGGER.info("Calculating contextual LHS for all 24 hours")
|
||||
|
||||
result = {}
|
||||
for hour in range(24):
|
||||
lhs = self.calculate_contextual_lhs_for_hour(cycles, hour)
|
||||
result[hour] = lhs
|
||||
|
||||
hours_with_data = sum(1 for v in result.values() if v is not None)
|
||||
_LOGGER.info("Contextual LHS calculated for %d hours with data", hours_with_data)
|
||||
|
||||
return result
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
"""Service for calculating average dead time from heating cycles.
|
||||
|
||||
Pure domain logic for aggregating dead_time_cycle_minutes values
|
||||
from HeatingCycle instances. No Home Assistant dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..value_objects import HeatingCycle
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeadTimeCalculationService:
|
||||
"""Calculate average dead time from heating cycles.
|
||||
|
||||
Responsibilities:
|
||||
- Filter cycles with valid dead_time_cycle_minutes
|
||||
- Compute average dead time across valid cycles
|
||||
- Return None when no valid data exists
|
||||
|
||||
Pure domain logic with no Home Assistant dependencies.
|
||||
"""
|
||||
|
||||
def calculate_average_dead_time(self, heating_cycles: list[HeatingCycle]) -> float | None:
|
||||
"""Calculate average dead_time from cycles with valid dead_time_cycle_minutes.
|
||||
|
||||
Args:
|
||||
heating_cycles: List of heating cycles to analyze
|
||||
|
||||
Returns:
|
||||
Average dead time in minutes, or None if no valid data
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Entering calculate_average_dead_time: cycles=%d",
|
||||
len(heating_cycles),
|
||||
)
|
||||
|
||||
cycles_with_dead_time = [
|
||||
cycle
|
||||
for cycle in heating_cycles
|
||||
if cycle.dead_time_cycle_minutes is not None and cycle.dead_time_cycle_minutes > 0
|
||||
]
|
||||
|
||||
if not cycles_with_dead_time:
|
||||
_LOGGER.debug("No cycles with valid dead_time_cycle_minutes")
|
||||
_LOGGER.debug("Exiting calculate_average_dead_time: result=None")
|
||||
return None
|
||||
|
||||
total_dead_time = sum(
|
||||
cycle.dead_time_cycle_minutes
|
||||
for cycle in cycles_with_dead_time
|
||||
if cycle.dead_time_cycle_minutes is not None
|
||||
)
|
||||
avg_dead_time = total_dead_time / len(cycles_with_dead_time)
|
||||
|
||||
_LOGGER.info(
|
||||
"Calculated average dead_time from %d cycles: %.1f minutes",
|
||||
len(cycles_with_dead_time),
|
||||
avg_dead_time,
|
||||
)
|
||||
_LOGGER.debug("Exiting calculate_average_dead_time: result=%.1f", avg_dead_time)
|
||||
|
||||
return avg_dead_time
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
"""Extraction date range calculator for historical data loading."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExtractionDateRangeCalculator:
|
||||
"""Utility service to calculate the date range for recording extraction.
|
||||
|
||||
This service encapsulates the business logic for determining which dates should
|
||||
be extracted from the Recorder based on:
|
||||
- The configured retention period (in days)
|
||||
- The oldest cycle already in the cache
|
||||
- The current datetime
|
||||
|
||||
The goal is to extract enough historical data to build machine learning models
|
||||
while respecting the retention window and avoiding redundant extraction.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_extraction_range(
|
||||
retention_days: int,
|
||||
oldest_cycle_in_cache: datetime | None,
|
||||
current_time: datetime | None = None,
|
||||
) -> tuple[date, date]:
|
||||
"""Calculate the date range for recording extraction.
|
||||
|
||||
Logic:
|
||||
1. If oldest_cycle_in_cache is None (empty cache):
|
||||
- Extract from (now - retention_days) to today
|
||||
2. If oldest_cycle_in_cache exists:
|
||||
- Calculate: oldest_cycle - 24 hours
|
||||
- Extract from max(original_start_date, oldest_cycle - 24h) to today
|
||||
- This ensures we have one full day of context before the oldest cycle
|
||||
|
||||
Args:
|
||||
retention_days: The retention window in days (e.g., 90)
|
||||
oldest_cycle_in_cache: The datetime of the oldest cycle currently in cache,
|
||||
or None if cache is empty
|
||||
current_time: Current datetime (for testing; defaults to now())
|
||||
|
||||
Returns:
|
||||
A tuple of (start_date, end_date) both inclusive, both as datetime.date objects
|
||||
Raises:
|
||||
ValueError: If retention_days is negative.
|
||||
"""
|
||||
if current_time is None:
|
||||
current_time = datetime.now()
|
||||
|
||||
if retention_days < 0:
|
||||
_LOGGER.error("retention_days must be non-negative, got %d", retention_days)
|
||||
raise ValueError(f"retention_days must be non-negative, got {retention_days}")
|
||||
|
||||
# Fixed boundary: start of retention window
|
||||
retention_boundary = current_time - timedelta(days=retention_days)
|
||||
|
||||
if oldest_cycle_in_cache is None:
|
||||
# Empty cache: extract full retention window
|
||||
start_date = retention_boundary.date()
|
||||
end_date = current_time.date()
|
||||
_LOGGER.debug(
|
||||
"Empty cache: extracting from %s to %s (retention=%d days)",
|
||||
start_date,
|
||||
end_date,
|
||||
retention_days,
|
||||
)
|
||||
return start_date, end_date
|
||||
|
||||
# Cache has data: look one day before the oldest cycle
|
||||
oldest_minus_24h = oldest_cycle_in_cache - timedelta(days=1)
|
||||
|
||||
# Don't extract before retention boundary
|
||||
extraction_start = max(oldest_minus_24h, retention_boundary)
|
||||
extraction_start_date = extraction_start.date()
|
||||
extraction_end_date = current_time.date()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Cache has data (oldest=%s): extracting from %s to %s "
|
||||
"(retention_boundary=%s, oldest_minus_24h=%s)",
|
||||
oldest_cycle_in_cache.isoformat(),
|
||||
extraction_start_date,
|
||||
extraction_end_date,
|
||||
retention_boundary.date(),
|
||||
oldest_minus_24h.date(),
|
||||
)
|
||||
|
||||
return extraction_start_date, extraction_end_date
|
||||
|
||||
@staticmethod
|
||||
def calculate_refresh_range(
|
||||
current_time: datetime | None = None,
|
||||
) -> tuple[date, date]:
|
||||
"""Calculate the date range for a 24h refresh (last day only).
|
||||
|
||||
This is used for periodic refresh to get the most recent data without
|
||||
re-extracting the entire history.
|
||||
|
||||
Args:
|
||||
current_time: Current datetime (for testing; defaults to now())
|
||||
|
||||
Returns:
|
||||
A tuple of (yesterday_date, today_date) as datetime.date objects
|
||||
"""
|
||||
if current_time is None:
|
||||
current_time = datetime.now()
|
||||
|
||||
yesterday = (current_time - timedelta(days=1)).date()
|
||||
today = current_time.date()
|
||||
|
||||
_LOGGER.debug("24h refresh: extracting from %s to %s", yesterday, today)
|
||||
return yesterday, today
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
"""Service for calculating global Learning Heating Slope (LHS).
|
||||
|
||||
Pure domain logic for calculating average heating slope from all cycles,
|
||||
regardless of time of day. No Home Assistant dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..constants import DEFAULT_LEARNED_SLOPE
|
||||
from ..value_objects import HeatingCycle
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GlobalLHSCalculatorService:
|
||||
"""Calculate global LHS from all heating cycles.
|
||||
|
||||
Responsibilities:
|
||||
- Calculate average heating slope from all cycles
|
||||
- Return default slope when no cycles available
|
||||
- Handle edge cases gracefully
|
||||
|
||||
Pure domain logic with no Home Assistant dependencies.
|
||||
"""
|
||||
|
||||
def calculate_global_lhs(self, cycles: list[HeatingCycle]) -> float:
|
||||
"""Calculate average LHS from all heating cycles.
|
||||
|
||||
Args:
|
||||
cycles: List of all heating cycles to analyze
|
||||
|
||||
Returns:
|
||||
float: Average heating slope in °C/hour, or DEFAULT_LEARNED_SLOPE if no cycles
|
||||
|
||||
Calculation:
|
||||
avg_lhs = sum(cycle.avg_heating_slope for cycle in cycles) / len(cycles)
|
||||
"""
|
||||
_LOGGER.debug("Calculating global LHS from %d cycles", len(cycles))
|
||||
|
||||
if not cycles:
|
||||
_LOGGER.info(
|
||||
"No cycles available for global LHS calculation, returning default slope: %.2f°C/h",
|
||||
DEFAULT_LEARNED_SLOPE,
|
||||
)
|
||||
return DEFAULT_LEARNED_SLOPE
|
||||
|
||||
# Filter out non-positive slopes: cycles where temperature didn't rise
|
||||
# carry no useful learning data about heating speed
|
||||
lhs_values = [cycle.avg_heating_slope for cycle in cycles if cycle.avg_heating_slope > 0]
|
||||
|
||||
if not lhs_values:
|
||||
_LOGGER.info(
|
||||
"No cycles with positive heating slope, returning default: %.2f°C/h",
|
||||
DEFAULT_LEARNED_SLOPE,
|
||||
)
|
||||
return DEFAULT_LEARNED_SLOPE
|
||||
|
||||
global_lhs = sum(lhs_values) / len(lhs_values)
|
||||
|
||||
_LOGGER.info(
|
||||
"Calculated global LHS: %.2f°C/h from %d cycles",
|
||||
global_lhs,
|
||||
len(cycles),
|
||||
)
|
||||
_LOGGER.debug("Global LHS calculation complete")
|
||||
|
||||
return global_lhs
|
||||
+1022
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
"""ML-based decision strategy using IHP-ML-Models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..interfaces import ISchedulerReader
|
||||
from ..interfaces.decision_strategy_interface import IDecisionStrategy
|
||||
from ..value_objects import (
|
||||
EnvironmentState,
|
||||
HeatingAction,
|
||||
HeatingDecision,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MLDecisionStrategy(IDecisionStrategy):
|
||||
"""ML-powered heating decisions using IHP-ML-Models.
|
||||
|
||||
This strategy delegates heating decisions to an AI model trained
|
||||
in the IHP-ML-Models add-on. It provides more sophisticated
|
||||
predictions by learning from historical data.
|
||||
|
||||
Decision logic:
|
||||
- Queries ML model API for action predictions
|
||||
- Uses reinforcement learning for optimal timing
|
||||
- Adapts to specific home characteristics over time
|
||||
|
||||
Requirements:
|
||||
- IHP-ML-Models Home Assistant add-on must be installed
|
||||
- ML model must be trained with historical heating data
|
||||
|
||||
Attributes:
|
||||
_scheduler_reader: Interface to read scheduled timeslots
|
||||
_ml_client: Interface to ML model API (to be implemented)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scheduler_reader: ISchedulerReader,
|
||||
# ml_client: IMLClient, # TODO: Add interface for ML API
|
||||
) -> None:
|
||||
"""Initialize ML decision strategy.
|
||||
|
||||
Args:
|
||||
scheduler_reader: Implementation of scheduler reading interface
|
||||
# ml_client: Client to communicate with ML model API
|
||||
"""
|
||||
_LOGGER.debug("Initializing MLDecisionStrategy")
|
||||
self._scheduler_reader = scheduler_reader
|
||||
# self._ml_client = ml_client # TODO: Implement ML client
|
||||
_LOGGER.warning(
|
||||
"MLDecisionStrategy is not fully implemented yet. "
|
||||
"Requires IMLClient interface and adapter."
|
||||
)
|
||||
|
||||
async def decide_heating_action(
|
||||
self,
|
||||
environment: EnvironmentState,
|
||||
) -> HeatingDecision:
|
||||
"""Decide heating action using ML model.
|
||||
|
||||
Args:
|
||||
environment: Current environmental conditions
|
||||
|
||||
Returns:
|
||||
A heating decision predicted by the ML model
|
||||
"""
|
||||
_LOGGER.debug("MLDecisionStrategy.decide_heating_action called")
|
||||
_LOGGER.debug(
|
||||
f"Environment: indoor={environment.indoor_temperature}°C, "
|
||||
f"outdoor={environment.outdoor_temp}°C, "
|
||||
f"humidity={environment.indoor_humidity}%"
|
||||
)
|
||||
|
||||
# Get next scheduled timeslot for context
|
||||
next_timeslot = await self._scheduler_reader.get_next_timeslot()
|
||||
|
||||
if next_timeslot is None:
|
||||
decision = HeatingDecision(
|
||||
action=HeatingAction.NO_ACTION, reason="No scheduled timeslots found"
|
||||
)
|
||||
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
|
||||
return decision
|
||||
|
||||
# TODO: Query ML model API for action prediction
|
||||
# Example:
|
||||
# ml_prediction = await self._ml_client.predict_action(
|
||||
# environment=environment,
|
||||
# next_timeslot=next_timeslot,
|
||||
# )
|
||||
#
|
||||
# return HeatingDecision(
|
||||
# action=ml_prediction.action,
|
||||
# target_temp=ml_prediction.target_temp,
|
||||
# reason=f"ML prediction (confidence: {ml_prediction.confidence:.2%})"
|
||||
# )
|
||||
|
||||
_LOGGER.warning(
|
||||
"ML model integration not implemented yet. " "Returning NO_ACTION as placeholder."
|
||||
)
|
||||
|
||||
return HeatingDecision(
|
||||
action=HeatingAction.NO_ACTION,
|
||||
reason="ML strategy not fully implemented (requires IHP-ML-Models integration)",
|
||||
)
|
||||
|
||||
async def check_overshoot_risk(
|
||||
self,
|
||||
environment: EnvironmentState,
|
||||
current_slope: float,
|
||||
) -> HeatingDecision:
|
||||
"""Check overshoot risk using ML model.
|
||||
|
||||
Args:
|
||||
environment: Current environmental conditions
|
||||
current_slope: Current heating rate in °C/hour
|
||||
|
||||
Returns:
|
||||
Decision to stop heating if ML model predicts overshoot
|
||||
"""
|
||||
_LOGGER.debug("MLDecisionStrategy.check_overshoot_risk called")
|
||||
_LOGGER.debug(
|
||||
f"Current slope: {current_slope:.4f}°C/hour, "
|
||||
f"indoor_temp={environment.indoor_temperature}°C"
|
||||
)
|
||||
|
||||
next_timeslot = await self._scheduler_reader.get_next_timeslot()
|
||||
|
||||
if next_timeslot is None:
|
||||
decision = HeatingDecision(
|
||||
action=HeatingAction.NO_ACTION, reason="No scheduled timeslot to check against"
|
||||
)
|
||||
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
|
||||
return decision
|
||||
|
||||
# TODO: Query ML model for overshoot risk assessment
|
||||
# Example:
|
||||
# risk_assessment = await self._ml_client.assess_overshoot_risk(
|
||||
# environment=environment,
|
||||
# current_slope=current_slope,
|
||||
# target_temp=next_timeslot.target_temp,
|
||||
# target_time=next_timeslot.target_time,
|
||||
# )
|
||||
#
|
||||
# if risk_assessment.should_stop:
|
||||
# return HeatingDecision(
|
||||
# action=HeatingAction.STOP_HEATING,
|
||||
# reason=f"ML detected overshoot risk (confidence: {risk_assessment.confidence:.2%})"
|
||||
# )
|
||||
|
||||
_LOGGER.warning(
|
||||
"ML overshoot detection not implemented yet. " "Returning NO_ACTION as placeholder."
|
||||
)
|
||||
|
||||
return HeatingDecision(
|
||||
action=HeatingAction.NO_ACTION, reason="ML overshoot detection not fully implemented"
|
||||
)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""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)
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
"""Simple rule-based decision strategy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..interfaces import ILhsStorage, ISchedulerReader
|
||||
from ..interfaces.decision_strategy_interface import IDecisionStrategy
|
||||
from ..services.prediction_service import PredictionService
|
||||
from ..value_objects import (
|
||||
EnvironmentState,
|
||||
HeatingAction,
|
||||
HeatingDecision,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SimpleDecisionStrategy(IDecisionStrategy):
|
||||
"""Rule-based heating decisions without ML.
|
||||
|
||||
This strategy uses simple predictive calculations based on
|
||||
learned heating slopes. It's easier to set up as it doesn't
|
||||
require the IHP-ML-Models add-on.
|
||||
|
||||
Decision logic:
|
||||
- Uses learned heating slope (LHS) from past heating cycles
|
||||
- Calculates anticipated start time based on current conditions
|
||||
- Prevents overshooting with conservative thresholds
|
||||
|
||||
Attributes:
|
||||
_scheduler_reader: Interface to read scheduled timeslots
|
||||
_storage: Interface to access learned data
|
||||
_prediction_service: Service for prediction calculations
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scheduler_reader: ISchedulerReader,
|
||||
model_storage: ILhsStorage,
|
||||
) -> None:
|
||||
"""Initialize simple decision strategy.
|
||||
|
||||
Args:
|
||||
scheduler_reader: Implementation of scheduler reading interface
|
||||
model_storage: Implementation of model storage interface
|
||||
"""
|
||||
_LOGGER.debug("Initializing SimpleDecisionStrategy")
|
||||
self._scheduler_reader = scheduler_reader
|
||||
self._storage = model_storage
|
||||
self._prediction_service = PredictionService()
|
||||
_LOGGER.debug("SimpleDecisionStrategy initialized with scheduler and storage")
|
||||
|
||||
async def decide_heating_action(
|
||||
self,
|
||||
environment: EnvironmentState,
|
||||
) -> HeatingDecision:
|
||||
"""Decide heating action using simple rules.
|
||||
|
||||
Args:
|
||||
environment: Current environmental conditions
|
||||
|
||||
Returns:
|
||||
A heating decision based on simple predictive rules
|
||||
"""
|
||||
_LOGGER.debug("SimpleDecisionStrategy.decide_heating_action called")
|
||||
_LOGGER.debug(
|
||||
f"Environment: indoor={environment.indoor_temperature}°C, "
|
||||
f"outdoor={environment.outdoor_temp}°C, "
|
||||
f"humidity={environment.indoor_humidity}%"
|
||||
)
|
||||
|
||||
# Get next scheduled timeslot
|
||||
next_timeslot = await self._scheduler_reader.get_next_timeslot()
|
||||
|
||||
if next_timeslot is None:
|
||||
decision = HeatingDecision(
|
||||
action=HeatingAction.NO_ACTION, reason="No scheduled timeslots found"
|
||||
)
|
||||
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
|
||||
return decision
|
||||
|
||||
# Check if target temperature is already reached
|
||||
current_temp = environment.indoor_temperature
|
||||
if current_temp >= next_timeslot.target_temp:
|
||||
decision = HeatingDecision(
|
||||
action=HeatingAction.NO_ACTION,
|
||||
reason=f"Already at target temperature ({current_temp:.1f}°C >= {next_timeslot.target_temp:.1f}°C)",
|
||||
)
|
||||
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
|
||||
return decision
|
||||
|
||||
# Get learned heating slope
|
||||
lhs = await self._storage.get_learned_heating_slope()
|
||||
_LOGGER.info(f"Learned heating slope: {lhs:.4f}°C/hour")
|
||||
|
||||
# Calculate prediction
|
||||
prediction = self._prediction_service.predict_heating_time(
|
||||
current_temp=environment.indoor_temperature,
|
||||
target_temp=next_timeslot.target_temp,
|
||||
outdoor_temp=environment.outdoor_temp,
|
||||
humidity=environment.indoor_humidity,
|
||||
learned_slope=lhs,
|
||||
target_time=next_timeslot.target_time,
|
||||
cloud_coverage=environment.cloud_coverage,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
f"Prediction: anticipated_start={prediction.anticipated_start_time.isoformat()}, "
|
||||
f"duration={prediction.estimated_duration_minutes:.1f}min, "
|
||||
f"confidence={prediction.confidence_level:.2f}"
|
||||
)
|
||||
|
||||
# Decide based on anticipated start time
|
||||
now = environment.timestamp
|
||||
|
||||
if prediction.anticipated_start_time <= now < next_timeslot.target_time:
|
||||
decision = HeatingDecision(
|
||||
action=HeatingAction.START_HEATING,
|
||||
target_temp=next_timeslot.target_temp,
|
||||
reason=f"Time to start heating (anticipated start: {prediction.anticipated_start_time.isoformat()})",
|
||||
)
|
||||
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
|
||||
return decision
|
||||
elif now >= next_timeslot.target_time:
|
||||
decision = HeatingDecision(
|
||||
action=HeatingAction.NO_ACTION, reason="Schedule time has passed"
|
||||
)
|
||||
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
|
||||
return decision
|
||||
else:
|
||||
decision = HeatingDecision(
|
||||
action=HeatingAction.NO_ACTION,
|
||||
reason=f"Wait until {prediction.anticipated_start_time.isoformat()}",
|
||||
)
|
||||
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
|
||||
return decision
|
||||
|
||||
async def check_overshoot_risk(
|
||||
self,
|
||||
environment: EnvironmentState,
|
||||
current_slope: float,
|
||||
) -> HeatingDecision:
|
||||
"""Check overshoot risk using simple calculations.
|
||||
|
||||
Args:
|
||||
environment: Current environmental conditions
|
||||
current_slope: Current heating rate in °C/hour
|
||||
|
||||
Returns:
|
||||
Decision to stop heating if overshoot is detected
|
||||
"""
|
||||
_LOGGER.debug("SimpleDecisionStrategy.check_overshoot_risk called")
|
||||
_LOGGER.debug(
|
||||
f"Current slope: {current_slope:.4f}°C/hour, "
|
||||
f"indoor_temp={environment.indoor_temperature}°C"
|
||||
)
|
||||
|
||||
next_timeslot = await self._scheduler_reader.get_next_timeslot()
|
||||
|
||||
if next_timeslot is None:
|
||||
decision = HeatingDecision(
|
||||
action=HeatingAction.NO_ACTION, reason="No scheduled timeslot to check against"
|
||||
)
|
||||
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
|
||||
return decision
|
||||
|
||||
# Calculate estimated temperature at target time
|
||||
time_to_target = (
|
||||
next_timeslot.target_time - environment.timestamp
|
||||
).total_seconds() / 3600.0
|
||||
|
||||
if time_to_target <= 0:
|
||||
decision = HeatingDecision(action=HeatingAction.NO_ACTION, reason="Target time reached")
|
||||
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
|
||||
return decision
|
||||
|
||||
estimated_temp = environment.indoor_temperature + (current_slope * time_to_target)
|
||||
|
||||
# Stop if we'll overshoot by more than 0.5°C
|
||||
overshoot_threshold = next_timeslot.target_temp + 0.5
|
||||
|
||||
_LOGGER.debug(
|
||||
f"Estimated temp at target time: {estimated_temp:.1f}°C, "
|
||||
f"threshold: {overshoot_threshold:.1f}°C"
|
||||
)
|
||||
|
||||
if estimated_temp > overshoot_threshold:
|
||||
decision = HeatingDecision(
|
||||
action=HeatingAction.STOP_HEATING,
|
||||
reason=f"Overshoot risk detected (estimated: {estimated_temp:.1f}°C > threshold: {overshoot_threshold:.1f}°C)",
|
||||
)
|
||||
_LOGGER.info(f"Decision: {decision.action.value} - {decision.reason}")
|
||||
return decision
|
||||
|
||||
decision = HeatingDecision(
|
||||
action=HeatingAction.NO_ACTION,
|
||||
reason=f"No overshoot risk (estimated: {estimated_temp:.1f}°C)",
|
||||
)
|
||||
_LOGGER.debug(f"Decision: {decision.action.value} - {decision.reason}")
|
||||
return decision
|
||||
Reference in New Issue
Block a user