DEV-02: per-instance inverter objects, so nothing is graded against a sibling
checks / unit (pull_request) Successful in 4m15s
checks / unit (pull_request) Successful in 4m15s
Every piece of per-device state in the Controller was a scalar, and several of
them are safety inputs. app/inverter.py introduces an `Inverter` that owns its
entities, its rating, its device range, its SignWatchdog, its last_write and
its last_beat, under store keys namespaced by unit name. The Controller holds a
list of them and keeps only what is genuinely site-level: the P1 reading, the
site integrator, and the tariff quarter.
WHAT THE SCALARS WERE SILENTLY ASSERTING
dev_min/dev_max one pair, read from one setpoint_entity. A second
inverter's rating was NEVER READ AT ALL, so its clamp, its
freeze rail and the peak guard's achievable_w were the
first one's numbers.
soc one reading into the maintenance FSM, SAFETY-24's pack
floor and arbitrage's bounds. An average lets the emptier
pack be driven below its own floor.
sign_wd ONE SignWatchdog, whose own docstring says "one instance
per actuator", latched under FLAT store keys - so a trip
caused by B latched A across restarts and one operator
press cleared both.
last_beat one deadline and one serialised hass.call(), so a hung
write to A ate B's 30 s layer-1 budget.
actual_w BECAME SITE LEVEL WHILE THE READINGS BECAME PER-INSTANCE, and those
are opposite directions on purpose. compute()'s anti-windup test is
`abs(prev_w - actual_w) > saturation_w` with prev_w the SITE target, so feeding
it one inverter's power compares a site number against a unit number, trips the
saturation freeze permanently and leaves a standing grid error. Measured on
admin/goodwe's two-master rig (tools/alloc_sim.py --splits, 2 kW load, 40
cycles), worst |delta| between splits at the meter:
gain one unit's power the site sum
0.2 510 W 0 W
0.4 250 W 0 W
0.6 50 W 0 W
That was ALLOC-01's disclosed finding; test_alloc.py part 8 kept its stimulus
and now grades the other row.
THE STORE MIGRATION IS GRADED, NOT MERELY PERFORMED. sign_tripped/sign_reason
move to unit/<name>/... once, verified by READING BACK what was written, and
the version marker is set only when nothing that carried state was lost. The
flat keys stay in the file so a rollback still finds its latch. And __init__
compares the pre-migration flat latch against what unit A actually came up
with: if the latch did not survive it is RE-ARMED from the legacy value and
shouted about. A latch that silently fails to migrate is a disarmed safety
guard on an install whose state file says it is protected - which is strictly
worse than never persisting it, because now there is evidence saying it is
fine. test_inverter.py part 2 breaks the migration and watches the guard fire.
HEARTBEATS ARE INDEPENDENT AND THE BUDGET IS DERIVED. Each inverter owns its
deadline, the refreshes go out concurrently, and each write is bounded by
WRITE_BUDGET_S = 4 s. The inequality is graded rather than asserted: 4 +
heartbeat_s's schema maximum of 25 < 30, validate.FIRMWARE_INGEST_VETO_S.
Verified by injecting a transport that accepts and never answers.
live_units() ANSWERS HONESTLY. A unit whose every write has been rejected for a
whole layer-1 window is not commandable; the allocator drops it, the survivors
carry its share, the action log names it, and the dropped unit is commanded
0 W rather than left standing on its last share. A roster entry with NO
setpoint_entity is deliberately not handled there - that is a configuration
that cannot be delivered, and it reaches main.py's refusal branch, which is how
that branch keeps a live trigger after this card removed its old one.
AT N=1 NOTHING MOVES. The removed scalars survive as read/write properties of
the sole inverter, so every existing replay runs unmodified - and they REFUSE
at N>=2 rather than answering for the first inverter, which is the tripwire on
a consumer that was never made per-instance.
EVIDENCE. 19 files green. test_inverter.py is new (82 checks). Eighteen
mutations were applied to the shipped modules on a purged tree via
tools/mutation_run.sh and each was observed RED. Two of them SURVIVED first
time and the checks were changed rather than the table: the serialised dispatch
and the shared last_beat both passed while part 6 gathered its own writes and
computed its own `due` list. send_heartbeats() was extracted from run_control()
so the shipped dispatch is reachable by a check at all.
INVERTER B STILL DOES NOT EXIST. build_roster() returns exactly one inverter
from the flat options and is the one function CFG-04 changes; every two-unit
figure here is architecture, and the second rating in the checks is marked a
hypothesis.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
co-authored by
Claude Opus 5
parent
3030f72b9e
commit
e306c74cb8
@@ -127,6 +127,7 @@ from app.arbiter import (
|
||||
from app.control import Tuning, compute, peak_at_risk
|
||||
from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w
|
||||
from app import main as app_main
|
||||
from app.inverter import Inverter
|
||||
from app.main import Controller
|
||||
from app.p1 import (
|
||||
PLAUSIBLE_MAX_W, P1Ingest, QUARTER_S, QuarterAverager, make_sample,
|
||||
@@ -921,6 +922,7 @@ class _Pub:
|
||||
class _Hass:
|
||||
def __init__(self):
|
||||
self.writes = []
|
||||
self.per_entity = []
|
||||
self.notifications = []
|
||||
# SAFETY-25. The whole payload, not only the id. AC 3 is about what the
|
||||
# operator READS, so a fixture that records only that a notification
|
||||
@@ -936,6 +938,11 @@ class _Hass:
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append(value)
|
||||
# DEV-02. WHICH inverter each write went to, which is the only way to
|
||||
# tell "both units were commanded their own share" from "one unit was
|
||||
# commanded twice". `writes` keeps its old shape - a list of floats -
|
||||
# because a dozen checks above read it that way.
|
||||
self.per_entity.append((entity, value))
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
@@ -1289,8 +1296,14 @@ _soc_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "soc"))
|
||||
print(f" main.py's decide(batt_w=...) is `{_batt_src}`, decide(soc=...) is "
|
||||
f"`{_soc_src}`")
|
||||
check("...and the call site says so in the source: the argument is the measured "
|
||||
"battery attribute, not the standing command and not the resolved target",
|
||||
_batt_src == "self.batt")
|
||||
"battery attribute, not the standing command and not the resolved target. "
|
||||
"⚠️ DEV-02 MOVED WHICH ATTRIBUTE AND NOT WHICH KIND OF THING IT IS: "
|
||||
"`self.batt` was one inverter's power used as a site measurement, and "
|
||||
"`site_batt_w` is the SUM over the roster - still measured, still not "
|
||||
"`standing` and not `self.target`. The reduction itself is graded in "
|
||||
"test_inverter.py; what is pinned here is that a MEASUREMENT is what "
|
||||
"sizes the floor",
|
||||
_batt_src == "self.site_batt_w")
|
||||
# ⚠️ SAFETY-24, AND ON THE SAME SINGLE CALL SITE. This grader takes the FIRST
|
||||
# `.decide(` in main.py and reads a NAME rather than a value, so a second call
|
||||
# site - or a decoy - would defeat it; this card deliberately adds neither, and
|
||||
@@ -1298,8 +1311,14 @@ check("...and the call site says so in the source: the argument is the measured
|
||||
check("...and `soc` is handed over as the MEASURED reading, bare. Not "
|
||||
"`self.soc or 100`, not a `float(..., 100)`, not the maintenance floor: "
|
||||
"a full battery is exactly the value that disarms SAFETY-24's floor, and "
|
||||
"NOTES.md 4.4b records this project shipping that exact defaulting once",
|
||||
_soc_src == "self.soc")
|
||||
"NOTES.md 4.4b records this project shipping that exact defaulting once. "
|
||||
"⚠️ DEV-02 MADE IT `pack_floor_soc` - THE EMPTIEST PACK - and the "
|
||||
"defaulting hazard is unchanged and still the point: that property is a "
|
||||
"bare `min()` over measured readings that answers None if ANY unit did "
|
||||
"not read, because `decide()` treats None as floor-limited, which costs a "
|
||||
"shave rather than a pack. An `or 100` anywhere on that path is the same "
|
||||
"defect with two inverters",
|
||||
_soc_src == "self.pack_floor_soc")
|
||||
check(f"...and main.py has exactly ONE `peakguard.decide(` call site (it has "
|
||||
f"{len(_peak_calls)}), so the call graded above is unambiguously the "
|
||||
f"shipped one. Other modules may call their own decide() - OPT-04's "
|
||||
@@ -2545,9 +2564,43 @@ check("...and the arbiter agrees the routing is legal, on the list main.py "
|
||||
# And the day the roster grows, the SAME cycle must partition. Graded through
|
||||
# main.py's own `actuator_of()` on a real Controller - the method every
|
||||
# `claims.append` above routes through - rather than through a copy of it.
|
||||
c_src.units = ("inverter-a", "inverter-b")
|
||||
#
|
||||
# ⚠️ DEV-02: THE ROSTER IS GROWN BY ADDING A REAL `Inverter`, NOT BY SETTING
|
||||
# `c.units`. It used to be a tuple somebody could assign beside a roster of one,
|
||||
# i.e. a site claiming two actuators with one writer - which is a half-driven
|
||||
# site with nothing to say so - and `Controller.units` is now DERIVED from
|
||||
# `Controller.inverters` and has no setter. Building the object is also what
|
||||
# makes these checks mean more than they did: the second unit has its own
|
||||
# setpoint entity, its own rating and its own device range, so the claims below
|
||||
# are filed from ITS numbers rather than from a copy of A's.
|
||||
#
|
||||
# ⚠️ INVERTER B DOES NOT EXIST YET. Its rating here is A's, copied, and that is
|
||||
# a PLACEHOLDER: nothing in this file is a measured parameter for a second
|
||||
# device (SPIKE-04 measures one). What is graded is the ARCHITECTURE - which
|
||||
# actuator each claim lands on - and that is independent of the number.
|
||||
def second_inverter(ctl, *, name="inverter-b", setpoint="number.sp_b",
|
||||
max_w=None, dev=None):
|
||||
"""Append a real second `Inverter` to `ctl`, and return a restore()."""
|
||||
inv = Inverter(name, setpoint_entity=setpoint, soc_entity="sensor.soc_b",
|
||||
batt_entity="sensor.batt_b",
|
||||
max_w=LIVE_MAX_W if max_w is None else max_w,
|
||||
store=ctl.store)
|
||||
inv.dev_min, inv.dev_max = dev or (-LIVE_MAX_W, LIVE_MAX_W)
|
||||
# The readings a real cycle needs from it. `inputs_ok` requires EVERY unit
|
||||
# to have answered, which is the point - a pack nothing can grade stops the
|
||||
# site rather than being quietly dropped from the split.
|
||||
inv.soc, inv.batt = ctl.inverters[0].soc, 0.0
|
||||
original = list(ctl.inverters)
|
||||
ctl.inverters.append(inv)
|
||||
|
||||
def restore():
|
||||
ctl.inverters[:] = original
|
||||
return inv, restore
|
||||
|
||||
|
||||
_b, _restore_b = second_inverter(c_src)
|
||||
_routed = {s: c_src.actuator_of(s) for s in CLAIM_SOURCES}
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
_restore_b()
|
||||
print(" with a second inverter on the roster, main.py routes: "
|
||||
+ ", ".join(f"{s}->{a}" for s, a in sorted(_routed.items())))
|
||||
check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
|
||||
@@ -2563,7 +2616,7 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
|
||||
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE})
|
||||
check("...and the roster it was restored to is the shipped one, so nothing "
|
||||
"below inherits a two-inverter Controller",
|
||||
c_src.units == (app_main.UNIT_A,) and len(c_src.units) == 1)
|
||||
c_src.units == (app_main.UNIT_A,) and len(c_src.inverters) == 1)
|
||||
|
||||
# ⚠️ AND THE ROUTING TABLE BEING RIGHT IS NOT THE SAME AS THE CALL SITES USING
|
||||
# IT RIGHT. `actuator_of("maintenance")` written at the `device` append routes a
|
||||
@@ -2618,7 +2671,27 @@ _direct = [n.args[0] for n in _appends if isinstance(n.args[0], ast.Call)]
|
||||
_indirect = [n.args[0].id for n in _appends if isinstance(n.args[0], ast.Name)]
|
||||
_routing = sorted(((c.args[0].value, append_actuator(c), c.lineno)
|
||||
for c in _direct), key=lambda r: r[2])
|
||||
_misrouted = [r for r in _routing if r[1] != f"self.actuator_of({r[0]!r})"]
|
||||
# ⚠️ DEV-02 GAVE `actuator_of()` A SECOND ARGUMENT - the roster entry the claim
|
||||
# is ABOUT - so a per-inverter append inside the `for inv in self.inverters:`
|
||||
# loop reads `self.actuator_of('device', inv)`. The pin is unchanged in what it
|
||||
# grades: the FIRST argument, the string literal, must still be the source the
|
||||
# claim is filed under. What the second argument may be is deliberately NOT
|
||||
# constrained here - it is a roster entry, and which one is a question the
|
||||
# dynamic two-inverter cycle below answers on real claims - but its PRESENCE is,
|
||||
# because a per-inverter claim built in that loop and routed with no `inv` files
|
||||
# inverter B's rating on inverter A. That is graded at the line: a source
|
||||
# classified SCOPE_UNIT must pass one.
|
||||
def _routing_literal(a):
|
||||
"""The first argument of `self.actuator_of(...)`, or None if it is not one."""
|
||||
if not a.startswith("self.actuator_of("):
|
||||
return None
|
||||
return a[len("self.actuator_of("):-1].split(",")[0].strip()
|
||||
|
||||
|
||||
_misrouted = [r for r in _routing if _routing_literal(r[1]) != repr(r[0])]
|
||||
_unit_sources_unrouted = [
|
||||
r for r in _routing
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
|
||||
print(f" {len(_direct)} direct appends, each read at its own line: "
|
||||
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
|
||||
check("all eight of `Controller.cycle()`'s DIRECT appends route through "
|
||||
@@ -2638,6 +2711,17 @@ check("⚠️ AND AT EVERY ONE OF THE EIGHT THE LITERAL IT ROUTES THROUGH IS THE
|
||||
"`actuator_of(\"peak\")` at the maintenance append is a different string "
|
||||
"here even though it is the same VALUE at the shipped roster of one",
|
||||
_misrouted == [])
|
||||
check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
|
||||
"`actuator_of()` takes the roster entry as its second argument and "
|
||||
"defaults to the FIRST one when it is left out - which is right for the "
|
||||
"site-scoped sources and is the whole defect for a per-inverter one: a "
|
||||
"`device` limit built inside the roster loop and routed without `inv` "
|
||||
"files inverter B's rating on inverter A, where it binds the wrong "
|
||||
"share. Five of the eight appends are SCOPE_UNIT - `device`, "
|
||||
"`supervised`, `failsafe` and maintenance's two - and all five pass one",
|
||||
_unit_sources_unrouted == []
|
||||
and len([r for r in _routing
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 5)
|
||||
check("...and the only appends this pin does NOT reach are the two that file a "
|
||||
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
|
||||
"`actuator=` at the call site to mis-type. Their routing is pinned where "
|
||||
@@ -2656,17 +2740,18 @@ check("...and between them the ten appends account for exactly the eight "
|
||||
# cycle proving the routing reaches the arbiter THROUGH main.py rather than only
|
||||
# through this file's reading of main.py. It grades three of the eight appends.
|
||||
# The static pin above is what covers the other five.
|
||||
c_src.units = ("inverter-a", "inverter-b")
|
||||
_n2_writes_before = len(c_src.hass.writes)
|
||||
_b2, _restore_b2 = second_inverter(c_src)
|
||||
_n2_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl2 = cycle_claims(lambda: run_cycle(c_src))
|
||||
_n2_target, _n2_reason = c_src.target, c_src.reason
|
||||
_n2_events = list(c_src.events)
|
||||
_n2_writes = c_src.hass.writes[_n2_writes_before:]
|
||||
_n2_last = c_src.last_write
|
||||
_n2_writes = c_src.hass.per_entity[_n2_writes_before:]
|
||||
_n2_shares = {i.name: i.target for i in c_src.inverters}
|
||||
_n2_last = {i.name: i.last_write for i in c_src.inverters}
|
||||
_n2_alloc = c_src.allocation
|
||||
finally:
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
_restore_b2()
|
||||
print(" a REAL two-inverter cycle files: "
|
||||
+ ", ".join(f"{c.source}@{c.actuator}" for c in _cl2))
|
||||
check("a real cycle at a roster of TWO partitions its own claims correctly - "
|
||||
@@ -2676,18 +2761,29 @@ check("a real cycle at a roster of TWO partitions its own claims correctly - "
|
||||
"`device`, `supervised` and `loop`, so it is the static pin above and not "
|
||||
"this line that covers `failsafe`, `safety` and the two `maintenance` "
|
||||
"appends", len(_cl2) >= 3 and scope_violations(_cl2) == [])
|
||||
check("...and the partition is the expected one on the sources this state "
|
||||
"reaches: the two rating limits bind the unit, the loop claim stays on "
|
||||
"the grid connection",
|
||||
{c.source: c.actuator for c in _cl2}
|
||||
== {"device": "inverter-a", "supervised": "inverter-a",
|
||||
"loop": ACTUATOR_SITE})
|
||||
check("⚠️ AND THE RATING LIMITS ARE FILED ONCE PER INVERTER, ON ITS OWN "
|
||||
"ACTUATOR - which is DEV-02 in one line. Before this card there was ONE "
|
||||
"`device` pair, read from ONE `setpoint_entity`, filed once: a second "
|
||||
"inverter's rating was never read and never bound anything, so its share "
|
||||
"was clamped against the first one's numbers. Now the loop over the "
|
||||
"roster files a pair per unit and the loop claim stays on the grid "
|
||||
"connection",
|
||||
sorted((c.source, c.actuator) for c in _cl2)
|
||||
== [("device", "inverter-a"), ("device", "inverter-b"),
|
||||
("loop", ACTUATOR_SITE), ("supervised", "inverter-a"),
|
||||
("supervised", "inverter-b")])
|
||||
check("...and naming those three is not an accident of this fixture but the "
|
||||
"reason the static pin exists - five of the eight sources need a state "
|
||||
"no check in this file reaches",
|
||||
{c.source for c in _cl2}
|
||||
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
|
||||
"arbitrage"})
|
||||
check("...and each unit's `supervised` bound is ITS OWN `max_w`, not a copy of "
|
||||
"the first unit's. That is the sentence AC 4 is: give inverter B a "
|
||||
"different rating and the claim filed on inverter B moves with it",
|
||||
[(c.lo, c.hi) for c in _cl2
|
||||
if c.source == "supervised" and c.actuator == "inverter-b"]
|
||||
== [(-_b2.max_w, _b2.max_w)])
|
||||
|
||||
|
||||
# --- 10h. ALLOC-01: WHAT MAIN.PY DOES WITH AN ALLOCATION IT CANNOT DELIVER ---
|
||||
@@ -2711,13 +2807,19 @@ check("...and naming those three is not an accident of this fixture but the "
|
||||
# 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.
|
||||
# ⚠️ AND 10h-1 IS THE HALF DEV-02 CHANGED, SO IT HAS BEEN RE-AIMED AGAIN AND
|
||||
# THE PREVIOUS AIM IS RECORDED HERE RATHER THAN DELETED. ALLOC-01 had to ADD
|
||||
# this guard: there was 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 could reach
|
||||
# while a second sat uncommanded. Not the broadcast fault, but the same shape of
|
||||
# over-command.
|
||||
#
|
||||
# DEV-02 gives it a writer per inverter, so that trigger is gone and the
|
||||
# deliverable case is now the ORDINARY one: both units are commanded THEIR OWN
|
||||
# SHARE, which is what 10h-1 grades below. The refusal survives with a trigger
|
||||
# it can still reach - a roster entry with NO setpoint entity of its own - and
|
||||
# 10h-1b drives 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}")
|
||||
@@ -2736,53 +2838,105 @@ 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 - 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 "
|
||||
"zero rather than left wherever the last cycle put it, which is the "
|
||||
"fail-toward-inaction direction and the opposite of leaving a stale "
|
||||
"command standing", _n2_writes == [0.0] and _n2_last == 0.0)
|
||||
check("...and the 0 W is NOT what the site target happened to be - resolve the "
|
||||
"same claims at a roster of ONE, where the seam passes through, and a "
|
||||
"real number comes back. That is what says the branch does work rather "
|
||||
"than agreeing with the resolution by luck",
|
||||
resolve(_cl2, units=("inverter-a",)).allocated is True
|
||||
and resolve(_cl2, units=("inverter-a",)).target_w != 0.0)
|
||||
check("⚠️ AND MAIN.PY NOW DELIVERS THE SPLIT. THIS IS DEV-02'S DELIVERABLE, "
|
||||
"and it is the line that used to read 'refusing to command a partial "
|
||||
"site'. Each inverter is written ITS OWN SHARE, to ITS OWN setpoint "
|
||||
"entity, in one cycle - two entities, two numbers, and the two numbers "
|
||||
"are the allocator's, not the site target repeated",
|
||||
sorted(_n2_writes)
|
||||
== sorted([("number.sp", float(_n2_alloc.shares["inverter-a"])),
|
||||
("number.sp_b", float(_n2_alloc.shares["inverter-b"]))]))
|
||||
check("...and neither entity was handed the SITE target, which is the "
|
||||
"broadcast fault stated as a check on the wire rather than on the "
|
||||
"allocator. Each unit supplying the site number IN FULL is 4000 W "
|
||||
"peak-to-peak forever at gain 1.0 on admin/goodwe's two-master rig, with "
|
||||
"not one guard firing",
|
||||
_n2_target == 0.0 or all(v != _n2_target for _e, v in _n2_writes))
|
||||
check("...and the resolution is NOT the 0 W failsafe any more: the site target "
|
||||
"is a real number and it reached the hardware. That is the whole "
|
||||
"difference this card makes to this part",
|
||||
_n2_target != 0.0 and "failsafe" not in _n2_reason)
|
||||
check("...and each inverter's own `last_write` records what IT was told, so "
|
||||
"the heartbeat re-emits a SHARE rather than the site number and "
|
||||
"SAFETY-02 grades each unit against the command IT is standing on",
|
||||
_n2_last == {u: float(v) for u, v in _n2_alloc.shares.items()})
|
||||
check("...and the action log no longer carries the 'no writer' refusal, "
|
||||
"because there is a writer. This is the line that goes red if that "
|
||||
"branch is left standing with its old trigger",
|
||||
not any("no writer" in e for e in _n2_events))
|
||||
|
||||
# --- 10h-1b. THE RE-AIMED REFUSAL: A ROSTER ENTRY WITH NO WRITER ------------
|
||||
# ⚠️ THE INSTINCT SURVIVES ITS TRIGGER, WHICH IS WHAT ALLOC-01 ASKED FOR WHEN IT
|
||||
# SAID RE-AIM RATHER THAN DELETE - and PR #40's reviewer is why: replacing
|
||||
# main.py's sibling branch with `if False:` left the whole suite green, so an
|
||||
# unreachable-but-right guard was one refactor from being deleted as dead code.
|
||||
# A configured inverter with no `setpoint_entity` of its own is still a unit
|
||||
# this controller cannot put a number on, and commanding the ones it CAN reach
|
||||
# would over-command them by exactly the missing unit's share.
|
||||
_nw, _restore_nw = second_inverter(c_src, name="inverter-c", setpoint="")
|
||||
_nw_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl_nw = cycle_claims(lambda: run_cycle(c_src))
|
||||
_nw_target, _nw_reason = c_src.target, c_src.reason
|
||||
_nw_events = list(c_src.events)
|
||||
_nw_writes = c_src.hass.per_entity[_nw_writes_before:]
|
||||
finally:
|
||||
_restore_nw()
|
||||
print(f" a roster entry with no setpoint entity: main.py resolved to "
|
||||
f"{_nw_target:+.0f} W, reason {_nw_reason!r}, wrote {_nw_writes}")
|
||||
print(" and logged: "
|
||||
+ next((e for e in _nw_events if "ARBITER" in e), "(nothing)"))
|
||||
check("⚠️ A UNIT WITH NO WRITER TAKES THE SITE TO THE 0 W FAILSAFE. It is a "
|
||||
"CONFIGURATION that cannot be delivered - not a link that went away, "
|
||||
"which is the dropped case below and is announced and survived - and a "
|
||||
"site running at part strength on a roster somebody wrote down wrong "
|
||||
"reads as healthy on every dashboard",
|
||||
_nw_target == 0.0 and "failsafe" in _nw_reason)
|
||||
check("...and it says WHY, naming the inverter it has no setpoint entity for",
|
||||
any("no setpoint entity for inverter-c" in e for e in _nw_events))
|
||||
check("...and then it WRITES that 0 W to the unit it CAN reach, on the same "
|
||||
"line as every other cycle. A substitution, not an abstention: the "
|
||||
"inverter holds its last command forever, so failing toward inaction "
|
||||
"means commanding zero, not falling silent",
|
||||
_nw_writes == [("number.sp", 0.0)])
|
||||
check("...and the 0 W is NOT what the site target happened to be - the same "
|
||||
"claims at a roster whose units all have writers resolve to a real "
|
||||
"number, which is what says the branch does work rather than agreeing "
|
||||
"with the resolution by luck",
|
||||
resolve(_cl_nw, units=("inverter-a",)).allocated is True
|
||||
and resolve(_cl_nw, 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
|
||||
# argument comes from.
|
||||
#
|
||||
# ⚠️ AND SINCE DEV-02 IT IS DRIVEN BY THE REAL STIMULUS RATHER THAN OVERRIDDEN.
|
||||
# This used to replace the method with a lambda, because at a roster of one
|
||||
# "its master is stale" had no expression separate from "this controller is
|
||||
# blind". `Inverter.is_live()` gives it one: a link whose every write has been
|
||||
# REJECTED for a whole `LINK_STALE_S` window is not commandable, which is layer
|
||||
# 1's own veto period - the point at which the firmware takes that inverter to
|
||||
# zero anyway. So inverter B is put into exactly that state and the shipped
|
||||
# method is asked, which is what makes this a check on `live_units()` instead of
|
||||
# a check on a lambda this file wrote.
|
||||
_bs, _restore_bs = second_inverter(c_src)
|
||||
_bs.rejected_since = datetime.now(timezone.utc) - timedelta(
|
||||
seconds=app_main.LINK_STALE_S + 5)
|
||||
_bs.last_write_ok = False
|
||||
_stale_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
c_src.live_units = lambda: ("inverter-a",)
|
||||
check("PRECONDITION: the SHIPPED `live_units()` - not a lambda - drops the "
|
||||
"inverter whose writes have been rejected for a whole layer-1 window, "
|
||||
"and keeps the healthy one",
|
||||
c_src.live_units() == ("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:]
|
||||
_stale_writes = c_src.hass.per_entity[_stale_writes_before:]
|
||||
finally:
|
||||
c_src.live_units = _real_live
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
_restore_bs()
|
||||
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)"))
|
||||
@@ -2798,7 +2952,13 @@ check("...and the SURVIVOR CARRIES THE WHOLE SITE TARGET, which is right - "
|
||||
"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])
|
||||
and ("number.sp", _stale_target) in _stale_writes)
|
||||
check("⚠️ AND THE DROPPED UNIT IS COMMANDED 0 W, NOT LEFT STANDING. The "
|
||||
"inverter holds its last command FOREVER - it has no meter timeout of its "
|
||||
"own - so 'not in the split' has to mean zero on the wire. Leaving it "
|
||||
"silent would leave a dead-link inverter pushing its last share while the "
|
||||
"survivor carried the whole site target on top of it",
|
||||
("number.sp_b", 0.0) in _stale_writes)
|
||||
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 "
|
||||
@@ -2808,18 +2968,26 @@ check("⚠️ AND IT DOES NOT ABSORB IT SILENTLY, which is the whole of AC 6 and
|
||||
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)
|
||||
# Same stimulus, both units. ⚠️ AND IT REACHES A ROSTER OF ONE TOO: this is the
|
||||
# state `live_units()`'s own docstring warns about - the sole inverter dropping
|
||||
# takes the SITE to 0 W - so it is driven here rather than left as a sentence.
|
||||
_bd, _restore_bd = second_inverter(c_src)
|
||||
_gone = datetime.now(timezone.utc) - timedelta(
|
||||
seconds=app_main.LINK_STALE_S + 5)
|
||||
_bd.rejected_since, _bd.last_write_ok = _gone, False
|
||||
c_src.inverters[0].rejected_since = _gone
|
||||
c_src.inverters[0].last_write_ok = False
|
||||
_dead_writes_before = len(c_src.hass.per_entity)
|
||||
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:]
|
||||
_dead_writes = [v for _e, v in c_src.hass.per_entity[_dead_writes_before:]]
|
||||
finally:
|
||||
c_src.live_units = _real_live
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
_restore_bd()
|
||||
c_src.inverters[0].rejected_since = None
|
||||
c_src.inverters[0].last_write_ok = True
|
||||
print(f" every master stale: {_dead_alloc.explain()}")
|
||||
print(" and logged: "
|
||||
+ next((e for e in _dead_events if "ARBITER" in e), "(nothing)"))
|
||||
@@ -2835,11 +3003,11 @@ 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 set(_dead_writes) == {0.0} and len(_dead_writes) == 2
|
||||
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",
|
||||
check("...and the roster it was restored to is the shipped one, with a healthy "
|
||||
"link, so nothing below inherits a two-inverter Controller or a dead one",
|
||||
c_src.units == (app_main.UNIT_A,)
|
||||
and c_src.live_units() == (app_main.UNIT_A,))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user