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
parent d73b4ec7a3
commit 7d0be3c70c
6 changed files with 104 additions and 8 deletions
+8
View File
@@ -1,5 +1,13 @@
# 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
### Changed
@@ -2,6 +2,7 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import datetime, time
from enum import Enum
@@ -194,7 +195,11 @@ def compute_decision(s: Snapshot) -> Decision:
sanitized_ev_w = s.ev_consumption_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:
desired = max(MIN_AMPS, raw_amps)
@@ -71,6 +71,28 @@ class EVSolarChargerCoordinator(DataUpdateCoordinator[Decision | None]):
except (TypeError, ValueError):
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:
if not entity_id:
return None
@@ -96,12 +118,12 @@ class EVSolarChargerCoordinator(DataUpdateCoordinator[Decision | None]):
return SunState.ABOVE
def _read_net_grid(self) -> float | None:
"""Return signed net grid power: + import, - export."""
net = self._read_float(self.entry_data.get(CONF_NET_GRID_SENSOR))
"""Return signed net grid power in watts: + import, - export."""
net = self._read_power_w(self.entry_data.get(CONF_NET_GRID_SENSOR))
if net is not None:
return net
imp = self._read_float(self.entry_data.get(CONF_GRID_IMPORT_SENSOR))
exp = self._read_float(self.entry_data.get(CONF_GRID_EXPORT_SENSOR))
imp = self._read_power_w(self.entry_data.get(CONF_GRID_IMPORT_SENSOR))
exp = self._read_power_w(self.entry_data.get(CONF_GRID_EXPORT_SENSOR))
if imp is None or exp is None:
return None
return imp - exp
@@ -120,7 +142,7 @@ class EVSolarChargerCoordinator(DataUpdateCoordinator[Decision | None]):
now=dt_util.now(),
sun_state=self._read_sun_state(),
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,
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,
@@ -256,7 +278,7 @@ class EVSolarChargerCoordinator(DataUpdateCoordinator[Decision | None]):
net = self._read_net_grid()
if net is None:
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"
if self._read_float(self.entry_data.get(CONF_EV_SOC_SENSOR)) is None:
return "ev soc sensor unavailable"
@@ -1,7 +1,7 @@
{
"domain": "ev_solar_charger",
"name": "EV Solar Charger",
"version": "0.1.2",
"version": "0.1.3",
"iot_class": "local_polling",
"config_flow": true,
"requirements": [],
+18
View File
@@ -346,6 +346,24 @@ def test_solar_clamps_to_max_amps(base_snapshot: Snapshot) -> None:
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:
"""Negative EV consumption (bad sensor) should be ignored, not amplify leftover."""
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
# Service-call failure is logged, not propagated; coordinator still records intent
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.