Files
goodwe-addon/goodwe_controller/tools/supervisor_shape_probe.py
T
adminandClaude Opus 5 f9e7fbf86d SAFETY-32: bms_soc_floor per pack, rule 8 per unit
`bms_soc_floor` describes A BATTERY, not a site. At two packs of different
models the cut-outs differ, and one number for both graded the shallower pack
against the deeper one's BMS - so a site with cut-outs of 10 % and 30 % under
`peak_soc_floor: 20` STARTED, and the guard would command a forced discharge
that pack B's BMS, not the operator's floor, is the thing that stops. That is
rule 8's own failure mode, reached through rule 8.

Scoped to the card's third bullet, per its PM PROOFREAD: the aggregate
anti-windup (DEV-02) and per-instance `achievable_w` (CFG-04) were verified as
already shipped and are untouched.

* config.yaml: `bms_soc_floor: int(0,50)` REQUIRED per `inverters:` entry.
  Nothing is inherited; the flat key stays the single-inverter shape's own
  cut-out. A per-entry DEFAULT was declined - a default is one number for every
  pack, which is this card's defect - and the decision is recorded.
* inverter.py: `Inverter.bms_soc_floor`; an entry that states none gets None,
  not a borrowed number and not 0.0 (the most permissive value the key has).
* peakguard.py: `Pack`, `pack_floor_pct()`, `pack_permissions()`,
  `binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the
  one place the question is answered.
* validate.py: `unit_bms_floors_indexed()` carries the ROSTER index; rule 8
  grades EVERY unit and NAMES the one it refuses. Graded with the offender
  SECOND and with the same two numbers in the other order.
* main.py: `Controller.packs`; `decide(soc_floor=...)` is the per-pack
  reduction of the same option; the floor-limited notification names the pack
  that stopped the site. `pack_floor_soc` - the emptiest pack, and its
  None-on-missing behaviour - is untouched.
* tools/supervisor_shape_probe.py: RE-RUN and repaired. The required key broke
  §C and left §D printing PASS on a refusal about a different key. §D is now an
  isolated counterfactual over an accepted merged baseline, one key varied,
  asserting the key name. `all cases passed` on 2026.08.0.

Stated rather than hidden: under any config rule 8 accepts the per-pack FLOOR
reduces to `peak_soc_floor` for every pack. The reduction is written for
`unit_caps_indexed()`'s reason and graded on both sides - 1775 rule-8-valid
pairs answer the option, the pair rule 8 refuses answers the cut-out.
`peak_soc_floor` is still one number for the site, so the "deeper pack
needlessly withheld" half is filed as an open question, not closed.

Full 19-file suite green. Twelve source mutations through
tools/mutation_run.sh: eleven red, one argued survivor. The battery also found
`_flat_bms_floor()` defaulting in two places, each covering for the other.

No measurement of inverter B appears anywhere: it does not exist yet. Every
second-pack figure is simulated and labelled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 03:01:57 +02:00

276 lines
16 KiB
Python

"""CFG-04: does Supervisor accept a list-of-objects option? MEASURED, not assumed.
⚠️ WHY THIS SCRIPT EXISTS AND WHY IT IS COMMITTED. config.yaml had no
list-of-objects option anywhere before CFG-04, and `static_profile` carries a
structured value as a JSON STRING in a `str?` precisely because nobody knew
whether the Supervisor's options validator supported a nested one. That is a
question about a specific version of a specific piece of somebody else's
software, and the honest way to answer it is to run that software.
WHAT IT RUNS. Supervisor's OWN `AppOptions` validator and its OWN
`SCHEMA_APP_CONFIG`, out of the release image, against THIS repository's
config.yaml. Nothing here is a re-implementation of the validation rules; if
Supervisor changes them, this script changes its answer.
HOW TO RUN IT (the version is the one `ha info` reports on the target install -
2026.08.0 is what 192.168.2.158 ran when CFG-04 landed):
docker run --rm \
-v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" -v /tmp/out:/out \
--entrypoint python3 \
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
/probe/supervisor_shape_probe.py
⚠️ IT TOUCHES NOTHING. It reads config.yaml and runs a validator in a throwaway
container. It does not talk to the live Supervisor, does not POST options and
does not install anything - the schema conflict in docs/deploy-rollback.md §4 is
exactly what you do NOT want to discover on a running site.
WHAT IT ANSWERED, 2026-09-04, on 2026.08.0 - the full output is in
DECISIONS/CFG-04-inverter-roster.md:
A. The manifest, list-of-objects schema and all, is ACCEPTED.
B. The shipped `options:` block validates as it stands, and the SAME block
with ONLY `inverters` removed is refused BY NAME - `Missing option
'inverters' in root`. That is the one measurement the `inverters: []`
default rests on, and it is an ISOLATED counterfactual: exactly one key
varies between the two runs, so the refusal cannot be about anything else.
B2. A reconstructed pre-0.6.0 options blob, merged with those defaults,
validates. Its RAW form is refused - but for whichever required key the
reconstruction happens to lack FIRST, which is an artefact of the
reconstruction and NOT evidence about `inverters`. B is that evidence.
C. A two-inverter blob validates, per-entry types enforced.
D. An entry missing `name`, `setpoint_entity`, `max_w` or `bms_soc_floor` is
refused BY NAME - one key varied from an ACCEPTED baseline, and the key's
own name asserted in the refusal - and a JSON string where the list belongs
is refused outright.
⚠️ RE-RUN 2026-09-04 FOR SAFETY-32, which made `bms_soc_floor` a REQUIRED
per-entry key. That change broke §C and, worse, left §D printing PASS on a
refusal about the WRONG KEY - the exact decay MUTATION.md habit 8 describes. §C
now carries the key and §D is an isolated counterfactual asserting the key name.
"""
import sys, json, copy
sys.path.insert(0, "/usr/src/supervisor")
import voluptuous as vol
from voluptuous.humanize import humanize_error
from deepmerge import Merger
from supervisor.apps.validate import SCHEMA_APP_CONFIG
from supervisor.apps.options import AppOptions
from pathlib import Path
from supervisor.utils.yaml import read_yaml_file
MERGER = Merger(type_strategies=[(dict, ["merge"])],
fallback_strategies=["override"], type_conflict_strategies=["override"])
raw = read_yaml_file(Path("/w/config.yaml"))
print("== A. the whole config.yaml through SCHEMA_APP_CONFIG ==")
try:
cfg = SCHEMA_APP_CONFIG(raw)
print(" PASS Supervisor accepts the add-on manifest, version", cfg["version"])
except vol.Invalid as e:
print(" FAIL ", humanize_error(raw, e)); sys.exit(1)
schema, defaults = cfg["schema"], cfg["options"]
print(" schema declares", len(schema), "keys; inverters ->", json.dumps(schema["inverters"]))
# .get, not [], deliberately: if someone deletes the `inverters: []` default
# this line must not crash the probe before section B gets to REFUSE it.
print(" options ship inverters ->", json.dumps(defaults.get("inverters")))
def validate(options):
v = vol.Schema(vol.All(dict, AppOptions(None, schema, cfg["name"], "local_goodwe_controller")))
try:
return True, v(copy.deepcopy(options))
except vol.Invalid as e:
return False, str(e)
FAILED = []
def show(t, ok, d=""):
print(f" {'PASS' if ok else 'FAIL'} {t}")
if d: print(" " + str(d)[:300])
if not ok: FAILED.append(t)
# The live install's persisted options, reconstructed from HANDOVER.md §1 and
# docs/deploy-rollback.md §0/§B.5. No `inverters` key: it did not exist.
LIVE = {
"meter_entity": "sensor.p1_meter_active_power", "meter_invert": False,
"soc_entity": "sensor.goodwe_battery_soc", "batt_entity": "sensor.goodwe_battery_power",
"batt_invert": False,
"setpoint_entity": "number.kelder_goodwe_master_stage1_goodwe_setpoint_w",
"meter_source": "homewizard_local", "meter_phases": 1, "meter_max_age_s": 30,
"meter_poll_s": 5, "p1_host": "192.168.2.250",
"max_w": 5000, "gain": 0.6, "slew_w": 1000, "deadband_w": 15, "target_grid_w": -10,
"step_w": 10, "saturation_w": 500, "saturation_cycles": 3, "integrator_max_w": 0,
"heartbeat_s": 10, "stale_input_s": 15, "cycle_floor_s": 15, "auto_start": False,
"maintenance_enabled": False, "estop_fitted": True, "log_level": "info",
}
print("\n== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==")
# ⚠️ THIS IS AN ISOLATED COUNTERFACTUAL AND IT HAS TO STAY ISOLATED. The claim
# the `inverters: []` default rests on is about ONE key, so exactly ONE key
# varies between these two runs: the full shipped `options:` block, and the same
# block with `inverters` removed and nothing else touched. Feeding the validator
# some other, INCOMPLETE blob would also produce a refusal - but about whichever
# required key that blob lacks first, which is a different measurement wearing
# this one's label. §B2 below is that other blob, and it is labelled as such.
ok, out = validate(dict(defaults))
show("the shipped `options:` block validates AS IT STANDS - so anything refused "
"on the next line is refused for the one key that was removed", ok,
"" if ok else out)
_no_inv = {k: v for k, v in defaults.items() if k != "inverters"}
ok_ni, out_ni = validate(_no_inv)
# ⚠️ THE ASSERTION IS ON THE KEY NAME, NOT ON `not ok`, AND THAT IS THE POINT.
# A refusal is only evidence for THIS key if it NAMES this key. Asserted as a
# bare `not ok`, this line would go on printing PASS after someone deleted
# `inverters: []` from config.yaml - blind to the exact regression it is the
# committed evidence for. Asserted on the string, it goes red: with the default
# gone the line above fails too, because then nothing distinguishes the two runs.
_names_it = (not ok_ni) and "Missing option 'inverters' in root" in str(out_ni)
show("...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - "
"which is every options blob written before 0.6.0, since the key did not "
"exist when they were written", _names_it, out_ni)
print("\n== B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version ==")
# ⚠️ WHAT THIS SECTION IS AND IS NOT. LIVE is an INCOMPLETE reconstruction - it
# carries the keys HANDOVER.md and deploy-rollback.md record and no others, so
# it is missing many required keys that have nothing to do with this card. Its
# raw refusal is therefore about whichever of THOSE the validator reaches first
# (it has printed `meter_freeze_s` and `p1_phase_export_entities` on different
# runs), NOT about `inverters`. It is kept because the MERGED half is a real
# demonstration that `deepmerge(options:, stored)` is what App.options is; the
# raw half is printed only so the merge has something to be contrasted with.
# The `inverters` measurement is §B, where one key and only one key varies.
ok, out = validate(LIVE)
show("the reconstructed raw blob is REFUSED - for an unrelated missing key, "
"because the reconstruction is incomplete; see the refusal printed below "
"and §B for the `inverters` measurement", not ok, out)
merged = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(LIVE))
ok, out = validate(merged)
show("the same blob MERGED with these defaults - what App.options actually is", ok,
f"inverters={out.get('inverters')!r} setpoint_entity={out.get('setpoint_entity')!r} "
f"max_w={out.get('max_w')!r}" if ok else out)
print("\n== C. AC2: a two-inverter blob an operator would POST ==")
# ⚠️ B's 4600 W IS FROM ITS DATASHEET, NOT A PLACEHOLDER AND NOT A MEASUREMENT.
# SPIKE-04a (2026-09-04) identified inverter B from the owner's model number and
# serial - a GoodWe GW5048D-ES, serial `55048ESU22AW0992` - and the datasheet's
# Max. Charging / Discharging Power is 4600 W. The 3000 W that stood here was a
# hypothesis from before the unit was known and was simply wrong; the 5000 VA AC
# nameplate is wrong for this purpose too, because nothing will be connected to
# B's MPPTs so the battery path is the ceiling. See goodwe/NOTES.md §1.1b.
#
# ⚠️ AND IT IS STILL NOT MEASURED. The datasheet prints 4600 W under BATTERY
# INPUT DATA, so it is probably DC, while `max_w` is graded against 0x0518 - the
# inverter's own AC power. If it is DC the AC ceiling is nearer 4600 x 0.94 =
# 4324 W. NOTES.md §1.1b records both readings without picking one, and
# SAFETY-20's `min(max_w, dev_max)` is what bounds it correctly once dev_max is
# read off the unit. So this blob is what an operator would POST TODAY, not a
# claim about what inverter B will turn out to do.
#
# ⚠️ THE TWO RATINGS ARE STILL DIFFERENT (5000 / 4600) AND MUST STAY DIFFERENT.
# This file's own §B is the record of what an instrument that cannot distinguish
# its two cases is worth. Note that 5000/4600 is a much NARROWER separation than
# the 5000/3000 it replaces - fine here, where nothing computes on the numbers,
# and NOT fine in the test fixtures, which is why those keep a wide illustrative
# pair instead of the real one.
#
# ⚠️ AND `bms_soc_floor` IS A REQUIRED PER-ENTRY KEY SINCE SAFETY-32, so this
# blob is also what proves an operator can still POST a two-inverter config
# after that. THE TWO FLOORS DIFFER ON PURPOSE (10 and 15): equal values would
# not exercise a per-entry number at all, for the same reason the two ratings
# must stay different.
# ⚠️ NEITHER OF B'S NUMBERS IS A MEASUREMENT OF A PACK. Only inverter A's 10 %
# is read off hardware (register 0x0560 on the reference GW5000S-BP). B's 4600 W
# is its DATASHEET figure and B's 15 % is an illustrative value chosen to differ
# - there is no second pack on this site, and in production BOTH packs are
# expected to floor at 10 %, which is why the differing pair lives here and in
# the fixtures rather than in config.yaml's example.
TWO = dict(LIVE, setpoint_entity="", inverters=[
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
"max_w": 5000, "bms_soc_floor": 10},
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
"soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power",
"max_w": 4600, "bms_soc_floor": 15}])
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
# The validated blob, for anyone who wants to POST it. Optional: the probe's
# whole job is the verdicts above, and a missing /out must not fail it.
try:
json.dump(out if ok else {}, open("/out/two_inverter_options.json", "w"), indent=1)
except OSError:
pass
print("\n== D. what the schema itself refuses, per entry ==")
# ⚠️ ONE KEY VARIED FROM AN ACCEPTED BASELINE, AND THE REFUSAL IS ASSERTED BY
# NAME - §B's two lessons, applied here because SAFETY-32 is what broke this
# section. These entries used to omit several keys at once and assert a bare
# `not ok`; the moment `bms_soc_floor` became required, the "no max_w" case went
# on printing PASS while the refusal it printed said `Missing option
# 'bms_soc_floor'`. A refusal of unknown provenance is not a measurement of
# anything, so the baseline below is COMPLETE and each case deletes exactly one
# key - or, for the range case, moves exactly one value out of range.
D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600,
"bms_soc_floor": 10}
# MERGED with the shipped defaults, exactly as §C is and as App.options is: the
# raw LIVE blob is missing root keys unrelated to this section (`maintenance_
# start_hour` on the run that caught it), and a baseline refused for one of THOSE
# is the provenance problem §B2 is kept as a warning about.
D_BASE = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO))
ok, out = validate(dict(D_BASE, inverters=[D_FULL]))
show("the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere "
"else to come from", ok, json.dumps(out.get("inverters")) if ok else out)
for title, entry, expect in (
("no name", {k: v for k, v in D_FULL.items() if k != "name"}, "name"),
("no setpoint_entity",
{k: v for k, v in D_FULL.items() if k != "setpoint_entity"}, "setpoint_entity"),
("no max_w", {k: v for k, v in D_FULL.items() if k != "max_w"}, "max_w"),
("no bms_soc_floor (SAFETY-32)",
{k: v for k, v in D_FULL.items() if k != "bms_soc_floor"}, "bms_soc_floor"),
("max_w over the range", dict(D_FULL, max_w=9999), "at most 5000"),
("bms_soc_floor over the range (SAFETY-32)",
dict(D_FULL, bms_soc_floor=99), "at most 50")):
ok, out = validate(dict(D_BASE, inverters=[entry]))
show(f"an entry with {title} is refused, and the refusal NAMES it",
(not ok) and expect in str(out), out)
ok, out = validate(dict(D_BASE, inverters="[]"))
show("a JSON STRING where the list belongs is refused", not ok, out)
print("\n== E. the ROLLBACK direction, and the operator's UI ==")
# The old (flat) schema, i.e. what an operator would be running after a
# downgrade, meeting options that carry an `inverters` list.
_flat_schema = {k: v for k, v in schema.items() if k != "inverters"}
_v = vol.Schema(vol.All(dict, AppOptions(None, _flat_schema, cfg["name"],
"local_goodwe_controller")))
try:
_rolled = _v(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
show("a schema WITHOUT `inverters` drops the key with a warning rather "
"than refusing - so a downgrade is not the §B trap in reverse",
"inverters" not in _rolled, sorted(_rolled)[:6])
except vol.Invalid as e:
show("a schema WITHOUT `inverters` drops the key", False, str(e))
from supervisor.apps.options import UiOptions # noqa: E402
try:
_ui = [e for e in UiOptions(None)(schema) if e.get("name") == "inverters"]
show("Supervisor's UI renderer turns it into a repeatable section rather "
"than choking - an operator can add the second inverter in the "
"Configuration tab, which the JSON-string alternative could not offer",
bool(_ui) and _ui[0].get("multiple") is True, json.dumps(_ui))
except Exception as e: # noqa: BLE001
show("Supervisor's UI renderer survives the shape", False, f"{type(e).__name__}: {e}")
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
# and still succeeds, and a transcript is only evidence if a wrong answer is
# distinguishable from a right one by something other than reading it.
print()
if FAILED:
print(f"== {len(FAILED)} CASE(S) FAILED ==")
for t in FAILED:
print(" - " + t)
sys.exit(1)
print("== all cases passed ==")