New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -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",
]
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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