DEV-02: per-instance Inverter objects - rating, SoC, watchdogs, heartbeat and store keys stop being scalars #43

Merged
admin merged 4 commits from DEV-02 into release/1.0 2026-09-04 00:36:07 +02:00
13 changed files with 3577 additions and 344 deletions
+18 -1
View File
@@ -83,7 +83,24 @@ rejected.
⚠️ **If you are testing the failsafe by hand, note that stopping and starting the
add-on will not clear a trip.** That is the point of it; use the *Start / stop
control* button twice, or delete `sign_tripped` from `/data/state.json`.
control* button twice, or delete the trip from `/data/state.json`.
⚠️ **DEV-02 renamed that key. There is one `SignWatchdog` per inverter, so the
LIVE latch lives under `unit/<inverter>/sign_tripped`** —
`unit/inverter-a/sign_tripped` on a single-inverter site. **That is the one to
delete if you are clearing a trip by hand**; deleting the flat one alone clears
nothing.
⚠️ **The flat `sign_tripped` is still maintained, though — it is the ROLLBACK
VIEW, not a fossil.** It is written alongside the namespaced key on a trip and
cleared alongside it when you press *Start / stop control*, so an add-on you
**downgrade** past this version still finds the latch it should. It is also read
on **every** start, and the one disagreement it can produce is acted on: *the
flat key says tripped and no inverter came up latched* means the latch was
written by an older add-on while this install was rolled back — or the migration
lost it — and the controller **re-arms the latch and shouts in the log** rather
than coming up disarmed with a green banner. So if you edit `/data/state.json`
by hand, clear **both** spellings or the next start will put the trip back.
⚠️ `test_loop.py` takes about **95 seconds** (measured 93 s) and that is not a bug. It
drives the real control loop, which sleeps 1 s a tick, eight times over, to prove a
+62
View File
@@ -2,6 +2,68 @@
## Unreleased
**Every per-device value stops being a Controller scalar: rating, SoC, battery
power, watchdogs, heartbeat, write bookkeeping and store keys are now
per-instance (DEV-02).** `app/inverter.py` introduces an `Inverter` object 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.** One `dev_min`/`dev_max` pair was
read from one `setpoint_entity`, so a second inverter's rating was **never
read at all** and its clamp, its freeze rail and the peak guard's
`achievable_w` were the first one's numbers. One `soc` fed the maintenance
FSM, SAFETY-24's pack floor and arbitrage's bounds. 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. One `last_beat` and one serialised `hass.call()` meant 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, 2 kW load: with one unit's power
the aggregate trace at the meter is split-dependent by **510 W at `gain: 0.2`,
250 W at 0.4 and 50 W at 0.6**; with the site sum **every split is
bit-identical at every gain the schema accepts**. That was ALLOC-01's
disclosed finding, graded in `test_alloc.py` part 8 and now closed there.
* **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 only set when nothing that carried
state was lost. The flat keys are left in the file so a rollback still finds
its latch. And `Controller.__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**, because a latch that
silently fails to migrate is a disarmed safety guard on an install whose state
file says it is protected.
* **Heartbeats are independent, and the budget is derived.** Each inverter owns
its own deadline, the refreshes are dispatched **concurrently**, and each write
is bounded by `WRITE_BUDGET_S` (4 s). The inequality that fixes that number is
graded rather than asserted: `4 + heartbeat_s`'s schema maximum of `25 < 30`,
layer 1's `FIRMWARE_INGEST_VETO_S`. Verified by injecting a transport that
accepts and never answers: B's write reaches the wire while A is still
hanging, and the serialised shape it replaces is run beside it.
* **`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 commands 0 W and names the inverter.
* **At N=1 nothing moves.** The removed scalars survive as read/write properties
of the sole inverter, so every existing replay in this repository 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. `test_inverter.py` is the new file (77 checks); `test_alloc.py`
part 8, `test_peakguard.py` 10g/10h, `test_watchdog.py` and `test_clamp.py`
were re-aimed rather than relaxed.
* **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 above is architecture, not a measurement of a second device;
the second rating in the checks is marked as a hypothesis.
**The write path is gated on the allocation record, not on how many config keys
exist (SAFETY-28).** specs §1.5 makes *"any configuration in which two writers
can reach one actuator"* a non-goal. Until now that non-goal was enforced by
+7 -1
View File
@@ -306,7 +306,13 @@ does. That is deliberate on both counts:
always vanishes the instant the trip works. A watchdog that took that as proof
the fault was over would hand the inverter straight back to the loop that was
driving the wrong way, three minutes later, forever.
- The trip is written to `/data/state.json` and restored on startup. Restarting
- The trip is written to `/data/state.json` — under
`unit/<inverter>/sign_tripped`, one key per inverter since DEV-02, so a trip
caused by one inverter cannot latch another — and restored on startup. The
pre-DEV-02 flat `sign_tripped` is written and cleared alongside it as the
**rollback view**, so downgrading the add-on past DEV-02 still finds the
latch, and a trip taken while the install is rolled back is re-armed by the
version that comes back up rather than silently lost. Restarting
the add-on is the first thing most people try when they see a red banner, and
a latch that a restart clears would bring the controller back **armed, into the
same fault, unattended** — and under a Supervisor that restarts a crashed add-on
+57
View File
@@ -127,6 +127,63 @@ Two rules come out of it, and both are cheap:
function, that pair is the first thing to try — TEST-16's two survivors were
adjacent lines in one function and neither was individually live.
## The third way a check lies: its DATA cannot reach the failure it names
Stale bytecode manufactures survivors from a mutation that never ran. Masking
manufactures them from a second guard doing the first one's work. This one
manufactures them from a **fixture**: the check runs, the assertion is
evaluated, the sentence above it is true — and the numbers it compares are
arranged so that the failure it describes could not have changed the answer.
It is not hypothetical and it is not rare. Three instances in one week, all
found by review rather than by a red line:
* **ARB-03** shipped a call-site scan asserting that every `claims.append` in
`cycle()` routes through `actuator_of()`. Its own parse **saw 3 of the 8
appends**; the other five were invisible to it and would have passed however
they were routed.
* **ALLOC-01** shipped a replay generator to hunt for envelope escapes. It found
**0**. A widened generator over the same allocator found **39,991**.
* **DEV-02** shipped `"each unit's supervised bound is ITS OWN max_w — give
inverter B a different rating and the claim filed on inverter B moves with
it"`, comparing inverter B's bound against `_b2.max_w`. The fixture defaulted
B's rating to A's, so it compared `(-5000, 5000)` against `(-5000, 5000)` —
**a number against itself**. Filing both per-unit bounds from
`self.inverters[0]` left **all 19 files green**.
Note what these have in common with a masked rung: the mutation *runs*, and the
suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly
can see it, because the code under test really did behave identically on the
stimulus it was given. The defect is upstream of the assertion, in what the
check was handed.
**The rule, and it is one line:**
> **Any check asserting "X differs from Y" must be run once with X and Y
> actually made different.**
Three cheap habits fall out of it, and all three are now used in this repository:
1. **Write the PRECONDITION as its own check.** `test_peakguard.py` 10g asserts
that A and B have different ratings *before* it asserts that B's bound is
B's. If a later edit makes them equal again, the precondition goes red rather
than the claim going quietly vacuous.
2. **Assert against literals, not against the expression under test.**
`== [(-_b2.max_w, _b2.max_w)]` cannot distinguish "B's number" from "the
number this fixture happens to have twice"; `== (-3000.0, 3000.0)` beside a
printed `A = 5000` can.
3. **Print what the check saw.** Every one of the three above would have been
caught by a human reading one line of output — `supervised bounds by
actuator: {'inverter-a': (-5000, 5000), 'inverter-b': (-5000, 5000)}` is
self-evidently not evidence of a difference.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
static routing pin catches. The *bound* variant — right actuator, wrong
numbers — is a different defect with a different fix, and it was absent. Both
are in the table now.
## Where each surface stands
* **Local and dev-harness runs — the exposed one.** This is where FCST-03 hit
+334
View File
@@ -0,0 +1,334 @@
"""One inverter, as an OBJECT rather than as a set of Controller scalars.
WHAT THIS FIXES, in one sentence: every piece of per-device state in this
add-on used to be a single scalar on the Controller, and several of those
scalars are SAFETY INPUTS - so a second inverter would have been graded against
the first one's numbers.
The scalars, and what each one was 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 all inverter A's.
`soc` ONE state of charge 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; a maximum parks the fuller one.
`batt` ONE battery power into the anti-windup saturation
detector - see the SITE/UNIT note at the bottom of
this docstring, because this one goes the OTHER WAY.
`sign_wd` ONE SignWatchdog, whose own docstring says "one
instance per actuator", latched under FLAT store keys.
A trip caused by B latched A across restarts and one
operator action cleared both.
`last_write` ONE write bookkeeping, and ONE serialised `hass.call()`
behind it: a hung write to A ate B's 30 s layer-1
budget.
⚠️ WHAT IS DELIBERATELY *NOT* HERE, AND IT IS THE HALF THAT IS EASY TO GET
BACKWARDS. THERE IS NO `Tuning` ON THIS OBJECT AND THERE IS NO INTEGRATOR.
§6.2 and ALLOC-01's stability argument are ONE integrator on P1, ABOVE the
split: the site target S is divided into per-unit shares, so the gain seen at
the meter is a CONVEX COMBINATION of the per-unit gains and a bad split cannot
destabilise the site. Give each inverter its own `Tuning` and somebody will
eventually call `compute()` with it, and the moment two integrators regulate
one meter the whole argument is gone - that is the two-writers failure the
arbiter exists to prevent, one level up.
The one field of `Tuning` that genuinely IS per-device is `max_w`, the
supervised cap on what may be WRITTEN to one setpoint entity (§11.3), and it is
here as `max_w`. The SITE `Tuning` the loop runs on takes its bound from the
roster - `sum(inv.max_w)` - so at N=1 it is byte-identical to the shipped
number and at N>=2 it is what the site can actually deliver. That is the whole
of "the rating stops being a scalar"; a per-unit Tuning would be three more
numbers nobody reads and one more place a limit has to be kept in step (CFG-03
already tracks three).
------------------------------------------------------------------------------
SITE OR UNIT? THE TWO HALVES OF THE N=1 IDENTITY, AND THEY MOVE IN OPPOSITE
DIRECTIONS
At N=1 §6.2's identity holds: the fabricated grid reading and the battery
setpoint are the same number, so "the site" and "the inverter" are the same
thing and every scalar above was RIGHT. Splitting them is not one change, it is
two, and they point opposite ways:
COMMANDING becomes per-unit. One setpoint entity per inverter, one rating,
one clamp, one watchdog, one heartbeat.
SENSING, for the loop, becomes SITE. `compute()` regulates the METER. Its
`actual_w` is the anti-windup input to
`abs(prev_w - actual_w) > saturation_w`, where
`prev_w` is the SITE target. Feed it ONE unit's
power at a roster of two and that test compares
a site number against a unit number, trips the
freeze permanently and leaves a standing grid
error.
That second half is MEASURED, not argued. On admin/goodwe's two-master rig
(`tools/alloc_sim.py --splits --feedback unit`), with the loop fed one
inverter's power the aggregate trace at the meter becomes a function of the
split - up to 510 W apart at gain 0.2, 250 W at gain 0.4, 50 W at gain 0.6 -
while the allocator's shares sum to S in every one of those runs. The
counterfactual is the proof: change that ONE input to the site total and every
split is bit-identical at every gain the schema accepts. ALLOC-01's review
isolated it and test_alloc.py part 8 graded it as a disclosed defect waiting on
this card.
So this object owns `batt` - THIS inverter's own power, which is what a
per-unit consumer needs - and the Controller sums them into the site figure the
loop is fed. Getting that backwards reintroduces the divergence.
------------------------------------------------------------------------------
LIVENESS, AND WHY IT IS A METHOD WITH A REASON
`Controller.live_units()` decides which inverters may be commanded this tick,
and `allocator.plan()` DROPS what it does not list - the survivors then carry
the whole site target. A wrong answer there is a real change in what the site
can deliver, so `is_live()` answers one question and says which: CAN THIS
CONTROLLER PUT A NUMBER ON THIS INVERTER RIGHT NOW.
ONE way it cannot, and exactly one: every write has been REJECTED for a whole
`LINK_STALE_S` window. Not one rejection - `hass.set_number()` returns False for
a transient 400 as readily as for a device that has gone - but a continuous run
of them with no acceptance in between.
⚠️ AND NOT "IT HAS NO `setpoint_entity`", THOUGH THAT IS ALSO A UNIT THIS
CONTROLLER CANNOT COMMAND. The two look the same and want opposite answers. A
link that WAS working and stopped is an event: the survivors carry its share,
the record says so, and the site goes on running at reduced strength, which is
the best available outcome. A roster entry with no writer configured at all is a
CONFIGURATION that cannot be delivered - it was never reachable, nothing has
gone wrong on the wire, and quietly running the site at part strength on a
roster somebody wrote down wrong is how a half-driven site reads as healthy on
every dashboard. That one belongs to main.py's refusal branch, which commands
0 W and NAMES the inverter; putting it here would absorb it into the ordinary
drop and delete the last trigger that branch has. See `Controller.cycle()`.
⚠️ AND NOT "ITS SENSORS ARE MISSING" EITHER. A pack whose SoC did not answer is
a third fault with a third remedy: `inputs_ok` already stops the whole site at
P_SAFETY for that, and dropping the unit from the SPLIT instead would hand its
share to a sibling while the site kept regulating - the opposite of what a
missing measurement should cause. Liveness is about the WRITE PATH, and only
about a write path that has stopped answering.
"""
import math
from datetime import datetime
from .clamp import ClampResult
from .store import unit_key
from .watchdog import SignWatchdog, SignWatchdogConfig
# How long every write to one inverter must have been REJECTED before this
# controller stops claiming it can command it.
#
# ⚠️ IT IS LAYER 1'S OWN WINDOW, NOT A NEW OPINION ABOUT LINKS. The ESP32
# commands 0 W and keeps commanding it after ~30 s without a refresh
# (`validate.FIRMWARE_INGEST_VETO_S`), so a link this controller has not been
# able to write for a whole 30 s is a link the hardware is about to take away
# from it anyway. Claiming it as commandable past that point is the add-on
# telling the allocator it can deliver watts that the firmware has already
# decided it will not. No config key: CFG-03 tracks three places a default has
# to be kept in step and this does not earn a fourth - it is derived from a
# constant the firmware and validate.py already share.
LINK_STALE_S = 30.0
# The one inverter this add-on drives today. ⚠️ A NAME, NOT A CONFIGURATION -
# it exists so that a per-inverter claim has an actuator to be filed ON, which
# is what makes the site/unit split something a check can grade rather than a
# comment. It lives here rather than in main.py now that the roster does.
UNIT_A = "inverter-a"
# The per-unit keys this object keeps in the Store, namespaced by unit name.
# ⚠️ THE VALUES ARE THE PRE-DEV-02 FLAT KEY NAMES, and that is what makes the
# migration mechanical rather than transcribed: `store.migrate_unit_keys()`
# reads this same mapping, so a key renamed here cannot silently stop being
# migrated. See store.py.
UNIT_STORE_KEYS = ("sign_tripped", "sign_reason")
class Inverter:
"""ONE inverter: its entities, its rating, its watchdogs, its store keys.
Constructed once per configured inverter and held in `Controller.inverters`.
Everything on it is about THIS device; nothing on it is about the site.
⚠️ `hass` IS NOT A CONSTRUCTOR ARGUMENT. This object is built in
`Controller.__init__`, which runs before any I/O and, in four existing
checks, with `hass=None` entirely. The device range is read LATER, by
`Controller.run_control()`, from THIS unit's own `setpoint_entity` - which
is the whole of "each instance's rating is read from its own entity".
"""
def __init__(self, name, *, setpoint_entity="", soc_entity="",
batt_entity="", batt_invert=False, max_w=2000.0,
store=None, sign_cfg=None):
self.name = str(name)
self.setpoint_entity = str(setpoint_entity or "")
self.soc_entity = str(soc_entity or "")
self.batt_entity = str(batt_entity or "")
self.batt_invert = bool(batt_invert)
# The supervised cap on what may be written to THIS setpoint entity.
# See the module docstring on why this is a number and not a `Tuning`.
self.max_w = float(max_w)
# --- the device's own reported range, read from its own entity -------
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
# often not up when the add-on boots, and (None, None) is the honest
# report that the range checks were skipped rather than invented.
self.dev_min = None
self.dev_max = None
# --- this unit's own measurements ------------------------------------
self.soc = None
self.batt = None
# --- what this unit was last told, and what it last accepted ---------
# `target` is THIS unit's share of the site target from the last
# resolution - the number its heartbeat re-emits and the number
# SAFETY-02 grades against the meter. At N=1 it IS the site target,
# because the allocator is a pass-through there.
self.target = 0.0
self.last_write = None
self.last_write_at = None
self.last_write_ok = None
# ⚠️ TWO TIMESTAMPS, NOT ONE FLAG. `last_write_ok` is the last write's
# verdict and it flips on a single transient 400; liveness needs a RUN
# of rejections, so `rejected_since` records when the current run
# started and is cleared by any acceptance.
self.rejected_since = None
# The heartbeat's own deadline, on the event loop's monotonic clock.
# ⚠️ PER UNIT, WHICH IS THE WHOLE OF AC 6. One shared `last_beat` and
# one serialised write meant a hung call to A pushed B's refresh past
# layer 1's ~30 s veto - i.e. one inverter's transport fault zeroing a
# healthy sibling. Starts at 0.0, which is what the single scalar did.
self.last_beat = 0.0
# --- SAFETY-03's per-write evidence ----------------------------------
# What THIS unit's last write clamped, or None before it has run. The
# ALARM (`ClampWatchdog`) stays one per controller - clamp.py says so in
# its own docstring, and it is an anti-flood counter over an event bus
# rather than a property of a device - but the EVIDENCE is per unit,
# because a clamp on B says nothing about A.
self.clamp_last: ClampResult | None = None
# --- SAFETY-02, one instance per actuator, under NAMESPACED keys -----
# ⚠️ THE WATCHDOG'S OWN DOCSTRING SAID "one instance per actuator" WHILE
# THE CONTROLLER HELD EXACTLY ONE. Restored from THIS unit's own keys,
# so a trip caused by B latches B and leaves A commanding - and an
# operator clearing one does not silently clear the other.
self.store = store
data = getattr(store, "data", {}) if store is not None else {}
self.sign_wd = SignWatchdog(
sign_cfg or SignWatchdogConfig(),
tripped=bool(data.get(self.key("sign_tripped"), False)),
reason=str(data.get(self.key("sign_reason"), "") or ""))
# -- store ---------------------------------------------------------------
def key(self, name: str) -> str:
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
return unit_key(self.name, name)
def store_set(self, name: str, value) -> None:
if self.store is not None:
self.store.set(self.key(name), value)
# -- the numbers other modules ask this object for -----------------------
def rating_w(self) -> float:
"""The largest magnitude this unit can be commanded, in W.
`min(max_w, |dev_min|, |dev_max|)` - the supervised cap and whatever the
device itself reports, whichever is smaller. It is the same conjunction
`freeze.rail_ceiling_w()` computes for the freeze rail and the same one
main.py hands the peak guard as `achievable_w`, which is why it is
written once here: three transcriptions of a rating is three chances for
inverter B to be graded against inverter A's number.
A bound that could not be read is SKIPPED, not treated as zero: (None,
None) means ESPHome was not up, and answering 0 W there would say this
inverter can do nothing.
"""
out = abs(float(self.max_w))
for bound in (self.dev_min, self.dev_max):
if bound is not None and math.isfinite(float(bound)):
out = min(out, abs(float(bound)))
return out
def has_writer(self) -> bool:
return bool(self.setpoint_entity)
# -- liveness ------------------------------------------------------------
def note_write(self, ok: bool, now: datetime) -> None:
"""Record one write's verdict. The only thing that moves liveness."""
self.last_write_ok = bool(ok)
if ok:
self.rejected_since = None
elif self.rejected_since is None:
self.rejected_since = now
def is_live(self, now: datetime, stale_s: float = LINK_STALE_S):
"""`(live, why_not)` - may this controller command this unit THIS TICK?
Read the module docstring before widening this - in particular on why a
unit with NO `setpoint_entity` is deliberately still "live" here and is
refused one layer up instead. The allocator DROPS what `live_units()`
does not list and the survivors carry the whole site target, so a false
"not live" is a site target divided among fewer inverters than exist -
and at a roster of one it is the allocator refusing outright and the
site commanding 0 W.
⚠️ ANSWERED PER TICK, NEVER CACHED, for `allocator.plan()`'s reason: a
link goes stale between one cycle and the next.
"""
if self.rejected_since is not None:
held = (now - self.rejected_since).total_seconds()
if held >= float(stale_s):
return False, (f"every write rejected for {held:.0f} s "
f"(limit {float(stale_s):.0f} s)")
return True, ""
def link_note(self, now: datetime) -> str:
"""One line for the status page. Empty when there is nothing to say."""
if not self.has_writer():
return (f"{self.name}: NO SETPOINT ENTITY - this controller cannot "
f"command it, and the site refuses rather than running at "
f"part strength")
live, why = self.is_live(now)
if not live:
return f"{self.name}: NOT COMMANDABLE - {why}"
if self.rejected_since is not None:
held = (now - self.rejected_since).total_seconds()
return (f"{self.name}: writes rejected for {held:.0f} s - still "
f"commandable until {LINK_STALE_S:.0f} s")
return ""
def __repr__(self) -> str:
return (f"<Inverter {self.name} sp={self.setpoint_entity!r} "
f"max_w={self.max_w:.0f} dev=({self.dev_min}, {self.dev_max})>")
def build_roster(opts: dict, store, sign_cfg=None) -> list:
"""The site's inverters, in roster order. TODAY THAT IS EXACTLY ONE.
⚠️ INVERTER B DOES NOT EXIST YET (~2 weeks out) AND THIS FUNCTION MUST NOT
INVENT IT. There is no `inverters:` list in config.yaml, no second
`setpoint_entity`, and no measured rating, ramp or lag for a second device -
SPIKE-04 measures those and CFG-04 shapes the option that carries them. What
this card delivers is that the CODE no longer assumes there is one; adding
the second entry is a config change to THIS FUNCTION and to nothing else,
which is the point.
So: unit A is built from the flat options, exactly as the shipped scalars
were, and the flat options keep their names. An install that upgrades reads
identically - same entities, same rating, same behaviour - which is AC 8.
"""
return [Inverter(
UNIT_A,
setpoint_entity=opts.get("setpoint_entity", ""),
soc_entity=opts.get("soc_entity", ""),
batt_entity=opts.get("batt_entity", ""),
batt_invert=bool(opts.get("batt_invert")),
max_w=float(opts.get("max_w", 2000)),
store=store, sign_cfg=sign_cfg)]
File diff suppressed because it is too large Load Diff
+197
View File
@@ -39,6 +39,21 @@ _LOG = logging.getLogger("goodwe.store")
# commanding -1000, -2000 W before re-tripping 180 s later. Under a supervisor
# that restarts a crashed add-on that is a loop, three minutes of wrong-direction
# power at a time, forever.
#
# ⚠️ AND SINCE DEV-02 THE TWO SAFETY-02 KEYS ARE THE *LEGACY* SPELLING. They are
# FLAT - one `sign_tripped` for a controller that now holds a LIST of inverters -
# so a trip caused by inverter B would latch inverter A across restarts, and one
# operator action would clear both. The LIVE latch is per inverter, under
# `unit/<name>/...`, and that is the one the watchdog is restored from.
#
# ⚠️ THE FLAT PAIR IS NOT DEAD, THOUGH: it is the ROLLBACK VIEW. It is written
# alongside the namespaced keys on a trip and cleared alongside them on the
# operator's clear, so an add-on that is DOWNGRADED past this schema still finds
# the latch it should - and so that a trip taken while the install was rolled
# back is still visible to the version that comes back up. Rule 2 of
# `migrate_unit_keys()` and `Controller._grade_store_migration()` carry the
# argument; the short version is that a latch is only worth persisting if it
# survives the version change an operator makes while staring at the banner.
DEFAULTS = {
"phase": "idle",
"phase_started": None,
@@ -48,6 +63,188 @@ DEFAULTS = {
"sign_reason": "",
}
# --------------------------------------------------------------------------- #
# DEV-02: PER-INVERTER KEYS, AND THE MIGRATION IS THE RISKY HALF
#
# ⚠️ A LATCH THAT SILENTLY FAILS TO MIGRATE IS A DISARMED SAFETY GUARD ON AN
# INSTALL THAT BELIEVES IT IS PROTECTED. SAFETY-02's whole reason for being
# persisted is that "restart the add-on" is the FIRST thing an operator staring
# at a FAILSAFE banner tries, and a latch held only in memory comes back armed,
# into the fault, unattended. A latch that is written down under a key nothing
# reads any more is exactly the same failure with a longer paper trail: the file
# still says `sign_tripped: true`, the banner is green, and the controller
# resumes driving the wrong way.
#
# So this is a MIGRATION WITH A POSTCONDITION, not a copy. `migrate_unit_keys()`
# reports what it did AND reads the result back; `Controller.__init__` grades
# that report and refuses to come up quietly if the latch did not survive. The
# test that grades it BREAKS the migration and watches the guard fire.
# --------------------------------------------------------------------------- #
# 0 = the flat, one-inverter layout that shipped before DEV-02.
# 1 = per-inverter keys under `unit/<name>/...`.
SCHEMA_VERSION = 1
SCHEMA_KEY = "schema_version"
# Which flat keys belong to ONE inverter, and therefore move into its namespace.
# ⚠️ THE MAINTENANCE KEYS ARE NOT IN HERE AND THAT IS DELIBERATE. `phase`,
# `phase_started`, `last_completed` and `last_start_attempt` belong to the
# maintenance state machine, of which this add-on still holds exactly ONE - a
# monthly recalibration is a pack procedure and per-pack SCHEDULING is a card of
# its own, not something to infer from a key rename. Moving them here would
# quietly re-home an install's whole maintenance history under a unit name; they
# stay where they are, untouched, which is the other half of "migrates without
# losing its latch OR ITS HISTORY".
LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")
def unit_key(unit: str, key: str) -> str:
"""The namespaced Store key for `key` on inverter `unit`.
⚠️ ONE SPELLING, IN ONE PLACE. `Inverter.key()` calls this and so does the
migration, so a check can assert that what was migrated is what is read.
Two transcriptions of a key name is one copy plus a future disagreement
about which one holds the latch - and the copy that drifts is always the one
that reads.
"""
return f"unit/{unit}/{key}"
class MigrationReport:
"""What `migrate_unit_keys()` did, and whether it can be believed.
⚠️ IT CARRIES `verified`, WHICH IS READ BACK FROM THE STORE RATHER THAN
ASSUMED FROM THE ASSIGNMENT. That is the difference between this and a
two-line copy: the caller is able to ask "is the latch that was in the file
before now under the key the watchdog reads", and a `False` there is a
disarmed guard, which is an ERROR and not a log line nobody sees.
"""
def __init__(self):
self.ran = False # did anything need moving
self.from_version = 0
self.moved = {} # new key -> value written
self.kept = [] # keys already namespaced, left alone
self.verified = True # every move read back equal
self.problems = [] # human-readable, one per failed move
# ⚠️ THE SUBSET THAT MATTERS, AND THE DISTINCTION IS NOT PEDANTRY. A
# store that drops a write of `sign_tripped=False` has lost the
# DEFAULT - the same statement "absent" already makes, so nothing is
# gone. One that drops a write of `True` has lost A LATCH. Only the
# second is a disarmed safety guard, and only the second may be allowed
# to shout: an alarm that fires on the harmless case is an alarm an
# operator learns to dismiss before the real one arrives.
self.lost = []
@property
def ok(self) -> bool:
"""Nothing that CARRIED STATE failed to land."""
return not self.lost
def explain(self) -> str:
if not self.ran:
return (f"store already at schema {self.from_version}; no "
f"per-inverter migration needed")
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
f"namespaces: " + ", ".join(sorted(self.moved)))
if self.kept:
out += " (left alone, already namespaced: " + ", ".join(sorted(self.kept)) + ")"
if self.problems:
out += " - PROBLEMS: " + "; ".join(self.problems)
if self.lost:
out += " - STATE LOST: " + "; ".join(self.lost)
return out
def migrate_unit_keys(store, unit: str) -> MigrationReport:
"""Move the pre-DEV-02 flat per-inverter keys into `unit`'s namespace.
`unit` is the FIRST inverter in the roster, which on every install that can
possibly have a flat key is the only one it ever had. A store written by a
version that knew about more than one inverter is already at
SCHEMA_VERSION and is not touched.
THE THREE RULES, each one a case that would otherwise be a wrong answer:
1. IT NEVER OVERWRITES AN EXISTING NAMESPACED VALUE. A store that has both
spellings has been written by this version, and the namespaced one is
where the WATCHDOG is restored from. Preferring the flat one HERE would
resurrect a trip the operator has already cleared, because a migration
runs before anything can tell the two apart. Deciding between them is
main.py's job and it has the one rule that can: the flat key carries new
information only when NOTHING namespaced is latched.
2. THE FLAT KEYS ARE LEFT IN THE FILE, AND THEY ARE KEPT LIVE. Deleting
them buys tidiness and costs a rollback: an operator who downgrades the
add-on after a migration would find no latch at all, which is the
disarmed guard again in the other direction. Left in place, a downgrade
finds the trip it had.
⚠️ AND "LEFT IN PLACE" IS NOT ENOUGH ON ITS OWN, WHICH IS WHAT THIS RULE
USED TO SAY. The sentence here read "they go stale from this moment -
nothing writes them again - and the direction that staleness fails in is
a SPURIOUS latch, never a missing one". That was true of the latch this
migration MOVES and false of every trip that happens AFTERWARDS: nothing
flat was written for those, so a rollback found `sign_tripped: false` and
came up DRIVING. So main.py mirrors both keys on a trip and clears both
on the operator's clear. The flat pair is the ROLLBACK VIEW of the latch,
maintained, not a fossil - which is also what lets main.py's
`_grade_store_migration()` run on every start and catch a trip that
happened while the install was rolled back (see its docstring: the flat
key saying tripped while no inverter came up tripped is the one
disagreement that can occur, and it is a DOWNGRADE-WINDOW LATCH).
3. THE VERSION MARKER IS WRITTEN LAST. A crash between the copies and the
marker leaves the migration to run again, and rule 1 makes running it
again a no-op. A marker written first would leave a half-migrated store
looking finished.
"""
rep = MigrationReport()
data = getattr(store, "data", {})
rep.from_version = int(data.get(SCHEMA_KEY, 0) or 0)
if rep.from_version >= SCHEMA_VERSION:
return rep
rep.ran = True
for legacy in LEGACY_UNIT_KEYS:
target = unit_key(unit, legacy)
if target in data:
rep.kept.append(target)
continue
# ⚠️ `DEFAULTS[legacy]` RATHER THAN A LITERAL. An install that never
# tripped has no `sign_tripped` in its file at all, and the value it
# then lands on has to be the one `Store.load()` would have supplied -
# otherwise "absent" and "never tripped" stop being the same statement,
# which is the distinction store.py's own header is about.
value = data.get(legacy, DEFAULTS.get(legacy))
store.set(target, value)
rep.moved[target] = value
# THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION. This
# is the difference between this function and a two-line copy: the caller
# can ask "is the latch that was in the file before now under the key the
# watchdog reads", through the same `unit_key()` the watchdog's constructor
# reads through, and a `False` there is a guard that is off on an install
# whose state file says it is on.
for target, value in rep.moved.items():
got = getattr(store, "data", {}).get(target, "<<missing>>")
if got == value:
continue
rep.verified = False
note = f"{target} was written as {value!r} and reads back as {got!r}"
rep.problems.append(note)
legacy = target.rsplit("/", 1)[-1]
if value != DEFAULTS.get(legacy):
rep.lost.append(note)
if rep.ok:
store.set(SCHEMA_KEY, SCHEMA_VERSION)
else:
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
# next start rather than be recorded as finished - the one thing worse
# than a failed migration is a failed migration a later version skips.
_LOG.error("per-inverter store migration LOST STATE and has not been "
"marked done: %s", rep.explain())
return rep
class Store:
def __init__(self, path: str = "/data/state.json"):
+58 -19
View File
@@ -672,29 +672,68 @@ check("...and the broadcast export overshoot at gain 0.6 - the half that has no
feedback="unit")[1:]) <= -400.0
and min(g for _t, g in close_loop(0.6, 0.5)[1:]) >= 0.0)
# ⚠️ THE ONE THAT DOES NOT HOLD, AND IT IS REPORTED RATHER THAN HIDDEN.
# ⚠️ THE ONE THAT DID NOT HOLD, AND DEV-02 IS WHAT CLOSED IT. This block was
# written as a DISCLOSURE - "the allocator is not the cause, and it is DEV-02's
# to close" - and it is kept, with its stimulus intact, because the defect it
# describes is exactly the thing a regression would restore. What has changed is
# only which of the two rows the SHIPPED add-on is on.
#
# THE DEFECT. There was one `batt_entity`, so at a roster of two `actual_w` was
# ONE unit's power used as a site measurement - the SENSING half of the same
# N=1 identity ALLOC-01 removed from the COMMANDING half. `compute()`'s
# anti-windup test is `abs(prev_w - actual_w) > saturation_w` with `prev_w` the
# SITE target, so that comparison put a site number against a unit number,
# tripped the saturation freeze permanently and left a standing grid error.
#
# MEASURED, on admin/goodwe's own two-master rig rather than only here
# (`tools/alloc_sim.py --splits --feedback unit`, 2 kW load, 40 cycles):
#
# gain 0.2 worst |delta| between splits 510 W
# gain 0.4 250 W
# gain 0.6 50 W
# gain 0.8 and above 0 W
#
# and with `--feedback site` - the counterfactual, which is what DEV-02 makes
# the shipped sensing - every split is bit-identical at every one of those
# gains. Both rows are still run below, because the comparison is the evidence.
_unit_fb = {s: close_loop(0.6, s, feedback="unit")
== close_loop(0.6, 0.5, feedback="unit") for s in _splits}
print(f" with the loop fed ONE inverter's power instead of the site's, at "
f"gain 0.6: " + ", ".join(f"{int(s*100)}/{int((1-s)*100)}="
f"{'same' if v else 'DIFFERS'}"
for s, v in _unit_fb.items()))
check("⚠️ SPLIT-INVARIANCE IS A PROPERTY OF THE LOOP'S INPUTS, NOT ONLY OF THE "
"ALLOCATOR, AND THE SHIPPED ADD-ON BREAKS IT FROM THE OTHER END. There is "
"ONE `inverter_power` sensor, so at a roster of two `actual_w` is ONE "
"unit's power used as a site measurement - the SENSING half of the same "
"N=1 identity this card removes from the COMMANDING half. Feed the loop "
"that and the aggregate trace becomes a function of the split. The "
"allocator is not the cause: its shares sum to S in every one of these "
"runs. Measured on the real rig too (tools/alloc_sim.py --splits "
"--gain 0.6), and it is DEV-02's to close",
not all(_unit_fb.values()))
check("...and the counterfactual is what says the sensor is the cause rather "
"than the allocator: change that ONE input to the site total and every "
"split is bit-identical again, at every gain",
print(f" with the loop fed ONE inverter's power - the PRE-DEV-02 sensing - "
f"at gain 0.6: " + ", ".join(f"{int(s*100)}/{int((1-s)*100)}="
f"{'same' if v else 'DIFFERS'}"
for s, v in _unit_fb.items()))
_worst_unit = max(
max(abs(a[1] - b[1]) for a, b in zip(close_loop(g, s, feedback="unit"),
close_loop(g, 0.5, feedback="unit")))
for g in (0.2, 0.4, 0.6) for s in _splits)
print(f" worst split-dependence under that sensing, gains 0.2-0.6: "
f"{_worst_unit:.0f} W at the meter")
check("⚠️ THE DEFECT IS STILL REPRODUCIBLE, WHICH IS WHAT MAKES THE FIX BELOW "
"MEAN SOMETHING. Fed ONE inverter's power the aggregate trace at the "
"meter is a function of the split, by hundreds of watts below gain 1.0. "
"The allocator is not the cause - its shares sum to S in every one of "
"these runs - and a check that had DELETED this row when the sensing was "
"fixed would leave the next reader unable to tell a working loop from an "
"untested one",
not all(_unit_fb.values()) and _worst_unit > 100.0)
check("⚠️ AND DEV-02 PUT THE SHIPPED ADD-ON ON THE OTHER ROW: `actual_w` is now "
"the SITE SUM of the per-instance battery readings "
"(`Controller.site_batt_w`, pinned at main.py's call site by "
"test_inverter.py part 5), and every split is bit-identical again at "
"every gain. This is the line that goes red if anyone hands the loop one "
"unit's power again - which is the tempting shape, because it is what a "
"single `batt_entity` gives you",
all(close_loop(g, s, feedback="site") == close_loop(g, 0.5,
feedback="site")
for g in (0.2, 0.4, 0.6, 1.0) for s in _splits))
for g in (0.05, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0) for s in _splits))
check("...and the fix is not vacuous in the other direction either: the SHARES "
"still differ between splits under site feedback, so 'bit-identical at "
"the meter' is a statement about the aggregate and not about an allocator "
"that stopped allocating",
close_loop(0.6, 0.9, feedback="site")
== close_loop(0.6, 0.5, feedback="site")
and plan(2000.0, TWO, weights={A: 0.9, Bee: 0.1}).shares
!= plan(2000.0, TWO, weights={A: 0.5, Bee: 0.5}).shares)
# ==============================================================================
+27 -7
View File
@@ -84,6 +84,7 @@ from app.clamp import (
from app.allocator import Allocation, UnitPlan, plan
from app.arbiter import P_LOOP, Claim, UnitBounds, allocate, resolve
from app.control import Tuning, compute
from app.inverter import Inverter
from app.main import Controller
fails = []
@@ -782,13 +783,21 @@ class _RefactoredController(Controller):
Neither changes when the guard runs relative to the write, so every check in
`evidence_checks()` must stay green. If one of them goes red here, the check
is grading the text of main.py and has to be thrown away.
⚠️ THE SIGNATURE GAINED `unit=` IN DEV-02 AND THIS FIXTURE FOLLOWED IT, which
is the honest update and not a weakening: `write_setpoint()` now addresses ONE
inverter (or, with `unit=None`, commands the whole site to 0 W), because a
controller with a roster cannot have one write path that means "the
inverter". A delegate that dropped the argument would silently re-broadcast
at N>=2, so it is passed through here exactly as a real refactor would have
to pass it through.
"""
async def _emit_setpoint(self, value, *, force=False):
await Controller.write_setpoint(self, value, force=force)
async def _emit_setpoint(self, value, *, force=False, unit=None):
await Controller.write_setpoint(self, value, force=force, unit=unit)
async def write_setpoint(self, value, *, force=False):
await self._emit_setpoint(value, force=force)
async def write_setpoint(self, value, *, force=False, unit=None):
await self._emit_setpoint(value, force=force, unit=unit)
async def _clamp_guard(self):
await Controller.check_output_clamp(self)
@@ -1102,9 +1111,20 @@ async def _failsafe_wire():
"max_w": LIVE_MAX_W, "soc_entity": "sensor.soc",
"batt_entity": "sensor.batt", "meter_entity": "sensor.grid"},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.units = (A_UNIT, B_HYPO)
# ⚠️ DEV-02: THE ROSTER IS GROWN BY ADDING AN `Inverter`, NOT BY ASSIGNING
# `units`. `Controller.units` is now DERIVED from `Controller.inverters` and
# has no setter, because a settable roster is a site that claims two
# actuators while holding one writer - a half-driven site with nothing to
# say so. The object carries the writer, which is what makes this fixture a
# roster of two rather than a tuple of two names.
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W # while the roster is one
_b = Inverter(B_HYPO, setpoint_entity="number.sp_b",
soc_entity="sensor.soc", batt_entity="sensor.batt",
max_w=LIVE_MAX_W, store=c.store)
_b.dev_min, _b.dev_max = -LIVE_MAX_W, LIVE_MAX_W
_b.soc, _b.batt = 50.0, 0.0
c.inverters.append(_b)
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
for _ in range(3):
await c.read_inputs()
await c.cycle()
@@ -1117,7 +1137,7 @@ print(f" allocator refused every tick "
check("the allocator having failed, the 0 W failsafe still REACHES THE WIRE - "
"this is the deadlock the gate must not have, driven through the real "
"cycle() and its real refusal branch rather than asserted",
not ctl_fs.allocation.ok and hass_fs.writes == [0.0]
not ctl_fs.allocation.ok and hass_fs.writes == [0.0, 0.0]
and ctl_fs.target == 0.0)
check("...and it was the ALLOCATOR's refusal that produced it, not the gate's "
"- if the gate were the thing zeroing this cycle the action log would "
File diff suppressed because it is too large Load Diff
+299 -73
View File
@@ -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,57 @@ 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 HYPOTHESIS: 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
# whose number each per-unit bound is built from.
#
# ⚠️ AND IT IS 3000 W RATHER THAN A's 5000 W, WHICH IS NOT COSMETIC. THE RULE,
# STATED AT THE LINE THAT NEEDED IT: ANY CHECK ASSERTING "X DIFFERS FROM Y" MUST
# BE RUN ONCE WITH X AND Y ACTUALLY MADE DIFFERENT. This helper used to default
# B's rating and device range to A's `LIVE_MAX_W`, and the AC 4 check below
# compared inverter B's `supervised` bound against `_b2.max_w` - which was
# LITERALLY THE SAME NUMBER as A's. It read as evidence and observed nothing:
# filing both per-unit claims from `self.inverters[0]`'s numbers instead of
# `inv`'s left all 19 files green. That is the third check this session whose
# DATA could not reach the failure it named (ARB-03's call-site scan saw 3 of 8
# sites; ALLOC-01's replay generator saw 0 escapes where a widened one saw
# 39,991), so the number is different HERE, once, for every fixture in the file.
B_RATING_HYPOTHESIS = LIVE_MAX_W - 2000.0
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()."""
rating = B_RATING_HYPOTHESIS if max_w is None else max_w
inv = Inverter(name, setpoint_entity=setpoint, soc_entity="sensor.soc_b",
batt_entity="sensor.batt_b", max_w=rating, store=ctl.store)
inv.dev_min, inv.dev_max = dev or (-rating, rating)
# 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 +2630,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 +2685,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 +2725,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 +2754,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 +2775,73 @@ 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"})
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
# checks above grade WHICH ACTUATOR each claim lands on. They say nothing about
# WHOSE NUMBERS the bound is built from - and those are two different defects
# with two different fixes. Filing the per-unit `device` and `supervised` claims
# from `self.inverters[0]`'s numbers while leaving `actuator=self.actuator_of(
# ..., inv)` alone routes every claim correctly and bounds inverter B by
# inverter A's rating: at A=5000/B=3000 the planned envelope for inverter-b
# becomes (-5000, 5000) instead of (-3000, 3000), which is a 2000 W
# over-command the arbiter believes is supervised.
#
# ⚠️ THE RULE THIS LINE EXISTS TO OBEY: ANY CHECK ASSERTING "X DIFFERS FROM Y"
# MUST BE RUN ONCE WITH X AND Y ACTUALLY MADE DIFFERENT. The version this
# replaces compared inverter B's bound against `_b2.max_w` while
# `second_inverter()` defaulted `max_w` to A's `LIVE_MAX_W` - so it compared
# 5000 against 5000, and the mutation above left all 19 files GREEN. The
# PRECONDITION below is what stops that happening again: it fails if the two
# ratings are ever made equal, so the check cannot go quietly vacuous.
_sup2 = {c.actuator: (c.lo, c.hi) for c in _cl2 if c.source == "supervised"}
_dev2 = {c.actuator: (c.lo, c.hi) for c in _cl2 if c.source == "device"}
print(f" supervised bounds by actuator: {_sup2}; device bounds: {_dev2}")
check("PRECONDITION, AND IT IS THE WHOLE POINT: A AND B HAVE DIFFERENT "
"RATINGS. A check that 'B's bound is B's own number' cannot observe "
"anything while B's number IS A's number, and this file shipped that "
"check for a whole review round",
_b2.max_w != c_src.inverters[0].max_w
and (_b2.dev_min, _b2.dev_max)
!= (c_src.inverters[0].dev_min, c_src.inverters[0].dev_max))
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, and it is now stated "
"against LITERAL, DIFFERENT numbers rather than against an expression "
"that could be reading the same object twice: B is bounded at "
f"+/-{B_RATING_HYPOTHESIS:.0f} W and A at +/-{LIVE_MAX_W:.0f} W",
_sup2 == {"inverter-a": (-LIVE_MAX_W, LIVE_MAX_W),
"inverter-b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS)})
check("...and so is each unit's `device` bound, which is the other number the "
"same loop files and the one that was NEVER READ before this card - one "
"`dev_min`/`dev_max` pair came off one `setpoint_entity`, so a second "
"inverter's reported range bound nothing at all",
_dev2 == {"inverter-a": (-LIVE_MAX_W, LIVE_MAX_W),
"inverter-b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS)})
check("...and the bounds the ARBITER RESOLVED for inverter B - not just the "
"claims filed at it - are B's own, because a claim filed correctly and "
"then resolved against the wrong envelope would be the same defect one "
"layer down",
_n2_alloc is not None
and next((p for p in _n2_alloc.plans if p.unit == "inverter-b"), None)
is not None
and (lambda p: p.lo == -B_RATING_HYPOTHESIS
and p.hi == B_RATING_HYPOTHESIS)(
next(p for p in _n2_alloc.plans if p.unit == "inverter-b")))
# --- 10h. ALLOC-01: WHAT MAIN.PY DOES WITH AN ALLOCATION IT CANNOT DELIVER ---
@@ -2711,13 +2865,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 +2896,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 +3010,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 +3026,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 +3061,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,))
+39 -9
View File
@@ -278,6 +278,18 @@ print("end to end: the trip is COMMAND ZERO, not alarm-only")
import asyncio # noqa: E402
from app.main import Controller # noqa: E402
# ⚠️ DEV-02. THE KEY SPELLING IS IMPORTED, NEVER TRANSCRIBED. `sign_tripped` and
# `sign_reason` are now NAMESPACED PER INVERTER - flat, a trip caused by B
# latched A across every restart and one operator press cleared both. A check
# that spelled `"unit/inverter-a/sign_tripped"` by hand here would go on passing
# against a main.py that had started writing somewhere else, which is this
# repository's own named failure mode: the copy that drifts is always the
# grading one. `unit_key()` is the one definition and `Inverter.key()` calls it.
from app.inverter import UNIT_A # noqa: E402
from app.store import unit_key # noqa: E402
TRIPPED_KEY = unit_key(UNIT_A, "sign_tripped")
REASON_KEY = unit_key(UNIT_A, "sign_reason")
class _Hass:
@@ -430,9 +442,24 @@ async def _restart():
pre, post, store, hass2 = asyncio.run(_restart())
check("it tripped before the restart", pre.sign_wd.tripped)
check("the trip reached the store", store.data["sign_tripped"] is True)
check("the trip reached the store, UNDER THE TRIPPED INVERTER'S OWN KEY",
store.data[TRIPPED_KEY] is True)
check("...with the reason beside it, so the operator is told why",
"SIGN_DISAGREEMENT" in store.data["sign_reason"])
"SIGN_DISAGREEMENT" in store.data[REASON_KEY])
check("⚠️ ...AND THE FLAT PRE-DEV-02 KEY IS MIRRORED, AS THE ROLLBACK VIEW. "
"This assertion used to read the other way - that the flat key was NOT "
"written - on the argument that writing it would latch every other "
"inverter on the site. That argument was right about the READ and wrong "
"about the WRITE: nothing at runtime reads the flat key, and the one "
"thing that does (`_grade_store_migration()`, on every start) ignores it "
"whenever ANY namespaced latch is set, which a sibling's trip always "
"leaves. What the absence actually cost was the ROLLBACK: store.py's "
"rule 2 keeps this key so a downgrade finds the latch, and a trip taken "
"after the migration wrote nothing here - so an operator who downgraded "
"came up DRIVING. `test_inverter.py` part 3 is where 'B's trip does not "
"latch A across a restart' is graded, on a real roster of two",
store.data.get("sign_tripped") is True
and "SIGN_DISAGREEMENT" in str(store.data.get("sign_reason", "")))
check("the new Controller comes back TRIPPED", post.sign_wd.tripped)
check("...and says why, restored rather than re-detected",
"SIGN_DISAGREEMENT" in post.sign_wd.reason)
@@ -447,11 +474,14 @@ check("the banner still leads with the failsafe",
# a one-press clear would be reachable by an operator who never read the banner.
asyncio.run(post.handle_action("auto_toggle")) # -> stopped
check("pressing stop does not clear the failsafe",
post.sign_wd.tripped and store.data["sign_tripped"] is True)
post.sign_wd.tripped and store.data[TRIPPED_KEY] is True)
asyncio.run(post.handle_action("auto_toggle")) # -> started
check("pressing start DOES clear it, and clears the store with it",
not post.sign_wd.tripped and store.data["sign_tripped"] is False
and store.data["sign_reason"] == "")
check("pressing start DOES clear it, and clears the store with it - BOTH "
"spellings, which is what stops the every-start latch guard re-arming a "
"failsafe the operator has already dealt with",
not post.sign_wd.tripped and store.data[TRIPPED_KEY] is False
and store.data[REASON_KEY] == ""
and store.data.get("sign_tripped") is False)
# One more restart from the cleared store, to prove the clear persisted too.
fresh = Controller({"setpoint_entity": "number.sp", "meter_source": "off"},
_Hass(), store, _P(), stop=asyncio.Event())
@@ -473,7 +503,7 @@ check("...so the next restart comes back armed and untripped, as it should",
class _CrashOnFlag(_MemStore):
def set(self, k, v):
if k == "sign_tripped" and v:
if k == TRIPPED_KEY and v:
raise RuntimeError("power cut, mid-trip")
super().set(k, v)
@@ -503,9 +533,9 @@ async def _crash_mid_write():
crashed_store, died = asyncio.run(_crash_mid_write())
check("the second write is the one that sets the flag", died)
check("a crash between the two writes leaves the REASON on disk",
"SIGN_DISAGREEMENT" in crashed_store.data.get("sign_reason", ""))
"SIGN_DISAGREEMENT" in crashed_store.data.get(REASON_KEY, ""))
check("...and never a latch with nothing to explain it",
not crashed_store.data.get("sign_tripped"))
not crashed_store.data.get(TRIPPED_KEY))
print()
if fails:
+29 -15
View File
@@ -6,6 +6,11 @@
python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split
python3 tools/alloc_sim.py --all
# DEV-02: the split-invariance finding ALLOC-01's review handed on, and the
# sensing change that closed it. The two rows are the evidence.
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback unit # 250 W apart
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback site # identical
WHY THIS IS A SCRIPT AND NOT A `test_*.py`
==========================================
It needs admin/goodwe checked out beside this repository, and the add-on's CI
@@ -121,20 +126,28 @@ def run_site_feedback(sitesim, control, mode, gain, load_w=2000.0, cycles=40,
cycle_s=None, dt=0.05):
"""`sitesim.run_site()` WITH EXACTLY ONE LINE CHANGED, and it is named here.
⚠️ THIS IS A COPY AND IT IS A COPY ON PURPOSE. `run_site` feeds `compute()`
ONE inverter's power as `actual_w`, and its docstring says why: the shipped
add-on binds a single `inverter_power` sensor, so an operator who wires up a
second inverter has a loop that sees unit A only, and a rig that fed the sum
would be KINDER than the deployment. That is correct for grading the
broadcast fault and it is exactly the variable this experiment has to move,
because it is the one input to the loop that is a function of the SPLIT.
⚠️ THIS IS A COPY AND IT IS A COPY ON PURPOSE. `run_site` lives in
admin/goodwe, which this file reads and never writes, and it feeds
`compute()` ONE inverter's power as `actual_w` because that is what the
add-on gave it: a single `batt_entity`, so an operator who wired up a second
inverter had a loop that saw unit A only. A rig that fed the sum would have
been KINDER than the deployment.
So this differs from `run_site` in one expression - `site.battery`, the
site's total inverter power, instead of `inverters[0].plant.actual` - and in
nothing else. It is not evidence about the shipped add-on; it is the
counterfactual that says WHICH of the two candidate causes explains the
split-dependence that `--splits` measures at gain 0.6. The add-on does not
have this sensor yet. DEV-02 is where it would come from.
nothing else.
⚠️ AND SINCE DEV-02 THAT ONE EXPRESSION IS THE SHIPPED ONE, WHICH TURNS THIS
FUNCTION FROM A COUNTERFACTUAL INTO THE MODEL. `Controller.site_batt_w` is
the SUM of the per-instance battery readings and is what main.py hands
`compute()`; `--feedback site` is therefore the add-on as it now stands, and
`--feedback unit` is the PRE-DEV-02 sensing, kept because the comparison is
the evidence. Measured here, 2 kW load, 40 cycles: fed one unit's power the
aggregate trace at the meter is split-dependent by 510 W at gain 0.2, 250 W
at 0.4 and 50 W at 0.6; fed the site total every split is bit-identical at
every gain. `run_site` still models the old sensing and must not be
"corrected" to the new one - the row it produces is what the fix is measured
against.
"""
from plantsim import INVERTER_A, INVERTER_B, Inverter, Site
cycle_s = sitesim.CYCLE_S if cycle_s is None else cycle_s
@@ -240,10 +253,11 @@ def main():
ap.add_argument("--load", type=float, default=2000.0)
ap.add_argument("--gain", type=float, default=1.0)
ap.add_argument("--feedback", choices=("unit", "site"),
default="unit",
help="what the loop is fed as actual_w: one "
"inverter's power (the shipped add-on) or "
"the site total (the counterfactual)")
default="site",
help="what the loop is fed as actual_w: the site total "
"(the shipped add-on since DEV-02, the default here) "
"or one inverter's power (the PRE-DEV-02 sensing, "
"kept because the comparison is the evidence)")
a = ap.parse_args()
if not (a.table or a.splits or a.sweep):
a.all = True