ARB-03: restore Claim.actuator and make resolve() three-stage #40

Merged
admin merged 2 commits from ARB-03 into release/1.0 2026-09-02 04:16:45 +02:00
7 changed files with 1191 additions and 24 deletions
+109
View File
@@ -2,6 +2,115 @@
## Unreleased
**`Claim` carries the `actuator` specs.md always said it did, and `resolve()`
is three-stage (ARB-03).** `specs.md` §6.1 declares `actuator: str` as the first
field of `Claim`; the shipped `app/arbiter.py` had no such field, so every claim
was implicitly about "the one and only actuator", `resolve()` returned one
scalar, and `main.py` assigned it to one `self.target`. **There was no seam an
allocator could occupy** - and `ARCH-01` decided the site grid target is
ALLOCATED across several inverters and never broadcast. This is a regression fix
against the written design, and it creates that seam. **At N=1 it is a pure
refactor: every one of the 17 suites replays unedited and green, on the same
numbers.**
* **The split, verified at each construction site rather than assumed.** Four
sources are about the **grid connection** and are SITE claims - `loop`
(regulates `grid_w`, the P1 measurement), `peak` (the quarter-hour average
offtake the capacity tariff bills), `arbitrage` (buys and sells at the grid
against a day-ahead price) and `safety` (`stopped` is the site's switch,
`inputs missing` is the site's own meter). Four are about **a pack, a device
or one write** and are PER-INVERTER - `device` (one inverter's own reported
rating), `supervised` (`max_w` caps what may be WRITTEN to one setpoint
entity), `failsafe` (**site-wide evidence, per-unit remedy**: SAFETY-02 grades
the command THIS controller put on THIS actuator, and at N>=2 that evidence
cannot be pinned on a sibling nobody here commands) and `maintenance` (§12's
recalibration is a pack procedure). The reasoning is recorded **at the line**,
in `arbiter.CLAIM_SCOPE` and again at each `claims.append` in `main.py`.
* **Getting it wrong is the parked-battery bug, so it is routed rather than
written.** 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** -
SAFETY-25 §0a's fatal case. `actuator_for()` is the one routing point and it
**raises `KeyError` on an unclassified source** rather than defaulting to the
site, because defaulting to the site is the failure itself.
* **Three stages, each a function a test can construct a case at.**
`pre_resolve()` turns one inverter's own claims into that unit's interval;
`site_resolve()` turns the site claims into one grid target and **is the
shipped arbiter, unchanged**; `allocate()` is **the seam, and deliberately not
an allocator** - `ALLOC-01` builds it. With at most one unit it passes the
target through (§6.2's N=1 identity); with two or more **it declines, no
shares, `allocated=False`**, because the obvious placeholder - hand the target
to each - IS the fault `ARCH-01` was filed about. **What `main.py` then does
is SUBSTITUTE, not abstain**: it replaces the resolution with a 0 W `failsafe`,
logs "refusing to broadcast the site target", and writes that 0 W through
`write_setpoint()` like any other cycle. The refusal is to BROADCAST; the
write still happens, in the fail-toward-inaction direction. Graded end to end
on a real two-inverter cycle by new **part 10h** - the branch is unreachable
on the shipped roster, and replacing it with `if False:` had left all 17
suites green.
* **`resolve()` HONOURS the roster it is handed, and that is now graded too.**
Left `None` the roster is derived from the claims, which is the documented
default; passed explicitly it is used. Deriving it *always* - ignoring the
argument - makes a unit that filed nothing this cycle cease to exist, so a
site of two reads as a site of one and the seam passes the whole grid target
through to the inverter that spoke: `ARCH-01`'s fault with its own evidence
removed. `test_arbiter.py` part 7 grades it on the one case that separates the
two, a **silent unit**.
* **The contradiction is no longer 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, a unit contradicting
ITSELF included) 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 nothing splits, and that is §6.2's identity rather than the
classification being ignored.** 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 and every source routes to the site. **Measured:
dropping that identity - splitting at N=1 - takes `test_peakguard.py`,
`test_freeze.py` and part 10g red**, because the `failsafe` and `maintenance`
SET claims would stop competing for the winner, `explain()` would name a
different source and `loop_is_following()` reads that name.
* **SAFETY-24's floor asymmetry survives, and now has a second half.** The
combination sweep is re-run and still holds - no negative floor, `hi` still
`+inf` on every claim the guard files - and it now also asserts every one of
them is on the **site** actuator, because a floor bound to one inverter's share
would leave the site, the thing the capacity tariff meters, with no floor.
* **The part 10f tripwire is kept and widened, not adapted around.** Its static
AST scan and its dynamic SoC sweep are untouched in substance - `source` is
still `Claim.set()`'s first positional argument precisely so the scan keeps
reading it, which is why `actuator` is the LAST field of `Claim` and not the
first. Its spy gained `**kw` so the roster passes through unread rather than
being silently dropped. New **part 10g** pins `CLAIM_SCOPE` to the same
eight-source set, so a ninth source now goes red twice. Verified by breaking
it: building SAFETY-25's Option B as a ninth source still takes 10f red on the
line it is written.
* **And the CALL SITES are pinned statically, at all eight of them.** 10g's
first cut graded routing only through a **real two-inverter cycle** and called
that "the line a mis-typed `actuator_of(...)` at a call site goes red on".
**It was not.** That cycle reaches `device`, `supervised` and `loop` and
nothing else - three of the eight direct appends - so a mis-type at the other
five survived every suite in this repository. Proved on the shipped tree by PR
#40's reviewer: `actuator_of("maintenance")` -> `actuator_of("peak")` at the
maintenance-shaping append left `test_arbiter.py`, `test_peakguard.py` and
`test_arbitrage.py` all green while, at a roster of two, it built a site loop
of -1500 W against the guard's site `lo` of 1500 W and the now-misrouted
charge-only `hi = 0` - `lo > hi`, arbiter clause 3, **0 W in the billed
quarter**, SAFETY-25 §0a's fatal case from a one-word typo. That is this
repository's named failure mode - **a check observing something other than
what it claimed** - reproduced inside the check written to prevent it, so it
is closed the way 10f closes it: over the **same AST scan**. All eight direct
appends expose their actuator as a literal `actuator=self.actuator_of('...')`,
so the literal is required to EQUAL the source the claim is filed under - all
eight, on the line the typo is written, in every configuration and at every
roster, whether or not any fixture reaches the state that files them.
**Measured: each of the eight mutated to a wrong actuator in turn, each one
red.** The two INDIRECT appends (`peak_claim`, `arb_claim`) carry no
`actuator=` at the call site to mis-type, and are pinned where their routing
is actually chosen - `peakguard.claim()` by the `SWEEP_SITE` sweep,
`arbitrage.claim()` by `test_arbitrage.py`. 10g's dynamic cycle is kept and
now says which three of the eight it sees instead of claiming the class.
**SAFETY-25's open question is ANSWERED: the BMS cut-out is the DELIBERATE and
only backstop for demand-driven discharge, and a loop-side reserve floor is
DEFERRED to `ALLOC-01` rather than rejected (SAFETY-25).** Decided by the
+347 -5
View File
@@ -25,6 +25,39 @@ 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. ⚠️ IT IS NOT AN ALLOCATOR AND MUST
NOT BECOME ONE HERE - see its own docstring. ALLOC-01
builds it.
⚠️ 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
@@ -50,6 +83,131 @@ P_LOOP = 10 # ordinary grid-following
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,
# --- 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 SITE-WIDE AND THE REMEDY IS NOT, and that is why this
# entry needs its reasoning written down. SAFETY-02 compares P1 - a site
# measurement - against `standing`, THE COMMAND THIS CONTROLLER PUT ON THIS
# ACTUATOR. What it concludes is "the unit I am driving is pushing power the
# wrong way", and what it does is park that unit. At N>=2 the same evidence
# cannot be attributed to a sibling nobody here commands, so a site-wide
# remedy would be this watchdog stopping an inverter it never graded.
"failsafe": 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,
}
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:
@@ -60,15 +218,76 @@ class Claim:
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 = "") -> "Claim":
return Claim(source, priority, SET, value=value, reason=reason)
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 = "") -> "Claim":
return Claim(source, priority, LIMIT, lo=lo, hi=hi, reason=reason)
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
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
@@ -78,6 +297,14 @@ class Resolution:
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
def explain(self) -> str:
"""One line an operator can act on - this is the point of the module."""
@@ -88,7 +315,79 @@ class Resolution:
return f"{self.winner} -> {self.target_w:.0f} W"
def resolve(claims: list) -> Resolution:
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),
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
for c in unit_claims:
if c.kind != LIMIT or (gate is not None and c.priority < gate):
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)
def allocate(target_w: float, bounds: dict) -> tuple:
"""STAGE 3. THE SEAM. Returns `(shares, allocated)`.
⚠️ THIS IS NOT THE ALLOCATOR AND MUST NOT BECOME ONE HERE. ALLOC-01 owns
that, and §6.2 already fixed its shape: SUM-INVARIANT SHARE ALLOCATION - the
shares sum to `target_w` exactly, each unit is told a function of its OWN
share, and the weights are a comfort choice (SoC, thermals, wear) and
explicitly not a tuning parameter, because the effective gain at the meter is
then a convex combination of the per-unit gains for any split.
What it does today, and why each half is what it is:
* WITH AT MOST ONE UNIT there is nothing to divide. The share IS the site
target, and that is not an approximation - it is §6.2's N=1 identity, the
one that makes "the battery moves X W" and "the grid is at X W" the same
sentence. Pass-through.
* WITH TWO OR MORE UNITS IT DECLINES: no shares, `allocated=False`. ⚠️ THE
OBVIOUS PLACEHOLDER - hand `target_w` to each unit - IS THE FAULT ARCH-01
WAS FILED ABOUT. Each inverter would supply it in full, the loop gain at
the meter would be N times the per-unit gain, and at the gain config.yaml
accepts as its documented maximum that is a permanent +-2 kW limit cycle
with every watchdog in §11.2 reporting healthy. A placeholder that cannot
be told apart from the real thing by any guard we have is worse than no
placeholder, so this one refuses out loud instead.
"""
if len(bounds) <= 1:
only = next(iter(bounds), ACTUATOR_SITE)
return {only: float(target_w)}, True
return {}, False
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)
@@ -123,3 +422,46 @@ def resolve(claims: list) -> Resolution:
bound_by = f"{binder.source}({binder.reason})" if binder.reason else binder.source
return Resolution(float(target), winner.source, bound_by, False, considered)
def resolve(claims: list, *, units=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 ALLOC-01 will pass the real roster rather than let a
silent unit look like a smaller site.
⚠️ 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.
⚠️ 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]
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)
# STAGE 3 - the seam.
out.shares, out.allocated = allocate(out.target_w, bounds)
out.unit_bounds = bounds
out.scope_errors = scope_violations(claims)
return out
+6 -2
View File
@@ -146,7 +146,7 @@ import math
from dataclasses import dataclass
from datetime import datetime
from .arbiter import P_ARBITRAGE, Claim
from .arbiter import ACTUATOR_SITE, P_ARBITRAGE, Claim
from .price import Absence, price_option
_LOG = logging.getLogger("goodwe.arbitrage")
@@ -425,5 +425,9 @@ def claim(decision: ArbitrageDecision) -> Claim | None:
"""
if not decision.trading or decision.power_w is None:
return None
# ⚠️ ON THE SITE ACTUATOR, EXPLICITLY. A trade is bought and sold at the
# grid: the price is a property of the connection and the energy crosses the
# meter. So this is a claim on the SITE target, which the allocator then
# divides - not an instruction to any one pack.
return Claim.set(SOURCE, P_ARBITRAGE, float(decision.power_w),
decision.action)
decision.action, actuator=ACTUATOR_SITE)
+128 -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,44 @@ 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 TO BROADCAST, NOT A REFUSAL
# TO WRITE, and the difference is the next four lines: the resolution is
# REPLACED by a 0 W failsafe and then written like any other cycle at
# `write_setpoint(self.target)` below. Nothing is skipped. 0 W is the
# fail-toward-inaction direction - the setpoint is commanded to zero
# rather than left wherever the last cycle put it.
#
# 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.
#
# ⚠️ AND BEING UNREACHABLE IS NOT A REASON TO LEAVE IT UNGRADED. It was:
# PR #40's reviewer replaced this `if` with `if False:` and the whole
# 17-file suite stayed green, so the branch was one refactor away from
# being deleted as dead code. test_peakguard.py part 10h now drives a
# real two-inverter cycle and grades all four halves - the 0 W, the
# `failsafe` winner, the log line, and the write that follows it.
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 ------------------------------------------
+9 -2
View File
@@ -372,7 +372,7 @@ not need re-testing. Nothing here indexes a phase.
from dataclasses import dataclass
from .arbiter import P_PEAK, Claim
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
from .control import peak_at_risk
from .tariff import QUARTER_S
@@ -744,7 +744,14 @@ def claim(decision: PeakDecision) -> Claim | None:
# when the battery stops charging. Spelling them the same way would make the
# SAFETY-24 case indistinguishable from the SAFETY-18 one on the one line
# that gets read.
# ⚠️ ON THE SITE ACTUATOR, EXPLICITLY. The billed quantity is the quarter's
# average offtake AT THE GRID CONNECTION - one per site, whatever the site
# has behind it - so this floor bounds the site target and is divided by the
# allocator along with it. It is never a bound on one unit's share: SAFETY-25
# §0c's table has this `lo` at the SITE stage precisely so the deferred loop
# reserve's per-inverter `hi` cannot become its counterpart in a `lo > hi`.
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
reason=("blind" if decision.blind
else "SoC floor" if decision.floor_limited
else "quarter peak"))
else "quarter peak"),
actuator=ACTUATOR_SITE)
+328 -2
View File
@@ -13,10 +13,13 @@ Read MUTATION.md before you report a survivor.
"""
import sys
from math import inf
from app.arbiter import (
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY,
Claim, resolve,
ACTUATOR_SITE, CLAIM_SCOPE, P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE,
P_MAINTENANCE, P_PEAK, P_SAFETY, SCOPE_SITE, SCOPE_UNIT, UnitBounds,
Claim, actuator_for, allocate, pre_resolve, resolve, scope_violations,
site_resolve,
)
fails = []
@@ -28,6 +31,15 @@ def check(name, cond):
fails.append(name)
def _raises(fn):
"""True if `fn()` raises - a check that something is REFUSED, not defaulted."""
try:
fn()
except Exception:
return True
return False
print("basics")
r = resolve([])
check("no claims at all means 0 W", r.target_w == 0.0 and r.winner == "failsafe")
@@ -99,6 +111,320 @@ check("charging is clamped by the low limit", r.target_w == -1000)
r = resolve([Claim.set("loop", P_LOOP, -200), charge_only])
check("charge-only permits charging", r.target_w == -200)
# =============================================================================
# ARB-03: the actuator specs.md declared, and the three stages
# =============================================================================
# ⚠️ EVERY CHECK ABOVE THIS LINE IS UNEDITED, AND THAT IS ITSELF THE FIRST
# ACCEPTANCE CRITERION. Not one of them names an actuator, so every claim they
# build is a SITE claim - which is exactly the state main.py is in at N=1 - and
# they still answer what they answered before the field existed. If a line above
# had to be changed to accommodate `actuator`, stage 2 would not be the shipped
# arbiter and the refactor would not be pure.
print()
print("ARB-03 1. the field, and what its absence means")
lone = Claim.set("loop", P_LOOP, 900)
check("a claim built without naming an actuator carries one anyway",
lone.actuator == ACTUATOR_SITE)
# ⚠️ THE TYPE ITSELF, NOT ONLY THE TWO HELPERS. Measured: with only the helper
# checked, emptying the DATACLASS default survives this suite untouched, because
# `Claim.set()` and `Claim.limit()` pass their own default down and the field's
# never runs. Anything that builds a `Claim(...)` directly - a future strategy,
# a `dataclasses.replace()` - would have got the unspecified actuator that this
# card exists to remove.
check("...and so does one built through the dataclass directly, which is the "
"constructor the two helpers do not cover",
Claim("loop", P_LOOP).actuator == ACTUATOR_SITE)
check("...and it is an explicit SITE, not None and not an empty string. "
"'unspecified' is what the shipped code had, and 'unspecified' is what "
"made every claim implicitly about the one and only actuator",
isinstance(lone.actuator, str) and lone.actuator != ""
and lone.actuator is not None)
check("...and `source` is still the first positional argument, which is what "
"test_peakguard.py part 10f's AST scan reads off main.py to pin the "
"claim-source set. specs.md declares `actuator` first; the tuple position "
"is not the part of that declaration that carries meaning, and moving "
"`source` off zero would blind the tripwire that guards SAFETY-25",
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
print()
print("ARB-03 2. the split: eight sources, each classified where it is BUILT")
# The classification is graded against what each claim is ABOUT, restated here
# so that the table in arbiter.py and the expectation in a test are not the same
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
# own AST, so a ninth source cannot arrive without both files noticing.
SITE_SOURCES = {
"loop": "regulates grid_w, the P1 measurement - one meter, one site",
"peak": "the quarter-hour average offtake AT the grid connection",
"arbitrage": "buys and sells at the grid against a day-ahead price",
"safety": "'stopped' is the site's switch, 'inputs missing' is the "
"site's own meter - neither is about a pack",
}
UNIT_SOURCES = {
"device": "one inverter's own reported min/max rating",
"supervised": "max_w caps what may be WRITTEN to one setpoint entity",
"failsafe": "site-wide evidence, per-unit remedy: SAFETY-02 grades the "
"command THIS controller put on THIS actuator",
"maintenance": "the monthly recalibration is a pack procedure",
}
for _src, _why in sorted(SITE_SOURCES.items()):
print(f" site {_src:<11} {_why}")
for _src, _why in sorted(UNIT_SOURCES.items()):
print(f" unit {_src:<11} {_why}")
check("all eight claim sources are classified, and only those eight",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the four that are about the GRID CONNECTION are site claims",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
check("the four that are about a PACK, a DEVICE or one write are per-inverter",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
check("a source nobody classified cannot be routed at all - actuator_for() "
"raises rather than defaulting to the site, because defaulting to the "
"site is the failure this card removes",
_raises(lambda: actuator_for("reserve", "inverter-a", ("inverter-a",))))
print()
print("ARB-03 3. at N=1 the site IS the unit, so nothing moves")
# §6.2's identity: with one inverter the fabricated grid reading and the battery
# setpoint are the same number, so there is one actuator and it is both.
ONE = ("inverter-a",)
TWO = ("inverter-a", "inverter-b")
check("with one inverter every source routes to the site, per-inverter ones "
"included - that is the identity, not the classification being ignored",
all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("...and with none configured at all, likewise",
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("with TWO the identity breaks and the split becomes real: the four "
"per-inverter sources route to the unit this controller drives, the four "
"site sources stay on the grid connection",
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
== {**{s: "inverter-a" for s in UNIT_SOURCES},
**{s: ACTUATOR_SITE for s in SITE_SOURCES}})
print()
print("ARB-03 4. stage 2 IS the shipped arbiter (AC 3)")
# ⚠️ NOT ASSERTED, REPLAYED. Every claim list this file resolves above is run
# through BOTH entry points and every field the shipped code reads off a
# Resolution is compared. `resolve()` at N=1 is `site_resolve()` on the same
# list, so a single edit inside stage 2 would show up in every check above as
# well as here - and one that only affects the wrapper shows up only here.
REPLAY = [
[],
[Claim.set("loop", P_LOOP, 900)],
[Claim.set("loop", P_LOOP, 900), Claim.set("maintenance", P_MAINTENANCE, 2500)],
[Claim.set("loop", P_LOOP, 4000),
Claim.limit("supervised", P_SAFETY, lo=-2000, hi=2000, reason="max_w")],
[Claim.set("loop", P_LOOP, 4000), Claim.limit("a", P_SAFETY, hi=3000),
Claim.limit("b", P_SAFETY, hi=1500)],
[Claim.set("loop", P_LOOP, 900), charge_only],
[Claim.set("loop", P_LOOP, 900), charge_only, Claim.set("peak", P_PEAK, 2500)],
[Claim.set("peak", P_PEAK, 9000), charge_only,
Claim.limit("device", P_SAFETY, lo=-5000, hi=5000, reason="rating")],
[Claim.set("loop", P_LOOP, 900), Claim.set("safety", P_SAFETY, 0, "inputs missing")],
[Claim.set("loop", P_LOOP, 500), Claim.limit("x", P_SAFETY, lo=1000),
Claim.limit("y", P_SAFETY, hi=200)],
[Claim.set("loop", P_LOOP, -3000),
Claim.limit("supervised", P_SAFETY, lo=-1000, hi=1000)],
[Claim.set("loop", P_LOOP, -200), charge_only],
[Claim.set("arbitrage", P_ARBITRAGE, -2000, "buy"),
Claim.limit("peak", P_PEAK, lo=800.0, reason="quarter peak"),
Claim.limit("supervised", P_SAFETY, lo=-2000, hi=2000, reason="max_w")],
]
def _shape(r):
return (r.target_w, r.winner, r.bound_by, r.contradiction,
list(r.considered), r.explain())
_diff = [i for i, cl in enumerate(REPLAY)
if _shape(resolve(cl)) != _shape(site_resolve(cl))]
print(f" replayed {len(REPLAY)} claim lists through both entry points")
check("NOT ONE of them resolves differently - target, winner, binder, "
"contradiction flag, the considered list and the operator line are equal "
"on every one. At N=1 the three stages are the shipped arbiter with "
"bookkeeping either side of it", _diff == [] and len(REPLAY) >= 12)
check("...and stage 3 hands the whole target to the one actuator there is, "
"which is the N=1 identity rather than an allocation",
resolve([Claim.set("loop", P_LOOP, 900)]).shares == {ACTUATOR_SITE: 900.0})
check("...and says it allocated, because at N<=1 there is nothing to divide",
resolve([Claim.set("loop", P_LOOP, 900)]).allocated)
print()
print("ARB-03 5. THE CONTRADICTION THAT CANNOT BE WRITTEN DOWN (AC 4)")
# SAFETY-25 §0a, constructed. A per-inverter reserve says "this pack must not
# discharge" (`hi = 0`); the peak guard says "the site must deliver at least
# 1500 W" (`lo = 1500`). At N=1 those are two bounds on ONE number and they
# genuinely cross - which is today's behaviour, and today's hazard, and is left
# exactly as it is. Filed on TWO actuators they are bounds on two different
# numbers, and there is no expression in which one is the other's counterpart.
SITE_SIDE = [Claim.set("loop", P_LOOP, -1500.0, "grid"),
Claim.limit("peak", P_PEAK, lo=1500.0, reason="quarter peak")]
CROSSED = SITE_SIDE + [Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0,
reason="charge-only")]
SPLIT = SITE_SIDE + [Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0,
reason="charge-only", actuator="inverter-b")]
r_crossed, r_split = resolve(CROSSED), resolve(SPLIT)
print(f" both on one actuator: {r_crossed.explain()}")
print(f" the `hi` on inverter-b: {r_split.explain()} "
f"inverter-b bounds {r_split.unit_bounds['inverter-b'].explain()}")
check("filed on ONE actuator the pair still contradicts and still parks the "
"battery at 0 W in the billed quarter. That is the shipped behaviour and "
"this card does not change it - at N=1 the two really are bounds on one "
"number", r_crossed.contradiction and r_crossed.target_w == 0.0)
check("filed on TWO the site keeps its floor: the unit `hi` is not a term of "
"the site's `lo > hi` test, so there is no contradiction to flag",
not r_split.contradiction and r_split.target_w == 1500.0)
check("...and the constraint is not lost, it is CARRIED - inverter-b's own "
"interval still says 'do not discharge', for the allocator to honour when "
"it divides the 1500 W", r_split.unit_bounds["inverter-b"].hi == 0.0
and r_split.unit_bounds["inverter-b"].hi_src == "maintenance")
check("...and the site target is bit-for-bit what the site claims alone "
"resolve to, so the unit claim contributed NOTHING to it - which is the "
"'cannot be expressed' rather than 'is guarded against'",
_shape(site_resolve(SITE_SIDE))[:4] == _shape(r_split)[:4])
# Swept rather than argued: no per-inverter source, at any bound, on any unit,
# can reach the site's limits.
_SWEEP_N = _SWEEP_BAD = 0
for _src in sorted(UNIT_SOURCES):
for _unit in ("inverter-a", "inverter-b"):
for _lo, _hi in ((0.0, inf), (-inf, 0.0), (2500.0, inf), (-inf, -2500.0),
(900.0, 100.0)):
_r = resolve(SITE_SIDE + [Claim.limit(_src, P_MAINT_SHAPE, lo=_lo,
hi=_hi, actuator=_unit)])
_SWEEP_N += 1
if _r.contradiction or _r.target_w != 1500.0:
_SWEEP_BAD += 1
print(f" swept {_SWEEP_N} (per-inverter source x unit x bound) filings "
f"against a live site floor: {_SWEEP_BAD} disturbed the site target")
check(f"NOT ONE of the {_SWEEP_N} filings contradicts the site floor or moves "
f"the site target - including the last bound of each row, which is a unit "
f"contradicting ITSELF (lo 900 > hi 100). A unit's own impossible pair "
f"parks that unit; it does not park the site",
_SWEEP_BAD == 0 and _SWEEP_N >= 40)
_self_bad = resolve(SITE_SIDE + [Claim.limit("maintenance", P_MAINT_SHAPE,
lo=900.0, hi=100.0,
actuator="inverter-b")])
check("...and that unit says so on its own bounds, so the fault is reported "
"where it was made rather than swallowed",
_self_bad.unit_bounds["inverter-b"].contradiction
and "CONTRADICTION" in _self_bad.unit_bounds["inverter-b"].explain())
print()
print("ARB-03 6. stage 1: one unit's claims, one unit's interval")
mb = pre_resolve([Claim.limit("device", P_SAFETY, -5000, 5000, "rating"),
Claim.limit("supervised", P_SAFETY, -2000, 2000, "max_w"),
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, reason="charge-only")])
print(f" a unit filing rating, max_w and charge-only: {mb.explain()}")
check("the most restrictive of the unit's own limits is the interval",
mb.lo == -2000 and mb.hi == 0.0 and mb.hi_src == "maintenance"
and mb.lo_src == "supervised")
owned = pre_resolve([Claim.set("maintenance", P_MAINTENANCE, -2500, "charge"),
Claim.limit("supervised", P_SAFETY, -2000, 2000, "max_w"),
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, reason="charge-only")])
print(f" a unit whose maintenance owns the setpoint: {owned.explain()}")
check("a unit's own winning SET gates its own limits exactly as clause 2 does "
"at the site - the P_MAINT_SHAPE ceiling is not applied to a "
"P_MAINTENANCE drain that outranks it", owned.hi == 2000.0)
check("...and the SET's value travels BESIDE the interval, not inside it. "
"Folding -2500 into [-2500, -2500] would turn 'clamped to max_w' into "
"'contradicts max_w', which is a different answer and a wrong one",
owned.pinned_w == -2500 and owned.pinned_by == "maintenance"
and owned.gate == P_MAINTENANCE and not owned.contradiction)
print()
print("ARB-03 7. the seam is a seam, and NOT an allocator")
check("with no units the whole target goes to the site actuator",
allocate(1500.0, {}) == ({ACTUATOR_SITE: 1500.0}, True))
check("with one unit the share IS the site target - §6.2's N=1 identity, "
"pass-through", allocate(1500.0, {"inverter-a": UnitBounds()})
== ({"inverter-a": 1500.0}, True))
_shares, _ok = allocate(1500.0, {"inverter-a": UnitBounds(),
"inverter-b": UnitBounds()})
print(f" two units, no allocator: shares={_shares} allocated={_ok}")
check("with two it DECLINES rather than inventing a split. ⚠️ THE OBVIOUS "
"PLACEHOLDER - hand the target to each - IS THE FAULT ARCH-01 WAS FILED "
"ABOUT: each inverter supplies it in full, the gain at the meter becomes "
"N times the per-unit gain, and no watchdog in the table fires",
_shares == {} and _ok is False)
check("...and specifically it does not broadcast", 1500.0 not in _shares.values())
# ⚠️ AND `resolve()` HAS TO HONOUR THE ROSTER IT IS GIVEN, WHICH IS NOT THE SAME
# THING AS HAVING A `units` PARAMETER. Left None the roster is DERIVED from the
# claims, and that derivation is right only for a caller that files a claim for
# every unit every cycle. Ignore the argument and derive it always - `if units
# is None:` -> `if True:` - and a unit that filed nothing this cycle simply
# CEASES TO EXIST: the site of two looks like a site of one, the seam passes the
# whole grid target through to the single inverter that spoke, and ARCH-01's
# fault is built with the evidence for it removed. That mutation left this whole
# suite green until this check was written (PR #40 review), so it is graded on
# the one observation that distinguishes the two - a SILENT unit.
_silent = resolve([Claim.set("loop", P_LOOP, 900.0),
Claim.limit("device", P_SAFETY, -2000, 2000, "rating",
actuator="inverter-a")],
units=TWO)
print(f" roster of two, only inverter-a files: bounds="
f"{sorted(_silent.unit_bounds)} allocated={_silent.allocated} "
f"shares={_silent.shares}")
check("resolve() HONOURS the roster it is handed rather than inferring one from "
"who happened to speak. inverter-b filed nothing this cycle and is still "
"a unit: it gets its own interval, and the site is still a site of TWO, "
"so the seam still DECLINES",
sorted(_silent.unit_bounds) == ["inverter-a", "inverter-b"]
and _silent.allocated is False and _silent.shares == {})
check("...and that is the whole cost of ignoring the argument, stated as the "
"number it would produce: derive the roster from the claims instead and "
"the site is a site of ONE, so the seam passes 900 W through to "
"inverter-a - the silent inverter's absence read as a smaller site",
resolve([Claim.set("loop", P_LOOP, 900.0),
Claim.limit("device", P_SAFETY, -2000, 2000, "rating",
actuator="inverter-a")]).shares
== {"inverter-a": 900.0})
check("...and left None the roster IS derived from the claims, which is the "
"documented default every N=1 replay above depends on - so the parameter "
"changes the answer only where the two disagree",
resolve([Claim.set("loop", P_LOOP, 900.0),
Claim.limit("device", P_SAFETY, -2000, 2000, "rating",
actuator="inverter-a")], units=None).allocated is True)
print()
print("ARB-03 8. verified by breaking it: a claim routed to the wrong stage (AC 6)")
# The `peak` floor, misfiled on a unit. It is the site's floor - if it lands on
# one inverter's interval the SITE keeps no floor at all, and the loop is free to
# go on charging through the quarter that sets the month's bill.
mis_peak = SITE_SIDE[:1] + [Claim.limit("peak", P_PEAK, lo=1500.0,
reason="quarter peak",
actuator="inverter-b")]
r_mis = resolve(mis_peak)
print(f" peak floor misfiled on inverter-b: {r_mis.explain()} "
f"scope_errors={r_mis.scope_errors}")
check("the mis-routing is CAUGHT: scope_violations names the source and the "
"actuator it does not belong on", len(r_mis.scope_errors) == 1
and "peak" in r_mis.scope_errors[0])
check("...and the reason it must be caught, measured rather than argued: with "
"the floor on a unit the SITE has no floor, so the loop charges 1500 W "
"into the quarter the guard was trying to shave",
r_mis.target_w == -1500.0 and resolve(SITE_SIDE).target_w == 1500.0)
check("a UNIT source filed on the site at a site that HAS named units is "
"caught too - that is the direction that produces `lo > hi`",
scope_violations(SITE_SIDE + [
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0),
Claim.limit("device", P_SAFETY, -1, 1, actuator="inverter-b")]) != [])
check("...and at N=1, where every source legitimately routes to the site, the "
"same list is clean. The check grades ROUTING, not tidiness",
scope_violations(SITE_SIDE + [
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0)]) == [])
check("an unclassified ninth source is a violation wherever it is filed",
scope_violations([Claim.limit("reserve", P_LOOP, hi=0.0)]) != [])
check("and the eight, routed by actuator_for() at a two-inverter site, are "
"clean - which is what says the router and the checker agree",
scope_violations(
[Claim.limit(s, P_SAFETY, -1, 1,
actuator=actuator_for(s, "inverter-a", TWO))
for s in CLAIM_SCOPE]) == [])
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
+264 -3
View File
@@ -115,7 +115,8 @@ from pathlib import Path
from app import peakguard
from app.arbiter import (
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK,
P_SAFETY, SCOPE_SITE, SCOPE_UNIT, Claim, resolve, scope_violations,
)
from app.control import Tuning, compute, peak_at_risk
from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w
@@ -1519,6 +1520,14 @@ check("...and its arbiter reason names the SoC floor rather than reusing "
# forbidden to cap. Swept rather than argued.
SWEEP_NEG = SWEEP_BOTH = SWEEP_N = 0
SWEEP_HI = True
# ⚠️ ARB-03 ADDS ONE DIMENSION TO THIS PROOF RATHER THAN RE-RUNNING IT UNCHANGED.
# `Claim` gained an `actuator`, and the asymmetry that makes this guard safe is
# now TWO facts, not one: `hi` is +inf on every claim it files, AND every claim
# it files is on the SITE actuator. A `lo` that landed on one inverter's interval
# would still have `hi = +inf` and would still never cap a discharge - and would
# leave the SITE, the thing the capacity tariff actually bills, with no floor at
# all. So the same combinations are swept for both.
SWEEP_SITE = True
for load in (0.0, CEIL - 500.0, CEIL, CEIL + 300.0, CEIL + 3000.0,
LIVE_MAX_W * 3):
for elapsed in (30.0, 450.0, 870.0):
@@ -1538,6 +1547,8 @@ for load in (0.0, CEIL - 500.0, CEIL, CEIL + 300.0, CEIL + 3000.0,
_c = peakguard.claim(_d)
if _c is not None and not (isinf(_c.hi) and _c.hi > 0):
SWEEP_HI = False
if _c is not None and _c.actuator != ACTUATOR_SITE:
SWEEP_SITE = False
print(f" swept {SWEEP_N} (load x elapsed x battery x SoC x floor) "
f"combinations, SoC included: {SWEEP_NEG} negative floors, "
f"{SWEEP_BOTH} decisions both floor-limited and saturated")
@@ -1548,6 +1559,12 @@ check(f"NOT ONE of the {SWEEP_N} combinations produces a negative floor. That "
SWEEP_NEG == 0 and SWEEP_N > 600)
check("...and `hi` stays +inf on every claim any of them files",
SWEEP_HI)
check("...and every one of them is filed on the SITE actuator, which is the "
"other half of the same asymmetry now that a claim has an actuator: the "
"billed quantity is the quarter's offtake AT THE GRID CONNECTION, so a "
"floor that bound one inverter's share would leave the site - the thing "
"the capacity tariff meters - with no floor at all",
SWEEP_SITE)
check("...and no combination is both floor-limited and saturated, so the two "
"notifications are exclusive on measured inputs and not merely by reading "
"the code", SWEEP_BOTH == 0)
@@ -2376,9 +2393,17 @@ def cycle_claims(run):
seen = []
real = app_main.resolve
def _spy(claims):
def _spy(claims, **kw):
# ⚠️ `**kw` PASSES THE ROSTER THROUGH UNREAD, AND THAT IS THE WHOLE
# ADAPTATION ARB-03 MADE HERE. main.py now calls
# `resolve(claims, units=self.units)`; a spy with a fixed signature would
# raise a TypeError on every real cycle, which is loud, but a spy that
# DROPPED the roster would be silently grading a different arbitration
# from the one main.py performs. What is observed - `claims`, the list
# itself - is unchanged, so the two halves below still see exactly what
# they saw before, and a ninth source still goes red.
seen.append(list(claims))
return real(claims)
return real(claims, **kw)
app_main.resolve = _spy
try:
@@ -2467,6 +2492,242 @@ check("...and replay_loop() folding main.py's TWO P_SAFETY rating limits into "
and near(LOOP_MAX_W, read_option("max_w")))
# --- 10g. ARB-03: AND THE ACTUATOR EACH OF THEM IS FILED ON -----------------
# ⚠️ THE SAME PIN, ONE DIMENSION WIDER, AND FOR THE SAME REASON. 10f pins WHICH
# sources main.py can file. ARB-03 gave every claim an `actuator`, and a claim
# routed to the wrong stage is a defect 10f cannot see: the source set is
# unchanged when the peak floor is misfiled onto one inverter, and the site is
# then left with no floor at all in the quarter that sets the month's bill.
#
# It is also what keeps SAFETY-25 §0c honest from the other side. That table
# says the deferred loop reserve becomes a PER-INVERTER `hi` at stage A while
# the peak floor stays a SITE `lo` at stage B, and calls the crossing
# structurally impossible. The structure is `CLAIM_SCOPE`, so `CLAIM_SCOPE` is
# pinned to the AST-scanned set rather than trusted to agree with it.
check("every source main.py can file is CLASSIFIED site or per-inverter, and "
"nothing else is. A ninth source now goes red twice - once here for "
"having no classification, once above for being a ninth - and it takes an "
"argument about what the claim is ABOUT to make either line green",
set(CLAIM_SCOPE) == CLAIM_SOURCES)
print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
if v == SCOPE_SITE))
+ " | per-inverter: "
+ ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT)))
check("...and the split is the one ARB-03 argued: the four about the GRID "
"CONNECTION are site claims and the four about a pack, a device or one "
"write are per-inverter",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
== {"loop", "peak", "arbitrage", "safety"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance"})
# The shipped roster is ONE inverter, so §6.2's identity holds and every claim a
# real cycle files is a site claim. Observed on the claims main.py's own resolve
# was handed, not assumed from the routing table.
_real_acts = {(c.source, c.actuator) for c in _cl}
print(f" the real FLOOR-LIMITED cycle files: "
+ ", ".join(f"{s}@{a}" for s, a in sorted(_real_acts)))
check("at the SHIPPED one-inverter roster every claim a real cycle files is on "
"the SITE actuator, per-inverter sources included. That is §6.2's "
"identity - one inverter means the fabricated grid reading and the "
"battery setpoint are the same number, so there is one actuator and it is "
"both - and it is why ARB-03 is a pure refactor here",
all(a == ACTUATOR_SITE for _, a in _real_acts))
check("...and the arbiter agrees the routing is legal, on the list main.py "
"actually handed it", scope_violations(_cl) == [])
# And the day the roster grows, the SAME cycle must partition. Graded through
# main.py's own `actuator_of()` on a real Controller - the method every
# `claims.append` above routes through - rather than through a copy of it.
c_src.units = ("inverter-a", "inverter-b")
_routed = {s: c_src.actuator_of(s) for s in CLAIM_SOURCES}
c_src.units = (app_main.UNIT_A,)
print(" with a second inverter on the roster, main.py routes: "
+ ", ".join(f"{s}->{a}" for s, a in sorted(_routed.items())))
check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
"inverter and its OWN routing method splits the eight: `device`, "
"`supervised`, `failsafe` and `maintenance` bind the unit this controller "
"drives, `loop`, `peak`, `arbitrage` and `safety` stay on the grid "
"connection. Swap any one of them - route `peak` to the unit and the site "
"keeps no floor; route `maintenance` to the site and it can cross that "
"floor into `lo > hi` - and this line goes red",
_routed == {"device": "inverter-a", "supervised": "inverter-a",
"failsafe": "inverter-a", "maintenance": "inverter-a",
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE})
check("...and the roster it was restored to is the shipped one, so nothing "
"below inherits a two-inverter Controller",
c_src.units == (app_main.UNIT_A,) and len(c_src.units) == 1)
# ⚠️ AND THE ROUTING TABLE BEING RIGHT IS NOT THE SAME AS THE CALL SITES USING
# IT RIGHT. `actuator_of("maintenance")` written at the `device` append routes a
# rating limit to whatever `maintenance` routes to, and at the shipped
# one-inverter roster BOTH answers are the site, so nothing above can see it.
# ⚠️ AND THE DYNAMIC CYCLE BELOW SEES ONLY THREE OF THE EIGHT DIRECT APPENDS.
# That was THIS PART committing this repository's own named failure - a check
# observing something other than what it claimed - inside the check written to
# prevent it. The first cut of 10g called the two-inverter cycle "the line a
# mis-typed `actuator_of(...)` at a call site goes red on", full stop. IT IS
# NOT. That cycle is auto, P1-healthy, no maintenance procedure, no tripped sign
# watchdog, the guard idle and arbitrage off, so it reaches `device`,
# `supervised` and `loop` AND NOTHING ELSE. PR #40's reviewer mutated the other
# five appends one at a time and every suite in this repository stayed GREEN,
# this part included. At the maintenance-shaping append,
# `actuator_of("maintenance")` -> `actuator_of("peak")` then produced, at a
# roster of two, a site loop of -1500 W against the guard's site `lo` of 1500 W
# and the now-misrouted charge-only `hi = 0`: `lo > hi`, arbiter clause 3, 0 W
# IN THE BILLED QUARTER - SAFETY-25 §0a's fatal case, built by a one-word typo
# under a green suite. The same mutation at the `failsafe` append survived too.
#
# So THE CALL SITES ARE PINNED STATICALLY, over 10f's own AST scan of
# `Controller.cycle()` and for 10f's reason: a static pin sees every append on
# the line it is written, in every configuration, whether or not any fixture
# here reaches the state that files it. All eight direct appends expose their
# actuator as a LITERAL - `actuator=self.actuator_of('<source>')` - so the
# literal is simply required to EQUAL the source the claim is filed under. That
# is what makes "a mis-typed `actuator_of(...)` goes red" a true sentence about
# all eight rather than about the three a fixture happens to reach.
def append_actuator(node):
"""The actuator expression a DIRECT append routes its claim through.
⚠️ RETURNED AS SOURCE TEXT, NOT EVALUATED, and that is the whole point.
`self.actuator_of('device')` and `self.actuator_of('maintenance')` are the
SAME VALUE at the shipped roster and two different routings; only the text
written at the call site tells them apart, which is exactly why every
dynamic check in this file is blind to the swap at N=1.
"""
kw = [k for k in node.keywords if k.arg == "actuator"]
if len(kw) != 1:
return None
return ast.unparse(kw[0].value)
# The appends that BUILD their claim in place - the eight whose routing is
# chosen by hand, and the only ones where a typo is possible - against the two
# that append a claim built elsewhere in the method and carry no `actuator=`
# here at all.
_direct = [n.args[0] for n in _appends if isinstance(n.args[0], ast.Call)]
_indirect = [n.args[0].id for n in _appends if isinstance(n.args[0], ast.Name)]
_routing = sorted(((c.args[0].value, append_actuator(c), c.lineno)
for c in _direct), key=lambda r: r[2])
_misrouted = [r for r in _routing if r[1] != f"self.actuator_of({r[0]!r})"]
print(f" {len(_direct)} direct appends, each read at its own line: "
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
check("all eight of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
"of EVERY one of them. An append that computed its actuator, or omitted "
"the argument, or hand-wrote a string, fails HERE rather than being "
"silently skipped - which is how a scan like this goes quietly vacuous, "
"and 10f is in this file because that already happened once",
len(_direct) == 8
and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE EIGHT THE LITERAL IT ROUTES THROUGH IS THE "
"SOURCE THE CLAIM IS FILED UNDER. THIS is the line a mis-typed "
"`actuator_of(...)` at a call site goes red on - all eight of them, on "
"the line the typo is written, at every roster, INCLUDING the five "
"appends no fixture in this repository reaches. "
"`actuator_of(\"peak\")` at the maintenance append is a different string "
"here even though it is the same VALUE at the shipped roster of one",
_misrouted == [])
check("...and the only appends this pin does NOT reach are the two that file a "
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
"`actuator=` at the call site to mis-type. Their routing is pinned where "
"it is CHOSEN instead: peakguard.claim() by the SWEEP_SITE sweep above, "
"over every load x elapsed x battery x SoC x floor combination, and "
"arbitrage.claim() by test_arbitrage.py",
sorted(_indirect) == sorted(CLAIM_BUILDERS)
and len(_direct) + len(_indirect) == len(_appends))
check("...and between them the ten appends account for exactly the eight "
"sources 10f pins, so the routing pin and the source set are one scan "
"and neither can drift from the other",
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
== CLAIM_SOURCES)
# THE DYNAMIC HALF, kept, and now stated for what it actually observes: a real
# cycle proving the routing reaches the arbiter THROUGH main.py rather than only
# through this file's reading of main.py. It grades three of the eight appends.
# The static pin above is what covers the other five.
c_src.units = ("inverter-a", "inverter-b")
_n2_writes_before = len(c_src.hass.writes)
try:
_, _cl2 = cycle_claims(lambda: run_cycle(c_src))
_n2_target, _n2_reason = c_src.target, c_src.reason
_n2_events = list(c_src.events)
_n2_writes = c_src.hass.writes[_n2_writes_before:]
_n2_last = c_src.last_write
finally:
c_src.units = (app_main.UNIT_A,)
print(" a REAL two-inverter cycle files: "
+ ", ".join(f"{c.source}@{c.actuator}" for c in _cl2))
check("a real cycle at a roster of TWO partitions its own claims correctly - "
"every claim main.py filed is on an actuator its source's scope permits, "
"observed on the list main.py's own `resolve` was handed. ⚠️ AND THIS "
"SEES THREE OF THE EIGHT APPENDS, NOT THE CLASS: this state files "
"`device`, `supervised` and `loop`, so it is the static pin above and not "
"this line that covers `failsafe`, `safety` and the two `maintenance` "
"appends", len(_cl2) >= 3 and scope_violations(_cl2) == [])
check("...and the partition is the expected one on the sources this state "
"reaches: the two rating limits bind the unit, the loop claim stays on "
"the grid connection",
{c.source: c.actuator for c in _cl2}
== {"device": "inverter-a", "supervised": "inverter-a",
"loop": ACTUATOR_SITE})
check("...and naming those three is not an accident of this fixture but the "
"reason the static pin exists - five of the eight sources need a state "
"no check in this file reaches",
{c.source for c in _cl2}
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
"arbitrage"})
# --- 10h. ARB-03: AND WHAT MAIN.PY DOES WITH `allocated=False` --------------
# ⚠️ AN UNREACHABLE BRANCH THAT NOTHING GRADED. The roster ships as one unit, so
# `allocate()` never declines in production and no fixture went looking: PR
# #40's reviewer replaced main.py's `if not resolution.allocated:` with
# `if False:` and the whole 17-file suite stayed GREEN. The branch is the right
# instinct - the alternative default is the fault ARCH-01 was filed about, N
# inverters each supplying the site target IN FULL, a permanent limit cycle at
# the meter with every §11.2 watchdog reporting healthy - and an ungraded right
# instinct is one refactor away from being deleted as dead code. The
# two-inverter cycle above IS that state, so it is graded on that cycle rather
# than with a fixture of its own.
#
# ⚠️ AND WHAT IT DOES IS SUBSTITUTE, NOT ABSTAIN. main.py does not skip the
# write. It replaces the resolution with a 0 W `failsafe` and then calls
# `write_setpoint(0.0)` like any other cycle. 0 W is the correct
# fail-toward-inaction direction and the log line is accurate about what it
# refuses ("refusing to broadcast the site target"), but "main.py refuses to
# write" would be the same overclaim this part just took out of 10g, so it is
# graded as what it is: a substitution followed by a write.
print(f" at N=2 main.py resolved to {_n2_target:+.0f} W, reason "
f"{_n2_reason!r}, wrote {_n2_writes}")
print(" and logged: "
+ next((e for e in _n2_events if "ARBITER" in e), "(nothing)"))
check("with two inverters and no allocator main.py does NOT hand the site "
"target to the roster. The resolution it acts on is the 0 W failsafe it "
"substitutes, and `explain()` says so - this is the branch a `if False:` "
"deleted with the suite still green",
_n2_target == 0.0 and "failsafe" in _n2_reason)
check("...and it says WHY, in the action log, in the operator's own words - "
"the refusal is to BROADCAST, and it names the roster size that caused it",
any("refusing to broadcast the site target" in e and "2 inverters" in e
for e in _n2_events))
check("...and then it WRITES that 0 W, on the same line as every other cycle. "
"It is a substitution, not an abstention: the setpoint is commanded to "
"zero rather than left wherever the last cycle put it, which is the "
"fail-toward-inaction direction and the opposite of leaving a stale "
"command standing", _n2_writes == [0.0] and _n2_last == 0.0)
check("...and the 0 W is NOT what the site target happened to be - resolve the "
"same claims at a roster of ONE, where the seam passes through, and a "
"real number comes back. That is what says the branch does work rather "
"than agreeing with the resolution by luck",
resolve(_cl2, units=("inverter-a",)).allocated is True
and resolve(_cl2, units=("inverter-a",)).target_w != 0.0)
# ==============================================================================
print()
print("11. mutation: what defeats these guards, and which check bites")