fix: solar forecast charging
This commit is contained in:
@@ -10,8 +10,15 @@ from .const import (
|
||||
CONF_CHARGER_ID,
|
||||
CHARGING_PROFILE_ALL_SURPLUS,
|
||||
CHARGING_PROFILE_CONSERVATIVE,
|
||||
CHARGING_PROFILE_FORECAST,
|
||||
CHARGING_PROFILE_FORECAST_CONSERVATIVE,
|
||||
CONF_CHARGING_PROFILE,
|
||||
CONF_DEAD_BAND_RESYNC_MINUTES,
|
||||
CONF_FORECAST_CONFIDENCE,
|
||||
CONF_FORECAST_LOOKAHEAD_MINUTES,
|
||||
CONF_FORECAST_SENSOR,
|
||||
DEFAULT_FORECAST_CONFIDENCE,
|
||||
DEFAULT_FORECAST_LOOKAHEAD_MINUTES,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_EV_CHARGING_SENSOR,
|
||||
CONF_HOUSE_LOAD_SENSOR,
|
||||
@@ -43,11 +50,21 @@ _CHARGING_PROFILE_SELECTOR = selector.SelectSelector(
|
||||
options=[
|
||||
selector.SelectOptionDict(value=CHARGING_PROFILE_CONSERVATIVE, label="Conservative"),
|
||||
selector.SelectOptionDict(value=CHARGING_PROFILE_ALL_SURPLUS, label="All Surplus"),
|
||||
selector.SelectOptionDict(value=CHARGING_PROFILE_FORECAST, label="Forecast look-ahead"),
|
||||
selector.SelectOptionDict(value=CHARGING_PROFILE_FORECAST_CONSERVATIVE, label="Forecast surplus"),
|
||||
],
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
)
|
||||
|
||||
_CONFIDENCE_SELECTOR = selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(min=50, max=100, step=5, unit_of_measurement="%", mode=selector.NumberSelectorMode.SLIDER)
|
||||
)
|
||||
|
||||
_LOOKAHEAD_SELECTOR = selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(min=5, max=120, step=5, unit_of_measurement="min", mode=selector.NumberSelectorMode.BOX)
|
||||
)
|
||||
|
||||
_POWER_SENSOR_SELECTOR = selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="sensor", device_class="power")
|
||||
)
|
||||
@@ -103,6 +120,19 @@ def _options_schema(hass: HomeAssistant, current: dict) -> vol.Schema:
|
||||
CONF_DEAD_BAND_RESYNC_MINUTES,
|
||||
default=current.get(CONF_DEAD_BAND_RESYNC_MINUTES, DEFAULT_DEAD_BAND_RESYNC_MINUTES),
|
||||
): _DEAD_BAND_RESYNC_SELECTOR,
|
||||
# ── Forecast profiles ─────────────────────────────────────────────────
|
||||
vol.Optional(CONF_FORECAST_SENSOR): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(integration="forecast_solar")
|
||||
),
|
||||
vol.Required(
|
||||
CONF_FORECAST_CONFIDENCE,
|
||||
default=current.get(CONF_FORECAST_CONFIDENCE, DEFAULT_FORECAST_CONFIDENCE),
|
||||
): _CONFIDENCE_SELECTOR,
|
||||
vol.Required(
|
||||
CONF_FORECAST_LOOKAHEAD_MINUTES,
|
||||
default=current.get(CONF_FORECAST_LOOKAHEAD_MINUTES, DEFAULT_FORECAST_LOOKAHEAD_MINUTES),
|
||||
): _LOOKAHEAD_SELECTOR,
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
vol.Optional(CONF_NOTIFY_TARGET): _notify_selector(_notify_services(hass)),
|
||||
})
|
||||
|
||||
|
||||
@@ -32,8 +32,18 @@ DEFAULT_STOP_GRACE_MINUTES = 5
|
||||
CONF_CHARGING_PROFILE = "charging_profile"
|
||||
CHARGING_PROFILE_CONSERVATIVE = "conservative"
|
||||
CHARGING_PROFILE_ALL_SURPLUS = "all_surplus"
|
||||
CHARGING_PROFILE_FORECAST = "forecast"
|
||||
CHARGING_PROFILE_FORECAST_CONSERVATIVE = "forecast_conservative"
|
||||
DEFAULT_CHARGING_PROFILE = CHARGING_PROFILE_CONSERVATIVE
|
||||
|
||||
# Forecast profile settings
|
||||
CONF_FORECAST_SENSOR = "forecast_sensor"
|
||||
CONF_FORECAST_CONFIDENCE = "forecast_confidence" # percent, 0-100
|
||||
CONF_FORECAST_LOOKAHEAD_MINUTES = "forecast_lookahead_minutes"
|
||||
|
||||
DEFAULT_FORECAST_CONFIDENCE = 80 # percent
|
||||
DEFAULT_FORECAST_LOOKAHEAD_MINUTES = 30
|
||||
|
||||
CONF_DEAD_BAND_RESYNC_MINUTES = "dead_band_resync_minutes"
|
||||
DEFAULT_DEAD_BAND_RESYNC_MINUTES = 1
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -13,7 +14,14 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
from .const import (
|
||||
CHARGING_PROFILE_ALL_SURPLUS,
|
||||
CHARGING_PROFILE_CONSERVATIVE,
|
||||
CHARGING_PROFILE_FORECAST,
|
||||
CHARGING_PROFILE_FORECAST_CONSERVATIVE,
|
||||
CONF_CHARGING_PROFILE,
|
||||
CONF_FORECAST_CONFIDENCE,
|
||||
CONF_FORECAST_LOOKAHEAD_MINUTES,
|
||||
CONF_FORECAST_SENSOR,
|
||||
DEFAULT_FORECAST_CONFIDENCE,
|
||||
DEFAULT_FORECAST_LOOKAHEAD_MINUTES,
|
||||
CONF_CHARGER_ID,
|
||||
CONF_DEAD_BAND_RESYNC_MINUTES,
|
||||
CONF_DEVICE_ID,
|
||||
@@ -112,6 +120,75 @@ class EaseeSolarCoordinator(DataUpdateCoordinator):
|
||||
minutes = self._entry.options.get(CONF_DEAD_BAND_RESYNC_MINUTES, DEFAULT_DEAD_BAND_RESYNC_MINUTES)
|
||||
return timedelta(minutes=minutes)
|
||||
|
||||
def _w_at_lookahead(self, watts: dict) -> float | None:
|
||||
"""Find the watt value in a {timestamp: W} dict nearest to now + lookahead_minutes.
|
||||
|
||||
Handles both naive local-time strings ("YYYY-MM-DD HH:MM:SS") and
|
||||
ISO strings with timezone offset, plus datetime keys serialised by HA.
|
||||
"""
|
||||
lookahead = self._entry.options.get(CONF_FORECAST_LOOKAHEAD_MINUTES, DEFAULT_FORECAST_LOOKAHEAD_MINUTES)
|
||||
try:
|
||||
tz = ZoneInfo(self.hass.config.time_zone)
|
||||
except Exception:
|
||||
tz = timezone.utc
|
||||
target = datetime.now(tz) + timedelta(minutes=lookahead)
|
||||
|
||||
best_delta: float | None = None
|
||||
best_w: float | None = None
|
||||
for ts_key, w in watts.items():
|
||||
try:
|
||||
if isinstance(ts_key, datetime):
|
||||
ts = ts_key if ts_key.tzinfo else ts_key.replace(tzinfo=tz)
|
||||
else:
|
||||
ts = datetime.fromisoformat(str(ts_key))
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=tz)
|
||||
except ValueError:
|
||||
continue
|
||||
delta = abs((ts - target).total_seconds())
|
||||
if best_delta is None or delta < best_delta:
|
||||
best_delta = delta
|
||||
best_w = float(w)
|
||||
return best_w
|
||||
|
||||
def _get_raw_forecast_w(self) -> float | None:
|
||||
"""Return raw forecast power from the Forecast.Solar HA integration sensor.
|
||||
|
||||
Prefers the 'watts' attribute (dict of period→W) for a true look-ahead.
|
||||
Falls back to the sensor state value when the attribute is absent.
|
||||
"""
|
||||
entity_id = self._entry.options.get(CONF_FORECAST_SENSOR)
|
||||
if not entity_id:
|
||||
return None
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state is None or state.state in ("unknown", "unavailable"):
|
||||
return None
|
||||
|
||||
watts = state.attributes.get("watts")
|
||||
if isinstance(watts, dict) and watts:
|
||||
w = self._w_at_lookahead(watts)
|
||||
if w is not None:
|
||||
return w
|
||||
|
||||
try:
|
||||
return float(state.state)
|
||||
except ValueError:
|
||||
_LOGGER.warning("Forecast sensor %s has non-numeric state: %s", entity_id, state.state)
|
||||
return None
|
||||
|
||||
def _get_adjusted_forecast_w(self) -> float | None:
|
||||
raw = self._get_raw_forecast_w()
|
||||
if raw is None:
|
||||
return None
|
||||
confidence = self._entry.options.get(CONF_FORECAST_CONFIDENCE, DEFAULT_FORECAST_CONFIDENCE) / 100
|
||||
adjusted = raw * confidence
|
||||
lookahead = self._entry.options.get(CONF_FORECAST_LOOKAHEAD_MINUTES, DEFAULT_FORECAST_LOOKAHEAD_MINUTES)
|
||||
_LOGGER.debug(
|
||||
"Forecast %.0f W × %.0f%% confidence = %.0f W (lookahead %d min)",
|
||||
raw, confidence * 100, adjusted, lookahead,
|
||||
)
|
||||
return adjusted
|
||||
|
||||
def _get_solar_excess_w(self) -> tuple[float | None, float | None, float | None, float | None]:
|
||||
profile = self._entry.options.get(CONF_CHARGING_PROFILE, DEFAULT_CHARGING_PROFILE)
|
||||
production = self._rolling_average(self._production_sensor)
|
||||
@@ -122,10 +199,35 @@ class EaseeSolarCoordinator(DataUpdateCoordinator):
|
||||
return None, None, house_load, ev_charging
|
||||
|
||||
if profile == CHARGING_PROFILE_ALL_SURPLUS:
|
||||
# Allocate all solar production to EV; house load draws from grid.
|
||||
surplus = production
|
||||
else:
|
||||
assert profile == CHARGING_PROFILE_CONSERVATIVE
|
||||
|
||||
elif profile == CHARGING_PROFILE_FORECAST:
|
||||
# Conservative surplus first; if below threshold, check whether
|
||||
# the confidence-adjusted forecast justifies an early start.
|
||||
actual = production - house_load - ev_charging
|
||||
forecast = self._get_adjusted_forecast_w()
|
||||
if forecast is not None and forecast > actual:
|
||||
_LOGGER.debug(
|
||||
"Forecast profile: using forecast %.0f W instead of actual %.0f W",
|
||||
forecast, actual,
|
||||
)
|
||||
surplus = forecast
|
||||
else:
|
||||
surplus = actual
|
||||
|
||||
elif profile == CHARGING_PROFILE_FORECAST_CONSERVATIVE:
|
||||
# Like Forecast but subtracts current house load and EV draw from the
|
||||
# forecast value — earlier start than Conservative, more cautious than
|
||||
# plain Forecast which ignores current consumption entirely.
|
||||
forecast = self._get_adjusted_forecast_w()
|
||||
if forecast is not None:
|
||||
surplus = forecast - house_load - ev_charging
|
||||
else:
|
||||
surplus = production - house_load - ev_charging
|
||||
|
||||
else: # CHARGING_PROFILE_CONSERVATIVE (default) or unrecognised value
|
||||
if profile != CHARGING_PROFILE_CONSERVATIVE:
|
||||
_LOGGER.warning("Unknown charging profile %r — falling back to conservative", profile)
|
||||
surplus = production - house_load - ev_charging
|
||||
|
||||
return surplus, production, house_load, ev_charging
|
||||
@@ -233,36 +335,43 @@ class EaseeSolarCoordinator(DataUpdateCoordinator):
|
||||
async def _async_update_data(self) -> dict:
|
||||
try:
|
||||
solar_excess_w, production_w, house_load_w, ev_charging_w = self._get_solar_excess_w()
|
||||
raw_target = (
|
||||
solar_excess_to_charger_current(solar_excess_w)
|
||||
if self.solar_charging_enabled and solar_excess_w is not None
|
||||
else 0
|
||||
)
|
||||
target_current_a = self._apply_stop_hysteresis(raw_target)
|
||||
|
||||
if self._charger_is_sleeping():
|
||||
# Session finished/waiting: zero out once then stop adjusting.
|
||||
if self._last_sent_current != 0:
|
||||
await self._async_set_charger_current(0)
|
||||
self._last_sent_current = 0
|
||||
self._last_sent_at = datetime.now(timezone.utc)
|
||||
if not self._charger_allows_control() and not self._charger_is_sleeping():
|
||||
# Disconnected / error / unknown — no amps to calculate.
|
||||
# Clear any running grace period so it doesn't bleed into the next session.
|
||||
self._below_threshold_since = None
|
||||
target_current_a = 0
|
||||
elif self._should_send(target_current_a) and self._charger_allows_control():
|
||||
prev = self._last_sent_current
|
||||
await self._async_set_charger_current(target_current_a)
|
||||
self._last_sent_current = target_current_a
|
||||
self._last_sent_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
raw_target = (
|
||||
solar_excess_to_charger_current(solar_excess_w)
|
||||
if self.solar_charging_enabled and solar_excess_w is not None
|
||||
else 0
|
||||
)
|
||||
target_current_a = self._apply_stop_hysteresis(raw_target)
|
||||
|
||||
if prev in (None, 0) and target_current_a > 0:
|
||||
await self._async_notify(
|
||||
"Solar charging started",
|
||||
f"Solar excess is sufficient — charging at {target_current_a} A.",
|
||||
)
|
||||
elif prev and prev > 0 and target_current_a == 0:
|
||||
await self._async_notify(
|
||||
"Solar charging stopped",
|
||||
"Solar excess dropped below the minimum threshold.",
|
||||
)
|
||||
if self._charger_is_sleeping():
|
||||
# Session finished/waiting: zero out once then stop adjusting.
|
||||
if self._last_sent_current != 0:
|
||||
await self._async_set_charger_current(0)
|
||||
self._last_sent_current = 0
|
||||
self._last_sent_at = datetime.now(timezone.utc)
|
||||
target_current_a = 0
|
||||
elif self._should_send(target_current_a) and self._charger_allows_control():
|
||||
prev = self._last_sent_current
|
||||
await self._async_set_charger_current(target_current_a)
|
||||
self._last_sent_current = target_current_a
|
||||
self._last_sent_at = datetime.now(timezone.utc)
|
||||
|
||||
if prev in (None, 0) and target_current_a > 0:
|
||||
await self._async_notify(
|
||||
"Solar charging started",
|
||||
f"Solar excess is sufficient — charging at {target_current_a} A.",
|
||||
)
|
||||
elif prev and prev > 0 and target_current_a == 0:
|
||||
await self._async_notify(
|
||||
"Solar charging stopped",
|
||||
"Solar excess dropped below the minimum threshold.",
|
||||
)
|
||||
|
||||
return {
|
||||
"charger_id": self.charger_id,
|
||||
|
||||
@@ -17,12 +17,18 @@
|
||||
"charging_profile": "Charging profile",
|
||||
"stop_grace_minutes": "Stop grace period",
|
||||
"dead_band_resync_minutes": "Dead-band resync interval",
|
||||
"forecast_sensor": "Forecast.Solar sensor",
|
||||
"forecast_confidence": "Forecast confidence",
|
||||
"forecast_lookahead_minutes": "Look-ahead window",
|
||||
"notify_target": "Notification target"
|
||||
},
|
||||
"data_description": {
|
||||
"charging_profile": "Conservative: charge only on true solar surplus (production − house load − current EV draw). All Surplus: allocate all solar production to the EV; house load draws from the grid.",
|
||||
"charging_profile": "Conservative: charge only on true solar surplus (production − house load − current EV draw). All Surplus: allocate all solar production to the EV; house load draws from the grid. Forecast look-ahead: start charging early using the full confidence-adjusted forecast as the surplus. Forecast surplus: same but subtracts current house load and EV draw — more cautious early start.",
|
||||
"stop_grace_minutes": "How many minutes to keep charging after solar excess drops below the minimum threshold, before stopping the charger.",
|
||||
"dead_band_resync_minutes": "How often to re-send the current charging level when within the dead-band, to track gradual solar drift.",
|
||||
"forecast_sensor": "Pick a power sensor from your Forecast.Solar integration. If the sensor exposes a 'watts' attribute the look-ahead window is used to find the matching period; otherwise the sensor state is used directly.",
|
||||
"forecast_confidence": "Scale the forecast value by this factor before comparing to the charging threshold. At 80%, a 7000 W forecast is treated as 5600 W.",
|
||||
"forecast_lookahead_minutes": "How many minutes ahead to look in the Forecast.Solar power curve when deciding to start early.",
|
||||
"notify_target": "Name of a notify service to receive start/stop alerts (e.g. mobile_app_my_phone). Leave empty to disable notifications."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user