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
|
||||
|
||||
Reference in New Issue
Block a user