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
+39 -6
View File
@@ -190,10 +190,26 @@ check("...and the refusal names the actuator and every writer aimed at it, so "
# inconveniences, and then none of this is enforced. Two inverters with two
# entities are two writers on TWO actuators, which is not the non-goal - it is
# the whole point of the per-inverter roster.
#
# ⚠️ THE ENTRIES BELOW GAINED `name` AND `max_w` WHEN CFG-04 LANDED, AND THE
# HAZARD FIXTURE ABOVE DELIBERATELY DID NOT. SAFETY-28 wrote both against a
# shape that did not exist yet and guessed `id`; CFG-04 chose `name`, and made
# `name`/`setpoint_entity`/`max_w` required of every entry (rule 9). The hazard
# fixture is left in the OLD guessed shape on purpose - the claim it makes is
# that `actuator_writers()` finds writers STRUCTURALLY, under a container and a
# key spelling this file was never told, and rewriting it into today's shape
# would quietly turn it into a check that the census knows about `inverters`.
# This one is the opposite claim - that a real, valid, two-inverter config is
# ACCEPTED - so it has to be a config the shipped validator actually accepts.
_PER_INVERTER_OK = {k: v for k, v in base().items() if k != "setpoint_entity"}
_PER_INVERTER_OK["inverters"] = [
{"id": "inverter-a", "setpoint_entity": "number.goodwe_setpoint"},
{"id": "inverter-b", "setpoint_entity": "number.goodwe_setpoint_b"}]
{"name": "inverter-a", "setpoint_entity": "number.goodwe_setpoint",
"max_w": 2000},
# ⚠️ INVERTER B DOES NOT EXIST. 2000 W here is the SHIPPED default repeated,
# not a rating measured off a second device - SPIKE-04 measures that when
# there is one to measure. Nothing in this file depends on the number.
{"name": "inverter-b", "setpoint_entity": "number.goodwe_setpoint_b",
"max_w": 2000}]
check("two inverters with their own entities are ACCEPTED - one writer each, "
"no shared actuator, and that is CFG-04's whole purpose",
validate_startup_config(_PER_INVERTER_OK, -5000, 5000) == [])
@@ -219,11 +235,28 @@ check("...and entity ids are folded to lower case, so a second writer cannot "
why(validate_startup_config(base(inverters=[{"setpoint_entity":
"NUMBER.GOODWE_SETPOINT"}]),
-5000, 5000), "one actuator"))
# ⚠️ THIS CHECK CHANGED SHAPE IN CFG-04, AND THE REASON IS A DECISION RATHER
# THAN AN ADJUSTMENT. It used to assert that a blank per-inverter entity left
# the whole config ACCEPTED - "blank is how an operator leaves a slot
# unconfigured". CFG-04 decided that is not true of a ROSTER ENTRY: an entry
# with no `setpoint_entity` is a unit that `allocator.plan()` still gives a
# share of the site target to and that nothing can command, on a status page
# reporting a site at full strength. Rule 9 refuses it by name. The claim about
# the CENSUS is untouched and is now asserted directly on the census, where it
# always belonged - a blank contributes no writer, so it cannot manufacture a
# two-writer refusal out of two unconfigured slots.
check("a BLANK per-inverter entity is not a writer - the same rule the scalar "
"key gets, since write_setpoint() returns immediately on a blank entity "
"and blank is how an operator leaves a slot unconfigured",
validate_startup_config(base(inverters=[{"setpoint_entity": " "}]),
-5000, 5000) == [])
"key gets, since write_setpoint() returns immediately on a blank entity",
actuator_writers(base(inverters=[{"setpoint_entity": " "},
{"setpoint_entity": ""}]))
== {"number.goodwe_setpoint": ["setpoint_entity"]})
check("...but the ENTRY is refused all the same, by rule 9 and not by the "
"census: a roster slot nothing can write still takes a share of the site "
"target every cycle and delivers none of it",
why(validate_startup_config(base(inverters=[{"name": "inverter-b",
"max_w": 2000,
"setpoint_entity": " "}]),
-5000, 5000), "has no setpoint_entity"))
# --------------------------------------------------------------------------- #
print("3. a meter that can never read")