|
| 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