mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
Massive refactoring of sensor platform reducing core.py from 2,170 to 909 lines (58% reduction). Extracted business logic into specialized calculators and attribute builders following separation of concerns principles. Changes: - Created sensor/calculators/ package (8 specialized calculators, 1,838 lines): * base.py: Abstract BaseCalculator with coordinator access * interval.py: Single interval calculations (current/next/previous) * rolling_hour.py: 5-interval rolling windows * daily_stat.py: Calendar day min/max/avg statistics * window_24h.py: Trailing/leading 24h windows * volatility.py: Price volatility analysis * trend.py: Complex trend analysis with caching (640 lines) * timing.py: Best/peak price period timing * metadata.py: Home/metering metadata - Created sensor/attributes/ package (8 specialized modules, 1,209 lines): * Modules match calculator types for consistent organization * __init__.py: Routing logic + unified builders * Handles state presentation separately from business logic - Created sensor/chart_data.py (144 lines): * Extracted chart data export functionality from entity class * YAML parsing, service calls, metadata formatting - Created sensor/value_getters.py (276 lines): * Centralized handler mapping for all 80+ sensor types * Single source of truth for sensor routing - Extended sensor/helpers.py (+88 lines): * Added aggregate_window_data() unified aggregator * Added get_hourly_price_value() for backward compatibility * Consolidated sensor-specific helper functions - Refactored sensor/core.py (909 lines, was 2,170): * Instantiates all calculators in __init__ * Delegates value calculations to appropriate calculator * Uses unified handler methods via value_getters mapping * Minimal platform-specific logic remains (icon callbacks, entity lifecycle) - Deleted sensor/attributes.py (1,106 lines): * Functionality split into attributes/ package (8 modules) - Updated AGENTS.md: * Documented Calculator Pattern architecture * Added guidance for adding new sensors with calculation groups * Updated file organization with new package structure Architecture Benefits: - Clear separation: Calculators (business logic) vs Attributes (presentation) - Improved testability: Each calculator independently testable - Better maintainability: 21 focused modules vs monolithic file - Easy extensibility: Add sensors by choosing calculation pattern - Reusable components: Calculators and attribute builders shared across sensors Impact: Significantly improved code organization and maintainability while preserving all functionality. All 80+ sensor types continue working with cleaner, more modular architecture. Developer experience improved with logical file structure and clear separation of concerns.
188 lines
6.2 KiB
Python
188 lines
6.2 KiB
Python
"""
|
|
Sensor platform-specific helper functions.
|
|
|
|
This module contains helper functions specific to the sensor platform:
|
|
- aggregate_price_data: Calculate average price from window data
|
|
- aggregate_level_data: Aggregate price levels from intervals
|
|
- aggregate_rating_data: Aggregate price ratings from intervals
|
|
- aggregate_window_data: Unified aggregation based on value type
|
|
- get_hourly_price_value: Get price for specific hour with offset
|
|
|
|
For shared helper functions (used by both sensor and binary_sensor platforms),
|
|
see entity_utils/helpers.py:
|
|
- get_price_value: Price unit conversion
|
|
- translate_level: Price level translation
|
|
- translate_rating_level: Rating level translation
|
|
- find_rolling_hour_center_index: Rolling hour window calculations
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
from typing import TYPE_CHECKING
|
|
|
|
from custom_components.tibber_prices.entity_utils.helpers import get_price_value
|
|
from custom_components.tibber_prices.utils.price import (
|
|
aggregate_price_levels,
|
|
aggregate_price_rating,
|
|
)
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
|
|
def aggregate_price_data(window_data: list[dict]) -> float | None:
|
|
"""
|
|
Calculate average price from window data.
|
|
|
|
Args:
|
|
window_data: List of price interval dictionaries with 'total' key
|
|
|
|
Returns:
|
|
Average price in minor currency units (cents/øre), or None if no prices
|
|
|
|
"""
|
|
prices = [float(i["total"]) for i in window_data if "total" in i]
|
|
if not prices:
|
|
return None
|
|
# Return in minor currency units (cents/øre)
|
|
return round((sum(prices) / len(prices)) * 100, 2)
|
|
|
|
|
|
def aggregate_level_data(window_data: list[dict]) -> str | None:
|
|
"""
|
|
Aggregate price levels from window data.
|
|
|
|
Args:
|
|
window_data: List of price interval dictionaries with 'level' key
|
|
|
|
Returns:
|
|
Aggregated price level (lowercase), or None if no levels
|
|
|
|
"""
|
|
levels = [i["level"] for i in window_data if "level" in i]
|
|
if not levels:
|
|
return None
|
|
aggregated = aggregate_price_levels(levels)
|
|
return aggregated.lower() if aggregated else None
|
|
|
|
|
|
def aggregate_rating_data(
|
|
window_data: list[dict],
|
|
threshold_low: float,
|
|
threshold_high: float,
|
|
) -> str | None:
|
|
"""
|
|
Aggregate price ratings from window data.
|
|
|
|
Args:
|
|
window_data: List of price interval dictionaries with 'difference' and 'rating_level'
|
|
threshold_low: Low threshold for rating calculation
|
|
threshold_high: High threshold for rating calculation
|
|
|
|
Returns:
|
|
Aggregated price rating (lowercase), or None if no ratings
|
|
|
|
"""
|
|
differences = [i["difference"] for i in window_data if "difference" in i and "rating_level" in i]
|
|
if not differences:
|
|
return None
|
|
|
|
aggregated, _ = aggregate_price_rating(differences, threshold_low, threshold_high)
|
|
return aggregated.lower() if aggregated else None
|
|
|
|
|
|
def aggregate_window_data(
|
|
window_data: list[dict],
|
|
value_type: str,
|
|
threshold_low: float,
|
|
threshold_high: float,
|
|
) -> str | float | None:
|
|
"""
|
|
Aggregate data from multiple intervals based on value type.
|
|
|
|
Unified helper that routes to appropriate aggregation function.
|
|
|
|
Args:
|
|
window_data: List of price interval dictionaries
|
|
value_type: Type of value to aggregate ('price', 'level', or 'rating')
|
|
threshold_low: Low threshold for rating calculation
|
|
threshold_high: High threshold for rating calculation
|
|
|
|
Returns:
|
|
Aggregated value (price as float, level/rating as str), or None if no data
|
|
|
|
"""
|
|
# Map value types to aggregation functions
|
|
aggregators: dict[str, Callable] = {
|
|
"price": lambda data: aggregate_price_data(data),
|
|
"level": lambda data: aggregate_level_data(data),
|
|
"rating": lambda data: aggregate_rating_data(data, threshold_low, threshold_high),
|
|
}
|
|
|
|
aggregator = aggregators.get(value_type)
|
|
if aggregator:
|
|
return aggregator(window_data)
|
|
return None
|
|
|
|
|
|
def get_hourly_price_value(
|
|
price_info: dict,
|
|
*,
|
|
hour_offset: int,
|
|
in_euro: bool,
|
|
) -> float | None:
|
|
"""
|
|
Get price for current hour or with offset.
|
|
|
|
Legacy helper for hourly price access (not used by Calculator Pattern).
|
|
Kept for potential backward compatibility.
|
|
|
|
Args:
|
|
price_info: Price information dict with 'today' and 'tomorrow' keys
|
|
hour_offset: Hour offset from current time (positive=future, negative=past)
|
|
in_euro: If True, return price in major currency (EUR), else minor (cents/øre)
|
|
|
|
Returns:
|
|
Price value, or None if not found
|
|
|
|
"""
|
|
# Use HomeAssistant's dt_util to get the current time in the user's timezone
|
|
now = dt_util.now()
|
|
|
|
# Calculate the exact target datetime (not just the hour)
|
|
# This properly handles day boundaries
|
|
target_datetime = now.replace(microsecond=0) + timedelta(hours=hour_offset)
|
|
target_hour = target_datetime.hour
|
|
target_date = target_datetime.date()
|
|
|
|
# Determine which day's data we need
|
|
day_key = "tomorrow" if target_date > now.date() else "today"
|
|
|
|
for price_data in price_info.get(day_key, []):
|
|
# Parse the timestamp and convert to local time
|
|
starts_at = dt_util.parse_datetime(price_data["startsAt"])
|
|
if starts_at is None:
|
|
continue
|
|
|
|
# Make sure it's in the local timezone for proper comparison
|
|
starts_at = dt_util.as_local(starts_at)
|
|
|
|
# Compare using both hour and date for accuracy
|
|
if starts_at.hour == target_hour and starts_at.date() == target_date:
|
|
return get_price_value(float(price_data["total"]), in_euro=in_euro)
|
|
|
|
# If we didn't find the price in the expected day's data, check the other day
|
|
# This is a fallback for potential edge cases
|
|
other_day_key = "today" if day_key == "tomorrow" else "tomorrow"
|
|
for price_data in price_info.get(other_day_key, []):
|
|
starts_at = dt_util.parse_datetime(price_data["startsAt"])
|
|
if starts_at is None:
|
|
continue
|
|
|
|
starts_at = dt_util.as_local(starts_at)
|
|
if starts_at.hour == target_hour and starts_at.date() == target_date:
|
|
return get_price_value(float(price_data["total"]), in_euro=in_euro)
|
|
|
|
return None
|