Skip to content

Commit 4e6aad3

Browse files
committed
Fix CT002 balancer cycling between empty batteries
In _reject_probe, the just-rejected candidate was re-inserted at the front of the deprioritized section of the priority list, so the next _maybe_force_swap_saturated scan kept re-picking the same battery once the saturation flag cleared. The rejection also reset _last_rotation, suppressing any scheduled rotation for a full efficiency_rotation_interval, so no other candidate was ever surfaced. Move the rejected candidate to the end of the priority list and drop the _last_rotation update on rejection, so the next tick is eligible to pick a different untested battery immediately. Adds a regression test that reproduces kiss81's 4-consumer/800 W/single active slot scenario and asserts a full battery is active for >= 50% of the final 600 ticks (0% before the fix, ~100% with it). Fixes #230.
1 parent 979d4ba commit 4e6aad3

2 files changed

Lines changed: 152 additions & 5 deletions

File tree

src/astrameter/ct002/balancer.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -450,13 +450,14 @@ def _reject_probe(self, now: float, reason: str) -> None:
450450
self._clear_consumer_grace(probe.candidate_id)
451451
self._clear_post_probe_fade()
452452
remaining = [
453-
cid for cid in self._priority if cid not in probe.restore_active_ids
454-
]
455-
self._priority = list(probe.restore_active_ids) + [
456-
cid for cid in remaining if cid not in probe.restore_active_ids
453+
cid
454+
for cid in self._priority
455+
if cid not in probe.restore_active_ids and cid != probe.candidate_id
457456
]
457+
self._priority = (
458+
list(probe.restore_active_ids) + remaining + [probe.candidate_id]
459+
)
458460
self._probe_state = None
459-
self._last_rotation = now
460461
logger.info(
461462
"Efficiency: probe rejected for %s (%s), restoring backups %s",
462463
probe.candidate_id[:16],
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Regression: balancer cycles forever between two empty batteries while
2+
healthy ones sit permanently deprioritized.
3+
4+
Reported in issue #230 (user kiss81): four phase-A consumers, sustained
5+
~800 W load, and ``MIN_EFFICIENT_POWER`` high enough that only one
6+
active slot is used. The two alphabetically-first consumers are "empty"
7+
(inverter caps output at 0 W regardless of the target) and the two
8+
alphabetically-later consumers are "full" (follow their target).
9+
10+
Before the fix, ``_reject_probe`` reinserted the just-rejected candidate
11+
near the front of the deprioritized section, so ``_maybe_force_swap_saturated``
12+
kept re-picking the same battery. At the same time the rejection
13+
updated ``_last_rotation``, which suppressed scheduled rotation for a
14+
full ``efficiency_rotation_interval``. The result: the two empty
15+
batteries swapped back and forth forever and the full batteries never
16+
got probed.
17+
18+
This test drives :class:`LoadBalancer.compute_target` directly via a
19+
:class:`_FakeClock` harness (same style as
20+
``tests/test_balancer_probe_lockup.py``) and asserts that at least one
21+
of the two full batteries appears in the active set for >= 50% of the
22+
final 600 ticks.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import time
28+
29+
from astrameter.ct002.balancer import (
30+
BalancerConfig,
31+
ConsumerMode,
32+
LoadBalancer,
33+
)
34+
35+
36+
class _FakeClock:
37+
def __init__(self) -> None:
38+
self._t = time.time()
39+
40+
def __call__(self) -> float:
41+
return self._t
42+
43+
def advance(self, dt: float) -> None:
44+
self._t += dt
45+
46+
47+
class SimBattery:
48+
"""Minimal inverter simulation: ramps ``power`` toward ``desired``.
49+
50+
If ``is_empty`` is ``True`` the inverter caps output at 0 W regardless
51+
of the commanded target — this is the "empty" battery from the report.
52+
"""
53+
54+
def __init__(self, mac: str, *, is_empty: bool) -> None:
55+
self.mac = mac
56+
self.is_empty = is_empty
57+
self.max_discharge = 800
58+
self.ramp = 300
59+
self.power = 0.0
60+
61+
def step(self, target_delta: float, reported_power: float) -> None:
62+
desired = reported_power + target_delta
63+
if self.is_empty:
64+
desired = 0
65+
desired = max(0, min(self.max_discharge, desired))
66+
delta = desired - self.power
67+
if delta > self.ramp:
68+
delta = self.ramp
69+
elif delta < -self.ramp:
70+
delta = -self.ramp
71+
self.power += delta
72+
73+
74+
def _make_balancer(clock: _FakeClock) -> LoadBalancer:
75+
"""Balancer tuned for kiss81's reproduction conditions."""
76+
return LoadBalancer(
77+
config=BalancerConfig(
78+
fair_distribution=True,
79+
balance_gain=0.2,
80+
balance_deadband=15,
81+
min_efficient_power=750,
82+
probe_min_power=80,
83+
efficiency_rotation_interval=900,
84+
efficiency_fade_alpha=0.15,
85+
efficiency_saturation_threshold=0.4,
86+
),
87+
saturation_alpha=0.15,
88+
saturation_min_target=20,
89+
saturation_decay_factor=0.995,
90+
saturation_grace_seconds=90.0,
91+
saturation_stall_timeout_seconds=60.0,
92+
saturation_enabled=True,
93+
clock=clock,
94+
)
95+
96+
97+
def test_full_batteries_eventually_get_active_slot() -> None:
98+
# Alphabetical order places the two empty ones at positions 0 and 1
99+
# and the two full ones at positions 2 and 3.
100+
empty_macs = ["aabb00000001", "aabb00000002"]
101+
full_macs = ["ccdd00000003", "ccdd00000004"]
102+
batteries = [SimBattery(mac, is_empty=True) for mac in empty_macs] + [
103+
SimBattery(mac, is_empty=False) for mac in full_macs
104+
]
105+
106+
clock = _FakeClock()
107+
lb = _make_balancer(clock)
108+
109+
phase_a_load = 800.0
110+
active_membership: list[set[str]] = []
111+
112+
for tick in range(1800):
113+
reports = {b.mac: {"phase": "A", "power": round(b.power)} for b in batteries}
114+
grid_total = phase_a_load - sum(b.power for b in batteries)
115+
116+
deltas: dict[str, float] = {}
117+
for b in batteries:
118+
phase_targets = lb.compute_target(
119+
consumer_id=b.mac,
120+
consumer_mode=ConsumerMode("auto"),
121+
all_reports=reports,
122+
grid_total=grid_total,
123+
inactive=frozenset(),
124+
manual=frozenset(),
125+
sample_id=(tick,),
126+
)
127+
deltas[b.mac] = phase_targets[0]
128+
129+
for b in batteries:
130+
b.step(deltas[b.mac], reports[b.mac]["power"])
131+
132+
slots = max(1, len(lb._priority) - len(lb._deprioritized))
133+
active_membership.append(set(lb._priority[:slots]))
134+
135+
clock.advance(1.0)
136+
137+
full_set = set(full_macs)
138+
tail = active_membership[-600:]
139+
hits = sum(1 for active in tail if active & full_set)
140+
ratio = hits / len(tail)
141+
142+
assert ratio >= 0.5, (
143+
f"Full batteries were active for only {ratio:.1%} of the final "
144+
f"600 ticks (threshold 50%). Before the fix this ratio is ~0%; "
145+
f"with the fix it should be near 100%."
146+
)

0 commit comments

Comments
 (0)