diff --git a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md index d996e48..0f7bfba 100644 --- a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md +++ b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md @@ -1,14 +1,21 @@ # OPT-11 - battery modes, per battery A battery's mode (on/off, Solar first, Leftover solar) is a soft unit limit at -`P_MODE` = 35 that always holds 0 W, applies only while the site target is -decided at or below 35, and is released by anything above it, such as the peak -guard. Design and owner answers: `goodwe-session/OPT-11-design.md` (2026-09-27); +`P_MODE` = 35 that always holds 0 W. A SET above 35 (a safety stop) overrides +it; a limit above 35 (the peak guard) makes it give way only as far as that +limit needs, and a battery its mode holds back then carries only what the +others cannot (`arbiter._unit_modes()`, `_site_mode()`). Design and owner answers: `goodwe-session/OPT-11-design.md` (2026-09-27); P1 is its section 6 P1, and the pull request lists what was measured. - Off by default (`battery_modes_enabled`); off, commands are byte-identical to a5dddad (`test_modes.py` part 1). -- Off is a mode range, so the peak guard can still use an Off battery (design - sections 2-3). Whether Off should be absolute is an open owner question. +- Off is a mode range, so the peak guard can still use an Off battery when the + others cannot cover the floor (design sections 2-3). Whether Off should be + absolute is an open owner question. +- Review (PR #149): releasing only when the peak floor bound S let a trade won + above the floor keep the modes and place less than the floor, and let a + capped grid charge release an Off battery. Both are graded in + `test_arbiter.py` part 11. The loop bound leaves a side that reaches the + rail to the rail, so SAFETY-03 still flags a loop winding into it. - In P1 only Off closes a direction; Leftover solar differs by charging last. Price runs, selling (`pack_sell`, reserved) and the forecast are P3/P4. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index a23e7ce..4f36859 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -517,12 +517,12 @@ below. Off by default. Turn it on, restart the add-on, and the Batteries screen gives each battery two controls. They apply at once, with no restart. -- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, like every battery. +- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover. - **Mode.** *Solar first* charges from spare solar before the other batteries. *Leftover solar* charges from the solar they cannot take. Both cover the house at any hour. Charging from the grid in cheap hours, and selling, come in a later update. The peak limit, the safety stops and the monthly cycle always come before a battery's mode. -*For installers:* a mode is a limit at priority 35, kept apart from the hard limits, and released whenever a claim above it (the peak guard) decides the site target. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`. +*For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`. ### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason` diff --git a/goodwe_controller/app/allocator.py b/goodwe_controller/app/allocator.py index b4bce23..9ddef8b 100644 --- a/goodwe_controller/app/allocator.py +++ b/goodwe_controller/app/allocator.py @@ -304,9 +304,9 @@ def unit_interval(b, deciding_priority=None): """OPT-11. `(lo, hi, note)`: the interval a unit is allocated into. The hard limits (`b.lo`, `b.hi`) always apply. The unit's MODE range - (`b.mode_lo`, `b.mode_hi`) narrows them only when the site target was - decided at or below the mode's own priority - `deciding_priority` None - means no higher claim was named, so the mode applies. `note` says why the + (`b.mode_lo`, `b.mode_hi`) narrows them unless `deciding_priority` is + above the mode's own priority (arbiter._unit_modes() passes None when the + modes hold, and the yielding limit's priority when they give way). `note` says why the mode did not apply, or is None when it did or there is none: * MODE_FAULT: the mode range leaves out 0 W. A mode may forbid a direction diff --git a/goodwe_controller/app/arbiter.py b/goodwe_controller/app/arbiter.py index 4eda428..5d69d1d 100644 --- a/goodwe_controller/app/arbiter.py +++ b/goodwe_controller/app/arbiter.py @@ -87,9 +87,9 @@ P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only) # loop would otherwise cancel every charge the moment it created import. P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose # OPT-11. A battery's mode (Solar first, Leftover solar, Off): a SOFT unit -# limit, kept apart from the hard ones and applied only while the site target -# is decided at or below this priority. Above arbitrage, so a trade stays inside -# each battery's mode; below the peak guard, which releases every mode. +# limit, kept apart from the hard ones. Above arbitrage, so a trade stays inside +# each battery's mode; below the peak guard, to which a mode gives way only as +# far as the floor needs (`_unit_modes()`, `_site_mode()`). # DECISIONS/OPT-11-battery-modes.md. P_MODE = 35 P_LOOP = 10 # ordinary grid-following @@ -250,8 +250,8 @@ CLAIM_SCOPE = { # main.py: `Claim.limit("mode", P_MODE, lo, hi, ...)`, OPT-11. THIS # battery's mode, chosen per battery by its owner. A unit claim that never # enters a `lo > hi` test with a site limit: stage 1 keeps it apart from the - # hard limits, and stage 3 applies it only when the site target was decided - # at or below P_MODE. At N=1 it routes to the site like every source, and + # hard limits, and it gives way to a site limit above P_MODE only as far as + # that limit needs (`_unit_modes()`). At N=1 it routes to the site, and # `resolve()` takes it out of stage 2 for the same reason. "mode": SCOPE_UNIT, } @@ -434,9 +434,10 @@ class Resolution: # `explain()` names. winner_priority: int | None = None # OPT-11. The higher of the winning set's priority and the priority of the - # limit that bound `target_w`. Stage 3 applies each unit's mode only when - # this is at or below that mode's priority (P_MODE), so a peak floor that - # binds S (P_PEAK) releases every mode and never meets a mode `hi`. + # limit that bound `target_w`. Whether the modes hold is decided from the + # limits themselves (`_site_mode()`, `_unit_modes()`): a mode gives way + # only as far as a limit above it (the peak floor) needs, so a mode `hi` + # never meets a site `lo`. deciding_priority: int | None = None # OPT-11 at N=1: why the site's mode layer was dropped this tick, or None. # At N>1 the same fact is on `allocation.mode_faults`, per unit. @@ -676,7 +677,7 @@ def resolve(claims: list, *, units=None, weights=None, live=None, # `claims`, so this is the shipped arbiter on the shipped list. out = site_resolve(site_claims) if site_modes: - _site_mode(out, site_modes) + _site_mode(out, site_modes, site_claims) # STAGE 3 - the seam, and since ALLOC-01 the allocator behind it. # @@ -709,23 +710,67 @@ def resolve(claims: list, *, units=None, weights=None, live=None, # nothing. See `packshare.weights()` on the residual this leaves (the fill's # own direction can differ from the target's sign under a pin, in which case # the weighting is a worse preference and still cannot be unsafe). + # OPT-11: whether each unit's mode holds this tick (`_unit_modes()`). + # Before the weights, because a limit above the modes may move the target. + gate, held = _unit_modes(out, site_claims, bounds, live) if callable(weights): weights = weights(out.target_w) + if held: + weights = {u: (0.0 if u in held else + (1.0 if weights is None else weights.get(u, 1.0))) + for u in bounds} out.shares, out.allocated, out.allocation = allocate_record( out.target_w, bounds, weights=weights, live=live, scope_errors=out.scope_errors, site_priority=out.winner_priority, - tick=tick, deciding_priority=out.deciding_priority) + tick=tick, deciding_priority=gate) return out -def _site_mode(out: Resolution, modes: list) -> None: +def _limits_above(site_claims: list, prio: int) -> tuple: + """OPT-11: `(lo, lo_src, hi, hi_src)` over the site LIMITs above `prio`. + + These are what a mode may never take the site target outside of: the peak + guard (P_PEAK), maintenance shaping (P_MAINT_SHAPE) and the hard limits. + """ + lo, hi = -inf, inf + lo_src = hi_src = None + for c in site_claims: + if c.kind != LIMIT or c.priority <= prio: + continue + if c.lo > lo: + lo, lo_src = c.lo, c + if c.hi < hi: + hi, hi_src = c.hi, c + return lo, lo_src, hi, hi_src + + +def _yield_to(out: Resolution, t1: float, above: tuple): + """OPT-11: the target `t1` the modes allow, moved only as far as the limits + above the mode need (`_limits_above()`). Returns the claim that moved it, + or None when `t1` already satisfies them, and sets `out.target_w`.""" + lo, lo_src, hi, hi_src = above + t2 = max(lo, min(hi, t1)) + out.target_w = float(t2) + if t2 == t1: + return None + binder = lo_src if t2 == lo else hi_src + out.bound_by = (f"{binder.source}({binder.reason})" if binder.reason + else binder.source) + out.deciding_priority = max(out.deciding_priority or binder.priority, + binder.priority) + return binder + + +def _site_mode(out: Resolution, modes: list, site_claims: list) -> None: """OPT-11 at N=1: the one battery's mode bounds the SITE target, after stage 2. - The unit rule of `allocator.unit_interval()` on the site: the mode applies - only when stage 2 decided at or below its priority, and a range without 0 W - is a software fault that drops the mode (`out.mode_fault`). A clamp of one - number into a range that holds 0 cannot contradict anything, so the peak - floor still wins by releasing the mode, as at N>1. + A SET above the mode (a safety stop) decides alone. Otherwise the target is + clamped into the mode range and then back into the site limits above the + mode, so the mode gives way only as far as those limits need: a peak floor + of 2000 W over an Off battery asks it for 2000 W, whether a trade or the + loop won stage 2, and a charge the peak guard merely caps stays at 0 W. A + range without 0 W is a software fault that drops the mode + (`out.mode_fault`). Clamping one number cannot contradict anything. """ out.considered = out.considered + [f"{c.source}:{c.kind}" for c in modes] lims = [c for c in modes if c.kind == LIMIT] @@ -737,10 +782,53 @@ def _site_mode(out: Resolution, modes: list) -> None: if not (lo <= 0.0 <= hi): out.mode_fault = f"mode range [{lo:.0f}, {hi:.0f}] leaves out 0 W" return - if out.deciding_priority is not None and out.deciding_priority > prio: + if out.winner_priority is not None and out.winner_priority > prio: return - target = max(lo, min(hi, out.target_w)) - if target != out.target_w: + before = out.target_w + t1 = max(lo, min(hi, before)) + if _yield_to(out, t1, _limits_above(site_claims, prio)) is None \ + and t1 != before: c = lims[0] out.bound_by = f"{c.source}({c.reason})" if c.reason else c.source - out.target_w = float(target) + + +def _unit_modes(out: Resolution, site_claims: list, bounds: dict, live): + """OPT-11 at N>1: `(gate, held)` for stage 3. + + `gate` is the `deciding_priority` handed to the allocator: None applies + every unit's mode, a number above P_MODE releases them. `held` is the set + of units whose mode holds them back in the target's direction; while the + modes are released they weigh 0, so they carry only what the other units + cannot (the allocator's last-resort pass). + + The rule matches N=1: a SET above the mode decides alone. Otherwise the + target is clamped into what the units can place with their modes + (`allocator.loop_bound()`) and back into the site limits above the mode. + If that point is still placeable with the modes, they hold and the target + is unchanged (what they cannot place is reported unmet, as before). If a + limit above the mode needs more, the target moves only that far, and the + modes are released for it. + """ + mode_units = [u for u, b in bounds.items() + if getattr(b, "mode_priority", None) is not None] + if not mode_units or out.contradiction: + return out.deciding_priority, set() + prio = min(bounds[u].mode_priority for u in mode_units) + if out.winner_priority is not None and out.winner_priority > prio: + return out.deciding_priority, set() + clo, chi = allocator.loop_bound(bounds, live) + t1 = max(clo, min(chi, out.target_w)) + before = out.target_w + binder = _yield_to(out, t1, _limits_above(site_claims, prio)) + if binder is None: + out.target_w = float(before) + return None, set() + held = set() + for u in mode_units: + b = bounds[u] + nlo, nhi, note = allocator.unit_interval(b) + if note is not None: + continue + if (nhi < b.hi) if out.target_w > 0 else (nlo > b.lo): + held.add(u) + return binder.priority, held diff --git a/goodwe_controller/app/control.py b/goodwe_controller/app/control.py index 12d1134..8e01509 100644 --- a/goodwe_controller/app/control.py +++ b/goodwe_controller/app/control.py @@ -17,6 +17,7 @@ exists because its absence produced a specific, observed failure. """ import math +from math import inf from dataclasses import dataclass # --------------------------------------------------------------------------- @@ -500,6 +501,14 @@ def compute( # main.py never builds one: every term of `loop_bound()` holds 0). if bound is not None and not (bound[0] <= 0.0 <= bound[1]): bound = None + # ...and a side of it that reaches the rail is left to the rail, so that + # SAFETY-03's `i_clamped` still fires when the loop winds into it. With + # every battery on, the bound IS the rail, and the law is exactly as it was. + if bound is not None: + _rail = min(tuning.max_w, tuning.max_w if tuning.integrator_max_w is None + else tuning.integrator_max_w) + bound = (float(bound[0]) if bound[0] > -_rail else -inf, + float(bound[1]) if bound[1] < _rail else inf) # --- saturation, WITH the duration term ------------------------------- # Command and actual diverging means the inverter cannot follow - it is at @@ -603,9 +612,8 @@ def compute( i_w = max(moved, i_w) # OPT-11: the integrator does not wind past what the modes let the - # batteries place. Applied BEFORE the integrator bound below, so a loop - # held by a mode reads as `mode-bound` and not as SAFETY-03's winding into - # the rail (`i_clamped`). That bound still bites where it is the tighter. + # batteries place. Only a side tighter than the rail is left in `bound` + # (above), so this never takes SAFETY-03's `i_clamped` away from the rail. if bound is not None: held = max(float(bound[0]), min(float(bound[1]), i_w)) if held != i_w: diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index ed80798..4cdaaf2 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -3692,9 +3692,9 @@ class Controller: # Filed only with battery modes on, and never on a battery in any # maintenance phase: maintenance at 60/40 outranks the mode. The range # always holds 0 W (packmode.mode_range()). pre_resolve() keeps it - # apart from the hard limits, and the allocator applies it only while - # the site target is decided at or below P_MODE, so the peak guard's - # site floor (P_PEAK) releases it and never meets it in a `lo > hi`. + # apart from the hard limits, and it gives way to the peak guard's + # site floor (P_PEAK) only as far as the floor needs, so the two never + # meet in a `lo > hi` (arbiter._unit_modes()). if self.modes_on: for inv, result in zip(self.inverters, maint_results): if result.phase != IDLE: diff --git a/goodwe_controller/test_arbiter.py b/goodwe_controller/test_arbiter.py index 6a8b6fe..071fb64 100644 --- a/goodwe_controller/test_arbiter.py +++ b/goodwe_controller/test_arbiter.py @@ -694,6 +694,102 @@ check("...and with no mode claim at all, stage 2's answer is untouched " resolve([Claim.set("loop", P_LOOP, 1200.0)], units=ONE).target_w == 1200.0) +print() +print("OPT-11 11. a mode gives way only as far as the peak limit needs") +# Review of PR #149. The first cut released the modes only when a limit above +# P_MODE bound S. A trade (30) that won above the peak floor then kept the +# modes, and the units placed less than the floor: the mode beat the peak +# guard (a billed peak). And a peak limit that merely capped a charge released +# the modes, so an Off battery was charged from the grid. The rule now: clamp +# into what the modes can place, then back into the limits above the mode. +_offb = {"inverter-b": (0.0, 0.0)} + + +def _placed(r): + return sum(r.shares.values()) + + +_ra = resolve(_two(0.0, arb=3000.0, peak_lo=2000.0, rating=1500.0, + modes=_offb)[:-1] + [ + Claim.set("arbitrage", P_ARBITRAGE, 3000.0), + Claim.limit("peak", P_PEAK, lo=2000.0)], units=TWO) +print(f" trade 3000 W over a 2000 W floor, A 1500 W, B off: {_ra.shares}") +check("⭐ a trade above the peak floor with B Off: the units still place the " + "floor (2000 W), A to its rating and B only the 500 W A cannot give", + _placed(_ra) == 2000 and _ra.shares == {"inverter-a": 1500, + "inverter-b": 500}) +_r1t = resolve(_one + [Claim.set("arbitrage", P_ARBITRAGE, 3000.0), + Claim.limit("peak", P_PEAK, lo=2000.0)], units=ONE) +check("⭐ ...and at N=1 an Off battery under a trade gives the peak floor " + "(2000 W), not 0 W", _r1t.target_w == 2000.0 + and _r1t.bound_by == "peak") +_rc = resolve(_two(0.0, arb=-3000.0, peak_lo=-1000.0, modes=_offb), + units=TWO) +check("⭐ a grid charge the peak limit caps at 1000 W does not release B's " + "Off: A takes it all and B stays at 0 W", + _rc.shares == {"inverter-a": -1000, "inverter-b": 0}) +_r1c = resolve(_one + [Claim.set("arbitrage", P_ARBITRAGE, -3000.0), + Claim.limit("peak", P_PEAK, lo=-1000.0)], units=ONE) +check("...and at N=1 an Off battery is not charged from the grid under a " + "capped trade", _r1c.target_w == 0.0) +_rp = resolve(_two(500.0, peak_lo=1500.0, modes=_offb), units=TWO) +check("a peak floor A can carry alone leaves B Off at 0 W", + _rp.shares == {"inverter-a": 1500, "inverter-b": 0} + and _rp.target_w == 1500.0) +_rall = resolve(_two(0.0, peak_lo=3000.0, + modes={"inverter-a": (0.0, 0.0), + "inverter-b": (0.0, 0.0)}), units=TWO) +check("every battery Off and a 3000 W peak floor: the floor is placed, " + "split between them, and nothing contradicts", + _placed(_rall) == 3000 and not _rall.contradiction + and _rall.allocation.refused is None) + +import random as _rnd # noqa: E402 +_g = _rnd.Random(149) +_worse, _needless, _sign, _nn = [], [], [], 0 +for _ in range(20000): + _units = ("inverter-a", "inverter-b", "inverter-c")[:_g.choice((2, 3))] + _cl = [] + for _u in _units: + _rt = _g.choice((1000.0, 2500.0)) + _cl.append(Claim.limit("supervised", P_SAFETY, -_rt, _rt, + actuator=_u)) + if _g.random() < 0.2: + _cl.append(Claim.limit("pack", P_SAFETY, + *_g.choice(((0.0, inf), (-inf, 0.0))), + actuator=_u)) + _cl.append(Claim.limit(MODE_SOURCE, P_MODE, + _g.choice((0.0, -_rt)), _g.choice((0.0, _rt)), + actuator=_u)) + _src = _g.choice(("loop", "arbitrage")) + _cl.append(Claim.set(_src, P_LOOP if _src == "loop" else P_ARBITRAGE, + _g.uniform(-7000.0, 7000.0))) + _pk = _g.uniform(-4000.0, 6000.0) + _cl.append(Claim.limit("peak", P_PEAK, lo=_pk)) + _r = resolve(_cl, units=_units) + _r0 = resolve([c for c in _cl if c.source != MODE_SOURCE], units=_units) + _nn += 1 + if _r.allocation.refused is not None: + _sign.append(_cl) + continue + if _placed(_r) < _pk - 1 and _placed(_r0) >= _pk - 1: + _worse.append(_cl) + # An Off unit gives power only when the peak floor is short without it. + _off = [c.actuator for c in _cl if c.source == MODE_SOURCE + and c.lo == 0.0 and c.hi == 0.0] + # What the units whose mode lets them give power can give, within it. + _on_cap = sum(max(0.0, min(_r.unit_bounds[u].hi, _r.unit_bounds[u].mode_hi)) + for u in _units if _r.unit_bounds[u].mode_hi > 0.0 + and _r.unit_bounds[u].lo <= 0.0) + if any(_r.shares.get(u, 0) > 0 for u in _off) and _pk <= _on_cap - 1: + _needless.append(_cl) +check(f"over {_nn} random N=2/3 cycles (modes, packs, a trade or the loop, a " + f"peak floor): no split is refused, the placed power never falls under " + f"the floor where the same cycle without modes met it, and an Off " + f"battery gives power only when the others cannot carry the floor " + f"({len(_sign)}, {len(_worse)}, {len(_needless)})", + not _sign and not _worse and not _needless) + print() if fails: print(f"{len(fails)} FAILED: {', '.join(fails)}") diff --git a/goodwe_controller/test_control.py b/goodwe_controller/test_control.py index 668d2bd..9db66c2 100644 --- a/goodwe_controller/test_control.py +++ b/goodwe_controller/test_control.py @@ -1268,6 +1268,30 @@ for _ in range(4000): check("a bound wider than the rail changes no Decision (4000 random cycles)", _wide) +# (g) Review of PR #149: a bound EQUAL to the rail - every battery on, the +# default once modes are on - must leave SAFETY-03 exactly as it was. Before +# the fix the mode clamp ran first and took `i_clamped` away from the rail. +_rail_same, _rail_hits = True, 0 +for _tb in (TB, Tuning(gain=0.6, max_w=5000.0, slew_w=400.0, deadband_w=15.0, + step_w=10, target_grid_w=-10.0, + integrator_max_w=7000.0)): + _lim = _tb.max_w + for _ in range(4000): + args = (_rngc.uniform(-5000, 5000), _rngc.uniform(-12000, 12000), + _rngc.uniform(-5000, 5000), _tb, _rngc.randint(0, 5), + _rngc.choice((None, _rngc.uniform(-8000, 8000)))) + _d0 = compute(*args) + _rail_hits += _d0.i_clamped + if compute(*args, bound=(-_lim, _lim)) != _d0: + _rail_same = False +check(f"a bound equal to the rail changes no Decision, SAFETY-03's i_clamped " + f"included ({_rail_hits} of 8000 random cycles wind into the rail), and " + f"with integrator_max_w above max_w too", _rail_same and _rail_hits > 100) +_one_side = compute(4000.0, 12000.0, 4000.0, TB, 0, 4990.0, + bound=(0.0, 5000.0)) +check("...and a bound that closes only the other side still lets the rail " + "flag a loop winding into it", _one_side.i_clamped) + print() if fails: print(f"{len(fails)} FAILED: {', '.join(fails)}") diff --git a/goodwe_controller/test_modes.py b/goodwe_controller/test_modes.py index 0acb952..2f47c4f 100644 --- a/goodwe_controller/test_modes.py +++ b/goodwe_controller/test_modes.py @@ -98,6 +98,12 @@ A5DDDAD = { "n2": "a60003b1930fa3cba21f3220670d9172b9ff438347d77ba9bc3383fca1ceb15a", "n1_off": "fc3bca170c46920f08d86baee62ea883954a89ea6222d6bbf7d6652877226262", "n2_off": "a60003b1930fa3cba21f3220670d9172b9ff438347d77ba9bc3383fca1ceb15a", + # Added in review (PR #149), printed on a5dddad the same way: both + # batteries due for maintenance, and the arbiter alone on 6000 seeded + # claim lists with the peak floor, trades, maintenance and refusals. + "n1_maint": "5a21d43ec7cbf95a224bbb9bca13a8e62a00161101b586ae9fa68ab565992977", + "n2_maint": "48a1ad96121592e207a4664fb2da9f5208a49b88e5802a358873a5d30cb8c18e", + "resolve": "43a874216711936e14b9185bcc8d26c58fe4f3cfc3c1c8eaf89bffd95d6e81ef", } _claims, _undo_c = spy_claims() _bounds, _undo_b = spy_bounds() @@ -112,7 +118,8 @@ for k in A5DDDAD: check("⭐ at N=1 and N=2, with the option absent and with it false, every " "command, reason and allocation record over 540 cycles each is " "byte-identical to a5dddad (house steps, a solar surplus, a battery " - "at its cut-out level, and at N=2 a refusal with probes)", + "at its cut-out level, at N=2 a refusal with probes, both batteries " + "due for maintenance), and so is the arbiter on 6000 claim lists", _now == A5DDDAD) check("...and no cycle in those runs filed a `mode` claim or handed " "`compute()` a bound", @@ -194,6 +201,20 @@ finally: _undo_b() check("...because main.py hands compute() the loop bound, B's 0 W included: " "[-2500, 2500]", _bounds and set(_bounds) == {(-2500.0, 2500.0)}) +# Review (PR #149): a unit the allocator drops (stale link) must not count in +# the loop bound either, or the loop asks for watts nobody live can place. +_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, soc=lambda u, t: 50.0, + cut=lambda u, t: False) +_both = _c.loop_bound([app_main.Claim.limit( + "supervised", app_main.P_SAFETY, -2500.0, 2500.0, + actuator=_c.actuator_of("supervised", i)) for i in _c.inverters]) +_c.live_units = lambda: (A,) +_one_live = _c.loop_bound([app_main.Claim.limit( + "supervised", app_main.P_SAFETY, -2500.0, 2500.0, + actuator=_c.actuator_of("supervised", i)) for i in _c.inverters]) +check("the loop bound counts only live units: both live [-5000, 5000], B's " + "link stale [-2500, 2500]", + _both == (-5000.0, 5000.0) and _one_live == (-2500.0, 2500.0)) for _n in (1, 2): _st = store_with(**{A: (False, packmode.SOLAR_FIRST), @@ -310,10 +331,14 @@ check("pack_use stores the switch, and both are written to the action log", _bad = [asyncio.run(_c.handle_action("pack_use", unit="inverter-z", value=True)), asyncio.run(_c.handle_action("pack_use", unit=A, value="yes")), + # 0 == False in Python: a stored 0 would read back as ON. + asyncio.run(_c.handle_action("pack_use", unit=A, value=0)), + asyncio.run(_c.handle_action("pack_use", unit=A, value=1)), asyncio.run(_c.handle_action("pack_mode", unit=A, value=packmode.CHEAP_HOURS)), asyncio.run(_c.handle_action("pack_mode", unit=A, value=None))] -check("an unknown battery, a switch that is not true or false, and a mode " +check("an unknown battery, a switch that is not true or false (0 and 1 " + "included), and a mode " "this release does not offer (Cheap hours arrives in P3) are refused, " "with a plain reason, and store nothing", all(isinstance(x, dict) and set(x) == {"error"} for x in _bad) diff --git a/goodwe_controller/tools/opt11_identity.py b/goodwe_controller/tools/opt11_identity.py index 49764f4..3339a97 100644 --- a/goodwe_controller/tools/opt11_identity.py +++ b/goodwe_controller/tools/opt11_identity.py @@ -14,7 +14,8 @@ printed on a5dddad (they are written there, with that provenance). Scenarios: N=1 and N=2, a house that steps, a surplus of solar, a battery reaching its cut-out level and, at N=2, a battery that refuses (SAFETY-73) and -is probed. +is probed; the same with both batteries due for maintenance; and the arbiter +alone on 6000 seeded claim lists (peak floor, trades, maintenance, refusals). """ import asyncio @@ -187,6 +188,11 @@ def digest(rows) -> str: return hashlib.sha256(repr(rows).encode()).hexdigest() +# Both batteries due for their monthly cycle at the run's first cycle, so the +# maintenance slot, its pins and its shaping are in every row (PR #149 review: +# the scenarios above never reach maintenance). +_MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11} + SCENARIOS = { "n1": dict(n=1), "n2": dict(n=2), @@ -194,11 +200,78 @@ SCENARIOS = { # option without turning it on runs. The old tree ignores the key. "n1_off": dict(n=1, extra={"battery_modes_enabled": False}), "n2_off": dict(n=2, extra={"battery_modes_enabled": False}), + "n1_maint": dict(n=1, extra=_MAINT), + "n2_maint": dict(n=2, extra=_MAINT), } +def resolve_rows(cases=6000, seed=1149): + """The arbiter alone, on seeded random claim lists with no mode claim: + the peak floor, a trade, the loop, maintenance pins and shaping, SAFETY-73 + refusal limits and probe pins, packs, ratings and safety stops, at one to + three inverters. The peak guard needs a P1 meter the cycle runs above do + not have, so its claim is graded here. Uses only a5dddad's API.""" + import random + from math import inf + from app import arbiter as a + rnd = random.Random(seed) + rows = [] + for _ in range(cases): + n = rnd.choice((1, 2, 3)) + units = ("inverter-a", "inverter-b", "inverter-c")[:n] + + def act(src, u): + return a.actuator_for(src, u, units) + cl = [] + for u in units: + r = rnd.choice((1000.0, 2500.0, 5000.0)) + cl.append(a.Claim.limit("supervised", a.P_SAFETY, -r, r, "max_w", + actuator=act("supervised", u))) + x = rnd.random() + if x < 0.15: + cl.append(a.Claim.limit("pack", a.P_SAFETY, *rnd.choice( + ((0.0, inf), (-inf, 0.0))), actuator=act("pack", u))) + elif x < 0.25: + cl.append(a.Claim.limit("refused", a.P_SAFETY, *rnd.choice( + ((-inf, 0.0), (0.0, inf))), "refused", + actuator=act("refused", u))) + elif x < 0.32: + cl.append(a.Claim.set("refused", a.P_SAFETY, + rnd.choice((200.0, -200.0)), "probing", + actuator=act("refused", u))) + elif x < 0.42: + cl.append(a.Claim.set("maintenance", a.P_MAINTENANCE, + rnd.choice((2000.0, -2000.0)), "drain", + actuator=act("maintenance", u))) + elif x < 0.5: + cl.append(a.Claim.limit("maintenance", a.P_MAINT_SHAPE, + hi=-rnd.choice((500.0, 2000.0)), + actuator=act("maintenance", u))) + y = rnd.random() + if y < 0.8: + cl.append(a.Claim.set("loop", a.P_LOOP, rnd.uniform(-8000, 8000))) + if 0.5 < y < 0.9: + cl.append(a.Claim.set("arbitrage", a.P_ARBITRAGE, + rnd.uniform(-6000, 6000))) + if rnd.random() < 0.6: + cl.append(a.Claim.limit("peak", a.P_PEAK, + lo=rnd.uniform(-4000, 6000))) + if rnd.random() < 0.05: + cl.append(a.Claim.set("safety", a.P_SAFETY, 0.0, "stopped")) + rnd.shuffle(cl) + r = a.resolve(cl, units=units if n > 1 else None, + weights=(None if rnd.random() < 0.5 else + {u: rnd.choice((0.0, 0.5, 1.0)) for u in units})) + rows.append((r.target_w, r.winner, r.bound_by, r.contradiction, + tuple(sorted((r.shares or {}).items())), r.allocated, + r.allocation.explain() if r.allocation else None)) + return rows + + def digests() -> dict: - return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()} + out = {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()} + out["resolve"] = digest(resolve_rows()) + return out if __name__ == "__main__": @@ -208,5 +281,9 @@ if __name__ == "__main__": print(f"{k}: {len(rows)} cycles, loop reasons " f"{sorted({r[3] for r in rows})}, arbiter " f"{sorted({r[6].split(' ->')[0] for r in rows})}") + rr = resolve_rows() + print(f"resolve: {len(rr)} claim lists, winners " + f"{sorted({r[1] for r in rr})}, bound by " + f"{sorted({str(r[2]).split('(')[0] for r in rr})}") for k, v in digests().items(): print(f"{k} {v}")