OPT-11 P1 review: a mode never beats the peak floor; SAFETY-03 kept at the rail
- arbiter: a mode now gives way only as far as a site limit above P_MODE
needs (_unit_modes at N>1, _site_mode at N=1). Before, the modes were
released only when the peak floor bound S, so a trade won above the floor
kept them and placed less than the floor (an Off battery at N=1 went to 0 W
under a 2000 W floor), and a capped grid charge released an Off battery.
While released, held batteries weigh 0 and carry only the shortfall.
- control: a side of the loop bound that reaches the rail is left to the
rail, so SAFETY-03's i_clamped still fires with every battery on.
- opt11_identity: add maintenance scenarios and a 6000-case arbiter digest
(peak floor, trades, maintenance, refusals); a5dddad digests pinned.
- tests: test_arbiter part 11, test_control (g), test_modes (0/1 refused,
loop bound counts live units only). DOCS and DECISIONS say the new rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d69fd8fbfd
commit
ffc9a4d721
@@ -1,14 +1,21 @@
|
|||||||
# OPT-11 - battery modes, per battery
|
# OPT-11 - battery modes, per battery
|
||||||
|
|
||||||
A battery's mode (on/off, Solar first, Leftover solar) is a soft unit limit at
|
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
|
`P_MODE` = 35 that always holds 0 W. A SET above 35 (a safety stop) overrides
|
||||||
decided at or below 35, and is released by anything above it, such as the peak
|
it; a limit above 35 (the peak guard) makes it give way only as far as that
|
||||||
guard. Design and owner answers: `goodwe-session/OPT-11-design.md` (2026-09-27);
|
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.
|
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
|
- Off by default (`battery_modes_enabled`); off, commands are byte-identical to
|
||||||
a5dddad (`test_modes.py` part 1).
|
a5dddad (`test_modes.py` part 1).
|
||||||
- Off is a mode range, so the peak guard can still use an Off battery (design
|
- Off is a mode range, so the peak guard can still use an Off battery when the
|
||||||
sections 2-3). Whether Off should be absolute is an open owner question.
|
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.
|
- 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.
|
Price runs, selling (`pack_sell`, reserved) and the forecast are P3/P4.
|
||||||
|
|||||||
@@ -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.
|
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.
|
- **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.
|
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`
|
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
|
||||||
|
|
||||||
|
|||||||
@@ -304,9 +304,9 @@ def unit_interval(b, deciding_priority=None):
|
|||||||
"""OPT-11. `(lo, hi, note)`: the interval a unit is allocated into.
|
"""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
|
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
|
(`b.mode_lo`, `b.mode_hi`) narrows them unless `deciding_priority` is
|
||||||
decided at or below the mode's own priority - `deciding_priority` None
|
above the mode's own priority (arbiter._unit_modes() passes None when the
|
||||||
means no higher claim was named, so the mode applies. `note` says why 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 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
|
* MODE_FAULT: the mode range leaves out 0 W. A mode may forbid a direction
|
||||||
|
|||||||
@@ -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.
|
# loop would otherwise cancel every charge the moment it created import.
|
||||||
P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose
|
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
|
# 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
|
# limit, kept apart from the hard ones. Above arbitrage, so a trade stays inside
|
||||||
# is decided at or below this priority. Above arbitrage, so a trade stays inside
|
# each battery's mode; below the peak guard, to which a mode gives way only as
|
||||||
# each battery's mode; below the peak guard, which releases every mode.
|
# far as the floor needs (`_unit_modes()`, `_site_mode()`).
|
||||||
# DECISIONS/OPT-11-battery-modes.md.
|
# DECISIONS/OPT-11-battery-modes.md.
|
||||||
P_MODE = 35
|
P_MODE = 35
|
||||||
P_LOOP = 10 # ordinary grid-following
|
P_LOOP = 10 # ordinary grid-following
|
||||||
@@ -250,8 +250,8 @@ CLAIM_SCOPE = {
|
|||||||
# main.py: `Claim.limit("mode", P_MODE, lo, hi, ...)`, OPT-11. THIS
|
# 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
|
# 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
|
# 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
|
# hard limits, and it gives way to a site limit above P_MODE only as far as
|
||||||
# at or below P_MODE. At N=1 it routes to the site like every source, and
|
# 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.
|
# `resolve()` takes it out of stage 2 for the same reason.
|
||||||
"mode": SCOPE_UNIT,
|
"mode": SCOPE_UNIT,
|
||||||
}
|
}
|
||||||
@@ -434,9 +434,10 @@ class Resolution:
|
|||||||
# `explain()` names.
|
# `explain()` names.
|
||||||
winner_priority: int | None = None
|
winner_priority: int | None = None
|
||||||
# OPT-11. The higher of the winning set's priority and the priority of the
|
# 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
|
# limit that bound `target_w`. Whether the modes hold is decided from the
|
||||||
# this is at or below that mode's priority (P_MODE), so a peak floor that
|
# limits themselves (`_site_mode()`, `_unit_modes()`): a mode gives way
|
||||||
# binds S (P_PEAK) releases every mode and never meets a mode `hi`.
|
# 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
|
deciding_priority: int | None = None
|
||||||
# OPT-11 at N=1: why the site's mode layer was dropped this tick, or 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.
|
# 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.
|
# `claims`, so this is the shipped arbiter on the shipped list.
|
||||||
out = site_resolve(site_claims)
|
out = site_resolve(site_claims)
|
||||||
if site_modes:
|
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.
|
# 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
|
# 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
|
# 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).
|
# 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):
|
if callable(weights):
|
||||||
weights = weights(out.target_w)
|
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.shares, out.allocated, out.allocation = allocate_record(
|
||||||
out.target_w, bounds, weights=weights, live=live,
|
out.target_w, bounds, weights=weights, live=live,
|
||||||
scope_errors=out.scope_errors, site_priority=out.winner_priority,
|
scope_errors=out.scope_errors, site_priority=out.winner_priority,
|
||||||
tick=tick, deciding_priority=out.deciding_priority)
|
tick=tick, deciding_priority=gate)
|
||||||
return out
|
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.
|
"""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
|
A SET above the mode (a safety stop) decides alone. Otherwise the target is
|
||||||
only when stage 2 decided at or below its priority, and a range without 0 W
|
clamped into the mode range and then back into the site limits above the
|
||||||
is a software fault that drops the mode (`out.mode_fault`). A clamp of one
|
mode, so the mode gives way only as far as those limits need: a peak floor
|
||||||
number into a range that holds 0 cannot contradict anything, so the peak
|
of 2000 W over an Off battery asks it for 2000 W, whether a trade or the
|
||||||
floor still wins by releasing the mode, as at N>1.
|
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]
|
out.considered = out.considered + [f"{c.source}:{c.kind}" for c in modes]
|
||||||
lims = [c for c in modes if c.kind == LIMIT]
|
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):
|
if not (lo <= 0.0 <= hi):
|
||||||
out.mode_fault = f"mode range [{lo:.0f}, {hi:.0f}] leaves out 0 W"
|
out.mode_fault = f"mode range [{lo:.0f}, {hi:.0f}] leaves out 0 W"
|
||||||
return
|
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
|
return
|
||||||
target = max(lo, min(hi, out.target_w))
|
before = out.target_w
|
||||||
if target != 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]
|
c = lims[0]
|
||||||
out.bound_by = f"{c.source}({c.reason})" if c.reason else c.source
|
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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ exists because its absence produced a specific, observed failure.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from math import inf
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -500,6 +501,14 @@ def compute(
|
|||||||
# main.py never builds one: every term of `loop_bound()` holds 0).
|
# 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]):
|
if bound is not None and not (bound[0] <= 0.0 <= bound[1]):
|
||||||
bound = None
|
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 -------------------------------
|
# --- saturation, WITH the duration term -------------------------------
|
||||||
# Command and actual diverging means the inverter cannot follow - it is at
|
# Command and actual diverging means the inverter cannot follow - it is at
|
||||||
@@ -603,9 +612,8 @@ def compute(
|
|||||||
i_w = max(moved, i_w)
|
i_w = max(moved, i_w)
|
||||||
|
|
||||||
# OPT-11: the integrator does not wind past what the modes let the
|
# OPT-11: the integrator does not wind past what the modes let the
|
||||||
# batteries place. Applied BEFORE the integrator bound below, so a loop
|
# batteries place. Only a side tighter than the rail is left in `bound`
|
||||||
# held by a mode reads as `mode-bound` and not as SAFETY-03's winding into
|
# (above), so this never takes SAFETY-03's `i_clamped` away from the rail.
|
||||||
# the rail (`i_clamped`). That bound still bites where it is the tighter.
|
|
||||||
if bound is not None:
|
if bound is not None:
|
||||||
held = max(float(bound[0]), min(float(bound[1]), i_w))
|
held = max(float(bound[0]), min(float(bound[1]), i_w))
|
||||||
if held != i_w:
|
if held != i_w:
|
||||||
|
|||||||
@@ -3692,9 +3692,9 @@ class Controller:
|
|||||||
# Filed only with battery modes on, and never on a battery in any
|
# Filed only with battery modes on, and never on a battery in any
|
||||||
# maintenance phase: maintenance at 60/40 outranks the mode. The range
|
# maintenance phase: maintenance at 60/40 outranks the mode. The range
|
||||||
# always holds 0 W (packmode.mode_range()). pre_resolve() keeps it
|
# always holds 0 W (packmode.mode_range()). pre_resolve() keeps it
|
||||||
# apart from the hard limits, and the allocator applies it only while
|
# apart from the hard limits, and it gives way to the peak guard's
|
||||||
# the site target is decided at or below P_MODE, so the peak guard's
|
# site floor (P_PEAK) only as far as the floor needs, so the two never
|
||||||
# site floor (P_PEAK) releases it and never meets it in a `lo > hi`.
|
# meet in a `lo > hi` (arbiter._unit_modes()).
|
||||||
if self.modes_on:
|
if self.modes_on:
|
||||||
for inv, result in zip(self.inverters, maint_results):
|
for inv, result in zip(self.inverters, maint_results):
|
||||||
if result.phase != IDLE:
|
if result.phase != IDLE:
|
||||||
|
|||||||
@@ -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
|
resolve([Claim.set("loop", P_LOOP, 1200.0)], units=ONE).target_w
|
||||||
== 1200.0)
|
== 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()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||||
|
|||||||
@@ -1268,6 +1268,30 @@ for _ in range(4000):
|
|||||||
check("a bound wider than the rail changes no Decision (4000 random cycles)",
|
check("a bound wider than the rail changes no Decision (4000 random cycles)",
|
||||||
_wide)
|
_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()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ A5DDDAD = {
|
|||||||
"n2": "a60003b1930fa3cba21f3220670d9172b9ff438347d77ba9bc3383fca1ceb15a",
|
"n2": "a60003b1930fa3cba21f3220670d9172b9ff438347d77ba9bc3383fca1ceb15a",
|
||||||
"n1_off": "fc3bca170c46920f08d86baee62ea883954a89ea6222d6bbf7d6652877226262",
|
"n1_off": "fc3bca170c46920f08d86baee62ea883954a89ea6222d6bbf7d6652877226262",
|
||||||
"n2_off": "a60003b1930fa3cba21f3220670d9172b9ff438347d77ba9bc3383fca1ceb15a",
|
"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()
|
_claims, _undo_c = spy_claims()
|
||||||
_bounds, _undo_b = spy_bounds()
|
_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 "
|
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 "
|
"command, reason and allocation record over 540 cycles each is "
|
||||||
"byte-identical to a5dddad (house steps, a solar surplus, a battery "
|
"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)
|
_now == A5DDDAD)
|
||||||
check("...and no cycle in those runs filed a `mode` claim or handed "
|
check("...and no cycle in those runs filed a `mode` claim or handed "
|
||||||
"`compute()` a bound",
|
"`compute()` a bound",
|
||||||
@@ -194,6 +201,20 @@ finally:
|
|||||||
_undo_b()
|
_undo_b()
|
||||||
check("...because main.py hands compute() the loop bound, B's 0 W included: "
|
check("...because main.py hands compute() the loop bound, B's 0 W included: "
|
||||||
"[-2500, 2500]", _bounds and set(_bounds) == {(-2500.0, 2500.0)})
|
"[-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):
|
for _n in (1, 2):
|
||||||
_st = store_with(**{A: (False, packmode.SOLAR_FIRST),
|
_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",
|
_bad = [asyncio.run(_c.handle_action("pack_use", unit="inverter-z",
|
||||||
value=True)),
|
value=True)),
|
||||||
asyncio.run(_c.handle_action("pack_use", unit=A, value="yes")),
|
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,
|
asyncio.run(_c.handle_action("pack_mode", unit=A,
|
||||||
value=packmode.CHEAP_HOURS)),
|
value=packmode.CHEAP_HOURS)),
|
||||||
asyncio.run(_c.handle_action("pack_mode", unit=A, value=None))]
|
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, "
|
"this release does not offer (Cheap hours arrives in P3) are refused, "
|
||||||
"with a plain reason, and store nothing",
|
"with a plain reason, and store nothing",
|
||||||
all(isinstance(x, dict) and set(x) == {"error"} for x in _bad)
|
all(isinstance(x, dict) and set(x) == {"error"} for x in _bad)
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
import asyncio
|
||||||
@@ -187,6 +188,11 @@ def digest(rows) -> str:
|
|||||||
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
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 = {
|
SCENARIOS = {
|
||||||
"n1": dict(n=1),
|
"n1": dict(n=1),
|
||||||
"n2": dict(n=2),
|
"n2": dict(n=2),
|
||||||
@@ -194,11 +200,78 @@ SCENARIOS = {
|
|||||||
# option without turning it on runs. The old tree ignores the key.
|
# option without turning it on runs. The old tree ignores the key.
|
||||||
"n1_off": dict(n=1, extra={"battery_modes_enabled": False}),
|
"n1_off": dict(n=1, extra={"battery_modes_enabled": False}),
|
||||||
"n2_off": dict(n=2, 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:
|
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__":
|
if __name__ == "__main__":
|
||||||
@@ -208,5 +281,9 @@ if __name__ == "__main__":
|
|||||||
print(f"{k}: {len(rows)} cycles, loop reasons "
|
print(f"{k}: {len(rows)} cycles, loop reasons "
|
||||||
f"{sorted({r[3] for r in rows})}, arbiter "
|
f"{sorted({r[3] for r in rows})}, arbiter "
|
||||||
f"{sorted({r[6].split(' ->')[0] for r in rows})}")
|
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():
|
for k, v in digests().items():
|
||||||
print(f"{k} {v}")
|
print(f"{k} {v}")
|
||||||
|
|||||||
Reference in New Issue
Block a user