Skip to content

Commit a1cbe22

Browse files
committed
Handle B2500 pass-through at 100% SoC (#338)
Reported by the repo owner as a follow-up: when the B2500 is at 100% SoC any incoming DC solar flows straight through as AC output, so it reports positive power regardless of commands. Against the pre-fix balancer this pins the Venus around -340 W via the balance-correction + sign-clamp interaction, leaving ~160 W of sustained feed-in — and reproduces the user's symptom without any Venus startup-threshold assumption. The earlier commit fixed the split-evenly deadlock but the strict ``grid_total < 0`` guard let go at the exact zero-crossing during pass-through equilibrium (Venus at -500 W, B2500 at +500 W, grid at 0 W): the balance-correction fired, pushed Venus positive by one max_correction_per_step, and the system oscillated. Extend the charge-blind mask to also fire at ``grid_total == 0`` when any AC-chargeable battery is currently charging (``power < 0``). That signals pass-through equilibrium specifically, and keeps the discharge path untouched — a pure-discharge equilibrium at grid=0 has both batteries at positive power, so the extension doesn't activate. Adds ``B2500PassThrough`` to the test harness and a regression test verifying Venus converges to -500 W and grid to 0 W under the 500 W pass-through scenario.
1 parent 726e202 commit a1cbe22

2 files changed

Lines changed: 97 additions & 6 deletions

File tree

src/astrameter/ct002/balancer.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -832,18 +832,34 @@ def _compute_auto_target(
832832
num_consumers = max(1, len(reports))
833833
eff_part = {cid: max(0.01, 1.0 - saturation.get(cid, 0.0)) for cid in reports}
834834

835-
# Exclude DC-only batteries (B2500 family, Jupiter, anything not in
836-
# AC_CHARGEABLE_DEVICE_PREFIXES) from charge distribution under
837-
# surplus. They'll happily discharge on positive grid_total, but
838-
# asking them to charge wastes the share that should have gone to
839-
# the AC sibling (Venus). See issue #338.
835+
# Exclude DC-only batteries (B2500 family, Jupiter, anything not
836+
# in AC_CHARGEABLE_DEVICE_PREFIXES) from charge distribution
837+
# whenever the grid is in charge territory. The base gate is
838+
# ``grid_total < 0`` (surplus), but we also extend it to the
839+
# exact zero-crossing when an AC-chargeable battery is already
840+
# charging (``power < 0``) — that signals pass-through
841+
# equilibrium, which happens when a full B2500 is passing its DC
842+
# solar input through as AC output (+P W) while the Venus
843+
# charges a matching -P W, leaving grid at 0. Without this
844+
# extension the balance-correction fires at the zero-crossing
845+
# and oscillates the Venus back out of its steady state. We
846+
# deliberately don't fire on ``grid_total == 0`` during pure
847+
# discharge (both batteries discharging to serve the house load)
848+
# because no AC-chargeable battery is charging there.
849+
# See issue #338.
850+
ac_charging = any(
851+
_is_ac_chargeable(r.get("device_type", ""))
852+
and parse_int(r.get("power", 0)) < 0
853+
for r in reports.values()
854+
)
855+
in_charge_territory = grid_total < 0 or (grid_total == 0 and ac_charging)
840856
charge_blind = (
841857
{
842858
cid
843859
for cid, r in reports.items()
844860
if not _is_ac_chargeable(r.get("device_type", ""))
845861
}
846-
if grid_total < 0
862+
if in_charge_territory
847863
else set()
848864
)
849865
for cid in charge_blind:

tests/test_balancer_mixed_battery_charging.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,37 @@ def step(self, target_delta: float, reported_power: float) -> None:
140140
self.power = 0.0
141141

142142

143+
class B2500PassThrough:
144+
"""B2500 at 100 % SoC passing its DC solar input straight through as AC.
145+
146+
When the B2500 is full it can no longer absorb its own DC input, so
147+
the excess flows out as AC — the unit reports positive power
148+
(apparent discharge) regardless of any CT command. The balancer
149+
sees this as "B2500 is producing" while the real grid is still
150+
importing the surplus it can't absorb. See issue #338 (follow-up
151+
from the repo owner): this scenario reproduces the deadlock
152+
*without* requiring any Venus startup-threshold assumption, because
153+
the balance-correction + sign-clamp interaction alone pins the
154+
Venus below the level needed to cancel the B2500's feed.
155+
"""
156+
157+
def __init__(
158+
self,
159+
mac: str,
160+
passthrough_w: int,
161+
*,
162+
device_type: str = "HMJ-1",
163+
) -> None:
164+
self.mac = mac
165+
self.passthrough_w = passthrough_w
166+
self.device_type = device_type
167+
self.power = float(passthrough_w)
168+
169+
def step(self, target_delta: float, reported_power: float) -> None:
170+
# Output is dictated by DC solar input, not the CT command.
171+
self.power = float(self.passthrough_w)
172+
173+
143174
def _make_balancer(clock: _FakeClock) -> LoadBalancer:
144175
"""Balancer with CT002 defaults (matching the out-of-the-box config)."""
145176
return LoadBalancer(
@@ -317,6 +348,50 @@ def test_non_venus_prefixes_are_treated_as_dc(dc_device_type: str) -> None:
317348
)
318349

319350

351+
# ---------------------------------------------------------------------------
352+
# B2500 pass-through at 100 % SoC
353+
# ---------------------------------------------------------------------------
354+
355+
356+
def test_b2500_passthrough_at_full_soc_does_not_pin_venus() -> None:
357+
"""B2500 full + passing 500 W DC through as AC: Venus must absorb it.
358+
359+
Without the fix the pre-fix balancer pins the Venus at ~-340 W: the
360+
balance correction treats the B2500's +500 W "output" as a peer
361+
behaviour the Venus should match toward, and the sign clamp then
362+
blocks Venus from being pushed negative enough to cancel the feed.
363+
The result is a sustained ~160 W export, independent of any
364+
inverter startup threshold.
365+
366+
With the fix, the B2500 is recognised by prefix (``HMJ-1``) as
367+
DC-only and excluded from charge distribution; the Venus receives
368+
the full -500 W target, charges to -500 W, and pins the grid at 0.
369+
"""
370+
b2500 = B2500PassThrough("b2500_full", passthrough_w=500)
371+
venus = ACBatteryWithStartupThreshold("venus", device_type="HMG-50")
372+
373+
grid, power = _run_scenario([b2500, venus], surplus_watts=0.0, ticks=60)
374+
375+
venus_tail = power["venus"][-20:]
376+
b2500_tail = power["b2500_full"][-20:]
377+
grid_tail = grid[-20:]
378+
379+
# B2500 keeps pushing its 500 W pass-through regardless of commands.
380+
assert all(abs(p - 500.0) < 1.0 for p in b2500_tail), (
381+
f"B2500 pass-through output should stay at 500 W, tail was {b2500_tail}"
382+
)
383+
# Venus absorbs the full pass-through; grid at ~0.
384+
assert min(venus_tail) < -490, (
385+
f"Venus should converge near -500 W to cancel the pass-through, "
386+
f"tail was {venus_tail}"
387+
)
388+
avg_grid = sum(grid_tail) / len(grid_tail)
389+
assert abs(avg_grid) < 30, (
390+
f"Grid should converge near 0 W (Venus exactly cancels B2500), "
391+
f"got {avg_grid:.0f} W"
392+
)
393+
394+
320395
# ---------------------------------------------------------------------------
321396
# Discharge unaffected
322397
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)