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