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 0b539499..21554df6 100644 --- a/custom_components/omnibattery/control/charge_delay.py +++ b/custom_components/omnibattery/control/charge_delay.py @@ -107,6 +107,26 @@ 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( + (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.""" @@ -624,6 +644,20 @@ 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) + # 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 # pre-dawn price peak), hold until the cheapest import hour before solar @@ -665,12 +699,6 @@ def _unlock(reason): return _unlock("past_t_end") # --- 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 - ) - 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..229c53f7 100644 --- a/tests/test_charge_delay.py +++ b/tests/test_charge_delay.py @@ -506,6 +506,76 @@ 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 + + +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) + + +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 # ----------------------------------------------------------------------