Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 34 additions & 6 deletions custom_components/omnibattery/control/charge_delay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down
70 changes: 70 additions & 0 deletions tests/test_charge_delay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ----------------------------------------------------------------------
Expand Down
Loading