MAINT-05: one recalibration per pack, and only one pack moves power at a time
checks / unit (pull_request) Successful in 4m48s
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:
+153
-24
@@ -126,16 +126,53 @@ SCHEMA_VERSION = 1
|
||||
SCHEMA_KEY = "schema_version"
|
||||
|
||||
# Which flat keys belong to ONE inverter, and therefore move into its namespace.
|
||||
# ⚠️ THE MAINTENANCE KEYS ARE NOT IN HERE AND THAT IS DELIBERATE. `phase`,
|
||||
# `phase_started`, `last_completed` and `last_start_attempt` belong to the
|
||||
# maintenance state machine, of which this add-on still holds exactly ONE - a
|
||||
# monthly recalibration is a pack procedure and per-pack SCHEDULING is a card of
|
||||
# its own, not something to infer from a key rename. Moving them here would
|
||||
# quietly re-home an install's whole maintenance history under a unit name; they
|
||||
# stay where they are, untouched, which is the other half of "migrates without
|
||||
# losing its latch OR ITS HISTORY".
|
||||
# ⚠️ THE MAINTENANCE KEYS ARE STILL NOT IN HERE, AND SINCE MAINT-05 THAT IS A
|
||||
# DIFFERENT DECISION RATHER THAN THE SAME ONE. They DO move now - one
|
||||
# `Maintenance` per roster entry needs one history per roster entry - but they
|
||||
# move on their OWN marker, `MAINT_SCHEMA_KEY`, and only on a site that actually
|
||||
# has more than one pack. Two conditions the SAFETY-02 keys do not share, so
|
||||
# folding them into this tuple would either drag them across on a
|
||||
# single-inverter install (which is the byte-identical store layout MAINT-05
|
||||
# must not change) or hold the sign latch hostage to a roster count it has
|
||||
# nothing to do with. `MAINT_UNIT_KEYS` below carries them.
|
||||
LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MAINT-05: THE SECOND MIGRATION, AND WHY IT IS A SECOND ONE
|
||||
#
|
||||
# 0 = the flat, one-history layout every install has had since the add-on
|
||||
# shipped: `phase` / `phase_started` / `last_completed` /
|
||||
# `last_start_attempt` describe THE pack, because there was one FSM.
|
||||
# 1 = one history per roster entry, under `unit/<name>/...`.
|
||||
#
|
||||
# ⚠️ ITS OWN MARKER, NOT A BUMP OF `SCHEMA_VERSION`, AND THE REASON IS THAT THE
|
||||
# TWO MIGRATIONS DO NOT HAVE THE SAME PRECONDITION. DEV-02's runs on every
|
||||
# install exactly once and is monotone in the version number. This one runs ONLY
|
||||
# WHERE THE ROSTER HAS MORE THAN ONE PACK, because at N=1 §6.2's identity holds
|
||||
# and the flat keys ARE that pack's namespace - moving them would add four keys
|
||||
# and a version marker to a store MAINT-05's own acceptance criterion requires
|
||||
# to be byte-identical. A single monotone counter cannot express "migrated on
|
||||
# this site, deliberately not migrated on that one" without lying to the next
|
||||
# version that reads it: a one-inverter store would have to be left at the old
|
||||
# number forever, and then a genuine v2 migration could never be told apart from
|
||||
# a site that simply never grew.
|
||||
#
|
||||
# ⚠️ AND IT IS TWO MARKERS, NOT TWO COPIES OF THE THREE RULES.
|
||||
# `_move_flat_keys()` and `_verify_moves()` below are shared verbatim by both
|
||||
# migrations, so rules 1 and 3 and the READ-BACK POSTCONDITION are one
|
||||
# implementation. A second transcription of a migration is a second opinion
|
||||
# about what "it landed" means.
|
||||
MAINT_SCHEMA_VERSION = 1
|
||||
MAINT_SCHEMA_KEY = "maint_schema_version"
|
||||
|
||||
# The flat maintenance-history keys, in the order they are moved.
|
||||
# ⚠️ THE VALUES ARE THE FLAT SPELLINGS AND THE MODULE THAT READS THEM INDEXES
|
||||
# THIS SAME TUPLE (`maintenance.UnitHistory`), so a key renamed here cannot
|
||||
# silently stop being migrated - the same property `UNIT_STORE_KEYS` gives the
|
||||
# SAFETY-02 pair.
|
||||
MAINT_UNIT_KEYS = ("phase", "phase_started", "last_completed",
|
||||
"last_start_attempt")
|
||||
|
||||
|
||||
def unit_key(unit: str, key: str) -> str:
|
||||
"""The namespaced Store key for `key` on inverter `unit`.
|
||||
@@ -174,6 +211,8 @@ class MigrationReport:
|
||||
# to shout: an alarm that fires on the harmless case is an alarm an
|
||||
# operator learns to dismiss before the real one arrives.
|
||||
self.lost = []
|
||||
# Why nothing ran, when nothing ran. Empty means "already at schema".
|
||||
self.skipped = ""
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
@@ -182,8 +221,15 @@ class MigrationReport:
|
||||
|
||||
def explain(self) -> str:
|
||||
if not self.ran:
|
||||
return (f"store already at schema {self.from_version}; no "
|
||||
f"per-inverter migration needed")
|
||||
# ⚠️ `skipped` RATHER THAN A SECOND SENTENCE ABOUT VERSIONS. Since
|
||||
# MAINT-05 there are two reasons a migration does not run - the
|
||||
# store is already at this schema, or the roster has one pack and
|
||||
# the flat keys ARE its namespace - and an operator reading the
|
||||
# startup log needs to be told which, because only one of them
|
||||
# changes when a second inverter is declared.
|
||||
return self.skipped or (f"store already at schema "
|
||||
f"{self.from_version}; no "
|
||||
f"per-inverter migration needed")
|
||||
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
|
||||
f"namespaces: " + ", ".join(sorted(self.moved)))
|
||||
if self.kept:
|
||||
@@ -243,7 +289,30 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
return rep
|
||||
|
||||
rep.ran = True
|
||||
for legacy in LEGACY_UNIT_KEYS:
|
||||
_move_flat_keys(store, unit, LEGACY_UNIT_KEYS, rep)
|
||||
_verify_moves(store, rep)
|
||||
|
||||
if rep.ok:
|
||||
store.set(SCHEMA_KEY, SCHEMA_VERSION)
|
||||
else:
|
||||
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
|
||||
# next start rather than be recorded as finished - the one thing worse
|
||||
# than a failed migration is a failed migration a later version skips.
|
||||
_LOG.error("per-inverter store migration LOST STATE and has not been "
|
||||
"marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
def _move_flat_keys(store, unit: str, keys, rep: MigrationReport) -> None:
|
||||
"""Rule 1, once, for both migrations.
|
||||
|
||||
Copies each flat `key` to `unit/<unit>/<key>` UNLESS the namespaced spelling
|
||||
is already present - a store that has both has been written by a version
|
||||
that knew about the namespace, and that value is the live one. Rule 2 is the
|
||||
absence of a delete: the flat keys are left exactly where they were.
|
||||
"""
|
||||
data = getattr(store, "data", {})
|
||||
for legacy in keys:
|
||||
target = unit_key(unit, legacy)
|
||||
if target in data:
|
||||
rep.kept.append(target)
|
||||
@@ -252,17 +321,25 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
# tripped has no `sign_tripped` in its file at all, and the value it
|
||||
# then lands on has to be the one `Store.load()` would have supplied -
|
||||
# otherwise "absent" and "never tripped" stop being the same statement,
|
||||
# which is the distinction store.py's own header is about.
|
||||
# which is the distinction store.py's own header is about. The same
|
||||
# sentence holds for `last_completed`: absent means "never recalibrated"
|
||||
# and a literal here would be a second opinion about that.
|
||||
value = data.get(legacy, DEFAULTS.get(legacy))
|
||||
store.set(target, value)
|
||||
rep.moved[target] = value
|
||||
|
||||
# THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION. This
|
||||
# is the difference between this function and a two-line copy: the caller
|
||||
# can ask "is the latch that was in the file before now under the key the
|
||||
# watchdog reads", through the same `unit_key()` the watchdog's constructor
|
||||
# reads through, and a `False` there is a guard that is off on an install
|
||||
# whose state file says it is on.
|
||||
|
||||
def _verify_moves(store, rep: MigrationReport) -> None:
|
||||
"""THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION.
|
||||
|
||||
This is the difference between these functions and a two-line copy: the
|
||||
caller can ask "is the latch that was in the file before now under the key
|
||||
the watchdog reads" - and, since MAINT-05, "is the history that was in the
|
||||
file before now under the key THIS PACK's state machine reads" - through the
|
||||
same `unit_key()` those readers read through. A `False` here is a guard that
|
||||
is off, or a month of coulomb-count history gone, on an install whose state
|
||||
file says otherwise.
|
||||
"""
|
||||
for target, value in rep.moved.items():
|
||||
got = getattr(store, "data", {}).get(target, "<<missing>>")
|
||||
if got == value:
|
||||
@@ -274,14 +351,66 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
if value != DEFAULTS.get(legacy):
|
||||
rep.lost.append(note)
|
||||
|
||||
|
||||
def migrate_maint_keys(store, unit: str, n_units: int) -> 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.
|
||||
|
||||
⚠️ `n_units <= 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".
|
||||
|
||||
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", {})
|
||||
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
|
||||
if n_units <= 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)
|
||||
|
||||
if rep.ok:
|
||||
store.set(SCHEMA_KEY, SCHEMA_VERSION)
|
||||
store.set(MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION)
|
||||
else:
|
||||
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
|
||||
# next start rather than be recorded as finished - the one thing worse
|
||||
# than a failed migration is a failed migration a later version skips.
|
||||
_LOG.error("per-inverter store migration LOST STATE and has not been "
|
||||
"marked done: %s", rep.explain())
|
||||
_LOG.error("per-pack maintenance-history migration LOST STATE and has "
|
||||
"not been marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user