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
+77
View File
@@ -2,6 +2,83 @@
## Unreleased
**Maintenance option defaults lived in three places that had already disagreed,
and MAINT-04's tripwire was holding them apart rather than closing them
(CFG-03).** `config.yaml` shipped `maintenance_charge_w: 2000`, `main.py` carried
two `opts.get("maintenance_charge_w", 2500)` fallbacks, and `MaintConfig`'s
dataclass carried a third `2500.0`. An options dict with the key **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. Demonstrated through the shipped
code:
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**. Unreachable through
the Supervisor, which always supplies every key from `config.yaml`; fully
reachable from `sim/inject.py` and from the test suite, which is where anyone
debugging maintenance is.
**`maintenance_discharge_w` had the identical divergence** (`config.yaml` 2000,
`MaintConfig` 2500) and the card named only the charge side. Closing one key and
leaving its twin would have reproduced the defect one line down, so both are
closed.
**The single source of truth is `config.yaml`'s `options:` block** — the file the
Supervisor reads and the operator edits. 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. A mirror rather than a runtime read because 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. `test_maintenance.py`
pins every entry by **reading** the shipped file, so a drift is a CI failure
rather than a comment. Every other declaration 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;
- `main.py`'s second fallback site (the cheap-window charge floor) reads
`self.maint.cfg.charge_w` instead of re-opening the options dict, so the rate
the shaping asks for and the rate the state machine drains at cannot be two
numbers from one install;
- `validate.py`'s rule 6 and its `maintenance_discharge_w` warning take their
fallbacks from the mirror.
**Where the RANGE lives is now stated and checked, so the next person does not
add a fifth site.** `maintenance_charge_w: int(500,5000)` in `config.yaml`'s
`schema:` block is a fourth declaration of a different kind — a bound, not a
default. Bounds live in the schema block **only**: the Supervisor enforces them
and reads only that file. `test_maintenance.py` asserts that every mirrored key
has a schema entry (or the Supervisor silently drops the option and the add-on
runs on the mirror instead of the operator's value) and that every shipped
default lies **inside** its own range. What the schema grammar cannot express is
a relation *between* keys — `maintenance_charge_w` against `max_w` — which is
why rule 6 is written in Python and does not belong in the mirror either.
**MAINT-04's tripwire in `test_validate.py` is DELETED, not left passing**, as it
and `validate.py`'s ⚠️ block both instructed each other to do. Its passing
condition *was* the bug. In its place, the same four consumers exercised the same
way and now agreeing: the validator accepts a dict with every `maintenance_*` key
missing, a **real `Controller`** built from that dict holds 2000 W, `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`.
**Checked by breaking it, five ways, each observed red before being reverted:**
move `config.yaml` alone → the mirror pin fails; move `config.yaml` **and** the
mirror to 1500 → every consumer follows together (`MaintConfig()` 1500 W, the
`Controller` 1500 W, the arbiter `-1500 W, limited by maintenance(cheap-window
charge)`) and both suites go green again; retype `charge_w: float = 2500.0` into
the dataclass → red; restore `opts.get("maintenance_charge_w", 2500)` in
`main.py` → red; delete a key from `config.yaml`'s options block → red.
Stale prose that asserted the closed divergence as current is corrected in
`app/watchdog.py`, `test_watchdog.py` and `test_peakguard.py`.
**`peak_soc_floor` reads like a pack-wide reserve and is not one, and on a
sustained-load evening the grid-following loop discharges straight past it to
the BMS cut-out (SAFETY-25).** The gap has been written down four times without