CFG-03: give maintenance option defaults one source of truth
checks / unit (pull_request) Successful in 3m44s

Option defaults for the maintenance cycle were declared in three places that
could disagree, and MAINT-04 proved they already did. config.yaml shipped
`maintenance_charge_w: 2000`; main.py carried TWO `opts.get(..., 2500)`
fallbacks; MaintConfig's dataclass carried a third `2500.0`. So an options dict
with the key ABSENT was ACCEPTED by validate.py's rule 6 - which read 2000 -
and then built by main.py at 2500, against a 2000 W ceiling:

    options dict with maintenance_charge_w ABSENT, max_w=2000
      validate_startup_config  ->  []  (accepted, no problems)
      MaintConfig().charge_w   ->  2500.0
      arbiter.resolve()        ->  0.0 W, contradiction=True

That is the bug rule 6 exists to refuse, passing rule 6. It is unreachable on a
Supervisor install, which always supplies every key from config.yaml, and fully
reachable from sim/inject.py and from the test suite - which is where anyone
debugging maintenance actually works.

MAINT-04 shipped a TRIPWIRE and said so: test_validate.py pinned all three
literals and asserted the consequence through the real arbiter, so the
divergence could not silently widen or be half-closed. Its PASSING CONDITION
WAS THE BUG. Both it and validate.py's comment said to delete each other when
the divergence was closed. It is closed, so both are deleted.

THE DIVERGENCE WAS WIDER THAN ONE KEY. `maintenance_discharge_w` had the
identical three-way split (config.yaml 2000, MaintConfig 2500) and the card
named only the charge side. Closing one and leaving its twin would have
satisfied the numbers and left three sources of truth one line down, which is
the state that produced the bug. Both are closed, and so is the whole family:
all eight `maintenance_*` keys go through the same mechanism.

WHAT THE SINGLE SOURCE IS. config.yaml's `options:` block - it is what the
Supervisor reads and what the operator edits, so it wins by definition. There
is now exactly one in-code copy, `maintenance.MAINT_OPTION_DEFAULTS`, which is
TARIFF-02's `PRICE_OPTION_DEFAULTS` and SAFETY-24's `PEAK_OPTION_DEFAULTS`
pattern applied to the family that named the card - validate.py's rule 8 comment
already called that "CFG-03 read as a rule rather than as a complaint", so this
makes the rule hold where it was first broken rather than inventing a fourth
mechanism for one option family.

A MIRROR RATHER THAN A RUNTIME READ, and the reason is mechanical: the
Dockerfile copies `app/` and `run.sh` into the image and NOT config.yaml. The
running add-on cannot open the file it is configured by, so there is nothing to
read at 03:00 on a client's Pi, and a YAML-subset parser that could fail at
import is a new way for the add-on not to start. What makes the copy safe is
that test_maintenance.py pins every entry by READING the shipped file - drift is
a CI failure, not a comment.

EVERY OTHER SITE NOW DERIVES FROM IT OR HAS CEASED TO EXIST:

  * MaintConfig's eight dataclass defaults are indexed out of the mirror. No
    numeric literal survives in the class that held the third opinion.
  * `MaintConfig.from_options()` is the ONLY place in the add-on that reads a
    `maintenance_*` option. main.py's eight inline `opts.get(key, <literal>)`
    calls are gone: moving the read next to the mirror means the fallback cannot
    be anything but the mirror, because there is no literal in sight of it to
    disagree with. It uses `key in opts` rather than truthiness, so a shipped
    `false` or `0` is honoured instead of being quietly replaced by the default
    - this card's own bug in a different costume.
  * main.py's SECOND fallback site - the cheap-window charge floor at the
    P_MAINT_SHAPE claim - reads `self.maint.cfg.charge_w` instead of re-opening
    the options dict with its own literal. The Controller had already parsed
    this once in __init__; asking the object that owns it cannot drift from it,
    and the rate the shaping asks for can no longer be a different number from
    the rate the state machine drains at on the same install.
  * validate.py's rule 6 and its `maintenance_discharge_w` warning take their
    fallbacks from the mirror.

WHERE THE RANGE LIVES, STATED AND CHECKED. config.yaml's `schema:` block carries
`maintenance_charge_w: int(500,5000)` - a fourth declaration site of a different
kind. It is a BOUND, not a default, so it is not this bug; but leaving the
question open is how a fifth site arrives. The decision recorded in config.yaml
and in maintenance.py is: bounds live in the schema block ONLY, because the
Supervisor is what enforces them and the Supervisor reads only that file, so a
Python copy of `int(500,5000)` would be a place to disagree and nothing else.
test_maintenance.py checks both halves of that: every mirrored key must have a
schema entry - without one the Supervisor drops the option and the add-on
silently runs on the mirror instead of the operator's value - and every shipped
default must lie INSIDE its own range, since a default the schema rejects is a
config that cannot be re-entered once it is edited. What the schema grammar
cannot express is a relation BETWEEN keys (`maintenance_charge_w` against
`max_w`), which is exactly why rule 6 is written in Python and why it does not
belong in the mirror either.

THE REPLACEMENT CHECKS, AND HOW THEY WERE VERIFIED. test_validate.py's new
section exercises the same four consumers the defect ran through, the same way:
the validator ACCEPTS a dict with every maintenance key missing; a REAL
Controller built from that same dict holds 2000 W (through main.py, not through
a regex over its source); `MaintConfig()` answers 2000 W with no dict at all;
and the real arbiter emits `-2000 W, limited by supervised(max_w)` instead of
`0 W, contradiction=True`. test_maintenance.py pins the mirror to config.yaml in
both directions - equal values, and full coverage of every `maintenance_*` key
the file ships, so a ninth key cannot arrive unmirrored - and asserts the
consumers move as a RELATION rather than against numbers, reading MaintConfig's
defaults back out of `dataclasses.fields()`.

Each new check was verified by BREAKING the thing it watches and observing red,
this project's rule:

  1. config.yaml -> 1500, mirror left at 2000:  test_maintenance 1 FAILED,
     test_validate 4 FAILED.
  2. config.yaml AND the mirror -> 1500: green again, and every consumer moved
     together - MaintConfig() 1500 W, the Controller 1500 W, and the arbiter
     `-1500 W, limited by maintenance(cheap-window charge)` where it had said
     `-2000 W, limited by supervised(max_w)`. That is AC4: change the single
     source and watch the consumers follow.
  3. `charge_w: float = 2500.0` retyped into the dataclass: 2 FAILED in each of
     the two files.
  4. `opts.get("maintenance_charge_w", 2500)` restored in main.py: 1 FAILED,
     naming the file and the literal it found.
  5. `maintenance_hold_min` deleted from config.yaml's options block: 3 FAILED.

Full suite 17/17 in goodwe-addon-ci, twice, once under parallel load.

Stale prose that asserted the now-closed divergence as current is corrected in
app/watchdog.py, test_watchdog.py and test_peakguard.py - watchdog.py's example
still uses 1500 W, but because the point of the example is a charge strictly
inside the ceiling, not because 2500 is what ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-01 06:16:54 +02:00
co-authored by Claude Opus 5
parent b7267eb51a
commit d5b1ef19b3
10 changed files with 495 additions and 139 deletions
+27 -18
View File
@@ -131,19 +131,16 @@ class Controller:
integrator_max_w=(float(opts["integrator_max_w"])
if opts.get("integrator_max_w") else None),
)
self.maint = Maintenance(
MaintConfig(
enabled=bool(opts.get("maintenance_enabled", False)),
interval_days=int(opts.get("maintenance_interval_days", 28)),
start_hour=int(opts.get("maintenance_start_hour", 10)),
discharge_w=float(opts.get("maintenance_discharge_w", 2500)),
charge_w=float(opts.get("maintenance_charge_w", 2500)),
soc_floor=float(opts.get("maintenance_soc_floor", 11)),
soc_target=float(opts.get("maintenance_soc_target", 99)),
hold_min=int(opts.get("maintenance_hold_min", 120)),
),
store,
)
# ⚠️ CFG-03. EIGHT `opts.get(key, <literal>)` CALLS USED TO LIVE HERE,
# and two of those literals disagreed with config.yaml - 2500 against a
# shipped 2000, which is a config validate.py refuses. The read now
# belongs to `MaintConfig.from_options()`, next to the one in-code
# mirror of config.yaml it falls back to, so this file states no opinion
# at all about what a missing maintenance key means. Read
# maintenance.MAINT_OPTION_DEFAULTS before adding a ninth key: it goes
# in config.yaml's `options:` and `schema:` blocks and in that dict, and
# nowhere else.
self.maint = Maintenance(MaintConfig.from_options(opts), store)
# SAFETY-02. One instance, one actuator, one cadence - see watchdog.py.
#
@@ -240,10 +237,14 @@ class Controller:
# knows, and refusing one set below the pack's own cut-out means knowing
# that cut-out too. So this card adds exactly two keys and reads their
# defaults from `peakguard.PEAK_OPTION_DEFAULTS` - TARIFF-02's mirror
# pattern - rather than typing literals here. THAT is what keeps CFG-03
# at three places: the divergence validate.py's rule 6 documents
# (`maintenance_charge_w` 2000 there, 2500 here) is exactly what a
# literal in this file would repeat.
# pattern - rather than typing literals here. THAT is what kept this
# card from widening: the divergence validate.py's rule 6 used to
# document (`maintenance_charge_w` 2000 in config.yaml, 2500 in this
# file) is exactly what a literal here would have repeated. CFG-03 has
# since closed that one too - `maintenance.MAINT_OPTION_DEFAULTS`,
# read through `MaintConfig.from_options()` - so the rule now holds for
# every option family this file builds: no numeric fallback in main.py
# that config.yaml has not shipped.
self.peak_guard = None
self._peak_engaged = False
self._peak_saturated = False
@@ -692,8 +693,16 @@ class Controller:
hi = 0.0
reason = "charge-only"
if self.cheap:
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ
# OF `self.o`. This line used to re-open the options dict
# with its own literal fallback, so the rate the shaping
# asked for and the rate the state machine drained at could
# be different numbers from the same install - and, with the
# key absent, they were. The Controller already parsed this
# once in __init__; asking the object that owns it cannot
# drift from it.
floor = maintenance_charge_floor(
float(self.o.get("maintenance_charge_w", 2500)),
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"
+103 -8
View File
@@ -38,17 +38,112 @@ CHARGE_TIMEOUT_MIN = 2160
# After an abort, do not immediately re-trigger in the same start hour.
RESTART_COOLDOWN_MIN = 90
# ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR EVERY `maintenance_*`
# OPTION, and the only place in the add-on where one of these numbers is typed.
#
# WHAT WENT WRONG WITHOUT IT. Until this card each default was written down
# three times - config.yaml's `options:` block, an `opts.get(key, <literal>)`
# fallback in main.py, and this dataclass - and MAINT-04 proved they had already
# drifted: config.yaml said 2000, the other two said 2500. An options dict with
# `maintenance_charge_w` ABSENT was therefore ACCEPTED by validate.py's rule 6
# (which read 2000) and then BUILT by main.py at 2500, against a 2000 W ceiling
# - the exact `lo > hi` contradiction rule 6 exists to refuse, resolving to 0 W
# with `contradiction=True`. Unreachable through the Supervisor, which always
# supplies every key; fully reachable from sim/inject.py and from the test
# suite, which is where anyone debugging maintenance actually is.
#
# WHERE THE SINGLE SOURCE ACTUALLY IS: config.yaml's `options:` block. It is
# what the Supervisor reads and what the operator edits, so it wins by
# definition. This dict is a MIRROR of it and not a second opinion:
# test_maintenance.py pins every entry by READING the shipped file, so a drift
# goes red in CI instead of shipping. That is TARIFF-02's `PRICE_OPTION_DEFAULTS`
# and SAFETY-24's `PEAK_OPTION_DEFAULTS` pattern, now applied to the family that
# named the card. A mirror rather than a runtime read because the Dockerfile
# copies `app/` and `run.sh` into the image and NOT config.yaml - the add-on
# cannot open the file it is configured by, so there is nothing to read at 03:00
# on a client's Pi, and a parser that could fail at import is a new way for the
# add-on not to start.
#
# ⚠️ DEFAULTS ONLY. NO BOUNDS LIVE HERE, and that is a decision, not an
# omission. The RANGE of each key (`maintenance_charge_w: int(500,5000)`) lives
# in config.yaml's `schema:` block and nowhere else, because the Supervisor is
# what enforces it and the Supervisor reads only that file. What this module
# would gain by mirroring the bounds too is a fifth place to disagree with.
# validate.py's rule 6 is NOT a range and does not belong here either: it is a
# relation BETWEEN TWO KEYS (`maintenance_charge_w` against `max_w`) that the
# schema grammar cannot express at all, which is the whole reason that rule is
# written in Python.
#
# ⚠️ KEYED BY THE OPTION NAME, not by the MaintConfig field name. The dict is
# what `from_options()` indexes while reading the operator's dict, so the two
# strings have to be the same string; a `charge_w` key here would reintroduce a
# transcription step between config.yaml and the reader.
MAINT_OPTION_DEFAULTS = {
"maintenance_enabled": False,
"maintenance_interval_days": 28,
"maintenance_start_hour": 10,
# ⚠️ 2000, NOT THE 2500 THIS SHIPPED AS, and config.yaml carries the
# argument: both rates must stay at or below `max_w` (2000) or the
# cheap-window charge shaping asks the arbiter for `hi = -charge_w` against
# a supervised `lo = -max_w` and contradicts itself into 0 W. Raising max_w
# is what unlocks a faster cycle - raise it FIRST, then these.
"maintenance_discharge_w": 2000,
"maintenance_charge_w": 2000,
"maintenance_soc_floor": 11,
"maintenance_soc_target": 99,
"maintenance_hold_min": 120,
}
@dataclass(frozen=True)
class MaintConfig:
enabled: bool = False
interval_days: int = 28
start_hour: int = 10
discharge_w: float = 2500.0
charge_w: float = 2500.0
soc_floor: float = 11.0
soc_target: float = 99.0
hold_min: int = 120
"""The maintenance cycle's settings, already parsed.
⚠️ EVERY DEFAULT IS READ OUT OF `MAINT_OPTION_DEFAULTS`, never typed. A
literal in this class is what made `MaintConfig()` a THIRD opinion about
what a missing key means - see the block above - so `MaintConfig()` and
`MaintConfig.from_options({})` and validate.py's fallback are now
arithmetically the same number by construction rather than by vigilance.
"""
enabled: bool = bool(MAINT_OPTION_DEFAULTS["maintenance_enabled"])
interval_days: int = int(MAINT_OPTION_DEFAULTS["maintenance_interval_days"])
start_hour: int = int(MAINT_OPTION_DEFAULTS["maintenance_start_hour"])
discharge_w: float = float(MAINT_OPTION_DEFAULTS["maintenance_discharge_w"])
charge_w: float = float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"])
soc_floor: float = float(MAINT_OPTION_DEFAULTS["maintenance_soc_floor"])
soc_target: float = float(MAINT_OPTION_DEFAULTS["maintenance_soc_target"])
hold_min: int = int(MAINT_OPTION_DEFAULTS["maintenance_hold_min"])
@classmethod
def from_options(cls, opts: dict) -> "MaintConfig":
"""Build from an add-on options dict.
⚠️ THE ONLY PLACE IN THE ADD-ON THAT READS A `maintenance_*` OPTION.
main.py used to do this inline as eight `opts.get(key, <literal>)`
calls, which is eight opportunities to type a number that config.yaml
does not ship - and one of them had already been taken. Moving the read
next to the mirror means the fallback cannot be anything but the mirror,
because there is no dict literal in sight of it to disagree with.
⚠️ `key in opts`, not `opts.get(key) or ...`. A shipped `false` and a
shipped `0` are values an operator can legitimately set, and the truthy
form would quietly replace both with the default - which is this card's
own bug wearing a different hat.
"""
def _opt(key):
return opts[key] if key in opts else MAINT_OPTION_DEFAULTS[key]
return cls(
enabled=bool(_opt("maintenance_enabled")),
interval_days=int(_opt("maintenance_interval_days")),
start_hour=int(_opt("maintenance_start_hour")),
discharge_w=float(_opt("maintenance_discharge_w")),
charge_w=float(_opt("maintenance_charge_w")),
soc_floor=float(_opt("maintenance_soc_floor")),
soc_target=float(_opt("maintenance_soc_target")),
hold_min=int(_opt("maintenance_hold_min")),
)
@dataclass
+31 -25
View File
@@ -202,6 +202,7 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
import logging
from .arbitrage import enabled as arbitrage_on
from .maintenance import MAINT_OPTION_DEFAULTS
from .p1 import is_enabled, validate_meter_options
from .peakguard import PEAK_OPTION_DEFAULTS
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS
@@ -379,26 +380,23 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# leave the trap armed for the one operator action - ticking
# maintenance_enabled - that cannot possibly be expected to change what a
# charge rate means.
# ⚠️ THE FALLBACK IS config.yaml's SHIPPED VALUE, and it has to be kept
# equal to it - that is the whole point of AC3. It is reachable only when a
# caller hands over a dict with the key missing (sim/inject.py, a test); the
# Supervisor always supplies every key from config.yaml, so on a real
# install the argument is always present.
#
# ⚠️ KNOWN MISMATCH, LEFT DELIBERATELY AND FLAGGED RATHER THAN FIXED HERE:
# `main.py` still falls back to 2500 in two places
# (`opts.get("maintenance_charge_w", 2500)`), as does maintenance.py's
# MaintConfig dataclass. So a dict with the key ABSENT is accepted by this
# rule at 2000 and then built by main.py at 2500 - the contradiction this
# rule exists to catch, in the one case it cannot see. It is unreachable on
# a Supervisor install and those files are another ticket's right now.
# THE REAL FIX is one source of truth for option defaults; until then the
# divergence is PINNED BY A CHECK rather than by this comment - see
# test_validate.py, "the KNOWN cross-file default divergence". It asserts
# the mismatch as it stands and goes red if EITHER side moves, including
# when somebody closes it in main.py, at which point this block and that
# check both get deleted.
charge_w = _number(opts, "maintenance_charge_w", 2000.0)
# ⚠️ THE FALLBACK IS config.yaml's SHIPPED VALUE, READ RATHER THAN TYPED
# (CFG-03). It is reachable only when a caller hands over a dict with the
# key missing (sim/inject.py, a test); the Supervisor always supplies every
# key from config.yaml, so on a real install the argument is always present
# and this default is dead code. That is precisely why it went stale
# unnoticed for a whole card: MAINT-04 moved config.yaml to 2000 while
# main.py and MaintConfig still said 2500, so a dict with the key ABSENT was
# accepted here at 2000 and then built by main.py at 2500 - the
# contradiction this rule exists to catch, in the one case it could not see.
# The KNOWN MISMATCH block that used to stand here, and the tripwire in
# test_validate.py that pinned it, are both DELETED: the divergence is
# closed rather than recorded. `maintenance.MAINT_OPTION_DEFAULTS` is now
# the one in-code mirror of config.yaml for this family - the same move
# rule 8 below already makes with `PEAK_OPTION_DEFAULTS` - and
# test_maintenance.py pins it by reading the shipped file.
charge_w = _number(opts, "maintenance_charge_w",
float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"]))
if charge_w is None:
problems.append(
f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number")
@@ -420,10 +418,12 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# --- 8. an EMS energy floor the BMS does not already enforce -------------
# ⚠️ THE FALLBACKS ARE peakguard.PEAK_OPTION_DEFAULTS, NOT LITERALS, and
# that is CFG-03 read as a rule rather than as a complaint. Rule 6's own
# comment documents a LIVE divergence in this repo caused by typing 2000
# here and 2500 in main.py; this card refuses to add a second instance of
# it. The dict is the single in-code mirror, pinned to config.yaml by
# test_peakguard.py, and main.py reads the same one.
# comment used to document a LIVE divergence in this repo caused by typing
# 2000 here and 2500 in main.py; SAFETY-24 refused to add a second instance
# of it, and CFG-03 has since closed the first. The dict is the single
# in-code mirror, pinned to config.yaml by test_peakguard.py, and main.py
# reads the same one - exactly as maintenance.MAINT_OPTION_DEFAULTS now is
# for the `maintenance_*` family.
soc_floor = _number(opts, "peak_soc_floor",
PEAK_OPTION_DEFAULTS["peak_soc_floor"])
bms_floor = _number(opts, "bms_soc_floor",
@@ -465,7 +465,13 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# words on the status page every cycle, and the cycle still completes -
# slower, and eating into maintenance.DRAIN_TIMEOUT_MIN. Loud and it moves
# energy, so it is neither (a) nor (b): warned, not refused.
disch_w = _number(opts, "maintenance_discharge_w", 2000.0)
# ⚠️ AND THE FALLBACK COMES FROM THE SAME MIRROR AS RULE 6's. CFG-03's PM
# proofread caught this one: `maintenance_discharge_w` had the IDENTICAL
# three-way divergence (config.yaml 2000, MaintConfig 2500) and the card
# named only the charge side. Closing one key and leaving its twin is how a
# single-source fix gets reproduced as a single-source bug one line down.
disch_w = _number(opts, "maintenance_discharge_w",
float(MAINT_OPTION_DEFAULTS["maintenance_discharge_w"]))
if disch_w is not None and max_w is not None and max_w > 0 and disch_w > max_w:
_LOG.warning(
"maintenance_discharge_w %.0f W is above max_w %.0f W. Not refused - "
+11 -9
View File
@@ -97,15 +97,17 @@ Four gates, and each one exists to exclude a specific thing that is NOT a fault:
caller says whether the grid-following loop actually won the arbitration this
cycle, and the timer only accumulates when it did.
⚠️ 1500 W, NOT THE DEFAULT 2500 W, AND THE REASON IS NOT COSMETIC. At the
shipped defaults `maintenance_charge_w` is 2500 and `max_w` is 2000, so the
cheap-window claim is `hi = -2500` against a supervised `lo = -2000`: lo > hi,
which arbiter.py resolves as a CONTRADICTION and 0 W, not as a -2500 W charge.
The example above is written at 1500 W because that is a combination the
schema and the arbiter both actually produce. (The contradiction at the
defaults is a real pre-existing bug - a default maintenance charge silently
never happens in a cheap window - but it is maintenance's, not this module's,
and it is filed separately. Do not "fix" it here.)
⚠️ 1500 W, NOT 2500 W, AND THE REASON IS NOT COSMETIC. The example has to be
a charge the arbiter actually produces. When this was written the shipped
`maintenance_charge_w` was 2500 against a `max_w` of 2000, so the cheap-window
claim was `hi = -2500` against a supervised `lo = -2000`: lo > hi, which
arbiter.py resolves as a CONTRADICTION and 0 W, not as a -2500 W charge - a
default maintenance charge that silently never happened. MAINT-04 refused that
pair in validate.py and moved the shipped rate to 2000; CFG-03 gave the
default one source of truth so main.py and MaintConfig could not go on
answering 2500 to a dict that had never said so. The number here stays 1500
because the point of the example is a charge STRICTLY INSIDE the ceiling, not
one that happens to sit exactly on it.
2. The strict `< 0` product. "Opposed" and "neither side is zero" in one
expression.