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
@@ -114,6 +114,12 @@ from math import inf, isinf
|
||||
from pathlib import Path
|
||||
|
||||
from app import peakguard
|
||||
# ⚠️ THE DROP AND REFUSAL REASONS ARE IMPORTED, NOT TRANSCRIBED. A check that
|
||||
# matched the English would stay green against an allocator that renamed the
|
||||
# case out from under it, which is this repository's own named failure mode.
|
||||
from app.allocator import (
|
||||
DROP_STALE as ALLOC_DROP_STALE, REFUSE_EMPTY as ALLOC_REFUSE_EMPTY,
|
||||
)
|
||||
from app.arbiter import (
|
||||
ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK,
|
||||
P_SAFETY, SCOPE_SITE, SCOPE_UNIT, Claim, resolve, scope_violations,
|
||||
@@ -2658,6 +2664,7 @@ try:
|
||||
_n2_events = list(c_src.events)
|
||||
_n2_writes = c_src.hass.writes[_n2_writes_before:]
|
||||
_n2_last = c_src.last_write
|
||||
_n2_alloc = c_src.allocation
|
||||
finally:
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
print(" a REAL two-inverter cycle files: "
|
||||
@@ -2683,37 +2690,64 @@ check("...and naming those three is not an accident of this fixture but the "
|
||||
"arbitrage"})
|
||||
|
||||
|
||||
# --- 10h. ARB-03: AND WHAT MAIN.PY DOES WITH `allocated=False` --------------
|
||||
# ⚠️ AN UNREACHABLE BRANCH THAT NOTHING GRADED. The roster ships as one unit, so
|
||||
# `allocate()` never declines in production and no fixture went looking: PR
|
||||
# #40's reviewer replaced main.py's `if not resolution.allocated:` with
|
||||
# `if False:` and the whole 17-file suite stayed GREEN. The branch is the right
|
||||
# instinct - the alternative default is the fault ARCH-01 was filed about, N
|
||||
# inverters each supplying the site target IN FULL, a permanent limit cycle at
|
||||
# the meter with every §11.2 watchdog reporting healthy - and an ungraded right
|
||||
# instinct is one refactor away from being deleted as dead code. The
|
||||
# two-inverter cycle above IS that state, so it is graded on that cycle rather
|
||||
# than with a fixture of its own.
|
||||
# --- 10h. ALLOC-01: WHAT MAIN.PY DOES WITH AN ALLOCATION IT CANNOT DELIVER ---
|
||||
# ⚠️ THIS PART WAS WRITTEN AGAINST `allocated=False` AND HAS BEEN RE-AIMED, NOT
|
||||
# DELETED. ARB-03's `allocate()` declined at every roster of two or more,
|
||||
# because it had no allocator and the obvious placeholder - hand the site target
|
||||
# to each inverter - IS the ARCH-01 fault. ALLOC-01 filled the seam, so that
|
||||
# trigger is gone. The branch is NOT, and this part is why it had better not be:
|
||||
# PR #40's reviewer replaced main.py's `if not resolution.allocated:` with
|
||||
# `if False:` and the whole 17-file suite stayed GREEN, so an unreachable-but-
|
||||
# right instinct was one refactor away from being deleted as dead code.
|
||||
# Removing the trigger without giving the branch a new one would rebuild that
|
||||
# hole exactly.
|
||||
#
|
||||
# ⚠️ AND WHAT IT DOES IS SUBSTITUTE, NOT ABSTAIN. main.py does not skip the
|
||||
# write. It replaces the resolution with a 0 W `failsafe` and then calls
|
||||
# `write_setpoint(0.0)` like any other cycle. 0 W is the correct
|
||||
# fail-toward-inaction direction and the log line is accurate about what it
|
||||
# refuses ("refusing to broadcast the site target"), but "main.py refuses to
|
||||
# write" would be the same overclaim this part just took out of 10g, so it is
|
||||
# graded as what it is: a substitution followed by a write.
|
||||
print(f" at N=2 main.py resolved to {_n2_target:+.0f} W, reason "
|
||||
# THERE ARE NOW TWO REFUSALS AND THEY ARE DIFFERENT REFUSALS, so all three
|
||||
# states below are driven as REAL CYCLES through `Controller.cycle()`:
|
||||
#
|
||||
# 10h-1 the allocator SUCCEEDS and this controller cannot deliver the split.
|
||||
# 10h-2 one inverter's master is stale: the survivor carries the whole site
|
||||
# target, WHICH IS DELIVERABLE, and the drop is announced (AC 6).
|
||||
# 10h-3 every master is stale: no envelope set, so the allocator itself
|
||||
# refuses and `allocated=False` is reached for real.
|
||||
#
|
||||
# ⚠️ AND 10h-1 IS THE GUARD ALLOC-01 HAD TO ADD RATHER THAN ONE IT INHERITED.
|
||||
# Making `allocate()` succeed at N>=2 would otherwise have been a REGRESSION:
|
||||
# there is one `setpoint_entity` in this add-on, so a controller handed a valid
|
||||
# 450/450 split and a roster of two would have written `self.target` - 900, the
|
||||
# SITE number - to the single inverter it can reach, while a second sat
|
||||
# uncommanded. Not the broadcast fault, but the same shape of over-command, and
|
||||
# the allocator's own arithmetic says the share is half of it.
|
||||
print(f" at N=2 the allocator produced {_n2_alloc.explain()}")
|
||||
print(f" and main.py resolved to {_n2_target:+.0f} W, reason "
|
||||
f"{_n2_reason!r}, wrote {_n2_writes}")
|
||||
print(" and logged: "
|
||||
+ next((e for e in _n2_events if "ARBITER" in e), "(nothing)"))
|
||||
check("with two inverters and no allocator main.py does NOT hand the site "
|
||||
"target to the roster. The resolution it acts on is the 0 W failsafe it "
|
||||
check("⚠️ THE ALLOCATOR NOW SUCCEEDS AT A ROSTER OF TWO, and the shares SUM to "
|
||||
"the site target exactly rather than repeating it. That sum is the whole "
|
||||
"safety property: it makes the gain at the meter a convex combination of "
|
||||
"the per-unit gains, where a broadcast makes it N times the per-unit "
|
||||
"gain - 4000 W peak-to-peak forever at gain 1.0 on admin/goodwe's "
|
||||
"two-master rig, with not one guard firing",
|
||||
_n2_alloc.ok and sorted(_n2_alloc.shares) == ["inverter-a", "inverter-b"]
|
||||
and sum(_n2_alloc.shares.values()) == _n2_alloc.sum_w
|
||||
and _n2_alloc.unmet_w == 0)
|
||||
check("...and it did NOT broadcast: neither share is the site target, and "
|
||||
"THAT is the line a placeholder `{u: target for u in units}` goes red on",
|
||||
all(v != _n2_alloc.sum_w for v in _n2_alloc.shares.values())
|
||||
or _n2_alloc.sum_w == 0)
|
||||
check("⚠️ AND MAIN.PY REFUSES TO ACT ON A SPLIT IT CANNOT DELIVER. It drives "
|
||||
"ONE inverter - one `setpoint_entity`, one power sensor - and the "
|
||||
"allocation names two. Writing the site target to the one 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. So the resolution it acts on is the 0 W failsafe it "
|
||||
"substitutes, and `explain()` says so - this is the branch a `if False:` "
|
||||
"deleted with the suite still green",
|
||||
_n2_target == 0.0 and "failsafe" in _n2_reason)
|
||||
check("...and it says WHY, in the action log, in the operator's own words - "
|
||||
"the refusal is to BROADCAST, and it names the roster size that caused it",
|
||||
any("refusing to broadcast the site target" in e and "2 inverters" in e
|
||||
check("...and it says WHY, in the action log, in the operator's own words - it "
|
||||
"names the inverter it has no writer for and the card that gives it one",
|
||||
any("no writer for inverter-b" in e and "DEV-02" in e
|
||||
for e in _n2_events))
|
||||
check("...and then it WRITES that 0 W, on the same line as every other cycle. "
|
||||
"It is a substitution, not an abstention: the setpoint is commanded to "
|
||||
@@ -2727,6 +2761,87 @@ check("...and the 0 W is NOT what the site target happened to be - resolve the "
|
||||
resolve(_cl2, units=("inverter-a",)).allocated is True
|
||||
and resolve(_cl2, units=("inverter-a",)).target_w != 0.0)
|
||||
|
||||
# --- 10h-2. A DEAD LINK, AND THE SURVIVOR NOT ABSORBING IT SILENTLY ---------
|
||||
# ⚠️ STIMULATED THROUGH main.py's OWN SEAM, NOT THROUGH A PATCHED ARBITER.
|
||||
# `Controller.live_units()` is the production method the allocator's `live`
|
||||
# argument comes from; today it answers "the whole roster" because this
|
||||
# controller IS its one inverter's only link, and DEV-02 replaces its body with
|
||||
# a per-master freshness test. Overriding what it returns for one cycle is
|
||||
# therefore stimulating the state DEV-02 will produce, not mocking the thing
|
||||
# under test - and it is the only way to reach AC 6 through a real cycle before
|
||||
# DEV-02 lands.
|
||||
c_src.units = ("inverter-a", "inverter-b")
|
||||
_stale_writes_before = len(c_src.hass.writes)
|
||||
_real_live = c_src.live_units
|
||||
try:
|
||||
c_src.live_units = lambda: ("inverter-a",)
|
||||
_, _cl_stale = cycle_claims(lambda: run_cycle(c_src))
|
||||
_stale_target = c_src.target
|
||||
_stale_alloc = c_src.allocation
|
||||
_stale_events = list(c_src.events)
|
||||
_stale_writes = c_src.hass.writes[_stale_writes_before:]
|
||||
finally:
|
||||
c_src.live_units = _real_live
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
print(f" inverter-b's master stale: {_stale_alloc.explain()}")
|
||||
print(" and logged: "
|
||||
+ next((e for e in _stale_events if "ALLOCATOR" in e), "(nothing)"))
|
||||
check("a unit whose master is stale is REMOVED FROM THE ENVELOPE SET rather "
|
||||
"than allocated into. An allocator that kept commanding a link it cannot "
|
||||
"reach is writing into a void and calling the site target met",
|
||||
_stale_alloc.dropped == (("inverter-b", ALLOC_DROP_STALE),)
|
||||
and "inverter-b" not in _stale_alloc.shares)
|
||||
check("...and the SURVIVOR CARRIES THE WHOLE SITE TARGET, which is right - "
|
||||
"there is one integrator on P1 and its output still has to be delivered - "
|
||||
"and it is now DELIVERABLE, so main.py writes it instead of failsafing. "
|
||||
"The two guards are distinguished by this line: a dead sibling is not the "
|
||||
"same event as a sibling with no writer",
|
||||
_stale_alloc.shares == {"inverter-a": _stale_alloc.sum_w}
|
||||
and _stale_target == float(_stale_alloc.sum_w)
|
||||
and _stale_writes == [_stale_target])
|
||||
check("⚠️ AND IT DOES NOT ABSORB IT SILENTLY, which is the whole of AC 6 and of "
|
||||
"SAFETY-30. The action log names the inverter, the reason it left, and "
|
||||
"the consequence - that what remains now carries its share. A site "
|
||||
"running at half its inverters and reporting nothing reads as a healthy "
|
||||
"site to the operator and to every dashboard",
|
||||
any("inverter-b" in e and "stale link" in e and "carry" in e
|
||||
for e in _stale_events))
|
||||
|
||||
# --- 10h-3. EVERY MASTER STALE: THE ALLOCATOR'S OWN REFUSAL, REACHED --------
|
||||
c_src.units = ("inverter-a", "inverter-b")
|
||||
_dead_writes_before = len(c_src.hass.writes)
|
||||
try:
|
||||
c_src.live_units = lambda: ()
|
||||
_, _cl_dead = cycle_claims(lambda: run_cycle(c_src))
|
||||
_dead_target, _dead_reason = c_src.target, c_src.reason
|
||||
_dead_alloc = c_src.allocation
|
||||
_dead_events = list(c_src.events)
|
||||
_dead_writes = c_src.hass.writes[_dead_writes_before:]
|
||||
finally:
|
||||
c_src.live_units = _real_live
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
print(f" every master stale: {_dead_alloc.explain()}")
|
||||
print(" and logged: "
|
||||
+ next((e for e in _dead_events if "ARBITER" in e), "(nothing)"))
|
||||
check("⚠️ AND HERE `allocated=False` IS REACHED FOR REAL, ON A REAL CYCLE. "
|
||||
"With no inverter in the envelope set there is nobody to allocate to, "
|
||||
"and inventing a share for a link nobody can reach is the failure this "
|
||||
"branch exists for. The refusal path ARB-03 left behind is therefore "
|
||||
"still live code with a live trigger, which is what the ticket asked for "
|
||||
"when it said re-aim rather than delete",
|
||||
_dead_alloc.ok is False and _dead_alloc.shares == {}
|
||||
and ALLOC_REFUSE_EMPTY in _dead_alloc.refused)
|
||||
check("...and main.py substitutes the 0 W failsafe on it, writes that 0 W, and "
|
||||
"puts the allocator's OWN sentence in the action log rather than a "
|
||||
"paraphrase - so the operator reads which of the refusals fired",
|
||||
_dead_target == 0.0 and "failsafe" in _dead_reason
|
||||
and _dead_writes == [0.0]
|
||||
and any("ARBITER: REFUSED" in e and ALLOC_REFUSE_EMPTY in e
|
||||
for e in _dead_events))
|
||||
check("...and the roster it was restored to is the shipped one, so nothing "
|
||||
"below inherits a two-inverter Controller or a patched liveness",
|
||||
c_src.units == (app_main.UNIT_A,)
|
||||
and c_src.live_units() == (app_main.UNIT_A,))
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
|
||||
Reference in New Issue
Block a user