OPT-11 P1: per-battery mode limits, on/off, solar order
A battery's mode is a soft unit limit (source `mode`, P_MODE 35) kept
apart from the hard limits in pre_resolve(). The allocator applies it
only while the site target is decided at or below 35 (new
Resolution.deciding_priority), so the peak floor releases it and a mode
`hi` never meets the site `lo`. At N=1 resolve() applies the same rule
to the site target after stage 2. A mode range without 0 W is dropped
as a software fault, so REFUSE_SIGN stays unreachable from a mode.
compute() gets the loop bound (sum over live units of hard limits with
modes applied), so the loop never asks for watts no battery may give
and the saturation freeze never parks on them. While charging, Leftover
solar weighs 0 so Solar first fills first. Store keys pack_use and
pack_mode per battery, set by two new /action values; Off stops and
skips maintenance with a log line. Minimal Batteries cards (EN/NL).
Off by default (battery_modes_enabled); off, commands are byte-identical
to a5dddad at N=1 and N=2 (test_modes.py part 1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1041,6 +1041,133 @@ check("...and the three that are NOT caught are reported as such, with the "
|
||||
all(b is False for _n, b, _w in MUT[-3:]))
|
||||
|
||||
|
||||
|
||||
print()
|
||||
print("13. OPT-11: the mode layer - applied at or below 35, released above it")
|
||||
# A mode is a SOFT unit limit beside the hard ones (UnitBounds.mode_*). The
|
||||
# allocator applies it only when the site target was decided at or below the
|
||||
# mode's own priority (P_MODE, 35), drops it when its range leaves out 0 W,
|
||||
# and never lets it narrow a hard limit into nothing.
|
||||
from app.allocator import (MODE_FAULT, MODE_OUTRANKED, MODE_RELEASED, # noqa: E402
|
||||
loop_bound, unit_interval)
|
||||
from app.arbiter import P_MODE # noqa: E402
|
||||
|
||||
|
||||
def M(lo=-RATING, hi=RATING, mlo=-inf, mhi=inf, prio=P_MODE, **kw):
|
||||
"""A unit's bounds with a mode range beside its hard limits."""
|
||||
b = B(lo, hi, **kw)
|
||||
return UnitBounds(lo=b.lo, hi=b.hi, gate=b.gate, pinned_w=b.pinned_w,
|
||||
pinned_by=b.pinned_by, contradiction=b.contradiction,
|
||||
mode_lo=mlo, mode_hi=mhi, mode_priority=prio)
|
||||
|
||||
|
||||
_off = {A: M(), Bee: M(mlo=0.0, mhi=0.0)}
|
||||
_at35 = plan(3000, _off, deciding_priority=P_MODE)
|
||||
_at50 = plan(3000, _off, deciding_priority=P_PEAK)
|
||||
_at10 = plan(3000, _off, deciding_priority=P_LOOP)
|
||||
print(f" B off, 3000 W: decided at 10 {_at10.explain()} | at 35 "
|
||||
f"{_at35.explain()} | at 50 {_at50.explain()}")
|
||||
check("⭐ RELEASE AT 35: a target decided at P_MODE itself keeps the mode - "
|
||||
"B (Off) is held at 0 W and A carries all 3000 W",
|
||||
_at35.shares == {A: 3000, Bee: 0} and _at35.unmet_w == 0)
|
||||
check("...and below it too (the loop, 10): the same split",
|
||||
_at10.shares == _at35.shares)
|
||||
check("⭐ RELEASE AT 50: a target decided by the peak floor releases the mode "
|
||||
"and B takes its weighted half",
|
||||
_at50.shares == {A: 1500, Bee: 1500} and _at50.unmet_w == 0)
|
||||
check("...and the boundary is exactly 35: at 36 the mode is released",
|
||||
plan(3000, _off, deciding_priority=P_MODE + 1).shares
|
||||
== {A: 1500, Bee: 1500})
|
||||
check("...and no deciding priority named means the mode applies (the safe "
|
||||
"reading for a direct caller)",
|
||||
plan(3000, _off).shares == {A: 3000, Bee: 0})
|
||||
check("unit_interval() reports why: None when applied, MODE_RELEASED above "
|
||||
"35, and the hard limits always",
|
||||
unit_interval(_off[Bee], P_MODE) == (0.0, 0.0, None)
|
||||
and unit_interval(_off[Bee], P_PEAK) == (-RATING, RATING, MODE_RELEASED))
|
||||
|
||||
# CONTAINS 0: a mode that would FORCE a direction is a software fault.
|
||||
_force = {A: M(mlo=500.0, mhi=RATING), Bee: M()}
|
||||
_f = plan(-1000, _force, deciding_priority=P_LOOP)
|
||||
print(f" A's mode is [500, 5000] (leaves out 0 W), site charges 1000 W: "
|
||||
f"{_f.explain()}, faults {_f.mode_faults}")
|
||||
check("⭐ CONTAINS 0: a mode range that leaves out 0 W is dropped and named "
|
||||
"as a fault, so REFUSE_SIGN stays unreachable: both units charge and "
|
||||
"the split is allowed",
|
||||
_f.ok and _f.mode_faults == ((A, MODE_FAULT),)
|
||||
and all(v <= 0 for v in _f.shares.values())
|
||||
and sum(_f.shares.values()) == -1000)
|
||||
_f_obeyed = plan(-1000, {A: B(500.0, RATING), Bee: B(-RATING, RATING)})
|
||||
check("...and that is not a hypothetical: the same range obeyed as a hard "
|
||||
"limit forces A to give 500 W while B charges 1500 W, which the "
|
||||
"allocator REFUSES as circulating power",
|
||||
not _f_obeyed.ok and REFUSE_SIGN in _f_obeyed.refused)
|
||||
check("...and a mode whose own lo is above its own hi is the same fault",
|
||||
unit_interval(M(mlo=300.0, mhi=-300.0))[2] == MODE_FAULT)
|
||||
_out = unit_interval(M(lo=-2000.0, hi=-500.0, mlo=0.0, mhi=0.0))
|
||||
check("a hard limit that leaves the mode no room wins outright "
|
||||
"(MODE_OUTRANKED): the unit keeps its hard interval",
|
||||
_out == (-2000.0, -500.0, MODE_OUTRANKED))
|
||||
check("a unit with no mode (every unit with battery modes off) gets exactly "
|
||||
"its hard limits, and plan() is the same record as before the mode "
|
||||
"fields existed",
|
||||
unit_interval(B(-700.0, 900.0)) == (-700.0, 900.0, None)
|
||||
and plan(1234, TWO, deciding_priority=P_PEAK) == plan(1234, TWO))
|
||||
|
||||
# The sweep: sum and sign kept whatever the modes, the target and the
|
||||
# deciding priority; every share inside its effective interval.
|
||||
_rng = random.Random(1135)
|
||||
_bad, _nm = [], 0
|
||||
for _ in range(20000):
|
||||
_units = {}
|
||||
for u in (A, Bee, "inverter-c")[:_rng.choice((2, 3))]:
|
||||
r = _rng.choice((2000.0, 3600.0, 5000.0))
|
||||
mlo = _rng.choice((-r, -700.0, 0.0))
|
||||
mhi = _rng.choice((0.0, 450.0, r))
|
||||
_units[u] = M(-r, r, mlo, mhi)
|
||||
_d = _rng.choice((P_LOOP, P_ARBITRAGE, P_MODE, P_MODE + 1, P_PEAK, None))
|
||||
_t = _rng.randint(-15000, 15000)
|
||||
_p = plan(_t, _units, deciding_priority=_d)
|
||||
_nm += 1
|
||||
_eff = {u: unit_interval(b, _d)[:2] for u, b in _units.items()}
|
||||
_sh = _p.shares
|
||||
if (not _p.ok or sum(_sh.values()) + _p.unmet_w != _p.sum_w
|
||||
or (any(v > 0 for v in _sh.values())
|
||||
and any(v < 0 for v in _sh.values()))
|
||||
or any(not (_eff[u][0] <= _sh[u] <= _eff[u][1]) for u in _sh)
|
||||
or (_d is not None and _d > P_MODE
|
||||
and any(_eff[u] != (b.lo, b.hi) for u, b in _units.items()))):
|
||||
_bad.append((_t, _d, _p.explain()))
|
||||
for x in _bad[:5]:
|
||||
print(f" BAD {x}")
|
||||
check(f"over {_nm} random ticks (2-3 units, mode ranges holding 0 W, targets "
|
||||
f"+-15 kW, deciding at 10/30/35/36/50/None) the shares sum to S with "
|
||||
f"`unmet`, never split the sign, and each stays inside its interval - "
|
||||
f"the mode's above 35 released, applied at or below",
|
||||
not _bad and _nm == 20000)
|
||||
|
||||
# loop_bound(): what the loop may ask when it decides.
|
||||
_lb = loop_bound({A: M(mlo=-3000.0, mhi=0.0), Bee: M(mlo=0.0, mhi=0.0)})
|
||||
check("loop_bound() sums each unit's interval with its mode applied: A may "
|
||||
"only charge 3000 W, B is off, so the loop may ask [-3000, 0]",
|
||||
_lb == (-3000.0, 0.0))
|
||||
check("...a unit that is not live adds nothing",
|
||||
loop_bound({A: M(), Bee: M()}, live=(A,)) == (-RATING, RATING))
|
||||
check("...a PINNED unit adds the span from 0 to its pin, so a 200 W probe "
|
||||
"(SAFETY-73: the closed limit becomes a pin) stays reachable",
|
||||
loop_bound({A: M(gate=P_SAFETY, pin=200.0),
|
||||
Bee: M(mlo=0.0, mhi=0.0)}) == (0.0, 200.0))
|
||||
check("...and the pin is clamped into the hard limits first, as plan() "
|
||||
"clamps it",
|
||||
loop_bound({A: M(lo=-RATING, hi=0.0, gate=P_SAFETY, pin=200.0),
|
||||
Bee: M(mlo=0.0, mhi=0.0)}) == (0.0, 0.0))
|
||||
check("...a mode that leaves out 0 W adds the hard interval (dropped, as in "
|
||||
"plan()), so every term holds 0 and so does the sum",
|
||||
loop_bound({A: M(mlo=500.0, mhi=900.0), Bee: M(mlo=0.0, mhi=0.0)})
|
||||
== (-RATING, RATING))
|
||||
check("...and a unit contradicting itself adds nothing",
|
||||
loop_bound({A: M(contra=True), Bee: M(mlo=0.0, mhi=0.0)}) == (0.0, 0.0))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
Reference in New Issue
Block a user