Merge release/1.0 into SAFETY-30
checks / unit (pull_request) Successful in 4m39s

CFG-04 (#45) merged first and moved the base. Two conflicts, both resolved
as the union of the two sides:

- app/main.py: an import-block collision. CFG-04 added validate_unit_ranges
  to the .validate import; SAFETY-30 added AGAINST_METER, AGAINST_OWN_POWER,
  SIGN_OPTION_DEFAULTS and SignWatchdog to the .watchdog import. Neither
  side dropped anything, so the resolution keeps both lists whole.
- CHANGELOG.md: two adjacent new entries under ## Unreleased. Both kept.

Both sides also independently rewrote large parts of test_peakguard.py
(60 and 47 insertions against the merge base) and git merged them
textually, so the full suite was run on the MERGE RESULT rather than
inferred from the two parents: 19 passed, 0 failed, twice.

One caveat recorded rather than hidden: test_peakguard.py failed once
during the first sequential full-suite run and then passed 10/10 in
isolation and in two further full runs on this same tree. It passes on
both parents too, so this is the pre-existing flakiness CFG-04 already
disclosed, not a merge defect - it is timing- or load-sensitive. It sits
on the required CI context and can therefore block an unrelated merge at
random, which wants a card of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-04 12:23:50 +02:00
co-authored by Claude Opus 5
13 changed files with 2090 additions and 120 deletions
+60 -17
View File
@@ -178,20 +178,65 @@ def fresh_store(name: str) -> PeakStore:
return PeakStore(str(TMP / f"{name}.db"))
def read_option(key: str):
"""The shipped default for `key`, out of config.yaml.
# ⚠️⚠️ THESE THREE READERS WERE CHANGED BY CFG-04, AND THE OLD ONES WERE THE
# PERMISSIVE KIND. Read this before touching them.
#
# WHAT THEY USED TO DO. `read_option()` walked the whole file and took the FIRST
# line whose STRIPPED text started with `<key>:`; `_schema_bound()` did the same
# from `schema:` onwards. The docstring justified first-match by naming the one
# collision that existed - `peak_cap_w:` appears once under `options:` and once
# under `schema:` - and it was right about that one.
#
# WHY THAT STOPPED BEING SAFE. CFG-04's `inverters:` is a LIST OF OBJECTS, and
# its entries carry a `max_w:` of their own, indented six spaces under
# `schema:`. `line.strip()` throws the indentation away, so `read_schema_max
# ("max_w")` began answering with the PER-INVERTER bound instead of the site
# one - silently, and in this file's case correctly, because the two bounds
# happen to be the same `int(100,5000)` today. A reader that returns inverter
# #1's number and keeps passing is the failure mode this repository is named
# after: green, and not watching. config.yaml declares `inverters:` BEFORE
# `max_w:` deliberately so that the trap stays baited rather than sorted out by
# an ordering nobody would maintain.
#
# WHAT THEY DO NOW. A top-level option and a top-level schema key are at EXACTLY
# two spaces; anything nested is at four or more. So the anchor is the
# indentation, the file's two halves disambiguate `peak_cap_w` (the options half
# for a default, the schema half for a bound) and first-match is not relied on
# at all. And where a key really is ambiguous - the same key twice at top level
# in the same half - these RAISE instead of picking one, because a reader that
# guesses is how this got here.
#
# test_inverter.py part 12 runs all of them against a synthetic TWO-INVERTER
# config.yaml whose nested numbers differ from its flat ones, which is the only
# way to tell a reader that is anchored from one that is lucky.
def _cfg_halves():
body = (HERE / "config.yaml").read_text(encoding="utf-8")
marker = body.index("\nschema:")
return body[:marker], body[marker:]
⚠️ `peak_cap_w:` appears TWICE - once under `options:`, once under `schema:`.
test_tariff.py's part 9 documents the trap; this takes the FIRST match, which
is the options block, i.e. the number an install actually lands on.
"""
for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines():
if line.strip().startswith(f"{key}:"):
try:
return float(line.split(":", 1)[1].strip())
except ValueError:
continue
return None
def _top_level(half: str, key: str) -> list:
"""Every ` key: ...` line at EXACTLY two spaces. Raises if there are two."""
hits = [ln for ln in half.splitlines()
if ln.startswith(f" {key}:") and not ln[2:3].isspace()]
if len(hits) > 1:
raise AssertionError(
f"config.yaml declares {key!r} {len(hits)} times at the top level "
f"of one block: {hits!r}. This reader will not pick one for you - "
"an option with two defaults is a question for whoever wrote the "
"second, not a tie for a check to break.")
return hits
def read_option(key: str):
"""The shipped default for `key`, out of config.yaml's `options:` block."""
hits = _top_level(_cfg_halves()[0], key)
if not hits:
return None
try:
return float(hits[0].split(":", 1)[1].strip())
except ValueError:
return None
def read_schema_max(key: str):
@@ -210,11 +255,9 @@ def read_schema_min(key: str):
def _schema_bound(key: str, which: int):
body = (HERE / "config.yaml").read_text(encoding="utf-8")
marker = body.index("\nschema:")
for line in body[marker:].splitlines():
for line in _top_level(_cfg_halves()[1], key):
st = line.strip()
if st.startswith(f"{key}:") and "(" in st and ")" in st:
if "(" in st and ")" in st:
return float(st[st.index("(") + 1:st.rindex(")")].split(",")[which])
return None