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:
@@ -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": [],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user