feat: unit-aware power sensors + ceil() for max solar self-consumption
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:
naps62
2026-05-28 15:47:31 +00:00
co-authored by Claude Opus 4.7
parent d73b4ec7a3
commit 7d0be3c70c
6 changed files with 104 additions and 8 deletions
+8
View File
@@ -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": [],
+18
View File
@@ -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(
+43
View File
@@ -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.