At N>=2 the holding pack's pin follows the guard's floor while the loop still asks for +5000 W, so the ALLOCATOR "could not be placed" row was re-logged on every 1 W change and filled the 40-row action log. While the pin is raised, cycle() now files a site `hold` ceiling at the floor (P_MAINTENANCE), as SAFETY-84 does for a charge. Filed only when the holding pack gives; hi == the floor, so it never clashes with it. Tests: a noisy N=2 house logs no ALLOCATOR row and at most one clamp row (20 and 20 without the ceiling); the ceiling is the floor itself; a CHARGE is not refiled at N=1. The DECISIONS entry records the owner's 2026-10-01 decision: the HOLD ends on its clock, no top-up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
857 lines
46 KiB
Python
857 lines
46 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
|
|
# The order in effect: safety limits and the SAFETY-73 refusal > the peak
|
|
# guard > maintenance > modes > trade > loop. A peak floor above a drain still
|
|
# binds: the `drain` site floor at N>=2 (SAFETY-78), refiled at P_MAINTENANCE
|
|
# at N=1 (SAFETY-83); over a HOLD, `_peak_over_hold()` (SAFETY-86).
|
|
# A cheap-window charge is suspended while the peak guard holds and, at N>=2,
|
|
# sized to the quarter's headroom (SAFETY-84).
|
|
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
|
|
# OPT-11. A battery's mode (Solar first, Leftover solar, Off): a SOFT unit
|
|
# limit, kept apart from the hard ones. Above arbitrage, so a trade stays inside
|
|
# each battery's mode; below the peak guard, to which a mode gives way only as
|
|
# far as the floor needs (`_unit_modes()`, `_site_mode()`).
|
|
# DECISIONS/OPT-11-battery-modes.md.
|
|
P_MODE = 35
|
|
P_LOOP = 10 # ordinary grid-following
|
|
|
|
# The one source filed at P_MODE. pre_resolve() keeps it out of the hard limits.
|
|
MODE_SOURCE = "mode"
|
|
|
|
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,
|
|
# main.py: `Claim.limit("drain", P_MAINTENANCE, lo=...)`, SAFETY-78. At
|
|
# N>=2, while a pack's maintenance drain is pinned, the site target may
|
|
# not go below what that pin places: below it a sibling would have to
|
|
# charge against the drain (circulation, refused, 0 W). A site `lo`,
|
|
# never filed at N=1. DECISIONS/SAFETY-78-drain-at-two.md.
|
|
"drain": SCOPE_SITE,
|
|
# main.py: `Claim.limit("charge", P_MAINT_SHAPE, hi=...)`, SAFETY-84. The
|
|
# mirror of `drain` for a cheap-window maintenance charge at N>=2: the site
|
|
# target may not go above what the charging pack's own `hi` asks, or a
|
|
# sibling would have to give against it. Never filed at N=1.
|
|
# DECISIONS/SAFETY-84-charge-at-two.md.
|
|
"charge": SCOPE_SITE,
|
|
# main.py: `Claim.limit("hold", P_MAINTENANCE, hi=...)`, SAFETY-86. While a
|
|
# holding pack gives the peak shortfall at N>=2, the site target stays at
|
|
# the peak floor, what the pin and the others place. Never filed at N=1.
|
|
"hold": 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,
|
|
# main.py: `Claim.limit("mode", P_MODE, lo, hi, ...)`, OPT-11. THIS
|
|
# battery's mode, chosen per battery by its owner. A unit claim that never
|
|
# enters a `lo > hi` test with a site limit: stage 1 keeps it apart from the
|
|
# hard limits, and it gives way to a site limit above P_MODE only as far as
|
|
# that limit needs (`_unit_modes()`). At N=1 it routes to the site, and
|
|
# `resolve()` takes it out of stage 2 for the same reason.
|
|
"mode": 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
|
|
# OPT-11: the mode layer, APART from `lo`/`hi` above, which stay the hard
|
|
# limits. `mode_priority` is None when no mode claim applies. Whether the
|
|
# mode binds is decided at stage 3 (`allocator.unit_interval()`), because it
|
|
# depends on the priority that decided the SITE target.
|
|
mode_lo: float = -inf
|
|
mode_hi: float = inf
|
|
mode_priority: int | None = None
|
|
|
|
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
|
|
# OPT-11. The higher of the winning set's priority and the priority of the
|
|
# limit that bound `target_w`. Whether the modes hold is decided from the
|
|
# limits themselves (`_site_mode()`, `_unit_modes()`): a mode gives way
|
|
# only as far as a limit above it (the peak floor) needs, so a mode `hi`
|
|
# never meets a site `lo`.
|
|
deciding_priority: int | None = None
|
|
# OPT-11 at N=1: why the site's mode layer was dropped this tick, or None.
|
|
# At N>1 the same fact is on `allocation.mode_faults`, per unit.
|
|
mode_fault: str | 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 and c.source != MODE_SOURCE),
|
|
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
|
|
# OPT-11: a mode claim never joins the hard limits, so a mode cannot make a
|
|
# unit contradict itself or narrow a pin. A mode SET is not a thing: only
|
|
# limits are read. The lowest mode priority is kept (the first to release).
|
|
mode_lo, mode_hi, mode_priority = -inf, inf, None
|
|
for c in unit_claims:
|
|
if c.kind != LIMIT or (gate is not None and c.priority < gate):
|
|
continue
|
|
if c.source == MODE_SOURCE:
|
|
mode_lo, mode_hi = max(mode_lo, c.lo), min(mode_hi, c.hi)
|
|
mode_priority = (c.priority if mode_priority is None
|
|
else min(mode_priority, c.priority))
|
|
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, mode_lo=mode_lo, mode_hi=mode_hi,
|
|
mode_priority=mode_priority)
|
|
|
|
|
|
def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
|
|
scope_errors=(), site_priority=None,
|
|
deciding_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,
|
|
deciding_priority=deciding_priority)[:2]
|
|
|
|
|
|
def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
|
|
scope_errors=(), site_priority=None, tick=None,
|
|
deciding_priority=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, deciding_priority=deciding_priority)
|
|
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:
|
|
# OPT-11: 0 W here, and every limit that took part counts as deciding.
|
|
return Resolution(0.0, winner.source, bound_by=None,
|
|
contradiction=True, considered=considered,
|
|
winner_priority=winner.priority,
|
|
deciding_priority=max(winner.priority,
|
|
lo_src.priority,
|
|
hi_src.priority))
|
|
|
|
target = max(lo, min(hi, winner.value))
|
|
bound_by = None
|
|
deciding = winner.priority
|
|
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
|
|
deciding = max(deciding, binder.priority)
|
|
|
|
return Resolution(float(target), winner.source, bound_by, False, considered,
|
|
winner_priority=winner.priority,
|
|
deciding_priority=deciding)
|
|
|
|
|
|
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]
|
|
# OPT-11: a mode claim on the site (N=1, where every source routes there)
|
|
# never enters stage 2's `lo > hi` test. It is applied after it, below, by
|
|
# the same release rule stage 3 uses.
|
|
site_modes = [c for c in site_claims if c.source == MODE_SOURCE]
|
|
if site_modes:
|
|
site_claims = [c for c in site_claims if c.source != MODE_SOURCE]
|
|
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)
|
|
if site_modes:
|
|
_site_mode(out, site_modes, 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).
|
|
# OPT-11: whether each unit's mode holds this tick (`_unit_modes()`).
|
|
# Before the weights, because a limit above the modes may move the target.
|
|
gate, held = _unit_modes(out, site_claims, bounds, live)
|
|
if callable(weights):
|
|
weights = weights(out.target_w)
|
|
if held:
|
|
weights = {u: (0.0 if u in held else
|
|
(1.0 if weights is None else weights.get(u, 1.0)))
|
|
for u in 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, deciding_priority=gate)
|
|
return out
|
|
|
|
|
|
def _limits_above(site_claims: list, prio: int) -> tuple:
|
|
"""OPT-11: `(lo, lo_src, hi, hi_src)` over the site LIMITs above `prio`.
|
|
|
|
These are what a mode may never take the site target outside of: the peak
|
|
guard (P_PEAK), maintenance shaping (P_MAINT_SHAPE) and the hard limits.
|
|
"""
|
|
lo, hi = -inf, inf
|
|
lo_src = hi_src = None
|
|
for c in site_claims:
|
|
if c.kind != LIMIT or c.priority <= prio:
|
|
continue
|
|
if c.lo > lo:
|
|
lo, lo_src = c.lo, c
|
|
if c.hi < hi:
|
|
hi, hi_src = c.hi, c
|
|
return lo, lo_src, hi, hi_src
|
|
|
|
|
|
def _yield_to(out: Resolution, t1: float, above: tuple):
|
|
"""OPT-11: the target `t1` the modes allow, moved only as far as the limits
|
|
above the mode need (`_limits_above()`). Returns the claim that moved it,
|
|
or None when `t1` already satisfies them, and sets `out.target_w`."""
|
|
lo, lo_src, hi, hi_src = above
|
|
t2 = max(lo, min(hi, t1))
|
|
out.target_w = float(t2)
|
|
if t2 == t1:
|
|
return None
|
|
binder = lo_src if t2 == lo else hi_src
|
|
out.bound_by = (f"{binder.source}({binder.reason})" if binder.reason
|
|
else binder.source)
|
|
out.deciding_priority = max(out.deciding_priority or binder.priority,
|
|
binder.priority)
|
|
return binder
|
|
|
|
|
|
def _site_mode(out: Resolution, modes: list, site_claims: list) -> None:
|
|
"""OPT-11 at N=1: the one battery's mode bounds the SITE target, after stage 2.
|
|
|
|
A SET above the mode (a safety stop) decides alone. Otherwise the target is
|
|
clamped into the mode range and then back into the site limits above the
|
|
mode, so the mode gives way only as far as those limits need: a peak floor
|
|
of 2000 W over an Off battery asks it for 2000 W, whether a trade or the
|
|
loop won stage 2, and a charge the peak guard merely caps stays at 0 W. A
|
|
range without 0 W is a software fault that drops the mode
|
|
(`out.mode_fault`). Clamping one number cannot contradict anything.
|
|
"""
|
|
out.considered = out.considered + [f"{c.source}:{c.kind}" for c in modes]
|
|
lims = [c for c in modes if c.kind == LIMIT]
|
|
if not lims or out.contradiction:
|
|
return
|
|
lo = max(c.lo for c in lims)
|
|
hi = min(c.hi for c in lims)
|
|
prio = min(c.priority for c in lims)
|
|
if not (lo <= 0.0 <= hi):
|
|
out.mode_fault = f"mode range [{lo:.0f}, {hi:.0f}] leaves out 0 W"
|
|
return
|
|
if out.winner_priority is not None and out.winner_priority > prio:
|
|
return
|
|
before = out.target_w
|
|
t1 = max(lo, min(hi, before))
|
|
if _yield_to(out, t1, _limits_above(site_claims, prio)) is None \
|
|
and t1 != before:
|
|
c = lims[0]
|
|
out.bound_by = f"{c.source}({c.reason})" if c.reason else c.source
|
|
|
|
|
|
def _unit_modes(out: Resolution, site_claims: list, bounds: dict, live):
|
|
"""OPT-11 at N>1: `(gate, held)` for stage 3.
|
|
|
|
`gate` is the `deciding_priority` handed to the allocator: None applies
|
|
every unit's mode, a number above P_MODE releases them. `held` is the set
|
|
of units whose mode holds them back in the target's direction; while the
|
|
modes are released they weigh 0, so they carry only what the other units
|
|
cannot (the allocator's last-resort pass).
|
|
|
|
The rule matches N=1: a SET above the mode decides alone. Otherwise the
|
|
target is clamped into what the units can place with their modes
|
|
(`allocator.loop_bound()`) and back into the site limits above the mode.
|
|
If that point is still placeable with the modes, they hold and the target
|
|
is unchanged (what they cannot place is reported unmet, as before). If a
|
|
limit above the mode needs more, the target moves only that far, and the
|
|
modes are released for it.
|
|
"""
|
|
mode_units = [u for u, b in bounds.items()
|
|
if getattr(b, "mode_priority", None) is not None]
|
|
if not mode_units or out.contradiction:
|
|
return out.deciding_priority, set()
|
|
prio = min(bounds[u].mode_priority for u in mode_units)
|
|
if out.winner_priority is not None and out.winner_priority > prio:
|
|
return out.deciding_priority, set()
|
|
clo, chi = allocator.loop_bound(bounds, live)
|
|
t1 = max(clo, min(chi, out.target_w))
|
|
before = out.target_w
|
|
binder = _yield_to(out, t1, _limits_above(site_claims, prio))
|
|
if binder is None:
|
|
out.target_w = float(before)
|
|
return None, set()
|
|
held = set()
|
|
for u in mode_units:
|
|
b = bounds[u]
|
|
nlo, nhi, note = allocator.unit_interval(b)
|
|
if note is not None:
|
|
continue
|
|
if (nhi < b.hi) if out.target_w > 0 else (nlo > b.lo):
|
|
held.add(u)
|
|
return binder.priority, held
|