MAINT-05: one recalibration per pack, and only one pack moves power at a time
checks / unit (pull_request) Successful in 4m48s

§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
This commit is contained in:
2026-09-04 13:38:29 +02:00
co-authored by Claude Opus 5
parent 2d025eebf7
commit 1aef19e995
9 changed files with 1980 additions and 169 deletions
+39 -16
View File
@@ -802,24 +802,47 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# rule 8 below already makes with `PEAK_OPTION_DEFAULTS` - and
# test_maintenance.py pins it by reading the shipped file.
#
# ⚠️ AND IT IS GRADED AGAINST THE FIRST UNIT'S CAP, NOT THE SMALLEST AND NOT
# THE FLAT ONE. Rule 5 takes the weakest unit because there is a watchdog on
# every one of them; there is exactly ONE `Maintenance` in this add-on and
# `Controller.maint_unit` names the pack it recalibrates - the first in the
# roster, because the cycle's history lives under un-namespaced Store keys.
# The shaping claim is filed on THAT actuator and on no other, so the cap
# that can contradict it is THAT unit's. Taking `min()` here would refuse a
# perfectly workable site - a 5000 W pack being recalibrated beside a 2000 W
# sibling the cycle never touches - which is the over-refusal this file's
# docstring says gets a validator switched off. When `maint_unit` stops
# being `inverters[0]`, this line moves with it.
# ⚠️ MAINT-05: IT IS GRADED AGAINST THE WEAKEST UNIT'S CAP NOW, AND THE
# COMMENT THIS REPLACES SAID SO WOULD HAPPEN. It read: "there is exactly ONE
# `Maintenance` in this add-on and `Controller.maint_unit` names the pack it
# recalibrates - the first in the roster, because the cycle's history lives
# under un-namespaced Store keys. The shaping claim is filed on THAT
# actuator and on no other, so the cap that can contradict it is THAT
# unit's. Taking `min()` here would refuse a perfectly workable site - a
# 5000 W pack being recalibrated beside a 2000 W sibling the cycle never
# touches ... When `maint_unit` stops being `inverters[0]`, this line moves
# with it."
#
# `maint_unit` has stopped being `inverters[0]`. There is one `Maintenance`
# per roster entry, each with its own namespaced history, and EVERY pack is
# recalibrated on its own schedule - so the sibling "the cycle never
# touches" does not exist any more and the over-refusal argument has no
# subject. The shaping claim is filed on whichever pack is charging, so the
# cap that can contradict it is any cap on the roster, and the one that
# contradicts it FIRST is the smallest. This is rule 5's conjunction for
# rule 5's reason: a threshold that is fine against the biggest unit says
# nothing about the smallest, and the smallest is the one that goes to 0 W.
#
# ⚠️ THIS REFUSES CONFIGS THAT USED TO START, AND THAT IS THE POINT RATHER
# THAN A SIDE EFFECT. `max_w` 5000 on pack A beside 2000 on pack B with
# `maintenance_charge_w` 3000 was accepted before this card and was correct
# while only pack A was ever recalibrated. Pack B now recalibrates too, and
# its cheap-window charge asks the arbiter for `hi = -3000` against a
# supervised `lo = -2000`: `lo > hi`, arbiter clause 3, 0 W, contradiction -
# a maintenance charge that silently never happens on that pack, which is
# exactly what rule 6 exists to refuse at the moment it is WRITTEN. At a
# roster of one `min()` over one cap is that cap and the comparison, the
# message and the boundary are identical.
charge_w = _number(opts, "maintenance_charge_w",
float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"]))
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON. `caps[0]` is the first
# unit WITH A USABLE CAP, which is `inverters[0]` only while every earlier
# entry has one; `_unit_label(opts, 0)` was hard-wired to `inverters[0]`
# regardless. Both come off the one pair now.
maint_index, maint_cap = caps_ix[0] if caps_ix else (0, None)
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON: `unit_caps_indexed()`
# SKIPS entries with no usable `max_w`, so a caps-list position is not a
# roster position and `_unit_label()` indexes the roster. The index travels
# with the number, so the unit the message names is the unit the number came
# off - which on a roster whose first entry has a junk `max_w` is not the
# same thing at all.
maint_index, maint_cap = (min(caps_ix, key=lambda p: p[1]) if caps_ix
else (0, None))
maint_where = _unit_label(opts, maint_index)
if charge_w is None:
problems.append(