From 75f9a500001b176889b941a75b70495ff453e719 Mon Sep 17 00:00:00 2001 From: Frank <639906+syphernl@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:56:36 +0200 Subject: [PATCH 1/3] fix(charge delay): publish energy_needed_kwh while waiting for solar The pre-sunrise branch returns before the energy balance runs, so the Charge Delay Status sensor dropped energy_needed_kwh entirely while the state was Waiting for solar. Consumers that default a missing attribute to 0 then show "0.0 kWh needed to reach 95%" at an SOC nowhere near the target. The deficit is plain SOC arithmetic and does not depend on T_start, so compute it in that branch as well, clamped at 0 so a battery already above target reports no deficit instead of a negative one. Extracted into _energy_needed_kwh() and reused at the existing call site; the delay decision itself is unchanged. --- .../omnibattery/control/charge_delay.py | 21 ++++++++++--- tests/test_charge_delay.py | 31 +++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/custom_components/omnibattery/control/charge_delay.py b/custom_components/omnibattery/control/charge_delay.py index 0b539499..33256b41 100644 --- a/custom_components/omnibattery/control/charge_delay.py +++ b/custom_components/omnibattery/control/charge_delay.py @@ -107,6 +107,14 @@ def _decision_now() -> datetime: return now +def _energy_needed_kwh(batteries: list, target_soc: float) -> float: + """Return the kWh still missing to reach ``target_soc`` across ``batteries``.""" + return sum( + (target_soc - c.data.get("battery_soc", 100)) / 100.0 * c.data.get("battery_total_energy", 0) + for c in batteries if c.data + ) + + class ChargeDelayManager: """Manages the unified charge-delay gate, persistence and projection.""" @@ -646,8 +654,14 @@ def _unlock(reason): T_START_FALLBACK_HOUR ) return _unlock("no_t_start") - # Still waiting for solar production + # Still waiting for solar production. The battery deficit does not + # depend on T_start, so publish it here too: without it the status + # sensor drops the attribute and a dashboard cannot tell "nothing + # to charge" from "not calculated yet". status["state"] = "Waiting for solar" + status["energy_needed_kwh"] = round( + max(0.0, _energy_needed_kwh(automatic_batteries, target_soc)), 2 + ) return True # --- Get T_end --- @@ -666,10 +680,7 @@ def _unlock(reason): # --- Calculate energy balance --- # Energy needed to reach target_soc - energy_needed_kwh = sum( - (target_soc - c.data.get("battery_soc", 100)) / 100.0 * c.data.get("battery_total_energy", 0) - for c in automatic_batteries if c.data - ) + energy_needed_kwh = _energy_needed_kwh(automatic_batteries, target_soc) if energy_needed_kwh <= 0: return _unlock("batteries_full") diff --git a/tests/test_charge_delay.py b/tests/test_charge_delay.py index d1d100b7..6bb61d14 100644 --- a/tests/test_charge_delay.py +++ b/tests/test_charge_delay.py @@ -506,6 +506,37 @@ def test_low_forecast_price_release_uses_fallback_when_no_t_start(): assert edges == [11] # T_START_FALLBACK_HOUR +def test_waiting_for_solar_publishes_energy_needed(monkeypatch): + # Pre-dawn hold: the deficit is SOC arithmetic, so it must be published + # even though the solar balance cannot be calculated yet. + now = dt_util.now().replace(hour=6, minute=0, second=0, microsecond=0) + monkeypatch.setattr(charge_delay_module, "_decision_now", lambda: now) + ctrl = _controller( + _solar_t_start=None, + coordinators=[_coord(soc=50, total_energy=5.0)], + ) + mgr = _make_mgr(ctrl, states={"sensor.forecast": _state(15.0)}) + + assert mgr._should_delay_charge(80) is True + assert ctrl._charge_delay_status["state"] == "Waiting for solar" + # 30% of a 5 kWh battery. + assert ctrl._charge_delay_status["energy_needed_kwh"] == pytest.approx(1.5) + + +def test_waiting_for_solar_energy_needed_never_negative(monkeypatch): + # Above target before sunrise: report no deficit rather than a negative one. + now = dt_util.now().replace(hour=6, minute=0, second=0, microsecond=0) + monkeypatch.setattr(charge_delay_module, "_decision_now", lambda: now) + ctrl = _controller( + _solar_t_start=None, + coordinators=[_coord(soc=90, total_energy=5.0)], + ) + mgr = _make_mgr(ctrl, states={"sensor.forecast": _state(15.0)}) + + assert mgr._should_delay_charge(80) is True + assert ctrl._charge_delay_status["energy_needed_kwh"] == 0.0 + + # ---------------------------------------------------------------------- # _estimate_energy_balance_unlock_h: projection math # ---------------------------------------------------------------------- From 909eb5321b2c35ad0ed5c369297917d1283c2d56 Mon Sep 17 00:00:00 2001 From: Frank <639906+syphernl@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:46:04 +0200 Subject: [PATCH 2/3] fix(charge delay): publish energy_needed_kwh for the cheap-import hold too The grid-deficit hold ("Delayed (cheap import HH:MM est.)") returns before the solar balance runs, so it dropped the attribute the same way the Waiting-for-solar branch did, and it is evaluated first. Publish the deficit once, before both holds, and reuse the value at the existing call site. Adds a CHANGELOG entry under Unreleased. --- CHANGELOG.md | 1 + .../omnibattery/control/charge_delay.py | 20 +++++++++---------- tests/test_charge_delay.py | 16 +++++++++++++++ 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de8126a0..ce69b440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,7 @@ - **A battery with native daily counters zeroed the system energy totals** (#380): the aggregates require every battery's daily figure to be stamped as belonging to today, and only the derived counter carried that stamp, so one device keeping its own counters produced 0.00 kWh charged and discharged on the overview with 10.3 and 3.97 kWh sitting in the per-battery sensors. The stamp now waits for evidence that the device's own counter has turned over, so a figure still holding yesterday's accumulation across our midnight is never summed as today's. Thanks to @sphings79. - **A battery switched off at start-up no longer takes the whole system down** (#403): it starts unreachable, is reported by the non-responsive batteries sensor, and the integration reloads once it answers. Thanks to @syphernl for the contribution. - **The daily timeline keeps its hourly-balance history when the feature is switched off**: the cause row and its marker were gated on the live switch, so turning Hourly Net Balance off erased them from every past interval. The per-cell record now stands on its own. +- **Charge Delay Status dropped `energy_needed_kwh` during the pre-sunrise holds** (PR#488): the "Waiting for solar" and "Delayed (cheap import)" branches return before the solar balance is calculated, so the attribute was omitted and consumers that default it to 0 reported "0.0 kWh needed" while the battery was far below target. The deficit is SOC arithmetic and is now published for both holds, clamped at 0 so a battery above target reports no deficit. Thanks to @syphernl. ## [1.4.0] - 2026-09-04 diff --git a/custom_components/omnibattery/control/charge_delay.py b/custom_components/omnibattery/control/charge_delay.py index 33256b41..3ae4fc58 100644 --- a/custom_components/omnibattery/control/charge_delay.py +++ b/custom_components/omnibattery/control/charge_delay.py @@ -632,6 +632,15 @@ def _unlock(reason): "grid needed (unlock delay)" if ctrl._charge_delay_balance_needs_charge else "solar sufficient (keep delay)", ) + # Energy needed to reach target_soc. Plain SOC arithmetic, independent of + # T_start, so publish it before the pre-sunrise holds below: they return + # without calculating the solar balance, and a consumer that defaults the + # missing attribute to 0 would read the hold as "nothing to charge". + # Clamped for the sensor so a battery above target reports no deficit + # rather than a negative one; the raw value still drives the unlock below. + energy_needed_kwh = _energy_needed_kwh(automatic_batteries, target_soc) + status["energy_needed_kwh"] = round(max(0.0, energy_needed_kwh), 2) + if ctrl._charge_delay_balance_needs_charge: # Genuine grid-deficit day: rather than unlocking immediately (often a # pre-dawn price peak), hold until the cheapest import hour before solar @@ -654,14 +663,8 @@ def _unlock(reason): T_START_FALLBACK_HOUR ) return _unlock("no_t_start") - # Still waiting for solar production. The battery deficit does not - # depend on T_start, so publish it here too: without it the status - # sensor drops the attribute and a dashboard cannot tell "nothing - # to charge" from "not calculated yet". + # Still waiting for solar production status["state"] = "Waiting for solar" - status["energy_needed_kwh"] = round( - max(0.0, _energy_needed_kwh(automatic_batteries, target_soc)), 2 - ) return True # --- Get T_end --- @@ -679,9 +682,6 @@ def _unlock(reason): return _unlock("past_t_end") # --- Calculate energy balance --- - # Energy needed to reach target_soc - energy_needed_kwh = _energy_needed_kwh(automatic_batteries, target_soc) - if energy_needed_kwh <= 0: return _unlock("batteries_full") diff --git a/tests/test_charge_delay.py b/tests/test_charge_delay.py index 6bb61d14..d4b2dc1a 100644 --- a/tests/test_charge_delay.py +++ b/tests/test_charge_delay.py @@ -537,6 +537,22 @@ def test_waiting_for_solar_energy_needed_never_negative(monkeypatch): assert ctrl._charge_delay_status["energy_needed_kwh"] == 0.0 +def test_cheap_import_hold_publishes_energy_needed(monkeypatch): + # The grid-deficit hold also returns before the solar balance is calculated. + now = dt_util.now().replace(hour=4, minute=0, second=0, microsecond=0) + monkeypatch.setattr(charge_delay_module, "_decision_now", lambda: now) + ctrl = _controller( + _solar_t_start=None, + coordinators=[_coord(soc=50, total_energy=5.0)], + ) + mgr = _make_mgr(ctrl, states={"sensor.forecast": _state(1.0)}) + mgr._low_forecast_price_release = lambda now_h: True + + assert mgr._should_delay_charge(80) is True + assert ctrl._charge_delay_balance_needs_charge is True + assert ctrl._charge_delay_status["energy_needed_kwh"] == pytest.approx(1.5) + + # ---------------------------------------------------------------------- # _estimate_energy_balance_unlock_h: projection math # ---------------------------------------------------------------------- From a1eff0e83a39229f68ea4418b0206304212dc950 Mon Sep 17 00:00:00 2001 From: Frank <639906+syphernl@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:48:44 +0200 Subject: [PATCH 3/3] fix(charge delay): clear stale balance figures when a hold returns early A hold that returns before the solar balance runs left the previous cycle's net solar, charge time and unlock estimate in the status dict, now beside a freshly published deficit. Clear those fields where the deficit is set; each is rewritten as soon as it is computed. --- .../omnibattery/control/charge_delay.py | 17 ++++++++++++++ tests/test_charge_delay.py | 23 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/custom_components/omnibattery/control/charge_delay.py b/custom_components/omnibattery/control/charge_delay.py index 3ae4fc58..21554df6 100644 --- a/custom_components/omnibattery/control/charge_delay.py +++ b/custom_components/omnibattery/control/charge_delay.py @@ -107,6 +107,18 @@ def _decision_now() -> datetime: return now +# Status fields the solar balance produces. A hold that returns before the +# balance runs clears them, so a fresh deficit never sits beside stale figures. +_BALANCE_STATUS_FIELDS = ( + "solar_t_end", + "remaining_solar_kwh", + "remaining_consumption_kwh", + "net_solar_kwh", + "charge_time_h", + "estimated_unlock_time", +) + + def _energy_needed_kwh(batteries: list, target_soc: float) -> float: """Return the kWh still missing to reach ``target_soc`` across ``batteries``.""" return sum( @@ -640,6 +652,11 @@ def _unlock(reason): # rather than a negative one; the raw value still drives the unlock below. energy_needed_kwh = _energy_needed_kwh(automatic_batteries, target_soc) status["energy_needed_kwh"] = round(max(0.0, energy_needed_kwh), 2) + # The solar balance below may not be reached this cycle. Drop what it + # would have produced rather than leaving a previous cycle's figures + # beside the fresh deficit; each is rewritten as soon as it is computed. + for key in _BALANCE_STATUS_FIELDS: + status[key] = None if ctrl._charge_delay_balance_needs_charge: # Genuine grid-deficit day: rather than unlocking immediately (often a diff --git a/tests/test_charge_delay.py b/tests/test_charge_delay.py index d4b2dc1a..229c53f7 100644 --- a/tests/test_charge_delay.py +++ b/tests/test_charge_delay.py @@ -553,6 +553,29 @@ def test_cheap_import_hold_publishes_energy_needed(monkeypatch): assert ctrl._charge_delay_status["energy_needed_kwh"] == pytest.approx(1.5) +def test_hold_clears_balance_figures_from_a_previous_cycle(monkeypatch): + # A hold that returns before the balance runs must not leave yesterday's + # net solar beside today's deficit. + now = dt_util.now().replace(hour=4, minute=0, second=0, microsecond=0) + monkeypatch.setattr(charge_delay_module, "_decision_now", lambda: now) + ctrl = _controller( + _solar_t_start=None, + coordinators=[_coord(soc=50, total_energy=5.0)], + _charge_delay_status={ + "state": "Idle", + "safety_margin_min": 30, + "net_solar_kwh": 9.9, + "charge_time_h": 3.3, + }, + ) + mgr = _make_mgr(ctrl, states={"sensor.forecast": _state(1.0)}) + mgr._low_forecast_price_release = lambda now_h: True + + assert mgr._should_delay_charge(80) is True + assert ctrl._charge_delay_status["net_solar_kwh"] is None + assert ctrl._charge_delay_status["charge_time_h"] is None + + # ---------------------------------------------------------------------- # _estimate_energy_balance_unlock_h: projection math # ----------------------------------------------------------------------