checks / unit (pull_request) Successful in 4m19s
specs §1.5 makes "any configuration in which two writers can reach one
actuator" a non-goal. That non-goal was enforced by counting config keys:
validate.py's ACTUATOR_WRITE_KEYS = ("setpoint_entity",), consumed at rule 1,
with a comment stating the guarantee outright - "there is exactly one such
key, so a second writer is not expressible in the options schema at all".
That was true and it graded THE SHAPE OF THE OPTIONS SCHEMA, not the writes.
Add CFG-04's per-inverter config - a roster of inverters each with its own
setpoint_entity - and there are two real writers with len() of that tuple
still equal to 1. The check stays green while the property it protects is
gone. sim/inject.py's two_writers scenario asserted on the same tuple, so it
went green too. That is this repository's named failure mode: a check that
observes something other than what it claims to observe.
Demonstrated rather than argued. test_validate.py part 2 now constructs the
hazardous per-inverter config, asserts that the OLD proxy is still green on
it, and then asserts the new check refuses it. Against release/1.0's
validator the updated sim scenario reports: "validate_startup_config()
ACCEPTED a configuration in which 3 declarations reach the ONE actuator
number.goodwe_setpoint ... the old proxy reports 1 write key(s) on this same
config and is therefore GREEN on it".
THE WRITE PATH IS NOW GATED ON THE RECORD
=========================================
Controller.write_refusal() asks Allocation.authorises() whether the single
allocator placed exactly this number, for this unit, on this tick.
write_setpoint() refuses anything else. A second writer with arithmetic of
its own cannot satisfy that - its number appears in no record - so it has to
file a Claim and go through resolve(), which is the one allocator. "Exactly
one allocator per shared resource" stops being a convention somebody can
forget and becomes something the write path checks.
The refusal is a SUBSTITUTION, not a return: 0 W goes on the wire in place of
the number nothing stands behind. Returning would leave the inverter latched
on its last command with the ESP32 heartbeat still being fed - holding an
unauthorised setpoint quietly, which is the outcome the gate exists to stop.
AC 1 is "refused at the write path, not merely logged", and test_clamp.py
part 7a grades the device's writes, not the log.
The gate sits BELOW the non-finite coercion and ABOVE the clamp. Below,
because a nan is loss of input and rule 1 has already turned it into 0 W.
Above, because the question is who chose this number, and the answer is only
meaningful about the number that was asked for - gating the CLAMPED value
would refuse exactly the cycles SAFETY-03 exists for, since a clamped write
differs from the allocator's share by construction. Mutation M7 moves the
gate below the clamp and turns nine checks red, part 6c among them.
A STALE RECORD CANNOT BE REPLAYED
=================================
Every Allocation now carries the `tick` it was made for. It is handed IN by
the caller - plan(..., tick=) - rather than counted inside the allocator,
because test_alloc.py part 10 grades that the module holds no state at all,
and a serial number incremented in there would be the cached-across-ticks
thing the envelope rule forbids wearing a different hat.
Controller.alloc_tick advances ONCE PER RESOLUTION, not once per loop
iteration, and both halves are load-bearing:
* a new allocation makes the previous record stale immediately, so a record
whose envelopes have moved cannot authorise a write. That is the replay
half (AC 2), graded by capturing a real record, running another real
cycle, putting the old one back and observing refusal.
* the "inputs missing, not yet stale" hold returns BEFORE the allocator
runs, exactly so one dropped meter reading does not zero the inverter,
and the heartbeat re-affirms self.target through this same gate while it
does. A tick incremented per loop iteration would refuse that heartbeat
and turn the hold into the outage it exists to prevent. Graded.
An UNSTAMPED record (tick None) authorises nothing: "I cannot say when I was
made" fails closed rather than reading as tick zero.
THE FAILSAFE CANNOT DEADLOCK BEHIND THE GATE, AND HERE IS WHY
=============================================================
write_setpoint() is also how layer 2 commands 0 W. cycle()'s two refusal
branches substitute a 0 W failsafe and write it like any other cycle;
shutdown() and the operator's stop button do the same. EVERY ONE OF THOSE
RUNS WHILE THE ALLOCATOR HAS REFUSED - ok is False and there are no shares -
so a gate that asked the allocator's permission to command zero would block
the safety write behind the very component whose failure the safety write
answers. A gate that can do that is worse than the hole it closes.
Resolved by authorising 0 W unconditionally, and on the VALUE rather than on
a failsafe=True flag: a flag is an assertion by the caller, which is exactly
what a second writer would pass; the value is a fact about what will be
written. The argument that this is safe and not merely convenient is that the
hazard §1.5 names is N-times loop gain from BROADCAST - handing every inverter
the same target so each supplies it in full (allocator.py's opening argument;
TEST-19 measured a permanent 4000 W p2p limit cycle at gain 1.0 with no guard
firing). At S = 0 the broadcast and the correct allocation COINCIDE: a_i = 1
for every unit gives every unit 0 W, which is exactly its share. There is no
over-command to make at zero. Said at the line, in write_refusal().
test_clamp.py part 7c drives it end to end through the real cycle() and the
real REFUSE_EMPTY branch: the allocator refuses every tick, the device still
sees 0 W, and the action log says ARBITER: REFUSED rather than WRITE REFUSED
so the two failures stay distinguishable to whoever has to debug them.
unmet_w IS STILL NOT ok
=======================
The gate reads ok and the unit's share and never unmet_w. allocator.py:179
says why: an allocation that could not place every watt is still safe to
write, and fusing them would let one saturated inverter park the whole site.
Graded on a record with 3800 W unmet and every share legal - mutation M5 adds
`or self.unmet_w` to the gate and that check goes red.
THE CONFIG CHECK, REPLACED RATHER THAN DELETED
==============================================
actuator_writers() counts WRITERS PER ACTUATOR instead of option keys. It
walks the options TREE rather than looking for a container called `inverters`,
because CFG-04 has not chosen that name yet and a census that guessed wrong
would go green while two writers existed - the same defect one step along.
Entity ids are folded to lower case so a second writer cannot be smuggled in
as Number.X versus number.x.
Two inverters with two entities are ACCEPTED - that is what the per-inverter
roster is for, and a validator that refuses the feature it guards is one
somebody switches off. Two declarations aimed at ONE entity are refused,
naming the actuator and every writer pointed at it. That includes the case an
author will meet first - keeping setpoint_entity as A's and listing A again -
because "they happen to be the same entity, so it is fine" is precisely the
reasoning that makes two writers invisible.
ACTUATOR_WRITE_KEYS survives ONLY as the enumeration a census needs, with its
guarantee comment deleted; nothing asserts on its length any more. Keeping the
name is also what stops admin/goodwe's current two_writers scenario failing on
an AttributeError between the two merges.
WHAT NEITHER CHECK OBSERVES, SAID OUT LOUD
==========================================
The census sees CONFIGURED writers. A second write path added in CODE - a
hass.set_number() call next to some new strategy - declares nothing and is
invisible to it, exactly as it was invisible to the tuple. That half is not
closed at startup and cannot be; it is closed at runtime by the record gate,
which does not care what the options look like or who called the method. The
two checks are deliberately different kinds, and only the runtime one survives
a schema this file has not seen yet.
EVIDENCE
========
* Full 18-file suite green on a purged tree (tools/mutation_run.sh).
* Verified by breaking it - seven mutations, each on a purged tree, each
reverted and re-verified:
M1 remove the gate -> test_clamp red (4 checks)
M2 make the zero exit an amnesty -> test_clamp red (12 checks)
M3 drop the tick comparison -> test_clamp red (5 checks)
M4 delete the census refusal -> test_validate red (3 checks)
M5 fuse unmet_w into ok -> test_clamp red (1 check)
M6 freeze the tick on the live path -> test_clamp red (2 checks)
M7 move the gate below the clamp -> test_clamp red (9 checks)
* AC 5: no existing check was weakened. The two edits to test_clamp.py are
fixtures, not assertions - parts 5 and 6c call write_setpoint() directly
with a number nothing allocated, which is now refused, so they say where
their number came from. Every assertion in both parts is unchanged, and
part 7f asserts that across part 3's sixty real cycles the gate refused
zero writes.
* admin/goodwe's TEST-19 norig scenarios (planner_binding, two_master_plant,
two_master_broadcast) still pass against this tree - they import the
add-on's control law across the repo boundary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
556 lines
29 KiB
Python
556 lines
29 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,
|
|
|
|
# --- 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:
|
|
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. `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
|
|
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
|