ARB-03: restore Claim.actuator and make resolve() three-stage #40
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user