SAFETY-38: pin site_standing with shares that cannot answer for the site

`app/main.py:1598`'s `site_standing = float(sum(standing.values()))` is the
first argument of SAFETY-30's latching, site-stopping sign detector AND the
`commanded_w` handed to SAFETY-18's `looks_frozen()`. It was pinned by nothing.
Coverage only: not one byte of `app/` moves - the shipped `sum` is correct.

WHY THE SUITE COULD NOT SEE IT. `_sign_site`, the only N>=2 site fixture in the
repository, runs shares of about -2500 W EACH - every one individually over its
320 W gate - so sum, mean and max(key=abs) all agree on it. Its values happen to
be equal, and a check comparing two things that happen to be equal cannot see
the difference it names. MUTATION.md habit 4, ninth instance, inside the site
half of the card that names the rule in its own text. At N=1 it is arithmetic
rather than a fixture defect: sum == mean == max == the one share, identically.

THE FIXTURE (test_watchdog.py part 9). Two SIMULATED units rated 220 W and
280 W, both obeying exactly, inverted gain, house importing 2000 W: the site
commands -500 W against a +2500 W meter and NO SINGLE SHARE clears the shipped
300 W gate. Four candidate reductions, four distinct answers, one over the gate
- sum -500, mean -250, max(key=abs) -280, [0] -220 - asserted as a precondition.
The asymmetry is load-bearing, and the smaller unit is first so [0] stays
distinct from max(key=abs). Gate shipped; only the hold is shortened.

BOTH CONSUMERS. 9b drives the same discrimination at SAFETY-18's threshold: the
site rail is 0.95 x 500 = 475 W, so the sum rails it while 250, 280 and 220 do
not. Shipped: BLIND_FROZEN, floor 0 W. Under both surviving mutations: not blind
at all, no floor filed - the battery may keep importing through a quarter whose
billed average is unobservable. The open quarter is backfilled with the stuck
reading so the mutant cannot reach the same action by the coverage route and
mask the difference. 9c asserts the N=1 identity on a real one-inverter tree.

SAFETY-32 already closes `list(standing.values())[0]` structurally and this card
does not claim it. Its disclosed blind spot - semantics are invisible to a shape
pin, permanently - is why the mean and max(key=abs) needed a behavioural set.

AND THE SEARCH FOR THE NEXT SURVIVOR FOUND ONE. `float(self.target)` - the
arbiter's site target instead of the sum of what stands on the wire - is -11 B
and survives all 20 files on untouched release/1.0 @ f72c48d, because every
fixture that existed was built where the two are the same number. They separate
when a device's reported range is narrower than its max_w: the `device` claim is
filed per actuator and never bounds the site target. Closed here by a
counterfactual that asks for -500 W while -250 W stands.

10 mutations of that one line, each on a fresh full-repo copy mounted :ro,
purged by tools/mutation_run.sh, the edit re-read from disk and the byte delta
printed. Ledger and honest gaps in DECISIONS/SAFETY-38-site-standing-pin.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-05 19:42:06 +02:00
committed by glenn schrooyen
co-authored by Claude Opus 5
parent bbc903b2b4
commit 0f0e767f42
4 changed files with 849 additions and 3 deletions
+518
View File
@@ -1993,6 +1993,524 @@ check("...and the counterfactual again: the same site with nothing latched "
not _bn_n["banner"].startswith("FAILSAFE")
and _bn_n["banner"] != _bn_b["banner"])
print()
print("9. SAFETY-38: `site_standing` - the ONE quantity BOTH site guards are")
print(" asked about - pinned by shares that CANNOT answer for it")
# ⚠️ WHAT THIS SECTION PINS, AND WHY NOTHING ABOVE IT COULD.
# `main.py`'s `site_standing = float(sum(standing.values()))` is the first
# argument of SAFETY-30's site sign detector AND the `commanded_w` handed to
# SAFETY-18's `looks_frozen()`. It was pinned by NOTHING. Three mutations of it
# were demonstrated live against a full green suite; SAFETY-32's package-wide
# structural pin has since closed `list(standing.values())[0]` - a slice
# standing between a roster-derived value and its reduction, which it sees on
# the line it is written, at every roster length, with no fixture. DO NOT
# RE-COVER THAT ONE HERE. The two that remain are SEMANTIC:
#
# sum(...) / max(1, len(standing)) the MEAN
# max(standing.values(), key=abs) the LARGEST SHARE
#
# Identical shape, different meaning - which is precisely the blind spot
# SAFETY-32 disclosed in its own words ("SEMANTICS. `min` -> `max` -> the mean
# are edits invisible to any shape pin, permanently"). No shape pin will ever
# close them. Only a behavioural fixture can, and this is it.
#
# ⚠️ WHY PART 5 ABOVE CANNOT SEE THEM, AND IT IS THIS REPOSITORY'S RECURRING
# DEFECT INSIDE THE SITE HALF OF THE VERY CARD THAT NAMES IT. `_sign_site` is
# the only N>=2 site fixture in the suite and it runs at
# `site_sign_disagree_w: 320` with shares of about -2500 W EACH - every one of
# them individually far OVER the gate. On values like those the sum, the mean
# and the largest share all clear it, so all three reductions agree, and a
# check whose two candidate answers happen to be EQUAL cannot observe a change
# that swaps them. MUTATION.md habit 4. At a roster of ONE it is worse: there
# `sum == mean == max == the one share` identically, so every mutation is
# EQUIVALENT there - see 9c, where that identity is asserted rather than
# assumed.
#
# ⚠️ SO THE NUMBERS BELOW ARE CHOSEN TO DISCRIMINATE, AND HERE IS THE
# ARITHMETIC THEY ARE CHOSEN FOR. Two simulated units rated 220 W and 280 W
# stand at their own rails, so `standing` is {-220, -280} in roster order:
#
# sum -500 W |500| > 300 OVER the gate -> TRIPS
# mean -250 W |250| < 300 under it -> silent
# max(..., key=abs) -280 W |280| < 300 under it -> silent
# list(...)[0] -220 W |220| < 300 under it -> silent
#
# FOUR DISTINCT MAGNITUDES - 500, 250, 280, 220 - of which exactly ONE clears
# the SHIPPED 300 W gate. The asymmetry is load-bearing twice over: equal
# shares would collapse mean, max and first onto one number and the fixture
# could not tell them apart, and putting the SMALLER unit first is what keeps
# `[0]` distinct from `max(key=abs)`.
#
# ⚠️ THE RATINGS ARE SIMULATED AND ARE NOT ANY REAL INVERTER'S. 220 W and 280 W
# are chosen for the arithmetic in that table and for nothing else. In
# particular they are NOT inverter B, which does not physically exist; B's
# rating is 4600 W FROM ITS DATASHEET (goodwe/NOTES.md 1.1b) and no number in
# this section is a measurement of anything.
#
# ⚠️ THE GATE IS THE SHIPPED ONE AND THE HOLD IS NOT. `site_sign_disagree_w` is
# left at its default so this fixture is graded against the magnitude an
# install actually runs; `site_sign_disagree_s` is shortened from 325 s to 2 s
# because the hold is a wall clock and the campaign behind 325 s is measured on
# the rig, not here. The gate is the coordinate this section is about.
from app import peakguard as _pg38 # noqa: E402
from app.freeze import rail_w as _rail_w38 # noqa: E402
from app.p1 import P1Sample as _P1Sample38 # noqa: E402
import time as _time38 # noqa: E402
_S38_A_W, _S38_B_W = 220.0, 280.0 # SIMULATED. See the note above.
_S38_LOAD_W = 2000.0
_S38_FROZEN_METER_W = 2500.0
def _s38_roster(*ratings):
"""A CFG-04 `inverters:` roster, which is the shape an N>=2 install runs.
⚠️ NOT `_sign_site`'s "build one from the flat keys and append a second".
`Controller.__init__` builds `Tuning` FROM the roster - `max_w=site_max_w` -
so a unit appended afterwards leaves the site loop bounded at ONE unit's
rating. That is harmless where part 3 uses it and fatal here: the whole
fixture is a site command standing at the ROSTER's rail.
"""
return [{"name": name, "setpoint_entity": f"number.sp_{tag}",
"soc_entity": f"sensor.soc_{tag}", "batt_entity": f"sensor.batt_{tag}",
"max_w": w, "bms_soc_floor": 10}
for name, tag, w in zip(("inverter-a", "inverter-b", "inverter-c"),
("a", "b", "c"), ratings)]
class _S38Plant:
"""Units that obey INSTANTLY and a meter that is `load - sum(measured)`.
The same modelling choice part 3 makes and for the same reason: an instant
plant removes the one mechanism that could make an obedient unit look
disobedient, so what remains to be graded is the ATTRIBUTION.
"""
def __init__(self, load_w=_S38_LOAD_W):
self.load_w = float(load_w)
self.writes = []
self.calls = []
self.ctrl = None
async def call(self, domain, service, data):
self.calls.append((domain, service, data))
async def limits(self, entity):
return None
def measured(self):
if self.ctrl is None:
return []
return [float((i.last_write if i.last_write is not None else i.target) or 0.0)
for i in self.ctrl.inverters]
async def number(self, entity, invert=False):
m = self.measured()
if entity == "sensor.grid":
return self.load_w - sum(m)
for i, tag in enumerate(("a", "b", "c")):
if entity == f"sensor.soc_{tag}":
return 50.0
if entity == f"sensor.batt_{tag}":
return m[i] if i < len(m) else None
return None
async def set_number(self, entity, value):
self.writes.append((entity, float(value)))
return True
def _s38_ctrl(hass, ratings, store=None, **over):
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
"inverters": _s38_roster(*ratings),
"gain": -0.6,
# The per-unit pair, distinct from the site pair in BOTH
# coordinates, for part 3's reason: an equality below must be able
# to fail on the magnitude alone or on the hold alone.
"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 1,
# ⚠️ `site_sign_disagree_w` DELIBERATELY ABSENT - the shipped 300 W
# default is the gate this whole section is graded against.
"site_sign_disagree_s": 2,
# SAFETY-31 is given a long window so the SITE is not stopped by
# circulation while this section grades the sign attribution. It is
# isolation, not a claim that SAFETY-31 is silent here.
"circulation_hold_s": 3600, "auto_start": True}
opts.update(over)
c = Controller(opts, hass, store if store is not None else _MemStore(),
_P(), stop=asyncio.Event())
for inv in c.inverters:
inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w
c.auto = True
hass.ctrl = c
return c
def _s38_stood(c):
return [inv.last_write if inv.last_write is not None else inv.target
for inv in c.inverters]
print()
print("9a. the SITE sign detector: per-unit shares EACH BELOW the gate whose")
print(" SUM clears it - the case the site detector exists for")
async def _s38_site():
hass = _S38Plant()
c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W))
seen = []
for _ in range(20):
await c.read_inputs()
await c.cycle()
seen.append(dict(grid=c.grid, stood=_s38_stood(c),
meas=[i.batt for i in c.inverters],
site=c.site_sign_wd.tripped,
units=[i.sign_wd.tripped for i in c.inverters],
following=c.following))
await asyncio.sleep(0.2)
return c, seen
_c38, _s38seen = asyncio.run(_s38_site())
# The last sample BEFORE the trip, named rather than indexed - part 5's lesson.
# After the trip every command is 0 W, so a fixed index reads the REMEDY.
_pre38 = [x for x in _s38seen if not x["site"]]
_mid38 = _pre38[-1]
_GATE38 = _c38.site_sign_wd.cfg.min_w
_share38 = list(_mid38["stood"])
_red38 = {"sum": sum(_share38),
"mean": sum(_share38) / max(1, len(_share38)),
"max(key=abs)": max(_share38, key=abs),
"[0]": _share38[0]}
print(f" last cycle before the trip: stood "
f"{[round(x) for x in _share38]} measured "
f"{[round(x) for x in _mid38['meas']]} meter {_mid38['grid']:+.0f} W")
print(f" site gate {_GATE38:.0f} W (SHIPPED default), hold "
f"{_c38.site_sign_wd.cfg.hold_s:.0f} s")
for _k, _v in _red38.items():
print(f" {_k:>13} = {_v:+7.1f} W "
f"{'OVER' if abs(_v) > _GATE38 else 'under'} the gate")
check("PRECONDITION: the gate really is the SHIPPED 300 W, so this fixture is "
"graded against the magnitude an install runs and not against a number "
"chosen to make it pass",
_GATE38 == float(SIGN_OPTION_DEFAULTS["site_sign_disagree_w"]) == 300.0)
check("⚠️ PRECONDITION, AND IT IS THE WHOLE OF SAFETY-38. EVERY per-unit share "
"is BELOW the site gate and their SUM is OVER it. `_sign_site` above "
"cannot make this statement - its shares are each individually over the "
"gate, so sum, mean and largest-share all agree there and its values "
"cannot observe a change that swaps them",
len(_share38) >= 2
and all(abs(s) < _GATE38 for s in _share38)
and abs(sum(_share38)) > _GATE38)
check("...and the FOUR candidate reductions give FOUR DISTINCT answers on it, "
"of which exactly ONE clears the gate. That is what makes this fixture "
"able to tell `sum` from the mean, from the largest share and from the "
"first share - and it is the property the existing site fixture lacks",
len({round(v, 6) for v in _red38.values()}) == 4
and [k for k, v in _red38.items() if abs(v) > _GATE38] == ["sum"])
check("PRECONDITION: EVERY inverter is obeying - each unit's measured power "
"agrees in sign with its own share - and the site is charging into an "
"importing house. Without this the trip below could be a per-unit fault "
"in disguise rather than the site fault this grades",
all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"]))
and sum(_mid38["stood"]) * _mid38["grid"] < 0
and _mid38["following"] is True)
check("⚠️ THE SITE WATCHDOG TRIPS ON THE SUM, and no per-unit watchdog does. "
"Under `sum -> mean` and `sum -> max(key=abs)` NOTHING trips, ever: the "
"reviewer's tree was still commanding -500 W of charge into a +2500 W "
"import at t = 595 s with following=True. This is the check both of "
"those mutations go red on",
_c38.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c38.inverters))
print(f" {_c38.site_sign_wd.reason!r}")
check("...and the sentence an operator reads quotes THE SUM, digit for digit - "
"-500 W, not -250, not -280, not -220. A mutation that still tripped but "
"on some other reduction would put a different number in the store, the "
"action log and the banner, and this is where that shows",
f"{sum(_share38):.0f} W" in _c38.site_sign_wd.reason
and "against a meter reading" in _c38.site_sign_wd.reason)
check("...and it stops the WHOLE site, through the `sign_site` claim by name "
"rather than by some other guard happening to stop it",
_c38.target == 0.0 and "sign_site" in _c38.reason
and all(i.target == 0.0 for i in _c38.inverters)
and _c38.store.data.get("site_sign_tripped") is True)
# ⚠️ THE COUNTERFACTUAL, and it is what says the check above is produced by the
# SUM crossing the gate rather than by two inverters being present. Same plant,
# same roster shape, same everything - except the units are rated so that the
# sum lands UNDER the gate. Nothing may trip.
_S38_SMALL = (120.0, 130.0) # sum 250 W: under the 300 W gate
async def _s38_under():
hass = _S38Plant()
c = _s38_ctrl(hass, _S38_SMALL)
for _ in range(20):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.2)
return c
_c38u = asyncio.run(_s38_under())
print(f" counterfactual: stood {[round(x) for x in _s38_stood(_c38u)]} "
f"sum {sum(_s38_stood(_c38u)):+.0f} W meter {_c38u.grid:+.0f} W")
check("...and the counterfactual: the SAME inverted-gain site whose shares sum "
"to 250 W - under the gate - does NOT trip, over the same 20 cycles. So "
"the trip above is the sum crossing 300 W and not the mere presence of a "
"second inverter",
abs(sum(_s38_stood(_c38u))) < _GATE38
and not _c38u.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c38u.inverters))
# ⚠️ AND A SECOND COUNTERFACTUAL, WHICH EXISTS BECAUSE OF A MUTATION THE CHECKS
# ABOVE COULD NOT SEE AND THIS SEARCH WENT LOOKING FOR.
# `site_standing = float(self.target)` - the arbiter's SITE TARGET instead of
# the sum of what actually stands on the wire - is -11 bytes, and it survived
# every check above AND the whole 20-file suite, because in every fixture so
# far the two are the same number. THEY ARE NOT THE SAME NUMBER when a setpoint
# device's own reported range is narrower than its `max_w`: the `device`
# P_SAFETY claim is filed PER ACTUATOR (main.py, `actuator_of("device", inv)`),
# so it bounds each unit's SHARE and never the site target, and
# `clamp_to_rating()` clips again on the way out. Here the site asks for -500 W
# and -250 W stands.
#
# WHICH ONE THE GUARDS MEAN IS ALREADY WRITTEN DOWN, IN BOTH OF THEM: SAFETY-02
# grades "the command standing on the wire", and `looks_frozen()`'s docstring
# says `commanded_w` is "the EMITTED setpoint - what the arbiter resolved and
# `write_setpoint()` put on the wire - not the loop's raw wish". SAFETY-20 is
# the whole card about the difference. This check is those sentences made to
# bite: on the site target the detector fires on a site that is standing at
# -250 W against a +2250 W meter - under its own 300 W gate - which is a
# LATCHING, site-stopping false trip on an install whose inverters simply
# report a narrower range than the operator typed.
_S38_DEV = (120.0, 130.0) # narrower than the 220/280 W ratings
async def _s38_devclamped():
hass = _S38Plant()
c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W))
for inv, d in zip(c.inverters, _S38_DEV):
inv.dev_min, inv.dev_max = -d, d
for _ in range(20):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.2)
return c
_c38d = asyncio.run(_s38_devclamped())
_stood38d = _s38_stood(_c38d)
print(f" device-clamped: site target {_c38d.target:+.0f} W but only "
f"{[round(x) for x in _stood38d]} = {sum(_stood38d):+.0f} W stands, "
f"meter {_c38d.grid:+.0f} W")
check("PRECONDITION: the site TARGET and the EMITTED total are DIFFERENT "
"numbers here, on opposite sides of the gate - -500 W asked, -250 W "
"standing. Without that separation this check and the one above compare "
"two values that happen to be equal, which is the defect this card is "
"the sixth instance of",
abs(_c38d.target) > _GATE38 and abs(sum(_stood38d)) < _GATE38)
check("⚠️ ...and NOTHING TRIPS, because the quantity graded is what STANDS ON "
"THE WIRE and not what the arbiter asked for. `float(self.target)` is "
"-11 bytes, survives the other 20 files, and turns this ordinary "
"narrow-rating install into a latching site-wide false trip",
not _c38d.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c38d.inverters)
and _c38d.target != 0.0)
print()
print("9b. THE SECOND CONSUMER: the same value is SAFETY-18's `commanded_w`")
print(" inside `looks_frozen()`, and both mutations blind it too")
# ⚠️ THIS IS THE AC THE CARD REFUSES TO LET BE SCOPED OUT SILENTLY.
# `site_standing` is handed to `looks_frozen()` at main.py's `peakguard.decide(
# ..., frozen=...)` call, where it is the EMITTED command graded against the
# site rail. Both surviving mutations shrink it there too, and nothing went red.
#
# THE RAIL ARITHMETIC, and it is the same discrimination as 9a at a different
# threshold: the site rail is `RAIL_FRAC * site_max_w` = 0.95 * 500 = 475 W.
#
# sum 500 W >= 475 RAILED -> BLIND_FROZEN, floor 0 W
# mean 250 W < 475 silent
# max(..., key=abs) 280 W < 475 silent
# list(...)[0] 220 W < 475 silent
#
# ⚠️ THE SIGN IS FLIPPED FROM 9a ON PURPOSE. Here the gain is the SHIPPED
# POSITIVE one and the frozen meter reads IMPORT, so the site discharges +500 W
# against a +2500 W import - the two agree in sign, SAFETY-30 has nothing to
# say, and this section grades SAFETY-18 alone. Reusing 9a's inverted gain
# would have the site sign latch park everything at 0 W and the freeze
# conjunction could then never be true: a check measuring the other guard's
# remedy.
#
# WHAT THE MUTATIONS COST HERE, measured rather than asserted: on the shipped
# tree the guard goes blind and files `floor_w = 0.0` - "the battery may not
# import while the billed quarter cannot be measured". Under both mutations it
# is NOT BLIND AT ALL and files `floor_w = None`, so the site may go on
# charging through a quarter whose billed average is unobservable. That is the
# capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
class _S38Src:
"""The transport's own `unchanged_s`, which only homewizard_local has."""
def __init__(self, unchanged_s):
self.unchanged_s = float(unchanged_s)
def _s38_feed(c, w, ts=None, mono=None):
now = ts if ts is not None else datetime.now(timezone.utc)
c.p1.submit(_P1Sample38(
ingest_ts=now,
ingest_mono=mono if mono is not None else _time38.monotonic(),
telegram_ts=now, source="homewizard_local",
import_w=max(w, 0.0), export_w=max(-w, 0.0), net_w=w,
per_phase_w=(w,), per_phase_import_w=(max(w, 0.0),)))
def _s38_seed_quarter(c, w):
"""Backfill the OPEN quarter with the same stuck value, at 10 s spacing.
⚠️ THIS IS NOT DECORATION. Without it the open block is mostly uncovered and
`blindness()` answers BLIND_COVERAGE - which is still blind, still floor
0 W, and would therefore MASK the mutations: the mutant would reach the
same action by a different route and a check on the action alone could not
see the difference it names. Seeded, the mutant is not blind at ALL, which
is the honest measurement of what the mutation costs. A meter that has been
stuck at the same reading all quarter is also what SAFETY-18 is about.
"""
wall = _time38.time()
q0 = (int(wall) // 900) * 900
mono0 = _time38.monotonic() - (wall - q0)
t = float(q0)
while t < wall:
_s38_feed(c, w, datetime.fromtimestamp(t, timezone.utc), mono0 + (t - q0))
t += 10.0
def _s38_frozen_ctrl(hass, ratings, unchanged_s=600.0, gain=0.05):
# ⚠️ `gain` 0.05 IS THE SCHEMA FLOOR AND IT IS HERE TO BUY A RAMP. At the
# shipped 0.6 the loop is at the rail on cycle 0 and the fixture has no
# BELOW-the-rail cycles to use as its counterfactual. At 0.05 the site
# climbs 125 W a cycle and crosses 475 W part way through the run, so the
# SAME run carries both answers.
c = _s38_ctrl(hass, ratings, meter_source="homewizard_local",
p1_host="meter.local", gain=gain)
c.p1_source = _S38Src(unchanged_s)
return c
async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=8):
hass = _S38Plant()
c = _s38_frozen_ctrl(hass, ratings)
_s38_seed_quarter(c, meter_w)
seen = []
for _ in range(n):
_s38_feed(c, meter_w)
await c.read_inputs()
await c.cycle()
pg = c.peak_guard
seen.append(dict(stood=_s38_stood(c),
blind=None if pg is None else pg.blind,
floor=None if pg is None else pg.floor_w))
await asyncio.sleep(0.05)
return c, seen
_c38f, _s38f = asyncio.run(_s38_frozen((_S38_A_W, _S38_B_W)))
_RAIL38 = _rail_w38(_c38f.site_max_w,
dev_min=_c38f.site_dev_bound("dev_min"),
dev_max=_c38f.site_dev_bound("dev_max"))
_last38 = _s38f[-1]
_below38 = [x for x in _s38f if abs(sum(x["stood"])) < _RAIL38]
print(f" site rail {_RAIL38:.0f} W of site_max_w {_c38f.site_max_w:.0f} W; "
f"meter stuck at {_S38_FROZEN_METER_W:+.0f} W for "
f"{_c38f.p1_source.unchanged_s:.0f} s (freeze hold "
f"{_c38f.freeze_wd.cfg.hold_s:.0f} s)")
for _x in _s38f:
print(" stood %-16s sum %+7.1f W floor=%s blind=%r"
% ([round(v) for v in _x["stood"]], sum(_x["stood"]),
_x["floor"], _x["blind"]))
check("PRECONDITION: at the rail EVERY per-unit share is BELOW the site rail "
"and their SUM is at or over it - the same discrimination as 9a, at "
"SAFETY-18's threshold instead of SAFETY-30's. `[0]`, the mean and the "
"largest share are 220, 250 and 280 W against a 475 W rail",
len(_last38["stood"]) >= 2
and all(abs(s) < _RAIL38 for s in _last38["stood"])
and abs(sum(_last38["stood"])) >= _RAIL38
and len({round(v, 6) for v in
(sum(_last38["stood"]),
sum(_last38["stood"]) / len(_last38["stood"]),
max(_last38["stood"], key=abs),
_last38["stood"][0])}) == 4)
check("⚠️ THE PEAK GUARD GOES BLIND ON SAFETY-18's CONJUNCTION and forbids "
"charging - floor 0 W - because the SITE's total command is at the rail "
"while the meter has stopped moving. Under `sum -> mean` and "
"`sum -> max(key=abs)` it is not blind at all and files no floor, so the "
"battery may keep importing through an unmeasurable billed quarter. This "
"is the second consumer, and it is covered rather than scoped out",
_last38["blind"] == _pg38.BLIND_FROZEN and _last38["floor"] == 0.0)
check("...and the counterfactual, from the SAME run: on the earlier cycles the "
"site command had not yet reached the rail, and the guard did NOT report "
"SAFETY-18 - with the meter equally frozen the whole time. So the "
"verdict is produced by the COMMAND MAGNITUDE `site_standing` carries, "
"not by `unchanged_s` alone",
len(_below38) >= 2
and all(x["blind"] != _pg38.BLIND_FROZEN for x in _below38))
print()
print("9c. AT ONE INVERTER NOTHING CHANGES, and 'nothing' is asserted")
# ⚠️ THE REASON THIS SUBSECTION EXISTS. At a roster of one `standing` has a
# single entry, so `sum`, the mean, `max(key=abs)` and `[0]` are THE SAME
# NUMBER: all three mutations are genuinely EQUIVALENT there and no fixture at
# N=1 can ever separate them. That is a fact about the arithmetic, and leaving
# it implicit is how "the shipped install is covered" gets believed. It is
# asserted here, on a real one-inverter controller's own standing command, so
# the claim rests on the roster rather than on the reader.
_S38_ONE = (500.0,)
async def _s38_one():
hass = _S38Plant()
c = _s38_frozen_ctrl(hass, _S38_ONE)
_s38_seed_quarter(c, _S38_FROZEN_METER_W)
for _ in range(8):
_s38_feed(c, _S38_FROZEN_METER_W)
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.05)
return c
_c38one = asyncio.run(_s38_one())
_one38 = _s38_stood(_c38one)
_onered38 = (sum(_one38), sum(_one38) / max(1, len(_one38)),
max(_one38, key=abs), _one38[0])
print(f" roster of {len(_c38one.inverters)}: stood "
f"{[round(v) for v in _one38]} -> sum/mean/max/first = "
f"{tuple(round(v, 1) for v in _onered38)}")
check("at a roster of ONE the four reductions are ONE NUMBER - so all three "
"mutations of `site_standing` are EQUIVALENT on a shipped install and no "
"N=1 fixture could ever tell them apart. That is why this card needed a "
"roster of two, stated as an assertion rather than left to the reader",
len(_c38one.inverters) == 1
and len({round(v, 6) for v in _onered38}) == 1)
check("...and the one-inverter site still reaches SAFETY-18's verdict on that "
"same number, unchanged: the command is at the rail, the meter has "
"stopped moving, the guard is blind and forbids charging",
_c38one.peak_guard is not None
and _c38one.peak_guard.blind == _pg38.BLIND_FROZEN
and _c38one.peak_guard.floor_w == 0.0
and abs(sum(_one38)) >= _rail_w38(
_c38one.site_max_w,
dev_min=_c38one.site_dev_bound("dev_min"),
dev_max=_c38one.site_dev_bound("dev_max")))
check("...and the SITE sign detector is still absent at a roster of one - part "
"7 counts the calls; this only records that a fixture built the other "
"way round, from an `inverters:` roster, reaches the same state",
not _c38one.site_sign_wd.tripped
and _c38one.site_sign_wd.disagree_since is None)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")