Files
goodwe-addon/goodwe_controller/DECISIONS/MAINT-05-per-pack-maintenance.md
T
adminandClaude Opus 5 1aef19e995
checks / unit (pull_request) Successful in 4m48s
MAINT-05: one recalibration per pack, and only one pack moves power at a time
§12's monthly cycle drains a pack to empty and refills it to true its coulomb
counter. There was exactly one `Maintenance` and one un-namespaced history, so
at two packs only the first was ever recalibrated - and nothing prevented the
cycle draining pack A while the loop 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
  install's flat history into the first pack's namespace under
  `migrate_unit_keys()`'s three rules - shared in code via `_move_flat_keys()`
  and `_verify_moves()`, not restated - on its own marker, because unlike
  DEV-02's it runs only where the roster has more than one pack. The flat keys
  are kept LIVE as the rollback view.
* ONE MAINTENANCE SLOT PER SITE (`maintenance.arbitrate()`). At most one pack
  in a power-moving phase - drain, charge AND hold. The holder is DERIVED from
  the packs' own stored phases, never persisted, so a restart mid-drain
  re-derives it. Several packs 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` and does not move the phase, so neither the 12 h
  drain nor the 36 h charge timeout is reachable by waiting.
* The claim is filed on the pack whose state machine produced it, and stays
  SCOPE_UNIT - ARCH-01's rule.
* `validate.py` rule 6 grades `maintenance_charge_w` against the WEAKEST cap on
  the roster now, as its own comment said it must once `maint_unit` stopped
  being `inverters[0]`.
* At a roster of one nothing changes, and it is asserted: no new store key, the
  migration does not run, the four cycle events are the shipped strings, the
  banner is the bare phase.
* RECORDED NOT FIXED: a drain pins its pack while the loop's target is a site
  claim, so below the pin the allocator refuses the split and the site parks at
  0 W. Pre-existing at 2d025ee. Pinned as a running check.

Verified: 19/19 suites green in the CI image; 17 mutations run through
tools/mutation_run.sh (bytecode purged), every one observed red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 13:38:29 +02:00

11 KiB

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.

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.

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.