Merge pull request 'MAINT-05: one recalibration per pack, and only one pack moves power at a time' (#47) from MAINT-05 into release/1.0

This commit was merged in pull request #47.
This commit is contained in:
2026-09-04 21:39:25 +02:00
10 changed files with 2919 additions and 175 deletions
+116
View File
@@ -2,6 +2,122 @@
## Unreleased
**Every pack is recalibrated on its own schedule, and only one moves power at a
time (MAINT-05).** §12's monthly cycle drains a pack to empty and refills it to
true its coulomb counter. There was exactly one `Maintenance` state machine and
one un-namespaced history, so at two packs **only the first was ever
recalibrated** and the second's counter drifted forever — and nothing prevented
the cycle draining pack A while the loop or arbitrage charged pack B, which is
two power-moving phases on one grid connection pulling against each other.
* **ONE `Maintenance` PER ROSTER ENTRY**, each with its own schedule, phase and
history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
existing install's flat history into the first pack's namespace under
`migrate_unit_keys()`'s three rules — shared in code, not restated — with the
same read-back postcondition, and the flat keys are kept **live** as the
rollback view so a downgrade finds a current history rather than one dated
migration day.
* **ONE MAINTENANCE SLOT PER SITE.** At most one pack may be in a power-moving
phase (`drain`, `charge` **and** `hold` — `charge` is in the set because a
pack pulled full from the grid beside a pack exporting to empty is 4 kW
circulating with the meter at zero). The holder is **derived** from the packs'
own stored phases, not persisted, so a restart mid-drain re-derives it. When
several packs are due in the same hour the slot goes to the one that has gone
**longest without a completed cycle**; roster order breaks a numeric tie and
nothing else. Nothing preempts a cycle in flight.
* **THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT.** A deferral does not
stamp `last_start_attempt` (that is the 90 min post-abort cooldown, inside a
60 min start hour), and does not move the phase, so neither the 12 h `drain`
nor the 36 h `charge` timeout — *"the battery is not following commands"* —
can be reached by waiting. It says WAITING, names the pack it waits for, and
recalibrates at the next start hour; a test drives the winner's whole cycle
and observes it start.
* **THE SCHEDULE IS NOT STAGGERED BY CONSTRUCTION.** The interlock defers and
the stagger is what comes out of it: one contested month separates two packs
by one cycle length and they stay separated. `DECISIONS/MAINT-05-per-pack-maintenance.md`
§3 carries the argument.
* **`validate.py` rule 6 now grades `maintenance_charge_w` against the WEAKEST
cap on the roster**, like rule 5. Its own comment said it would have to move
the day `maint_unit` stopped being `inverters[0]`; that day is this card. It
refuses configurations that used to start — a 3000 W charge beside a 2000 W
pack that is now recalibrated too — which is the `lo > hi` contradiction rule
6 exists to refuse, caught when the config is written. Identical at N=1.
* **AT A ROSTER OF ONE, NOTHING CHANGES**, and it is asserted rather than
assumed: the state file keeps the flat keys and gains none, the migration does
not run, the four cycle events are the shipped strings character for
character, and the status banner is the bare phase with no pack name.
* **THE MIGRATION IS GRADED AGAINST THE ROSTER, NOT AGAINST THE NAME IT WAS
HANDED** (fix round). `migrate_maint_keys()` takes the roster rather than a
pack count and `_verify_moves()` checks the namespace it wrote against the
names the machines actually read. A postcondition that reads back the
namespace it was given cannot detect being given the wrong one: handed
`inverter.UNIT_A` on a roster of `["garage", "shed"]`, the first version of
this card wrote all four keys under `unit/inverter-a/...`, read all four back
equal, reported **verified**, and stamped `maint_schema_version` — so both
packs came up believing they had never been recalibrated and the next start
skipped the migration too. That is now `misdirected`: not `ok`, the marker is
**not** written, and the next start re-runs and recovers (rule 1 makes the
re-run a no-op). `build_roster()` numbers a nameless roster entry
`inverter-1`, so `inverter-a` is not even the fallback for a declared roster.
Every two-pack fixture that could see this now names its first pack
`garage`; with them all called `inverter-a`, `self.inverters[0].name` and
`UNIT_A` were the same string and the swap left all 19 suites green.
* **AND THE REFUSAL NOW SURVIVES THE NEXT START** (second fix round). The fix
above graded only the keys it had just *written*, so it refused exactly once:
measured at Controller level over one persisted store, roster
`["garage", "shed"]`, with the call site still wrong on both starts —
construction 1 `ok=False misdirected=4 moved=4 kept=0 marker=<absent>`,
construction 2 `ok=True misdirected=0 moved=0 kept=4` and the marker
**stamped**. On the second start rule 1 finds the wrong keys already there and
routes all four to `kept`, so `moved` is empty and the roster loop grades
nothing — and start 3 skips the migration forever, *including after the call
site is fixed*. Loud-and-recoverable became silent-and-permanent after one
restart. `_verify_moves()` now grades `set(moved) | set(kept)`; both lists are
built from the same `unit` argument, so grading `kept` grades the same
namespace decision. `test_maintenance.py` part **9c** drives two starts with
the same wrong unit over one store — the shape nothing in the suite had, since
9b measured recovery along the path where the operator has *already* fixed the
call site.
* **AND THE MARKER GATE IS PINNED ON BOTH GUARDS.** `if rep.ok:` is
`not lost and not misdirected`, and the mutation `if rep.ok or rep.kept:`
stamped the marker on a migration that **lost a month of history** with all 19
suites green. Part **9d** is the fixture that separates the two guards: two
keys already namespaced (`kept` non-empty) and two whose write the store
drops (`lost` non-empty), `misdirected` empty throughout.
* ⚠️ **AND THE SAME MUTATION ONE FUNCTION UP WAS A SURVIVOR, FOUND HERE AND
CLOSED.** `migrate_unit_keys()` — SAFETY-02's **latch** migration — gates its
own marker on `rep.ok` too, and every check that reached that gate ran on a
store where nothing was namespaced yet, so `rep.kept` was empty in all of
them. `if rep.ok or rep.kept:` there stamps `schema_version` on a migration
that **dropped the latch**: the guard is off, the file still says
`sign_tripped: true`, and the next start skips the migration that would have
re-armed it. Measured a survivor of all 19 files; now red on
`test_inverter.py` part 10, which drops the `sign_tripped` write beside an
already-namespaced `sign_reason`. No source change — the shipped gate was
right; nothing could see it.
* ⚠️ **FOUND, RECORDED, NOT FIXED (SECOND ONE).** `migrate_unit_keys()` —
SAFETY-02's latch migration — has the identical hole and it costs a **guard**
rather than a history. It runs before `build_roster()` (each `Inverter`
restores its latch in its constructor), so it is handed the constant `UNIT_A`
and there is no roster to grade against. On a declared roster a pre-DEV-02
`sign_tripped: true` lands under `unit/inverter-a/sign_tripped`, which no unit
reads — and `_grade_store_migration()`, whose job is to re-arm exactly this,
is **defeated by the key the defect wrote**: its "is anything namespaced
latched" test is satisfied by the misdirected value itself. Fixing it means
naming the migration's target from the config before any `Inverter` exists,
which is `inverter.py` and SAFETY-02's semantics, with its own battery.
Measured and pinned as a running check (`test_maintenance.py` part 13), whose
assertions state the WRONG behaviour on purpose.
* ⚠️ **FOUND, RECORDED, NOT FIXED.** A maintenance drain **pins** its pack while
the loop's target is a **site** claim, so when the site target is below the
pin the only split asks the sibling to charge against the drain and
`allocator.plan()` refuses it — the site parks at 0 W and the pack's 12 h
timeout would eventually report *"drain stuck"* about a healthy battery.
Pre-existing on `release/1.0` at `2d025ee` with the unit names swapped; fixing
it means changing what the loop's target means while a unit is pinned, which
is loop and allocator territory. Pinned as a running check
(`test_maintenance.py` part 12) so it cannot be forgotten.
**At two inverters the sign watchdog stops comparing one unit against the whole
site meter, and becomes two checks instead of one (SAFETY-30).**
`disagrees(commanded_w, grid_w, ...)` compared the command **this controller put
@@ -0,0 +1,388 @@
# MAINT-05 — one recalibration per pack, one at a time, and what the card left open
**Status: DECIDED 2026-09-04.** There is one `Maintenance` per roster entry,
each with its own schedule, phase and history. The site holds exactly **one
maintenance slot**: at most one pack may be in a power-moving phase, the slot is
derived from the packs' own stored phases rather than persisted separately, and
when several packs are due in the same hour it goes to the one that has gone
**longest without a completed cycle**. The schedule is **not** staggered by
construction — the interlock defers, and the stagger emerges from it.
**Fix round 2026-09-04.** The store migration is now graded against the
**roster** rather than against the unit name it was handed — see §2's
*"The read-back postcondition is graded against the ROSTER"* — and the same
hole in SAFETY-02's latch migration is recorded in §7.
Filed with MAINT-05. Card `6a9a9e8503872905f298d80a`.
---
## 0. What the card asked to be decided, and the answers
| the card's open question | the decision |
|---|---|
| §2 the arbitration | **one pack at a time; the other stays under the loop.** Nothing preempts a cycle in flight. |
| §2 who wins when several are due | **the longest-waiting pack**, `last_completed` oldest first, `inf` for never-recalibrated. Roster order breaks a numeric tie and nothing else. |
| §4 staggering | **not by construction.** The interlock defers the second pack; the stagger is what comes out of it. §3 below. |
| AC 7 rule 6's `maint_unit` comment | **no, the first entry's cap is no longer right.** It grades against the weakest cap on the roster now, like rule 5. §4 below. |
| the store layout at N=1 | **flat, unchanged.** The namespace appears the day the roster grows. §2 below. |
## 1. Why the slot is derived and not persisted
The holder is *"whichever pack's stored phase is not `idle`"* — a fact already on
disk, written by the packs themselves in the per-pack history this card
introduces. A `maint_slot_holder` key would be a **second opinion about the same
thing**, and the two would disagree the first time the add-on was killed between
writing the phase and writing the holder. Derived, a controller that comes back
up mid-drain reads the drain and re-derives the holder from it; there is no
recovery path to get wrong because there is no second fact.
`CHARGE` is in `POWER_MOVING` even though the control **loop** owns the setpoint
in that phase, and that is the decision most likely to be "simplified" later.
Pack A charging from the grid at 2000 W while pack B drains 2000 W to export is
4 kW going round in a circle with the meter reading zero and both packs
believing they are behaving — the circulating power [[SAFETY-31]] exists to
detect, arriving as something this module can simply not do. `HOLD` is in the
tuple for the other half of the sentence: it moves nothing, and it **owns the
actuator**, so a second cycle starting under it would still be two
recalibrations at once.
## 2. Why the store layout at a roster of one does not change
The maintenance keys are flat — `phase`, `phase_started`, `last_completed`,
`last_start_attempt` — on every install since the add-on shipped. At one pack
there is nothing for a namespace to distinguish that pack **from**: §6.2's
identity, the same one `arbiter.actuator_for()` leans on when it routes every
claim to the site actuator at N=1. So `Maintenance` is constructed with
`unit=None` there, reads and writes the flat keys directly, and
`migrate_maint_keys()` **returns without writing anything**. A one-inverter state
file comes out of this card byte-identical, which is AC 6 and which
`test_maintenance.py` part 10 asserts by driving a whole cycle and comparing the
key set.
### The migration has its own marker, and that is not tidiness
`SCHEMA_VERSION` / `schema_version` is DEV-02's, and it is **monotone**: every
install runs that migration exactly once. This one runs **only where the roster
has more than one pack**, so a single counter could not express *"migrated on
this site, deliberately not migrated on that one"* without lying to the next
version that reads it — a one-inverter store would have to be pinned at the old
number forever, and a genuine future v2 migration could then never be told apart
from a site that simply never grew. `maint_schema_version` answers one question
and answers it honestly.
The **three rules are shared in code**, not restated: `_move_flat_keys()` is
rule 1 (never overwrite an existing namespaced value) and rule 2 (the flat keys
stay in the file), `_verify_moves()` is the read-back postcondition, and the
marker is written last. A second transcription of a migration is a second
opinion about what "it landed" means.
### The read-back postcondition is graded against the ROSTER (fix round)
**A postcondition that reads back the namespace it was handed cannot detect
being handed the wrong one.** That sentence is the whole finding, and this card
shipped it broken.
`_verify_moves()` iterated `rep.moved` — the keys it had just written — and
asked whether each read back equal. On a store where the write lands, it always
does. So a migration handed a `unit` that is on no roster wrote four keys, read
four keys back, set `verified = True`, and the caller stamped
`maint_schema_version`. Every symptom of a clean migration, and an install's
whole maintenance history parked under a name nothing will ever look up. And
because the marker was stamped, **the next start skipped the migration too** —
which is precisely the state `store.py`'s header says must never happen: a
failed migration a later version treats as done.
Reproduced on the reviewer's roster, a house with a garage pack and a shed pack,
carrying a flat `last_completed` of 2026-08-07:
| | `unit` the migration was handed | key written | pack 0 reads back | due now? |
|---|---|---|---|---|
| correct | `self.inverters[0].name` = `garage` | `unit/garage/last_completed` | `2026-08-07` | **False** |
| wrong | `inverter.UNIT_A` = `inverter-a` | `unit/inverter-a/last_completed` | `None` | **True** |
`build_roster()` names a declared roster's entries **from the config**, and its
fallback for a nameless entry is a **positional** `inverter-1` — so `inverter-a`
is not the name of anything on a declared roster, not even by default.
**The remedy, and why this one.** `migrate_maint_keys()` now takes the **roster**
in place of an `n_units` count (the count was `len(self.inverters)` at the only
call site, so the roster carries it, and a count passed beside the names is a
second opinion about how many packs there are). `_verify_moves()` grades the
namespace it wrote against those names, and a unit that is not among them is
reported as `misdirected` — a **different** failure from `lost`:
* `lost` is *the store took the write and dropped it* — a storage fault, and it
has a harmless-default case (a write of `last_completed: None` loses nothing,
because "absent" and "never recalibrated" are the same statement).
* `misdirected` is *the store did exactly as it was told, by a caller that was
wrong*. There is no harmless-default version of it: a write nobody reads is
not information, whatever its value. So anything in it fails `ok` on its own.
And **`ok` gates the marker**, so a misdirected migration is not stamped and the
next start runs it again. The argument for it is asymmetric: a re-run is
recoverable — rule 1 makes it a no-op where keys did land, and rule 2 left the
flat keys in the file to recover *from* — while a silent skip is not recoverable
at all, because the store now claims the migration happened.
### ⚠️ THE PARAGRAPH ABOVE USED TO END HERE, AND AS IT STOOD IT WAS FALSE
**The refusal lasted exactly one start. The second start stamped the marker.**
That is a correction to what the first fix round wrote down, not a new
qualification of it, and it is recorded here because a decision document that
states a guarantee the code does not provide is worse than one that says
nothing.
`_verify_moves()` graded only `rep.moved`. Measured at Controller level over
**one persisted store**, roster `["garage", "shed"]`, flat `last_completed`
2026-08-07, with the call site still handing `inverter-a` on **both** starts:
```
construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent> (withheld)
construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1 (STAMPED)
history parked under unit/inverter-a/…; marker stamped after ONE restart.
```
On start 2 the same wrong call site finds the four keys it wrote already
present, so **rule 1** — never overwrite a namespaced value — routes every one
of them to `rep.kept`. `rep.moved` is empty, the roster-grading loop iterates
over nothing, `misdirected` is empty, `ok` is True, the marker is stamped, and
start 3 takes the `from_version >= MAINT_SCHEMA_VERSION` early return **forever
— including after the call site is fixed**. Loud-and-recoverable became
silent-and-permanent after one restart: exactly the state `store.py`'s header
says must never happen, reached by the fix written to prevent it.
**The remedy is one line**, and it is sound by construction rather than by
argument: grade `set(rep.moved) | set(rep.kept)`. Every entry in *both* lists is
a `unit_key(unit, legacy)` built from the **same** `unit` argument, so grading
`kept` grades the same namespace decision `moved` does. A correct call site's
`kept` entries are under a roster name and pass; a wrong one cannot launder its
namespace by having already written it. `migrate_unit_keys()` passes
`roster=None` and returns before the loop, so it is unaffected.
**Why the check missed it, which is the part worth keeping.** Part 9b's recovery
check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` — the **corrected** name.
It measures recovery along the one path where the operator has *already fixed
the bug*. The real failure is a call site that is still wrong on the next start,
and nothing in the 19 files called `migrate_maint_keys()` twice with the **same
wrong** `unit`. The check was true, passed, and could not reach the failure its
sentence named — MUTATION.md's third defect, this time sitting inside the fix
written to close it. Part **9c** now drives two starts with the same wrong unit
over one store, asserts the marker is still withheld on the second, and asserts
the *shape* of the second report (`moved` empty, `kept` full) so it cannot
quietly revert to 9b's path.
### The marker gate is `rep.ok` and nothing weaker — both guards, each pinned
`if rep.ok:` is `not lost and not misdirected`, and until this round only the
`misdirected` half was reachable by a check that also had a non-empty `kept`.
The mutation
```
if rep.ok: -> if rep.ok or rep.kept:
```
stamps the marker on a migration that **lost a month of history** — defeating
the older, settled `lost` guard as well as the new one — and **all 19 suites
were green on it**. Part **9d** is the fixture that separates them: two keys
already namespaced (`kept` non-empty, so the mutant's `or` fires) and two keys
whose write the store drops (`lost` non-empty, so `ok` is False), with
`misdirected` empty throughout so the section cannot pass on the other guard by
accident. Measured differential on it: shipped → marker absent; mutant → marker
`1`, both reporting `ok=False lost=2 kept=2`.
**Why the fixtures had to change, and this is the part that got the card
rejected.** Nothing in the repository could see whether any of the above was
true, because `inverter.UNIT_A` **is** the string `"inverter-a"` and every
two-pack fixture in `test_maintenance.py` named its first roster entry
`inverter-a`. `self.inverters[0].name` and `UNIT_A` were therefore the same
value in every check that existed, and swapping one for the other at the call
site left **all 19 suites green**. MUTATION.md's third defect exactly — a check
whose *data* cannot reach the failure it names — and the fifth consecutive card
to carry it. Parts 9 and 11 now run on `["garage", "shed"]`, each behind a
**precondition check** asserting that the first name is not `UNIT_A`, so an edit
that makes them equal again goes red instead of making the claims vacuous.
### And the flat keys are kept LIVE, which is the half DEV-02 had to add later
Rule 2 originally said the flat keys are left in place and go stale. DEV-02
amended it, for the sign latch, to *"left in place is not enough"* — and the
same sentence holds for history. A fossil dated the day the second inverter was
declared would hand a rolled-back single-FSM add-on a `last_completed` months
old (it recalibrates a pack that does not need it) or a stale `phase` with a
`phase_started` old enough to abort on the 12 h timeout the moment it comes up.
So `UnitHistory` **mirrors the first pack's writes back onto the flat keys** for
as long as the roster has more than one entry. Only the first pack: the flat
spelling means `inverters[0]` on every install that has one, and a mirror from
pack B would overwrite pack A's rollback view with pack B's phase.
## 3. Staggering: the interlock defers, and the stagger is the residue
Two packs on one monthly schedule are due in the same hour every month. The
options were a stagger **by construction** — offset each pack's first cycle by
`interval_days / N` — or **deferral**. Deferral, for three reasons:
1. **A constructed stagger cannot remove the need for the interlock.** A forced
start, a commissioning cycle, an abort-and-retry, or a pack added mid-month
desynchronises any static offset immediately, so the interlock has to be
there anyway — and then the stagger is a second mechanism that must also be
correct, for a case the first one already handles.
2. **A deferral is observable and a stagger is not.** The deferred pack says, in
the action log and on the status page, that it is waiting, which pack it is
waiting for, and that it will run at the next start hour. A silent offset
moves an operator's configured start day and explains nothing.
3. **The stagger comes out of the deferral for free and then persists.** Pack A
completes on day 1 and is next due on day 29; pack B, deferred, completes on
day 2 and is next due on day 30. One contested month separates them by one
cycle length and they stay separated.
**Starvation was the risk and it is closed at two points.** A deferral does
**not** stamp `last_start_attempt` — that key is the post-abort cooldown, and at
90 minutes inside a 60-minute start hour, stamping it would lock the deferred
pack out of exactly the hour it is waiting for. And the winner is the
longest-waiting pack rather than the first in the roster, so a pack that keeps
aborting cannot monopolise the slot month after month while its sibling drifts —
which is this card's own defect wearing a different costume.
**The deferral must not read as a fault.** §12's 12 h and 36 h phase timeouts
mean *"the battery is not following commands"*. A deferred pack stays `IDLE`
with `phase_started` untouched, so no timeout is reachable by waiting, and the
line it logs says WAITING, names the pack it waits for and says it will run
next — none of which any failure path in `maintenance.py` says.
## 4. Rule 6 (the card calls it rule 8): the first entry's cap is no longer right
`validate.py`'s rule 6 comment said, in as many words, that
`maintenance_charge_w` was graded against `inverters[0]`'s cap because there was
exactly one `Maintenance` and it could only ever recalibrate the first pack —
and that taking `min()` would be an over-refusal, because it would refuse *"a
5000 W pack being recalibrated beside a 2000 W sibling the cycle never
touches"*. It closed: **"when `maint_unit` stops being `inverters[0]`, this line
moves with it."**
It has stopped. Every pack is recalibrated on its own schedule, so **the sibling
the cycle never touches does not exist** and the over-refusal argument has no
subject. The shaping claim is filed on whichever pack is charging, so the cap
that can contradict it is any cap on the roster and the one that contradicts it
first is the smallest.
**This refuses configurations that used to start**, and that is the point rather
than a side effect: `max_w` 5000 on pack A beside 2000 on pack B with
`maintenance_charge_w` 3000 was correct while only pack A was ever recalibrated,
and is now a cheap-window charge asking the arbiter for `hi = -3000` against a
supervised `lo = -2000` — `lo > hi`, arbiter clause 3, 0 W, on the pack nobody
was watching. Refused when it is **written**, which is rule 6's whole job. At a
roster of one `min()` over one cap is that cap: same comparison, same message,
same boundary.
## 5. The claim stays per-unit, and nothing here files a site claim
`arbiter.CLAIM_SCOPE` has classified `maintenance` as `SCOPE_UNIT` since it was
written. Two FSMs mean two claims on two actuators, which the allocator consumes
as per-unit intervals. [[ARCH-01]] is explicit that this must never become a
site claim, because per-inverter constraints filed site-wide are the
cross-inverter `lo > hi` contradiction at `arbiter.py:114`. The only line this
card changed there is **which** roster entry the claim is filed on: the one whose
state machine produced the result, rather than `inverters[0]`.
## 6. FOUND AND NOT FIXED: the site loop still contradicts a drain at N>=2
Measured while wiring this card, and **pre-existing on `release/1.0` at
`2d025ee`** — the same drain filed on `inverters[0]` produces the identical
refusal with the two unit names swapped.
The maintenance SET **pins** its pack. The loop's target is a **site** claim.
When the site target is *below* the pin — a 2000 W drain while the house imports
less than that, which is most of a night — the only split that sums to the site
target asks the sibling to **charge** while the maintained pack **discharges**,
and `allocator.plan()` refuses it by name as circulating power. The refusal is
correct and fails safe: the whole site goes to 0 W with an `ARBITER: REFUSED`
line in the action log.
**The consequence an operator would see is a misdiagnosis.** The pack is held at
0 W while its state machine believes it is draining, so after 12 h the DRAIN
timeout fires and reports *"drain stuck"* — §12's words for a battery that is not
following commands. The battery is fine; the site refused to allocate.
**Why it is not fixed here.** The fix is to change what the site loop's target
*means* while a unit is pinned — regulate the meter using only the unpinned
packs — which is loop and allocator territory and changes the integrator's
subject. MAINT-05 moves which pack is pinned and nothing else about it. It is
pinned as a running check (`test_maintenance.py` part 12) rather than left as a
comment, so it cannot be forgotten or quietly tuned away, and it wants a card of
its own.
## 7. FOUND AND NOT FIXED: `migrate_unit_keys()` has the same hole, and it is a LATCH
Found while fixing §2's instance. It is the same defect one module over and it
costs a **disarmed safety guard** rather than a lost history.
`Controller.__init__` calls `migrate_unit_keys(store, UNIT_A)` — a hardcoded
constant where the maintenance migration now passes the roster's own first name.
It **has** to run before `build_roster()`, because each `Inverter` restores its
sign latch in its constructor, so there is no roster to name yet and
`_verify_moves()` is passed `roster=None`. On a **declared** roster a pre-DEV-02
`sign_tripped: true` therefore lands under `unit/inverter-a/sign_tripped`, which
no unit on that site reads.
**And the guard that exists for exactly this is defeated by the key the defect
wrote.** `_grade_store_migration()` re-arms the latch when the flat key says
tripped and *nothing namespaced is latched*. The misdirected write **is**
namespaced and **is** latched, so `namespaced_latched` is `True`, the guard
returns, and no inverter on the roster comes up tripped — on an install whose
`/data/state.json` still reads `sign_tripped: true`. That is DEV-02's own
sentence for the worst case: the state file says protected, the banner is green,
and the controller resumes commanding into the fault that tripped it.
Measured, on the same `["garage", "shed"]` roster:
```
pre-DEV-02 flat latch + declared roster ['garage', 'shed'] ->
migrated 2 flat key(s): unit/inverter-a/sign_reason, unit/inverter-a/sign_tripped
latch keys in the store: ['sign_tripped', 'site_sign_tripped', 'unit/inverter-a/sign_tripped']
roster came up tripped: [('garage', False), ('shed', False)]
the same store on the FLAT config -> [('inverter-a', True)]
```
The last line is the counterfactual: on the flat shape, where `UNIT_A` really is
the roster's only name, the same store migrates and comes up latched. So this is
a defect of the **declared roster**, not of the migration as such.
**Why it is not fixed here.** The remedy is to name the migration's target from
the config before any `Inverter` exists, which means factoring `build_roster()`'s
naming rule out of `build_roster()` so there is still only **one** spelling of
it — `inverter.py` and SAFETY-02's latch semantics, with its own mutation battery
over `test_inverter.py` and `test_watchdog.py`. MAINT-05 moves the maintenance
history and nothing about the sign latch, and a silent expansion into a safety
latch is how a rejected PR becomes a broken one.
It is pinned as a running check (`test_maintenance.py` part 13) rather than left
as a comment. ⚠️ **Its assertions state the WRONG behaviour on purpose** — read
them as the specification of a defect; the card that fixes it should expect them
to go red and should replace them.
### And its MARKER GATE had the round-3 hole too — found here, closed here
Separate from the namespace hole above, and **fixed** rather than recorded,
because closing it cost one test fixture and no source change.
`migrate_unit_keys()` gates `schema_version` on `rep.ok` exactly as
`migrate_maint_keys()` gates its own marker, and every check that reached that
gate ran on a store where **nothing** was namespaced yet — so `rep.kept` was
empty in all of them and
```
if rep.ok: -> if rep.ok or rep.kept:
```
**survived all 19 files**. What it does is stamp the schema marker on a
migration that DROPPED THE LATCH: the guard is off, the state file still reads
`sign_tripped: true`, and because the store now says the migration finished, the
next start skips the one thing that would have re-armed it. DEV-02's own worst
case, filed under *done*.
`test_inverter.py` part 10 now drops the `sign_tripped` write **beside an
already-namespaced `sign_reason`** — `lost` non-empty *and* `kept` non-empty,
which is the one shape none of the existing fixtures had — and asserts the
marker is withheld, with the accepted-baseline counterfactual beside it (habit
7). The shipped gate was correct throughout; what changed is that a check can
now see it.
+63 -3
View File
@@ -135,11 +135,19 @@ manufactures them from a **fixture**: the check runs, the assertion is
evaluated, the sentence above it is true — and the numbers it compares are
arranged so that the failure it describes could not have changed the answer.
It is not hypothetical and it is not rare. Six instances in two weeks; three
It is not hypothetical and it is not rare. EIGHT instances in two weeks; four
were found by review, two - CFG-04's fixture pair - by an author's own mutation
battery, which is the cheaper place, and the sixth by review again, in the one
battery, which is the cheaper place, one by review again, in the one
place nobody had thought to point the rule at: not a check in the suite, but a
COMMITTED EVIDENCE ARTIFACT four documents cite.
COMMITTED EVIDENCE ARTIFACT four documents cite - and the eighth INSIDE THE FIX
WRITTEN TO CLOSE THE SEVENTH.
⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance,
MAINT-05's, is the one that says why: the defect is not carelessness about
fixtures, it is that **a check and the thing it checks were built from the same
constant**. Ask of every difference-claim not "is this assertion true" but
"where did each side of this comparison come from" - and if the answer is the
same place twice, the check is decoration.
* **ARB-03** shipped a call-site scan asserting that every `claims.append` in
`cycle()` routes through `actuator_of()`. Its own parse **saw 3 of the 8
@@ -167,6 +175,44 @@ COMMITTED EVIDENCE ARTIFACT four documents cite.
than against its behaviour. It now drives the shipped `run_control()` on a
two-inverter config, **under a deadline**, because the mutation's real symptom
is that the controller enters its loop and never returns.
* **MAINT-05** shipped `Controller.__init__` calling
`migrate_maint_keys(store, self.inverters[0].name, ...)` under a comment
saying *"it targets `self.inverters[0].name` and NOT `UNIT_A` ... writing
`UNIT_A` here would deposit an install's whole maintenance history under a
name that is not on the roster at all"*. `inverter.UNIT_A` **is** the string
`"inverter-a"`, and every two-pack fixture in `test_maintenance.py` named its
first roster entry `inverter-a` — so the two expressions were the same value
in every check that existed, and swapping them left **all 19 files green**
while losing a real install's whole maintenance history. Two-pack fixtures are
now `["garage", "shed"]`, each behind a precondition check (habit 1) that the
first name is not `UNIT_A`.
⚠️ **AND THE POSTCONDITION HAD THE SAME SHAPE AS THE FIXTURE.**
`store._verify_moves()` read back the keys it had just written, under the
namespace it had just been handed — so it verified clean, and the caller
stamped the schema marker, on a migration nothing would ever read. *A check
that reads back the namespace it was handed cannot detect being handed the
wrong one*: it compared a thing against itself, exactly as DEV-02's fixture
compared `(-5000, 5000)` against `(-5000, 5000)`. The habit-2 fix is the same
one — grade against something the thing under test did not supply, here the
ROSTER the machines read.
⚠️ **AND THE FIX ITSELF CARRIED THE DEFECT, WHICH IS THE SIXTH CARD IN A ROW
AND THE FIRST TIME IT HAS APPEARED *INSIDE* A REMEDY.** The fix round's
recovery check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` — the
**corrected** name. It measures recovery along the one path where the operator
has *already fixed the bug*, and nothing in the 19 files called
`migrate_maint_keys()` twice with the **same wrong** `unit`. So the refusal
the card was rejected twice to obtain lasted **exactly one start**: measured
at Controller level over one persisted store, roster `["garage", "shed"]` —
construction 1 `ok=False misdirected=4 moved=4 kept=0 marker=<absent>`,
construction 2 `ok=True misdirected=0 moved=0 kept=4 marker=1`. On start 2
rule 1 routes the wrongly-written keys to `rep.kept`, `rep.moved` is empty,
the roster loop iterates over nothing, and the marker is stamped on a
migration that reached no readable key — after which the early return skips it
forever, *including after the call site is fixed*. The check was true and
passed for the wrong reason. Habit **9** below is what falls out of it.
* **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule
reaching outside the suite. The probe is the committed evidence that
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
@@ -241,6 +287,18 @@ Cheap habits fall out of it, and all of them are now used in this repository:
red. `inverters: []` was deleted from `config.yaml` and the probe re-run
against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` for
exactly that reason.
9. **Exercise a refusal a SECOND time with the SAME wrong input — not the
corrected one.** A guard that refuses once and then admits the same bad
caller is not a guard, it is a delay, and the second call is a *different
code path* whenever the first call left state behind. MAINT-05's roster
grading iterated the keys it had just **written**; on the second start the
same wrong caller found them already present, they moved to the
`already namespaced` list, and the loop graded an empty set. Re-running with
the **fixed** argument — which is what the check did — measures the operator's
recovery, never the persistence of the refusal. Ask of every refusal: *what
does this do when the caller has not been fixed and simply runs again?* And
assert the **shape** of that second report, not only its verdict, so the
check cannot silently revert to grading the first path.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
@@ -297,6 +355,8 @@ commit and PR bodies.
| **TARIFF-01** reviewer survivor: `record_block()` ignoring its `policy` argument (0dbd485) | **re-verified — still a survivor** | re-run here through `tools/mutation_run.sh` against the 15 suites that were on the record, all green with `billed_w(block, policy)` mutated to `billed_w(block, POLICY_NET)`. The recorded diagnosis holds: every block reaching the store is single-phase, where NET, SUM and MAX collapse. **Now CLOSED by TEST-13** (`test_tariff.py` section 7), which round-trips section 7's own unbalanced `(3000, 500, -200) W` block THROUGH the store under each policy - three distinct banked figures, so a hardcoded policy takes at least two of them red. Third instance of "a stimulus evaluated where the quantity it grades is degenerate", after WITNESS-02's span normalisation and TARIFF-01's own DST window. |
| **SAFETY-25** 8 mutations against `test_peakguard.py` part 10, then 9 more after review, all red | **not at risk** | each run against a tree copied fresh with `__pycache__` excluded and mounted `:ro`, so no `.pyc` existed to go stale - the same structural immunity recorded for CI above. Byte deltas recorded on the PR; none is `+0 B`. All are **reds**, which is the direction this defect cannot fabricate. ⚠️ The second round exists because the first round's mutations were all aimed at DISARMING the guard, and the claim part 10 actually made was about the guard being FIXED - the reviewer built the fix and the suite stayed green. Bytecode was never the issue there; an unaimed battery was. |
| **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. |
| **MAINT-05 round 3**, `migrate_maint_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **was a survivor — now CLOSED** | measured against head `65cddf3` under the purge, tree mounted `:ro`, `19/19 PASS`. It stamps the schema marker on a migration that LOST state, defeating the older `lost` guard as well as the new `misdirected` one, because every check that reached the marker did so on a store whose `kept` list was empty. Closed by `test_maintenance.py` **9d**; the mutation is now red on three named checks. |
| **MAINT-05 round 3**, `migrate_unit_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **found here — SAFETY-02's half, now CLOSED** | the SAME mutation one function up, on the LATCH migration rather than the history one. Measured a survivor against this branch under the purge, `:ro`, `19/19 PASS`, then closed by `test_inverter.py` part 10's new fixture — a store with `unit/inverter-a/sign_reason` already present (so `kept` is non-empty) whose `sign_tripped: true` write is dropped. Shipped: marker withheld. Mutant: marker stamped on a **disarmed latch**, and the next start skips the migration that would have re-armed it. |
| **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. |
**Nothing here says a past result is wrong.** The two survivors re-run above
+317 -107
View File
@@ -54,14 +54,16 @@ from .hass import HomeAssistant
# window an operator would look up here is the one the roster actually uses.
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries)
from .maintenance import IDLE, MaintConfig, Maintenance
from .maintenance import (
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
)
from .mqtt import MqttPublisher
from . import peakguard
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
from .store import migrate_unit_keys
from .store import migrate_maint_keys, migrate_unit_keys
from .validate import (ConfigRefused, refuse, validate_startup_config,
validate_unit_ranges)
from .watchdog import (
@@ -265,7 +267,66 @@ class Controller:
# maintenance.MAINT_OPTION_DEFAULTS before adding a ninth key: it goes
# in config.yaml's `options:` and `schema:` blocks and in that dict, and
# nowhere else.
self.maint = Maintenance(MaintConfig.from_options(opts), store)
# --- MAINT-05: ONE `Maintenance` PER ROSTER ENTRY --------------------
# ⚠️ AND THE MIGRATION RUNS FIRST, FOR THE REASON THE SAFETY-02 ONE
# DOES. These objects READ their history in their constructors' wake -
# `phase`, `last_completed` - so a history still sitting under the flat
# spelling when they are built is a pack that comes up believing it has
# never been recalibrated, on a site whose state file says otherwise.
# It targets `self.inverters[0].name` and NOT `UNIT_A`: the flat keys
# belonged to whichever pack the one FSM was driving, which is the first
# roster entry, and a declared `inverters:` roster names its own entries
# (`inverter.build_roster()` says so, and its fallback for a nameless
# entry is a POSITIONAL `inverter-1`, so `inverter-a` is not even the
# default there). Writing `UNIT_A` here would deposit an install's whole
# maintenance history under a name that is not on the roster at all.
#
# ⚠️ AND THAT SENTENCE USED TO BE A COMMENT NOTHING COULD GRADE, WHICH
# IS WHY THE ROSTER IS PASSED RATHER THAN A COUNT. Every two-pack
# fixture in the suite named its first entry `inverter-a`, so
# `self.inverters[0].name` and `UNIT_A` were the same string in every
# check that existed and swapping them left all 19 files green - while
# on a roster of `["garage", "shed"]` it silently loses the history and
# stamps the marker, so the next start skips the migration too.
# `migrate_maint_keys()` now grades the namespace it wrote against the
# names the machines read; test_maintenance.py sections 9 and 11 drive
# a `["garage", "shed"]` roster, which is what makes the mutation red
# instead of invisible.
self.maint_migration = migrate_maint_keys(
store, self.inverters[0].name, [inv.name for inv in self.inverters])
_LOG.info("maintenance history: %s", self.maint_migration.explain())
if not self.maint_migration.ok:
# Not a disarmed guard - history, not a latch - so it does not
# re-arm anything. It is still the difference between a pack that is
# recalibrated on schedule and one that silently is not, which is
# §12's opening sentence, so it is said where an operator looks.
self.log_event("maintenance history did NOT migrate cleanly: "
+ self.maint_migration.explain())
# ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the
# PROCEDURE - how hard to drain, how full to charge, how long to hold -
# and the procedure is the same on every pack in the roster; what is per
# pack is the SCHEDULE and the HISTORY, which live in the store. A
# per-entry config block would be a second shape for CFG-04's roster to
# carry and a fifth place for a `maintenance_charge_w` to disagree with
# config.yaml. If a site ever needs two different procedures, that is a
# roster-schema card and it starts here.
_maint_cfg = MaintConfig.from_options(opts)
_one_pack = len(self.inverters) <= 1
self.maints = [
Maintenance(
_maint_cfg, store,
# ⚠️ `None` AT A ROSTER OF ONE IS THE FLAT LAYOUT, NOT A MISSING
# NAME. §6.2's identity: one pack, one history, and the keys it
# has been written under since the add-on shipped. See
# `maintenance.UnitHistory` and `store.migrate_maint_keys()` -
# this is the line that keeps a one-inverter state file
# byte-identical.
unit=None if _one_pack else inv.name,
name=inv.name,
# The rollback view, and only ever the first pack's: the flat
# keys mean `inverters[0]` on every install that has them.
mirror_flat=(not _one_pack and i == 0))
for i, inv in enumerate(self.inverters)]
# SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own
# docstring has always said and what this Controller did not do. Each
@@ -453,6 +514,9 @@ class Controller:
self._peak_saturated = False
self._peak_floor_limited = False
self._maint_shape_suspended = False
# [(pack name, why)] for packs that are due and waiting their turn.
# Empty at a roster of one, where nothing can defer anything.
self._maint_deferred = []
# Set by amain() once the transport is built, so the status page can show
# what only the transport knows (homewizard_local's unchanged_s). Stays
# None when P1 is off, or under a transport that has no such counter.
@@ -667,18 +731,79 @@ class Controller:
return tuple(inv.name for inv in self.inverters)
@property
def maint_unit(self):
"""The pack the ONE maintenance state machine is recalibrating.
def maint_index(self) -> int:
"""Which roster slot holds the site's ONE maintenance slot right now.
⚠️ NAMED RATHER THAN ASSUMED. This add-on holds exactly one
`Maintenance`, whose history lives under the store's un-namespaced
`phase` / `last_completed` keys, so the pack it drives is the first in
the roster. Two packs recalibrating on two schedules needs a second FSM
and a namespaced history of its own; what THIS card fixes is only that
the claim is filed on ONE actuator instead of on whichever one happened
to be asked. Read store.LEGACY_UNIT_KEYS on why those keys did not move.
The pack that is mid-cycle if there is one, otherwise the first - which
at a roster of one is always 0, always was, and is the whole of this
property's N=1 behaviour.
"""
return self.inverters[0]
for i, m in enumerate(self.maints):
if m.phase in POWER_MOVING:
return i
return 0
@property
def maint(self):
"""The `Maintenance` the site's one maintenance slot belongs to.
⚠️ MAINT-05 TURNED THIS FROM AN ATTRIBUTE INTO A VIEW, AND THE THING IT
IS A VIEW OF CHANGED WITH IT. There are now `self.maints` - one FSM per
roster entry, each with its own schedule, phase and history - and every
SITE-level surface that used to read `self.maint` (the `phase` MQTT
value, the status banner, the operator's start/abort buttons) wants THE
ONE THAT IS RECALIBRATING, not "the first one". `maintenance.arbitrate()`
guarantees at most one pack is ever out of IDLE, so "the one that is
recalibrating" is well defined; when none is, this is `maints[0]` and
the surfaces read exactly what they read before.
⚠️ AT A ROSTER OF ONE IT IS `maints[0]` ON EVERY TICK, in both branches,
which is the object that used to be assigned here. Nothing downstream
can tell the difference - that is AC 6, and test_maintenance.py part 9
asserts it rather than trusting this paragraph.
"""
return self.maints[self.maint_index]
@property
def maint_unit(self):
"""The pack the site's one maintenance slot is currently on.
⚠️ THIS USED TO BE `self.inverters[0]`, FLATLY, AND THE COMMENT THAT
STOOD HERE EXPLAINED WHY: there was exactly one `Maintenance`, its
history lived under the store's un-namespaced `phase` / `last_completed`
keys, so the pack it drove could only be the first in the roster. That
sentence is what MAINT-05 removed. There is now one FSM and one
namespaced history PER ROSTER ENTRY, so the pack being recalibrated is
whichever one holds the site's maintenance slot, and `maint_index` -
not the roster's order - is what answers it.
⚠️ AND `validate.py` RULE 6 MOVED WITH IT, exactly as its own comment
said it must ("when `maint_unit` stops being `inverters[0]`, this line
moves with it"). It graded `maintenance_charge_w` against the FIRST
entry's cap on the argument that the cycle never touches the others;
that argument is now false, so it grades against the weakest cap on the
roster, like rule 5. See the ⚠️ block there.
"""
return self.inverters[self.maint_index]
def _maint_button(self, now):
"""The pack an operator's one maintenance button acts on.
The pack holding the site's maintenance slot when one holds it, so
"abort" stops the cycle that is running and "start" restarts it -
exactly what both buttons meant while there was one pack, and the reason
this is not simply "the longest-waiting one". When every pack is idle it
IS the longest-waiting one, which is `arbitrate()`'s own clause 2, so a
forced start and a scheduled start choose the same pack rather than two
different ones.
⚠️ AT A ROSTER OF ONE BOTH BRANCHES ARE `maints[0]`. There is no third
case: `max()` over a one-element list is that element.
"""
if self.maints[self.maint_index].phase in POWER_MOVING:
return self.maints[self.maint_index]
return max(enumerate(self.maints),
key=lambda p: (p[1].waited_days(now), -p[0]))[1]
def inverter(self, name: str):
"""The roster entry called `name`, or None."""
@@ -1329,14 +1454,36 @@ class Controller:
"""
now = datetime.now(timezone.utc)
# ⚠️ THE PACK IT IS MAINTAINING, NOT "the SoC". §12's recalibration
# drains and refills ONE pack and grades ONE coulomb counter, so it is
# graded on that pack's own reading - see `maint_unit`. A minimum across
# packs would abort the cycle on a sibling's state, and an average would
# declare a pack full that is not.
result = self.maint.tick(now, self.maint_unit.soc)
for msg in result.events:
self.log_event(msg)
# ⚠️ EACH PACK ON ITS OWN READING, NOT "the SoC". §12's recalibration
# drains and refills ONE pack and grades ONE coulomb counter, so each
# state machine is ticked with the SoC of the inverter it belongs to. A
# minimum across packs would abort a cycle on a sibling's state, and an
# average would declare a pack full that is not. The pairing is
# positional and both lists are built from the roster in `__init__`, so
# a pack cannot be graded on its neighbour's meter.
#
# ⚠️ AND `arbitrate()` RUNS FIRST, ONCE, OVER ALL OF THEM. It is the
# site's one maintenance slot - see maintenance.py - and a pack that
# does not hold it is told so by name rather than left to discover it.
# Calling `tick()` without the answer this produced is the
# two-packs-draining-at-once defect with the interlock present but not
# wired, which is why the plan is built here and not inside the loop.
maint_plan = maint_arbitrate(self.maints, now)
maint_results = []
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
res = machine.tick(now, inv.soc, blocked_by=blocked)
for msg in res.events:
self.log_event(msg)
maint_results.append(res)
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
# deferral is edge-logged (it lasts as long as the other pack's cycle),
# so by the time an operator asks "why has pack B not recalibrated" the
# line that said so has scrolled off. §12's whole failure mode is a
# schedule that silently stops firing; a pack that is due, idle and
# WAITING has to be able to say so at the moment it is looked at.
self._maint_deferred = [(m.name, r.deferred)
for m, r in zip(self.maints, maint_results)
if r.deferred]
# --- SAFETY-02, and it runs BEFORE the early return below -------------
# ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing
@@ -1838,88 +1985,114 @@ class Controller:
self.log_event(self.arb.explain())
# --- maintenance -----------------------------------------------------
if result.owns_setpoint:
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
# drain THIS pack to its floor, charge THIS pack full, recalibrate
# THIS pack's coulomb counter. Two packs recalibrate on two
# schedules. See arbiter.CLAIM_SCOPE.
# ⚠️ FILED ON THE UNIT BEING MAINTAINED, WHICH IS THE FIRST ONE.
# §12's monthly recalibration is a PACK procedure and this add-on
# holds exactly ONE `Maintenance` state machine, so the pack it is
# maintaining is `self.maint_unit`. Two packs recalibrating on two
# schedules is a second FSM with its own namespaced history, which
# is a card of its own - what this card must not do is file one
# pack's procedure on every actuator.
claims.append(Claim.set("maintenance", P_MAINTENANCE,
float(result.setpoint_w or 0.0), result.phase,
actuator=self.actuator_of("maintenance",
self.maint_unit)))
self.sat_count = 0
elif result.charge_only:
# Money outranks the maintenance schedule: while the quarter-hour
# projection is over the cap, the charge-only shaping is simply not
# claimed, so the loop can discharge and shave the peak. When a
# dedicated peak-shaving strategy arrives it will claim SET at
# P_PEAK and outrank this limit without any code here changing.
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's claim
# is `lo >= 0`. Both are limits, arbiter.py applies EVERY limit at or
# above the winner's priority, and most restrictive wins - so a peak
# event during a maintenance charge window files `lo > hi`, which is
# a CONTRADICTION and resolves to 0 W. The battery would park in the
# exact quarter it should be shaving. Priority does not save it:
# clause 2 has no notion of a higher-priority limit overriding a
# lower one, and it should not - "most restrictive" is what makes the
# safety bounds unconditional.
#
# So the higher-priority claimant SUPPRESSES the lower one rather
# than out-voting it, which is what this branch already did for
# `peak_forecast_entity`. That entity is optional and empty on a
# default install, so until now the suspension could not fire on the
# site that needed it. `self.peak_guard` is measured from the meter
# and is always there.
#
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is not
# tidiness. It was inline, and a reviewer mutated it back to its
# historical broken form - re-gated on `peak_forecast_entity` alone -
# and every suite in this repository stayed green while a default
# install parked at 0 W in the quarter that sets the month's bill.
# Read shaping_suspended() before touching this: both terms are
# there, and why neither is sufficient alone.
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc,
float(self.o.get("peak_cap_w", 3500))):
# Edge-logged: level-logging here would write one line per cycle
# for as long as the event lasts and flush the 40-line action log
# of the very context that explains it.
if not self._maint_shape_suspended:
self._maint_shape_suspended = True
self.log_event("peak at risk - maintenance charge shaping "
"suspended so the peak claim is not "
"contradicted")
else:
self._maint_shape_suspended = False
hi = 0.0
reason = "charge-only"
if self.cheap:
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ
# OF `self.o`. This line used to re-open the options dict
# with its own literal fallback, so the rate the shaping
# asked for and the rate the state machine drained at could
# be different numbers from the same install - and, with the
# key absent, they were. The Controller already parsed this
# once in __init__; asking the object that owns it cannot
# drift from it.
floor = maintenance_charge_floor(
self.maint.cfg.charge_w,
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
if floor > 0:
hi, reason = -floor, "cheap-window charge"
# PER-INVERTER, like the SET above and for the same reason -
# the shaping is about the pack being charged.
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason,
actuator=self.actuator_of("maintenance",
self.maint_unit)))
# ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT.
# At most one machine is out of IDLE on any tick, so at most one of
# these branches fires and the claim list is the shape it has always
# been. The loop is not a widening of what maintenance may ask for; it
# is what makes the ONE claim it does file land on the pack that is
# actually being recalibrated, whichever roster slot that is.
for inv, result in zip(self.inverters, maint_results):
if result.owns_setpoint:
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
# drain THIS pack to its floor, charge THIS pack full,
# recalibrate THIS pack's coulomb counter. Two packs recalibrate
# on two schedules. See arbiter.CLAIM_SCOPE.
#
# ⚠️ FILED ON THE PACK WHOSE STATE MACHINE PRODUCED IT - `inv`,
# the roster entry this `result` was ticked from - and that is
# the whole of MAINT-05 at this line. It used to be
# `self.maint_unit`, which was `inverters[0]` flatly, because
# there was one FSM and its history had nowhere else to live.
# Pack B's drain filed on pack A is one pack being commanded for
# another's procedure while the pack that is actually draining
# stays under the loop: the site would move the right total and
# recalibrate nothing.
#
# ⚠️ AND IT STAYS `SCOPE_UNIT`. Two FSMs mean two claims on two
# actuators, which the allocator consumes as per-unit intervals;
# ARCH-01 is explicit that this must never become a site claim,
# because per-inverter constraints filed site-wide are the
# cross-inverter `lo > hi` contradiction at arbiter.py:114.
claims.append(Claim.set("maintenance", P_MAINTENANCE,
float(result.setpoint_w or 0.0), result.phase,
actuator=self.actuator_of("maintenance", inv)))
self.sat_count = 0
elif result.charge_only:
# Money outranks the maintenance schedule: while the quarter-hour
# projection is over the cap, the charge-only shaping is simply not
# claimed, so the loop can discharge and shave the peak. When a
# dedicated peak-shaving strategy arrives it will claim SET at
# P_PEAK and outrank this limit without any code here changing.
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's
# claim is `lo >= 0`. Both are limits, arbiter.py applies EVERY
# limit at or above the winner's priority, and most restrictive
# wins - so a peak event during a maintenance charge window files
# `lo > hi`, which is a CONTRADICTION and resolves to 0 W. The
# battery would park in the exact quarter it should be shaving.
# Priority does not save it: clause 2 has no notion of a
# higher-priority limit overriding a lower one, and it should not -
# "most restrictive" is what makes the safety bounds unconditional.
#
# So the higher-priority claimant SUPPRESSES the lower one rather
# than out-voting it, which is what this branch already did for
# `peak_forecast_entity`. That entity is optional and empty on a
# default install, so until now the suspension could not fire on the
# site that needed it. `self.peak_guard` is measured from the meter
# and is always there.
#
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is
# not tidiness. It was inline, and a reviewer mutated it back to its
# historical broken form - re-gated on `peak_forecast_entity` alone
# - and every suite in this repository stayed green while a default
# install parked at 0 W in the quarter that sets the month's bill.
# Read shaping_suspended() before touching this: both terms are
# there, and why neither is sufficient alone.
#
# ⚠️ ONE SUSPENSION FLAG FOR THE SITE, NOT ONE PER PACK, AND THE
# INTERLOCK IS WHAT MAKES THAT TRUE. The quantity it edge-logs is
# a peak event at the grid connection, and `arbitrate()` allows
# at most one pack to be in CHARGE at a time, so this branch can
# run at most once per tick. Were two packs ever allowed to
# charge together, this flag would have to become per-pack - and
# so would the reasoning above it.
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc,
float(self.o.get("peak_cap_w", 3500))):
# Edge-logged: level-logging here would write one line per
# cycle for as long as the event lasts and flush the 40-line
# action log of the very context that explains it.
if not self._maint_shape_suspended:
self._maint_shape_suspended = True
self.log_event("peak at risk - maintenance charge shaping "
"suspended so the peak claim is not "
"contradicted")
else:
self._maint_shape_suspended = False
hi = 0.0
reason = "charge-only"
if self.cheap:
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ
# OF `self.o`. This line used to re-open the options dict
# with its own literal fallback, so the rate the shaping
# asked for and the rate the state machine drained at
# could be different numbers from the same install - and,
# with the key absent, they were. The Controller already
# parsed this once in __init__; asking the object that
# owns it cannot drift from it. Every pack's machine
# holds the same `MaintConfig` instance, so this is the
# rate the pack in front of us is charging at.
floor = maintenance_charge_floor(
self.maint.cfg.charge_w,
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
if floor > 0:
hi, reason = -floor, "cheap-window charge"
# PER-INVERTER, like the SET above and for the same reason -
# the shaping is about the pack being charged, which is `inv`.
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi,
reason=reason,
actuator=self.actuator_of("maintenance",
inv)))
# --- the grid-following loop -----------------------------------------
if self.auto and self.inputs_ok:
@@ -3310,14 +3483,29 @@ class Controller:
elif not self.auto:
level, banner = "warn", "Stopped — inverter commanded to 0 W"
elif self.maint.phase != IDLE:
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
# of one this is the string it has always been, character for
# character; at two, "Maintenance: drain" without a name is a
# banner an operator cannot act on, because the remedy is at one
# device and the site has two.
level, banner = "warn", f"Maintenance: {self.maint.phase}"
if len(self.inverters) > 1:
banner += f" ({self.maint_unit.name})"
else:
level, banner = "ok", "Running — holding grid at zero"
def w(v):
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
next_due = self.maint.next_due(datetime.now(timezone.utc))
# ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. One date on a
# site with two schedules has to be the one that comes first, or the row
# says "2026-10-01" while a pack recalibrates tomorrow. `None` means
# "never run, due now" and has to win over any date, so it is tested for
# rather than sorted with. At a roster of one this is that pack's own
# date, unchanged.
_now = datetime.now(timezone.utc)
_dues = [m.next_due(_now) for m in self.maints]
next_due = None if any(d is None for d in _dues) else min(_dues)
rows = [
("Grid", w(self.grid)),
("Battery", w(self.site_batt_w)),
@@ -3333,7 +3521,14 @@ class Controller:
else f"{self.pack_floor_soc:g} %"),
("Commanded", w(self.target)),
("Why", self.reason),
("Maintenance phase", self.maint.phase),
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
else f"{self.maint.phase} ({self.maint_unit.name})"),
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
# page - where `_maint_deferred` is empty on every tick - carries
# exactly the rows it carried before.
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
for n, why in self._maint_deferred)),)
if self._maint_deferred else ()),
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
@@ -3445,11 +3640,26 @@ class Controller:
await self.command_site_zero()
return {"auto": self.auto}
if what == "maint_start":
for msg in self.maint.force_start(now):
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
# ONE LABEL. `_maint_button()` answers it: the pack that holds the
# site's maintenance slot if one does - which is what "start"
# already meant at a roster of one, where pressing it mid-drain
# re-entered DRAIN - and otherwise the pack that has gone longest
# without a cycle, which is the same pack `arbitrate()` would have
# picked on its own. At N=1 both branches are `maints[0]` and this
# is the shipped call, byte for byte.
#
# A PER-PACK BUTTON IS A UI CARD, NOT THIS ONE. `web.py` posts a
# bare `maint_start` with no unit, and inventing a unit parameter
# here would ship half a surface: the page would still have one
# button and the API would have two meanings.
for msg in self._maint_button(now).force_start(now):
self.log_event(msg)
return {"phase": self.maint.phase}
if what == "maint_abort":
for msg in self.maint.abort(now, "operator"):
# Aborts the pack that is actually cycling - `_maint_button()` again
# - and then zeroes the SITE, which is what it always did.
for msg in self._maint_button(now).abort(now, "operator"):
self.log_event(msg)
await self.command_site_zero()
return {"phase": self.maint.phase}
+309 -16
View File
@@ -18,16 +18,47 @@ OWNERSHIP, not priority. Exactly one thing writes the setpoint at any moment:
The loop is never "disabled" - it yields. The heartbeat to the inverter keeps
running in every phase, so the hardware watchdog stays fed and a crash here
still ends with the inverter at 0 W rather than latched.
ONE PACK AT A TIME (MAINT-05). There is now one `Maintenance` per roster entry,
each with its own schedule, its own phase and its own history under its own
Store keys - and therefore, for the first time, two of them able to move power
on ONE grid connection at once. Read `arbitrate()` before touching any of it:
the site holds exactly ONE maintenance slot, a pack that is not IDLE holds it,
and every other pack is held at IDLE under the loop until it is released. That
is not a scheduling nicety. A drain of pack A against a charge of pack B is two
power-moving phases pulling opposite ways across the meter - the circulating
power SAFETY-31 exists to DETECT, arriving here as something this module can
simply not do.
"""
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from .store import MAINT_UNIT_KEYS, unit_key
_LOG = logging.getLogger("goodwe.maint")
IDLE, DRAIN, CHARGE, HOLD = "idle", "drain", "charge", "hold"
# ⚠️ THE PHASES THAT MOVE POWER, WHICH IS NOT THE SAME LIST AS "owns the
# setpoint". The table above is about WHO WRITES the number; this is about
# whether the pack is being pushed. DRAIN and HOLD own the setpoint. CHARGE does
# NOT - the loop owns it - and CHARGE is in here anyway, because the charge-only
# clamp plus the cheap-window floor is what pulls a second pack full while the
# first is being emptied. Leaving CHARGE out is precisely the interlock hole
# this card is about: pack A charging from the grid at 2000 W while pack B
# drains 2000 W to export is 4 kW going round in a circle with the meter at
# zero, and both packs believing they are behaving.
#
# HOLD commands 0 W and moves nothing, and it is in here for the other half of
# the sentence: it OWNS the actuator - the pack is parked at the top for the BMS
# and is deliberately not available to the loop - so a second cycle starting
# under it would still be two recalibrations at once, which is the thing whose
# value is a clean uninterrupted coulomb count.
POWER_MOVING = (DRAIN, CHARGE, HOLD)
# A drain of a 13.8 kWh usable pack (300 Ah x 51.2 V, DoD 90) at the shipped
# 2000 W is ~6.9 h even with no house load helping - and less in practice, since
# house load drains alongside the export and soc_floor stops short of empty.
@@ -153,17 +184,102 @@ class MaintResult:
setpoint_w: float | None = None
charge_only: bool = False
events: list = field(default_factory=list)
# ⚠️ WHY A DEFERRAL IS A FIELD AND NOT JUST A LOG LINE. A pack that is due
# and is not starting looks, from every surface an operator has, exactly
# like a pack whose schedule has quietly stopped firing - §12's opening
# sentence, the failure this whole module exists because of. It is carried
# so the status page can SAY "waiting for the other pack" rather than
# showing an idle machine and a due date in the past. Empty on every path
# that is not deferring, so a roster of one never sees it.
deferred: str = ""
class UnitHistory:
"""One pack's maintenance history in the Store, under one spelling.
⚠️ THE NAMESPACE IS A CONSTRUCTOR ARGUMENT AND `None` IS A REAL VALUE, NOT
AN OMISSION. `unit=None` means the FLAT keys - `phase`, `phase_started`,
`last_completed`, `last_start_attempt` - which is what every install has
written since the add-on shipped and what a one-inverter site keeps writing
after MAINT-05. §6.2's identity: with one pack there is nothing for a
namespace to distinguish it FROM, and adding one would change the layout of
a live state file to say something it already said. `arbiter.actuator_for()`
makes the same move for the same reason and its docstring argues it.
⚠️ `mirror_flat` IS THE ROLLBACK VIEW AND IT IS ONLY EVER THE FIRST PACK'S.
At N>=2 pack A's history has been migrated into its namespace and is written
there; the flat keys are ALSO kept in step, so an operator who rolls the
add-on back to a single-FSM version finds a CURRENT history rather than one
dated the day the second inverter was declared. store.migrate_maint_keys()
carries the argument, and it is DEV-02's rule 2 applied to history instead
of to a latch: "left in the file" is not the same promise as "still true".
Pack B has no flat spelling to mirror to - the flat keys mean pack A on
every install that has them - so it is constructed with mirroring off, and
a mirror from B would OVERWRITE A's rollback view with B's phase.
"""
def __init__(self, store, unit: str | None = None, mirror_flat: bool = False):
self.store = store
self.unit = unit
self.mirror_flat = bool(mirror_flat)
def key(self, name: str) -> str:
"""The Store key this pack's `name` lives under."""
return name if self.unit is None else unit_key(self.unit, name)
def get(self, name: str, default=None):
return getattr(self.store, "data", {}).get(self.key(name), default)
def set(self, name: str, value) -> None:
self.store.set(self.key(name), value)
if self.mirror_flat:
self.store.set(name, value)
def get_time(self, name: str):
return self.store.get_time(self.key(name))
def set_time(self, name: str, when) -> None:
self.store.set_time(self.key(name), when)
if self.mirror_flat:
self.store.set_time(name, when)
class Maintenance:
def __init__(self, cfg: MaintConfig, store):
"""ONE pack's recalibration cycle: its schedule, its phase, its history.
⚠️ ONE PER ROSTER ENTRY SINCE MAINT-05, AND THE SLOT IS NOT ITS OWN. This
object decides WHEN ITS PACK would like to start; `arbitrate()` decides
which of them may, because the constraint is a property of the grid
connection and no single FSM can see it. Never call `tick()` in a loop
without passing the `blocked_by` that `arbitrate()` returned for it: that is
the two-packs-at-once bug with the interlock present but not wired.
"""
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
name: str = "", mirror_flat: bool = False):
self.cfg = cfg
self.store = store
# ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class
# names an UNQUALIFIED key - "phase", "last_completed" - and the view is
# the one place that turns it into the key this pack owns. A raw
# `store.set("phase", ...)` anywhere below would write pack A's history
# from pack B's state machine, which is the shared-state bug this card
# is about, and there would be nothing to see at a roster of one.
self.store = UnitHistory(store, unit, mirror_flat)
self.unit = unit
# What an event or a refusal calls this pack. At a roster of one it is
# never rendered - a single machine cannot be deferred - so the shipped
# one-inverter reason strings are untouched.
self.name = str(name or unit or "the pack")
# Edge-memo for the deferral note. The action log holds 40 lines and a
# deferral lasts as long as the other pack's cycle - hours - so a line
# per cycle would flush the very context that explains it. Same pattern
# as `Controller._arb_action` and `_maint_shape_suspended`.
self._deferred = ""
# -- state ---------------------------------------------------------------
@property
def phase(self) -> str:
return self.store.data.get("phase", IDLE)
return self.store.get("phase", IDLE)
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None:
self.store.set("phase", phase)
@@ -185,14 +301,60 @@ class Maintenance:
last = self.store.get_time("last_completed")
return None if last is None else last + timedelta(days=self.cfg.interval_days)
def waited_days(self, now: datetime) -> float:
"""Days since this pack last COMPLETED a cycle; `inf` if it never has.
⚠️ THE ARBITRATION KEY, AND IT IS DELIBERATELY NOT `due()`. `due()` is a
yes/no and two packs that are both due are indistinguishable by it -
which is how a roster-order tie-break gets written and how the first
entry then wins every contested month forever while the second drifts.
This is an ORDERING: the pack whose coulomb counter has gone longest
without a recalibration goes first. `inf` for never-recalibrated is the
same statement `due()` makes about an absent `last_completed`, kept as
one so a fresh pack cannot lose to one that ran last month.
"""
last = self.store.get_time("last_completed")
return math.inf if last is None else (now - last).total_seconds() / 86400.0
def _cooled(self, now: datetime) -> bool:
"""Is the post-abort cooldown over?"""
last_try = self.store.get_time("last_start_attempt")
return (last_try is None
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN))
def wants_start(self, now: datetime) -> bool:
"""Would this pack begin a cycle on this tick if the site let it?
⚠️ THE SAME PREDICATE `tick()` USES, NOT A COPY OF IT. `arbitrate()` has
to know who is asking before anybody moves, and a second transcription
of "enabled, in the start hour, due and cooled down" is a second opinion
about who was asking - so the IDLE branch of `tick()` calls this one.
"""
return (self.phase == IDLE and self.cfg.enabled
and now.hour == self.cfg.start_hour and self.due(now)
and self._cooled(now))
# -- operations ----------------------------------------------------------
def force_start(self, now: datetime) -> list:
def force_start(self, now: datetime, blocked_by: str = "") -> list:
"""Start a cycle regardless of the calendar.
Commissioning uses this: a cycle must be proven end to end before a site
is signed off, and nobody waits a month to find out the schedule never
fires.
⚠️ REGARDLESS OF THE CALENDAR, NOT REGARDLESS OF THE INTERLOCK. The
calendar is a preference and an operator may overrule it; "one pack
moves power at a time" is a property of the grid connection and an
operator pressing a button does not add a second one. A forced start
against a busy site is REFUSED, by name, and the phase machine does not
move - the refusal is returned as an event so the operator reads it in
the action log rather than watching nothing happen.
"""
if blocked_by:
msg = (f"maintenance: forced start REFUSED on {self.name} - "
f"{blocked_by}")
_LOG.warning("%s", msg)
return [msg]
events = []
self.store.set_time("last_start_attempt", now)
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
@@ -203,23 +365,80 @@ class Maintenance:
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
return events
def _defer_note(self, why: str, events: list) -> list:
"""Say once, not every second, that this pack is waiting its turn.
⚠️ THE WORDING IS DELIBERATELY NOT AN ABORT AND NOT A TIMEOUT. §12's
notes are explicit that a 12 h phase timeout means "the battery is not
following commands - a FAULT, not a slow day", and the one thing that
must not happen here is an operator reading a deferral as that. This
line says the pack is WAITING, names the pack it is waiting for, and
says it will run next - none of which any failure path in this module
says.
"""
if self._deferred != why:
self._deferred = why
msg = (f"maintenance: {self.name} is due and is WAITING its turn - "
f"{why}. It is idle under the control loop and will "
f"recalibrate at the next start hour once the slot is free; "
f"this is not a stalled phase.")
_LOG.info("%s", msg)
events.append(msg)
return events
# -- the machine ---------------------------------------------------------
def tick(self, now: datetime, soc: float | None) -> MaintResult:
def tick(self, now: datetime, soc: float | None,
blocked_by: str = "") -> MaintResult:
"""One cycle of this pack's state machine.
`blocked_by` is `arbitrate()`'s answer for THIS pack: empty when the
site's one maintenance slot is available to it, otherwise the reason it
is not. It gates two things and nothing else - a start, and a phase that
is already moving power on a pack that does not hold the slot.
"""
events: list = []
phase = self.phase
# ⚠️ THE SLOT IS CHECKED BEFORE THE PHASE, AND THIS BRANCH IS A RECOVERY
# RATHER THAN AN EXPECTED PATH. `arbitrate()` grants the slot to the
# pack that is already mid-cycle, so a pack that is BOTH moving power
# and blocked means two histories on disk say "drain" - a hand-edited
# state file, or a store carried across a version that had no interlock.
# The fail-safe direction is unambiguous: two packs pulling against each
# other on one connection is the fault SAFETY-31 latches the whole site
# for. Stand this one down to 0 W and say why; the holder keeps its
# cycle, which is the outcome that loses no coulomb count at all.
if blocked_by and phase in POWER_MOVING:
events += self.abort(now, f"interlock - {blocked_by}")
return MaintResult(IDLE, True, 0.0, False, events)
if phase == IDLE:
if self.cfg.enabled and now.hour == self.cfg.start_hour and self.due(now):
last_try = self.store.get_time("last_start_attempt")
cooled = (
last_try is None
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN)
)
if cooled:
self.store.set_time("last_start_attempt", now)
self._enter(DRAIN, now, events,
"maintenance: due -> drain (exports the surplus)")
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
if self.wants_start(now):
if blocked_by:
# ⚠️ `last_start_attempt` IS NOT STAMPED HERE, AND THAT IS
# THE STARVATION FIX RATHER THAN AN OMISSION. That key is
# the post-ABORT cooldown: it exists so a cycle that failed
# does not re-trigger inside the same start hour. A deferral
# is not an attempt - nothing was tried and nothing failed -
# and stamping it would put this pack under a 90 minute
# cooldown for a start hour that is 60 minutes long, so the
# pack that lost the arbitration would still be locked out
# at the moment the winner released the slot.
#
# ⚠️ AND THE PHASE DOES NOT MOVE, WHICH IS THE OTHER HALF.
# `phase_started` is untouched, so the 12 h DRAIN and 36 h
# CHARGE timeouts - which mean "the battery is not following
# commands, a fault" - cannot be reached by waiting. A
# deferred pack is IDLE under the loop, not a stuck one.
return MaintResult(IDLE, False, None, False,
self._defer_note(blocked_by, events),
blocked_by)
self._deferred = ""
self.store.set_time("last_start_attempt", now)
self._enter(DRAIN, now, events,
"maintenance: due -> drain (exports the surplus)")
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
self._deferred = ""
return MaintResult(IDLE, False, None, False, events)
# ⚠️ Fail toward inaction. Without a state-of-charge reading none of the
@@ -273,3 +492,77 @@ class Maintenance:
_LOG.error("maintenance: unknown phase %r, forcing idle", phase)
events += self.abort(now, f"unknown phase {phase!r}")
return MaintResult(IDLE, True, 0.0, False, events)
def _days(n: float) -> str:
return "never recalibrated" if n == math.inf else f"{n:.0f} d since its last cycle"
def arbitrate(machines, now: datetime) -> list:
"""Grant the site's ONE maintenance slot. Returns a list parallel to
`machines`: "" for the pack that may move power, otherwise the reason the
pack may not.
⚠️ THE SLOT IS A PROPERTY OF THE GRID CONNECTION, WHICH IS WHY IT IS NOT ON
`Maintenance`. Each FSM can see its own pack's schedule and nothing else; the
quantity being rationed - "how many phases are moving power across one
meter" - is a sum over the roster, exactly like `sum(|B_i|) - |sum(B_i)|` in
SAFETY-31 and for the same reason. A per-pack answer to a site-level
question is the shape of defect this repository keeps finding.
⚠️ IT IS DERIVED, NOT PERSISTED, AND THAT IS DELIBERATE. The holder is
"whichever pack's stored phase is not IDLE" - a fact already on disk, in the
per-pack history the packs themselves write. A separate `maint_slot_holder`
key would be a second opinion about the same thing, and the two would
disagree the first time the add-on was killed between writing the phase and
writing the holder. Restart-safe by construction: a controller that comes
back up mid-drain reads the drain and re-derives the holder from it.
TWO CLAUSES, IN ORDER:
1. A PACK ALREADY MOVING POWER KEEPS THE SLOT. Nothing preempts a cycle in
flight, because a recalibration interrupted halfway is worth less than
nothing - the coulomb counter is graded on ONE uninterrupted drain and
refill, so a cycle stopped at 40 % has spent the energy and bought no
calibration. If more than one pack somehow comes up moving power, the
FIRST in roster order holds and `tick()` stands the others down; see the
recovery branch there.
2. OTHERWISE THE LONGEST-WAITING CANDIDATE STARTS. `wants_start()` is asked
of every pack, and among those that say yes the slot goes to the one with
the largest `waited_days()`. NOT roster order: two packs on one monthly
schedule are due in the same hour every month, and a roster-order tie
would hand the slot to `inverters[0]` every single time while the second
pack's coulomb counter drifted forever - which is, in a different
costume, the exact defect this card was opened for. Roster order breaks a
genuine numeric tie and nothing else.
⚠️ AT A ROSTER OF ONE THIS IS THE IDENTITY FUNCTION. One machine is never
another machine's blocker: clause 1 finds at most itself and clause 2 has at
most one candidate, so it returns `[""]` on every tick and no reason string
it can build is ever rendered. That is what makes the one-inverter behaviour
byte-identical rather than merely similar.
"""
blocks = [""] * len(machines)
busy = [(i, m) for i, m in enumerate(machines) if m.phase in POWER_MOVING]
if busy:
held, holder = busy[0]
for i, m in enumerate(machines):
if i != held:
blocks[i] = (f"{holder.name} is mid-cycle ({holder.phase}) and "
f"holds the site's one maintenance slot")
return blocks
wants = [(i, m) for i, m in enumerate(machines) if m.wants_start(now)]
if len(wants) <= 1:
return blocks
# Largest waited_days wins; `-index` makes a numeric tie fall to roster
# order rather than to whatever `max()` happens to see first.
won, winner = max(wants, key=lambda p: (p[1].waited_days(now), -p[0]))
for i, m in wants:
if i != won:
blocks[i] = (f"{winner.name} has waited longer "
f"({_days(winner.waited_days(now))} against "
f"{_days(m.waited_days(now))}) and takes the site's "
f"one maintenance slot first")
return blocks
+281 -26
View File
@@ -126,16 +126,53 @@ 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".
# ⚠️ THE MAINTENANCE KEYS ARE STILL NOT IN HERE, AND SINCE MAINT-05 THAT IS A
# DIFFERENT DECISION RATHER THAN THE SAME ONE. They DO move now - one
# `Maintenance` per roster entry needs one history per roster entry - but they
# move on their OWN marker, `MAINT_SCHEMA_KEY`, and only on a site that actually
# has more than one pack. Two conditions the SAFETY-02 keys do not share, so
# folding them into this tuple would either drag them across on a
# single-inverter install (which is the byte-identical store layout MAINT-05
# must not change) or hold the sign latch hostage to a roster count it has
# nothing to do with. `MAINT_UNIT_KEYS` below carries them.
LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")
# --------------------------------------------------------------------------- #
# MAINT-05: THE SECOND MIGRATION, AND WHY IT IS A SECOND ONE
#
# 0 = the flat, one-history layout every install has had since the add-on
# shipped: `phase` / `phase_started` / `last_completed` /
# `last_start_attempt` describe THE pack, because there was one FSM.
# 1 = one history per roster entry, under `unit/<name>/...`.
#
# ⚠️ ITS OWN MARKER, NOT A BUMP OF `SCHEMA_VERSION`, AND THE REASON IS THAT THE
# TWO MIGRATIONS DO NOT HAVE THE SAME PRECONDITION. DEV-02's runs on every
# install exactly once and is monotone in the version number. This one runs ONLY
# WHERE THE ROSTER HAS MORE THAN ONE PACK, because at N=1 §6.2's identity holds
# and the flat keys ARE that pack's namespace - moving them would add four keys
# and a version marker to a store MAINT-05's own acceptance criterion requires
# to be byte-identical. A single monotone counter cannot express "migrated on
# this site, deliberately not migrated on that one" without lying to the next
# version that reads it: a one-inverter store would have to be left at the old
# number forever, and then a genuine v2 migration could never be told apart from
# a site that simply never grew.
#
# ⚠️ AND IT IS TWO MARKERS, NOT TWO COPIES OF THE THREE RULES.
# `_move_flat_keys()` and `_verify_moves()` below are shared verbatim by both
# migrations, so rules 1 and 3 and the READ-BACK POSTCONDITION are one
# implementation. A second transcription of a migration is a second opinion
# about what "it landed" means.
MAINT_SCHEMA_VERSION = 1
MAINT_SCHEMA_KEY = "maint_schema_version"
# The flat maintenance-history keys, in the order they are moved.
# ⚠️ THE VALUES ARE THE FLAT SPELLINGS AND THE MODULE THAT READS THEM INDEXES
# THIS SAME TUPLE (`maintenance.UnitHistory`), so a key renamed here cannot
# silently stop being migrated - the same property `UNIT_STORE_KEYS` gives the
# SAFETY-02 pair.
MAINT_UNIT_KEYS = ("phase", "phase_started", "last_completed",
"last_start_attempt")
def unit_key(unit: str, key: str) -> str:
"""The namespaced Store key for `key` on inverter `unit`.
@@ -174,16 +211,35 @@ class MigrationReport:
# 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 = []
# ⚠️ A DIFFERENT FAILURE FROM `lost`, AND IT HAS TO BE, BECAUSE THE
# READ-BACK CANNOT SEE IT. `lost` is "the store took the write and
# dropped it". This is "the store took the write, kept it perfectly, and
# NOTHING WILL EVER READ IT" - the migration was handed a unit name that
# is not on the roster, so every machine looks under a different key and
# finds nothing. There is no harmless-default version of this the way
# there is for `lost`: a write nobody reads is not information, whatever
# its value, so anything in here fails `ok` on its own.
self.misdirected = []
# Why nothing ran, when nothing ran. Empty means "already at schema".
self.skipped = ""
@property
def ok(self) -> bool:
"""Nothing that CARRIED STATE failed to land."""
return not self.lost
"""Nothing that CARRIED STATE failed to land, and it landed WHERE THE
MACHINES LOOK."""
return not self.lost and not self.misdirected
def explain(self) -> str:
if not self.ran:
return (f"store already at schema {self.from_version}; no "
f"per-inverter migration needed")
# ⚠️ `skipped` RATHER THAN A SECOND SENTENCE ABOUT VERSIONS. Since
# MAINT-05 there are two reasons a migration does not run - the
# store is already at this schema, or the roster has one pack and
# the flat keys ARE its namespace - and an operator reading the
# startup log needs to be told which, because only one of them
# changes when a second inverter is declared.
return self.skipped or (f"store already at schema "
f"{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:
@@ -192,6 +248,8 @@ class MigrationReport:
out += " - PROBLEMS: " + "; ".join(self.problems)
if self.lost:
out += " - STATE LOST: " + "; ".join(self.lost)
if self.misdirected:
out += " - WRONG NAMESPACE: " + "; ".join(self.misdirected)
return out
@@ -243,7 +301,30 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
return rep
rep.ran = True
for legacy in LEGACY_UNIT_KEYS:
_move_flat_keys(store, unit, LEGACY_UNIT_KEYS, rep)
_verify_moves(store, rep)
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
def _move_flat_keys(store, unit: str, keys, rep: MigrationReport) -> None:
"""Rule 1, once, for both migrations.
Copies each flat `key` to `unit/<unit>/<key>` UNLESS the namespaced spelling
is already present - a store that has both has been written by a version
that knew about the namespace, and that value is the live one. Rule 2 is the
absence of a delete: the flat keys are left exactly where they were.
"""
data = getattr(store, "data", {})
for legacy in keys:
target = unit_key(unit, legacy)
if target in data:
rep.kept.append(target)
@@ -252,17 +333,63 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
# 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.
# which is the distinction store.py's own header is about. The same
# sentence holds for `last_completed`: absent means "never recalibrated"
# and a literal here would be a second opinion about that.
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.
def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
"""THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION.
This is the difference between these functions 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" - and, since MAINT-05, "is the history that was in the
file before now under the key THIS PACK's state machine reads" - through the
same `unit_key()` those readers read through. A `False` here is a guard that
is off, or a month of coulomb-count history gone, on an install whose state
file says otherwise.
⚠️ AND `roster` IS THE OTHER HALF, WHICH THE READ-BACK ON ITS OWN CANNOT BE.
A POSTCONDITION THAT READS BACK THE NAMESPACE IT WAS HANDED CANNOT DETECT
BEING HANDED THE WRONG ONE. That is not a hypothetical: MAINT-05 shipped
with `migrate_maint_keys()` taking a bare `unit` string, and a call site that
passed `inverter.UNIT_A` instead of the first roster entry's name migrated an
install's whole maintenance history into `unit/inverter-a/...` on a roster of
`["garage", "shed"]`, read all four keys back under that same name, was
SATISFIED, and stamped the schema marker - so the next start skipped the
migration too and both packs came up believing they had never been
recalibrated. Every symptom of a clean migration, and the history gone.
`build_roster()` names a declared roster's entries from the config and falls
back to a POSITIONAL `inverter-N`, so `inverter-a` is not even the default
there; there is no roster on which that write could have been read.
So the namespace is graded against the one the MACHINES read - the roster -
rather than against the argument this function was handed, which is the
thing under test. A unit that is not on the roster is `misdirected`, which
fails `ok`, which means the caller does NOT stamp the version marker and the
next start runs the migration again. A re-run is recoverable, by rule 1; a
silent skip is the state store.py's header says must never happen.
⚠️ AND THE GRADED SET IS `moved` UNION `kept`, WHICH IS WHAT MAKES THAT
PARAGRAPH TRUE ON THE SECOND START AS WELL AS THE FIRST. Graded over `moved`
alone the refusal lasted exactly one start: the wrong call site is still
wrong next time, finds the keys it wrote already present, and rule 1 routes
every one of them to `kept` - so `moved` is empty, this loop sees nothing,
`ok` comes back True and the marker is stamped on a migration that has
reached no readable key. The measurement is in the comment on the loop
itself. `test_maintenance.py` section 9c calls this twice with the SAME
wrong `unit` over one store, which is the shape nothing in the suite had.
`roster=None` means "not graded", and the one caller that passes it is
`migrate_unit_keys()`, which runs BEFORE `build_roster()` for the reason its
own docstring gives - the `Inverter` objects restore their latch in their
constructors, so the keys have to be in place before there is a roster to
check against. That gap is real and is recorded as a running check rather
than left to a comment: see test_maintenance.py section 13.
"""
for target, value in rep.moved.items():
got = getattr(store, "data", {}).get(target, "<<missing>>")
if got == value:
@@ -274,14 +401,142 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
if value != DEFAULTS.get(legacy):
rep.lost.append(note)
if roster is None:
return
names = [str(n) for n in roster]
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split
# through the same spelling the writer used rather than re-deriving it.
#
# ⚠️ `moved` UNION `kept`, AND THE UNION IS THE WHOLE OF THE SECOND FIX
# ROUND. Grading only `moved` made the refusal last EXACTLY ONE START.
# Measured at Controller level over one persisted store, roster
# ["garage", "shed"], flat `last_completed` 2026-08-07, with the call site
# still handing `inverter-a` on both starts:
#
# construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent>
# construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1
#
# On start 2 the four keys the WRONG start 1 wrote are already present, so
# rule 1 routes every one of them to `rep.kept`, `rep.moved` is EMPTY, this
# loop iterates over nothing, `misdirected` is empty and `ok` is True - so
# the caller stamps the marker and start 3 takes the `from_version >=
# SCHEMA` early return FOREVER, including after the call site is fixed.
# Loud-and-recoverable became silent-and-permanent after one restart: the
# exact state this module's header says must never happen, reached by the
# fix that was written to prevent it.
#
# The union is sound by construction rather than by hope: every entry in
# both `rep.moved` and `rep.kept` is a `unit_key(unit, legacy)` built from
# the SAME `unit` argument, so grading `kept` grades the same namespace
# decision `moved` does. A correct call site puts its `kept` entries under a
# roster name and they pass; a wrong one cannot launder its namespace by
# having already written it.
for target in sorted(set(rep.moved) | set(rep.kept)):
parts = target.split("/")
unit = parts[1] if len(parts) > 2 else ""
if unit in names:
continue
rep.verified = False
note = (f"{target} was migrated into namespace {unit!r}, which is NOT "
f"on the roster {names!r} - nothing reads that key")
rep.problems.append(note)
rep.misdirected.append(note)
def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
"""Move the flat maintenance history into the FIRST pack's namespace.
MAINT-05. Until this card there was one `Maintenance` and one history, and
`store.py`'s own header argued the keys should stay flat because per-pack
scheduling was a card of its own. This is that card: there is now one FSM
per roster entry, so there is one history per roster entry, and the flat one
belongs to the pack that was being recalibrated - `inverters[0]`, which is
what `Controller.maint_unit` named and what `validate.py` rule 6 graded
against.
⚠️ `len(roster) <= 1` IS A RETURN, NOT A SPECIAL CASE. At one inverter the
flat keys ARE that pack's namespace (§6.2's identity, the same one
`arbiter.actuator_for()` leans on), `Maintenance` is constructed to read
them directly, and a migration would add four keys plus a marker to a store
that must come out byte-identical. The migration happens on the day the
roster grows, which is the day the flat spelling stops being able to answer
"which pack".
⚠️ `roster` IS REQUIRED, AND IT REPLACED AN `n_units: int`. Two reasons, and
the second is the one MAINT-05 was rejected over.
The small one: `n_units` was `len(self.inverters)` at the only call site, so
the roster carries it - `len(roster)` - and a count passed beside the names
is a second opinion about how many packs there are.
The load-bearing one: THE NAMES ARE THE POSTCONDITION. `unit` says which
pack the flat keys belonged to and that is genuinely a decision - the first
roster entry, because that is the pack the one FSM was driving - but a
decision the caller can get WRONG, and until this signature existed nothing
could tell. `_verify_moves()` read back the namespace it was handed, so a
migration into a name no machine reads verified clean and stamped the
marker. Handing the roster in means the read-back is graded against the
keys the MACHINES read, and a `unit` that is not among these names comes
back `misdirected`: not `ok`, marker NOT written, migration runs again next
start - and again the start after that, because `_verify_moves()` grades
`kept` as well as `moved`. See `_verify_moves()` for the install this was
measured on, and for what grading `moved` alone cost on start 2.
THE THREE RULES ARE `migrate_unit_keys()`'s, SHARED IN CODE rather than
restated: `_move_flat_keys()` is rule 1 (never overwrite a namespaced value)
and rule 2 (the flat keys are left in the file), `_verify_moves()` is the
read-back postcondition, and the marker is written LAST so a crash before it
leaves the migration to run again - which rule 1 makes a no-op.
⚠️ AND RULE 2 IS KEPT LIVE HERE TOO, WHICH IS THE HALF DEV-02 HAD TO ADD
LATER. "Left in place" alone makes the flat history a fossil dated the day
the second inverter was declared: an operator who rolls back to a
single-FSM add-on would find a `last_completed` months old and recalibrate a
pack that does not need it, or - worse - a stale `phase` and a
`phase_started` old enough to abort on the 12 h timeout the moment it comes
up. So `maintenance.UnitHistory` MIRRORS the first pack's writes back onto
the flat keys for as long as the roster has more than one entry. The flat
pair is the ROLLBACK VIEW of the history, maintained, exactly as
`sign_tripped` is the rollback view of the latch.
"""
rep = MigrationReport()
data = getattr(store, "data", {})
names = [str(n) for n in roster]
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
if len(names) <= 1:
rep.skipped = ("one inverter: the flat maintenance keys ARE that pack's "
"history and are read in place, so there is nothing to "
"move and the store layout does not change")
return rep
if rep.from_version >= MAINT_SCHEMA_VERSION:
rep.skipped = (f"maintenance history already at schema "
f"{rep.from_version}; written by a multi-inverter "
f"version and not touched")
return rep
rep.ran = True
_move_flat_keys(store, unit, MAINT_UNIT_KEYS, rep)
_verify_moves(store, rep, roster=names)
# ⚠️ `rep.ok` AND NOTHING ELSE - NOT `ok or kept`, NOT `not lost`, NOT
# `not misdirected`. BOTH guards gate this line and each one is pinned by a
# check that the other cannot satisfy: section 9c refuses a store whose
# failure is `misdirected` with `lost` empty, and section 9d refuses one
# whose failure is `lost` with `misdirected` empty AND `kept` non-empty -
# the mutation `if rep.ok or rep.kept:` stamps the marker on a migration
# that lost a month of history, and until section 9d existed all 19 suites
# were green on it.
if rep.ok:
store.set(SCHEMA_KEY, SCHEMA_VERSION)
store.set(MAINT_SCHEMA_KEY, MAINT_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())
# ⚠️ NOT MARKED DONE, FOR EITHER FAILURE. The same sentence
# `migrate_unit_keys()` carries: the one thing worse than a failed
# migration is a failed migration a later version skips. Rule 1 makes
# the re-run a no-op where the keys did land, so refusing the marker
# costs one repeated pass and buys the difference between a migration
# that can still be fixed and one the store says already happened.
_LOG.error("per-pack maintenance-history migration did NOT land and "
"has not been marked done: %s", rep.explain())
return rep
+39 -16
View File
@@ -802,24 +802,47 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# rule 8 below already makes with `PEAK_OPTION_DEFAULTS` - and
# test_maintenance.py pins it by reading the shipped file.
#
# ⚠️ AND IT IS GRADED AGAINST THE FIRST UNIT'S CAP, NOT THE SMALLEST AND NOT
# THE FLAT ONE. Rule 5 takes the weakest unit because there is a watchdog on
# every one of them; there is exactly ONE `Maintenance` in this add-on and
# `Controller.maint_unit` names the pack it recalibrates - the first in the
# roster, because the cycle's history lives under un-namespaced Store keys.
# The shaping claim is filed on THAT actuator and on no other, so the cap
# that can contradict it is THAT unit's. Taking `min()` here would refuse a
# perfectly workable site - a 5000 W pack being recalibrated beside a 2000 W
# sibling the cycle never touches - which is the over-refusal this file's
# docstring says gets a validator switched off. When `maint_unit` stops
# being `inverters[0]`, this line moves with it.
# ⚠️ MAINT-05: IT IS GRADED AGAINST THE WEAKEST UNIT'S CAP NOW, AND THE
# COMMENT THIS REPLACES SAID SO WOULD HAPPEN. It read: "there is exactly ONE
# `Maintenance` in this add-on and `Controller.maint_unit` names the pack it
# recalibrates - the first in the roster, because the cycle's history lives
# under un-namespaced Store keys. The shaping claim is filed on THAT
# actuator and on no other, so the cap that can contradict it is THAT
# unit's. Taking `min()` here would refuse a perfectly workable site - a
# 5000 W pack being recalibrated beside a 2000 W sibling the cycle never
# touches ... When `maint_unit` stops being `inverters[0]`, this line moves
# with it."
#
# `maint_unit` has stopped being `inverters[0]`. There is one `Maintenance`
# per roster entry, each with its own namespaced history, and EVERY pack is
# recalibrated on its own schedule - so the sibling "the cycle never
# touches" does not exist any more and the over-refusal argument has no
# subject. The shaping claim is filed on whichever pack is charging, so the
# cap that can contradict it is any cap on the roster, and the one that
# contradicts it FIRST is the smallest. This is rule 5's conjunction for
# rule 5's reason: a threshold that is fine against the biggest unit says
# nothing about the smallest, and the smallest is the one that goes to 0 W.
#
# ⚠️ THIS REFUSES CONFIGS THAT USED TO START, AND THAT IS THE POINT RATHER
# THAN A SIDE EFFECT. `max_w` 5000 on pack A beside 2000 on pack B with
# `maintenance_charge_w` 3000 was accepted before this card and was correct
# while only pack A was ever recalibrated. Pack B now recalibrates too, and
# its cheap-window charge asks the arbiter for `hi = -3000` against a
# supervised `lo = -2000`: `lo > hi`, arbiter clause 3, 0 W, contradiction -
# a maintenance charge that silently never happens on that pack, which is
# exactly what rule 6 exists to refuse at the moment it is WRITTEN. At a
# roster of one `min()` over one cap is that cap and the comparison, the
# message and the boundary are identical.
charge_w = _number(opts, "maintenance_charge_w",
float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"]))
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON. `caps[0]` is the first
# unit WITH A USABLE CAP, which is `inverters[0]` only while every earlier
# entry has one; `_unit_label(opts, 0)` was hard-wired to `inverters[0]`
# regardless. Both come off the one pair now.
maint_index, maint_cap = caps_ix[0] if caps_ix else (0, None)
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON: `unit_caps_indexed()`
# SKIPS entries with no usable `max_w`, so a caps-list position is not a
# roster position and `_unit_label()` indexes the roster. The index travels
# with the number, so the unit the message names is the unit the number came
# off - which on a roster whose first entry has a junk `max_w` is not the
# same thing at all.
maint_index, maint_cap = (min(caps_ix, key=lambda p: p[1]) if caps_ix
else (0, None))
maint_where = _unit_label(opts, maint_index)
if charge_w is None:
problems.append(
+85 -6
View File
@@ -572,6 +572,58 @@ check("...and a store that only fails to persist a DEFAULT is not reported as "
"dismiss before the real one arrives",
migrate_unit_keys(LosesTheLatch(), UNIT_A).ok is True)
# ⚠️ AND THE MARKER GATE, ON A STORE WHERE SOMETHING WAS ALREADY NAMESPACED.
# FOUND BY MAINT-05's ROUND-3 BATTERY, AND IT WAS A SURVIVOR OF ALL 19 FILES.
# `migrate_unit_keys()` gates `SCHEMA_KEY` on `rep.ok`, and every check above
# reaches that gate on a store where NOTHING was already under `unit/...` - so
# `rep.kept` is empty in all of them, and the mutation
#
# if rep.ok: -> if rep.ok or rep.kept:
#
# stamped the marker on a migration that DROPPED THE LATCH, with the whole suite
# green. That is a disarmed safety guard plus a store that says the migration
# finished, so the next start skips the one thing that would have re-armed it -
# DEV-02's own worst case with the paper trail filed under "done".
#
# The fixture is the one shape that separates them: `sign_reason` ALREADY
# namespaced (so `kept` is non-empty and the mutant's `or` fires) and the
# `sign_tripped: true` write dropped on the floor (so `lost` is non-empty and
# `ok` is False). `migrate_unit_keys()` is passed no roster, so `misdirected`
# cannot be what refuses this - it is the `lost` guard or nothing.
_kept_reason = "SIGN_DISAGREEMENT: already namespaced by a later version"
_broken_kept = LosesTheLatch({
"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: measured",
unit_key(UNIT_A, "sign_reason"): _kept_reason})
_rep_kept = migrate_unit_keys(_broken_kept, UNIT_A)
print(f" a dropped latch write beside an already-namespaced reason: "
f"{_rep_kept.explain()}")
print(f" lost={len(_rep_kept.lost)} kept={len(_rep_kept.kept)} "
f"ok={_rep_kept.ok}; marker "
f"{_broken_kept.data.get(SCHEMA_KEY, '<<absent>>')!r}")
check("PRECONDITION: this fixture reaches the marker gate through the `lost` "
"guard with a NON-EMPTY `kept`, which is the combination none of the "
"checks above has. One key dropped, one key left alone, `ok` False. If a "
"later edit empties `kept` here the mutation goes invisible again, so the "
"shape is asserted rather than assumed",
_rep_kept.ran and len(_rep_kept.lost) == 1 and len(_rep_kept.kept) == 1
and not _rep_kept.ok
and _broken_kept.data[unit_key(UNIT_A, "sign_reason")] == _kept_reason)
check("⚠️ AND THE STORE IS NOT MARKED MIGRATED. The key that was ALREADY there "
"does not buy a marker for the latch that was not: stamping here would "
"tell the next start that the migration which dropped `sign_tripped` had "
"finished, so it would never run again and the guard would stay off on an "
"install whose file still reads `sign_tripped: true`",
SCHEMA_KEY not in _broken_kept.data)
check("...and the counterfactual, one property different: the SAME store shape "
"on a store that does not drop writes IS marked migrated. So the refusal "
"above is about the latch that was lost, not about `kept` being non-empty",
migrate_unit_keys(
MemStore({"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: measured",
unit_key(UNIT_A, "sign_reason"): _kept_reason}),
UNIT_A).ok is True)
_broken2 = LosesTheLatch({"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: measured"})
_c2d, _ = built(store=_broken2)
@@ -1629,11 +1681,28 @@ check("⚠️ RULE 5 NAMES THE UNIT THE NUMBER CAME OFF. The weakest cap is "
"is inverter-B. The refusal says inverter-c",
_why(_p_skew, "max_w (inverter-c) is 1000 W")
and not _why(_p_skew, "max_w (inverter-b) is 1000 W"))
check("⚠️ RULE 6 TOO. `caps[0]` is the first entry WITH a usable cap - "
"inverter-b - while `_unit_label(opts, 0)` was hard-wired to inverter-a, "
"so the maintenance refusal quoted B's rating under A's name",
_why(_p_skew, "max_w (inverter-b) is 2000 W")
and not _why(_p_skew, "max_w (inverter-a) is 2000 W"))
# ⚠️ MAINT-05 MOVED RULE 6's SUBJECT, AND THIS CHECK MOVED WITH IT. It used to
# assert the refusal named inverter-b, because rule 6 graded the FIRST entry
# with a usable cap - `Controller.maint_unit` was `inverters[0]` and the shaping
# claim could be filed nowhere else. There is now one `Maintenance` per roster
# entry, so the shaping claim can be filed on ANY pack and the cap that
# contradicts it first is the SMALLEST - inverter-c's 1000 W. The property this
# check exists for is unchanged and is the reason it is still here: the index
# has to travel with the cap, or the message quotes one unit's rating under
# another unit's name.
check("⚠️ RULE 6 TOO, AND SINCE MAINT-05 IT IS THE WEAKEST PACK IT NAMES. "
"inverter-c's 1000 W sits at caps index 1, and `_unit_label(opts, 1)` is "
"inverter-B - so a caps index leaking into a roster index would say "
"inverter-b here. The refusal says inverter-c, with inverter-c's number",
_why(_p_skew, "maintenance_charge_w is 3000 W but max_w (inverter-c) is "
"1000 W")
and not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
"(inverter-b)"))
check("...and it is no longer graded against the FIRST usable cap at all, which "
"is the behaviour change MAINT-05 makes here: inverter-b's 2000 W was the "
"pre-card answer and appears nowhere in the maintenance refusal now",
not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
"(inverter-b) is 2000 W"))
check("...and the counterfactual, so this is not a check that would pass on a "
"validator that named nothing: the OLD expressions still evaluate, on "
"this same config, to the wrong names",
@@ -2132,6 +2201,16 @@ MUTATIONS = [
("validate.py allowing two roster entries with the SAME name",
"part 10: 'two entries with the SAME name are refused' - one Store "
"namespace for two units is DEV-02's defect rebuilt in YAML"),
("`migrate_unit_keys()`'s marker gate `if rep.ok:` -> `if rep.ok or "
"rep.kept:` - the schema marker stamped on a migration that DROPPED THE "
"LATCH, because one key happened to be namespaced already",
"part 10: 'AND THE STORE IS NOT MARKED MIGRATED'. ⚠️ THIS ONE SURVIVED "
"ALL 19 FILES and was found by MAINT-05's round-3 battery, not by this "
"card: every migration check here reached the gate on a store where "
"NOTHING was namespaced yet, so `rep.kept` was empty in all of them and "
"the `or` never fired. Stamping there disarms the latch AND tells the "
"next start the migration finished, so it never re-arms - DEV-02's own "
"worst case filed under 'done'"),
]
# ⚠️ FOUR OF THESE SURVIVED THE FIRST TIME AND THE CHECKS WERE CHANGED, NOT THE
# TABLE - two in DEV-02, two more in CFG-04, and the CFG-04 pair are recorded in
@@ -2150,7 +2229,7 @@ for what, caught in MUTATIONS:
check(f"all {len(MUTATIONS)} defeats of this card are caught by a named check "
f"above. A guard whose checks survive its own defeat is not a guard, and "
f"this repository has three separate records of exactly that",
len(MUTATIONS) == 34)
len(MUTATIONS) == 35)
print()
File diff suppressed because it is too large Load Diff
+58
View File
@@ -353,6 +353,64 @@ check("...and it is refused with maintenance_enabled off, as shipped",
base(max_w=2000, maintenance_charge_w=2500,
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
# ⚠️ MAINT-05: AND AT TWO PACKS IT IS THE WEAKEST CAP, NOT THE FIRST ENTRY'S.
# The comment this replaces in validate.py said, in as many words, that it took
# the FIRST unit's cap because there was exactly one `Maintenance` and it could
# only ever recalibrate `inverters[0]` - and that "when `maint_unit` stops being
# `inverters[0]`, this line moves with it". It has. Every pack is now
# recalibrated on its own schedule, so the shaping claim can be filed on any of
# them and the cap that contradicts it first is the smallest.
#
# ⚠️ THE FIXTURE'S TWO CAPS ARE DELIBERATELY DIFFERENT AND THE RATE SITS
# BETWEEN THEM. Two equal caps could not tell "grades the first" from "grades
# the smallest", and a rate above both would be refused by either rule - so
# 3000 W against caps of 5000 W and 2000 W is the one arrangement where the old
# answer and the new one disagree. ⚠️ 2000 W for the second pack is a
# HYPOTHESIS: inverter B does not physically exist and nothing here is measured
# from one.
def _roster(a_max, b_max, **over):
o = base(**over)
for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"):
o.pop(k, None)
o["inverters"] = [
dict(name="inverter-a", setpoint_entity="number.sp_a",
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
max_w=a_max),
dict(name="inverter-b", setpoint_entity="number.sp_b",
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
max_w=b_max)]
return o
_two = _roster(5000, 2000, maintenance_charge_w=3000, maintenance_discharge_w=1000,
sign_disagree_w=300, unit_sign_disagree_w=300)
_p_two = [p for p in validate_startup_config(_two, -5000, 5000)
if "maintenance_charge_w" in p]
print(f" 5000 W and 2000 W packs, charge 3000 W -> {_p_two}")
check("⚠️ A CHARGE RATE THE SMALLEST PACK CANNOT TAKE IS REFUSED, even though "
"the FIRST pack could. Before MAINT-05 the second pack was never "
"recalibrated, so its cap could not contradict anything; it is now, and "
"its cheap-window charge would ask for hi=-3000 against lo=-2000 - the "
"`lo > hi` contradiction, 0 W, on the pack nobody was watching",
len(_p_two) == 1 and why(_p_two, "max_w (inverter-b) is 2000 W"))
check("...and the message names the pack the number came off, not the first "
"entry - an operator told to lower a number on the wrong device is being "
"sent to the wrong device by the message that exists to stop that",
not why(_p_two, "max_w (inverter-a)"))
check("...while the same rate against two packs that can both take it starts, "
"so this is a comparison and not a blanket refusal of rosters",
[p for p in validate_startup_config(
_roster(5000, 4000, maintenance_charge_w=3000,
maintenance_discharge_w=1000, sign_disagree_w=300,
unit_sign_disagree_w=300), -5000, 5000)
if "maintenance_charge_w" in p] == [])
check("...and at a ROSTER OF ONE `min()` over one cap is that cap: the same "
"message, the same boundary, the same refusal the flat config always got",
why(validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
-5000, 5000), "THE CEILING WINS")
and validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
-5000, 5000) == [])
# ⚠️ THE SIBLING, CHECKED RATHER THAN ASSUMED. The shaping is NOT symmetric:
# drain files Claim.set at P_MAINTENANCE and owns the actuator, so the ceiling
# CLAMPS it (+2000 W) instead of contradicting it, and the arbiter's explain()