From 5e37c7d46f843b97c6927f282567a5f9e8f617ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 12:23:36 +0000 Subject: [PATCH 1/3] Don't collapse pure-DC pool on brief negative-grid transients (#359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The charge-blind/steer-to-zero path in `_compute_auto_target` exists to keep a B2500 from stealing the surplus a Venus needs to wake up (see issue #338). It fired on any `grid_total < 0` reading, even when no AC-chargeable battery was reporting — so a pure DC-only pool would slam every inverter to 0 W on a brief negative-grid transient (load drop, ramp overshoot) and then have to re-ramp from scratch. Gate the whole charge-territory check on at least one AC-chargeable battery actually being present. Pure-DC setups now ride the transient through the regular fair-share path; the B2500s' own AC-charge clamp still keeps them at 0 W under a sustained surplus, and the existing "no AC-chargeable battery reporting" diagnostic notice is decoupled from `charge_blind` so it still fires for the all-DC case. --- CHANGELOG.md | 1 + src/astrameter/ct002/balancer.py | 26 ++++++-- tests/test_balancer_mixed_battery_charging.py | 64 ++++++++++++++++++- 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82479dad..876dfbc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ - **Upgraded** `JSON_PATHS` parsing in the JSON HTTP and MQTT powermeters to the `jsonpath-ng` extended syntax, so values that arrive with a unit suffix (e.g. openHAB `Number:Power` returning `"331.74 W"`) can be sanitized inside the path with `` `split(...)` `` or `` `sub(/regex/, replacement)` `` instead of failing the float conversion ([#349](https://github.com/tomquist/astrameter/pull/349)). ### Fixed +- **Fixed** CT002/CT003 active control collapsing a pure DC-only battery pool (e.g. only B2500 / Jupiter) to 0 W whenever the grid briefly went negative (load drop, ramp overshoot). The Venus-protection logic only applies when an AC-chargeable battery is actually present; pure-DC setups now ride out brief negative-grid transients with a smooth fair-share trim instead of a full re-ramp cycle ([#359](https://github.com/tomquist/astrameter/issues/359)). - **Fixed** Modbus `UNIT_ID` handling and clarified Home Assistant entity ID configuration in the docs ([#191](https://github.com/tomquist/astrameter/pull/191), [#195](https://github.com/tomquist/astrameter/pull/195)). ## 1.0.8 diff --git a/src/astrameter/ct002/balancer.py b/src/astrameter/ct002/balancer.py index ba564ab1..98271964 100644 --- a/src/astrameter/ct002/balancer.py +++ b/src/astrameter/ct002/balancer.py @@ -847,12 +847,25 @@ def _compute_auto_target( # discharge (both batteries discharging to serve the house load) # because no AC-chargeable battery is charging there. # See issue #338. + # + # The whole gate is further conditioned on ``any_ac_chargeable``: + # if no AC-coupled battery is reporting there is nothing to + # protect from B2500 interference, so we let the normal fair- + # share path handle brief negative-grid transients (load drops, + # ramp overshoot) by smoothly reducing discharge rather than + # slamming the whole pool to 0 W and forcing a re-ramp cycle. + # See issue #359. ac_charging = any( _is_ac_chargeable(r.get("device_type", "")) and parse_int(r.get("power", 0)) < 0 for r in reports.values() ) - in_charge_territory = grid_total < 0 or (grid_total == 0 and ac_charging) + any_ac_chargeable = any( + _is_ac_chargeable(r.get("device_type", "")) for r in reports.values() + ) + in_charge_territory = any_ac_chargeable and ( + grid_total < 0 or (grid_total == 0 and ac_charging) + ) charge_blind = ( { cid @@ -881,10 +894,15 @@ def _compute_auto_target( # Degenerate case: every reporter is DC-only but we're under # surplus. Nothing can absorb; log once so the user can see why - # the grid is still feeding back, then fall through to the - # per-consumer DC hold below. + # the grid is still feeding back. In this all-DC mode we leave + # ``in_charge_territory`` off (see above) so that the regular + # fair-share path can still smoothly reduce discharge through + # brief negative-grid transients (e.g. a load drop while the + # batteries are mid-discharge — see issue #359); the B2500s' + # own AC-charge clamp keeps them at 0 W under a sustained + # surplus regardless. all_dc_under_surplus = ( - grid_total < 0 and charge_blind and not (set(reports) - charge_blind) + grid_total < 0 and bool(reports) and not any_ac_chargeable ) if all_dc_under_surplus and not self._all_dc_surplus_warned: logger.info( diff --git a/tests/test_balancer_mixed_battery_charging.py b/tests/test_balancer_mixed_battery_charging.py index 635c28c1..9d815edb 100644 --- a/tests/test_balancer_mixed_battery_charging.py +++ b/tests/test_balancer_mixed_battery_charging.py @@ -30,7 +30,10 @@ default-to-DC safety policy is in effect), * the fix path with recognised Venus / B2500 prefixes, * discharge unaffected, - * the degenerate all-DC-under-surplus case. + * the degenerate all-DC-under-surplus case, + * issue #359: brief negative-grid transients in a pure-DC pool must + not trigger the charge-blind/steer-to-zero path that exists only + to protect a co-resident Venus. """ from __future__ import annotations @@ -487,3 +490,62 @@ def test_unknown_device_type_deadlock_persists() -> None: f"With unknown device_types nobody charges; expected grid ~= -600 W, got " f"{avg_grid:.0f} W" ) + + +# --------------------------------------------------------------------------- +# Issue #359: brief negative-grid transient must not collapse a pure-DC pool +# --------------------------------------------------------------------------- + + +def test_transient_surplus_does_not_collapse_dc_only_pool() -> None: + """A brief load drop while DC batteries are discharging stays smooth. + + Pure B2500 pool (no Venus) discharging ~380 W each to meet a 760 W + house load. At tick 30 the load drops to 745 W, so for one tick + the grid reads -15 W (batteries are still at 760 W combined). + Under issue #359 this would fire ``charge_blind`` for every reporter + and ``_steer_to_zero`` would slam both inverters down by ~380 W, + causing a full re-ramp cycle. With the fix ``in_charge_territory`` + stays off when no AC-chargeable battery is reporting, so the + fair-share path handles the transient with a small (~-8 W) trim. + """ + a = DCOnlyBattery("dc_a", device_type="HMJ-2") + b = DCOnlyBattery("dc_b", device_type="HMJ-2") + + clock = _FakeClock() + lb = _make_balancer(clock) + + def house_load(tick: int) -> float: + return 760.0 if tick < 30 else 745.0 + + power_trace: dict[str, list[float]] = {"dc_a": [], "dc_b": []} + for tick in range(45): + reports = { + bat.mac: { + "phase": "A", + "power": round(bat.power), + "device_type": bat.device_type, + } + for bat in (a, b) + } + grid_total = house_load(tick) - sum(bat.power for bat in (a, b)) + for bat in (a, b): + delta = lb.compute_target( + consumer_id=bat.mac, + consumer_mode=ConsumerMode("auto"), + all_reports=reports, + grid_total=grid_total, + inactive=frozenset(), + manual=frozenset(), + sample_id=(tick,), + )[0] + bat.step(delta, reports[bat.mac]["power"]) + power_trace[bat.mac].append(bat.power) + clock.advance(1.0) + + # After the drop the batteries should settle near 372 W each + # (745 W shared evenly), not collapse to 0. + for mac in ("dc_a", "dc_b"): + tail = power_trace[mac][-10:] + assert min(tail) > 300, f"{mac} collapsed after transient surplus: tail={tail}" + assert max(tail) < 400, f"{mac} overshot after transient surplus: tail={tail}" From eeae644cb6c4f76aa69dad489afac128fc027425 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 12:29:00 +0000 Subject: [PATCH 2/3] Test: balancer never asks DC-only pool to discharge under surplus Adds a balancer-only assertion to complement the existing closed-loop all-DC-surplus test. Previously ``_steer_to_zero`` guaranteed every DC target was 0 under surplus; with the #359 fix that path falls through to fair-share, so the safety property now rests on the ``grid_total < 0 and target > 0`` sign-clamp plus fair-share producing a non-positive share of a negative grid. Pin the batteries at 0 W and verify the emitted target stays ``<= 0`` every tick. --- tests/test_balancer_mixed_battery_charging.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_balancer_mixed_battery_charging.py b/tests/test_balancer_mixed_battery_charging.py index 9d815edb..06b21e81 100644 --- a/tests/test_balancer_mixed_battery_charging.py +++ b/tests/test_balancer_mixed_battery_charging.py @@ -549,3 +549,54 @@ def house_load(tick: int) -> float: tail = power_trace[mac][-10:] assert min(tail) > 300, f"{mac} collapsed after transient surplus: tail={tail}" assert max(tail) < 400, f"{mac} overshot after transient surplus: tail={tail}" + + +def test_sustained_surplus_dc_only_balancer_never_asks_to_discharge() -> None: + """Under sustained surplus the balancer itself must not command discharge. + + Previously ``_steer_to_zero`` guaranteed every DC-only target was 0 + under surplus. With the issue #359 fix the all-DC path falls + through to fair-share, so the safety property now rests on the + sign-clamp at the tail of ``_compute_auto_target`` (``grid_total < + 0 and target > 0 → 0``) plus fair-share producing a non-positive + share of a negative grid. + + Pin the batteries at 0 W (simulating the B2500's AC-charge clamp) + and verify the balancer's emitted target stays ``<= 0`` for every + tick — i.e. the balancer never instructs a DC-only battery to + discharge into a surplus. Complements + ``test_all_dc_under_surplus_holds_zero_and_logs``, which exercises + the full closed-loop including the simulated battery's own clamp. + """ + macs = ("dc_a", "dc_b") + clock = _FakeClock() + lb = _make_balancer(clock) + surplus = 600.0 + + max_target_seen: dict[str, float] = {m: float("-inf") for m in macs} + for tick in range(200): + # Batteries pinned at 0 W (real B2500 cannot accept AC charge). + reports = {m: {"phase": "A", "power": 0, "device_type": "HMJ-1"} for m in macs} + grid_total = -surplus # nothing absorbs, so grid stays at -600 W + for m in macs: + delta = lb.compute_target( + consumer_id=m, + consumer_mode=ConsumerMode("auto"), + all_reports=reports, + grid_total=grid_total, + inactive=frozenset(), + manual=frozenset(), + sample_id=(tick,), + )[0] + # Target is a delta added to reported power (0 here), so the + # commanded absolute power equals ``delta``. Under surplus + # this must never be positive. + assert delta <= 0, ( + f"tick {tick}: balancer asked {m} to discharge into " + f"a {grid_total:.0f} W surplus (delta={delta})" + ) + max_target_seen[m] = max(max_target_seen[m], delta) + clock.advance(1.0) + + for m, peak in max_target_seen.items(): + assert peak <= 0, f"{m} peak target under surplus was {peak} (expected <= 0)" From 8c79284b0b443a60a1fc7a28e6cab6187559aaa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 12:31:12 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Drop=20separate=20changelog=20entry=20?= =?UTF-8?q?=E2=80=94=20covered=20by=20existing=20CT002/CT003=20bullet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 876dfbc7..82479dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,6 @@ - **Upgraded** `JSON_PATHS` parsing in the JSON HTTP and MQTT powermeters to the `jsonpath-ng` extended syntax, so values that arrive with a unit suffix (e.g. openHAB `Number:Power` returning `"331.74 W"`) can be sanitized inside the path with `` `split(...)` `` or `` `sub(/regex/, replacement)` `` instead of failing the float conversion ([#349](https://github.com/tomquist/astrameter/pull/349)). ### Fixed -- **Fixed** CT002/CT003 active control collapsing a pure DC-only battery pool (e.g. only B2500 / Jupiter) to 0 W whenever the grid briefly went negative (load drop, ramp overshoot). The Venus-protection logic only applies when an AC-chargeable battery is actually present; pure-DC setups now ride out brief negative-grid transients with a smooth fair-share trim instead of a full re-ramp cycle ([#359](https://github.com/tomquist/astrameter/issues/359)). - **Fixed** Modbus `UNIT_ID` handling and clarified Home Assistant entity ID configuration in the docs ([#191](https://github.com/tomquist/astrameter/pull/191), [#195](https://github.com/tomquist/astrameter/pull/195)). ## 1.0.8