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