feat: unit-aware power sensors + ceil() for max solar self-consumption
test / test (push) Has been cancelled
test / test (push) Has been cancelled
Two related fixes for v0.1.3: 1. _read_power_w() reads each power sensor's unit_of_measurement and converts kW to W. Before this, sensors reporting in kW (Tesla integration's leomobile_charger_power = "1" kW) were treated as raw watts, causing the leftover calculation to undercount by ~1000×. In practice the algorithm sent the EV charge rate that matched "what's exporting NOW" instead of "what's available if EV pulled everything" — leaving ~1 kW of solar exported instead of routed to the car. 2. round() → math.ceil() on the amp computation. Trade-off: up to ~VOLTAGE watts of grid import per tick, in exchange for zero solar export. Maximizes PV self-consumption, accepts small grid cost as rounding noise. Tests added: kW conversion round-trip; ceil rounds up partial amps. All 57 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d73b4ec7a3
commit
7d0be3c70c
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.1.3] - 2026-05-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Power-sensor unit handling**: the coordinator now reads the `unit_of_measurement` attribute on every configured power sensor (grid import/export, net grid, EV consumption) and converts kW to W automatically. Before this fix, a sensor reporting in kW (e.g. Tesla integration's `leomobile_charger_power`) was treated as raw watts, undercounting the value by 1000×. In practice this caused the integration to systematically under-charge from solar surplus.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Solar surplus is now rounded up (ceil), not nearest (round)**, when computing the EV's charge amps from leftover_w. Trade-off: up to ~VOLTAGE watts of grid import per tick in exchange for zero solar export to grid. Goal is to maximize PV self-consumption.
|
||||||
|
|
||||||
## [0.1.2] - 2026-05-27
|
## [0.1.2] - 2026-05-27
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, time
|
from datetime import datetime, time
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -194,7 +195,11 @@ def compute_decision(s: Snapshot) -> Decision:
|
|||||||
sanitized_ev_w = s.ev_consumption_w
|
sanitized_ev_w = s.ev_consumption_w
|
||||||
|
|
||||||
leftover_w = -s.net_grid_w + sanitized_ev_w
|
leftover_w = -s.net_grid_w + sanitized_ev_w
|
||||||
raw_amps = round(leftover_w / VOLTAGE)
|
# Use ceil() so any positive surplus is fully absorbed by the EV. Trade-off:
|
||||||
|
# up to ~VOLTAGE watts of grid import per tick in exchange for zero solar
|
||||||
|
# export to grid. Goal: maximize self-consumption, accept small rounding
|
||||||
|
# cost on grid side.
|
||||||
|
raw_amps = math.ceil(leftover_w / VOLTAGE)
|
||||||
|
|
||||||
if s.ev_soc < s.target_day_soc:
|
if s.ev_soc < s.target_day_soc:
|
||||||
desired = max(MIN_AMPS, raw_amps)
|
desired = max(MIN_AMPS, raw_amps)
|
||||||
|
|||||||
@@ -71,6 +71,28 @@ class EVSolarChargerCoordinator(DataUpdateCoordinator[Decision | None]):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _read_power_w(self, entity_id: str | None) -> float | None:
|
||||||
|
"""Read a power sensor and return its value in watts.
|
||||||
|
|
||||||
|
Auto-converts based on the sensor's `unit_of_measurement` attribute:
|
||||||
|
kW / kilowatt(s) → multiplied by 1000; W / watt(s) / missing → unchanged.
|
||||||
|
This lets the integration work with any HA power sensor regardless of
|
||||||
|
whether it natively reports in W or kW.
|
||||||
|
"""
|
||||||
|
if not entity_id:
|
||||||
|
return None
|
||||||
|
state = self.hass.states.get(entity_id)
|
||||||
|
if state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = float(state.state)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
unit = str(state.attributes.get("unit_of_measurement", "W")).lower()
|
||||||
|
if unit in ("kw", "kilowatt", "kilowatts"):
|
||||||
|
value *= 1000.0
|
||||||
|
return value
|
||||||
|
|
||||||
def _read_bool(self, entity_id: str | None, on_state: str = "on") -> bool | None:
|
def _read_bool(self, entity_id: str | None, on_state: str = "on") -> bool | None:
|
||||||
if not entity_id:
|
if not entity_id:
|
||||||
return None
|
return None
|
||||||
@@ -96,12 +118,12 @@ class EVSolarChargerCoordinator(DataUpdateCoordinator[Decision | None]):
|
|||||||
return SunState.ABOVE
|
return SunState.ABOVE
|
||||||
|
|
||||||
def _read_net_grid(self) -> float | None:
|
def _read_net_grid(self) -> float | None:
|
||||||
"""Return signed net grid power: + import, - export."""
|
"""Return signed net grid power in watts: + import, - export."""
|
||||||
net = self._read_float(self.entry_data.get(CONF_NET_GRID_SENSOR))
|
net = self._read_power_w(self.entry_data.get(CONF_NET_GRID_SENSOR))
|
||||||
if net is not None:
|
if net is not None:
|
||||||
return net
|
return net
|
||||||
imp = self._read_float(self.entry_data.get(CONF_GRID_IMPORT_SENSOR))
|
imp = self._read_power_w(self.entry_data.get(CONF_GRID_IMPORT_SENSOR))
|
||||||
exp = self._read_float(self.entry_data.get(CONF_GRID_EXPORT_SENSOR))
|
exp = self._read_power_w(self.entry_data.get(CONF_GRID_EXPORT_SENSOR))
|
||||||
if imp is None or exp is None:
|
if imp is None or exp is None:
|
||||||
return None
|
return None
|
||||||
return imp - exp
|
return imp - exp
|
||||||
@@ -120,7 +142,7 @@ class EVSolarChargerCoordinator(DataUpdateCoordinator[Decision | None]):
|
|||||||
now=dt_util.now(),
|
now=dt_util.now(),
|
||||||
sun_state=self._read_sun_state(),
|
sun_state=self._read_sun_state(),
|
||||||
net_grid_w=self._read_net_grid() or 0.0,
|
net_grid_w=self._read_net_grid() or 0.0,
|
||||||
ev_consumption_w=self._read_float(self.entry_data.get(CONF_EV_CONSUMPTION_SENSOR))
|
ev_consumption_w=self._read_power_w(self.entry_data.get(CONF_EV_CONSUMPTION_SENSOR))
|
||||||
or 0.0,
|
or 0.0,
|
||||||
ev_soc=self._read_float(self.entry_data.get(CONF_EV_SOC_SENSOR)) or 0.0,
|
ev_soc=self._read_float(self.entry_data.get(CONF_EV_SOC_SENSOR)) or 0.0,
|
||||||
cable_connected=self._read_bool(self.entry_data.get(CONF_EV_CABLE_SENSOR)) or False,
|
cable_connected=self._read_bool(self.entry_data.get(CONF_EV_CABLE_SENSOR)) or False,
|
||||||
@@ -256,7 +278,7 @@ class EVSolarChargerCoordinator(DataUpdateCoordinator[Decision | None]):
|
|||||||
net = self._read_net_grid()
|
net = self._read_net_grid()
|
||||||
if net is None:
|
if net is None:
|
||||||
return "grid sensor(s) unavailable"
|
return "grid sensor(s) unavailable"
|
||||||
if self._read_float(self.entry_data.get(CONF_EV_CONSUMPTION_SENSOR)) is None:
|
if self._read_power_w(self.entry_data.get(CONF_EV_CONSUMPTION_SENSOR)) is None:
|
||||||
return "ev consumption sensor unavailable"
|
return "ev consumption sensor unavailable"
|
||||||
if self._read_float(self.entry_data.get(CONF_EV_SOC_SENSOR)) is None:
|
if self._read_float(self.entry_data.get(CONF_EV_SOC_SENSOR)) is None:
|
||||||
return "ev soc sensor unavailable"
|
return "ev soc sensor unavailable"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "ev_solar_charger",
|
"domain": "ev_solar_charger",
|
||||||
"name": "EV Solar Charger",
|
"name": "EV Solar Charger",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
|
|||||||
@@ -346,6 +346,24 @@ def test_solar_clamps_to_max_amps(base_snapshot: Snapshot) -> None:
|
|||||||
assert d.desired_amps == 14
|
assert d.desired_amps == 14
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_ceil_rounds_up_partial_amp(base_snapshot: Snapshot) -> None:
|
||||||
|
"""Partial-amp surplus must round UP (ceil), not nearest, so solar is fully absorbed.
|
||||||
|
|
||||||
|
leftover = 1151 W → 5.004 A. round() would give 5; we want 6, even though it
|
||||||
|
pulls ~229 W from grid on this tick. The trade-off: zero solar export, in
|
||||||
|
exchange for at most ~VOLTAGE watts of grid import per tick.
|
||||||
|
"""
|
||||||
|
s = dataclasses.replace(
|
||||||
|
base_snapshot,
|
||||||
|
net_grid_w=-1151.0,
|
||||||
|
ev_consumption_w=0.0,
|
||||||
|
ev_soc=60.0,
|
||||||
|
target_day_soc=80.0,
|
||||||
|
)
|
||||||
|
d = compute_decision(s)
|
||||||
|
assert d.desired_amps == 6, "expected ceil(5.004) = 6, got round behavior"
|
||||||
|
|
||||||
|
|
||||||
def test_negative_ev_consumption_treated_as_zero(base_snapshot: Snapshot) -> None:
|
def test_negative_ev_consumption_treated_as_zero(base_snapshot: Snapshot) -> None:
|
||||||
"""Negative EV consumption (bad sensor) should be ignored, not amplify leftover."""
|
"""Negative EV consumption (bad sensor) should be ignored, not amplify leftover."""
|
||||||
s = dataclasses.replace(
|
s = dataclasses.replace(
|
||||||
|
|||||||
@@ -352,3 +352,46 @@ async def test_service_call_failure_does_not_crash_tick(hass: HomeAssistant) ->
|
|||||||
assert result.sub_mode is SubMode.FORCE_MAX
|
assert result.sub_mode is SubMode.FORCE_MAX
|
||||||
# Service-call failure is logged, not propagated; coordinator still records intent
|
# Service-call failure is logged, not propagated; coordinator still records intent
|
||||||
assert coord._last_desired_amps == 14
|
assert coord._last_desired_amps == 14
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_kw_power_sensor_converted_to_w(hass: HomeAssistant) -> None:
|
||||||
|
"""Coordinator must read kW sensors as 1000x watts.
|
||||||
|
|
||||||
|
Regression for v0.1.3: Tesla integration's leomobile_charger_power reports
|
||||||
|
in kW (`unit_of_measurement: kW`). If the coordinator treated the value as
|
||||||
|
watts, leftover_w would be undercounted by ~1000x and the integration
|
||||||
|
would chronically under-charge from solar surplus.
|
||||||
|
"""
|
||||||
|
hass.states.async_set(
|
||||||
|
"sensor.ev_consumption_kw", "1", {"unit_of_measurement": "kW"}
|
||||||
|
)
|
||||||
|
hass.states.async_set("sensor.grid_import", "0", {"unit_of_measurement": "W"})
|
||||||
|
hass.states.async_set("sensor.grid_export", "2075", {"unit_of_measurement": "W"})
|
||||||
|
hass.states.async_set("sensor.ev_soc", "60", {"unit_of_measurement": "%"})
|
||||||
|
hass.states.async_set("binary_sensor.ev_cable", "on")
|
||||||
|
hass.states.async_set("device_tracker.ev", "home")
|
||||||
|
hass.states.async_set("sun.sun", "above_horizon")
|
||||||
|
|
||||||
|
entry_data = {
|
||||||
|
CONF_GRID_IMPORT_SENSOR: "sensor.grid_import",
|
||||||
|
CONF_GRID_EXPORT_SENSOR: "sensor.grid_export",
|
||||||
|
CONF_EV_CONSUMPTION_SENSOR: "sensor.ev_consumption_kw",
|
||||||
|
CONF_EV_SOC_SENSOR: "sensor.ev_soc",
|
||||||
|
CONF_EV_CABLE_SENSOR: "binary_sensor.ev_cable",
|
||||||
|
CONF_EV_LOCATION_TRACKER: "device_tracker.ev",
|
||||||
|
}
|
||||||
|
coord = EVSolarChargerCoordinator(hass=hass, entry_data=entry_data, options={})
|
||||||
|
snapshot = await coord._build_snapshot(
|
||||||
|
mode=Mode.AUTO,
|
||||||
|
enabled=True,
|
||||||
|
target_day_soc=80.0,
|
||||||
|
target_night_soc=80.0,
|
||||||
|
dinner_start=time(16, 0),
|
||||||
|
night_start=time(22, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
# "1" kW must be read as 1000 W, not 1 W
|
||||||
|
assert snapshot.ev_consumption_w == 1000.0
|
||||||
|
# leftover = -(0 - 2075) + 1000 = 3075 W → ceil(3075/230) = 14 A (clamped to MAX)
|
||||||
|
# The pre-v0.1.3 bug would have given leftover = 2076 W → ceil = 10 A.
|
||||||
|
|||||||
Reference in New Issue
Block a user