Skip to content

Commit 96c53bd

Browse files
committed
Enhance Heating & Cooling Degree Days integration with weather entity support
- Added support for weather entities to enable forecast-based degree days estimates. - Introduced new sensors for estimated HDD/CDD for today and tomorrow. - Implemented automatic migration of existing entities to a new unique_id format to prevent conflicts. - Updated configuration flow to validate weather entities and handle reconfiguration. - Improved error handling and logging for weather entity issues. - Added translations and documentation updates for new features.
1 parent 8083612 commit 96c53bd

File tree

12 files changed

+1209
-13
lines changed

12 files changed

+1209
-13
lines changed

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
- Weather entity support for forecast-based degree days estimates
12+
- New sensors: HDD/CDD Estimated Today (combines actual + forecast data)
13+
- New sensors: HDD/CDD Estimated Tomorrow (forecast-based)
14+
- Reconfiguration flow to update integration settings
15+
- Repairs platform to handle weather entity regression (no longer supports hourly forecasts)
16+
- Automatic refresh of forecast sensors when weather entity updates
17+
18+
### Changed
19+
- Unique IDs now include entry_id to prevent conflicts with multiple instances
20+
- Forecast sensors values are rounded to 1 decimal place
21+
- Automatic migration of existing entities to new unique_id format (preserves history)
22+
23+
### Fixed
24+
- Fixed duplicate unique_id errors when multiple integration instances are configured
25+
826
## [1.0.2] - 2025-11-05
927

1028
### Added

custom_components/heating_cooling_degree_days/__init__.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
from homeassistant.config_entries import ConfigEntry
66
from homeassistant.const import Platform
7-
from homeassistant.core import HomeAssistant
7+
from homeassistant.core import HomeAssistant, callback
8+
from homeassistant.helpers.event import async_track_state_change
89

910
from .const import (
1011
CONF_BASE_TEMPERATURE,
@@ -13,12 +14,14 @@
1314
CONF_INCLUDE_WEEKLY,
1415
CONF_TEMPERATURE_SENSOR,
1516
CONF_TEMPERATURE_UNIT,
17+
CONF_WEATHER_ENTITY,
1618
DEFAULT_INCLUDE_COOLING,
1719
DEFAULT_INCLUDE_MONTHLY,
1820
DEFAULT_INCLUDE_WEEKLY,
1921
DOMAIN,
2022
)
2123
from .coordinator import HDDDataUpdateCoordinator
24+
from .migrations import async_migrate_entity_unique_ids
2225

2326
_LOGGER = logging.getLogger(__name__)
2427

@@ -71,6 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
7174
include_cooling=include_cooling,
7275
include_weekly=include_weekly,
7376
include_monthly=include_monthly,
77+
weather_entity=entry.data.get(CONF_WEATHER_ENTITY),
7478
)
7579

7680
# Load stored data before first refresh
@@ -84,6 +88,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
8488
hass.data.setdefault(DOMAIN, {})
8589
hass.data[DOMAIN][entry.entry_id] = coordinator
8690

91+
# Migrate old entities with old unique_id format to new format
92+
# This must be done BEFORE setting up platforms to avoid conflicts
93+
await async_migrate_entity_unique_ids(hass, entry)
94+
95+
# Set up listener for weather entity changes if configured
96+
weather_entity = entry.data.get(CONF_WEATHER_ENTITY)
97+
if weather_entity:
98+
99+
@callback
100+
def async_weather_state_changed(entity_id, old_state, new_state):
101+
"""Handle weather entity state changes."""
102+
if new_state is None:
103+
return
104+
# Trigger coordinator refresh when weather forecast updates
105+
_LOGGER.debug(
106+
"Weather entity %s state changed, triggering coordinator refresh",
107+
entity_id,
108+
)
109+
hass.async_create_task(coordinator.async_request_refresh())
110+
111+
# Listen for changes to the weather entity
112+
async_track_state_change(hass, weather_entity, async_weather_state_changed)
113+
_LOGGER.debug(
114+
"Registered state change listener for weather entity %s", weather_entity
115+
)
116+
87117
# Set up all the platforms
88118
_LOGGER.debug("Setting up platforms: %s", PLATFORMS)
89119
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

custom_components/heating_cooling_degree_days/calculations.py

Lines changed: 241 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Heating and Cooling Degree Days calculation functions."""
22

3-
from datetime import datetime
3+
from datetime import datetime, timedelta
44
import logging
55

66
from homeassistant.components.recorder import get_instance
77
from homeassistant.components.recorder.history import get_significant_states
88
from homeassistant.core import HomeAssistant
9+
from homeassistant.util import dt as dt_util
910

1011
_LOGGER = logging.getLogger(__name__)
1112

@@ -304,3 +305,242 @@ async def async_calculate_cdd(
304305

305306
_LOGGER.debug("CDD calculation result: %.2f degree-days", result)
306307
return result
308+
309+
310+
def calculate_hdd_from_forecast(
311+
forecast_data: list[dict], base_temp: float, start_time: datetime, end_time: datetime
312+
) -> float:
313+
"""Calculate HDD from weather forecast data.
314+
315+
Uses forecast entries that fall within the specified time range.
316+
For each forecast entry, estimates HDD based on temperature and templow.
317+
318+
Args:
319+
forecast_data: List of forecast dictionaries with 'datetime', 'temperature', 'templow'
320+
base_temp: Base temperature for HDD calculation
321+
start_time: Start of the period to calculate
322+
end_time: End of the period to calculate
323+
324+
Returns:
325+
float: Calculated HDD value rounded to 1 decimal place
326+
"""
327+
if not forecast_data:
328+
_LOGGER.debug("No forecast data provided for HDD calculation")
329+
return 0
330+
331+
total_hdd = 0
332+
used_forecasts = 0
333+
334+
for forecast in forecast_data:
335+
# Get forecast datetime - handle both 'datetime' and 'dt' keys
336+
forecast_dt = forecast.get("datetime") or forecast.get("dt")
337+
if not forecast_dt:
338+
continue
339+
340+
# Convert to datetime if it's a string
341+
if isinstance(forecast_dt, str):
342+
try:
343+
forecast_dt = dt_util.parse_datetime(forecast_dt)
344+
except (ValueError, TypeError):
345+
_LOGGER.warning("Could not parse forecast datetime: %s", forecast_dt)
346+
continue
347+
348+
# Skip if forecast is outside the time range
349+
if forecast_dt < start_time or forecast_dt >= end_time:
350+
continue
351+
352+
# Get temperature - for hourly forecasts, use temperature directly
353+
# For daily forecasts, use templow and temperature average
354+
temp = forecast.get("temperature")
355+
templow = forecast.get("templow")
356+
357+
if temp is None:
358+
continue
359+
360+
# For hourly forecasts, use temperature directly
361+
# For daily forecasts (with templow), use average
362+
if templow is not None:
363+
avg_temp = (templow + temp) / 2
364+
else:
365+
avg_temp = temp
366+
367+
# Calculate HDD for this forecast period
368+
# Assume each forecast represents approximately 1 hour
369+
# (this is a simplification - actual duration may vary)
370+
duration_days = 1.0 / 24.0 # 1 hour in days
371+
372+
# Calculate deficit from base temperature
373+
deficit = max(0, base_temp - avg_temp)
374+
forecast_hdd = deficit * duration_days
375+
376+
total_hdd += forecast_hdd
377+
used_forecasts += 1
378+
379+
_LOGGER.debug(
380+
"Calculated HDD from %d forecast entries: %.1f degree-days",
381+
used_forecasts,
382+
total_hdd,
383+
)
384+
385+
return round(total_hdd, 1)
386+
387+
388+
def calculate_cdd_from_forecast(
389+
forecast_data: list[dict], base_temp: float, start_time: datetime, end_time: datetime
390+
) -> float:
391+
"""Calculate CDD from weather forecast data.
392+
393+
Uses forecast entries that fall within the specified time range.
394+
For each forecast entry, estimates CDD based on temperature and templow.
395+
396+
Args:
397+
forecast_data: List of forecast dictionaries with 'datetime', 'temperature', 'templow'
398+
base_temp: Base temperature for CDD calculation
399+
start_time: Start of the period to calculate
400+
end_time: End of the period to calculate
401+
402+
Returns:
403+
float: Calculated CDD value rounded to 1 decimal place
404+
"""
405+
if not forecast_data:
406+
_LOGGER.debug("No forecast data provided for CDD calculation")
407+
return 0
408+
409+
total_cdd = 0
410+
used_forecasts = 0
411+
412+
for forecast in forecast_data:
413+
# Get forecast datetime - handle both 'datetime' and 'dt' keys
414+
forecast_dt = forecast.get("datetime") or forecast.get("dt")
415+
if not forecast_dt:
416+
continue
417+
418+
# Convert to datetime if it's a string
419+
if isinstance(forecast_dt, str):
420+
try:
421+
forecast_dt = dt_util.parse_datetime(forecast_dt)
422+
except (ValueError, TypeError):
423+
_LOGGER.warning("Could not parse forecast datetime: %s", forecast_dt)
424+
continue
425+
426+
# Skip if forecast is outside the time range
427+
if forecast_dt < start_time or forecast_dt >= end_time:
428+
continue
429+
430+
# Get temperature - for hourly forecasts, use temperature directly
431+
# For daily forecasts, use templow and temperature average
432+
temp = forecast.get("temperature")
433+
templow = forecast.get("templow")
434+
435+
if temp is None:
436+
continue
437+
438+
# For hourly forecasts, use temperature directly
439+
# For daily forecasts (with templow), use average
440+
if templow is not None:
441+
avg_temp = (templow + temp) / 2
442+
else:
443+
avg_temp = temp
444+
445+
# Calculate CDD for this forecast period
446+
# Assume each forecast represents approximately 1 hour
447+
duration_days = 1.0 / 24.0 # 1 hour in days
448+
449+
# Calculate excess above base temperature
450+
excess = max(0, avg_temp - base_temp)
451+
forecast_cdd = excess * duration_days
452+
453+
total_cdd += forecast_cdd
454+
used_forecasts += 1
455+
456+
_LOGGER.debug(
457+
"Calculated CDD from %d forecast entries: %.1f degree-days",
458+
used_forecasts,
459+
total_cdd,
460+
)
461+
462+
return round(total_cdd, 1)
463+
464+
465+
def combine_actual_and_forecast_hdd(
466+
actual_readings: list[tuple[datetime, float]],
467+
forecast_data: list[dict],
468+
base_temp: float,
469+
actual_end_time: datetime,
470+
forecast_end_time: datetime,
471+
) -> float:
472+
"""Combine actual temperature readings with forecast data for HDD calculation.
473+
474+
Calculates HDD from actual readings up to actual_end_time, then adds
475+
estimated HDD from forecast data for the remaining period.
476+
477+
Args:
478+
actual_readings: List of (timestamp, temperature) tuples from actual sensor
479+
forecast_data: List of forecast dictionaries
480+
base_temp: Base temperature for HDD calculation
481+
actual_end_time: End time for actual readings (start of forecast period)
482+
forecast_end_time: End time for forecast period
483+
484+
Returns:
485+
float: Combined HDD value rounded to 1 decimal place
486+
"""
487+
# Calculate HDD from actual readings
488+
actual_hdd = calculate_hdd_from_readings(actual_readings, base_temp)
489+
490+
# Calculate HDD from forecast for remaining period
491+
forecast_hdd = calculate_hdd_from_forecast(
492+
forecast_data, base_temp, actual_end_time, forecast_end_time
493+
)
494+
495+
total_hdd = actual_hdd + forecast_hdd
496+
497+
_LOGGER.debug(
498+
"Combined HDD: %.1f (actual) + %.1f (forecast) = %.1f",
499+
actual_hdd,
500+
forecast_hdd,
501+
total_hdd,
502+
)
503+
504+
return round(total_hdd, 1)
505+
506+
507+
def combine_actual_and_forecast_cdd(
508+
actual_readings: list[tuple[datetime, float]],
509+
forecast_data: list[dict],
510+
base_temp: float,
511+
actual_end_time: datetime,
512+
forecast_end_time: datetime,
513+
) -> float:
514+
"""Combine actual temperature readings with forecast data for CDD calculation.
515+
516+
Calculates CDD from actual readings up to actual_end_time, then adds
517+
estimated CDD from forecast data for the remaining period.
518+
519+
Args:
520+
actual_readings: List of (timestamp, temperature) tuples from actual sensor
521+
forecast_data: List of forecast dictionaries
522+
base_temp: Base temperature for CDD calculation
523+
actual_end_time: End time for actual readings (start of forecast period)
524+
forecast_end_time: End time for forecast period
525+
526+
Returns:
527+
float: Combined CDD value rounded to 1 decimal place
528+
"""
529+
# Calculate CDD from actual readings
530+
actual_cdd = calculate_cdd_from_readings(actual_readings, base_temp)
531+
532+
# Calculate CDD from forecast for remaining period
533+
forecast_cdd = calculate_cdd_from_forecast(
534+
forecast_data, base_temp, actual_end_time, forecast_end_time
535+
)
536+
537+
total_cdd = actual_cdd + forecast_cdd
538+
539+
_LOGGER.debug(
540+
"Combined CDD: %.1f (actual) + %.1f (forecast) = %.1f",
541+
actual_cdd,
542+
forecast_cdd,
543+
total_cdd,
544+
)
545+
546+
return round(total_cdd, 1)

0 commit comments

Comments
 (0)