ALLOC-01: allocate the site grid target, sum-invariant, never broadcast
checks / unit (pull_request) Successful in 4m6s
checks / unit (pull_request) Successful in 4m6s
ARB-03 left `allocate()` as a documented seam that passed through at N<=1 and
DECLINED at N>=2, with the reasoning recorded at the line: the obvious
placeholder - hand the site target to each inverter - IS the ARCH-01 fault, and
"a placeholder that cannot be told apart from the real thing by any guard we
have is worse than no placeholder". This fills the seam.
WHY A BROADCAST IS A FAULT AND NOT AN INEFFICIENCY. The control primitive is
meter emulation: the master reports a FABRICATED grid reading and the inverter
regulates itself to it, so one number means "the battery moves X W" to this
add-on and "the grid is at X W" to the inverter - an identity that holds only at
N=1. Hand two inverters the same target and each supplies it IN FULL, so the
loop gain at the meter becomes N times the per-unit gain. Reproduced on
admin/goodwe's two-master rig against this tree: -400 W export overshoot at
gain 0.6, and at gain 1.0 - which config.yaml accepts as its documented maximum
- a permanent 4000 W peak-to-peak limit cycle, commands alternating
1000/2000/1000/0 W, with NOT ONE GUARD FIRING. The sign watchdog is evaluated on
every cycle and never trips, because the disagreement flips on alternate ticks
and never holds its 180 s window.
WHAT WAS BUILT. `app/allocator.py`, host-side and strictly above the driver
seam. It consumes intervals and a number and nothing else - no claims, no
priorities, and no measurement, because an allocator that read P1 would be a
SECOND integrator on the site measurement.
* ENVELOPE CLAMP, from ARB-03 stage 1's `UnitBounds`, read PER TICK. `plan()`
is a pure function and the module holds no state, so there is nowhere for a
construction-time envelope to be cached.
* WATER-FILL by weight, with the surplus of a full unit moving to the others.
Weights express PREFERENCE, envelopes express PERMISSION: a zero weight is
honoured while another unit has headroom and overridden when it does not,
because the SUM is the safety property and stranding capacity behind a
comfort choice manufactures a standing error at the meter.
* LARGEST-REMAINDER ROUNDING. The target is rounded ONCE, at the top; every
sum below that line is integer arithmetic, so it cannot drift. Measured: a
per-unit round() drifts -125 W over 50 000 ticks at three inverters.
* THE ALLOCATION RECORD, with `unmet_w`, `dropped` and a per-unit reason.
`ok` is one boolean for SAFETY-28 to gate on, and `unmet_w` is deliberately
NOT fused into it - a saturated inverter must not park the whole site.
THE STABILITY PROPERTY IS STRUCTURAL. With sum(a_i) = 1 the gain at the meter is
sum(a_i * g_i), a convex combination of the per-unit gains, so it lies in
[min g_i, max g_i] FOR ANY SPLIT. That is what makes the weights a comfort
choice rather than a tuning parameter, and it is why a bad split cannot
destabilise the site. Graded directly, and measured end to end: 90/10, 50/50 and
100/0 give a bit-identical aggregate trace at the meter, and a gain sweep to 1.0
at every split has a flat tail at all 84 combinations.
THE TWO REFUSALS main.py NOW MAKES, AND WHY THERE ARE TWO. `allocated=False` no
longer means "no allocator"; it means no safe split EXISTS - a mis-routed claim,
an empty envelope set, or a split that could only be made by commanding one pack
to charge while another discharges. Separately, and this guard is the price of
making allocate() succeed rather than something inherited, main.py refuses to
act on a valid split it cannot DELIVER: there is one `setpoint_entity`, so
writing the site target to the one inverter it can reach would over-command that
inverter by exactly the other's share. Commanding a partial site is a worse
instruction than commanding nothing. DEV-02 gives it the second writer.
scope_violations() IS NOW CONSUMED, AND AS A REFUSAL. ARB-03 left it logging
without changing the resolution. A mis-routed claim 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 - the same answer clause 3 already gives a contradiction.
It is NOT promoted into an envelope: a unit-scoped claim that landed on the site
names no unit, so promoting it would mean guessing one, or applying one
inverter's rating to a sibling that does not have it.
AT N<=1 IT IS A PASS-THROUGH, BYTE-IDENTICAL: the share is the site target, as a
float, unrounded and unclamped. All 17 existing suites replay unedited.
test_peakguard.py part 10h and test_arbiter.py part 7 were RE-AIMED, not
deleted. PR #40's reviewer proved that refusal branch was unreachable and
untested once; removing its trigger without giving it a new one would have
rebuilt the hole. Part 10h now drives three real cycles - a deliverable split, a
dead sibling, and every master stale - and grades all three.
KNOWN AND NOT CLOSED HERE: at a roster of two the loop's `actual_w` is one
inverter's power used as a site measurement, because the add-on binds a single
`inverter_power` sensor. That is the SENSING half of the same N=1 identity this
card removes from the COMMANDING half, and it makes the aggregate trace
split-dependent below gain 1.0. Measured with its counterfactual in test_alloc.py
part 8 and tools/alloc_sim.py --splits. It belongs to DEV-02.
Verified: all 18 suites green in the CI image. 16 mutations run through
tools/mutation_run.sh on a purged tree; 13 red, and the 3 survivors are recorded
in test_alloc.py part 12 with the reason each cannot be caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
df88b323f5
commit
186e13ceca
@@ -2,6 +2,81 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
**The site grid target is now ALLOCATED across N inverters, sum-invariant, and
|
||||
never broadcast (ALLOC-01).** `ARB-03` left `allocate()` as a documented seam
|
||||
that passed through at N<=1 and DECLINED at N>=2; this fills it. The new
|
||||
`app/allocator.py` turns one site target `S` into per-inverter shares with one
|
||||
invariant - **`sum(shares) + unmet_w == S`, in integer arithmetic** - and each
|
||||
inverter is then told a function of **its own share**, never of the shared
|
||||
measurement.
|
||||
|
||||
* **Why a broadcast is a fault and not merely an inefficiency.** The control
|
||||
primitive is meter emulation: the master reports a *fabricated grid reading*
|
||||
and the inverter regulates itself to it, so one number means "the battery
|
||||
moves X W" here and "the grid is at X W" there - an identity that holds only
|
||||
at N=1. Hand both inverters the same target and each supplies it in full, so
|
||||
the loop gain at the meter becomes N times the per-unit gain. Measured on
|
||||
`admin/goodwe`'s two-master rig (TEST-19): **-400 W export overshoot at
|
||||
`gain: 0.6`, and at `gain: 1.0` - which `config.yaml` accepts as its
|
||||
documented maximum - a permanent 4000 W peak-to-peak limit cycle with not one
|
||||
guard firing.** The sign watchdog is evaluated on every cycle and never trips,
|
||||
because the disagreement flips on alternate ticks and never holds its 180 s
|
||||
window.
|
||||
* **The stability property is structural, not tuned.** With `sum(a_i) = 1` the
|
||||
gain at the meter is `sum(a_i * g_i)`, a **convex combination** of the
|
||||
per-unit gains, so it lies in `[min g_i, max g_i]` **for any split**. That is
|
||||
what makes the weights a comfort choice (SoC, thermals, wear) rather than a
|
||||
tuning parameter, and it means a bad split cannot destabilise the site.
|
||||
Graded directly in `test_alloc.py` part 9, and measured end-to-end: a 90/10,
|
||||
a 50/50 and a 100/0 split produce a **bit-identical aggregate trace at the
|
||||
meter**, and a gain sweep to 1.0 at every split shows a flat tail at all 84
|
||||
combinations.
|
||||
* **Rounding is a safety property here.** The register is 1 W, and a per-unit
|
||||
`round()` biases the sum - measured at **-125 W over 50 000 ticks at three
|
||||
inverters**. At 1-2 Hz that is a permanent standing error the site integrator
|
||||
chases and never reaches. So the target is rounded **once**, at the top, and
|
||||
the fractional parts are settled by **largest remainder** with a deterministic
|
||||
tie-break; every sum below that line is integer arithmetic and cannot drift.
|
||||
Verified over 250 000 randomised ticks: **0 sum failures, 0 envelope escapes,
|
||||
0 W accumulated bias.**
|
||||
* **The envelope is read per tick, never cached.** `plan()` is a pure function
|
||||
of that tick's intervals and holds no state, because 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.
|
||||
* **A dead link is announced, not absorbed.** A unit whose master is stale is
|
||||
dropped from the envelope set; the survivors carry its share, and the
|
||||
allocation record names the unit and the reason so `main.py` logs it. A site
|
||||
running at half its inverters and reporting nothing reads as healthy on every
|
||||
dashboard.
|
||||
* **Opposite directions are refused, not averaged.** One pack discharging while
|
||||
another charges nets to exactly the right number at the meter, so no existing
|
||||
guard can see it. A sign-coherence gate makes it inexpressible.
|
||||
* **`scope_violations()` is now consumed, and it is consumed as a refusal.**
|
||||
ARB-03 left it logging without changing the resolution. A mis-routed claim
|
||||
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 - the same answer
|
||||
clause 3 already gives a contradiction. It is *not* promoted into an envelope:
|
||||
a unit-scoped claim that landed on the site names no unit, so promoting it
|
||||
would mean guessing one.
|
||||
* **At N<=1 it is a pass-through, byte-identical.** The share is the site
|
||||
target, as a float, unrounded and unclamped. Every one of the existing suites
|
||||
replays unedited and green.
|
||||
* **`main.py` still refuses to act at N>=2, for a new and narrower reason.**
|
||||
There is one `setpoint_entity`, so this controller can command one inverter; a
|
||||
valid two-way split names an inverter it has no writer for, and commanding a
|
||||
partial site is a worse instruction than commanding nothing. The old
|
||||
"no allocator" refusal and its test (`test_peakguard.py` part 10h) were
|
||||
**re-aimed, not deleted** - `allocated=False` is now reachable on a mis-routed
|
||||
claim, an empty envelope set, or sign incoherence, and all three are graded on
|
||||
real cycles.
|
||||
* **Known and not closed here:** at a roster of two the loop's `actual_w` is one
|
||||
inverter's power used as a site measurement, because the add-on binds a single
|
||||
`inverter_power` sensor. That is the *sensing* half of the same N=1 identity
|
||||
this card removes from the *commanding* half, and it makes the aggregate trace
|
||||
split-dependent below `gain: 1.0`. Measured, with the counterfactual, in
|
||||
`test_alloc.py` part 8 and `tools/alloc_sim.py --splits`. It belongs to
|
||||
`DEV-02`.
|
||||
|
||||
**`Claim` carries the `actuator` specs.md always said it did, and `resolve()`
|
||||
is three-stage (ARB-03).** `specs.md` §6.1 declares `actuator: str` as the first
|
||||
field of `Claim`; the shipped `app/arbiter.py` had no such field, so every claim
|
||||
|
||||
Reference in New Issue
Block a user