Files
goodwe-addon/goodwe_controller/app/arbiter.py
T
glenn schrooyenandClaude Opus 5.5 ffc9a4d721
checks / unit (pull_request) Successful in 11m4s
infra-pin / fresh (pull_request) Successful in 2s
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
2026-09-28 19:41:36 +02:00

835 lines
44 KiB
Python

"""Who gets to command the inverter, and why.
Every strategy - the grid-following loop, the maintenance cycle, peak shaving,
the safety stops - stops writing the setpoint directly and instead returns a
CLAIM each cycle. This module resolves the claims into one number.
⚠️ THE REASON THIS EXISTS. Two controllers writing one actuator is the failure
this whole system is built to avoid: it is what the vendor controller did to the
reference site, and §4.4 of the project notes calls it out by name. Before this
module, "exactly one writer" was a convention held up by an if/else ladder and
careful reading. With an EV charger and a heat pump eventually wanting the same
battery, a convention is not good enough - so precedence is now a first-class
object with one rule and a printable explanation.
THE RULE, in full:
1. The highest-priority `set` claim wins. If there is no set claim at all, the
target is 0 W - fail toward inaction, never "hold the last value".
2. Every `limit` claim whose priority is >= the winning set's priority is then
applied. Most restrictive wins.
3. Contradictory limits (lo > hi) are a BUG, not a tie to break: command 0 W
and say so loudly.
Clause 2 is the interesting one. It is what makes "money outranks maintenance"
a consequence of the priorities instead of a special case somebody can forget:
the maintenance charge-only limit binds the loop, but a higher-priority peak
shaving claim simply is not bound by it.
⚠️ AND WHICH ACTUATOR IS A CLAIM ABOUT? THAT QUESTION HAS AN ANSWER AGAIN.
specs.md §6.1 declared `actuator: str` as the first field of `Claim` and the
implementation shipped without it, so every claim was implicitly about "the one
and only actuator" and `resolve()` returned one scalar. ARB-03 restores the
field, because §6.2 decided the site grid target is ALLOCATED across several
inverters and never broadcast to each of them - and an allocator needs a seam to
occupy that a single scalar does not have.
So resolution is now THREE STAGES, and they are separate functions so that a
test can construct a case at any one of them:
1. `pre_resolve()` - each inverter's OWN claims become an interval for that
unit. One unit's claims, one unit's bounds.
2. `site_resolve()` - the SITE claims become one grid target S. This is the
rule above, unchanged, and it is deliberately the same
code it was: at N=1 there are no unit claims, so this
stage sees the whole list and answers exactly what the
shipped arbiter answered.
3. `allocate()` - THE SEAM. It turns S into per-inverter shares subject to
stage 1's intervals. ALLOC-01 filled it: the allocator
itself is `app/allocator.py` and the reasoning lives
there, because it consumes intervals and a number and
must never learn about claims, priorities or P1.
⚠️ THE STRUCTURAL PROPERTY, AND IT IS THE REASON FOR THE SPLIT. A per-inverter
constraint is consumed by the allocator as an INTERVAL; it is never promoted
into the site claim list. So a unit `hi` and a site `lo` are never terms of the
same `lo > hi` test, and the contradiction clause below - which resolves to 0 W,
a battery parked inside a billed quarter - cannot be reached across two
inverters. That is DECISIONS/SAFETY-25-loop-soc-floor.md §0c, made mechanical:
not "guarded against", NOT EXPRESSIBLE. Filing a unit constraint as a site claim
to "keep it simple" is the whole bug, and `actuator_for()` is what stops a
strategy doing it by hand.
"""
from dataclasses import dataclass, field
from math import inf
# ⚠️ ONE WAY ONLY. `allocator` knows nothing about claims, priorities or
# this module - it consumes intervals and a number - so the dependency
# cannot become a cycle and the allocator stays testable on its own, which
# is what lets test_alloc.py sweep it hundreds of thousands of times
# without building a claim list for each one.
from . import allocator
# Priorities live here and nowhere else. The moment these become magic numbers
# scattered through the strategies, the whole point of this module is lost.
P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
P_PEAK = 50 # capacity-tariff peak shaving - costs real money
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
# OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A
# preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor
# clamp an arbitrage charge, which is §9.1's "give peak shaving priority over
# arbitrage whenever the two conflict" expressed as arithmetic rather than as a
# rule somebody has to remember. P_MAINT_SHAPE (40) is above it so a speculative
# spread cannot override the pack's balancing cycle. P_LOOP (10) is below it
# because trading against the meter is the entire point - the grid-following
# 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. 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
# The one source filed at P_MODE. pre_resolve() keeps it out of the hard limits.
MODE_SOURCE = "mode"
SET = "set"
LIMIT = "limit"
# --- actuators, and the two scopes a claim can have -------------------------
# ⚠️ §6.2: THE GRID CONNECTION IS AN ACTUATOR TOO. It is the one every inverter
# at the site shares, and two inverters regulating against one meter are two
# writers on it - which is the failure this module exists to prevent, one level
# up from the one it was built for. So it gets a name, and claims about it are
# filed on that name rather than on nothing in particular.
ACTUATOR_SITE = "site"
SCOPE_SITE = "site" # about the grid connection: one of these per site
SCOPE_UNIT = "unit" # about one pack, one inverter, one device
# ⚠️ THE CLASSIFICATION, AND IT IS THE REAL OUTPUT OF ARB-03. Each entry is read
# off what the claim is ABOUT at its construction site in main.py, not off which
# module builds it. Getting an entry wrong is not cosmetic: a per-inverter
# reserve filed as a site `hi` against the peak guard's site `lo` is `lo > hi`,
# clause 3, 0 W - the battery parked in the billed quarter that SAFETY-25's memo
# calls fatal. The reasoning is recorded here, at the line, because this table is
# where a ninth source will be classified and the reasoning is what a future
# reader needs in order to classify it correctly.
CLAIM_SCOPE = {
# --- SITE: about the grid connection ---------------------------------
# main.py: `Claim.set("loop", P_LOOP, decision.target_w, ...)`. compute()
# regulates `grid_w` - the P1 measurement - to zero. Its subject is the
# meter, and §6.2's whole architecture is ONE integrator on P1 whose output
# is then split. A site claim, and the one the split exists for.
"loop": SCOPE_SITE,
# main.py: `peakguard.claim(...)`. The quantity is the 15-minute average
# OFFTAKE AT THE GRID CONNECTION, which is what the Belgian capacity tariff
# bills. There is one quarter-hour peak per site, not one per pack.
"peak": SCOPE_SITE,
# main.py: `arbitrage.claim(...)`. It buys and sells at the grid against a
# day-ahead price. The price is a property of the connection, and the energy
# it trades crosses the meter.
"arbitrage": SCOPE_SITE,
# main.py: `Claim.set("safety", P_SAFETY, 0.0, "stopped" | "inputs missing")`.
# ⚠️ READ BOTH CONSTRUCTION SITES BEFORE MOVING THIS ONE. Neither is about a
# pack. "stopped" is the operator switching control off for the SITE, and
# "inputs missing" is the SITE's own telemetry gone - the meter this whole
# controller regulates against. Both must stop every inverter, and a site
# SET at P_SAFETY is how that is said once rather than N times.
"safety": SCOPE_SITE,
# main.py: `Claim.set("circulation", P_SAFETY, 0.0, "circulating power")`.
# SAFETY-31, and this one is a SITE claim for a reason worth writing down,
# because the neighbouring `failsafe` entry looks like it and is not.
#
# `failsafe` is SAFETY-02: site-wide evidence, per-unit conclusion. It
# compares the meter against THE COMMAND THIS CONTROLLER PUT ON ONE
# ACTUATOR, so what it learns is about that actuator and the remedy is that
# actuator.
#
# Circulating power is not a property any single inverter has. It is
# `sum(|B_i|) - |sum(B_i)|`, defined over the whole roster and identically
# zero for any one unit, and there is NO FACT OF THE MATTER about which of
# two opposing inverters is the offender - a unit ignoring its share and a
# sibling faithfully executing a share computed to sum with it produce the
# same reading. Filing this per-unit would require inventing an attribution
# the measurement cannot support, and would park one inverter while the
# other went on feeding the loop. The evidence is site-level and so is the
# remedy: stop commanding, everywhere, and say why.
"circulation": SCOPE_SITE,
# main.py: `Claim.set("sign_site", P_SAFETY, 0.0, "site sign disagreement")`.
# SAFETY-30, and it is the twin of `failsafe` below rather than a duplicate
# of it - the two exist BECAUSE the same words describe two different
# measurements once there is more than one inverter.
#
# `failsafe` grades ONE unit's standing command against THAT UNIT'S OWN
# measured power (register 0x0518). Its conclusion is "the actuator I am
# driving is not doing what I told it", which is a fact about one device,
# so it parks one device.
#
# This one grades the SUM of the standing commands against P1. Its
# conclusion is "the site as a whole is driving the wrong way" - an inverted
# gain, an integrator wound past the rail, a strategy on the wrong side of
# the meter - and there is nothing per-unit about it: the quantity is a sum
# over the roster and the offending number is the site target, which no
# single inverter owns. Parking one unit on that evidence would leave the
# rest of the site pushing the same way with the guard satisfied.
#
# ⚠️ AT N=1 IT IS NEVER FILED, and that is the identity rather than a
# special case: with one inverter the sum of the commands IS that unit's
# command and `failsafe` already carries exactly this trip, under the keys,
# the banner and the claim an install has had since SAFETY-02 shipped.
# `Controller.cycle()` does not even arm the site detector there.
"sign_site": SCOPE_SITE,
# --- UNIT: about one pack, one inverter, one device -------------------
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
# "rating")`. The numbers are read off ONE inverter's own reported min/max.
# Another inverter has another rating; there is no site rating.
"device": SCOPE_UNIT,
# main.py: `Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w")`.
# `max_w` is the supervised cap on what this controller may WRITE to a
# setpoint entity - §11.3's "the actuator is capped below the inverter
# rating". It bounds a write to one device, like `device` does and for the
# same reason, so it is scoped like `device`.
"supervised": SCOPE_UNIT,
# main.py: `Claim.set("failsafe", P_SAFETY, 0.0, "sign disagreement")`.
# ⚠️ THE EVIDENCE IS PER-UNIT AND SO IS THE REMEDY - AND SINCE SAFETY-30 THE
# FIRST HALF OF THAT SENTENCE IS TRUE. It used to read "the evidence is
# site-wide and the remedy is not", because the watchdog compared THIS
# unit's standing command against P1: a site measurement, attributed to one
# actuator on the strength of §6.2's N=1 identity. That identity does not
# survive a second inverter - at N>=2 the meter answers for a sibling this
# unit's command says nothing about, in both directions (a false trip when
# the sibling covers the meter, and a missed one when the sibling MASKS a
# unit that really is reversed).
#
# So at N>=2 the comparison behind this claim is THIS unit's command against
# THIS unit's own measured power - register 0x0518, per-instance since
# DEV-02 - and the scope that was already right for the wrong reason is now
# right for the right one. The site-wide half moved to `sign_site` above.
# At N=1 the two are the same comparison and this claim is the one filed,
# exactly as it shipped.
"failsafe": SCOPE_UNIT,
# main.py: `Claim.limit("pack", P_SAFETY, lo, hi, ...)`, OPT-09. THIS PACK'S
# OWN STATE OF CHARGE against THIS PACK'S OWN `bms_soc_floor`, filed only
# where it BINDS and only at a roster of two or more. It says "this battery
# cannot do that", which is a fact about one cupboard in the same way
# `device`'s rating is a fact about one box, so it is scoped like `device`
# and filed at the same priority.
#
# ⚠️ AND IT IS NOT SAFETY-25's DEFERRED LOOP-SIDE RESERVE, WHICH LOOKS
# EXACTLY LIKE IT AND IS THE REASON test_peakguard.py PART 10f EXISTS. That
# one was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW
# `loop_soc_floor` option - an EMS preference, chosen by an operator,
# stopping the grid-following loop at a depth somebody picked. This is the
# pack's own BMS cut-out, which is measured off register 0x0560 and required
# of every roster entry since SAFETY-32; no option is added, `peak_soc_floor`
# is deliberately NOT consulted, and the SITE target is not touched - only
# which unit carries it. Part 10f's own note names the revisit trigger:
# "under ALLOC-01 the reserve becomes a PER-INVERTER INTERVAL AT THE
# ALLOCATOR rather than a ninth claim here", and stage 1 is how an interval
# reaches the allocator, so a unit-scoped claim is the shape that sentence
# asks for rather than an evasion of it.
"pack": SCOPE_UNIT,
# main.py: `Claim.limit("refused", P_SAFETY, ...)` and, during a probe,
# `Claim.set("refused", P_SAFETY, +-PROBE_W)`. SAFETY-73: THIS battery gives
# nothing in one direction, measured on THIS unit's own reading. A hard unit
# limit that always intersects, so the share goes to the siblings. Filed at
# N>1 only; at N=1 SAFETY-52's law in compute() does the same job.
"refused": SCOPE_UNIT,
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
# it only shapes. §12's monthly recalibration is a PACK procedure - drain
# this pack to its floor, charge this pack to full, recalibrate this pack's
# coulomb counter. Two packs recalibrate on their own schedules.
"maintenance": SCOPE_UNIT,
# 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 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,
}
def actuator_for(source: str, unit: str, units=()) -> str:
"""Which actuator a claim from `source` belongs on, given the site's roster.
⚠️ ROUTE THROUGH THIS, DO NOT PASS `actuator=` BY HAND. An unclassified
source raises `KeyError` here rather than defaulting to the site, which is
the one direction that is not safe to default in: a per-inverter constraint
that lands in the site claim list is exactly the `lo > hi` this card exists
to make unreachable. A ninth claim source has to be classified in
CLAIM_SCOPE before it can be filed, and test_peakguard.py part 10f is the
other half of that - it goes red on the line the ninth append is written.
⚠️ AND AT N=1 EVERY SOURCE ROUTES TO THE SITE. That is not the classification
being ignored, it is §6.2's identity: with one inverter the fabricated grid
reading and the battery setpoint are the same number, so the grid connection
and the only unit ARE the same actuator and there is nothing to allocate.
Splitting the list at N=1 would be a behaviour change on a live safety path -
the `failsafe` and `maintenance` SET claims would stop competing with the
site's for the winner, `explain()` would name a different source, and
`loop_is_following()` reads that name. The identity holds only at N=1, so
this function is the ONE place that stops relying on it, the day the roster
grows.
"""
if CLAIM_SCOPE[source] == SCOPE_SITE:
return ACTUATOR_SITE
return ACTUATOR_SITE if len(units) <= 1 else unit
def scope_violations(claims) -> list:
"""Claims filed on an actuator their source's scope does not permit.
A list of one-line complaints, empty when the routing is right. This is the
check `actuator_for()` cannot make on its own, because a caller can always
write `actuator=` by hand: it grades the claim list that actually reached the
arbiter, whatever built it.
"""
out = []
# ⚠️ THE SECOND TEST IS CONDITIONAL ON THE SITE HAVING NAMED UNITS AT ALL,
# and that is not laxity. At N=1 §6.2's identity holds and a per-inverter
# source filed on the site is CORRECT - it is what `actuator_for()` returns.
# The direction that produces `lo > hi` only exists once there is a second
# actuator for the constraint to have belonged to.
split = any(c.actuator != ACTUATOR_SITE for c in claims)
for c in claims:
scope = CLAIM_SCOPE.get(c.source)
if scope is None:
out.append(f"{c.source}: not classified in CLAIM_SCOPE")
elif scope == SCOPE_SITE and c.actuator != ACTUATOR_SITE:
out.append(f"{c.source}: a SITE claim filed on unit {c.actuator!r} - "
f"it would bound one inverter's share instead of the grid")
elif scope == SCOPE_UNIT and c.actuator == ACTUATOR_SITE and split:
out.append(f"{c.source}: a UNIT claim filed on the site while the "
f"site has named units - it can cross a site limit")
return out
@dataclass(frozen=True)
class Claim:
source: str
priority: int
kind: str = SET
value: float = 0.0 # for SET
lo: float = -inf # for LIMIT
hi: float = inf # for LIMIT
reason: str = ""
# ⚠️ specs.md §6.1 DECLARES THIS FIELD FIRST AND IT IS LAST HERE, ON PURPOSE.
# The spec's point is that a claim NAMES its actuator; the tuple position is
# not the point, and it is load-bearing for two things that are. Every
# construction in this repository passes `source` first, and
# test_peakguard.py part 10f reads `Claim.set(...)`/`Claim.limit(...)`'s
# FIRST POSITIONAL ARGUMENT off main.py's AST to pin the claim-source set -
# the tripwire that guards SAFETY-25's deferred option. Moving `source` off
# position 0 would silently blind that scan, which is this repository's named
# failure mode. So the field is restored where it costs nothing.
#
# ⚠️ AND ITS DEFAULT IS THE SITE, EXPLICITLY. Not None, not "". A claim that
# does not name an actuator is a claim about the grid connection and says so;
# "unspecified" is what the shipped code already had, and it is what made
# every claim implicitly about the one and only actuator.
actuator: str = ACTUATOR_SITE
@staticmethod
def set(source: str, priority: int, value: float, reason: str = "", *,
actuator: str = ACTUATOR_SITE) -> "Claim":
return Claim(source, priority, SET, value=value, reason=reason,
actuator=actuator)
@staticmethod
def limit(source: str, priority: int, lo: float = -inf, hi: float = inf,
reason: str = "", *, actuator: str = ACTUATOR_SITE) -> "Claim":
return Claim(source, priority, LIMIT, lo=lo, hi=hi, reason=reason,
actuator=actuator)
@dataclass(frozen=True)
class UnitBounds:
"""What one inverter's OWN claims say about that inverter's share.
STAGE 1's output, one per unit, and the thing ALLOC-01 consumes. It is an
INTERVAL plus the two facts an allocator cannot reconstruct from an interval:
* `pinned_w` - the value that unit's own winning SET asked for. A SET is not
a bound: `resolve()` CLAMPS a set value into the limits rather than
intersecting with it, and folding a 2500 W drain into `[2500, 2500]` would
turn "clamped to the rating" into "contradicts the rating". So it travels
beside the interval, not inside it.
* `gate` - the priority of that winning SET, or None. Clause 2 applies a
limit only at or above the winner's priority, and at N>=2 the winner may be
a SITE set chosen at stage 2, i.e. AFTER this. Stage 1 states the
constraint and records what it is gated on; deciding whether it applies is
stage 3's business. Guessing here is how a maintenance charge-only `hi`
would come to bind a peak discharge it does not outrank.
`contradiction` is a unit contradicting ITSELF - its own `lo` above its own
`hi`. It parks that unit and says so; it does not park the site, which is the
entire difference this card is about.
"""
lo: float = -inf
hi: float = inf
lo_src: str | None = None
hi_src: str | None = None
gate: int | None = None
pinned_w: float | None = None
pinned_by: str | None = None
contradiction: bool = False
# OPT-11: the mode layer, APART from `lo`/`hi` above, which stay the hard
# limits. `mode_priority` is None when no mode claim applies. Whether the
# mode binds is decided at stage 3 (`allocator.unit_interval()`), because it
# depends on the priority that decided the SITE target.
mode_lo: float = -inf
mode_hi: float = inf
mode_priority: int | None = None
def explain(self) -> str:
if self.contradiction:
return (f"CONTRADICTION within the unit ({self.lo_src} lo="
f"{self.lo:.0f} > {self.hi_src} hi={self.hi:.0f})")
span = (f"[{'-inf' if self.lo == -inf else format(self.lo, '.0f')}, "
f"{'+inf' if self.hi == inf else format(self.hi, '.0f')}]")
if self.pinned_by is not None:
return f"{span} around {self.pinned_by} -> {self.pinned_w:.0f} W"
return span
@dataclass
class Resolution:
target_w: float
winner: str # which source set the value
bound_by: str | None = None # which limit actually changed it, if any
contradiction: bool = False
considered: list = field(default_factory=list)
# --- the three-stage fields. ⚠️ APPENDED, AND DEFAULTED, ON PURPOSE: every
# existing construction of this type is positional in its first five fields
# and `target_w` still means exactly what it meant - the SITE grid target.
# main.py assigns it to `self.target` unchanged.
unit_bounds: dict = field(default_factory=dict) # stage 1, per actuator
shares: dict = field(default_factory=dict) # stage 3, per actuator
allocated: bool = True # False when stage 3 declined to split
scope_errors: list = field(default_factory=list) # mis-routed claims
# ALLOC-01, appended for the reason the four above were: `target_w` still
# means the SITE grid target and every existing construction is untouched.
#
# `allocation` is stage 3's full record - what each unit was told and why,
# what could not be placed, what was dropped. `shares` and `allocated` are
# two fields OF that record, kept at the top level because main.py and four
# test files already read them there. ⚠️ IT IS THE RECORD, NOT THE PAIR,
# THAT SAFETY-28 GATES THE WRITE PATH ON: `allocated` says the allocator
# stands behind the split, and `unmet_w` says how much of the site target it
# could not place, and fusing those two would make one saturated inverter
# park the whole site.
allocation: object | None = None
# The priority of the SITE claim that chose `target_w`. Stage 3 weighs a
# unit's OWN winning SET against it - see `allocator.plan()`'s PINS note -
# and getting it from here rather than recomputing it is what stops the two
# rules drifting apart: this is set by the same line that picks the winner
# `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`. 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.
mode_fault: str | None = None
def explain(self) -> str:
"""One line an operator can act on - this is the point of the module."""
if self.contradiction:
return f"CONTRADICTION between limits - commanding 0 W ({self.winner})"
if self.bound_by:
return f"{self.winner} -> {self.target_w:.0f} W, limited by {self.bound_by}"
return f"{self.winner} -> {self.target_w:.0f} W"
def pre_resolve(unit_claims: list) -> UnitBounds:
"""STAGE 1. One inverter's own claims -> that inverter's interval.
Clauses 1 and 2 of the rule, transposed: the unit's highest-priority SET
names what the unit wants and gates which of the unit's limits apply, and the
most restrictive of those limits gives the interval. Read UnitBounds on why
the SET value is not folded into the interval.
⚠️ THE INPUT IS ONE UNIT'S CLAIMS AND NOTHING ELSE. A site claim reaching
this function would become a bound on one inverter's share - the mirror image
of the fault this card removes, and the reason `resolve()` partitions rather
than filters twice.
"""
sets = sorted((c for c in unit_claims
if c.kind == SET and c.source != MODE_SOURCE),
key=lambda c: c.priority, reverse=True)
gate = pinned_w = pinned_by = None
if sets:
gate, pinned_w, pinned_by = sets[0].priority, sets[0].value, sets[0].source
lo, hi = -inf, inf
lo_src = hi_src = None
# OPT-11: a mode claim never joins the hard limits, so a mode cannot make a
# unit contradict itself or narrow a pin. A mode SET is not a thing: only
# limits are read. The lowest mode priority is kept (the first to release).
mode_lo, mode_hi, mode_priority = -inf, inf, None
for c in unit_claims:
if c.kind != LIMIT or (gate is not None and c.priority < gate):
continue
if c.source == MODE_SOURCE:
mode_lo, mode_hi = max(mode_lo, c.lo), min(mode_hi, c.hi)
mode_priority = (c.priority if mode_priority is None
else min(mode_priority, c.priority))
continue
if c.lo > lo:
lo, lo_src = c.lo, c.source
if c.hi < hi:
hi, hi_src = c.hi, c.source
return UnitBounds(lo, hi, lo_src, hi_src, gate, pinned_w, pinned_by,
contradiction=lo > hi, mode_lo=mode_lo, mode_hi=mode_hi,
mode_priority=mode_priority)
def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
scope_errors=(), site_priority=None,
deciding_priority=None) -> tuple:
"""STAGE 3. THE SEAM, AND SINCE ALLOC-01 IT IS OCCUPIED. `(shares, allocated)`.
⚠️ THE ALLOCATOR ITSELF IS `app/allocator.py` AND THE REASONING LIVES THERE.
This is the adapter: it keeps the two-value shape every caller in this
repository already reads, and it exists as a separate name so that the
arbiter's three stages are still three functions a test can enter at any
one of them. `allocator.plan()` returns the full record - use that when you
need `unmet_w`, `dropped` or the per-unit reasons; `resolve()` stashes it on
`Resolution.allocation` for exactly that.
What §6.2 fixed and what this delivers:
* SUM-INVARIANT. `sum(shares) + unmet_w == round(target_w)`, in integer
arithmetic, so it cannot drift. Rounding is largest-remainder because a
per-unit `round()` biases the sum by up to N/2 W every tick and at 1-2 Hz
that is a PERMANENT standing error the site integrator chases forever.
* EACH UNIT IS TOLD A FUNCTION OF ITS OWN SHARE, never of the shared
measurement. That makes the gain at the meter a convex combination of the
per-unit gains for ANY split, which is why the weights are a comfort
choice and a bad split cannot destabilise the site.
* WITH AT MOST ONE UNIT IT IS STILL A PASS-THROUGH, byte-identical to what
ARB-03 shipped: the share IS the site target, as a float, unrounded and
unclamped. §6.2's N=1 identity, and the compatibility promise every
existing replay in this repository depends on.
⚠️ AND IT STILL DECLINES, WHICH IS NOT THE SAME LIST OF CASES AS BEFORE.
ARB-03 declined at N>=2 because it had no allocator; that trigger is gone
and the branch is NOT. It now fires when no safe split exists at all: a
mis-routed claim, no unit left in the envelope set, or a split that could
only be made by commanding one pack to charge while another discharges.
Read `allocator.plan()` for each. main.py's refusal path and
test_peakguard.py part 10h were re-aimed onto those rather than deleted -
PR #40's reviewer proved that guard was unreachable and untested once, and
removing its trigger without giving it a new one would rebuild the hole.
"""
return allocate_record(target_w, bounds, weights=weights, live=live,
scope_errors=scope_errors,
site_priority=site_priority,
deciding_priority=deciding_priority)[:2]
def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
scope_errors=(), site_priority=None, tick=None,
deciding_priority=None) -> tuple:
"""`allocate()` plus the record - `(shares, allocated, allocation)`.
`tick` is SAFETY-28's freshness token: it identifies the tick the record is
FOR and is handed straight through to `allocator.plan()`. Left None the
record is UNSTAMPED, which the write gate refuses - so a caller that intends
the record to authorise a write must pass one. Every existing caller in this
repository wants the arithmetic and not the authority, and gets None.
"""
rec = allocator.plan(target_w, bounds, weights=weights, live=live,
scope_errors=scope_errors,
site_priority=site_priority, site_key=ACTUATOR_SITE,
tick=tick, deciding_priority=deciding_priority)
return rec.shares, rec.ok, rec
def site_resolve(claims: list) -> Resolution:
"""STAGE 2. The SITE claims -> one grid target.
⚠️ THIS FUNCTION IS THE SHIPPED ARBITER, UNCHANGED. It is a separate name and
the same code, deliberately: at N=1 nothing is filed on a unit, so `resolve()`
hands it the whole claim list and it answers what it has always answered.
Every check in test_arbiter.py, test_watchdog.py, test_validate.py and
test_peakguard.py replays through here unedited, which is the proof that
ARB-03 changed no behaviour on the live path.
"""
sets = sorted((c for c in claims if c.kind == SET),
key=lambda c: c.priority, reverse=True)
if sets:
winner = sets[0]
else:
# No strategy asked for anything. That is not "carry on as before" - the
# inverter holds its last command forever, so silence must mean zero.
winner = Claim.set("failsafe", P_SAFETY, 0.0, "no claim was made")
lo, hi = -inf, inf
lo_src = hi_src = None
for c in claims:
if c.kind != LIMIT or c.priority < winner.priority:
continue
if c.lo > lo:
lo, lo_src = c.lo, c
if c.hi < hi:
hi, hi_src = c.hi, c
considered = [f"{c.source}:{c.kind}" for c in claims]
if lo > hi:
# OPT-11: 0 W here, and every limit that took part counts as deciding.
return Resolution(0.0, winner.source, bound_by=None,
contradiction=True, considered=considered,
winner_priority=winner.priority,
deciding_priority=max(winner.priority,
lo_src.priority,
hi_src.priority))
target = max(lo, min(hi, winner.value))
bound_by = None
deciding = winner.priority
if target != winner.value:
binder = lo_src if target == lo else hi_src
if binder is not None:
bound_by = f"{binder.source}({binder.reason})" if binder.reason else binder.source
deciding = max(deciding, binder.priority)
return Resolution(float(target), winner.source, bound_by, False, considered,
winner_priority=winner.priority,
deciding_priority=deciding)
def resolve(claims: list, *, units=None, weights=None, live=None,
tick=None) -> Resolution:
"""The three stages, in order, over one cycle's claims.
`units` is the site's roster of inverters. Left None it is derived from the
claims themselves, which is right for a caller that files a claim for every
unit every cycle (main.py does - `supervised` is unconditional) and wrong for
one that does not, so main.py passes the real roster rather than let a silent
unit look like a smaller site.
`weights` and `live` are ALLOC-01's two per-tick inputs to stage 3, and both
are per-TICK on purpose. `weights` is the comfort choice - SoC, thermals,
wear - and cannot destabilise the site whatever it says. ⚠️ OPT-09 LET IT BE
A CALLABLE as well as a mapping: called with the resolved site target once
stage 2 has run, because an energy-headroom weighting is direction-dependent
and the direction is stage 2's answer. See the call site below. `live` is the set
of units whose master has reported recently enough to be commanded; a unit
left out of it is DROPPED from the envelope set and the record says so,
because the survivor carrying a dead unit's share silently is the failure
SAFETY-30 is about. ⚠️ NEITHER MAY BE CACHED BY A CALLER. A unit's range
moves for reasons unrelated to the allocator - a stale link, a pack at its
floor, a device rating that failed to read - so an envelope read once at
construction over-allocates into a unit that cannot take it.
⚠️ AND THE ARGUMENT HAS TO BE HONOURED, WHICH IS NOT THE SAME AS EXISTING.
Ignore it - derive always - and a unit that filed nothing this cycle ceases
to exist: a site of two reads as a site of one and stage 3 passes the whole
grid target through to the inverter that spoke, which is ARCH-01's fault
with its own evidence removed. That mutation survived the whole suite until
test_arbiter.py part 7 graded it on a SILENT unit.
⚠️ `tick` IS SAFETY-28's FRESHNESS TOKEN AND IT IS NOT OPTIONAL ON THE LIVE
PATH. It identifies the tick this resolution is FOR, and it ends up on
`Resolution.allocation.tick`, where the write gate compares it against the
tick it is writing on. A record with no tick cannot authorise a write, which
is why main.py passes `self.alloc_tick` and why a test that only wants the
arbiter's arithmetic can leave it out and be sure it has not accidentally
minted a licence to write.
⚠️ THE PARTITION IS BY `actuator`, AND IT IS A PARTITION, NOT A FILTER. Every
claim lands in exactly one stage. That is the whole structural argument: a
unit's `hi` and the site's `lo` are never both terms of the same `lo > hi`
test, so SAFETY-25 §0a's cross-inverter contradiction - 0 W inside the billed
quarter - is not something to guard against, it is something that cannot be
written down.
"""
site_claims = [c for c in claims if c.actuator == ACTUATOR_SITE]
# OPT-11: a mode claim on the site (N=1, where every source routes there)
# never enters stage 2's `lo > hi` test. It is applied after it, below, by
# the same release rule stage 3 uses.
site_modes = [c for c in site_claims if c.source == MODE_SOURCE]
if site_modes:
site_claims = [c for c in site_claims if c.source != MODE_SOURCE]
if units is None:
units = {c.actuator for c in claims if c.actuator != ACTUATOR_SITE}
units = tuple(sorted(units))
# STAGE 1 - each unit's own claims become that unit's interval.
bounds = {u: pre_resolve([c for c in claims if c.actuator == u])
for u in units}
# STAGE 2 - the site claims become the grid target. At N=1 `site_claims` IS
# `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_claims)
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
#
# ⚠️ THE ORDER MATTERS: `scope_violations()` IS COMPUTED BEFORE THE SPLIT,
# NOT AFTER, because stage 3 consumes it. A claim filed on an actuator its
# source's scope does not permit means the number about to be divided may
# itself be wrong, and dividing a number that may be wrong is worse than
# not dividing it - `allocator.plan()` carries the full argument, including
# why the mis-routed claim is NOT promoted into somebody's envelope. This
# was the open question ARB-03 left, and it is answered here rather than
# left as a log line nothing grades.
out.scope_errors = scope_violations(claims)
out.unit_bounds = bounds
# OPT-09. THE WEIGHTS MAY ARRIVE AS A FUNCTION OF THE SITE TARGET, and this
# is the one line that makes SoC-aware weighting expressible at all.
#
# ⚠️ THE REASON IS THAT THE DIRECTION IS NOT KNOWN ANY EARLIER. A weighting
# by ENERGY HEADROOM is direction-dependent - room above the floor when the
# site is discharging, room below full when it is charging - and the two
# give opposite answers on the same roster. The site target is chosen by
# stage 2, three lines up, so a caller computing weights before `resolve()`
# would have to guess which way the site is about to go, and would be wrong
# in one of the two directions on every tick.
#
# ⚠️ IT IS CALLED ONCE, HERE, BETWEEN STAGE 2 AND STAGE 3, AND NEVER FROM
# INSIDE THE ALLOCATOR. `allocator.plan()` goes on taking a plain mapping,
# which is what keeps it a pure function of numbers and intervals: a
# callback invoked inside the fill would put a caller's code - and whatever
# it reads - back into the module whose whole discipline is that it reads
# 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=gate)
return out
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.
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]
if not lims or out.contradiction:
return
lo = max(c.lo for c in lims)
hi = min(c.hi for c in lims)
prio = min(c.priority for c in lims)
if not (lo <= 0.0 <= hi):
out.mode_fault = f"mode range [{lo:.0f}, {hi:.0f}] leaves out 0 W"
return
if out.winner_priority is not None and out.winner_priority > prio:
return
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
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