Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a17fcf5b86 | ||
|
|
59ab73559e | ||
|
|
619b218110 | ||
|
|
041ec0a61b | ||
|
|
3934ad7ea5 | ||
|
|
905b6fd2b3 | ||
|
|
65cddf35f1 | ||
|
|
1aef19e995 |
@@ -2,6 +2,122 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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.
|
||||||
|
|
||||||
**`bms_soc_floor` describes a BATTERY, so at two packs it stops being one number
|
**`bms_soc_floor` describes a BATTERY, so at two packs it stops being one number
|
||||||
(SAFETY-32).** The peak guard's energy backstop rests on two figures: the EMS
|
(SAFETY-32).** The peak guard's energy backstop rests on two figures: the EMS
|
||||||
floor `peak_soc_floor` (the depth a money-saving discharge may never reach) and
|
floor `peak_soc_floor` (the depth a money-saving discharge may never reach) and
|
||||||
@@ -125,6 +241,71 @@ thing that stops. Rule 8's own failure mode, reached through rule 8.
|
|||||||
cannot permit anything either way because `pack_floor_soc` is `None` at an
|
cannot permit anything either way because `pack_floor_soc` is `None` at an
|
||||||
empty roster.
|
empty roster.
|
||||||
|
|
||||||
|
* ⚠️ **THIRD FIX ROUND — the same family was alive at the SIBLING ARGUMENT of
|
||||||
|
the same call.** Rounds 2 and 3 hardened the FLOOR axis; `decide()` is handed
|
||||||
|
a second reduction over the same roster beside it, `soc=self.pack_floor_soc`,
|
||||||
|
and `peakguard.py`'s own docstring names the two as a pair. Three rewrites of
|
||||||
|
`Controller.pack_floor_soc` were green across all 19 files on `52380c0` and
|
||||||
|
again after the rebase onto `3934ad7`: `min(float(v) for v in vals)` →
|
||||||
|
`vals[:2]` (+4), `vals[-2:]` (+5) and `vals[:3]` (+4), while `[:1]`, `[-1:]`,
|
||||||
|
`[1:]`, `[:-1]`, `max()` and the mean were all red. **The suite's separating
|
||||||
|
power on the SoC axis stopped at exactly N=2** — the state the floor axis was
|
||||||
|
in before round 3. It changes a real answer: a three-pack roster at
|
||||||
|
50 / 60 / 11 % with the emptiest pack THIRD answers 11 % shipped and 50 %
|
||||||
|
mutated, so the site is graded for discharge PERMISSION against a pack 39
|
||||||
|
points fuller than the emptiest and spends a pack its own BMS stops. Round 3's
|
||||||
|
machinery transfers directly — new **part 12i**: six real `cycle()`s with the
|
||||||
|
emptiest pack at every position of a three-pack roster and both positions of a
|
||||||
|
two-pack one (the observable read off `PeakDecision.soc`, cut-outs held flat
|
||||||
|
so the floor axis cannot be what moved the answers); computed slice and
|
||||||
|
non-slice tables that go red naming any member nothing kills; a **length
|
||||||
|
sweep** of 78 real rosters, lengths 1–12, closing head and tail windows of
|
||||||
|
width 1–11 and recording width 12 as honestly unreached; a **none-guard
|
||||||
|
sweep** over the same 78; and **three exact structural pins** — the `vals`
|
||||||
|
comprehension must iterate the bare `self.inverters`, and both `any()` and
|
||||||
|
`min()` must reduce the whole `vals` — which close every slice width at every
|
||||||
|
roster length. No shipped behaviour changed; only what observes it.
|
||||||
|
* ⚠️ **SPIKE-04: production is DEGENERATE on the floor axis, so the
|
||||||
|
differing-floor fixtures are the entire exposure.** Pack B is 4 × Dyness
|
||||||
|
DL5.0C, DoD 90 → floor 10 % — **FROM THE DATASHEET AND THE SERIAL; inverter B
|
||||||
|
does not exist and nothing about it is measured.** Pack A is DoD 90 → 10 % as
|
||||||
|
well, and that one *is* measured. Both packs therefore floor at the same value
|
||||||
|
in the field, `max(10, 10)` is 10 whatever it reduces, and a differing floor
|
||||||
|
exists **only in the fixtures**. That is the argument for keeping them, not for
|
||||||
|
simplifying them: anyone who collapses `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to
|
||||||
|
one number deletes the only data this repository has that can see the
|
||||||
|
reduction at all, and part 12c2's precondition check goes red if they do. The
|
||||||
|
**SoC** axis has no such degeneracy — two batteries at the same state of
|
||||||
|
charge is a coincidence, not a configuration — which is what makes
|
||||||
|
`pack_floor_soc` the half of the pair that will be live from the day inverter
|
||||||
|
B is wired up.
|
||||||
|
* ⚠️ **The FAMILY enumerated rather than the member closed, and it found four
|
||||||
|
more survivors in code this card never touched.** Every reduction over
|
||||||
|
`self.inverters` / `self.packs` in `Controller` was found from the AST and
|
||||||
|
mutated to a slice against the full suite. Eighteen were already graded
|
||||||
|
(`site_batt_w`, `site_rating_w`, `site_max_w`, `site_dev_bound`,
|
||||||
|
`site_circulating_w`, `inputs_ok`, `units`, `Controller.packs`, `maint_unit`,
|
||||||
|
the maintenance-due minimum, the store-migration roster, `validate_unit_ranges`
|
||||||
|
and the round-2/3 sites). **Four were not, and three of them are SAFETY-30's,
|
||||||
|
all `any(i.sign_wd.tripped for i in self.inverters)`** — `cycle()`'s
|
||||||
|
stale-input hold, `publish()`'s status vocabulary and `status_payload()`'s
|
||||||
|
banner — each +4 bytes and green across all 19 files, for the reason every
|
||||||
|
blocker on this card was invisible: **the only fixture that reached them was a
|
||||||
|
roster of ONE, where `self.inverters[:1]` IS `self.inverters`.** The first has
|
||||||
|
teeth: a latched unit must take the roster past that hold or its `failsafe`
|
||||||
|
SET stops being re-filed the moment a poll is missed, which is main.py's own
|
||||||
|
sentence for it. New **test_watchdog.py part 8** grades all three with the
|
||||||
|
latch at EACH position plus a no-latch counterfactual, and new **part 12j**
|
||||||
|
pins all 26 roster comprehensions in `Controller` to iterating the roster
|
||||||
|
whole — so a reduction added over a slice anywhere in the class is red on the
|
||||||
|
line it is written, with no fixture required. Two rows are reported open
|
||||||
|
rather than closed: `_grade_store_migration()`'s second disjunct (now red on
|
||||||
|
12j, and argued to be an equivalent mutant because a unit tripped at startup
|
||||||
|
implies its namespaced key is set) and `cycle()`'s undeliverable-unit **log
|
||||||
|
line**, where `sorted(...)[:1]` (+4 bytes) is still green — it truncates names
|
||||||
|
in one event message; the refusal itself is a different expression and is
|
||||||
|
graded, so the site still stops.
|
||||||
|
|
||||||
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
|
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
|
||||||
exist yet. Every second-pack figure — in fixtures, in the probe, in
|
exist yet. Every second-pack figure — in fixtures, in the probe, in
|
||||||
config.yaml's commented example — is **simulated and labelled**; only inverter
|
config.yaml's commented example — is **simulated and labelled**; only inverter
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -311,6 +311,173 @@ refuses the two-model config that used to start; the permission is answered per
|
|||||||
pack, so the alarm names the pack that stopped the site rather than "the
|
pack, so the alarm names the pack that stopped the site rather than "the
|
||||||
battery"; and an entry with no stated cut-out is withheld instead of defaulted.
|
battery"; and an entry with no stated cut-out is withheld instead of defaulted.
|
||||||
|
|
||||||
|
## 4b. ⚠️ ROUND 4 — the sibling argument, and SPIKE-04 making this the live axis
|
||||||
|
|
||||||
|
Rounds 2 and 3 hardened the **floor** axis: the `max` inside
|
||||||
|
`binding_soc_floor()`, the roster handed to it, `Controller.packs`, and
|
||||||
|
`pack_floor_pct()`'s conjunction. Round 4's blocker is the same family at the
|
||||||
|
**sibling argument of the same call**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.peak_guard = peakguard.decide(
|
||||||
|
...,
|
||||||
|
soc=self.pack_floor_soc, # MINIMUM <- round 4
|
||||||
|
soc_floor=peakguard.binding_soc_floor(self.packs, ...), # MAXIMUM <- rounds 2-3
|
||||||
|
```
|
||||||
|
|
||||||
|
`peakguard.py`'s own docstring names them as a pair — *"MAXIMUM here pairs with
|
||||||
|
the MINIMUM in `pack_floor_soc`"* — and a conjunction is exactly as strong as
|
||||||
|
its weaker half. On `52380c0`, with all 19 files green and verified again after
|
||||||
|
the rebase onto `3934ad7`:
|
||||||
|
|
||||||
|
| mutation in `Controller.pack_floor_soc` | bytes | before round 4 | now |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `min(float(v) for v in vals[:2])` | +4 | **GREEN** | RED |
|
||||||
|
| `min(float(v) for v in vals[-2:])` | +5 | **GREEN** | RED |
|
||||||
|
| `min(float(v) for v in vals[:3])` | +4 | **GREEN** | RED |
|
||||||
|
| `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)`, the mean | | RED | RED |
|
||||||
|
|
||||||
|
The suite's separating power on the SoC axis stopped at exactly **N = 2** —
|
||||||
|
precisely the state the floor axis was in before round 3, and which round 3
|
||||||
|
itself named as its blocker 2. A fixture of N packs cannot separate reductions
|
||||||
|
that agree on N packs.
|
||||||
|
|
||||||
|
**It changes a real answer.** A three-pack roster at 50 / 60 / 11 %, emptiest
|
||||||
|
pack **third**: the shipped property answers 11 %, `vals[:2]` answers 50 %. The
|
||||||
|
site is then graded for discharge *permission* against a pack 39 points fuller
|
||||||
|
than the emptiest, and spends a pack its own BMS stops — through
|
||||||
|
`sim/inject.py`, which runs no validator rule at all. The verdict flips with it:
|
||||||
|
11 % is below the 20 % EMS floor, so the shipped site is floor-limited and sizes
|
||||||
|
no forced discharge, where the mutant is permitted and sizes a positive one.
|
||||||
|
|
||||||
|
### ⚠️ SPIKE-04 makes the differing-floor discipline the entire exposure
|
||||||
|
|
||||||
|
SPIKE-04 reads pack B as **4 × Dyness DL5.0C, DoD 90 → floor 10 %** — **FROM THE
|
||||||
|
DATASHEET AND THE SERIAL. Inverter B does not physically exist (~8 days out) and
|
||||||
|
nothing about it has been measured.** Pack A is DoD 90 → 10 % as well, and *that
|
||||||
|
one is measured*, off register `0x0560` on the reference GW5000S-BP.
|
||||||
|
|
||||||
|
So **production is degenerate on the floor axis**: both packs floor at the same
|
||||||
|
value, `max(10, 10) == 10`, and a differing floor exists **only in the
|
||||||
|
fixtures**. That is not an argument for simplifying the fixtures to matching
|
||||||
|
values — it is the argument for keeping them. `binding_soc_floor()`'s reduction
|
||||||
|
is observable *nowhere else*, and the moment a third pack, a replacement pack or
|
||||||
|
a different chemistry arrives it is observable in the field. **Anyone who
|
||||||
|
"simplifies" `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to one number deletes the only
|
||||||
|
data this repository has that can see the reduction at all**; part 12c2's
|
||||||
|
precondition check goes red if they do, and this paragraph is why.
|
||||||
|
|
||||||
|
The **SoC axis has no such degeneracy**. Two batteries at the same state of
|
||||||
|
charge is a coincidence, not a configuration, so `pack_floor_soc` is the half of
|
||||||
|
the pair that will be non-degenerate in the field from the day inverter B is
|
||||||
|
wired up.
|
||||||
|
|
||||||
|
### The treatment, transferred from round 3
|
||||||
|
|
||||||
|
* **the roster SET** — six real `Controller.cycle()` runs, the emptiest pack at
|
||||||
|
every position of a three-pack roster and both positions of a two-pack one,
|
||||||
|
the observable read off `PeakDecision.soc` (the argument, echoed) rather than
|
||||||
|
off a spy or a grep. Cut-outs held flat across all of them, so the floor axis
|
||||||
|
cannot be what moved the answers.
|
||||||
|
* **the positional-slice table and the non-slice table** — computed against the
|
||||||
|
real `Controller.inverters` each cycle carried and the real decision it
|
||||||
|
produced, printed, with a member that nothing kills taking the check red.
|
||||||
|
* **the length sweep** — the real property on real rosters of 1 to 12 inverters,
|
||||||
|
the empty pack at every position. Closes head and tail windows of width 1-11
|
||||||
|
at once. Width 12 and wider is **honestly not reached**, and asserted to be
|
||||||
|
unreached so a later sweep that lengthens the rosters must rewrite the
|
||||||
|
sentence.
|
||||||
|
* **the exact structural pin** — three of them, because there are three
|
||||||
|
reductions over roster length in this one property: `vals` is built by one
|
||||||
|
comprehension over the bare `self.inverters`; `any(... for v in vals)` reduces
|
||||||
|
the whole list; `min(... for v in vals)` reduces the whole list. Every slice
|
||||||
|
of either is red at **every** roster length, including the `[:3]` and `[:12]`
|
||||||
|
the runs and the sweep cannot see.
|
||||||
|
|
||||||
|
Unlike the floor axis, **there is no uncaught row here**: the claim at
|
||||||
|
`pack_floor_soc` is an *identity* ("this list"), so a shape pin is the right
|
||||||
|
instrument, where `binding_soc_floor()`'s claim is *semantic* ("the maximum")
|
||||||
|
and a shape pin there would refuse honest refactors. That asymmetry is why one
|
||||||
|
carries an open row and the other does not.
|
||||||
|
|
||||||
|
## 4c. ⚠️ THE ENUMERATION — what OTHER reductions over roster length are graded
|
||||||
|
|
||||||
|
This card has now been rejected three times for the same shape one step over, so
|
||||||
|
round 4 stopped closing members and enumerated the **family**. Every reduction
|
||||||
|
over `self.inverters` / `self.packs` in `Controller` was found from the AST and
|
||||||
|
mutated to a slice, one at a time, against the full 19-file suite.
|
||||||
|
|
||||||
|
| # | site | reduction | consumer | mutation | result |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | `pack_floor_soc` | `min` over SoC | `decide(soc=)`, `arbitrage.decide(soc=)` | `vals[:2]` +4 | **was GREEN → now RED** (12i) |
|
||||||
|
| 2 | `pack_floor_soc` | `any(v is None)` | same | `vals[:1]` +4 | RED (raises in 12d; pinned in 12i) |
|
||||||
|
| 3 | `pack_floor_soc` | the `vals` comprehension | same | `self.inverters[:2]` +4 | RED (12i sweep + pin) |
|
||||||
|
| 4 | `binding_soc_floor` | `max` over floors | `decide(soc_floor=)` | `min`, windows | RED (12c2, round 2-3) |
|
||||||
|
| 5 | call site | the roster argument | same | `self.packs[:1..3]` | RED (12g, round 3) |
|
||||||
|
| 6 | `Controller.packs` | the comprehension | `binding_soc_floor`, `pack_permissions` | `self.inverters[:2]` +4 | RED (12g three-pack runs) |
|
||||||
|
| 7 | `site_batt_w` | `sum` + `any(None)` | `decide(batt_w=)` — sizes the floor | `self.inverters[:1]` +4 | RED (test_inverter.py) |
|
||||||
|
| 8 | `site_rating_w` | `sum` of per-unit conjunction | `decide(achievable_w=)`, freeze rail | `[:1]` +4 | RED (test_inverter.py) |
|
||||||
|
| 9 | `site_max_w` | `sum` of `max_w` | the site loop's supervised bound | `[:1]` +4 | RED (test_inverter.py) |
|
||||||
|
| 10 | `site_dev_bound` | `sum` + `any(None)` | `looks_frozen`, `rail_unreachable` | `[:1]` +4 | RED (test_inverter.py) |
|
||||||
|
| 11 | `site_circulating_w` | `circulating_w` over measured power | SAFETY-31 trip | `[:1]` +4 | RED (test_watchdog.py) |
|
||||||
|
| 12 | `inputs_ok` | `all` over both readings | gates the whole cycle (§11.1 rule 1) | `[:1]` +4 | RED (test_inverter.py) |
|
||||||
|
| 13 | `units` | `tuple` of names | the arbiter's scope table | `[:1]` +4 | RED (5 files) |
|
||||||
|
| 14 | `status_payload` | `min(_dues)` over `self.maints` | the operator's "maintenance due" row | `_dues[:1]` +4 | RED (test_maintenance.py) |
|
||||||
|
| 15 | `maint_unit` | `self.inverters[self.maint_index]` | which pack the FSM drives | `[0]` −15 | RED (test_maintenance.py) |
|
||||||
|
| 16 | store migration | the roster name list | MAINT-05's key migration | `[:1]` +4 | RED (test_maintenance.py) |
|
||||||
|
| 17 | `validate_unit_ranges` | the roster argument | startup refusal | `[:1]` +4 | RED (test_inverter.py) |
|
||||||
|
| 18 | `cycle()` stale-input hold | `any(sign_wd.tripped)` | **a latched unit's failsafe SET** | `[:1]` +4 | **was GREEN → now RED** (test_watchdog.py part 8) |
|
||||||
|
| 19 | `publish()` status | `any(sign_wd.tripped)` | the MQTT status vocabulary | `[:1]` +4 | **was GREEN → now RED** (part 8) |
|
||||||
|
| 20 | `status_payload()` banner | `any(sign_wd.tripped)` | the one headline on the page | `[:1]` +4 | **was GREEN → now RED** (part 8) |
|
||||||
|
| 21 | `_grade_store_migration` | `any(sign_wd.tripped)` | SAFETY-02's re-arm guard | `[:1]` +4 | was GREEN → now RED (12j), see below |
|
||||||
|
| 22 | `cycle()` undeliverable list | `sorted(...)` over shares | the *text* of one event line | `[:1]` +4 | **STILL GREEN — honest gap, below** |
|
||||||
|
|
||||||
|
**Rows 18-20 are the finding, and they are not this card's code.** All three are
|
||||||
|
`any(i.sign_wd.tripped for i in self.inverters)`, all three are SAFETY-30's, and
|
||||||
|
all three survived a green 19-file suite for exactly the reason every blocker on
|
||||||
|
this card survived: *the only fixture that reached them was a roster of ONE,
|
||||||
|
where `self.inverters[:1]` IS `self.inverters`.* Part 3 of test_watchdog.py has
|
||||||
|
built a two-inverter roster with only inverter B latched since SAFETY-30
|
||||||
|
shipped — the precise data all three needed — and none of them read it.
|
||||||
|
|
||||||
|
Row 18 is the one with teeth. A latched unit must take the whole roster past the
|
||||||
|
stale-input hold, because that unit's `failsafe` SET is the thing parking it;
|
||||||
|
main.py's own comment says *"the trip would stop acting the moment a poll was
|
||||||
|
missed"*. With `[:1]`, a latched inverter B plus a one-cycle gap in inverter A's
|
||||||
|
SoC returns early, and B's last share stands for the heartbeat to re-emit.
|
||||||
|
test_watchdog.py part 8 now drives that with the latch at **each** position, plus
|
||||||
|
the no-latch counterfactual that proves the hold really fires.
|
||||||
|
|
||||||
|
**Row 21 — graded structurally, and argued to be redundant.** The `[:1]` there
|
||||||
|
is now red on 12j's enumeration, but I could not construct a *reachable* state in
|
||||||
|
which it changes an answer: `_grade_store_migration()` runs at startup, before
|
||||||
|
any cycle, so a unit is tripped in memory only if its own `unit/<name>/sign_tripped`
|
||||||
|
key was truthy — which makes `namespaced_latched` True and short-circuits the
|
||||||
|
disjunct. I believe it is an equivalent mutant at that call site rather than a
|
||||||
|
live hole, and I am recording the argument instead of asserting the conclusion.
|
||||||
|
|
||||||
|
**Row 22 — STILL GREEN, and reported with its size.** `", ".join(sorted(u for u
|
||||||
|
in resolution.shares if not self._can_write(u)))[:1]` truncates the **names in
|
||||||
|
one event-log line**. It is +4 bytes and no check bites. The refusal itself —
|
||||||
|
`elif {u for u in resolution.shares if not self._can_write(u)}` — is a different
|
||||||
|
expression and *is* graded (test_peakguard.py part 10h-1b), so the site still
|
||||||
|
stops; what a `[:1]` costs is an operator being told one name when two units have
|
||||||
|
no writer. It is not a roster comprehension (it iterates `resolution.shares`), so
|
||||||
|
12j does not cover it and does not pretend to. Closing it needs a three-inverter
|
||||||
|
fixture with two writer-less entries, which is ALLOC-01's fixture and not this
|
||||||
|
card's. **Filed as open, with its size and its consequence, rather than closed
|
||||||
|
quietly or left unmentioned.**
|
||||||
|
|
||||||
|
### And the family closed rather than the member
|
||||||
|
|
||||||
|
`test_peakguard.py` part 12j enumerates **every** comprehension in `Controller`
|
||||||
|
that iterates the roster — 26 of them across 23 methods, computed from main.py's
|
||||||
|
AST, printed, and required to iterate the roster **whole**. A new reduction added
|
||||||
|
over `self.inverters[:1]` anywhere in the class is red **on the line it is
|
||||||
|
written**, at every roster length, with no fixture required. That check is what
|
||||||
|
would have caught rounds 2, 3 and 4's blockers on the round they were written
|
||||||
|
instead of one review round each; it is also what found rows 18-21.
|
||||||
|
|
||||||
## 5. ⚠️ THE RESIDUAL — a per-pack EMS floor is NOT this card
|
## 5. ⚠️ THE RESIDUAL — a per-pack EMS floor is NOT this card
|
||||||
|
|
||||||
`peak_soc_floor` is still **one number for the site**, so the card's own
|
`peak_soc_floor` is still **one number for the site**, so the card's own
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
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
|
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
|
* **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
|
`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
|
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
|
two-inverter config, **under a deadline**, because the mutation's real symptom
|
||||||
is that the controller enters its loop and never returns.
|
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
|
* **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
|
reaching outside the suite. The probe is the committed evidence that
|
||||||
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
|
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
|
||||||
@@ -241,6 +287,50 @@ 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
|
red. `inverters: []` was deleted from `config.yaml` and the probe re-run
|
||||||
against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` for
|
against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` for
|
||||||
exactly that reason.
|
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.
|
||||||
|
|
||||||
|
10. **A fixture of N members cannot separate two reductions that agree on N
|
||||||
|
members — so grade the FAMILY, and grade it from the SOURCE.** This is the
|
||||||
|
only lesson SAFETY-32 cost four review rounds to learn, and it generalises
|
||||||
|
past packs to every roster, list and window in this repository. Two packs
|
||||||
|
cannot tell `max` from `min` from first from last. Three cannot tell `max`
|
||||||
|
from `max(x[-2:])`. **The regress does not terminate in a fixture**: `[:1]`
|
||||||
|
needs two members, `[:2]` three, `[:3]` four, and adding one more member
|
||||||
|
moves the hole rather than closing it. Three things follow, and all three
|
||||||
|
are now in `test_peakguard.py` parts 12c2, 12g, 12i and 12j:
|
||||||
|
* the fixture is a **SET** — the extreme at every position of the roster,
|
||||||
|
not one roster with the extreme somewhere;
|
||||||
|
* the family table is **COMPUTED and PRINTED**, so a member no fixture kills
|
||||||
|
shows up as a named survivor and takes the check red, instead of being
|
||||||
|
absent from a list nobody re-derived;
|
||||||
|
* where the claim is an **IDENTITY** ("this roster", "this list"), pin it
|
||||||
|
**exactly on the AST** — `ast.unparse(arg) == "self.packs"` is false of
|
||||||
|
every slice, where `"self.packs" in src` is true of all of them. Where the
|
||||||
|
claim is **SEMANTIC** ("the maximum"), a shape pin would refuse honest
|
||||||
|
refactors, so report the unreached width **with its size** instead.
|
||||||
|
|
||||||
|
11. **When a check of this shape is found, mutate its SIBLINGS before shipping
|
||||||
|
the fix.** SAFETY-32 was rejected three times for the same defect one step
|
||||||
|
over: the reduction, then the roster handed to it, then the reduction handed
|
||||||
|
to the *other argument of the same call*. Enumerating the family from the
|
||||||
|
AST and mutating each member took one afternoon and found **four more
|
||||||
|
survivors in code the card never touched** — three of SAFETY-30's
|
||||||
|
`any(i.sign_wd.tripped for i in self.inverters)` reductions, invisible
|
||||||
|
because the only fixture that reached them was a roster of ONE, where
|
||||||
|
`self.inverters[:1]` **is** `self.inverters`. Ask of every fix: *what is the
|
||||||
|
sibling of the thing I just hardened, and does any fixture separate it from
|
||||||
|
a slice of itself?*
|
||||||
|
|
||||||
And the mutation-table consequence: **a variant is not covered because a
|
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
|
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||||
@@ -297,6 +387,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. |
|
| **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-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. |
|
| **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. |
|
| **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
|
**Nothing here says a past result is wrong.** The two survivors re-run above
|
||||||
|
|||||||
+317
-107
@@ -54,14 +54,16 @@ from .hass import HomeAssistant
|
|||||||
# window an operator would look up here is the one the roster actually uses.
|
# window an operator would look up here is the one the roster actually uses.
|
||||||
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
||||||
roster_entries)
|
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 .mqtt import MqttPublisher
|
||||||
from . import peakguard
|
from . import peakguard
|
||||||
from .p1 import P1Ingest, build_source, is_enabled
|
from .p1 import P1Ingest, build_source, is_enabled
|
||||||
from .price import Uncertainty, price_option
|
from .price import Uncertainty, price_option
|
||||||
from .static_tariff import resolve_tariff, static_status
|
from .static_tariff import resolve_tariff, static_status
|
||||||
from .tariff import PeakStore, PeakTracker
|
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,
|
from .validate import (ConfigRefused, refuse, validate_startup_config,
|
||||||
validate_unit_ranges)
|
validate_unit_ranges)
|
||||||
from .watchdog import (
|
from .watchdog import (
|
||||||
@@ -265,7 +267,66 @@ class Controller:
|
|||||||
# maintenance.MAINT_OPTION_DEFAULTS before adding a ninth key: it goes
|
# maintenance.MAINT_OPTION_DEFAULTS before adding a ninth key: it goes
|
||||||
# in config.yaml's `options:` and `schema:` blocks and in that dict, and
|
# in config.yaml's `options:` and `schema:` blocks and in that dict, and
|
||||||
# nowhere else.
|
# 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
|
# 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
|
# docstring has always said and what this Controller did not do. Each
|
||||||
@@ -453,6 +514,9 @@ class Controller:
|
|||||||
self._peak_saturated = False
|
self._peak_saturated = False
|
||||||
self._peak_floor_limited = False
|
self._peak_floor_limited = False
|
||||||
self._maint_shape_suspended = 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
|
# 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
|
# 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.
|
# 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)
|
return tuple(inv.name for inv in self.inverters)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def maint_unit(self):
|
def maint_index(self) -> int:
|
||||||
"""The pack the ONE maintenance state machine is recalibrating.
|
"""Which roster slot holds the site's ONE maintenance slot right now.
|
||||||
|
|
||||||
⚠️ NAMED RATHER THAN ASSUMED. This add-on holds exactly one
|
The pack that is mid-cycle if there is one, otherwise the first - which
|
||||||
`Maintenance`, whose history lives under the store's un-namespaced
|
at a roster of one is always 0, always was, and is the whole of this
|
||||||
`phase` / `last_completed` keys, so the pack it drives is the first in
|
property's N=1 behaviour.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
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):
|
def inverter(self, name: str):
|
||||||
"""The roster entry called `name`, or None."""
|
"""The roster entry called `name`, or None."""
|
||||||
@@ -1349,14 +1474,36 @@ class Controller:
|
|||||||
"""
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# ⚠️ THE PACK IT IS MAINTAINING, NOT "the SoC". §12's recalibration
|
# ⚠️ EACH PACK ON ITS OWN READING, NOT "the SoC". §12's recalibration
|
||||||
# drains and refills ONE pack and grades ONE coulomb counter, so it is
|
# drains and refills ONE pack and grades ONE coulomb counter, so each
|
||||||
# graded on that pack's own reading - see `maint_unit`. A minimum across
|
# state machine is ticked with the SoC of the inverter it belongs to. A
|
||||||
# packs would abort the cycle on a sibling's state, and an average would
|
# minimum across packs would abort a cycle on a sibling's state, and an
|
||||||
# declare a pack full that is not.
|
# average would declare a pack full that is not. The pairing is
|
||||||
result = self.maint.tick(now, self.maint_unit.soc)
|
# positional and both lists are built from the roster in `__init__`, so
|
||||||
for msg in result.events:
|
# a pack cannot be graded on its neighbour's meter.
|
||||||
self.log_event(msg)
|
#
|
||||||
|
# ⚠️ 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 -------------
|
# --- SAFETY-02, and it runs BEFORE the early return below -------------
|
||||||
# ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing
|
# ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing
|
||||||
@@ -1870,88 +2017,114 @@ class Controller:
|
|||||||
self.log_event(self.arb.explain())
|
self.log_event(self.arb.explain())
|
||||||
|
|
||||||
# --- maintenance -----------------------------------------------------
|
# --- maintenance -----------------------------------------------------
|
||||||
if result.owns_setpoint:
|
# ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT.
|
||||||
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
|
# At most one machine is out of IDLE on any tick, so at most one of
|
||||||
# drain THIS pack to its floor, charge THIS pack full, recalibrate
|
# these branches fires and the claim list is the shape it has always
|
||||||
# THIS pack's coulomb counter. Two packs recalibrate on two
|
# been. The loop is not a widening of what maintenance may ask for; it
|
||||||
# schedules. See arbiter.CLAIM_SCOPE.
|
# is what makes the ONE claim it does file land on the pack that is
|
||||||
# ⚠️ FILED ON THE UNIT BEING MAINTAINED, WHICH IS THE FIRST ONE.
|
# actually being recalibrated, whichever roster slot that is.
|
||||||
# §12's monthly recalibration is a PACK procedure and this add-on
|
for inv, result in zip(self.inverters, maint_results):
|
||||||
# holds exactly ONE `Maintenance` state machine, so the pack it is
|
if result.owns_setpoint:
|
||||||
# maintaining is `self.maint_unit`. Two packs recalibrating on two
|
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
|
||||||
# schedules is a second FSM with its own namespaced history, which
|
# drain THIS pack to its floor, charge THIS pack full,
|
||||||
# is a card of its own - what this card must not do is file one
|
# recalibrate THIS pack's coulomb counter. Two packs recalibrate
|
||||||
# pack's procedure on every actuator.
|
# on two schedules. See arbiter.CLAIM_SCOPE.
|
||||||
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
#
|
||||||
float(result.setpoint_w or 0.0), result.phase,
|
# ⚠️ FILED ON THE PACK WHOSE STATE MACHINE PRODUCED IT - `inv`,
|
||||||
actuator=self.actuator_of("maintenance",
|
# the roster entry this `result` was ticked from - and that is
|
||||||
self.maint_unit)))
|
# the whole of MAINT-05 at this line. It used to be
|
||||||
self.sat_count = 0
|
# `self.maint_unit`, which was `inverters[0]` flatly, because
|
||||||
elif result.charge_only:
|
# there was one FSM and its history had nowhere else to live.
|
||||||
# Money outranks the maintenance schedule: while the quarter-hour
|
# Pack B's drain filed on pack A is one pack being commanded for
|
||||||
# projection is over the cap, the charge-only shaping is simply not
|
# another's procedure while the pack that is actually draining
|
||||||
# claimed, so the loop can discharge and shave the peak. When a
|
# stays under the loop: the site would move the right total and
|
||||||
# dedicated peak-shaving strategy arrives it will claim SET at
|
# recalibrate nothing.
|
||||||
# P_PEAK and outrank this limit without any code here changing.
|
#
|
||||||
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
|
# ⚠️ AND IT STAYS `SCOPE_UNIT`. Two FSMs mean two claims on two
|
||||||
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's claim
|
# actuators, which the allocator consumes as per-unit intervals;
|
||||||
# is `lo >= 0`. Both are limits, arbiter.py applies EVERY limit at or
|
# ARCH-01 is explicit that this must never become a site claim,
|
||||||
# above the winner's priority, and most restrictive wins - so a peak
|
# because per-inverter constraints filed site-wide are the
|
||||||
# event during a maintenance charge window files `lo > hi`, which is
|
# cross-inverter `lo > hi` contradiction at arbiter.py:114.
|
||||||
# a CONTRADICTION and resolves to 0 W. The battery would park in the
|
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
||||||
# exact quarter it should be shaving. Priority does not save it:
|
float(result.setpoint_w or 0.0), result.phase,
|
||||||
# clause 2 has no notion of a higher-priority limit overriding a
|
actuator=self.actuator_of("maintenance", inv)))
|
||||||
# lower one, and it should not - "most restrictive" is what makes the
|
self.sat_count = 0
|
||||||
# safety bounds unconditional.
|
elif result.charge_only:
|
||||||
#
|
# Money outranks the maintenance schedule: while the quarter-hour
|
||||||
# So the higher-priority claimant SUPPRESSES the lower one rather
|
# projection is over the cap, the charge-only shaping is simply not
|
||||||
# than out-voting it, which is what this branch already did for
|
# claimed, so the loop can discharge and shave the peak. When a
|
||||||
# `peak_forecast_entity`. That entity is optional and empty on a
|
# dedicated peak-shaving strategy arrives it will claim SET at
|
||||||
# default install, so until now the suspension could not fire on the
|
# P_PEAK and outrank this limit without any code here changing.
|
||||||
# site that needed it. `self.peak_guard` is measured from the meter
|
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
|
||||||
# and is always there.
|
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's
|
||||||
#
|
# claim is `lo >= 0`. Both are limits, arbiter.py applies EVERY
|
||||||
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is not
|
# limit at or above the winner's priority, and most restrictive
|
||||||
# tidiness. It was inline, and a reviewer mutated it back to its
|
# wins - so a peak event during a maintenance charge window files
|
||||||
# historical broken form - re-gated on `peak_forecast_entity` alone -
|
# `lo > hi`, which is a CONTRADICTION and resolves to 0 W. The
|
||||||
# and every suite in this repository stayed green while a default
|
# battery would park in the exact quarter it should be shaving.
|
||||||
# install parked at 0 W in the quarter that sets the month's bill.
|
# Priority does not save it: clause 2 has no notion of a
|
||||||
# Read shaping_suspended() before touching this: both terms are
|
# higher-priority limit overriding a lower one, and it should not -
|
||||||
# there, and why neither is sufficient alone.
|
# "most restrictive" is what makes the safety bounds unconditional.
|
||||||
if peakguard.shaping_suspended(
|
#
|
||||||
self.peak_guard, self.peak_fc,
|
# So the higher-priority claimant SUPPRESSES the lower one rather
|
||||||
float(self.o.get("peak_cap_w", 3500))):
|
# than out-voting it, which is what this branch already did for
|
||||||
# Edge-logged: level-logging here would write one line per cycle
|
# `peak_forecast_entity`. That entity is optional and empty on a
|
||||||
# for as long as the event lasts and flush the 40-line action log
|
# default install, so until now the suspension could not fire on the
|
||||||
# of the very context that explains it.
|
# site that needed it. `self.peak_guard` is measured from the meter
|
||||||
if not self._maint_shape_suspended:
|
# and is always there.
|
||||||
self._maint_shape_suspended = True
|
#
|
||||||
self.log_event("peak at risk - maintenance charge shaping "
|
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is
|
||||||
"suspended so the peak claim is not "
|
# not tidiness. It was inline, and a reviewer mutated it back to its
|
||||||
"contradicted")
|
# historical broken form - re-gated on `peak_forecast_entity` alone
|
||||||
else:
|
# - and every suite in this repository stayed green while a default
|
||||||
self._maint_shape_suspended = False
|
# install parked at 0 W in the quarter that sets the month's bill.
|
||||||
hi = 0.0
|
# Read shaping_suspended() before touching this: both terms are
|
||||||
reason = "charge-only"
|
# there, and why neither is sufficient alone.
|
||||||
if self.cheap:
|
#
|
||||||
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ
|
# ⚠️ ONE SUSPENSION FLAG FOR THE SITE, NOT ONE PER PACK, AND THE
|
||||||
# OF `self.o`. This line used to re-open the options dict
|
# INTERLOCK IS WHAT MAKES THAT TRUE. The quantity it edge-logs is
|
||||||
# with its own literal fallback, so the rate the shaping
|
# a peak event at the grid connection, and `arbitrate()` allows
|
||||||
# asked for and the rate the state machine drained at could
|
# at most one pack to be in CHARGE at a time, so this branch can
|
||||||
# be different numbers from the same install - and, with the
|
# run at most once per tick. Were two packs ever allowed to
|
||||||
# key absent, they were. The Controller already parsed this
|
# charge together, this flag would have to become per-pack - and
|
||||||
# once in __init__; asking the object that owns it cannot
|
# so would the reasoning above it.
|
||||||
# drift from it.
|
if peakguard.shaping_suspended(
|
||||||
floor = maintenance_charge_floor(
|
self.peak_guard, self.peak_fc,
|
||||||
self.maint.cfg.charge_w,
|
float(self.o.get("peak_cap_w", 3500))):
|
||||||
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
|
# Edge-logged: level-logging here would write one line per
|
||||||
if floor > 0:
|
# cycle for as long as the event lasts and flush the 40-line
|
||||||
hi, reason = -floor, "cheap-window charge"
|
# action log of the very context that explains it.
|
||||||
# PER-INVERTER, like the SET above and for the same reason -
|
if not self._maint_shape_suspended:
|
||||||
# the shaping is about the pack being charged.
|
self._maint_shape_suspended = True
|
||||||
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason,
|
self.log_event("peak at risk - maintenance charge shaping "
|
||||||
actuator=self.actuator_of("maintenance",
|
"suspended so the peak claim is not "
|
||||||
self.maint_unit)))
|
"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 -----------------------------------------
|
# --- the grid-following loop -----------------------------------------
|
||||||
if self.auto and self.inputs_ok:
|
if self.auto and self.inputs_ok:
|
||||||
@@ -3358,14 +3531,29 @@ class Controller:
|
|||||||
elif not self.auto:
|
elif not self.auto:
|
||||||
level, banner = "warn", "Stopped — inverter commanded to 0 W"
|
level, banner = "warn", "Stopped — inverter commanded to 0 W"
|
||||||
elif self.maint.phase != IDLE:
|
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}"
|
level, banner = "warn", f"Maintenance: {self.maint.phase}"
|
||||||
|
if len(self.inverters) > 1:
|
||||||
|
banner += f" ({self.maint_unit.name})"
|
||||||
else:
|
else:
|
||||||
level, banner = "ok", "Running — holding grid at zero"
|
level, banner = "ok", "Running — holding grid at zero"
|
||||||
|
|
||||||
def w(v):
|
def w(v):
|
||||||
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
|
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 = [
|
rows = [
|
||||||
("Grid", w(self.grid)),
|
("Grid", w(self.grid)),
|
||||||
("Battery", w(self.site_batt_w)),
|
("Battery", w(self.site_batt_w)),
|
||||||
@@ -3381,7 +3569,14 @@ class Controller:
|
|||||||
else f"{self.pack_floor_soc:g} %"),
|
else f"{self.pack_floor_soc:g} %"),
|
||||||
("Commanded", w(self.target)),
|
("Commanded", w(self.target)),
|
||||||
("Why", self.reason),
|
("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")),
|
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||||
("Cheap window", "yes" if self.cheap else "no"),
|
("Cheap window", "yes" if self.cheap else "no"),
|
||||||
("Tariff", self.tariff_words),
|
("Tariff", self.tariff_words),
|
||||||
@@ -3493,11 +3688,26 @@ class Controller:
|
|||||||
await self.command_site_zero()
|
await self.command_site_zero()
|
||||||
return {"auto": self.auto}
|
return {"auto": self.auto}
|
||||||
if what == "maint_start":
|
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)
|
self.log_event(msg)
|
||||||
return {"phase": self.maint.phase}
|
return {"phase": self.maint.phase}
|
||||||
if what == "maint_abort":
|
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)
|
self.log_event(msg)
|
||||||
await self.command_site_zero()
|
await self.command_site_zero()
|
||||||
return {"phase": self.maint.phase}
|
return {"phase": self.maint.phase}
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
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 logging
|
||||||
|
import math
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from .store import MAINT_UNIT_KEYS, unit_key
|
||||||
|
|
||||||
_LOG = logging.getLogger("goodwe.maint")
|
_LOG = logging.getLogger("goodwe.maint")
|
||||||
|
|
||||||
IDLE, DRAIN, CHARGE, HOLD = "idle", "drain", "charge", "hold"
|
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
|
# 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
|
# 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.
|
# house load drains alongside the export and soc_floor stops short of empty.
|
||||||
@@ -153,17 +184,102 @@ class MaintResult:
|
|||||||
setpoint_w: float | None = None
|
setpoint_w: float | None = None
|
||||||
charge_only: bool = False
|
charge_only: bool = False
|
||||||
events: list = field(default_factory=list)
|
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:
|
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.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 ---------------------------------------------------------------
|
# -- state ---------------------------------------------------------------
|
||||||
@property
|
@property
|
||||||
def phase(self) -> str:
|
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:
|
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None:
|
||||||
self.store.set("phase", phase)
|
self.store.set("phase", phase)
|
||||||
@@ -185,14 +301,60 @@ class Maintenance:
|
|||||||
last = self.store.get_time("last_completed")
|
last = self.store.get_time("last_completed")
|
||||||
return None if last is None else last + timedelta(days=self.cfg.interval_days)
|
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 ----------------------------------------------------------
|
# -- 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.
|
"""Start a cycle regardless of the calendar.
|
||||||
|
|
||||||
Commissioning uses this: a cycle must be proven end to end before a site
|
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
|
is signed off, and nobody waits a month to find out the schedule never
|
||||||
fires.
|
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 = []
|
events = []
|
||||||
self.store.set_time("last_start_attempt", now)
|
self.store.set_time("last_start_attempt", now)
|
||||||
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
|
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
|
||||||
@@ -203,23 +365,80 @@ class Maintenance:
|
|||||||
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
|
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
|
||||||
return events
|
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 ---------------------------------------------------------
|
# -- 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 = []
|
events: list = []
|
||||||
phase = self.phase
|
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 phase == IDLE:
|
||||||
if self.cfg.enabled and now.hour == self.cfg.start_hour and self.due(now):
|
if self.wants_start(now):
|
||||||
last_try = self.store.get_time("last_start_attempt")
|
if blocked_by:
|
||||||
cooled = (
|
# ⚠️ `last_start_attempt` IS NOT STAMPED HERE, AND THAT IS
|
||||||
last_try is None
|
# THE STARVATION FIX RATHER THAN AN OMISSION. That key is
|
||||||
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN)
|
# the post-ABORT cooldown: it exists so a cycle that failed
|
||||||
)
|
# does not re-trigger inside the same start hour. A deferral
|
||||||
if cooled:
|
# is not an attempt - nothing was tried and nothing failed -
|
||||||
self.store.set_time("last_start_attempt", now)
|
# and stamping it would put this pack under a 90 minute
|
||||||
self._enter(DRAIN, now, events,
|
# cooldown for a start hour that is 60 minutes long, so the
|
||||||
"maintenance: due -> drain (exports the surplus)")
|
# pack that lost the arbitration would still be locked out
|
||||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
# 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)
|
return MaintResult(IDLE, False, None, False, events)
|
||||||
|
|
||||||
# ⚠️ Fail toward inaction. Without a state-of-charge reading none of the
|
# ⚠️ 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)
|
_LOG.error("maintenance: unknown phase %r, forcing idle", phase)
|
||||||
events += self.abort(now, f"unknown phase {phase!r}")
|
events += self.abort(now, f"unknown phase {phase!r}")
|
||||||
return MaintResult(IDLE, True, 0.0, False, events)
|
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
@@ -126,16 +126,53 @@ SCHEMA_VERSION = 1
|
|||||||
SCHEMA_KEY = "schema_version"
|
SCHEMA_KEY = "schema_version"
|
||||||
|
|
||||||
# Which flat keys belong to ONE inverter, and therefore move into its namespace.
|
# 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`,
|
# ⚠️ THE MAINTENANCE KEYS ARE STILL NOT IN HERE, AND SINCE MAINT-05 THAT IS A
|
||||||
# `phase_started`, `last_completed` and `last_start_attempt` belong to the
|
# DIFFERENT DECISION RATHER THAN THE SAME ONE. They DO move now - one
|
||||||
# maintenance state machine, of which this add-on still holds exactly ONE - a
|
# `Maintenance` per roster entry needs one history per roster entry - but they
|
||||||
# monthly recalibration is a pack procedure and per-pack SCHEDULING is a card of
|
# move on their OWN marker, `MAINT_SCHEMA_KEY`, and only on a site that actually
|
||||||
# its own, not something to infer from a key rename. Moving them here would
|
# has more than one pack. Two conditions the SAFETY-02 keys do not share, so
|
||||||
# quietly re-home an install's whole maintenance history under a unit name; they
|
# folding them into this tuple would either drag them across on a
|
||||||
# stay where they are, untouched, which is the other half of "migrates without
|
# single-inverter install (which is the byte-identical store layout MAINT-05
|
||||||
# losing its latch OR ITS HISTORY".
|
# 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")
|
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:
|
def unit_key(unit: str, key: str) -> str:
|
||||||
"""The namespaced Store key for `key` on inverter `unit`.
|
"""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
|
# to shout: an alarm that fires on the harmless case is an alarm an
|
||||||
# operator learns to dismiss before the real one arrives.
|
# operator learns to dismiss before the real one arrives.
|
||||||
self.lost = []
|
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
|
@property
|
||||||
def ok(self) -> bool:
|
def ok(self) -> bool:
|
||||||
"""Nothing that CARRIED STATE failed to land."""
|
"""Nothing that CARRIED STATE failed to land, and it landed WHERE THE
|
||||||
return not self.lost
|
MACHINES LOOK."""
|
||||||
|
return not self.lost and not self.misdirected
|
||||||
|
|
||||||
def explain(self) -> str:
|
def explain(self) -> str:
|
||||||
if not self.ran:
|
if not self.ran:
|
||||||
return (f"store already at schema {self.from_version}; no "
|
# ⚠️ `skipped` RATHER THAN A SECOND SENTENCE ABOUT VERSIONS. Since
|
||||||
f"per-inverter migration needed")
|
# 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 "
|
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
|
||||||
f"namespaces: " + ", ".join(sorted(self.moved)))
|
f"namespaces: " + ", ".join(sorted(self.moved)))
|
||||||
if self.kept:
|
if self.kept:
|
||||||
@@ -192,6 +248,8 @@ class MigrationReport:
|
|||||||
out += " - PROBLEMS: " + "; ".join(self.problems)
|
out += " - PROBLEMS: " + "; ".join(self.problems)
|
||||||
if self.lost:
|
if self.lost:
|
||||||
out += " - STATE LOST: " + "; ".join(self.lost)
|
out += " - STATE LOST: " + "; ".join(self.lost)
|
||||||
|
if self.misdirected:
|
||||||
|
out += " - WRONG NAMESPACE: " + "; ".join(self.misdirected)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -243,7 +301,30 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
|||||||
return rep
|
return rep
|
||||||
|
|
||||||
rep.ran = True
|
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)
|
target = unit_key(unit, legacy)
|
||||||
if target in data:
|
if target in data:
|
||||||
rep.kept.append(target)
|
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
|
# 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 -
|
# then lands on has to be the one `Store.load()` would have supplied -
|
||||||
# otherwise "absent" and "never tripped" stop being the same statement,
|
# 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))
|
value = data.get(legacy, DEFAULTS.get(legacy))
|
||||||
store.set(target, value)
|
store.set(target, value)
|
||||||
rep.moved[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
|
def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
|
||||||
# can ask "is the latch that was in the file before now under the key the
|
"""THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION.
|
||||||
# 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
|
This is the difference between these functions and a two-line copy: the
|
||||||
# whose state file says it is on.
|
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():
|
for target, value in rep.moved.items():
|
||||||
got = getattr(store, "data", {}).get(target, "<<missing>>")
|
got = getattr(store, "data", {}).get(target, "<<missing>>")
|
||||||
if got == value:
|
if got == value:
|
||||||
@@ -274,14 +401,142 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
|||||||
if value != DEFAULTS.get(legacy):
|
if value != DEFAULTS.get(legacy):
|
||||||
rep.lost.append(note)
|
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:
|
if rep.ok:
|
||||||
store.set(SCHEMA_KEY, SCHEMA_VERSION)
|
store.set(MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION)
|
||||||
else:
|
else:
|
||||||
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
|
# ⚠️ NOT MARKED DONE, FOR EITHER FAILURE. The same sentence
|
||||||
# next start rather than be recorded as finished - the one thing worse
|
# `migrate_unit_keys()` carries: the one thing worse than a failed
|
||||||
# than a failed migration is a failed migration a later version skips.
|
# migration is a failed migration a later version skips. Rule 1 makes
|
||||||
_LOG.error("per-inverter store migration LOST STATE and has not been "
|
# the re-run a no-op where the keys did land, so refusing the marker
|
||||||
"marked done: %s", rep.explain())
|
# 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
|
return rep
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -851,24 +851,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
|
# rule 8 below already makes with `PEAK_OPTION_DEFAULTS` - and
|
||||||
# test_maintenance.py pins it by reading the shipped file.
|
# 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
|
# ⚠️ MAINT-05: IT IS GRADED AGAINST THE WEAKEST UNIT'S CAP NOW, AND THE
|
||||||
# THE FLAT ONE. Rule 5 takes the weakest unit because there is a watchdog on
|
# COMMENT THIS REPLACES SAID SO WOULD HAPPEN. It read: "there is exactly ONE
|
||||||
# every one of them; there is exactly ONE `Maintenance` in this add-on and
|
# `Maintenance` in this add-on and `Controller.maint_unit` names the pack it
|
||||||
# `Controller.maint_unit` names the pack it recalibrates - the first in the
|
# recalibrates - the first in the roster, because the cycle's history lives
|
||||||
# roster, because the cycle's history lives under un-namespaced Store keys.
|
# under un-namespaced Store keys. The shaping claim is filed on THAT
|
||||||
# The shaping claim is filed on THAT actuator and on no other, so the cap
|
# actuator and on no other, so the cap that can contradict it is THAT
|
||||||
# that can contradict it is THAT unit's. Taking `min()` here would refuse a
|
# unit's. Taking `min()` here would refuse a perfectly workable site - a
|
||||||
# perfectly workable site - a 5000 W pack being recalibrated beside a 2000 W
|
# 5000 W pack being recalibrated beside a 2000 W sibling the cycle never
|
||||||
# sibling the cycle never touches - which is the over-refusal this file's
|
# touches ... When `maint_unit` stops being `inverters[0]`, this line moves
|
||||||
# docstring says gets a validator switched off. When `maint_unit` stops
|
# with it."
|
||||||
# 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",
|
charge_w = _number(opts, "maintenance_charge_w",
|
||||||
float(MAINT_OPTION_DEFAULTS["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
|
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON: `unit_caps_indexed()`
|
||||||
# unit WITH A USABLE CAP, which is `inverters[0]` only while every earlier
|
# SKIPS entries with no usable `max_w`, so a caps-list position is not a
|
||||||
# entry has one; `_unit_label(opts, 0)` was hard-wired to `inverters[0]`
|
# roster position and `_unit_label()` indexes the roster. The index travels
|
||||||
# regardless. Both come off the one pair now.
|
# with the number, so the unit the message names is the unit the number came
|
||||||
maint_index, maint_cap = caps_ix[0] if caps_ix else (0, None)
|
# 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)
|
maint_where = _unit_label(opts, maint_index)
|
||||||
if charge_w is None:
|
if charge_w is None:
|
||||||
problems.append(
|
problems.append(
|
||||||
|
|||||||
@@ -582,6 +582,58 @@ check("...and a store that only fails to persist a DEFAULT is not reported as "
|
|||||||
"dismiss before the real one arrives",
|
"dismiss before the real one arrives",
|
||||||
migrate_unit_keys(LosesTheLatch(), UNIT_A).ok is True)
|
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,
|
_broken2 = LosesTheLatch({"sign_tripped": True,
|
||||||
"sign_reason": "SIGN_DISAGREEMENT: measured"})
|
"sign_reason": "SIGN_DISAGREEMENT: measured"})
|
||||||
_c2d, _ = built(store=_broken2)
|
_c2d, _ = built(store=_broken2)
|
||||||
@@ -1649,11 +1701,28 @@ check("⚠️ RULE 5 NAMES THE UNIT THE NUMBER CAME OFF. The weakest cap is "
|
|||||||
"is inverter-B. The refusal says inverter-c",
|
"is inverter-B. The refusal says inverter-c",
|
||||||
_why(_p_skew, "max_w (inverter-c) is 1000 W")
|
_why(_p_skew, "max_w (inverter-c) is 1000 W")
|
||||||
and not _why(_p_skew, "max_w (inverter-b) 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 - "
|
# ⚠️ MAINT-05 MOVED RULE 6's SUBJECT, AND THIS CHECK MOVED WITH IT. It used to
|
||||||
"inverter-b - while `_unit_label(opts, 0)` was hard-wired to inverter-a, "
|
# assert the refusal named inverter-b, because rule 6 graded the FIRST entry
|
||||||
"so the maintenance refusal quoted B's rating under A's name",
|
# with a usable cap - `Controller.maint_unit` was `inverters[0]` and the shaping
|
||||||
_why(_p_skew, "max_w (inverter-b) is 2000 W")
|
# claim could be filed nowhere else. There is now one `Maintenance` per roster
|
||||||
and not _why(_p_skew, "max_w (inverter-a) is 2000 W"))
|
# 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 "
|
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 "
|
"validator that named nothing: the OLD expressions still evaluate, on "
|
||||||
"this same config, to the wrong names",
|
"this same config, to the wrong names",
|
||||||
@@ -2152,6 +2221,16 @@ MUTATIONS = [
|
|||||||
("validate.py allowing two roster entries with the SAME name",
|
("validate.py allowing two roster entries with the SAME name",
|
||||||
"part 10: 'two entries with the SAME name are refused' - one Store "
|
"part 10: 'two entries with the SAME name are refused' - one Store "
|
||||||
"namespace for two units is DEV-02's defect rebuilt in YAML"),
|
"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
|
# ⚠️ 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
|
# TABLE - two in DEV-02, two more in CFG-04, and the CFG-04 pair are recorded in
|
||||||
@@ -2170,7 +2249,7 @@ for what, caught in MUTATIONS:
|
|||||||
check(f"all {len(MUTATIONS)} defeats of this card are caught by a named check "
|
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"above. A guard whose checks survive its own defeat is not a guard, and "
|
||||||
f"this repository has three separate records of exactly that",
|
f"this repository has three separate records of exactly that",
|
||||||
len(MUTATIONS) == 34)
|
len(MUTATIONS) == 35)
|
||||||
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4149,6 +4149,590 @@ check("⚠️ AC 5. At a roster of ONE the same notification carries NO per-pack
|
|||||||
and "peak_soc_floor bounds THE PEAK GUARD ONLY" in _body32one)
|
and "peak_soc_floor bounds THE PEAK GUARD ONLY" in _body32one)
|
||||||
|
|
||||||
|
|
||||||
|
# --- 12i. THE SIBLING ARGUMENT: `pack_floor_soc`, THE OTHER HALF OF THE PAIR --
|
||||||
|
# ⚠️ THIS IS ROUND 4's BLOCKER, AND IT IS THE SAME FAMILY ONE ARGUMENT OVER.
|
||||||
|
# Everything from 12c2 to 12h grades reductions on the FLOOR axis: the max
|
||||||
|
# inside `binding_soc_floor()`, the roster handed to it, `Controller.packs`, and
|
||||||
|
# `pack_floor_pct()`'s conjunction. `Controller.pack_floor_soc` is the MINIMUM
|
||||||
|
# over the same roster, handed to the SAME `peakguard.decide()` call as `soc=`,
|
||||||
|
# and until this block nothing graded its reduction past N=2.
|
||||||
|
#
|
||||||
|
# ⚠️ THE PAIRING IS DOCUMENTED, WHICH IS WHY HARDENING ONE HALF IS NOT ENOUGH.
|
||||||
|
# main.py's call site says "MAXIMUM here pairs with the MINIMUM in
|
||||||
|
# `pack_floor_soc`: both reductions move the verdict towards refusing a shave
|
||||||
|
# and away from spending a pack". A conjunction whose two halves have different
|
||||||
|
# amounts of evidence behind them is exactly as strong as its weaker half.
|
||||||
|
#
|
||||||
|
# THE SURVIVORS THIS BLOCK EXISTS FOR, all confirmed GREEN across all 19 files
|
||||||
|
# at 52380c0 and again on the rebase onto 3934ad7:
|
||||||
|
#
|
||||||
|
# min(float(v) for v in vals) -> ... for v in vals[:2] +4 bytes
|
||||||
|
# -> ... for v in vals[-2:] +5 bytes
|
||||||
|
# -> ... for v in vals[:3] +4 bytes
|
||||||
|
#
|
||||||
|
# while `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)` and the
|
||||||
|
# mean were all killed. The suite's separating power on the SoC axis stopped at
|
||||||
|
# exactly N=2 - which is the state the FLOOR axis was in before round 3, and
|
||||||
|
# which round 3 named as its own blocker 2. The evidence is a fixture of two
|
||||||
|
# packs, and A FIXTURE OF N PACKS CANNOT SEPARATE REDUCTIONS THAT AGREE ON N.
|
||||||
|
#
|
||||||
|
# IT CHANGES A REAL ANSWER, which is why it is a defect and not a tidiness
|
||||||
|
# point: a three-pack roster at 50 / 60 / 11 % with the emptiest pack THIRD.
|
||||||
|
# The shipped property answers 11 %; `vals[:2]` answers 50 %. The site is then
|
||||||
|
# graded for discharge PERMISSION against a pack 39 points fuller than the
|
||||||
|
# emptiest one, and spends a pack its own BMS stops - through `sim/inject.py`,
|
||||||
|
# which runs no validator rule at all.
|
||||||
|
#
|
||||||
|
# ⚠️ AND PRODUCTION IS DEGENERATE ON THE FLOOR AXIS, WHICH MAKES THIS AXIS THE
|
||||||
|
# LIVE ONE. SPIKE-04 reads pack B as 4 x Dyness DL5.0C at DoD 90 [FROM THE
|
||||||
|
# DATASHEET AND THE SERIAL - inverter B does not exist and nothing about it has
|
||||||
|
# been measured], so its cut-out is 10 %, the same as pack A's MEASURED 10 %.
|
||||||
|
# Two packs that floor at the same value make `binding_soc_floor()` answer the
|
||||||
|
# option whatever it reduces. The SoC axis has no such degeneracy: two batteries
|
||||||
|
# at the same state of charge is a coincidence, not a configuration.
|
||||||
|
#
|
||||||
|
# THE TREATMENT IS ROUND 3's, TRANSFERRED: a roster SET with the extreme at
|
||||||
|
# every position, a LENGTH SWEEP, and an EXACT STRUCTURAL PIN on the reduction's
|
||||||
|
# argument. Nothing below is a measurement of a second or third pack.
|
||||||
|
_S_EMPTY_SIM, _S_MID_SIM, _S_FULL_SIM = 11.0, 50.0, 60.0
|
||||||
|
_S_TRIO_SIM = (_S_FULL_SIM, _S_MID_SIM, _S_EMPTY_SIM)
|
||||||
|
|
||||||
|
|
||||||
|
def _soc_reaching_decide(tag, *socs):
|
||||||
|
"""Drive ONE REAL `Controller.cycle()` on an N-pack roster and report the
|
||||||
|
SoC `peakguard.decide()` was actually handed, read back off the decision
|
||||||
|
main.py itself filed (`PeakDecision.soc` is the argument, echoed).
|
||||||
|
|
||||||
|
⚠️ THE EXACT MIRROR OF `_floor_reaching_decide` ABOVE, on the other axis.
|
||||||
|
Not a spy and not a grep: the shipped `cycle()` runs, the shipped
|
||||||
|
`pack_floor_soc` runs, and the observable is a field of the decision the
|
||||||
|
controller stored - so the whole path from `Controller.inverters` to the
|
||||||
|
guard's argument is under test. Every pack carries the SAME cut-out
|
||||||
|
(inverter A's measured 10 %, which is below `peak_soc_floor`, so
|
||||||
|
`binding_soc_floor()` answers the option for every roster here); the only
|
||||||
|
thing that varies between calls is the packs' states of charge. That is
|
||||||
|
habit 1: with the FLOOR axis held flat, nothing but the SoC reduction can
|
||||||
|
move these answers.
|
||||||
|
"""
|
||||||
|
def _prep(ctl):
|
||||||
|
for _i in range(len(socs) - 1):
|
||||||
|
_sfx = chr(ord("b") + _i)
|
||||||
|
second_inverter(ctl, name=f"inverter-{_sfx}",
|
||||||
|
setpoint=f"number.sp_{_sfx}")
|
||||||
|
for inv, so in zip(ctl.inverters, socs):
|
||||||
|
inv.soc, inv.batt, inv.bms_soc_floor = so, 0.0, A_PACK_FLOOR
|
||||||
|
|
||||||
|
ctl, _hs, _net, _rem = wired_over_limit(
|
||||||
|
TMP / f"s32s_{tag}.db", LIVE_MAX_W * 0.3, prepare=_prep)
|
||||||
|
return ctl, ctl.peak_guard
|
||||||
|
|
||||||
|
|
||||||
|
# BASELINE: three packs, all of them ABOVE `peak_soc_floor`, so the guard is
|
||||||
|
# permitted and sizes a real forced discharge. Every run below varies one thing
|
||||||
|
# from this baseline (habit 7) - which pack is the empty one, and nothing else.
|
||||||
|
_s_ctl_ok, _s_ok = _soc_reaching_decide("ok3", _S_FULL_SIM, _S_MID_SIM,
|
||||||
|
SOC_BETWEEN)
|
||||||
|
# THE SET: the SAME THREE readings, the emptiest at EACH position in turn. A
|
||||||
|
# reduction that reads a proper subset of the positions misses the empty pack on
|
||||||
|
# the run where it sits at the position that subset drops, and answers a fuller
|
||||||
|
# pack instead.
|
||||||
|
_s3_runs = []
|
||||||
|
for _pos in range(3):
|
||||||
|
_sc3 = [_S_FULL_SIM, _S_MID_SIM, _S_EMPTY_SIM]
|
||||||
|
_sc3.remove(_S_EMPTY_SIM)
|
||||||
|
_sc3.insert(_pos, _S_EMPTY_SIM)
|
||||||
|
_c3s, _d3s = _soc_reaching_decide(f"s3p{_pos}", *_sc3)
|
||||||
|
_s3_runs.append((f"3-pack, emptiest at index {_pos}", _sc3, _c3s, _d3s))
|
||||||
|
# ...and the two-pack mirror, which is the shape production reaches first.
|
||||||
|
_s2_runs = []
|
||||||
|
for _pos in range(2):
|
||||||
|
_sc2 = [_S_FULL_SIM, _S_EMPTY_SIM] if _pos else [_S_EMPTY_SIM, _S_FULL_SIM]
|
||||||
|
_c2s, _d2s = _soc_reaching_decide(f"s2p{_pos}", *_sc2)
|
||||||
|
_s2_runs.append((f"2-pack, emptiest at index {_pos}", _sc2, _c2s, _d2s))
|
||||||
|
print(f" REAL cycles, every cut-out held at inverter-a's measured "
|
||||||
|
f"{A_PACK_FLOOR:.0f} %, peak_soc_floor {SOC_FLOOR:.0f} % (every pack past "
|
||||||
|
f"the first is SIMULATED):")
|
||||||
|
print(f" 3-pack baseline, none empty: SoCs "
|
||||||
|
f"{[f'{s:g}' for s in (_S_FULL_SIM, _S_MID_SIM, SOC_BETWEEN)]} % -> "
|
||||||
|
f"decide() got soc={_s_ok.soc:g} %, floor_limited={_s_ok.floor_limited}")
|
||||||
|
for _rn, _sc, _ctl_s, _dec_s in _s3_runs + _s2_runs:
|
||||||
|
print(f" {_rn}: SoCs {[f'{s:g}' for s in _sc]} % -> decide() got "
|
||||||
|
f"soc={_dec_s.soc:g} %, floor_limited={_dec_s.floor_limited}")
|
||||||
|
check("⚠️ THE POSITIONAL PIN ON THE SoC AXIS, AND IT NEEDS THREE PACKS TO "
|
||||||
|
"EXIST. On a roster of THREE the SoC that reaches `decide()` is the "
|
||||||
|
"EMPTIEST pack wherever on the roster that pack sits - index 0, index 1 "
|
||||||
|
"and index 2 all answer 11 %, and all three rosters carry the SAME "
|
||||||
|
"multiset of readings. A property that reduced any PROPER SUBSET of the "
|
||||||
|
"positions would answer a fuller pack on the run whose empty one sits at "
|
||||||
|
"the position it dropped. `vals[:2]` and `vals[-2:]` - the +4- and "
|
||||||
|
"+5-byte mutations that walked through every fixture in this file up to "
|
||||||
|
"round 3, because at N=2 they ARE the roster - go red here",
|
||||||
|
[r[3].soc for r in _s3_runs] == [_S_EMPTY_SIM] * 3
|
||||||
|
and all(len(r[2].inverters) == 3 for r in _s3_runs)
|
||||||
|
and all(sorted(inv.soc for inv in r[2].inverters)
|
||||||
|
== sorted(_S_TRIO_SIM) for r in _s3_runs)
|
||||||
|
and [[inv.soc for inv in r[2].inverters] for r in _s3_runs]
|
||||||
|
== [r[1] for r in _s3_runs])
|
||||||
|
check("...and the TWO-pack mirror, which is what production reaches the moment "
|
||||||
|
"inverter B is installed: swap the same two readings between the packs "
|
||||||
|
"and the answer is the emptier one both times. `vals[:1]` and `vals[-1:]` "
|
||||||
|
"- grade the site against pack A alone, or against the last pack alone - "
|
||||||
|
"each agree with the roster on one of these two runs and disagree on the "
|
||||||
|
"other, so neither survives the pair",
|
||||||
|
[r[3].soc for r in _s2_runs] == [_S_EMPTY_SIM] * 2
|
||||||
|
and [[inv.soc for inv in r[2].inverters] for r in _s2_runs]
|
||||||
|
== [[_S_EMPTY_SIM, _S_FULL_SIM], [_S_FULL_SIM, _S_EMPTY_SIM]])
|
||||||
|
check("⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT, which is what makes this a "
|
||||||
|
"defect rather than a reporting difference. With the emptiest pack at "
|
||||||
|
"11 % - below the 20 % EMS floor - every one of those five sites is "
|
||||||
|
"FLOOR-LIMITED and sizes NO forced discharge at all, where the baseline "
|
||||||
|
"with its emptiest pack at 25 % is permitted and sizes a positive one. A "
|
||||||
|
"reduction that dropped the empty pack's position would grade this site "
|
||||||
|
"for PERMISSION against a pack 39 points fuller and spend, through "
|
||||||
|
"`sim/inject.py`, a pack its own BMS stops",
|
||||||
|
all(r[3].floor_limited is True and r[3].floor_w == 0.0
|
||||||
|
for r in _s3_runs + _s2_runs)
|
||||||
|
and _s_ok.floor_limited is False and _s_ok.floor_w > 0.0
|
||||||
|
and _s_ok.soc == SOC_BETWEEN)
|
||||||
|
check("...and the precondition that makes the six runs comparable (habit 1): "
|
||||||
|
"every pack in every roster carries the SAME cut-out, so "
|
||||||
|
"`binding_soc_floor()` answers the SAME site floor on all of them and the "
|
||||||
|
"FLOOR axis cannot be what separated the answers; the three readings "
|
||||||
|
"really are three different numbers; and the empty one is really below "
|
||||||
|
"the EMS floor while the other two are really above it - two readings on "
|
||||||
|
"the same side of it would make every line here vacuous",
|
||||||
|
len({r[3].soc_floor for r in _s3_runs + _s2_runs}) == 1
|
||||||
|
and _s_ok.soc_floor == _s3_runs[0][3].soc_floor == float(SOC_FLOOR)
|
||||||
|
and all(inv.bms_soc_floor == A_PACK_FLOOR
|
||||||
|
for r in _s3_runs + _s2_runs for inv in r[2].inverters)
|
||||||
|
and len({_S_EMPTY_SIM, _S_MID_SIM, _S_FULL_SIM}) == 3
|
||||||
|
and _S_EMPTY_SIM < SOC_FLOOR < SOC_BETWEEN < _S_MID_SIM < _S_FULL_SIM)
|
||||||
|
|
||||||
|
# ⚠️ AND THE ENUMERATION ITSELF, COMPUTED RATHER THAN CLAIMED - 12g's table on
|
||||||
|
# the other axis. Every positional slice of `vals`, applied to the REAL
|
||||||
|
# `Controller.inverters` each cycle above actually carried, compared against the
|
||||||
|
# SoC that REALLY reached `decide()` on that run. A slice with an empty kill
|
||||||
|
# list is a live survivor and this line goes red naming it.
|
||||||
|
#
|
||||||
|
# ⚠️ WHAT THIS DOES NOT CLAIM. It grades the members listed and nothing wider -
|
||||||
|
# a rewrite that is not a positional slice (a different reduction, a filter on
|
||||||
|
# pack NAME, a sort before the slice) is not covered here. `max` and the mean
|
||||||
|
# are graded on the same runs immediately below. The table is PRINTED so a
|
||||||
|
# reader can see exactly how far the claim reaches.
|
||||||
|
_S_RUNS = ([("3-pack baseline, none empty", _s_ctl_ok, _s_ok)]
|
||||||
|
+ [(rn, ctl, dec) for rn, _sc, ctl, dec in _s3_runs + _s2_runs])
|
||||||
|
|
||||||
|
|
||||||
|
def _soc_reduced(vals, fn):
|
||||||
|
"""`pack_floor_soc`'s body with ONE thing changed: what `min()` iterates.
|
||||||
|
|
||||||
|
The None guard still runs over the WHOLE list, because these mutations are
|
||||||
|
inside the `min()` generator and not in the guard - the guard is graded
|
||||||
|
separately below. An empty slice makes the real `min()` raise, which is a
|
||||||
|
crashing cycle and therefore a kill; it is reported as a distinct answer
|
||||||
|
rather than swallowed.
|
||||||
|
"""
|
||||||
|
if not vals or any(v is None for v in vals):
|
||||||
|
return None
|
||||||
|
sl = fn(vals)
|
||||||
|
return float(min(float(v) for v in sl)) if sl else "min() of an empty slice"
|
||||||
|
|
||||||
|
|
||||||
|
_SOC_SLICE_FAMILY = {
|
||||||
|
"vals[:1]": lambda vs: vs[:1],
|
||||||
|
"vals[-1:]": lambda vs: vs[-1:],
|
||||||
|
"vals[:2]": lambda vs: vs[:2],
|
||||||
|
"vals[-2:]": lambda vs: vs[-2:],
|
||||||
|
"vals[1:]": lambda vs: vs[1:],
|
||||||
|
"vals[:-1]": lambda vs: vs[:-1],
|
||||||
|
"vals[1:-1]": lambda vs: vs[1:-1],
|
||||||
|
"vals[::2]": lambda vs: vs[::2],
|
||||||
|
}
|
||||||
|
_SOC_REDUCTION_FAMILY = {
|
||||||
|
"max(vals)": lambda vs: max(vs),
|
||||||
|
"mean(vals)": lambda vs: sum(vs) / len(vs),
|
||||||
|
"median(vals), upper": lambda vs: sorted(vs)[len(vs) // 2],
|
||||||
|
"sorted(vals)[1] (second emptiest)": lambda vs: sorted(vs)[min(1, len(vs) - 1)],
|
||||||
|
}
|
||||||
|
_soc_slice_kills = {
|
||||||
|
_nm: [rn for rn, ctl, dec in _S_RUNS
|
||||||
|
if _soc_reduced([inv.soc for inv in ctl.inverters], _fn) != dec.soc]
|
||||||
|
for _nm, _fn in _SOC_SLICE_FAMILY.items()}
|
||||||
|
_soc_red_kills = {
|
||||||
|
_nm: [rn for rn, ctl, dec in _S_RUNS
|
||||||
|
if _fn([inv.soc for inv in ctl.inverters]) != dec.soc]
|
||||||
|
for _nm, _fn in _SOC_REDUCTION_FAMILY.items()}
|
||||||
|
print(" THE POSITIONAL-SLICE FAMILY inside `pack_floor_soc`, and which run "
|
||||||
|
"separates each from the whole roster:")
|
||||||
|
for _nm, _k in _soc_slice_kills.items():
|
||||||
|
print(f" {_nm:<12} killed by: "
|
||||||
|
+ ("; ".join(_k) if _k else "*** NOTHING HERE - SURVIVOR ***"))
|
||||||
|
print(" ...and the NON-slice rewrites of the reduction itself:")
|
||||||
|
for _nm, _k in _soc_red_kills.items():
|
||||||
|
print(f" {_nm:<34} killed by: "
|
||||||
|
+ ("; ".join(_k) if _k else "*** NOTHING HERE - SURVIVOR ***"))
|
||||||
|
check("⚠️ EVERY positional slice in the enumerated family answers something "
|
||||||
|
"OTHER than the SoC that really reached `decide()` on at least one of "
|
||||||
|
"the six runs above - and it is the THREE-pack runs that kill `[:2]` and "
|
||||||
|
"`[-2:]`, which the two-pack runs structurally could not, by exactly the "
|
||||||
|
"argument 12c2 makes about N packs and reductions that agree on N. The "
|
||||||
|
"kill list is computed against the REAL `Controller.inverters` each cycle "
|
||||||
|
"carried and the REAL `PeakDecision.soc` it produced, so a member with an "
|
||||||
|
"empty list is a live survivor and takes this line red naming it. This "
|
||||||
|
"grades the LISTED members only; the printed table is its extent",
|
||||||
|
all(_soc_slice_kills.values()))
|
||||||
|
check("...and the same for the rewrites that are not slices at all: `max` - "
|
||||||
|
"the FULLEST pack, which is `binding_soc_floor()`'s reduction pasted onto "
|
||||||
|
"the wrong axis and the single most likely wrong edit here - the mean, "
|
||||||
|
"the median and the second-emptiest each disagree with the shipped answer "
|
||||||
|
"on at least one run. `max(vals)` on the index-2 run answers 60 % where "
|
||||||
|
"the site is graded at 11 %",
|
||||||
|
all(_soc_red_kills.values()))
|
||||||
|
check("...and the baseline the tables are measured against is the shipped "
|
||||||
|
"property's OWN answer, not a re-derivation: on every run, the SoC that "
|
||||||
|
"reached `decide()` equals `Controller.pack_floor_soc` on the roster that "
|
||||||
|
"cycle carried. If it did not, every kill list above would be measuring "
|
||||||
|
"the wrong thing",
|
||||||
|
all(dec.soc == ctl.pack_floor_soc for _rn, ctl, dec in _S_RUNS)
|
||||||
|
and len(_S_RUNS) == 6
|
||||||
|
and sorted(len(ctl.inverters) for _rn, ctl, dec in _S_RUNS)
|
||||||
|
== [2, 2, 3, 3, 3, 3])
|
||||||
|
|
||||||
|
# ⚠️ AND THE HONEST LIMIT OF EVERY RUN ABOVE, FOUND BY THIS ROUND'S OWN BATTERY
|
||||||
|
# AND NOT ARGUED FROM: `vals[:3]` and `vals[-3:]` SURVIVE all six behavioural
|
||||||
|
# runs, for the same reason `[:2]` survived the two-pack ones - THE LONGEST
|
||||||
|
# ROSTER HERE IS THREE, and a slice that keeps three consecutive positions IS
|
||||||
|
# the roster at N=3. Adding a four-pack cycle would close those two and hand
|
||||||
|
# `[:4]` and `[-4:]` the same free pass. THE REGRESS DOES NOT TERMINATE IN A
|
||||||
|
# FIXTURE - round 3's lesson, arriving unchanged on this axis.
|
||||||
|
#
|
||||||
|
# So the same two halves, each saying what it reaches. First the SWEEP, which is
|
||||||
|
# cheap here because `pack_floor_soc` is a PROPERTY and needs no cycle to
|
||||||
|
# evaluate: rosters of 1 to 12 real `Inverter`s, the empty pack at EVERY
|
||||||
|
# position of each, and the property must answer the emptiest every time. That
|
||||||
|
# kills every fixed-width head or tail window narrower than the longest roster
|
||||||
|
# at once, rather than one mutation at a time.
|
||||||
|
_S_SWEEP_N = 12
|
||||||
|
|
||||||
|
|
||||||
|
def _roster_of(socs):
|
||||||
|
"""A real Controller carrying one real `Inverter` per reading given."""
|
||||||
|
ctl, _hs = built(TMP / f"s32sw{len(socs)}.db")
|
||||||
|
for _i in range(len(socs) - 1):
|
||||||
|
second_inverter(ctl, name=f"sweep-{_i}", setpoint=f"number.sw_{_i}")
|
||||||
|
for inv, so in zip(ctl.inverters, socs):
|
||||||
|
inv.soc, inv.batt, inv.bms_soc_floor = so, 0.0, A_PACK_FLOOR
|
||||||
|
return ctl
|
||||||
|
|
||||||
|
|
||||||
|
_s_sweep_rows = []
|
||||||
|
for _n in range(1, _S_SWEEP_N + 1):
|
||||||
|
for _pos in range(_n):
|
||||||
|
_sc = [_S_FULL_SIM] * _n
|
||||||
|
_sc[_pos] = _S_EMPTY_SIM
|
||||||
|
_s_sweep_rows.append((_n, _pos, tuple(_sc),
|
||||||
|
_roster_of(_sc).pack_floor_soc))
|
||||||
|
_s_sweep_bad = [(n, p, sc, got) for n, p, sc, got in _s_sweep_rows
|
||||||
|
if got != min(sc)]
|
||||||
|
_s_head_kill = {w: any(min(sc[:w]) != min(sc) for _n, _p, sc, _g in _s_sweep_rows)
|
||||||
|
for w in range(1, _S_SWEEP_N + 1)}
|
||||||
|
_s_tail_kill = {w: any(min(sc[-w:]) != min(sc) for _n, _p, sc, _g in _s_sweep_rows)
|
||||||
|
for w in range(1, _S_SWEEP_N + 1)}
|
||||||
|
print(f" LENGTH SWEEP on the real property (all SIMULATED past pack A): "
|
||||||
|
f"{len(_s_sweep_rows)} rosters, lengths 1..{_S_SWEEP_N}, the "
|
||||||
|
f"{_S_EMPTY_SIM:g} % pack at EVERY position of each among "
|
||||||
|
f"{_S_FULL_SIM:g} % siblings -> "
|
||||||
|
f"{len(_s_sweep_rows) - len(_s_sweep_bad)} answer the emptiest, "
|
||||||
|
f"{len(_s_sweep_bad)} do not")
|
||||||
|
print(" fixed-width windows killed by the sweep: head widths "
|
||||||
|
+ ",".join(str(w) for w in sorted(_s_head_kill) if _s_head_kill[w])
|
||||||
|
+ "; tail widths "
|
||||||
|
+ ",".join(str(w) for w in sorted(_s_tail_kill) if _s_tail_kill[w])
|
||||||
|
+ f"; NOT reached: head/tail width {_S_SWEEP_N} and wider")
|
||||||
|
check("⚠️ THE LENGTH SWEEP, on real `Controller.pack_floor_soc`. Over rosters "
|
||||||
|
"of 1 to 12 real inverters with the empty pack at every position of each, "
|
||||||
|
"the property answers the EMPTIEST every time - so every head window "
|
||||||
|
"`min(vals[:w])` and every tail window `min(vals[-w:])` of width 1 to 11 "
|
||||||
|
"disagrees with it on at least one roster here, and that is the whole "
|
||||||
|
"positional family below width 12 closed in one property rather than one "
|
||||||
|
"mutation at a time. ⚠️ THIS IS THE REAL PROPERTY ON A REAL ROSTER, not a "
|
||||||
|
"re-implementation: `_roster_of()` appends real `Inverter` objects and "
|
||||||
|
"reads `ctl.pack_floor_soc`, so it also sees a rewrite of the "
|
||||||
|
"comprehension that builds `vals`",
|
||||||
|
not _s_sweep_bad
|
||||||
|
and len(_s_sweep_rows) == _S_SWEEP_N * (_S_SWEEP_N + 1) // 2
|
||||||
|
and all(_s_head_kill[w] and _s_tail_kill[w]
|
||||||
|
for w in range(1, _S_SWEEP_N)))
|
||||||
|
check("⚠️ ...AND WIDTH 12 AND WIDER IS HONESTLY NOT REACHED, recorded rather "
|
||||||
|
"than papered over - the same ceiling 12c2's sweep records on the floor "
|
||||||
|
"axis. No roster in this file is longer than 12, so `min(vals[:12])` IS "
|
||||||
|
"`min(vals)` on every one of them and no check here can tell them apart. "
|
||||||
|
"A 13-pack roster would close width 12 and hand width 13 the same free "
|
||||||
|
"pass. That is why the STRUCTURAL PIN below exists alongside these runs, "
|
||||||
|
"and this line asserts the unreachedness itself so a later sweep that "
|
||||||
|
"lengthens the rosters takes it red and forces the sentence rewritten",
|
||||||
|
not _s_head_kill[_S_SWEEP_N] and not _s_tail_kill[_S_SWEEP_N]
|
||||||
|
and max(len(sc) for _n, _p, sc, _g in _s_sweep_rows) == _S_SWEEP_N)
|
||||||
|
|
||||||
|
# ...and the NONE GUARD is a reduction over roster length TOO, and it had
|
||||||
|
# exactly the same fixture problem: 12d sets the SECOND of two packs to None, so
|
||||||
|
# `any(v is None for v in vals[:1])` - grade only pack A for silence - answers
|
||||||
|
# "all present" there and hands a list containing None to `min()`. The sweep
|
||||||
|
# below moves the silent pack along the roster at every length, which is the
|
||||||
|
# data that reaches it. THE DIRECTION MATTERS: None must WIN, because a silent
|
||||||
|
# pack is a pack nothing can grade and `decide()` treats None as floor-limited.
|
||||||
|
def _pfs_answer(ctl):
|
||||||
|
"""`pack_floor_soc`, with a raise reported as an ANSWER rather than
|
||||||
|
ending the file. A guard narrowed to a slice does not return a wrong
|
||||||
|
number - it lets a None reach `min(float(v) ...)` and raises TypeError -
|
||||||
|
and a check that dies is a check that names nothing."""
|
||||||
|
try:
|
||||||
|
return ctl.pack_floor_soc
|
||||||
|
except Exception as exc: # noqa: BLE001 - reported
|
||||||
|
return f"raised {type(exc).__name__}"
|
||||||
|
|
||||||
|
|
||||||
|
_s_none_rows = []
|
||||||
|
for _n in range(1, _S_SWEEP_N + 1):
|
||||||
|
for _pos in range(_n):
|
||||||
|
_ctl_n = _roster_of([_S_FULL_SIM] * _n)
|
||||||
|
_ctl_n.inverters[_pos].soc = None
|
||||||
|
_s_none_rows.append((_n, _pos, _pfs_answer(_ctl_n)))
|
||||||
|
print(f" NONE-GUARD SWEEP: {len(_s_none_rows)} rosters, ONE silent pack at "
|
||||||
|
f"every position of every length 1..{_S_SWEEP_N} -> "
|
||||||
|
f"{sum(1 for _n, _p, g in _s_none_rows if g is None)} answer None")
|
||||||
|
check("⚠️ THE NONE GUARD IS THE SAME FAMILY. One silent pack at ANY position of "
|
||||||
|
"ANY roster length makes `pack_floor_soc` None - never a number, and "
|
||||||
|
"never the minimum of the packs that did answer. A guard narrowed to a "
|
||||||
|
"positional slice would let the site spend the packs that answered while "
|
||||||
|
"one sat unmeasured; 12d's two-pack fixture, with the SECOND pack silent, "
|
||||||
|
"cannot see a guard narrowed to the first",
|
||||||
|
all(g is None for _n, _p, g in _s_none_rows)
|
||||||
|
and len(_s_none_rows) == _S_SWEEP_N * (_S_SWEEP_N + 1) // 2)
|
||||||
|
|
||||||
|
# ⚠️ THE STRUCTURAL HALF: the reduction's ARGUMENT, pinned EXACTLY, at any N.
|
||||||
|
# This is 12g's treatment applied inside a property rather than at a call site,
|
||||||
|
# and it is NOT a substring test - `"vals" in src` is true of every slice of
|
||||||
|
# `vals`, whereas `ast.unparse(iter) == "vals"` is false of all of them.
|
||||||
|
# `vals[:3]` unparses to `vals[:3]`.
|
||||||
|
#
|
||||||
|
# THREE expressions are pinned, because there are three reductions over roster
|
||||||
|
# length in this one property and the runs above reach them unevenly:
|
||||||
|
# the LIST `[inv.soc for inv in self.inverters]` - the roster itself
|
||||||
|
# the GUARD `any(v is None for v in vals)`
|
||||||
|
# the REDUCTION `min(float(v) for v in vals)`
|
||||||
|
#
|
||||||
|
# ⚠️ AND THE PIN IS BLIND TO WHAT THE RUNS SEE, which is why both halves are
|
||||||
|
# here. It cannot see `min` become `max` (identical shape, different name -
|
||||||
|
# killed by the roster set), it cannot see `inv.soc` become `inv.bms_soc_floor`
|
||||||
|
# (killed by the runs' held-flat cut-outs), and it cannot see a change inside
|
||||||
|
# `Inverter.soc` at all.
|
||||||
|
_pfs_fn = next(n for n in ast.walk(ast.parse(_mainsrc))
|
||||||
|
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
and n.name == "pack_floor_soc")
|
||||||
|
_pfs_vals = next(n.value for n in _pfs_fn.body
|
||||||
|
if isinstance(n, ast.Assign)
|
||||||
|
and [ast.unparse(t) for t in n.targets] == ["vals"])
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_iter_of(fn_node, name):
|
||||||
|
"""The iterable of the comprehension inside the single `name(...)` call."""
|
||||||
|
call = next(n for n in ast.walk(fn_node)
|
||||||
|
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||||
|
and n.func.id == name)
|
||||||
|
comp = call.args[0]
|
||||||
|
return comp.generators[0].iter if len(comp.generators) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
_pfs_min_iter = _gen_iter_of(_pfs_fn, "min")
|
||||||
|
_pfs_any_iter = _gen_iter_of(_pfs_fn, "any")
|
||||||
|
print(f" pack_floor_soc reduces: list over `{ast.unparse(_pfs_vals.generators[0].iter)}`"
|
||||||
|
f", any() over `{ast.unparse(_pfs_any_iter)}`, min() over "
|
||||||
|
f"`{ast.unparse(_pfs_min_iter)}`")
|
||||||
|
check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduces "
|
||||||
|
"the WHOLE list `vals` - a bare Name, not a Subscript, not a call, not a "
|
||||||
|
"second comprehension - and `vals` is built by ONE comprehension over the "
|
||||||
|
"WHOLE property `self.inverters`, an attribute of self and not a slice of "
|
||||||
|
"one. So EVERY slice of either is red here at EVERY roster length, "
|
||||||
|
"including `vals[:3]` and `vals[-3:]`, which survive all six runs above "
|
||||||
|
"because the longest roster they drive is three packs long, and including "
|
||||||
|
"`self.inverters[:2]` in the comprehension, which the sweep sees only up "
|
||||||
|
"to length 12. This grades the code's SHAPE and nothing about the values; "
|
||||||
|
"what the expression MEANS is graded by the runs",
|
||||||
|
isinstance(_pfs_min_iter, ast.Name) and _pfs_min_iter.id == "vals"
|
||||||
|
and ast.unparse(_pfs_min_iter) == "vals"
|
||||||
|
and isinstance(_pfs_vals, ast.ListComp)
|
||||||
|
and len(_pfs_vals.generators) == 1
|
||||||
|
and not _pfs_vals.generators[0].ifs
|
||||||
|
and isinstance(_pfs_vals.generators[0].iter, ast.Attribute)
|
||||||
|
and isinstance(_pfs_vals.generators[0].iter.value, ast.Name)
|
||||||
|
and _pfs_vals.generators[0].iter.value.id == "self"
|
||||||
|
and _pfs_vals.generators[0].iter.attr == "inverters"
|
||||||
|
and ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters")
|
||||||
|
check("...and the NONE GUARD reduces that same whole list, pinned the same way. "
|
||||||
|
"`any(v is None for v in vals[:1])` is +4 bytes and leaves every other "
|
||||||
|
"line in this file identical; the sweep above reaches it up to length 12 "
|
||||||
|
"and this reaches it at every length",
|
||||||
|
isinstance(_pfs_any_iter, ast.Name) and _pfs_any_iter.id == "vals"
|
||||||
|
and ast.unparse(_pfs_any_iter) == "vals")
|
||||||
|
check("...and the counterfactual that shows these pins are not vacuous (habit "
|
||||||
|
"7): the very slices they exist for really do unparse to something else, "
|
||||||
|
"so the equalities above are doing work rather than being trivially true "
|
||||||
|
"of anything shaped like an argument",
|
||||||
|
all(ast.unparse(ast.parse(f"vals{sl}", mode="eval").body) != "vals"
|
||||||
|
and ast.unparse(ast.parse(f"self.inverters{sl}",
|
||||||
|
mode="eval").body) != "self.inverters"
|
||||||
|
for sl in ("[:1]", "[-1:]", "[:2]", "[-2:]", "[:3]", "[-3:]",
|
||||||
|
"[:12]", "[-12:]", "[1:]", "[:-1]", "[1:-1]", "[::2]"))
|
||||||
|
and ast.unparse(ast.parse("list(reversed(vals))",
|
||||||
|
mode="eval").body) != "vals")
|
||||||
|
|
||||||
|
# ...and BOTH consumers of this property are pinned to it by name, so a reduction
|
||||||
|
# hardened here cannot be bypassed by a call site that stops using it. The peak
|
||||||
|
# guard's `soc=` is already pinned in part 4; the OTHER one is arbitrage's, and
|
||||||
|
# it is a safety consumer too - `soc_floor_pct` is what stops a sell.
|
||||||
|
_arb_calls = [n for n in ast.walk(ast.parse(_mainsrc))
|
||||||
|
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
||||||
|
and n.func.attr == "decide"
|
||||||
|
and isinstance(n.func.value, ast.Name)
|
||||||
|
and n.func.value.id == "arbitrage"]
|
||||||
|
_arb_soc = ast.unparse(next(k.value for k in _arb_calls[0].keywords
|
||||||
|
if k.arg == "soc"))
|
||||||
|
print(f" main.py's arbitrage.decide(soc=...) is `{_arb_soc}` "
|
||||||
|
f"({len(_arb_calls)} call site)")
|
||||||
|
check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()` "
|
||||||
|
"is handed the SAME `self.pack_floor_soc` and grades it against "
|
||||||
|
"`soc_floor_pct` to decide whether to SELL - so the mutation this block "
|
||||||
|
"exists for spends a pack on the arbitrage path as well as the peak-guard "
|
||||||
|
"path, and hardening the property covers both. A call site that reverted "
|
||||||
|
"to a per-unit reading, or defaulted it, would take this line red",
|
||||||
|
len(_arb_calls) == 1 and _arb_soc == "self.pack_floor_soc")
|
||||||
|
|
||||||
|
# --- 12j. THE ENUMERATION ITSELF, AS A RUNNING CHECK -------------------------
|
||||||
|
# ⚠️ THIS CARD HAS NOW BEEN REJECTED THREE TIMES FOR THE SAME SHAPE ONE STEP
|
||||||
|
# OVER: a reduction over roster length whose fixture could not separate it from
|
||||||
|
# a slice of the roster. Round 2 hardened `binding_soc_floor()`; round 3 found
|
||||||
|
# the call site's argument; round 4's blocker was `pack_floor_soc`, the SIBLING
|
||||||
|
# ARGUMENT of the same `decide()` call. Closing them one at a time has cost four
|
||||||
|
# rounds, so this block stops enumerating them by hand.
|
||||||
|
#
|
||||||
|
# EVERY comprehension in `Controller` that iterates the roster is listed here,
|
||||||
|
# COMPUTED from main.py's AST, PRINTED, and required to iterate the roster
|
||||||
|
# WHOLE. That is the family: `min`, `max`, `sum`, `any`, `all`, `sorted` and the
|
||||||
|
# list builders all take their roster through one of these, so a `[:1]`, a
|
||||||
|
# `[-1:]` or a `[:2]` anywhere in the class takes this line red on the line it
|
||||||
|
# was written - at every roster length, in every configuration, whether or not
|
||||||
|
# any fixture in this repository reaches it.
|
||||||
|
#
|
||||||
|
# ⚠️ WHAT IT DOES NOT COVER, said plainly. It grades the ITERABLE and nothing
|
||||||
|
# else: not which attribute is read off each unit, not which reduction is
|
||||||
|
# applied, and not the direct index sites (`self.inverters[0]` in `_only()`,
|
||||||
|
# `actuator_of()` and the store migration, `self.inverters[self.maint_index]` in
|
||||||
|
# `maint_unit`), which are not comprehensions and are graded where they live.
|
||||||
|
# The behavioural halves above are what say what these reductions MEAN.
|
||||||
|
_ctrl_cls = next(n for n in ast.walk(ast.parse(_mainsrc))
|
||||||
|
if isinstance(n, ast.ClassDef) and n.name == "Controller")
|
||||||
|
_ROSTER_ATTRS = ("inverters", "packs")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_roster(node):
|
||||||
|
return (isinstance(node, ast.Attribute) and node.attr in _ROSTER_ATTRS
|
||||||
|
and isinstance(node.value, ast.Name) and node.value.id == "self")
|
||||||
|
|
||||||
|
|
||||||
|
def _mentions_roster(node):
|
||||||
|
return any(_is_roster(n) for n in ast.walk(node))
|
||||||
|
|
||||||
|
|
||||||
|
_roster_comps = []
|
||||||
|
for _fn in ast.walk(_ctrl_cls):
|
||||||
|
if not isinstance(_fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
continue
|
||||||
|
for _n in ast.walk(_fn):
|
||||||
|
if isinstance(_n, (ast.ListComp, ast.SetComp, ast.DictComp,
|
||||||
|
ast.GeneratorExp)):
|
||||||
|
for _g in _n.generators:
|
||||||
|
if _mentions_roster(_g.iter):
|
||||||
|
_roster_comps.append((_fn.name, _g.iter.lineno,
|
||||||
|
ast.unparse(_g.iter)))
|
||||||
|
_roster_comps.sort(key=lambda r: (r[1], r[2]))
|
||||||
|
|
||||||
|
|
||||||
|
def _slices_roster(node):
|
||||||
|
"""Is the roster SUBSCRIPTED anywhere inside this expression?
|
||||||
|
|
||||||
|
⚠️ THE TEST IS ON THE SUBSCRIPT, NOT ON THE SPELLING. `enumerate(...)`,
|
||||||
|
`zip(...)` and `pack_permissions(roster, floor)` all hand the WHOLE roster
|
||||||
|
on and are legitimate; `self.inverters[:1]` is the family this card has been
|
||||||
|
rejected over three times. Requiring the iterable to unparse to exactly
|
||||||
|
`self.inverters` would refuse the first three and grade the spelling instead
|
||||||
|
of the thing.
|
||||||
|
"""
|
||||||
|
return any(isinstance(n, ast.Subscript) and _is_roster(n.value)
|
||||||
|
for n in ast.walk(node))
|
||||||
|
|
||||||
|
|
||||||
|
_sliced = [r for r in _roster_comps
|
||||||
|
if _slices_roster(ast.parse(r[2], mode="eval").body)]
|
||||||
|
print(f" EVERY comprehension in Controller that iterates the roster "
|
||||||
|
f"({len(_roster_comps)} of them, in "
|
||||||
|
f"{len({m for m, _l, _s in _roster_comps})} methods), and what each "
|
||||||
|
f"iterates:")
|
||||||
|
for _mname, _ln, _src in _roster_comps:
|
||||||
|
print(f" main.py:{_ln:<5} {_mname:<26} for ... in {_src}"
|
||||||
|
+ ("" if _src in ("self.inverters", "self.packs")
|
||||||
|
else " [whole roster, wrapped]"))
|
||||||
|
check("⚠️ THE FAMILY, ENUMERATED FROM THE SOURCE RATHER THAN FROM MEMORY. Every "
|
||||||
|
"comprehension in `Controller` that iterates the roster iterates it "
|
||||||
|
"WHOLE - `self.inverters` or `self.packs`, bare. This is the check that "
|
||||||
|
"would have caught rounds 2, 3 and 4's blockers on the round they were "
|
||||||
|
"written instead of one review each: `min(... for v in vals[:2])` is not "
|
||||||
|
"here (it reduces a derived list, which 12i pins directly), but every "
|
||||||
|
"`for inv in self.inverters[:1]` in the class is. A new reduction added "
|
||||||
|
"over a slice of the roster is red on the line it is written, at every "
|
||||||
|
"roster length and with no fixture required. ⚠️ THE ITERABLE ONLY - what "
|
||||||
|
"is read off each unit and what reduction is applied are graded by the "
|
||||||
|
"runs, and the direct index sites are not comprehensions and not here",
|
||||||
|
len(_roster_comps) > 0 and not _sliced)
|
||||||
|
check("...and the enumeration is not vacuously empty and its predicate is not "
|
||||||
|
"trivially true (habit 7): it found more than twenty roster "
|
||||||
|
"comprehensions across more than fifteen methods, it REJECTS the same "
|
||||||
|
"expressions with a slice on them - bare and wrapped in `enumerate()` "
|
||||||
|
"alike - and it ACCEPTS the three legitimate wrapped forms this class "
|
||||||
|
"actually uses. A predicate that answered the same thing for both would "
|
||||||
|
"make the line above a description rather than a check",
|
||||||
|
len(_roster_comps) >= 20
|
||||||
|
and len({m for m, _l, _s in _roster_comps}) >= 15
|
||||||
|
and all(_slices_roster(ast.parse(f"self.{a}{sl}", mode="eval").body)
|
||||||
|
and _slices_roster(ast.parse(f"enumerate(self.{a}{sl})",
|
||||||
|
mode="eval").body)
|
||||||
|
for a in _ROSTER_ATTRS
|
||||||
|
for sl in ("[:1]", "[-1:]", "[:2]", "[1:]", "[:-1]", "[::2]"))
|
||||||
|
and not any(_slices_roster(ast.parse(e, mode="eval").body)
|
||||||
|
for e in ("self.inverters", "self.packs",
|
||||||
|
"enumerate(self.inverters)",
|
||||||
|
"peakguard.pack_permissions(self.packs, f)")))
|
||||||
|
# ⚠️ AND THE TWO THIS ENUMERATION ACTUALLY FOUND, recorded here because the
|
||||||
|
# lesson is the finding and not the fix. Mutating every roster reduction in
|
||||||
|
# `Controller` to `[:1]` one at a time turned up TWO that survived a green
|
||||||
|
# 19-file suite, both `any(i.sign_wd.tripped for i in self.inverters)` and both
|
||||||
|
# SAFETY-30's rather than this card's:
|
||||||
|
#
|
||||||
|
# `cycle()`'s stale-input hold +4 bytes, 19/19 green - a LATCHED unit must
|
||||||
|
# take the roster past the hold, or its failsafe SET stops being re-filed
|
||||||
|
# the moment a poll is missed. Graded now in test_watchdog.py part 8, on
|
||||||
|
# a two-inverter roster with the latch at EACH position in turn.
|
||||||
|
# `publish()`'s status vocabulary +4 bytes, 19/19 green - a site with one
|
||||||
|
# inverter parked by its own failsafe claim published `running`. Same
|
||||||
|
# part, same two positions.
|
||||||
|
#
|
||||||
|
# Both were invisible for the same reason every blocker on this card was
|
||||||
|
# invisible: the only fixture that reached them was a roster of ONE, where
|
||||||
|
# `self.inverters[:1]` IS `self.inverters`.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
print()
|
print()
|
||||||
print("11. mutation: what defeats these guards, and which check bites")
|
print("11. mutation: what defeats these guards, and which check bites")
|
||||||
@@ -4502,6 +5086,74 @@ MUT.append(("`binding_soc_floor()` reduced over a window 12 packs wide or "
|
|||||||
"max is order- and duplicate-invariant; the second is nonetheless "
|
"max is order- and duplicate-invariant; the second is nonetheless "
|
||||||
"red on 12g's structural pin"))
|
"red on 12g's structural pin"))
|
||||||
|
|
||||||
|
# --- ROUND 4: THE SIBLING ARGUMENT, AND THE ENUMERATION THAT FOUND THE REST --
|
||||||
|
# 28. `pack_floor_soc` reduced over a WINDOW of roster positions. THIS ROUND'S
|
||||||
|
# BLOCKER, and the same family as rows 25-26 one ARGUMENT over: the MINIMUM
|
||||||
|
# handed to the same `decide()` call as `soc=`. Green across all 19 files at
|
||||||
|
# 52380c0 and again on the rebase onto 3934ad7.
|
||||||
|
MUT.append(("`pack_floor_soc` reduced over a WINDOW - `vals[:2]` (+4 bytes) or "
|
||||||
|
"`vals[-2:]` (+5) - ROUND 4's BLOCKER, the SoC axis of the pair",
|
||||||
|
all(_soc_slice_kills[k] for k in ("vals[:2]", "vals[-2:]",
|
||||||
|
"vals[:1]", "vals[-1:]")),
|
||||||
|
"part 12i's THREE-pack runs and their POSITIONAL-SLICE TABLE: the "
|
||||||
|
"same three readings with the emptiest at index 0, 1 and 2, so "
|
||||||
|
"every proper subset of the positions drops the empty pack on some "
|
||||||
|
"run. ⚠️ At N=2 `[:2]` IS the roster - the two-pack mirror cannot "
|
||||||
|
"see it, which is why the suite's separating power on this axis "
|
||||||
|
"stopped at exactly N=2 for four rounds"))
|
||||||
|
# 29. ...and the three-wide window, invisible to every behavioural run here.
|
||||||
|
MUT.append(("`pack_floor_soc` reduced over a THREE-wide window - `vals[:3]` / "
|
||||||
|
"`vals[-3:]` - invisible to every cycle in this file",
|
||||||
|
_s_head_kill[3] and _s_tail_kill[3]
|
||||||
|
and ast.unparse(_pfs_min_iter) == "vals",
|
||||||
|
"part 12i's LENGTH SWEEP (widths 1-11, on the REAL property over "
|
||||||
|
"rosters of 1 to 12 real inverters) and its STRUCTURAL PIN. The "
|
||||||
|
"six real cycles cannot see it - three packs is the longest roster "
|
||||||
|
"they drive - and the sweep and the pin are what do"))
|
||||||
|
# 30. the reduction itself, on the wrong axis. Zero byte delta.
|
||||||
|
MUT.append(("`pack_floor_soc` reduced with `max()` - the FULLEST pack, which is "
|
||||||
|
"`binding_soc_floor()`'s own reduction pasted onto the SoC axis - "
|
||||||
|
"or with the mean, the median or the second-emptiest",
|
||||||
|
all(_soc_red_kills.values()),
|
||||||
|
"part 12i's NON-SLICE table, on the same six real cycles: the "
|
||||||
|
"emptiest pack is 11 % where the fullest is 60 %, so every one of "
|
||||||
|
"the four disagrees on every run. ⚠️ Zero byte delta, and the most "
|
||||||
|
"likely wrong edit here precisely because the sibling reduction one "
|
||||||
|
"argument over IS a max"))
|
||||||
|
# 31. the None guard, which is a reduction over roster length too.
|
||||||
|
MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - "
|
||||||
|
"`any(v is None for v in vals[:1])`, +4 bytes",
|
||||||
|
all(g is None for _n, _p, g in _s_none_rows)
|
||||||
|
and ast.unparse(_pfs_any_iter) == "vals",
|
||||||
|
"part 12i's NONE-GUARD SWEEP - one silent pack at every position of "
|
||||||
|
"every length 1..12 - and the same structural pin. ⚠️ 12d's "
|
||||||
|
"two-pack fixture silences the SECOND pack only, so a guard "
|
||||||
|
"narrowed to the FIRST answers 'all present' there and hands a None "
|
||||||
|
"to `min()`"))
|
||||||
|
# 32. and the roster the list itself is built from.
|
||||||
|
MUT.append(("`pack_floor_soc` building `vals` from a SLICE of the roster - "
|
||||||
|
"`[inv.soc for inv in self.inverters[:2]]`, +4 bytes",
|
||||||
|
ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters"
|
||||||
|
and _s_head_kill[2] and not _sliced,
|
||||||
|
"part 12i's sweep reads the REAL property on real rosters so it "
|
||||||
|
"sees this too, its pin is on the comprehension's own iterable, "
|
||||||
|
"and 12j's ENUMERATION covers every other comprehension in the "
|
||||||
|
"class at once"))
|
||||||
|
# 33. THE ENUMERATION'S OWN FINDING, and the reason this row is worth more than
|
||||||
|
# the four above: it is not this card's code.
|
||||||
|
MUT.append(("a reduction over the roster added ANYWHERE ELSE in `Controller` "
|
||||||
|
"over a slice - which is how rounds 2, 3 and 4 each began",
|
||||||
|
bool(_roster_comps) and not _sliced,
|
||||||
|
"part 12j: every roster comprehension in the class, enumerated "
|
||||||
|
"from main.py's AST and required to iterate the roster whole. ⚠️ "
|
||||||
|
"Mutating each of them to `[:1]` found FOUR that survived a green "
|
||||||
|
"19-file suite, none of them this card's: three `sign_wd.tripped` "
|
||||||
|
"reductions (SAFETY-30's stale-input hold, `publish()`'s status "
|
||||||
|
"vocabulary and `status_payload()`'s banner - now graded in "
|
||||||
|
"test_watchdog.py part 8) and the store-migration guard's redundant "
|
||||||
|
"second disjunct. THIS row is the one that closes the family "
|
||||||
|
"rather than the member"))
|
||||||
|
|
||||||
for label, caught, where in MUT:
|
for label, caught, where in MUT:
|
||||||
print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}")
|
print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}")
|
||||||
print(f" {where}")
|
print(f" {where}")
|
||||||
@@ -4536,7 +5188,7 @@ check("...⚠️ AND SO ARE THE SEVEN DEFEATS THAT SURVIVED A GREEN 19-FILE SUIT
|
|||||||
"consecutive cards and, twice running, inside the fix written to close "
|
"consecutive cards and, twice running, inside the fix written to close "
|
||||||
"it. The last five were found by a battery rather than by argument, "
|
"it. The last five were found by a battery rather than by argument, "
|
||||||
"which is the only thing that has ever found any of them",
|
"which is the only thing that has ever found any of them",
|
||||||
all(m[1] for m in MUT[21:27]) and len(MUT) == 28)
|
all(m[1] for m in MUT[21:27]) and len(MUT) == 34)
|
||||||
check("...⚠️ AND ONE IS RECORDED AS NOT CAUGHT, which is the only honest row "
|
check("...⚠️ AND ONE IS RECORDED AS NOT CAUGHT, which is the only honest row "
|
||||||
"this round could write for it: a reduction over a window TWELVE packs "
|
"this round could write for it: a reduction over a window TWELVE packs "
|
||||||
"wide is indistinguishable from `max(floors)` on every roster in this "
|
"wide is indistinguishable from `max(floors)` on every roster in this "
|
||||||
@@ -4549,6 +5201,20 @@ check("...⚠️ AND ONE IS RECORDED AS NOT CAUGHT, which is the only honest row
|
|||||||
"with, and it exists to be argued with",
|
"with, and it exists to be argued with",
|
||||||
not MUT[27][1]
|
not MUT[27][1]
|
||||||
and all(_head_kill[w] and _tail_kill[w] for w in range(1, _SWEEP_N)))
|
and all(_head_kill[w] and _tail_kill[w] for w in range(1, _SWEEP_N)))
|
||||||
|
check("...⚠️ AND ROUND 4's SIX, WHICH ARE THE SAME FAMILY AT THE SIBLING "
|
||||||
|
"ARGUMENT OF THE SAME CALL. `pack_floor_soc` is the MINIMUM over the "
|
||||||
|
"roster handed to `decide(soc=...)`; `binding_soc_floor()` is the MAXIMUM "
|
||||||
|
"handed to `decide(soc_floor=...)` beside it, and peakguard.py's own "
|
||||||
|
"docstring names them as a pair. Rounds 2 and 3 hardened one half; "
|
||||||
|
"`vals[:2]`, `vals[-2:]` and `vals[:3]` were still green on 52380c0 with "
|
||||||
|
"the other, and a three-pack roster at 50 / 60 / 11 % answers 11 % clean "
|
||||||
|
"and 50 % mutated - a discharge PERMISSION graded against a pack 39 "
|
||||||
|
"points fuller than the emptiest. The last row is the one that matters "
|
||||||
|
"most: closing the MEMBER four times has cost four review rounds, so the "
|
||||||
|
"FAMILY is now enumerated from the source, and that enumeration found "
|
||||||
|
"four more survivors in code this card never touched",
|
||||||
|
all(m[1] for m in MUT[28:34]) and len(MUT) == 34
|
||||||
|
and _S_EMPTY_SIM < SOC_FLOOR)
|
||||||
check("...and so is the last one, which is not a defeat at all but the EDGE OF "
|
check("...and so is the last one, which is not a defeat at all but the EDGE OF "
|
||||||
"THIS CARD: the grid-following loop can still take the pack past this "
|
"THIS CARD: the grid-following loop can still take the pack past this "
|
||||||
"floor, because the floor bounds the peak guard's own forced discharge "
|
"floor, because the floor bounds the peak guard's own forced discharge "
|
||||||
|
|||||||
@@ -358,6 +358,64 @@ check("...and it is refused with maintenance_enabled off, as shipped",
|
|||||||
base(max_w=2000, maintenance_charge_w=2500,
|
base(max_w=2000, maintenance_charge_w=2500,
|
||||||
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
|
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:
|
# ⚠️ 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
|
# 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()
|
# CLAMPS it (+2000 W) instead of contradicting it, and the arbiter's explain()
|
||||||
|
|||||||
@@ -1809,6 +1809,181 @@ check("...and no `sign_site` claim can be filed at a roster of one, so the "
|
|||||||
"arbitration is byte-identical to what shipped",
|
"arbitration is byte-identical to what shipped",
|
||||||
"sign_site" not in _c1s.reason)
|
"sign_site" not in _c1s.reason)
|
||||||
|
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("8. SAFETY-32 ROUND 4's ENUMERATION: the two `any(...)` reductions over")
|
||||||
|
print(" the roster whose only evidence is a latched NON-FIRST unit")
|
||||||
|
# ⚠️ THIS SECTION IS NOT SAFETY-32's SUBJECT, AND IT IS HERE BECAUSE OF IT.
|
||||||
|
# SAFETY-32 was rejected three times for the same shape one step over: a
|
||||||
|
# reduction over roster length whose fixture could not separate it from a slice
|
||||||
|
# of the roster. Round 4 therefore ENUMERATED every such reduction in
|
||||||
|
# `Controller` - min, max, sum, any, all, sorted, [0], [-1] over
|
||||||
|
# `self.inverters` / `self.packs` - and mutated each to `[:1]` to find out which
|
||||||
|
# were GRADED. Two survived a green 19-file suite, both `any(i.sign_wd.tripped
|
||||||
|
# for i in self.inverters)`, both SAFETY-30's, and this block is what closes
|
||||||
|
# them. No shipped behaviour changes; the reductions were already right.
|
||||||
|
#
|
||||||
|
# main.py `cycle()`, the stale-input hold - +4 bytes, 19/19 GREEN
|
||||||
|
# main.py `publish()`, the status vocabulary - +4 bytes, 19/19 GREEN
|
||||||
|
#
|
||||||
|
# ⚠️ WHY THE SUITE COULD NOT SEE THEM, and it is the same sentence every time:
|
||||||
|
# the only fixture that reached either was a roster of ONE, where
|
||||||
|
# `self.inverters[:1]` IS `self.inverters`. Part 3 above builds a two-inverter
|
||||||
|
# roster in which ONLY inverter B is latched - the exact data both reductions
|
||||||
|
# needed - and neither of them was read on it. A fixture of N units cannot
|
||||||
|
# separate reductions that agree on N units, one card over.
|
||||||
|
#
|
||||||
|
# ⚠️ AND BOTH ORDERS ARE RUN, for 12g's reason: a single "only B is latched"
|
||||||
|
# fixture kills `[:1]` and leaves `[-1:]` - grade the roster by its LAST unit -
|
||||||
|
# answering identically. The pair is what leaves no single-position slice.
|
||||||
|
|
||||||
|
|
||||||
|
async def _latched_then_input_gap(reversed_idx):
|
||||||
|
"""Latch ONE unit's sign watchdog, then take a SIBLING's reading away.
|
||||||
|
|
||||||
|
⚠️ A REAL LATCH FROM A REAL EPISODE, not `sign_wd.tripped = True`: the same
|
||||||
|
`_SignHass` plant and the same `_drive()` part 3 uses, so the detector
|
||||||
|
reaches its verdict through the shipped path. Then one pack's SoC goes
|
||||||
|
missing for LESS than `stale_input_s`, which is the state the hold exists
|
||||||
|
for - a single missed poll must not become a fault.
|
||||||
|
|
||||||
|
THE FAULT THIS GRADES: a LATCHED unit must take the whole roster past that
|
||||||
|
hold, because the unit's `failsafe` SET is the thing parking it. Return
|
||||||
|
early and the unit's last share stays standing for the heartbeat to
|
||||||
|
re-emit - main.py's own comment: "the trip would stop acting the moment a
|
||||||
|
poll was missed". A reduction that looked at one roster POSITION would do
|
||||||
|
exactly that whenever the latched unit was not the position it looked at.
|
||||||
|
"""
|
||||||
|
hass = _SignHass(load_w=2000.0, reversed_units={reversed_idx})
|
||||||
|
c = _sign_site(hass)
|
||||||
|
await _drive(c)
|
||||||
|
sibling = c.inverters[1 - reversed_idx]
|
||||||
|
sibling.soc = None
|
||||||
|
c.inputs_bad_since = datetime.now(timezone.utc)
|
||||||
|
await c.cycle()
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
_gap_b = asyncio.run(_latched_then_input_gap(1))
|
||||||
|
_gap_a = asyncio.run(_latched_then_input_gap(0))
|
||||||
|
for _tag, _c, _i in (("B latched", _gap_b, 1), ("A latched", _gap_a, 0)):
|
||||||
|
_pl = [p for p in (_c.allocation.plans if _c.allocation else ())
|
||||||
|
if p.unit == _c.inverters[_i].name]
|
||||||
|
print(f" {_tag}, sibling's SoC missing 0 s: inputs_ok="
|
||||||
|
f"{_c.inputs_ok}, reason={_c.reason!r}, latched unit target="
|
||||||
|
f"{_c.inverters[_i].target}, why={_pl[0].why if _pl else '(no plan)'}")
|
||||||
|
check("PRECONDITION (habit 1): on BOTH runs the site really is in the state "
|
||||||
|
"the hold is for - one pack's reading is missing, `inputs_ok` is False, "
|
||||||
|
"the gap is younger than `stale_input_s`, and the SITE latch is NOT "
|
||||||
|
"tripped, so the only thing that can carry the roster past the hold is "
|
||||||
|
"the per-unit `any()`. And exactly ONE unit is latched on each run, at "
|
||||||
|
"the OTHER position",
|
||||||
|
_gap_b.inputs_ok is False and _gap_a.inputs_ok is False
|
||||||
|
and _gap_b.site_sign_wd.tripped is False
|
||||||
|
and _gap_a.site_sign_wd.tripped is False
|
||||||
|
and [i.sign_wd.tripped for i in _gap_b.inverters] == [False, True]
|
||||||
|
and [i.sign_wd.tripped for i in _gap_a.inverters] == [True, False])
|
||||||
|
check("⚠️ A LATCHED UNIT TAKES THE ROSTER PAST THE STALE-INPUT HOLD FROM EITHER "
|
||||||
|
"POSITION. Neither cycle parks on `inputs missing`; both go on to file "
|
||||||
|
"claims, and the latched unit is commanded 0 W by its OWN `failsafe` "
|
||||||
|
"claim, named in the allocation record. Grade this with "
|
||||||
|
"`self.inverters[:1]` and the B-latched run returns early - the +4-byte "
|
||||||
|
"mutation that was GREEN across all 19 files - and with "
|
||||||
|
"`self.inverters[-1:]` the A-latched run does. The remedy stops acting "
|
||||||
|
"the moment a poll is missed, which is main.py's own sentence for it",
|
||||||
|
not (_gap_b.reason or "").startswith("inputs missing")
|
||||||
|
and not (_gap_a.reason or "").startswith("inputs missing")
|
||||||
|
and _gap_b.inverters[1].target == 0.0
|
||||||
|
and _gap_a.inverters[0].target == 0.0
|
||||||
|
and all(any(p.unit == c.inverters[i].name and "failsafe" in p.why
|
||||||
|
for p in (c.allocation.plans if c.allocation else ()))
|
||||||
|
for c, i in ((_gap_b, 1), (_gap_a, 0))))
|
||||||
|
# ...and the counterfactual, which is what makes the two lines above a
|
||||||
|
# DIFFERENCE rather than a description: with NOTHING latched, the very same
|
||||||
|
# input gap DOES park the site on the hold. Without this the checks above would
|
||||||
|
# be satisfied by a `cycle()` that never had a hold at all.
|
||||||
|
async def _gap_no_latch():
|
||||||
|
c = _sign_site(_SignHass(load_w=2000.0))
|
||||||
|
await _drive(c, n=2)
|
||||||
|
c.inverters[0].soc = None
|
||||||
|
c.inputs_bad_since = datetime.now(timezone.utc)
|
||||||
|
await c.cycle()
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
_gap_none = asyncio.run(_gap_no_latch())
|
||||||
|
print(f" nothing latched, same gap: reason={_gap_none.reason!r}")
|
||||||
|
check("...and the counterfactual (habit 7): with NO unit latched the SAME gap "
|
||||||
|
"DOES hold the site - `inputs missing 0s` - so the two runs above show "
|
||||||
|
"the latch carrying the roster past a hold that is really there, rather "
|
||||||
|
"than a hold that never fires",
|
||||||
|
(_gap_none.reason or "").startswith("inputs missing")
|
||||||
|
and not any(i.sign_wd.tripped for i in _gap_none.inverters))
|
||||||
|
|
||||||
|
# --- and the SECOND survivor: the status vocabulary publish() hands the broker
|
||||||
|
# ⚠️ THE EXISTING CHECK IS AT A ROSTER OF ONE, which is why this one exists.
|
||||||
|
# "MQTT's controller-status sensor reports 'failsafe'" above drives a
|
||||||
|
# single-inverter Controller, where `self.inverters[:1]` is the whole roster.
|
||||||
|
# A site at 0 W reporting `running` is the failure main.py's comment names, and
|
||||||
|
# at two inverters it is reachable whenever the latched unit is not the one a
|
||||||
|
# narrowed reduction happens to read.
|
||||||
|
_pub = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _published(ctl):
|
||||||
|
_pub.clear()
|
||||||
|
ctl.mqtt = type("P", (), {"enabled": True,
|
||||||
|
"publish": lambda _s, v: _pub.update(v),
|
||||||
|
"close": lambda _s: None})()
|
||||||
|
ctl.publish()
|
||||||
|
return dict(_pub)
|
||||||
|
|
||||||
|
|
||||||
|
_pb, _pa = _published(_gap_b), _published(_gap_a)
|
||||||
|
_pn = _published(_gap_none)
|
||||||
|
print(f" published status: B latched -> {_pb.get('status')!r}, "
|
||||||
|
f"A latched -> {_pa.get('status')!r}, none latched -> "
|
||||||
|
f"{_pn.get('status')!r}")
|
||||||
|
check("⚠️ AND `publish()` SAYS `failsafe` WHICHEVER UNIT IS LATCHED. The status "
|
||||||
|
"sensor is the vocabulary every dashboard already reads, and a site with "
|
||||||
|
"one inverter parked by its own failsafe claim reporting `running` is "
|
||||||
|
"the exact failure main.py's comment names. `self.inverters[:1]` "
|
||||||
|
"publishes `running` on the B-latched site - +4 bytes, GREEN across all "
|
||||||
|
"19 files - and `self.inverters[-1:]` does it on the A-latched one",
|
||||||
|
_pb.get("status") == "failsafe" and _pa.get("status") == "failsafe")
|
||||||
|
check("...and the counterfactual: the same two-inverter site with NOTHING "
|
||||||
|
"latched publishes `running`, so the two above are a difference the "
|
||||||
|
"reduction produced and not a constant",
|
||||||
|
_pn.get("status") == "running" and _gap_none.auto is True)
|
||||||
|
|
||||||
|
# --- and the THIRD one the same enumeration found: the OPERATOR'S BANNER -----
|
||||||
|
# ⚠️ `status_payload()`'s failsafe branch is a fourth `any(... for i in
|
||||||
|
# self.inverters)`, and `self.inverters[:1]` there was +4 bytes and GREEN across
|
||||||
|
# all 19 files too - for the third time, because the only fixture that reached
|
||||||
|
# it was a roster of ONE. The banner is the ONE sentence at the top of the page;
|
||||||
|
# a site holding an inverter at 0 W under a "Stopped" or "NOT READY" headline
|
||||||
|
# sends the operator to the wrong question.
|
||||||
|
_bn_b = _gap_b.status_payload()
|
||||||
|
_bn_a = _gap_a.status_payload()
|
||||||
|
_bn_n = _gap_none.status_payload()
|
||||||
|
print(f" banner: B latched -> {_bn_b['banner']!r}")
|
||||||
|
print(f" A latched -> {_bn_a['banner']!r}")
|
||||||
|
print(f" none -> {_bn_n['banner']!r}")
|
||||||
|
check("⚠️ AND THE BANNER LEADS WITH THE FAILSAFE WHICHEVER UNIT IS LATCHED, at "
|
||||||
|
"level `bad`, with the N>=2 sentence that names the unit's OWN measured "
|
||||||
|
"power rather than the meter. `self.inverters[:1]` drops the headline on "
|
||||||
|
"the B-latched site and `self.inverters[-1:]` on the A-latched one, and "
|
||||||
|
"the page then leads with whatever the next branch says",
|
||||||
|
_bn_b["level"] == _bn_a["level"] == "bad"
|
||||||
|
and _bn_b["banner"] == _bn_a["banner"]
|
||||||
|
and _bn_b["banner"].startswith("FAILSAFE")
|
||||||
|
and "its own measured power" in _bn_b["banner"])
|
||||||
|
check("...and the counterfactual again: the same site with nothing latched "
|
||||||
|
"carries neither that level nor that sentence, so the banner really is "
|
||||||
|
"being produced by the reduction under test",
|
||||||
|
not _bn_n["banner"].startswith("FAILSAFE")
|
||||||
|
and _bn_n["banner"] != _bn_b["banner"])
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||||
|
|||||||
Reference in New Issue
Block a user