ARB-03: restore Claim.actuator and make resolve() three-stage
checks / unit (pull_request) Successful in 3m45s

specs.md:467 declares `actuator: str` as the FIRST field of `Claim`.
app/arbiter.py:55 had no such field. The specification designed
per-actuator claims and the implementation dropped it, so every claim was
implicitly about "the one and only actuator", resolve() returned one scalar
Resolution.target_w, and main.py assigned it to one self.target. THERE WAS
NO SEAM AN ALLOCATOR COULD OCCUPY - and ARCH-01 (specs.md §6.2, 2026-09-02)
decided the site grid target is ALLOCATED across several inverters and never
broadcast. This is a regression fix against the written design; creating that
seam is the whole of the change.

WHY IT MATTERS, IN THE UNITS THE SYSTEM IS BILLED IN. The control primitive
is meter emulation: the master reports a fabricated grid reading and the
inverter regulates itself to it, so one number means "the battery moves X W"
to the add-on and "the grid is at X W" to the inverter - an identity that
holds only at N=1. Broadcast one target to two inverters and each supplies it
in full; the loop gain at the meter becomes N times the per-unit gain, which
at the gain config.yaml:374 accepts is a permanent limit cycle with every
watchdog in §11.2 reporting healthy. And a per-inverter reserve filed as a
site `hi` against the peak guard's site `lo` is `lo > hi`, arbiter clause 3,
0 W IN THE BILLED QUARTER - the parked battery SAFETY-25's memo calls fatal.

THE SPLIT, VERIFIED AT EACH CONSTRUCTION SITE RATHER THAN ACCEPTED FROM THE
SURVEY. Four sources are about the GRID CONNECTION and are SITE claims:
`loop` (compute() regulates grid_w, the P1 measurement, and §6.2's whole
architecture is one integrator on P1 whose output is then split), `peak` (the
15-minute average offtake AT the grid connection, which is what the capacity
tariff bills - one quarter-hour peak per site, not one per pack),
`arbitrage` (buys and sells at the grid against a day-ahead price; the price
is a property of the connection and the energy crosses the meter) and
`safety` (both construction sites read: "stopped" is the operator switching
control off for the site, "inputs missing" is the site's own meter gone -
neither is about a pack, and both must stop every inverter).

Four are about a pack, a device or one write and are PER-INVERTER: `device`
(one inverter's own reported min/max - there is no site rating),
`supervised` (max_w caps what may be WRITTEN to one setpoint entity, §11.3,
so it is scoped like `device` for the same reason), `maintenance` (§12's
recalibration is a pack procedure - drain THIS pack, charge THIS pack,
recalibrate THIS pack's counter) and `failsafe`. `failsafe` is the entry that
needed the reading rather than the survey: SAFETY-02's EVIDENCE is site-wide
(it compares P1 against the standing command) but its CONCLUSION is
"the unit I am driving is pushing power the wrong way" and its remedy is to
park that unit. At N>=2 the same evidence cannot be attributed to a sibling
this controller never commanded, so a site-wide stop would park an inverter
this watchdog never graded. The reasoning for all eight is recorded AT THE
LINE, in arbiter.CLAIM_SCOPE and again at each claims.append in main.py.

WHAT WAS BUILT. resolve() is three stages, each a separate function so a test
can construct a case at any one of them:

  pre_resolve()  - one inverter's own claims -> that unit's interval. Clauses
                   1 and 2 transposed: the unit's own winning SET gates its
                   own limits. The SET's value travels BESIDE the interval,
                   not inside it - folding a -2500 W drain into
                   [-2500, -2500] would turn "clamped to max_w" into
                   "contradicts max_w", a different and wrong answer.
  site_resolve() - THE SHIPPED ARBITER, UNCHANGED. Same code, new name.
  allocate()     - THE SEAM, AND DELIBERATELY NOT AN ALLOCATOR. ALLOC-01
                   builds it. At N<=1 it passes the target through (§6.2's
                   identity). At N>=2 it DECLINES - no shares,
                   allocated=False - and main.py refuses to write rather than
                   broadcast, because the obvious placeholder (hand the target
                   to each) is the fault ARCH-01 was filed about and no guard
                   we have can tell it apart from the real thing.

THE CONTRADICTION IS NOT GUARDED AGAINST, IT CANNOT BE WRITTEN DOWN. The
partition is by `actuator` and it is a partition: a unit's `hi` and the site's
`lo` are never both terms of one `lo > hi` test. Constructed and swept - 40
filings (every per-inverter source x unit x bound, including a unit
contradicting ITSELF at lo 900 > hi 100) against a live site floor, and not
one moves the site target or raises a contradiction. A unit's own impossible
pair parks that unit and says so on its own bounds; it does not park the site.
That is SAFETY-25 §0c's table made mechanical.

AT N=1 THIS IS A PURE REFACTOR, AND THAT IS PROVEN RATHER THAN ASSERTED. With
one inverter the fabricated grid reading and the battery setpoint are the same
number, so the grid connection and the only unit ARE one actuator:
actuator_for() routes every source to the site, no claim is filed on a unit,
stage 2 sees the whole list and answers what the shipped arbiter answered.
All 17 suites replay UNEDITED above the ARB-03 sections and are green on the
same numbers; test_arbiter.py additionally runs 13 claim lists through both
resolve() and site_resolve() and compares target, winner, binder,
contradiction flag, the considered list and the operator line.

EVIDENCE, BY BREAKING EACH THING (tools/mutation_run.sh, purged tree):
  * Claim's dataclass default emptied            -> RED (test_arbiter)
    ...and it survived until the check was added: the two helpers pass their
    own default down, so the FIELD's default never ran. Now checked directly.
  * `peak` reclassified SCOPE_UNIT               -> RED (arbiter x4, peakguard x2)
  * `maintenance` reclassified SCOPE_SITE        -> RED (arbiter x4, peakguard)
  * the N=1 identity dropped in actuator_for()   -> RED (arbiter, peakguard
    incl. the maintenance/peak suppression checks, freeze) - which is the
    measurement that says splitting at N=1 would be a behaviour change
  * resolve() stops partitioning                 -> RED (arbiter x4)
  * allocate() broadcasts at N>=2                -> RED (arbiter x2)
  * stage 1 drops clause-2 gating                -> RED (arbiter)
  * a call site routes `device` through `loop`   -> RED (peakguard 10g)
  * SAFETY-25 Option B built as a ninth source   -> RED (peakguard 10f, both
    halves, plus 10g) - the tripwire still bites

SAFETY-24's asymmetry proof is re-run and holds: 0 negative floors over the
shipped sweep, `hi` still +inf on every claim the guard files, and it now also
asserts every one of them is on the SITE actuator - a floor bound to one
inverter's share would leave the site, the thing the capacity tariff meters,
with no floor at all.

test_peakguard.py part 10f is kept, not adapted around. `source` is still
Claim.set()'s first positional argument precisely so its AST scan keeps
reading it, which is why `actuator` is the LAST field of Claim and not the
first - moving `source` off position 0 would silently blind the tripwire that
guards SAFETY-25's deferred option. The one adaptation is `**kw` on its spy,
so main.py's roster passes through unread rather than being silently dropped.
New part 10g pins CLAIM_SCOPE to the same eight-source set and grades a REAL
two-inverter cycle, which is the only place a mis-typed actuator_of(...) at a
call site is observable - at N=1 every source legitimately routes to the site,
so a swap there is invisible by construction.

No config key, no option, no operator surface, no default moved. Inverter B
does not exist yet and nothing here measures it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-02 01:54:29 +02:00
co-authored by Claude Opus 5
parent 0c7e786ed5
commit 06f95e0a64
7 changed files with 955 additions and 24 deletions
+115 -10
View File
@@ -34,7 +34,8 @@ import aiohttp
from . import arbitrage
from .arbiter import (
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY,
Claim, Resolution, actuator_for, resolve,
)
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .clamp import (
@@ -59,6 +60,12 @@ from . import web
OPTIONS_PATH = "/data/options.json"
_LOG = logging.getLogger("goodwe")
# The one inverter this add-on drives. ⚠️ A NAME, NOT A CONFIGURATION. It exists
# so that a per-inverter claim has an actuator to be filed ON, which is what
# makes the site/unit split something a check can grade rather than a comment.
# ALLOC-01 replaces this with a real roster when inverter B arrives.
UNIT_A = "inverter-a"
# --------------------------------------------------------------------------- #
# OPT-04: THE BACKGROUND TARIFF REFRESH, AND WHY IT IS NOT OPTIONAL
#
@@ -255,6 +262,23 @@ class Controller:
# None when P1 is off, or under a transport that has no such counter.
self.p1_source = None
# --- ARB-03: which actuators this site has ---------------------------
# ⚠️ ONE INVERTER, AND THE ROSTER IS WRITTEN DOWN RATHER THAN ASSUMED.
# Every claim below is filed on an actuator, and `arbiter.actuator_for()`
# needs to know how many there are: at N=1 §6.2's identity holds - the
# fabricated grid reading and the battery setpoint are the same number -
# so the grid connection and the only inverter ARE one actuator and every
# claim routes to the site. The day this tuple gains a second entry, the
# per-inverter claims stop being site claims on their own, which is
# exactly the seam ALLOC-01 occupies.
#
# ⚠️ INVERTER B DOES NOT EXIST YET. This is not a configuration option
# and must not be turned into one here; ALLOC-01 owns the roster, the
# weights and the split. Naming the unit is all ARB-03 needs, and it is
# what makes a mis-routed claim something a check can see.
self.unit = UNIT_A
self.units = (UNIT_A,)
# live state
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
self.target = 0.0
@@ -415,6 +439,18 @@ class Controller:
self.last_write_at = datetime.now(timezone.utc)
# -- the cycle -----------------------------------------------------------
def actuator_of(self, source: str) -> str:
"""Which actuator a claim from `source` is filed on this cycle.
⚠️ THE CLASSIFICATION IS NOT HERE. `arbiter.CLAIM_SCOPE` decides whether
a source is about the grid connection or about one pack, and it carries
the reasoning for all eight; this method only supplies the roster, which
is the one thing the arbiter cannot know. Routing a claim by hand -
writing `actuator=` at a construction site - bypasses the KeyError that
stops a ninth, unclassified source being filed, so do not.
"""
return actuator_for(source, self.unit, self.units)
async def cycle(self) -> None:
"""Gather claims, resolve precedence, write the winner.
@@ -473,9 +509,17 @@ class Controller:
# duplication is deliberate - one of them is the policy, the other is the
# last thing between a bug and the hardware.
if self.dev_min is not None and self.dev_max is not None:
claims.append(Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max, "rating"))
# PER-INVERTER. The numbers are ONE inverter's own reported
# min/max; a second unit has a second rating and there is no such
# thing as a site rating. See arbiter.CLAIM_SCOPE.
claims.append(Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max, "rating",
actuator=self.actuator_of("device")))
max_w = float(self.o.get("max_w", 2000))
claims.append(Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"))
# PER-INVERTER, for the same reason as `device`: `max_w` is the
# supervised cap on what may be WRITTEN to one setpoint entity (§11.3),
# not a bound on the grid connection.
claims.append(Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w",
actuator=self.actuator_of("supervised")))
# --- safety stops ----------------------------------------------------
# ⚠️ SAFETY-02 is claimed FIRST and unconditionally while latched. It is
@@ -484,16 +528,31 @@ class Controller:
# whatever it thinks it is doing. Re-filed EVERY cycle: claims are not
# sticky, and one filed once would lapse the next cycle and hand the
# actuator silently back to the loop that was driving the wrong way.
#
# ⚠️ AND IT IS PER-INVERTER, THOUGH ITS EVIDENCE IS SITE-WIDE. The
# watchdog compares P1 - one meter, one site - against `standing`, THE
# COMMAND THIS CONTROLLER PUT ON THIS ACTUATOR, and what it concludes is
# "the unit I am driving is pushing power the wrong way". At N>=2 that
# evidence cannot be pinned on a sibling this controller never commanded,
# so a site-wide stop would park an inverter this watchdog never graded.
# See arbiter.CLAIM_SCOPE.
if self.sign_wd.tripped:
claims.append(Claim.set("failsafe", P_SAFETY, 0.0,
"sign disagreement"))
"sign disagreement",
actuator=self.actuator_of("failsafe")))
# SITE, both of them, and the two reasons are why. "stopped" is the
# operator switching control off for the whole site; "inputs missing" is
# the SITE's own telemetry gone - the meter this controller regulates
# against. Neither is about a pack, and both must stop every inverter.
if not self.auto:
claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped"))
claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped",
actuator=self.actuator_of("safety")))
elif not self.inputs_ok:
if self.target != 0.0:
self.log_event("inputs missing - commanding 0 W")
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing"))
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing",
actuator=self.actuator_of("safety")))
# --- SAFETY-07: the quarter-peak projection guard --------------------
# ONE `lo` limit at P_PEAK, and peakguard.py carries the whole argument.
@@ -567,6 +626,12 @@ class Controller:
standing, max_w, self.freeze_wd.cfg,
dev_min=self.dev_min, dev_max=self.dev_max),
)
# SITE, and peakguard.claim() says so on the object it builds. The
# quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION,
# which is what the capacity tariff bills; there is one quarter-hour
# peak per site, not one per pack. ⚠️ IT IS ALSO THE `lo` HALF OF
# SAFETY-25 §0c's table - a site `lo` at this stage, which is what the
# deferred loop reserve's per-inverter `hi` must never be able to cross.
peak_claim = (peakguard.claim(self.peak_guard)
if self.peak_guard is not None else None)
if peak_claim is not None:
@@ -630,6 +695,9 @@ class Controller:
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
charge_w=max_w, discharge_w=max_w,
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
# SITE, and arbitrage.claim() says so on the object it builds. It
# buys and sells at the grid against a day-ahead price: the price is
# a property of the connection and the energy crosses the meter.
arb_claim = arbitrage.claim(self.arb)
if arb_claim is not None:
claims.append(arb_claim)
@@ -643,8 +711,13 @@ class Controller:
# --- maintenance -----------------------------------------------------
if result.owns_setpoint:
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
# drain THIS pack to its floor, charge THIS pack full, recalibrate
# THIS pack's coulomb counter. Two packs recalibrate on two
# schedules. See arbiter.CLAIM_SCOPE.
claims.append(Claim.set("maintenance", P_MAINTENANCE,
float(result.setpoint_w or 0.0), result.phase))
float(result.setpoint_w or 0.0), result.phase,
actuator=self.actuator_of("maintenance")))
self.sat_count = 0
elif result.charge_only:
# Money outranks the maintenance schedule: while the quarter-hour
@@ -706,7 +779,10 @@ class Controller:
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
if floor > 0:
hi, reason = -floor, "cheap-window charge"
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason))
# PER-INVERTER, like the SET above and for the same reason -
# the shaping is about the pack being charged.
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason,
actuator=self.actuator_of("maintenance")))
# --- the grid-following loop -----------------------------------------
if self.auto and self.inputs_ok:
@@ -753,7 +829,12 @@ class Controller:
+ (" (integrator bound)" if decision.i_clamped else "")
+ (" (output bound)" if decision.clamped else "")),
)
claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason))
# SITE. compute() regulates `grid_w` - the P1 measurement - to
# zero. Its subject is the meter, and §6.2's architecture is ONE
# integrator on P1 whose output is then SPLIT. This is the claim the
# allocator exists to divide.
claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason,
actuator=self.actuator_of("loop")))
else:
# Stopped or blind: no accumulation may survive the outage.
self.i_w = 0.0
@@ -763,7 +844,31 @@ class Controller:
# de-assert when control is stopped.
self.loop_clamped = (False, "")
resolution = resolve(claims)
resolution = resolve(claims, units=self.units)
# ⚠️ N>=2 WITHOUT AN ALLOCATOR IS A REFUSAL, NOT A BROADCAST, and this
# branch is unreachable while the roster holds one unit. It is here
# because the alternative default is the fault ARCH-01 was filed about:
# hand the site target to each inverter and each supplies it IN FULL, so
# the loop gain at the meter becomes N times the per-unit gain - a
# permanent +-2 kW limit cycle at the gain config.yaml documents as its
# maximum, with every watchdog in §11.2 reporting healthy. `allocate()`
# declines rather than invent a split; this is what declining costs.
if not resolution.allocated:
self.log_event(f"ARBITER: no allocator for {len(self.units)} "
f"inverters - refusing to broadcast the site target")
resolution = Resolution(0.0, "failsafe", None, False,
resolution.considered,
unit_bounds=resolution.unit_bounds,
shares={}, allocated=False,
scope_errors=resolution.scope_errors)
if resolution.scope_errors:
# A claim reached the arbiter on an actuator its source's scope does
# not permit. It is a BUG in the filing site, not a state to survive:
# a per-inverter constraint in the site list is the `lo > hi` this
# card removes, and a site constraint on one unit is a bound the
# other unit never gets. Loud, every cycle, and it does not change
# the resolution - the arbiter already routed by `actuator`.
_LOG.error("ARBITER SCOPE: %s", "; ".join(resolution.scope_errors))
if resolution.contradiction:
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log")
# --- SAFETY-02's arming gate ------------------------------------------