Compare commits

..
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 147456c2a2 TEL-01: P1 ingestion, with the derivation and the age the EMS owns
A Belgian P1 meter publishes two UNSIGNED registers, not one signed figure.
Until now the add-on asked the installer to bridge that gap with a template
sensor, which put the sign convention of the whole control loop in a text box.
This moves it into the EMS: net = import - export, derived once, in one place,
with a test that fails if anyone inverts it.

Two transports behind one contract, chosen by `meter_source`: the HA WebSocket
subscribing to the DSMR integration's entities, and MQTT on a configurable
topic. Everything downstream reads P1Ingest, so switching is a config edit.
`meter_source: off` is the default and keeps the existing meter_entity path,
so no installed system changes until it opts in.

The other half is the timestamp. Every accepted sample is stamped at ingest
with a monotonic clock, `meter_max_age_s` is applied to it, and the age is
published as sensor.p1_sample_age_s for the ESP32's stale-input watchdog. That
entity is recomputed against the clock every second rather than only when a
telegram lands, because HA pushes state only on change: a meter frozen at a
constant reading emits nothing and looks, to anything watching the value,
exactly like a meter that has died. The age tells them apart.

Deliberately absent: any fallback to an inverter-side power figure. The
inverter's own AC power correlates 0.998 with battery power and 0.09 with the
real meter, so failing over to it means regulating against your own output.
A gap stays a gap - a reconnect emits no synthetic sample, and a rejected
telegram never resolves to 0 W or refreshes the timestamp.

Quarter-hour averages are time-weighted over clock-aligned blocks rather than
a mean of samples, so a cadence change cannot bias the capacity-tariff figure,
and only offtake is accumulated so a quarter of pure export averages to 0 kW.
Per-phase import is kept separately: on an unbalanced three-phase load the
phase sum and the connection net are different numbers, and only one of them
is billed.

test_p1.py: 99 checks, runnable with a bare interpreter and no meter. Includes
an end-to-end run of the HA transport against a fake Home Assistant websocket.

Stacked on SAFETY-04; nothing here touches control.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-24 21:52:51 +02:00
438 changed files with 593 additions and 213944 deletions
-18
View File
@@ -1,18 +0,0 @@
# This add-on is deployed to a Linux container. core.autocrlf=true on the
# authoring box gave the checkout CRLF, so a plain copy shipped CRLF files -
# run.sh with CRLF is a "bad interpreter" failure, and any hash-based drift
# check between repo and deployment fails for a reason that has nothing to do
# with the code. Deploy with:
# git -c core.autocrlf=false archive release/1.0 goodwe_controller | tar -x
# which is how 0.3.0 went out, byte-identical to the blobs.
* text=auto eol=lf
*.png binary
*.gz binary
# `.gitea/workflows/*.yml` steps embed shell heredocs; a `\r` on the terminator
# line means the heredoc never closes and the job fails on a syntax error that is
# invisible in the diff. A Windows checkout with core.autocrlf=true would
# reintroduce it on the next edit. CI-01.
*.yml text eol=lf
*.yaml text eol=lf
Dockerfile text eol=lf
-34
View File
@@ -1,34 +0,0 @@
# The image the add-on's tests run in (CI-01). This repo had NO CI at all before
# that ticket - eight runnable test files, none of them ever run by a machine.
#
# ⚠️ THE DEPENDENCIES ARE THE SHIPPED ONES, FROM APK, AND THAT IS THE POINT.
# goodwe_controller/Dockerfile installs `py3-aiohttp py3-paho-mqtt` from Alpine
# packages rather than pip, because Alpine ships musl and there are no musl
# wheels for aiohttp - pip would compile it from source on a client's Raspberry
# Pi. Testing against pip-installed glibc wheels would be testing a different
# add-on from the one that ships. Same base, same package manager, same names.
#
# ⚠️ NOT `FROM ghcr.io/home-assistant/...-base`. The real Dockerfile takes its
# base from the Supervisor's `BUILD_FROM` build-arg, which is per-architecture
# and not resolvable outside a Supervisor build. `alpine:3` is what those images
# are, minus the s6 supervision the tests do not use - and it is already cached
# on this runner's host, so the gate does not depend on a registry pull.
#
# ⚠️ The runner container itself CANNOT run these tests: it is Alpine with a bare
# python3, no aiohttp, no pip (`No module named pip`; checked on the box, not
# assumed). An image is not gold-plating here, it is the only option that does
# not mutate a shared long-lived runner.
# ⚠️ `git` IS A TEST DEPENDENCY, NOT A CONVENIENCE (CI-02). test_ci_gate.py
# grades the gate's own checkout by EXTRACTING the shell out of
# .gitea/workflows/checks.yml and RUNNING it - against two throwaway local
# repositories it builds itself, one whose pull request merges and one whose
# does not. Reading those commands as a string would be the thing this
# repository keeps finding: a check that never observed its subject. Running
# them needs git in the image the checks run in.
#
# It costs one apk package and no network at test time: the fixtures are local
# paths, and the token substitution in the workflow's `git remote add` is a
# no-op on a path with no `://` in it, which is exactly why the test can use
# the shipped line verbatim instead of a paraphrase of it.
FROM alpine:3
RUN apk add --no-cache python3 py3-aiohttp py3-paho-mqtt git
-125
View File
@@ -1,125 +0,0 @@
#!/bin/sh
# CI-15: run the add-on's test_*.py files, in parallel, inside the CI container.
# Called by the step "the add-on's own tests" of .gitea/workflows/checks.yml as
# sh -eu /w/goodwe-addon/.gitea/ci/run-tests.sh
# and runnable the same way from a clone (see TESTS_DIR below).
#
# WHY: the serial loop ran past the runner's 60-minute limit (SAFETY-125: 85 files,
# 3 557 s of test time, run 1430 cut off at file 84). The host has 12 cores.
#
# SHAPE
# 1. A parallel batch: JOBS files at a time (xargs -P), longest first.
# 2. THEN the serial group, one at a time, after the batch has finished, so
# each runs on a machine with nothing else of ours on it.
# Every file writes to its own $RUN_DIR/<file>.out (there is no shared output
# file) and prints its result as ONE block under a lock, so the PASS/FAIL
# lines and the `CI: ` / tail -40 text of two files never interleave.
#
# THE FILE LIST IS STILL DERIVED FROM THE TREE (see checks.yml). The two typed
# lists below only SORT or SET ASIDE files that exist; a new test_*.py that is
# in neither list is run, in the parallel batch. A name that no longer exists
# in a list is harmless. Nothing can make a file silently not run.
#
# SERIAL GROUP: files whose assertions depend on the wall clock, so CPU load
# from siblings could flip them. Name and reason, one each:
# test_loop.py drives the real run_control() against a real wall-clock
# floor (~93 s on purpose); worst inter-cycle gap 3.0 s
# against a 4.5 s bound (TEST-30, CI-06).
# test_freeze.py part 3 runs the real sim/hwsim.py over a transport with a
# 16 s window, a 10 s hold and an alarm measured at 11.0 s:
# one poll of margin, and "failed 1 run in 3" on the rig
# transport (TEST-30, CI-06).
# test_safety87.py builds a controller (CH65) whose meter reading is stamped
# on the REAL clock, and section 8 asks its status page,
# which says "Not ready - Meter: no reading for N seconds,
# limit 30 seconds" once N passes 30. Measured by CI-15: N is
# about half the file's run time (N = 45 s in a 94 s run, 48 s
# in a 103 s run). The file takes ~25 s idle and took 45 s in
# CI's first parallel run, so N was ~20 s of the 30. It FAILED
# alone under --cpus 0.15 (94 s) and in a loaded parallel run
# (103 s). No test file is changed here, so it runs alone.
# Add a file here only with a measured reason, and say it in this list.
SERIAL="test_loop.py test_freeze.py test_safety87.py"
#
# LONGEST FIRST (SAFETY-125's CI run, job 2580; then a local parallel run, seconds:
# modes_p4 758, safety126 634 (SAFETY-126, local, one core), safety125 508 on hotfix-0.19.2 only, modes_p3 364, safety119 328,
# price_run_fixes 184, safety90 160, safety103 134, safety94 114, peakguard 113,
# opt20 102, safety97 101, safety81 100). With JOBS workers pulling from one
# queue, starting the longest files first keeps the tail short: the batch takes
# about as long as the longest file. A sort order, not a selection.
HEAVY="test_modes_p4.py test_safety126.py test_safety125.py test_modes_p3.py test_safety119.py test_price_run_fixes.py test_safety90.py test_safety103.py test_safety94.py test_peakguard.py test_opt20.py test_safety97.py test_safety81.py"
#
JOBS=${CI_JOBS:-6}
cd "${TESTS_DIR:-/w/goodwe-addon/goodwe_controller}"
# one FILE: run it, keep its output in its own file, print its block whole.
if [ "${1:-}" = "--one" ]; then
f=$2
o="$RUN_DIR/$f.out"
start=$(date +%s)
if python3 "$f" > "$o" 2>&1; then rc=0; else rc=1; fi
secs=$(( $(date +%s) - start ))
{
if [ $rc = 0 ]; then
echo "PASS $f (${secs}s)"
grep "^CI: " "$o" || true
else
echo "FAIL $f (${secs}s)"
tail -40 "$o"
fi
} > "$RUN_DIR/$f.blk"
echo $rc > "$RUN_DIR/$f.rc"
flock "$RUN_DIR/print.lock" cat "$RUN_DIR/$f.blk"
exit 0
fi
set -- test_*.py
if [ "$1" = "test_*.py" ]; then
echo "NO test_*.py FOUND - the mount is empty or the layout moved. NOTHING has been checked."
exit 1
fi
echo "$# test file(s) to run: $JOBS at a time, then the serial group"
RUN_DIR=$(mktemp -d /tmp/ci-tests.XXXXXX)
export RUN_DIR
: > "$RUN_DIR/par"
: > "$RUN_DIR/ser"
for f in $HEAVY; do
case " $SERIAL " in *" $f "*) continue ;; esac
if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/par"; fi
done
for f in "$@"; do
case " $SERIAL $HEAVY " in *" $f "*) continue ;; esac
echo "$f" >> "$RUN_DIR/par"
done
for f in $SERIAL; do
if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/ser"; fi
done
echo "parallel: $(wc -l < "$RUN_DIR/par") file(s); serial: $(wc -l < "$RUN_DIR/ser") file(s)"
t0=$(date +%s)
rc=0
# `exit 0` in the worker: a failing FILE is recorded in its .rc, so a non-zero
# from xargs itself means the machinery broke, which is a failure too.
xargs -n 1 -P "$JOBS" sh -eu "$0" --one < "$RUN_DIR/par" || rc=1
echo "parallel batch done in $(( $(date +%s) - t0 ))s; serial group next"
while read -r f; do
sh -eu "$0" --one "$f" || rc=1
done < "$RUN_DIR/ser"
# Every file that was meant to run has a verdict, and every verdict is PASS. A
# worker that died before writing its .rc leaves no file, which counts as a fail.
ran=0
bad=0
for f in "$@"; do
ran=$((ran + 1))
if [ ! -f "$RUN_DIR/$f.rc" ]; then
echo "FAIL $f (no result: the run never reported)"
bad=$((bad + 1))
elif [ "$(cat "$RUN_DIR/$f.rc")" != 0 ]; then
bad=$((bad + 1))
fi
done
echo "$ran file(s), $bad failed, $(( $(date +%s) - t0 ))s wall"
if [ "$bad" = 0 ] && [ "$rc" = 0 ]; then exit 0; fi
exit 1
-2
View File
@@ -1,2 +0,0 @@
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847
-577
View File
@@ -1,577 +0,0 @@
# CI-01: this repository's FIRST automated check. Before this file, admin/goodwe-addon
# had no `.gitea/workflows/` directory at all - eight runnable test files, ~100 s
# of real coverage including a wall-clock cadence test, and not one machine ever
# ran them. Every merge here was a human reading a diff.
#
# ⚠️ NO EVENT FILTER OF ANY KIND - NO `paths:`, AND SINCE CI-9 NO `branches:`
# EITHER - AND THAT ABSENCE IS LOAD-BEARING. Branch protection on release/1.0
# requires the context `checks / unit (pull_request)`. Condition a STEP if you
# must; never the workflow.
#
# ⚠️ CI-01 WROTE THE REASON DOWN WRONG, AND THE WRONG REASON WAS THE SAFE-
# SOUNDING ONE. It said an unreceived required context sits PENDING forever, so
# a sat-out pull request becomes unmergeable - annoying, but fail-safe. That is
# not what this Gitea does. MEASURED on Gitea 1.27.3, 2026-09-19, throwaway
# PR #94: when a workflow exists but its event filter does not match, Gitea does
# not stay silent. It POSTS the required context itself, with state `skipped`:
#
# GET /api/v1/repos/admin/goodwe-addon/commits/<head sha>/statuses
# [{"status":"skipped","context":"checks / unit (pull_request)",
# "description":"Skipped","target_url":""}] <- no run, no job, no url
#
# GET /api/v1/repos/admin/goodwe-addon/commits/<head sha>/status
# {"state":"success","total_count":1} <- the rollup LAUNDERS it
#
# and Gitea's merge gate accepts it. Same PR, same head sha, one status on it
# and that status a skip, POST .../pulls/94/merge answered 409 `head out of
# date` - the 409 being a deliberately wrong `head_commit_id` in the probe, i.e.
# the request had ALREADY CLEARED BRANCH PROTECTION. The control, a required
# context with no status at all, answered 405 `Not all required status checks
# successful`. So the true failure mode is the exact inverse of the one written
# here before: sitting a pull request out does not make it unmergeable, it makes
# it MERGEABLE WITHOUT EVER BEING TESTED, and the badge says success while it
# happens. Fail-open, not fail-closed. Do not reintroduce a filter here.
#
# ⚠️ THE RUNNER IS SHARED WITH THE OTHER REPO'S RIG. `goodwe-runner` on
# 192.168.2.147 carries the labels `self-hosted` and `goodwe-rig` and is
# registered `:host`, and it is the only runner there is - so this job wears the
# rig label without wanting a rig. Nothing below starts, stops, or reads the
# `goodwe-sim` compose project or takes `sim/riglock.py`; it runs one short
# container and exits. It does occupy the runner's single slot (`capacity: 1`),
# so it QUEUES behind a rig campaign - a delayed verdict, never a destroyed one.
#
# NO JS ACTIONS: the runner image has no node, so `actions/checkout` cannot run.
# Checkout is plain git below - and since CI-02 it is a checkout of the MERGE of
# the pull request into its base, not of the branch tip. See the long note on
# that step; a green from this workflow is a claim about content that can land.
name: checks
# ⚠️ CI-9: `pull_request:` TAKES NO `branches:` LIST, ON PURPOSE. It used to
# read `branches: [release/1.0, master]`, which meant a STACKED pull request -
# one whose base is another feature branch rather than a release branch - did
# not match, and so was skipped, laundered to `success`, and mergeable untested
# by the mechanism described at the top of this file.
#
# ⚠️ AND RETARGETING DID NOT REPAIR IT, WHICH IS WHAT MADE THIS P1. The skip is
# a COMMIT STATUS ON THE HEAD SHA, not a property of the pull request, so moving
# the base afterwards leaves it exactly where it was. Observed live on PR #93
# (2026-09-19): retargeted TEL-10 -> release/1.0, and the 10:05:00 `skipped`
# stayed attached to head a6af332 with the rollup still reading success. It was
# caught by a human who forced a real execution by reopening the pull request
# (run 799, 10:56). Reproduced deliberately on throwaway PR #94: retargeted onto
# a branch carrying a byte-identical copy of release/1.0's protection rule, and
# the single `skipped` status survived the retarget unchanged - no new run, no
# new status - and still cleared the gate.
#
# ⚠️ WHY NOT FIX THIS AT THE GATE INSTEAD, which would be the better place. It
# cannot be done. Gitea's branch protection cannot express "this context must be
# SUCCESS" as opposed to "this context must be PRESENT AND NOT FAILING". The
# whole of its status-check vocabulary is two fields - `enable_status_check`
# (bool) and `status_check_contexts` (a list of globs MATCHING CONTEXT NAMES) -
# confirmed against this server's own schema at /swagger.v1.json, definitions
# EditBranchProtectionOption and CreateBranchProtectionOption. There is no field
# for the required STATE anywhere in either. The success/skip comparison lives
# in Gitea's compiled code, so the only lever left is upstream of it: make sure
# the context is never a skip in the first place. Hence this block.
#
# ⚠️ WHAT THIS COSTS, stated honestly, because it is a real trade. Every pull
# request now runs the full suite, including the intermediate steps of a stack
# that nobody intends to merge on its own. A run is ~8 minutes wall clock
# (run 799: 7m55s) on a runner that is `capacity: 1` and shared with the other
# repo's rig, so each extra run is ~8 minutes of slot that a rig campaign then
# queues behind.
#
# SCALE OF THE NEW WORK, re-derived from the API and stated with its denominator,
# because the first version of this paragraph had a made-up one. Counting the 93
# pull requests that predate this ticket (#1..#93; CI-9's own #94..#98 excluded,
# and note /pulls caps a page at 50, so this needs paging to see at all): 92 were
# opened against release/1.0 and would have run anyway, and exactly ONE was
# stacked - #2, TEL-01 -> SAFETY-04, closed unmerged, 2026-08-24. No pull request
# has ever targeted `master`, so half the old filter was dead from the start.
#
# ⚠️ THAT 92 IS AN UPPER BOUND, NOT A CENSUS, and the reason is this very bug.
# The API reports a pull request's CURRENT base, so one that was opened stacked
# and later retargeted is recorded against its final base and is invisible here.
# #93 is precisely that case - opened 2026-09-19T10:05:00 on TEL-10, retargeted,
# merged into release/1.0 at 11:05:26 the same morning, under three hours before
# CI-9 was opened - and it counts in the 92. So the true historical stacking rate
# is somewhere above 1/93, and unmeasurable from here.
#
# The bill is therefore small today and grows with however much stacking the team
# adopts. It is worth paying: the alternative is that the cheap case stays
# untested and indistinguishable from the tested one.
#
# ⚠️ THIS DOES NOT CHANGE GITEA'S ACCEPTANCE OF A `skipped` REQUIRED CONTEXT -
# that half is in its compiled code and is not reachable from this repository.
# The CONSEQUENCE, though, is very largely closeable from here, and is closed:
# after this edit there is nothing in this repository that produces a skip -
# there is exactly one workflow file, it now has no event filter of any kind,
# and the other candidate cause was MEASURED and is not one. The `guard the
# trigger` step below then keeps it that way mechanically rather than by asking
# the next editor to read this comment.
#
# ⚠️ READ THE BLIND SPOTS ON THAT STEP BEFORE TRUSTING THE SENTENCE ABOVE.
# "Mechanically" holds only for a reintroduced filter that still names the
# PR's own base. For blind spots 1 and 3 the defence IS this comment, because
# the guard lives inside the workflow that a filter makes Gitea skip. Blind
# spot 3 is the one that matters: a filter naming a branch THIS pull request
# does not target is itself skipped, lands green and untested, and then every
# subsequent pull request the filter DOES NOT NAME is skipped too. That is the
# gate off, silently.
#
# ⚠️ THAT DIRECTION IS THE WHOLE POINT AND IT IS EASY TO WRITE BACKWARDS. A
# pull request that MATCHES the filter still runs; it is the one the filter
# excludes that Gitea sits out, reports `skipped`, and the rollup launders
# into `success`. An earlier draft of this very clause said "matching it is
# skipped", which is the inverse of the measurement and contradicted the
# paragraph directly below.
#
# ⚠️ AND THE DANGEROUS ONE IS NOT `branches: [main]`, WHICH AT LEAST LOOKS
# WRONG. It is `branches: [release/1.0]`, which looks like tightening onto the
# protected branch and is what a careful person would write. Every day-to-day
# pull request still runs and still goes green, so nothing appears broken -
# and the only thing switched off is the `release/1.0` -> `master` release
# PR, i.e. the one whose content reaches an installed system. SEC-2 made that
# PR the mandated release path (docs/RELEASE.md), so this is now a live route
# rather than a hypothetical one.
#
# Closing it needs a SECOND permanently unfiltered workflow posting its own
# context, with that context added to the `status_check_contexts` of BOTH
# `release/1.0` AND `master` - both carry the rule since SEC-2, and adding it
# to one alone leaves the hole open on the other. A forge change, with the
# owner.
#
# THE MEASUREMENT, because the obvious guess is wrong and was nearly written
# here as fact. A skip-ci keyword in a commit message (`[skip ci]`, `[ci skip]`,
# `[no ci]`, `[skip actions]`, `[actions skip]`) does NOT behave like a filter
# miss. On throwaway PR #94, head b36effb, commit subject ending `[skip ci]`:
#
# .../commits/b36effb/statuses -> [] <- NO status, not a skip
# .../commits/b36effb/status -> {"state":"pending"} <- so the rollup is pending
# POST .../pulls/94/merge -> 405 Not all required status checks successful
#
# i.e. skip-ci is FAIL-CLOSED - it withholds the context entirely, which is the
# pending-forever behaviour CI-01's header wrongly attributed to a filter miss.
# It blocks the merge rather than laundering it, so it needs no fix and no
# app.ini change. Only the filter miss ever posted a `skipped`.
on:
pull_request:
workflow_dispatch:
# ⚠️ NO `concurrency:` BLOCK, AND ITS ABSENCE IS AS LOAD-BEARING AS THE MISSING
# `paths:` FILTER ABOVE. CI-01 put one here - `group: addon-checks-<pr number>`,
# `cancel-in-progress: true` - to stop a second pull request superseding a
# first one's run. It did stop that. It also did this, twice, on PR #14:
#
# run 531 created 17:08:19 `pending / Waiting to run`
# cancelled 17:10:44 <- the exact second run 532 was created
# run 533 created 17:45:11 `pending / Waiting to run`
# cancelled 18:11:22 <- the exact second run 534 was created
#
# Both cancelled runs have `started_at` at the Unix epoch: they never executed.
# A cancelled run posts `failure` on the required context, described
# `Canceled after 0s`. Branch protection compares STATE, not description, so
# "nothing ran" and "your tests are broken" arrive at the gate as the same red,
# and the correct operator response to each is the opposite of the other.
#
# CI-02 MEASURED THE THREE CANDIDATE SETTINGS ON A THROWAWAY PR (#29) RATHER
# THAN READING THE DOCS, because the docs and this Gitea disagree about which
# knob does it. In each trial one run was made to hold the single slot and two
# further pushes followed:
#
# trial 1, NO concurrency block - runs 561 and 562 both sat queued together
# and both executed. Nothing was cancelled.
# trial 2, group kept, cancel-in-progress: FALSE
# - run 564 sat `pending / Blocked by required
# conditions` and was cancelled at 0s the
# second run 565 was created.
#
# So `cancel-in-progress` IS NOT THE KNOB. A shared group cancels a run that has
# not started no matter what that flag says - GitHub documents this ("any
# previously pending run in the group will be cancelled") and Gitea 1.27
# implements it. The only configuration that leaves a queued run alive is having
# no group at all, which is what is written here.
#
# ⚠️ DO NOT REPLACE THIS WITH A SHA-KEYED GROUP as a compromise. It looks
# tempting - two runs on different commits could never share a group, so nothing
# would be cancelled - but the only cancellations it still permits are the
# same-sha ones, and those are precisely the dangerous half: a red for a run
# that never started, landing on a commit that IS still the head. It
# concentrates the failure instead of removing it.
#
# WHAT THIS COSTS, stated honestly: the runner is `capacity: 1` and shared with
# the other repo's rig, so a developer who pushes four times in a minute now
# occupies it four times instead of once. That is CI-01's own trade written the
# other way round - it accepted "a delayed verdict, never a destroyed one" for
# the rig, and this accepts the same for pushes. A destroyed verdict here is
# worse than a slow one, because it is indistinguishable from a real failure.
jobs:
unit:
runs-on: [self-hosted, goodwe-rig]
timeout-minutes: 40 # ~15 to run (CI-15: 6 files at a time; the serial suite was ~60 and hit the runner cap); the rest is margin for a busy host
env:
# ⚠️ UNDER /home/homelab, NOT $GITHUB_WORKSPACE (ENV-01, and re-measured
# during CI-01). act's workspace is `/root/.cache/act/<id>/hostexecutor`, a
# path that exists only INSIDE the runner container. Hand it to the host's
# snap-confined dockerd and the build fails with "failed to read
# dockerfile", or - worse, and this is the documented case - a bind mount
# resolves to an EMPTY DIRECTORY with no error at all. /home/homelab is
# mirrored 1:1 into the runner and is under $HOME, where snap confinement
# accepts it. The same probe under /tmp failed and under $HOME succeeded.
WS: /home/homelab/ci/addon-checks-${{ github.run_number }}
steps:
# ⚠️ THE CHECKOUT IS TWO LEVELS BELOW THE MOUNT ROOT, DELIBERATELY.
# test_freeze.py's find_hwsim() reads `HERE.parents[1] / "goodwe" / "sim"`,
# looking for the infra repo as a SIBLING of this one. Check out flat -
# goodwe_controller directly at the mount root - and `parents[1]` does not
# exist, so the test dies on `IndexError: 1` before its first assertion.
# Measured during CI-01; the flat layout was tried first and did exactly
# that. $WS/goodwe-addon/goodwe_controller gives parents[1] == $WS.
#
# ⚠️ WHAT THIS CHECKS OUT IS THE MERGE, NOT THE BRANCH, AND THAT IS THE
# WHOLE OF CI-02. What was here before was one line:
#
# git fetch -q --depth 1 origin "$GITHUB_REF" \
# || git fetch -q --depth 1 origin "$GITHUB_SHA"
#
# CI-02 measured what those two names actually are on this Gitea, on a
# deliberately conflicted throwaway PR (#29, runs 558 and 559), by asking
# one question per STEP - the Actions log endpoints 404 here, so a step's
# own conclusion is the only channel out of a job. The answers:
#
# GITHUB_REF == refs/pull/<n>/head (step P01 passed)
# GITHUB_REF != refs/pull/<n>/merge (step P02 failed)
# GITHUB_SHA == the pull request head sha (step P03 passed)
# fetching GITHUB_REF succeeds (step P04 passed)
# ...and lands on the bare head (step P05 passed)
# refs/pull/<n>/merge does not exist (step P06 failed)
#
# ⚠️ SO THE FALLBACK NEVER FIRED, AND THE DEFECT IS WORSE THAN THE ONE
# REPORTED. The ticket says a conflicted PR loses its merge ref and the
# `||` quietly grades the bare head instead. It does not: this Gitea has
# no merge refs at all - `git ls-remote origin 'refs/pull/*'` advertises
# only `/head`, for open and closed pull requests alike - so the FIRST
# half always succeeds, and both halves name the same commit anyway
# (GITHUB_REF resolves to GITHUB_SHA). The gate has never once graded
# merged content, on any pull request in this repository's history. Green
# has meant "this branch passed on its own", which on a base that has
# moved - and `block_on_outdated_branch` is off here, so it usually has -
# is not the content that lands. PR #14 reporting success while
# `mergeable: false` was not the fallback misfiring; it was the gate
# working exactly as written.
#
# A fallback that cannot fire cannot be "kept for the case it was for", so
# it is gone from this path and kept, ANNOUNCED, on the one path that has
# no pull request to merge (workflow_dispatch, below).
#
# ⚠️ NOT `--depth 1` ANY MORE. A merge needs the merge base, and a shallow
# pair does not have one. Measured before changing it: a full fetch of
# this repository is 6.8 MB and 1.1 s against this server, over 95
# commits. The depth flag was buying nothing worth a wrong verdict.
#
# ⚠️ THE WORKFLOW AND THE CONTENT NOW COME FROM DIFFERENT TREES. Gitea
# picks the workflow file from refs/pull/<n>/head, so THIS file is always
# the branch's; the tree graded below - including .gitea/ci/Dockerfile -
# is the merge. That is the right way round (a pull request must not be
# able to grade itself with a gate the base has not seen) but it means a
# change to this file is tested by the branch it is on, which is how you
# are reading this.
- name: check out the base branch and the pull request head (git; no node for actions/checkout)
env:
URL: ${{ github.server_url }}/${{ github.repository }}.git
TOKEN: ${{ secrets.GITHUB_TOKEN }}
PRNUM: ${{ github.event.pull_request.number }}
BASEREF: ${{ github.base_ref }}
run: |
set -eu
rm -rf "$WS" && mkdir -p "$WS/goodwe-addon" && cd "$WS/goodwe-addon"
git init -q . && git remote add origin "$URL"
# Token as an env-set header: not in argv or .git/config (TOOL-9).
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN"
if [ -n "$PRNUM" ]; then
# Two fetches, not one, so that a failure to reach either ref fails
# HERE with the ref in the message, rather than inside the merge
# step where it would read as a conflict. `github.base_ref` being
# populated at all was checked on the probe PR too (step Q01).
git fetch -q origin "+refs/heads/$BASEREF:refs/remotes/origin/base"
git fetch -q origin "+refs/pull/$PRNUM/head:refs/remotes/origin/pr"
git checkout -qf refs/remotes/origin/base
echo "base $BASEREF $(git rev-parse refs/remotes/origin/base)"
echo "head refs/pull/$PRNUM/head $(git rev-parse refs/remotes/origin/pr)"
else
# NO PULL REQUEST (workflow_dispatch). There is nothing to merge
# into, so this path grades a bare ref and SAYS SO - which is all
# the old `||` ever needed to do. It is kept here because a branch
# can be deleted or renamed between the dispatch and the fetch, and
# unlike the pull_request path that is a case nobody has ruled out.
if git fetch -q origin "$GITHUB_REF"; then
echo "GRADED: the bare ref $GITHUB_REF - NOT merged content (no pull request in this event)."
else
git fetch -q origin "$GITHUB_SHA"
echo "GRADED: the bare commit $GITHUB_SHA - NOT merged content, and NOT $GITHUB_REF either, which would not fetch."
fi
git checkout -qf FETCH_HEAD
fi
echo "checked out $(git log --oneline -1)"
# ⚠️ THIS STEP IS THE ONE THAT MAKES A GREEN MEAN SOMETHING. It exists as
# its OWN step rather than as three lines of the checkout because the
# Actions log endpoints 404 on this Gitea and
# /api/v1/repos/.../actions/runs/<id>/jobs - which carries a per-step
# conclusion - is the only machine-readable account of what a run did. A
# reader who sees this step red knows the pull request does not apply and
# that NO TEST RAN; a reader who sees the next two red knows the tests ran
# and spoke. Collapse them back together and that distinction is gone
# again, which is the shape of defect this whole ticket is about.
#
# The refusal is a FAILURE rather than a skip on purpose. "Reports
# nothing" would leave the required context pending forever, which this
# file's own header explains is unmergeable-forever; and a conflicted pull
# request cannot be merged by Gitea anyway, so the red blocks nothing that
# was not already blocked. What it does is stop a green being posted about
# content that cannot land.
- name: grade the merge, not the branch - refuse a pull request that does not apply
env:
PRNUM: ${{ github.event.pull_request.number }}
BASEREF: ${{ github.base_ref }}
run: |
set -eu
cd "$WS/goodwe-addon"
if [ -z "$PRNUM" ]; then
echo "no pull request in this event - nothing to merge, and the previous step said what it graded."
exit 0
fi
if git -c user.email=checks@localhost -c user.name=checks \
merge --no-edit refs/remotes/origin/pr; then
echo "GRADED: refs/pull/$PRNUM/head merged into $BASEREF"
echo " result $(git rev-parse HEAD)"
echo " tree $(git rev-parse 'HEAD^{tree}')"
else
git merge --abort 2>/dev/null || true
echo "REFUSED: refs/pull/$PRNUM/head does not merge into $BASEREF."
echo " NOTHING HAS BEEN TESTED. This is not a test failure - no test ran."
echo " Rebase or merge $BASEREF into the branch and push again."
exit 1
fi
# ⚠️ THE GUARD ON THE TRIGGER. THIS STEP EXISTS BECAUSE PROSE ALREADY
# FAILED IN THIS FILE, ONCE, FOR TWENTY DAYS.
#
# `git show 9c1a23328a` is CI-01's first commit. It shipped the header
# "⚠️ NO `paths:` FILTER, AND ITS ABSENCE IS LOAD-BEARING" and the line
# `branches: [release/1.0, master]` IN THE SAME COMMIT, EIGHTEEN LINES
# APART. The comment forbade one kind of event filter while the code
# directly above the job carried another, and nobody caught it through 84
# merges until the gate let a stacked pull request through. So the whole of
# CI-9's fix rests on an invariant - "checks.yml has no event filter" -
# that has already been silently violated in this exact file by the person
# who wrote the warning against violating it. A louder comment is not a
# remedy for that. A red step is.
#
# WHY THIS CAN WORK AT ALL, given CI-9 rejected the card's option 3 (a step
# that detects its own workflow having been skipped) as impossible. That
# option is impossible because a skipped workflow runs no steps, so there
# is nothing alive to notice. This is the different, tractable case: it
# fires on the pull request that REINTRODUCES a filter, and it does so
# whenever the filter being reintroduced STILL NAMES THAT PULL REQUEST'S
# OWN BASE - the ordinary case, because a filter put back by hand names
# the release branch people are actually targeting. The edit then has to
# get past this step before it can start causing skips. It is a ratchet,
# not a skip detector.
#
# ⚠️ AND IT IS THE HEAD'S WORKFLOW THAT DECIDES WHETHER ANYTHING RUNS, NOT
# THE BASE'S. An earlier draft of this comment claimed the reintroducing
# pull request "is still graded by the unfiltered workflow which is on the
# base today". That was wrong, it contradicted the checkout step's own note
# a few lines below, and the error was not cosmetic: it made this step
# sound like an unconditional ratchet, which is exactly the false comfort
# CI-9 exists to stamp out. MEASURED on PR #101 - head carrying a filter
# that excludes the base, base carrying the unfiltered workflow - and the
# result was no run at all, the context posted `skipped`, the rollup
# reading `success`, and the gate CLEARED (409). Gitea takes the workflow
# file from refs/pull/<n>/head, so a head that filters itself out is simply
# never graded, and nothing on the base can reach out and stop it. That is
# what blind spot 3 below is made of.
#
# WHAT IT INSPECTS is the MERGED tree in $WS - the content that would
# actually land on the base - not the branch tip and not the copy of this
# file that Gitea used to decide whether to run. Those are three different
# trees here (see the long note on the checkout step) and only the merged
# one is the thing being authorised.
#
# ⚠️ THREE ACKNOWLEDGED BLIND SPOTS. NOT ONE OF THEM IS CLOSEABLE FROM
# INSIDE THIS FILE, and the reason is the same in all three: each is a case
# where THIS WORKFLOW IS ITSELF THE THING THAT GETS SKIPPED, and a guard
# that lives inside a skipped workflow does not run. Closing them needs a
# SECOND, permanently unfiltered workflow whose own context is added to
# release/1.0's `status_check_contexts`, so that something survives to
# report when this file is sat out. That is a forge change and the
# repository owner's decision; it is being put to them separately and is
# deliberately NOT held as a blocker on CI-9.
#
# 1. A reintroduced `paths:` filter crafted to exclude the reintroducing
# pull request's OWN diff would skip that pull request, so this step
# would not run to object to it.
#
# 2. An edit that lands on a feature branch through a stacked pull
# request is graded by the HEAD's workflow, so a filter already
# present in the head hides the edit from this step - until that
# branch is proposed for release/1.0, where (unless it is case 3) the
# base matches the reintroduced filter and this step runs and bites.
# Caught at the release boundary, which is the boundary that matters.
#
# 3. ⚠️ THE WORST OF THE THREE, AND WORSE THAN BOTH OF THE OTHERS PUT
# TOGETHER: a reintroduced `branches:` filter that simply DOES NOT
# NAME THE BASE escapes this step entirely, because the pull request
# carrying it is itself skipped. It takes no craft and no malice to
# write - `branches: [main]` copied out of a GitHub template, or a
# tidy-up to `[master]`, a branch that NO pull request in this
# repository's history has ever targeted - and it arrives as a
# skipped, laundered, untested merge into release/1.0.
#
# What makes it categorically worse is what happens the moment after.
# Once that file is ON release/1.0, every subsequent pull request is
# graded against it and every one of them is skipped too. This is not
# the stacked case leaking through a crack; it is the gate off
# completely, for everything, silently, with every badge green. The
# only thing standing between the repository and that state today is
# a human reading this file - which is precisely the kind of defence
# this step exists because it has already failed once (9c1a23328a).
#
# It is deliberately a FAILURE and not a warning: a warning would leave
# the required context green, which is the precise defect CI-9 is about.
- name: guard the trigger - refuse a checks.yml that has regained an event filter
run: |
set -eu
f="$WS/goodwe-addon/.gitea/workflows/checks.yml"
test -f "$f" || { echo "checks.yml is missing from the merged tree."; exit 1; }
# Walk the top-level `on:` block of the MERGED file. Full-line comments
# at column 0 are skipped outright - this file's own prose quotes
# `paths:` and `branches:` many times and must not trip its own guard -
# and an inline `#` is trimmed from any line that is kept.
bad=$(awk '
function scan(t) {
sub(/#.*/, "", t)
if (t ~ /(^|[^A-Za-z0-9_-])(branches|branches-ignore|paths|paths-ignore|tags|tags-ignore|types)[[:space:]]*:/)
return 1
return 0
}
/^[[:space:]]*#/ { next }
/^("on"|'"'"'on'"'"'|on)[[:space:]]*:/ {
inon = 1
rest = $0; sub(/^[^:]*:/, "", rest)
if (scan(rest)) print NR ": " $0
next
}
inon == 1 {
if ($0 ~ /^[[:space:]]*$/) next
if ($0 ~ /^[[:space:]]/) { if (scan($0)) print NR ": " $0; next }
inon = 0
}
' "$f")
if [ -n "$bad" ]; then
echo "REFUSED: checks.yml has regained an event filter in its \`on:\` block."
echo "$bad"
echo
echo " A filter means some pull request stops matching this workflow. Gitea does"
echo " not stay silent about a workflow it sits out - it posts the required context"
echo " \`checks / unit (pull_request)\` itself with state \`skipped\`, the combined"
echo " status rolls that up as \`success\`, and the merge gate ACCEPTS it. That is"
echo " CI-9: a pull request merged on a workflow that never ran a step. It was"
echo " reproduced by merging PR #96 for real through a protected branch."
echo
echo " Branch protection cannot save you here - it can require that a context is"
echo " PRESENT, never that it is SUCCESS - so the filter is the whole defence."
echo " If you genuinely need to narrow what runs, condition a STEP, never the"
echo " workflow. See the \`on:\` block's comments for the measurements."
exit 1
fi
echo "OK: the \`on:\` block of the merged checks.yml carries no event filter."
# CI-12: the infra repo at the ONE pinned commit in .gitea/infra-pin (read
# from the merged tree), as the sibling test_freeze.py looks for. Any
# failure here fails the job; the tests step below refuses the stub.
- name: check out admin/goodwe at the pin in .gitea/infra-pin
env:
URL: ${{ github.server_url }}/admin/goodwe.git
# Read access to admin/goodwe only; this job's own token cannot see it.
TOKEN: ${{ secrets.INFRA_READ_TOKEN }}
run: |
set -eu
# `|| true`: under pipefail a missing or comment-only pin would skip the FAIL line (TOOL-9).
PIN=$(grep -v '^#' "$WS/goodwe-addon/.gitea/infra-pin" | tr -d '[:space:]') || true
echo "$PIN" | grep -Eq '^[0-9a-f]{40}$' || { echo "FAIL: .gitea/infra-pin holds '$PIN', not a full 40-hex SHA."; exit 1; }
test -n "$TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
rm -rf "$WS/goodwe" && mkdir -p "$WS/goodwe" && cd "$WS/goodwe"
git init -q .
# The token goes in a header via env: not in argv, and not in .git/config,
# which is mounted into the tests' container and outlives a failed job.
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
git fetch -q "$URL" "$PIN" || { echo "FAIL: could not fetch admin/goodwe at $PIN."; exit 1; }
git -c advice.detachedHead=false checkout -qf FETCH_HEAD
test "$(git rev-parse HEAD)" = "$PIN"
test -f sim/hwsim.py || { echo "FAIL: admin/goodwe@$PIN has no sim/hwsim.py."; exit 1; }
echo "infra: admin/goodwe @ $(git log --oneline -1)"
- name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile)
run: |
set -eu
docker build -q -t goodwe-addon-checks:1 "$WS/goodwe-addon/.gitea/ci" >/dev/null
docker run --rm goodwe-addon-checks:1 python3 -c \
'import aiohttp, paho.mqtt.client as m, sys; print("python", sys.version.split()[0], "aiohttp", aiohttp.__version__)'
# ⚠️ THE FILE LIST IS DERIVED FROM THE TREE, NOT TYPED HERE. A hand-written
# list is how a new test file joins the repo and is never run - the exact
# shape of failure this ticket exists to close. The step globs `test_*.py`,
# prints the count, and REFUSES A COUNT OF ZERO: a loop over an empty glob
# exits 0 and would report a green gate that examined nothing.
#
# ⚠️ test_loop.py TAKES ~93 SECONDS OF WALL CLOCK ON PURPOSE (its own
# docstring says so, and says why: it drives the real `run_control()`
# against a real wall-clock floor, and mocking the clock would leave the
# assertion testing the mock). It is not hung. Do not "fix" it with a
# shorter timeout - a 60 s limit was tried during CI-01 and reported
# exactly the false hang the docstring predicts.
#
# ⚠️ CI-15: THE FILES RUN SIX AT A TIME, NOT ONE AFTER ANOTHER. The serial
# loop summed 3 557 s on SAFETY-125's run (job 2580) and ran past the
# runner's 60-minute cap. The loop now lives in .gitea/ci/run-tests.sh (it
# comes from the merged tree, like the Dockerfile), so the same script runs
# locally and here. It runs the longest files first, then the files whose
# assertions depend on the wall clock - test_loop.py and test_freeze.py
# (TEST-30 / CI-06) and test_safety87.py (CI-15) - ALONE, after the
# parallel batch, so no sibling can steal their CPU. The reasons are in
# the script. Its output stays one
# PASS/FAIL block per file, in a file per file (never one shared /tmp/out).
# A file that is not in the script's two typed lists still runs, in the
# parallel batch: the list is a sort order, not a selection.
#
# test_freeze.py part 3 runs against the real `sim/hwsim.py` from the
# checkout above; GOODWE_REQUIRE_HWSIM=1 makes it fail rather than use its
# stub (CI-12). Lines a test prints with a `CI: ` prefix are echoed on PASS.
# ⚠️ PYTHONDONTWRITEBYTECODE=1 IS BELT AND BRACES, NOT A FIX FOR A LIVE
# HOLE. TEST-15 measured this runner's actual exposure and found none: the
# checkout below is bind mounted `:ro`, so the interpreter physically
# cannot write a __pycache__ into the tree it is grading - it fails
# SILENTLY, no error and exit status 0 - and every job `rm -rf`s $WS and
# clones afresh, so nothing survives from a previous job on this shared
# runner either. The variable is here because the `:ro` is the load-bearing
# part and it is one character wide: delete it while debugging a write and
# the gate quietly starts caching bytecode across a rebuild again. Note
# what it does NOT do, per TEST-15 part 2 - it stops bytecode being
# WRITTEN, never a stale one being READ. It is not a substitute for the
# purge in goodwe_controller/tools/mutation_run.sh.
- name: the add-on's own tests
run: |
set -eu
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -e GOODWE_REQUIRE_HWSIM=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu /w/goodwe-addon/.gitea/ci/run-tests.sh
- name: clean up
if: always()
run: |
rm -rf "$WS" || true
# Root-owned by this runner inside a directory the `homelab` user owns,
# and sudo on that box is interactive-only, so the user cannot clean
# this up and the job that made it has to.
ls -1dt /home/homelab/ci/addon-checks-*/ 2>/dev/null | tail -n +6 | xargs -r rm -rf || true
-46
View File
@@ -1,46 +0,0 @@
# CI-12: goes red when admin/goodwe release/1.0 has moved past .gitea/infra-pin.
# It runs on add-on activity only; admin/goodwe's own `addon-pin` workflow
# covers a move of release/1.0 there. Deliberately NOT a required check: it
# reports; `checks / unit` stays the only gate. How to bump: README.md,
# "CI and the infra pin".
name: infra-pin
on:
pull_request:
push:
branches: [release/1.0]
jobs:
fresh:
runs-on: [self-hosted, goodwe-rig]
timeout-minutes: 30 # seconds of work; the rest is queueing for the shared slot
steps:
- name: compare .gitea/infra-pin with admin/goodwe release/1.0
env:
SELF: ${{ github.server_url }}/${{ github.repository }}.git
INFRA: ${{ github.server_url }}/admin/goodwe.git
TOKEN: ${{ secrets.GITHUB_TOKEN }}
INFRA_TOKEN: ${{ secrets.INFRA_READ_TOKEN }} # admin/goodwe is private
run: |
set -eu
D=$(mktemp -d) && trap 'cd / && rm -rf "$D"' EXIT && cd "$D"
git init -q .
# The pin of the commit this event is about (the PR head, or the push).
# Token as an env-set header: not in argv or .git/config (TOOL-9).
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
git fetch -q --depth 1 "$SELF" "$GITHUB_SHA" || { echo "FAIL: could not fetch $GITHUB_SHA here."; exit 1; }
# `|| true`: under the runner's pipefail a missing or comment-only pin
# would stop here on git's error instead of the FAIL line below (TOOL-9).
PIN=$(git show FETCH_HEAD:.gitea/infra-pin | grep -v '^#' | tr -d '[:space:]') || true
test -n "$PIN" || { echo "FAIL: .gitea/infra-pin is missing at $GITHUB_SHA, or holds only comments."; exit 1; }
test -n "$INFRA_TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
# Header via env, as in checks.yml: the token stays out of argv.
HEAD=$(GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $INFRA_TOKEN" \
git ls-remote "$INFRA" refs/heads/release/1.0 | cut -f1) || true
test -n "$HEAD" || { echo "FAIL: could not read admin/goodwe release/1.0."; exit 1; }
if [ "$PIN" != "$HEAD" ]; then
echo "STALE: .gitea/infra-pin is $PIN but admin/goodwe release/1.0 is $HEAD."
echo " Test against $HEAD and bump the pin (README.md, \"CI and the infra pin\")."
exit 1
fi
echo "OK: .gitea/infra-pin $PIN is admin/goodwe release/1.0 HEAD."
+1 -6
View File
@@ -4,10 +4,6 @@ The guide technicians carry is the PDF:
**`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided.
⚠️ **Outdated: the content is still v0.1.0.** The PDF was rebuilt on 2026-10-17
for the EMS-port wording and the `slew_w` row only. Where the guide disagrees with
`goodwe_controller/DOCS.md`, DOCS.md wins.
It covers, in the order the work is done: pre-visit checks, hardware, firmware,
add-on install and configuration, how to read the Web UI, the six commissioning
gates, the e-stop (and the statement a site without one must sign), proving a
@@ -22,8 +18,7 @@ copy cannot drift from the PDF someone is holding in a cellar.
python3 docs/build_guide.py # regenerates field-guide.html and the PDF
```
Requires Chrome or Chromium (used headless for print-to-PDF); `CHROME` in the
script is a Windows path, so edit it on any other machine. Screenshots live
Requires Chrome or Chromium (used headless for print-to-PDF). Screenshots live
in `docs/img/` and are base64-embedded, so `field-guide.html` is standalone.
⚠️ **Every section must stay under the A4 printable height** (1039 px at 96 dpi
+3 -64
View File
@@ -12,7 +12,7 @@ a GoodWe GW5000S-BP.
| | |
|---|---|
| running | `local_goodwe_controller` **0.4.0**, `boot: auto`, holding grid at zero |
| running | `local_goodwe_controller` **0.2.0**, `boot: auto`, holding grid at zero |
| installed as | a **Local** add-on: `/addons/goodwe_controller` on HA OS at 192.168.2.158 |
| repo | Gitea `admin/goodwe-addon` (ssh://git@192.168.2.147:2222) — **private** |
| tuning | gain 0.6, `max_w` 5000, slew 1000, deadband 15, step 10, saturation 500/3, heartbeat 10 s |
@@ -52,65 +52,9 @@ Run the checks before deploying. No framework needed:
```bash
cd goodwe_controller
python3 test_control.py && python3 test_maintenance.py && python3 test_arbiter.py \
&& python3 test_validate.py && python3 test_p1.py && python3 test_watchdog.py \
&& python3 test_loop.py
python3 test_control.py && python3 test_maintenance.py && python3 test_arbiter.py
```
⚠️ **Mutating a module to prove a check bites? Read `goodwe_controller/
MUTATION.md` first, and run `goodwe_controller/tools/mutation_run.sh`.** A
same-size edit reverted inside one second changes neither the file size nor the
whole-second mtime, so CPython reuses the bytecode it already compiled and the
suite grades code that is not there - once already, reported as a survivor and
disproved by disassembly (TEST-15). `python3 -B` does **not** save you: it stops
bytecode being written, not a stale `.pyc` being read. The purge is the fix.
`test_watchdog.py` covers SAFETY-02's logic, its arming gate (derived from the
real arbiter, not passed in), its trip path, and the fact that the latch survives
an add-on restart. Its headline validation is not here at all: `sim/replay_sign.py`
in the infra repo drives the same module over the two captured RS485 days and must
fire on all four of the 2026-08-20 episodes and stay silent on 2026-08-23. That
needs both checkouts:
```bash
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 sim/replay_sign.py
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 sim/replay_sign.py --stub
```
The second run checks the grader still *discriminates* rather than merely still
passing — a watchdog that never trips and one that always trips must both be
rejected.
⚠️ **If you are testing the failsafe by hand, note that stopping and starting the
add-on will not clear a trip.** That is the point of it; use the *Start / stop
control* button twice, or delete the trip from `/data/state.json`.
⚠️ **DEV-02 renamed that key. There is one `SignWatchdog` per inverter, so the
LIVE latch lives under `unit/<inverter>/sign_tripped`** —
`unit/inverter-a/sign_tripped` on a single-inverter site. **That is the one to
delete if you are clearing a trip by hand**; deleting the flat one alone clears
nothing.
⚠️ **The flat `sign_tripped` is still maintained, though — it is the ROLLBACK
VIEW, not a fossil.** It is written alongside the namespaced key on a trip and
cleared alongside it when you press *Start / stop control*, so an add-on you
**downgrade** past this version still finds the latch it should. It is also read
on **every** start, and the one disagreement it can produce is acted on: *the
flat key says tripped and no inverter came up latched* means the latch was
written by an older add-on while this install was rolled back — or the migration
lost it — and the controller **re-arms the latch and shouts in the log** rather
than coming up disarmed with a green banner. So if you edit `/data/state.json`
by hand, clear **both** spellings or the next start will put the trip back.
⚠️ `test_loop.py` takes about **95 seconds** (measured 93 s) and that is not a bug. It
drives the real control loop, which sleeps 1 s a tick, eight times over, to prove a
wall-clock property about it (SAFETY-12); mocking the clock there would leave the
assertion testing the mock. The other six suites together are ~440 checks and finish in
seconds — `test_watchdog.py` spends ~3 s of that driving a real `Controller` through a
trip. If you shorten `test_loop.py`'s windows, re-measure and correct this figure —
an understated runtime is what makes a slow test look like a hung one, and a slow test
that looks hung gets deleted.
---
## 3. Known broken / unfinished
@@ -156,7 +100,7 @@ that looks hung gets deleted.
**A GoodWe "-20" / G2 inverter cannot be driven by this.**
Platform comes from characters 6–8 of the serial number. Only
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era generation — are
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era meter bus — are
supported. SBP G2 (`SPB`/`SPN`) and ES G2 (`ESN`/`ESC`) are platform 745, a
different protocol. The reference unit is `BPS`.
@@ -190,11 +134,6 @@ Each of these was found the expensive way. They are in the code comments too.
`timestamp` attribute.
- **Alpine ships paho-mqtt 1.x**, which has no `CallbackAPIVersion`, and musl has
no aiohttp wheel — install from apk, not pip.
- **A mutation run can grade code that is not there.** CPython validates a
`.pyc` on (source size, source mtime in whole seconds), so a same-size edit
reverted within one second is invisible to it and the old bytecode runs. It
manufactures *survivors*, never reds. Purge `__pycache__` -
`tools/mutation_run.sh` does - and see `goodwe_controller/MUTATION.md`.
- **The inverter holds its last command forever.** It has no meter-timeout. Every
failsafe here exists because of that one fact, and anything in doubt commands
**0 W** rather than holding.
+10 -35
View File
@@ -1,8 +1,8 @@
# GoodWe RS485 Controller — Home Assistant add-on repository
Replaces a GoodWe battery inverter's vendor controller with a local one: holds
net grid exchange at zero by sending the inverter a grid reading over its RS485
EMS port, and runs the monthly battery maintenance cycle the vendor box was doing.
net grid exchange at zero by emulating the inverter's smart meter over RS485,
and runs the monthly battery maintenance cycle the vendor box was doing.
```
repository.yaml add-on repository metadata
@@ -14,27 +14,16 @@ goodwe_controller/ the add-on
hass.py Supervisor/Core API access
store.py persistent state in /data
mqtt.py optional status entities
web.py ingress UI — routes only, status and configuration
config_page.py the configuration page's markup, style and script
configui.py its field model, built from the Supervisor's own
schema + translations — owns no key, label or rule
selfconfig.py reading and writing this add-on's own options,
with the validate-first / compare-and-swap gate
web.py ingress UI
main.py orchestration, heartbeat, failsafe behaviour
test_control.py runnable checks — no framework needed
test_maintenance.py runnable checks — walks a full cycle in fake time
DOCS.md the add-on's documentation tab
MUTATION.md how to run a mutation so the result means something
tools/mutation_run.sh the mutation harness - purges bytecode, then grades
tools/supervisor_selfapi_probe.py what the Supervisor really permits and
returns, run against its own release image
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
estop/rs485_log.py optional RS485 e-stop / bus witness
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; rebuilt 2026-10-17, content still v0.1.0 and outdated)
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
docs/build_guide.py its single source - rebuilds the HTML and PDF
FIELD-GUIDE.md how to edit and rebuild the guide
docs/RELEASE.md how a change reaches the battery - `master` is
protected, releases go via PR from `release/1.0`
```
**Picking this up after a break? Read `HANDOVER.md` first** — current state,
@@ -50,9 +39,6 @@ https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
then install **GoodWe RS485 Controller**.
Then turn on **Watchdog** on the add-on's Info tab — see
[Before you start](goodwe_controller/DOCS.md#before-you-start).
⚠️ **The repository must be PUBLIC for this to work.** Home Assistant clones it
anonymously; against a private repo the store shows nothing and gives no useful
error. If it must stay private, install locally instead: copy
@@ -82,8 +68,8 @@ machine itself dying:
2. **Wind-down before firmware updates** — 0 W written before the update starts.
3. **RS485 e-stop (optional)** — writes 0 W after 30 s of total bus silence.
Sites sold without the e-stop must have the client acknowledgement in the field
guide (Step 7b) signed.
Sites sold without the e-stop must have the acknowledgement in `FIELD-GUIDE.md`
§10 signed.
## Development
@@ -96,21 +82,10 @@ python3 test_control.py
python3 test_maintenance.py
```
Both must pass before shipping any change. To prove a check *bites* - the way
almost every claim in this repository was established - mutate the module and
run `tools/mutation_run.sh`, which clears `__pycache__` first. It is not
optional: a same-size edit reverted inside one second leaves CPython reusing the
old bytecode, and the run grades code that is not there. `goodwe_controller/MUTATION.md`
has the mechanism, the measurement, and what the flags do not do. They are not unit-test theatre —
Both must pass before shipping any change. They are not unit-test theatre —
each assertion corresponds to a rule whose absence produced an observed failure
on real hardware, and the comments in `control.py` say which.
### CI and the infra pin
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py` (six at a time, the wall-clock ones alone afterwards: `.gitea/ci/run-tests.sh`), with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
The pin tracks `admin/goodwe` **release/1.0**, not master. Two non-required checks go red when release/1.0 has moved past the pin: `infra-pin / fresh` here, on any add-on PR or push to release/1.0, and `addon-pin / fresh` in `admin/goodwe`, on every push to its release/1.0. Nothing re-runs `addon-pin` after a bump, so it stays red on that admin/goodwe commit; `infra-pin / fresh` green on the bump is the all-clear.
**Bump the pin** whenever either is red. Set `.gitea/infra-pin` to the new release/1.0 SHA (`git ls-remote <gitea>/admin/goodwe.git refs/heads/release/1.0`) in a PR, and let `checks / unit` grade the tests against it. CI reads `admin/goodwe` with the Actions secret `INFRA_READ_TOKEN`.
## Compatibility
**Check the serial number, not the model name.** Characters 6-8 of the serial are
@@ -118,17 +93,17 @@ the platform tag:
| tag | platform | verdict |
|---|---|---|
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era | **supported** |
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era meter bus | **supported** |
| `SPB SPN` | 745 — SBP **G2** (`GW…-SBP-20`) | no |
| `ESN ESC` | 745 — ES **G2** (`GW…-ES-20`) | no |
| `ETU EHU BTU …` | 205/745/753 — ET/EH/BT hybrids | no |
| `SDT DST MSU NSU …` | PV-only inverters — no battery to control | no |
A "-20" or "G2" suffix means a different platform with a different
A "-20" or "G2" suffix means a different platform with a different meter
protocol. The tags come from the `goodwe` library's `PLATFORM_105_MODELS`, which
is also what Home Assistant's own integration uses to pick a protocol.
- GoodWe **ES / BP family** inverters (AA55 generation)
- GoodWe **ES / BP family** inverters (AA55 / RS485 meter-bus generation)
- The protocol is **reverse-engineered**. There is no vendor contract, and a
firmware change on GoodWe's side could break every installation at once. Say
so when you sell it.
-64
View File
@@ -1,64 +0,0 @@
# STYLE.md - how user-facing text is written (UX-08)
This applies to everything a home owner or installer reads: notifications, phone pushes, the add-on page, refusals, add-on log lines at WARNING and above, and the names and descriptions of options. It is enforced by `goodwe_controller/test_messages.py`. Its allowlist is empty since phase 2d and its cap is 0: a new string that breaks the rule fails the test.
## 1. Shape
- **Title**: at most 5 everyday words. It says what is wrong ("Meter reading may be stuck"). No "GoodWe:" prefix, no full stop.
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
- **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words in `app/ui_text/<lang>.json` under `home_words`, built by `app/homeview.py`, checked by `test_messages.py` part 8. The page's own words, English and Dutch, are checked by `test_ui_text.py`.
- **Log line** (WARNING/ERROR): one line of at most 25 words **as logged**, with any built text filled in. Log lines stay in English and are not translated. A line that reports a safety reason gives one plain sentence and the reason's code (`STALE_READING`, `OUTPUT_CLAMP`), the evidence an installer searches for. The reason itself, with its figures and limits, goes on a DEBUG line right after it and into the action log, never onto the WARNING/ERROR line. It keeps the shape of a red alarm: at most 3 sentences of at most 30 words. The line as logged has no semicolon, banned word, capitals, "1 times" or "(s)". `test_messages.py` part 6 builds every such text with the real code and checks it, and lists the few non-safety families still over 25 words. A line that repeats every cycle is DEBUG. INFO lines carry no section numbers, ISO timestamps or capitals.
- **Refusal length is counted as shown**: a setting placeholder counts as its label and key, and the long cases are driven through the validator (`test_messages.py` part 3).
- **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown on the page. Installers read it as `events` in the `status` JSON.
- **Sentences**: one idea each, active voice, present tense, with nothing nested in brackets and no semicolons (a semicolon is a second sentence in disguise). These translate well into Dutch and French.
## 2. Numbers
Use a number only if it helps the reader act, for example the peak limit, or what was asked and what was sent in a fault report. Write it the way `app/messages.py` does: `5 000 W`, `20 %`, `10 minutes`, `14:05`.
## 3. Severity
Severity is shown by the title and the colour, never by CAPITALS or by words like "BUG SIGNAL".
| Level | Colour | When |
|---|---|---|
| alarm | red | Control stopped, money or energy lost now, a fault, or a person must act now. Up to 3 sentences. |
| warning | orange | Control is still running, but something needs a look. 1 sentence. |
| info | grey | No action needed. 1 sentence. |
| resolved | green | An earlier alarm or warning has cleared. 1 sentence. |
A rewrite never changes an alarm's severity, `notification_id` or cadence.
## 4. Words - the same word for the same thing everywhere
A setting is named by its label in `translations/en.yaml`, in quotes when it sits mid-sentence (“Inverter rating”). The words below match those labels. If a label changes, change it here and in `app/messages.py` in the same change.
| Say | Not |
|---|---|
| the add-on (who decides), control (what it does: "start control", "Control: last ran …") | controller, loop, planner |
| battery | pack, BMS pack |
| inverter, or its name | unit, device, actuator |
| meter | P1 feed, telegram |
| peak limit / this month's peak | ceiling, capaciteitstarief, billed quarter |
| 15-minute period | quarter |
| battery level (setting: “Battery level entity”) | SoC, state of charge, charge level |
| battery cut-out level, or cut-out level next to "battery" (setting: “Battery cut-out level”) | BMS floor, `bms_soc_floor` |
| peak reserve (setting: “Peak reserve”) | `peak_soc_floor`, EMS floor, "peak guard will not discharge below" |
| “Inverter rating” (the setting) | `max_w`, rail, inverter rating as a fact about the hardware |
| solar production, solar entity (settings: “Solar production entity”, “Solar production entities, per inverter”, “Reversed-sign solar entities”) | PV, reversed-sign list, single sensor setting |
| “Where the meter reading comes from” | meter source |
| “Grid power entity”; entity for any Home Assistant sensor a setting points at | grid power sensor, sensor setting |
| draw the battery down, give power | discharge, discharging |
| control is stopped / every inverter is set to 0 W | failsafe, trip, latch, parked |
| safety stop (a stop that only starting control clears) | failsafe latch |
| software fault | BUG SIGNAL |
| control board | master, ESP32 |
| power request, what the add-on asks | command, setpoint (on the page) |
| the power went the wrong way | sign disagreement, command and meter point opposite ways |
| check for inverters working against each other | circulating power check |
| month peak, 15-minute periods measured | billed quarter, banked, soft limit, open block |
| shadow mode | a mode that makes every decision but commands 0 W. The name is kept. In shadow mode a text says what the add-on **would** send, never that it sent it. |
**Banned in titles and bodies** (the test fails on them): SAFETY-/TARIFF-/TEL-/CFG-/FW-/HW-nn and other card ids, integrator, anti-windup, windup, clamp, clamped, saturated, BUG SIGNAL, latch, latched, arbiter, allocator, register, `0x…`, layer 1/2/3, heartbeat, watchdog, telegram, ACK, CRC, OTA, Modbus, planner, `§n`, file names, IP addresses, option keys in snake_case, **HA entity ids**, and runs of 2 or more CAPITALISED words.
HA entity ids are allowed only in refusals and validator messages, where the installer has to find that entity. They are never allowed in alarms or status rows.
**Glossary.** DOCS.md "Words used on this page" explains, once, the words a home owner meets without context: control board, isolator, Developer Tools, shadow mode, safety stop, peak limit, peak guard, peak reserve and cut-out level. A home-owner text may use them without explaining them again. Card ids stay out of DOCS.md headings: they go on a *For installers* line under the heading.
Binary file not shown.
-116
View File
@@ -1,116 +0,0 @@
# Releasing — how a change reaches the battery
`master` is the **default branch**, and the default branch is what the Home
Assistant Supervisor clones when it installs or updates this add-on. Whatever
sits on `master` is what runs on a real 5 kW inverter. Treat a push to `master`
as a deployment, because that is what it is.
---
## 1. `master` is protected — releases go through a pull request
Until 2026-09-23 `master` had **no protection of any kind**. Every gate this
repo built (CI-01, CI-9) sat on `release/1.0`, the branch that does *not* ship.
On 2026-09-22 version 0.10.1 went straight onto `master` — no pull request, no
CI run, no reviewer — and it succeeded (commit `0f14c2a`, the only commit on
`master` that carries no `(#NN)` merge suffix). SEC-2 closed that.
**The release path is now:**
1. Merge the work into `release/1.0` as usual, via a pull request that goes
green. Bump `version:` in `goodwe_controller/config.yaml` on that branch —
the Supervisor keys the built image by `version:`, so without a bump nothing
is offered and nothing changes.
2. Open a pull request **`release/1.0` → `master`**. Do not push to `master`;
it will be refused by the pre-receive hook:
```
remote: error: Not allowed to push to protected branch master
! [remote rejected] ... (pre-receive hook declined)
```
3. Wait for `checks / unit (pull_request)` to go **green** on that pull request.
The workflow in `.gitea/workflows/checks.yml` carries **no `branches:`
filter**, so a `master` base matches and a real run happens — verified on
throwaway PR #107 (base `master`), which queued a genuine run.
⚠️ Green means green. A `skipped` status is laundered into a `success`
rollup by this Gitea and the merge gate accepts it — see the long note at
the top of `.gitea/workflows/checks.yml`. Never add an event filter there.
4. Merge the pull request. `required_approvals` is **0**, so a green check is
the whole gate; there is no second pair of eyes unless someone asks for one.
5. **Reload the store.** Merging to `master` does not, by itself, offer the
update. The Supervisor only sees the new version after:
```bash
ssh root@192.168.2.158 "ha store reload && ha addons update local_goodwe_controller"
```
Until `ha store reload` runs, the add-on page still shows the old version
and the release looks like it did nothing.
Note that `release/1.0` is normally an *ancestor* of `master` between releases,
so a `release/1.0 → master` pull request is empty until new commits land on
`release/1.0`. That is expected; it means there is nothing to release yet.
---
## 2. Branch protection — before and after SEC-2
Forge is Gitea at `192.168.2.147:3000`, repo `admin/goodwe-addon`. Read it back
with `GET /api/v1/repos/admin/goodwe-addon/branch_protections`.
**Before (2026-09-23):**
| branch | protected | status check required | context | approvals |
|---|---|---|---|---|
| `master` (default) | **no rule at all** | — | — | — |
| `release/1.0` | yes | yes | `checks / unit (pull_request)` | 0 |
**After:**
| branch | protected | status check required | context | approvals |
|---|---|---|---|---|
| `master` (default) | yes | yes | `checks / unit (pull_request)` | 0 |
| `release/1.0` | yes (unchanged) | yes | `checks / unit (pull_request)` | 0 |
Both rules are otherwise identical: `enable_push: false`,
`enable_force_push: false`, `block_admin_merge_override: true`, no whitelists,
no file patterns. `required_approvals` stays at **0** on purpose — raising it
would block the review agent from merging, and nobody asked for it.
The default branch was **not** changed. Changing it would change what the
Supervisor clones, which is a different decision from this one.
### How the gate was proven to actually bite
A protection rule on this forge can *appear* satisfied while having checked
nothing, so the rule was tested rather than trusted. The discriminator is a
merge `POST` carrying a deliberately wrong `head_commit_id`: Gitea evaluates
branch protection *before* it checks the head, so
* `409 head out of date` → the request **cleared** protection;
* `405 Not all required status checks successful` → protection **refused** it.
Read the *message*, not just the code: `405` is also returned as
`Please try again later` for a pull request with **no commits**, which has
nothing to do with protection. An empty pull request on an unprotected base
gives that same `405` (verified, throwaway PR #110), so an empty
`release/1.0 → master` reports it too.
Validated against a control on an unprotected base (PR #106), which answered
`409` while its check was still pending — i.e. protection was no obstacle
there. On `master` (PR #107) the same probe answered `405` while pending, and
`409` once the check was genuinely green. A direct `git push` to `master` was
rejected by the pre-receive hook with `master`'s SHA unmoved.
---
## 3. Rolling back
There is no force-push escape hatch: `enable_force_push` is `false` on both
branches. To undo a bad release, land a revert on `release/1.0`, bump the
version again, and take it through §1. The `/addons/` rollback for the
reference install is in `HANDOVER.md`.
+8 -8
View File
@@ -95,7 +95,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="page" id="p1">
<div class="hdr">
<div><h1>GoodWe RS485 Controller</h1>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0</div></div>
<small>Installer copy</small>
</div>
@@ -154,7 +154,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table style="margin-bottom:2mm">
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
<td><b>SUPPORTED</b></td></tr>
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
@@ -162,7 +162,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
</table>
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
A “-20” or “G2” suffix in the model name means a different platform with a different
A “-20” or “G2” suffix in the model name means a different platform with a different meter
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
</div>
@@ -208,13 +208,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<!-- ============ PAGE 3 ============ -->
<div class="page" id="p3">
<h2>Steps 1–3 — Hardware</h2>
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
is low-voltage but sits inside an energised installation.</div>
<div class="step">
<h3><span class="num">1</span> Survey and record</h3>
<ol class="do">
<li><b>Photograph</b> the EMS-port wiring before touching it.</li>
<li><b>Photograph</b> the meter-port wiring before touching it.</li>
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
</ol>
@@ -226,7 +226,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<ol class="do">
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
inverter display).</li>
<li>Disconnect the vendor controller from the inverter's EMS port.</li>
<li>Disconnect the vendor controller from the meter port.</li>
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
you a rollback story.</li>
</ol>
@@ -237,7 +237,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="step">
<h3><span class="num">3</span> Fit and power the T-CAN485</h3>
<ol class="do">
<li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li>
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li>
<li>Power the T-CAN485 <b>last</b>.</li>
</ol>
@@ -503,7 +503,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table>
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code>&nbsp;/&nbsp;9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
+8 -8
View File
@@ -60,7 +60,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="page" id="p1">
<div class="hdr">
<div><h1>GoodWe RS485 Controller</h1>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0</div></div>
<small>Installer copy</small>
</div>
@@ -119,7 +119,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table style="margin-bottom:2mm">
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
<td><b>SUPPORTED</b></td></tr>
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
@@ -127,7 +127,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
</table>
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
A “-20” or “G2” suffix in the model name means a different platform with a different
A “-20” or “G2” suffix in the model name means a different platform with a different meter
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
</div>
@@ -173,13 +173,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<!-- ============ PAGE 3 ============ -->
<div class="page" id="p3">
<h2>Steps 1–3 — Hardware</h2>
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
is low-voltage but sits inside an energised installation.</div>
<div class="step">
<h3><span class="num">1</span> Survey and record</h3>
<ol class="do">
<li><b>Photograph</b> the EMS-port wiring before touching it.</li>
<li><b>Photograph</b> the meter-port wiring before touching it.</li>
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
</ol>
@@ -191,7 +191,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<ol class="do">
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
inverter display).</li>
<li>Disconnect the vendor controller from the inverter's EMS port.</li>
<li>Disconnect the vendor controller from the meter port.</li>
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
you a rollback story.</li>
</ol>
@@ -202,7 +202,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<div class="step">
<h3><span class="num">3</span> Fit and power the T-CAN485</h3>
<ol class="do">
<li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li>
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li>
<li>Power the T-CAN485 <b>last</b>.</li>
</ol>
@@ -468,7 +468,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table>
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code>&nbsp;/&nbsp;9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
-9
View File
@@ -1,9 +0,0 @@
# UX-13 screenshots
Made from the real page by `goodwe_controller/tools/ux13_screenshots.sh`. Do not
edit them by hand: run the script again.
- `home-*.png`: Home, running, in Dutch, in shadow mode, at phone width, and in setup.
- `settings-*.png`: Settings, Simple and Installer, and the time-window editor with an overlap.
- `batteries*.png`: the Batteries screen with modes on, in Dutch, an Off battery helping the peak limit, a refusal at phone width, and modes off.
- `setup-step-1*.png`: Setup, step 1.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

File diff suppressed because it is too large Load Diff
@@ -1,213 +0,0 @@
# CFG-04 — how a second inverter is declared, and why that shape was measured
**Status: DECIDED 2026-09-04 — a list of objects, `inverters:`, with `[]` as
the shipped default.** The alternative on the table was the `static_profile`
precedent: a structured value carried as a JSON **string** in a `str?` option.
Both work. The list wins on three counts that were measured rather than argued,
and the empty default is not cosmetic — without it the upgrade breaks.
Filed with CFG-04. Card 6a99fdf9d381935eddce4467.
---
## 1. The question, and why it could not be answered from the documentation
`config.yaml` used `list(off|ha_dsmr|…)` for enums and `str?` for free text, and
**there was no list-of-objects anywhere in the file**. `static_profile`
(`options:` `""`, `schema:` `str?`) carries a whole tariff structure as one JSON
line, and its own comment says why: *"a JSON string rather than a nested schema,
deliberately … it survives config.yaml's flat options block"*. That comment is
evidence that a nested schema was **doubted**, not that it was tried.
So the question was not "is this expressible in YAML" but **"does the Supervisor
this site actually runs accept it"** — and the Supervisor pins the options schema
to the *installed* version (`docs/pickup.md`), so guessing wrong is not a
compile error, it is an operator whose add-on will not start after an update
(`docs/deploy-rollback.md` §4, in the forward direction).
## 2. How it was answered
`tools/supervisor_shape_probe.py`, committed beside this record, runs
**Supervisor's own `AppOptions` validator and its own `SCHEMA_APP_CONFIG`** out
of the release image — `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`
— against **this repository's `config.yaml`**. 2026.08.0 is the version the live
install reports (`supervisor_version` in its own backup metadata, 2026-09-03,
alongside Home Assistant 2026.8.3).
Nothing was re-implemented: if the Supervisor changes its rules, the script
changes its answer. Nothing was touched, either — it reads the file and runs a
validator in a throwaway container. It does **not** talk to the live Supervisor
and does not POST options, because the schema conflict is the one thing you do
not want to discover on a running site.
### What it printed, 2026-09-04
⚠️ **This transcript was RE-RUN after review, against the same image tag, and
it is not the one this record first carried.** The first §B validated the
reconstructed live blob — a blob missing many required keys that have nothing
to do with this card — and quoted its refusal as evidence about `inverters`.
It never was: that refusal names whichever unrelated key the validator reaches
first, and it has printed `meter_freeze_s`, `p1_phase_export_entities` and
`sign_disagree_w` on three different runs. §B is now the isolated
counterfactual — the shipped `options:` block, and the same block with
`inverters` and nothing else removed — and it asserts on the KEY NAME in the
refusal. The mis-cited half is kept, relabelled, as §B2. See §4.
```
App 'GoodWe RS485 Controller' uses deprecated 'arch' values: ['armv7']
Option 'inverters' does not exist in the schema for GoodWe RS485 Controller (local_goodwe_controller)
== A. the whole config.yaml through SCHEMA_APP_CONFIG ==
PASS Supervisor accepts the add-on manifest, version 0.6.0
schema declares 66 keys; inverters -> [{"name": "str", "setpoint_entity": "str", "max_w": "int(100,5000)", "soc_entity": "str?", "batt_entity": "str?", "batt_invert": "bool?"}]
options ship inverters -> []
== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
PASS the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed
PASS ...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
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
== B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version ==
PASS 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
Missing option 'sign_disagree_w' in root in GoodWe RS485 Controller (local_goodwe_controller)
PASS the same blob MERGED with these defaults - what App.options actually is
inverters=[] setpoint_entity='number.kelder_goodwe_master_stage1_goodwe_setpoint_w' max_w=5000
== C. AC2: a two-inverter blob an operator would POST ==
PASS two 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}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_soc", "batt_entity": "s
== D. what the schema itself refuses, per entry ==
PASS an entry with no name is refused
Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with no setpoint_entity is refused
Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with no max_w is refused
Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with max_w over the range is refused
value must be at most 5000.0
PASS a JSON STRING where the list belongs is refused
Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller)
== E. the ROLLBACK direction, and the operator's UI ==
PASS a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse
['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert']
PASS 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
[{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer
== all cases passed ==
```
*(`arch: armv7` deprecation is pre-existing and unrelated.)*
## 3. What that establishes, item by item
| Claim | Evidence |
|---|---|
| A list of objects is a legal `schema:` element | §A — the whole manifest passes `SCHEMA_APP_CONFIG`, whose `SCHEMA_ELEMENT` explicitly permits `[{str: Self}]` |
| Per-entry types are enforced | §D — `max_w: 9999` refused with *"value must be at most 5000.0"* |
| A missing required key is refused **by name** | §D — *"Missing option 'name' in inverters"* |
| A two-inverter blob validates | §C |
| The operator can edit it in the UI | §E — `UiOptions` renders `{"type": "schema", "multiple": true}` |
| The `inverters: []` default is what makes a pre-0.6.0 blob validate | §B — the shipped defaults **minus only that key** are refused with *"Missing option 'inverters' in root"*; unmodified they pass |
| A downgrade drops the key rather than refusing | §E — a schema without `inverters` **drops the key with a warning**; it is not the §B trap in reverse |
## 4. ⚠️ The one that decided the default: `inverters: []`
§B is the load-bearing measurement, and it is an **isolated counterfactual**:
exactly one key differs between its two runs. `supervisor/apps/options.py`'s
`_check_missing_options()` treats a nested list as **required** unless its
element is an optional scalar — a list whose element is a dict is never
optional. So the shipped `options:` block validates as it stands, and the same
block with **only** `inverters` removed is answered with:
```
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
```
The live install's stored options have no such key, because it did not exist
when they were written. If `config.yaml` declared `inverters:` in `schema:` and
**not** in `options:`, then on update `App.test_update_schema()` would log *"new
schema is not compatible"* and the operator would be left with an add-on that
will not start — and the schema they would need to fix it with is pinned to the
version they cannot install.
What makes it safe is that `App.options` is
`deepmerge(config.yaml's options:, the operator's persisted options)`, so a
shipped `[]` fills the gap before validation ever runs. §B shows the halves that
carry the claim — the defaults **minus this one key refused, by this key's
name**, and the unmodified defaults **accepted** — and §B2 shows the merge doing
its job on a reconstructed pre-0.6.0 blob.
⚠️ **What §B2 does NOT show, and what this record used to claim it did.** §B2's
`LIVE` dict is an incomplete reconstruction: it carries the keys `HANDOVER.md`
and `docs/deploy-rollback.md` record and no others, so it is missing many
required keys unrelated to this card. Its RAW half is therefore refused for
whichever of *those* the validator reaches first — three separate runs have
printed `meter_freeze_s`, `p1_phase_export_entities` and `sign_disagree_w` —
and the earlier version of this section quoted that refusal as if it named
`inverters`. It never did. The string above was produced by §B, where the
`inverters` key is the only thing that varies.
The probe's assertion on that line is on the **key name in the refusal**, not on
"a refusal happened". With the weaker assertion §B would have gone on printing
PASS after someone deleted `inverters: []` from `config.yaml` — blind to the one
regression it is the committed evidence for. It is now checked both ways: with
the default deleted the probe prints `2 CASE(S) FAILED` and exits 1; restored,
it prints `all cases passed` and exits 0.
The merge strategy was checked too, because it is the other way this could have
gone wrong: `_OPTIONS_MERGER` is `type_strategies=[(dict, ["merge"])]` with an
`override` fallback, so **lists override rather than concatenate**. A shipped
one-element default plus an operator's two-element list would otherwise have
produced three entries — with inverter A declared twice, aimed at one actuator,
which is exactly the hazard SAFETY-28 refuses. `[]` makes that unreachable and
the override rule means even a non-empty default could not have grown a roster.
## 5. Why the list, and not the `str?` + JSON precedent
The JSON string works — §C of the earlier draft of this probe validated the same
roster as a `str?` — and if the list had failed it is what would have shipped.
It loses on three things:
1. **SAFETY-28's writer census stops working.** `actuator_writers()` walks the
options **tree** looking for `setpoint_entity` at any depth, and its own
docstring anticipates this card: *"Add CFG-04's per-inverter config — a list
of inverters, each with its own `setpoint_entity` — and there are two real
writers"*. Inside a JSON **string** those entities are invisible to it, so
the §1.5 two-writers guarantee would have had to be re-implemented against a
parsed copy — a second place for the census to be wrong.
2. **No per-field validation.** With `str?` the Supervisor sees one opaque
string: a typo'd rating, a missing name and a malformed entity id all arrive
at the add-on identically, and every error message has to be written here.
§D shows the Supervisor doing that work, in its own words, before the add-on
starts.
3. **No UI.** §E renders a repeatable section; a `str?` renders a text box into
which an operator hand-types JSON. `static_profile` accepts that cost because
§14 gives it exactly one key and its structure is a tariff document; a roster
is a list of devices an operator adds one at a time.
## 6. What is deliberately NOT in the shape
- **No per-entry defaults.** `name`, `setpoint_entity` and `max_w` are
**required**; the sensors are optional and their absence means *this unit has
no such sensor*, never *use the other one's*. CFG-03 keeps defaults in exactly
one place, and a per-entry default would be a second one — worse, it would be
a per-entry default whose value is another inverter's measurement, which is
the sibling-grading defect DEV-02 spent a card removing.
- **No `gain`, `slew_w`, `integrator_max_w` per entry.** §6.2 and ALLOC-01 are
ONE integrator on P1 above the split. `app/inverter.py`'s module docstring
carries that argument; the config must not make a second one expressible.
- **No `inverters` entry for the flat keys.** They ARE the single-inverter
shape. Declaring both is refused (rule 9e) rather than merged, because a
config that silently becomes a different config is worse than one that
refuses to load.
## 7. Revisit triggers
- **A Supervisor upgrade that changes `AppOptions`.** Re-run the probe with the
new version tag. The whole point of committing it is that this costs a minute.
- **SPIKE-04 measuring inverter B.** It supplies a real `max_w` for the second
entry. It does not change the shape.
- **A second `Maintenance` FSM.** `validate.py` rule 6 grades
`maintenance_charge_w` against the first entry of `unit_caps_indexed()`
because `Controller.maint_unit` is `inverters[0]`. When that stops being true, that line moves with it.
@@ -1,109 +0,0 @@
# FCST-03 - the hours the feed has not published, and what may not rest on them
**Status: SHIPPED.** Tests: `test_price_forecast.py`, `test_price_history.py`.
Written down by MAINT-20 from the long module comment of `app/price_forecast.py`.
Follows specs.md 8.3: "Day-ahead prices arrive once daily. Until they do, unknown
hours use yesterday's profile and are flagged `provisional: true`, and no
irreversible action may be taken on a provisional price. Real-time / imbalance
products are treated as high-volatility: arbitrage thresholds widen." This module
owns the first two; the widening is in price.py (`Uncertainty`, `widen_factor`,
`required_spread`), spent by `static_tariff.TariffPlan.arbitrage()` as one more
refusal on its existing ladder. Persistence of yesterday: [[FCST-04]].
## The flag is never passed through
A carried point and a published one are the same three floats and a bool.
`PricePoint(..., provisional=y.provisional)` copies yesterday's `False` onto
today's guess: stale data presented as current (SAFETY-18's defect one layer up).
So:
- `_carried()` has no parameter for the flag; `provisional=True` is a literal in
one place.
- A published point is passed through by identity (`filled.points[i] is
fetched.points[j]`, checked by the test), never copied.
- `_audit()` re-derives the expected flag for every emitted point from the hours
the feed published and raises on a mismatch. It never corrects (a module that
quietly fixes its own provenance cannot be trusted).
- test_price_forecast.py walks this file's AST and fails if any `PricePoint(...)`
passes anything but a literal for `provisional`.
"Published" does not mean "firm": 9.3 carries `provisional` per point because a
real-time product's current-interval print is published AND provisional, so the
audit's rule for a published point is "whatever the feed said". No shipped
plugin emits one since [[TARIFF-07]]; the rule is a property of the format and
weakening it would let the first imbalance feed arrive mispriced and flagged firm.
## The audit is a provenance check, not a completeness check (FCST-05)
Rules: a PUBLISHED point must be one of `published[u]` by identity and be
emitted once, with the feed's own flag; a CARRIED point's hour must not be in
`published` at all, flag exactly True, and no second point in that hour. This
replaced FCST-03's "at most one point per hour" count, which cannot express a
quarter-hourly feed. FCST-04 claimed two strengthenings; both were false: a
differential over every published mask on 3 hours crossed with every output of
length 0-3 found zero divergence either way (OLD raised/NEW accepted 0, OLD
accepted/NEW raised 0). The one real change is the intended relaxation (several
distinct published points per hour).
The audit does not check completeness: fed the decimation defect (four points in
an hour, only the last emitted), two of four, or an empty output, it accepts.
What stops decimation is the point COUNT and IDENTITY checks in the two test
files. It cannot be made complete from its arguments: `published` is over the
whole unwindowed read while the fill emits `[since, until)`, so a published point
outside the window is legitimately absent. FCST-05 built "every published point
is emitted" (+162 B) and it took the 1 h, 12 h and 48 h horizon checks red on a
healthy feed (a real change: the differential scored it 49 cases stronger),
so the rule as stated is wrong, and
asserting a completeness the instrument lacks is MUTATION.md habit 16's defect.
## The fill keys on the wall clock
For each whole hour of the horizon the feed has not published, take yesterday's
value for the SAME BRUSSELS WALL-CLOCK HOUR, not `t - 86400 s`: a 23-hour day has
no 02:00 and a 25-hour day has two, so a fixed 24 h shifts every carried hour
across a transition. On a 25-hour day both 02:00s are filled from yesterday's
single 02:00 (both guesses, both flagged); if yesterday was a 25-hour day, the
first of its two 02:00 points is used. An hour with no published price and no
yesterday value is left out, not filled with a neighbour, mean or zero: 11.4
applies (`Absence`, then the static profile). If nothing can be produced the
result is `no_data`, not an empty series.
A sub-hourly feed is not decimated: the hourly walk decides which hours need
filling, never what a published hour may hold. Belgian day-ahead settles per
quarter since 2025-10-01 (96 points a day), so every published point in the
window is passed through and a carried one is added only for an hour with no
published coverage. `{_utc_hour(p.start): p for p in r.points}` reads ordinary
and keeps the last point of each hour; FCST-04's first caller shipped it, the
series came out 15 points long, the spread fell from 0.143 to 0.014 EUR/kWh and
the cheapest "hour" became a `:45` point. `_published_by_hour()` holds a tuple
per hour for that reason.
The window is whole hours at both ends: `since` floored (the current hour is
still tradeable) and `until` floored too; with `until` compared raw a 48 h horizon
asked at 08:17 emitted 49 hours. `yesterday` has no default (a missing yesterday
and a useless one must not look alike). The fill is idempotent and re-runnable
(5.4 late publication, 14-16 h CET): published hours win, and the series is
rebuilt, not patched. TARIFF-11 log line: carried HOURS, total hours and
published POINTS are three different quantities (a quarter-hourly feed made it
read "33 of 141 hours" for 48 h).
## AC2: the irreversible-action gate
An irreversible action is a value, `Irreversible`, carrying the hours it commits.
`price_permits()` looks at exactly those hours and answers with a `Gate` carrying
evidence on the permitting arm and a reason on the blocking one (the `_Outcome`
shape). It blocks when the read observed nothing (11.4), an hour has no price, or
an hour is provisional, and names the hour. The maintenance drain's window is
derived from `maintenance.DRAIN_TIMEOUT_MIN` / `CHARGE_TIMEOUT_MIN`, so the gate
and the phase machine cannot drift. `_window_hours()` is separate so the gate and
its caller agree on "the hours it commits".
What is NOT gated matters more: the maintenance cycle is a calendar obligation
(12.2: a cycle that silently never fires looks like one not yet due), and a site
with `price_provider: off` (the default) has no series. Routing every start
through a gate that blocks on `no_data` would stop the monthly cycle on every
default install. So `begin_phase()` is the entry point for a start JUSTIFIED BY A
PRICE; main.py's calendar path is untouched, and OPT-04 / MAINT-05 own which a
site runs. Reversible actions (self-consumption modulation, the loop) are not
gated: 6 says absent machinery must not disable the loop. Read this before wiring
`begin_phase()` in.
@@ -1,251 +0,0 @@
# FCST-04 — what "yesterday" is, and what the fill is allowed to reach
Four decisions, recorded because each had a defensible alternative that a later
reader would otherwise re-open from scratch.
---
## 1. The fill runs on a `series` read only. A dead feed still goes to §11.4.
`fill_from_yesterday()` accepts a `no_data` read and will happily build a
100 %-carried series from yesterday alone. `resolve_tariff()` does not let it:
a feed that published nothing still falls to `_degrade()` and §11.4's own row —
*"No price data → fall back to the static profile, mark the plan provisional,
no arbitrage."*
**Why not prefer yesterday's real prices to a coarse day/night profile?**
Because it buys nothing the optimiser can use, and it silently replaces a
fallback the operator configured.
Measured, not assumed — and stated at the reach it actually has:
- **This one reaches control.** A fully-carried series is 100 % provisional, so
`TariffPlan.arbitrage()` finds fewer than two firm hours and refuses on
§8.3's firm-pair rung. That is the verdict the optimiser reads: `arbitrage.py`
calls `plan.arbitrage(...)` on `fresh_tariff_plan()`, and it is the only path
by which a provider plan moves energy. The §11.4 static fallback refuses on
the same rung, for the same reason.
- **This one does not, and saying so is the point.** `TariffPlan.cheap_now()`
also returns False on a fully-carried series, because `firm_points()` is
empty — but it has **no production caller on the provider plan**.
`self.cheap` comes from `static_status()`, which builds its *own* static plan
and calls `cheap_now()` on that. So it is a property of the type, checked in
`test_static_tariff.py`, and it is not evidence about the shipped control
path. The conclusion below holds on the first bullet alone.
The two paths therefore differ only in which numbers the status page shows.
So the choice is between two identical control behaviours, and the tie-breaker
is that `static_profile` is a documented option an operator fills in **for this
case**. Overriding it for a cosmetic difference is a behaviour change nobody
asked for.
⚠️ **The consequence, stated so it is not discovered later as a bug:**
`fill_from_yesterday()`'s `no_data` branch is not reached from production.
It stays checked in `test_price_forecast.py`. Turning it on is a product
decision — it would want a rule about how old a carried profile may be before it
stops being offered at all — and not a wiring one.
---
## 2. "Yesterday" is a date, not the newest record.
`newest()` is the obvious accessor and it is the one that decays. An add-on down
from Friday to Monday would answer Friday's prices to a fill whose only
vocabulary is `provisional: true` — a flag that says "this is a guess", never
"this guess is three days old". Nothing downstream can tell the difference and
nothing reports it.
So `yesterday(stamp)` computes the Brussels date before `stamp`'s Brussels date
and looks up **that** record. Anything else is `no_data` with a reason naming
both the date wanted and the dates held.
The cost is real and accepted: after an outage of more than a day, the first
morning back has no fill. That is the honest state — there is no yesterday —
and the horizon is short for one morning rather than wrong for several.
---
## 3. What is recorded is the provider's read, and the mechanism is ordering.
Feeding the filled series back into the store is how this rots: day 2 carries
day 1's guesses, day 3 carries day 2's carry of them, and after a week of a dead
feed the "yesterday" being carried is a fossil of the last real publication.
Every point still carries `provisional=True`, so the **flag never lies** — what
decays is the number, behind a flag that is telling the truth.
⚠️ **No value-level check can catch that**, and claiming one would be worse than
claiming none. A carried point and a published-provisional point (a real-time
product's current interval, §9.2) are the same three fields carrying the same
flag. The
difference is provenance, and provenance is not in the value.
The defence is therefore an **ordering**, not a validation:
- `carry()` is the only public entry point. It records its argument, then
returns the fill.
- The filled series exists only as that return value. The object never sees it
again.
- `_remember()` is private and called from exactly one place.
- `test_price_history.py` parses every shipped module under `app/`, `tools/`,
`scenarios/` and `estop/` and fails if any of them **uses the name**
`_remember` — an attribute, a bare name, a `def`, or the string constant a
`getattr` would need. By name rather than by substring, so a comment that
merely mentions the rule does not fail it and a call in `tools/` cannot pass
unseen.
- and it pins the order two ways: the **line numbers** of the two calls in
`carry()`, and — the one that matters — the **behaviour**. A feed carrying
yesterday alongside today, into an empty store, must fill the whole horizon;
reversed, the lookup finds nothing and the plan is only the hours the feed
published. Driven, so a reversal is red.
⚠️ **The first version of that ordering check graded nothing, and this file
said it did.** It compared positions in `ast.walk`, which is breadth-first:
`self._remember(...)` is a statement and `self.yesterday(...)` is an *argument*
of `fill_from_yesterday(...)`, one level deeper, so `_remember` came out first
whichever statement was written first. Measured — shipped and reversed both
scored the same. A decorative check that a document cites as a guarantee is
worse than no check, which is why the correction is recorded here rather than
quietly made.
That ordering has a second benefit worth keeping: a feed that returns yesterday
alongside today refreshes yesterday's record from live data *before* it is
consumed as the profile.
---
## What this ticket does not claim
It does not make a trade possible that yesterday's prices justify. Carried hours
are dropped from the trade set by §8.3, and that is not a limitation to be fixed
later — it is the rule.
The one change that **does** move energy came from the windowing, not the
carrying: the filled series covers `[since, until)`, so hours that have already
elapsed are no longer in the spread. A cheap night that has gone used to be the
series' cheapest hour, and a cycle cannot buy in an hour that is past — so the
trade was silently never taken. `test_price_history.py` section 2 grades that by
finding the cheapest firm hour with and without the fill and showing it moves.
⚠️ **And section 1's identity check now grades identity.** It used to compare
`eur_per_kwh` between `_before` and `_after`, which come from two *separate*
`resolve_tariff()` calls — each parsing the feed body into its own objects, so
`is` could never have held between them however the fill were written. FCST-03's
whole anti-copy argument is `filled.points[i] is fetched.points[j]`, and floats
cannot grade it. The value comparison is kept and now says what it is (the fill
moved the window, it did not touch a price); identity is graded on a read the
file constructs and still holds a reference to.
---
## 4. A sub-hourly feed passes through whole; the fill only decides which hours are missing.
⚠️ **This section originally said `eneco_eddy` is "the Belgian imbalance
product". That was false and [[TARIFF-07]] withdrew the plugin: Eneco's dynamic
product is "Zon & Wind Dynamisch" and is indexed to Belpex/EPEX **day-ahead**,
not to imbalance, and `api.eneco.be/eddy/prices` never existed. The mechanism
below is unaffected, because the real quarter-hourly feed is the one still
shipped.**
Belgian **day-ahead** settles per **quarter** — the 15-minute MTU took effect
on 2025-10-01, so `generic_dayahead` reads 96 points a day rather than 24 — and
`price.py`'s `_validate_raw()` accepts any strictly-increasing spacing, so a
published hour may legitimately hold four points.
FCST-04's first caller keyed the published set as `{_utc_hour(p.start): p for p
in r.points}`. It reads perfectly ordinary and it keeps the **last** point of
each hour. Measured on a 96-point quarter-hourly feed — all firm, fully
published, nothing to carry: the plan reached the optimiser as **15 points**,
the arbitrage spread fell from **0.143 to 0.014 EUR/kWh**, and the cheapest
"hour" became a `:45` point whose `at()` window runs into the next hour. That is
not the windowing effect above; it is a feed losing 84 % of its own prints, and
no `provisional` flag would ever have reported it.
**The alternative was rejected:** refusing sub-hourly feeds at `_validate_raw()`
would have made the count correct by breaking the one supported product that
legitimately publishes per quarter.
**What was done instead** keeps FCST-03's existing contract rather than weakening
it: *every* published point inside the window passes through **by identity**,
and a carried point is added only for a whole hour with **no** published
coverage at all. A carried hour is still a single hourly point whatever the
feed's own spacing is, because yesterday's profile is only known per wall-clock
hour — and carried hours are provisional, so they are out of the trade set
anyway.
⚠️ **`_audit()` got stricter, not looser, and that was the condition on doing
this at all.** The old post-condition refused any repeated `_utc_hour` — a count
that cannot express four points in one hour. It is replaced by two rules that
**subsume** it:
- a **published** point must *be* one of that hour's published points, by
identity, and may be emitted only once;
- a **carried** point may stand only in an hour that does not appear in the
published set at all, must be flagged `True`, and may not repeat.
So a duplicated carried hour — the DST mispricing the count existed to catch —
still raises; so does the same published point emitted twice; and a carried
point sharing an hour with a real print now raises too, which the count never
looked at. Each of those three is driven by its own mutation in
`test_price_forecast.py`.
## 5. Store details (moved here from `app/price_history.py` by MAINT-20)
**Why a file, and which one.** FCST-03 shipped the fill with no caller, so no
series ever contained a carried point. Without a yesterday that survives a
restart, the mornings after an operator touched something spend the whole
window on the truncated series. The cache is `/data/price_history.json`, not
`/data/state.json`: `Store.set()` rewrites the whole of state.json on each
write, and the refresh runs every `TARIFF_REFRESH_S` (1800 s), so 48 extra
rewrites a day would go through the file whose corruption reads as "no
maintenance ever ran". Separate file, separate blast radius. The directory is
not created (`tariff.PeakStore`'s rule): `/data` is always mounted, so a
`makedirs` could only succeed where the durable volume is missing and build a
cache on the ephemeral layer. "Cannot persist" is one question asked after the
attempt: see [[FCST-06]]; `self._days` never holds a point the file does not.
**Brussels date, not UTC.** 23:30 UTC in July is already tomorrow in Belgium.
`_yesterday_of()` subtracts on the local `date`, not the instant: the old
`brussels(stamp) - timedelta(days=1)` took 24 absolute hours and, at
2026-03-30T00:30+02:00, returned 2026-03-28 (a two-day-old profile flagged as
yesterday's). Rows are keyed by instant, not local hour, so the two 02:00s of
the 25-hour October day both survive and `fill_from_yesterday()`'s own rule is
the only collapsing rule. The merge key is the validated instant; the old
string key merged by spelling and sorted the `+01:00` 02:00 before the `+02:00`.
`KEEP_DAYS` is a sort-order cap, not a horizon: nothing is deleted by arithmetic
against the clock (a jumped clock, WITNESS-03, would otherwise delete everything
on the one morning the fill is for), and the two dates `yesterday()` can be
asked for are pinned on top of the cap, which can only retain.
**One validator (FCST-05, MEDIUM).** The read path was defensive about every
field and the write path trusted `q["start"]` to be hashable and sortable. An
int, null, dict or list `start` raised `TypeError` out of `carry()` on the day
being written; `run_tariff_refresh` caught it, so the add-on stayed up and every
refresh failed for good (the plan retires after `TARIFF_MAX_AGE_S`, arbitrage and
the cheap window go off, and `_remember()` raised before `_save()`, so no
self-heal). `_point_of()` is now the one standard for both paths and returns
`None` rather than raising.
**A broken cache is a short horizon, never a controller that will not start**
(6: absent machinery must not disable the loop). `PriceHistory(...)` and `.words`
are unguarded in `Controller.__init__`. Shipped defects, all fixed:
- `_load()` caught `json.JSONDecodeError` and `OSError` only; one non-UTF-8 byte
raised `UnicodeDecodeError` (a `ValueError`, not an `OSError`) and the add-on
did not start (FCST-05, HIGH). The handler names the `ValueError` family. The
schema version is compared with its type (`1.0 == 1` in Python).
- `{"days": {"2026-01-14": "oops"}}` loaded as healthy and raised
`AttributeError` out of `words`, `yesterday()` and `resolve_tariff()`.
`_usable_days()` drops such records, and counts (not drops) malformed rows
inside a kept record so the store stops reporting health it lacks.
- `words` printed `f"{d} ({_n_points(rec)} h)"`: points under the hour's name
(`'2026-01-13 (96 h)'` for a 24-hour quarter-hourly day, FCST-05 LOW). Both
numbers are printed now; only rows that are points are counted. `words` must
never raise, `_held()` is total.
- A failed merge is undone from a deep copy (a one-level copy shares the day
records the merge rebinds).
`NO_HISTORY` is a name, not `None`, so `history=` can be required: `None`
meaning "skip the fill" is how a caller silently gets the truncated horizon.
`carry()` records first, then fills, so the filled series exists only as a
return value that the store never sees again (rule 1).
@@ -1,125 +0,0 @@
# FCST-06 — "cannot persist" is one question, and it is asked about the outcome
Two decisions. The first is the one the ticket put up for judgement; the second
is the one that decides what a store answers *after* it has failed to write.
---
## 1. One derived predicate, not two places testing two symptoms — yes
`price_history.py`'s module docstring states, unconditionally:
> ⚠️ AND THAT RULE IS CARRIED OUT IN `_remember()`, NOT ONLY IN `_save()`. …
> A store that cannot persist now records nothing, and both of its answers say
> the same thing.
It was enforced against `not self.ok`, and `ok` is **about the directory**: it
says `/data` was there when the store opened. A read-only remount and a full
volume both leave it `True`. So the second way to be a store that cannot
persist — `ok=True`, the directory present, `_save()` failing — was outside the
one condition the rule named, and in that state `words` printed two full days
beside the write error while `yesterday()` answered 24 real points, from memory,
until a restart dropped them without a word.
**The ticket's question was whether "cannot persist" should be one derived
predicate rather than two places each testing a different symptom. It should,
and the reason is not tidiness — it is that the enumeration cannot be
completed.** FCST-04's M4 enforced the rule on the symptom that was in front of
it. FCST-05's HIGH was the same shape — an `except` clause naming the one
content-failure member somebody had met (`json.JSONDecodeError`) instead of the
`ValueError` family — and its fix was to name the family. This is the third instance in
one module, and it is the last place in this file where the rule can be spelt
twice. A fourth cause — an `OSError` subclass, a filesystem that accepts the
`mkstemp` and refuses the `os.replace`, a container whose volume is unmounted
mid-refresh — needs no fourth branch now, because none of them is asked about.
### Where the one question can be asked, which is the whole design
**"Can this store persist?" cannot be answered before the attempt.** A
directory that exists and was writable a moment ago is not a write that
completed; there is no pre-check that is not itself a guess, and adding one
(`os.access`, a probe write) would be a *third* symptom test — precisely what
the ticket rules out. So the predicate is an **outcome**:
_save() -> bool # True only if the file now holds what memory holds
Both ways of not persisting are the same answer from the same call: no durable
volume returns `False` without touching the disk, an `OSError` out of the write
returns `False` after recording why. `_remember()` reads that one answer and
keeps only what it says was kept. **The `not self.ok` test in `_remember()` is
gone** — not moved, gone — because there is nothing left for a second test to
add, and a check in `test_price_history.py` 5d asserts its absence from the
source.
⚠️ **That last check is deliberately a source check, and habit 4 is why it is
allowed to be.** The claim "there are not two places testing two symptoms" is a
claim about the *shape of the code*, not about a behaviour — the behaviour is
identical whether the rule is enforced once or twice. Every other check in 5d
is driven.
### What was NOT done, and why
- **Not a `can_persist()` helper.** It would have to answer before the attempt,
which is the thing that cannot be done honestly, and it would be a second
place to keep in step with the first.
- **Not a probe write at `_open()`.** It would make `ok` mean something new, it
would write to `/data` on every start, and a volume that is writable at 08:00
and full at 14:00 would still defeat it. A predicate that can go stale is a
symptom test wearing a predicate's name.
- **Not making a failed write fatal.** §6, and this module's own opening: a
broken cache must not be a broken controller. An add-on that will not start
because `/data` filled up is a worse defect than the one being fixed. The
store degrades; it does not raise. `_save()` reports and returns.
---
## 2. The MERGE is undone, not the store
When `_save()` answers no, `_remember()` restores the state the read started
from — **not** an empty one.
The alternative considered was to clear `self._days` outright, on the argument
that a store which cannot write is a store that should stop answering. It is
wrong, and measurably so: the state being restored is the last one that **is on
disk**, so answering it is not a promise the store cannot keep. A site whose
volume fills at 14:00 goes on answering the yesterday it wrote at 13:30 —
through the restart, because that record is in the file. What it stops doing is
*adding* to it.
Clearing would throw away a good yesterday that survives a restart in order to
punish a write that failed, and it would make the morning fill worse on exactly
the day the operator is already dealing with a full disk.
That gives the file the invariant its rule was always describing, stated once
in the class docstring:
> `self._days` **never holds a point the file does not.**
⚠️ Not *"mirrors the file"* — `_usable_days()` drops records the file still
holds, and that asymmetry is deliberate. The only direction that can **invent a
yesterday** is the one named.
⚠️ **And the snapshot is `copy.deepcopy`, not `dict(self._days)`.** A one-level
copy shares the day *records*, and the merge rebinds `points` on the ones it
touches — so the "restored" store would answer the merge that never reached the
file. Measured: with a shallow copy, a store holding 12 persisted points
answered **24** after a failed merge. It would also be correct only for as long
as nobody makes the merge mutate a `points` list or a row in place, which is the
same "correct for the conditions someone thought of" this ticket is about. The
store holds at most `KEEP_DAYS + 2` records and writes 48 times a day; a copy
that is right by construction is worth more than the allocations.
---
## What this does not change
- **No shipped path reaches the defect.** `/data` is the Supervisor's durable
volume and this module is its only writer, so on a real install `_save()`
succeeds and nothing above is on the hot path. The fix is that a guarantee the
module states about itself now holds in the condition it did not.
- **The failure was already visible and still is** — `_LOG.error("could not
persist price history: %s")` and `last_error`, reported by both `words` and
`yesterday()`'s reason. What changed is that it is now *acted on*.
- **Nothing about the prices.** The series served is the feed's own, every
carried point is still flagged provisional, and a broken cache is still a
short horizon rather than a §11.4 degradation.
@@ -1,367 +0,0 @@
# MAINT-05 — one recalibration per pack, one at a time, and what the card left open
**Status: DECIDED 2026-09-04.** There is one `Maintenance` per roster entry,
each with its own schedule, phase and history. The site holds exactly **one
maintenance slot**: at most one pack may be in a power-moving phase, the slot is
derived from the packs' own stored phases rather than persisted separately, and
when several packs are due in the same hour it goes to the one that has gone
**longest without a completed cycle**. The schedule is **not** staggered by
construction — the interlock defers, and the stagger emerges from it.
**Fix round 2026-09-04.** The store migration is now graded against the
**roster** rather than against the unit name it was handed — see §2's
*"The read-back postcondition is graded against the ROSTER"* — and the same
hole in SAFETY-02's latch migration is recorded in §7.
Filed with MAINT-05. Card `6a9a9e8503872905f298d80a`.
---
## 0. What the card asked to be decided, and the answers
| the card's open question | the decision |
|---|---|
| §2 the arbitration | **one pack at a time; the other stays under the loop.** Nothing preempts a cycle in flight. |
| §2 who wins when several are due | **the longest-waiting pack**, `last_completed` oldest first, `inf` for never-recalibrated. Roster order breaks a numeric tie and nothing else. |
| §4 staggering | **not by construction.** The interlock defers the second pack; the stagger is what comes out of it. §3 below. |
| AC 7 rule 6's `maint_unit` comment | **no, the first entry's cap is no longer right.** It grades against the weakest cap on the roster now, like rule 5. §4 below. |
| the store layout at N=1 | **flat, unchanged.** The namespace appears the day the roster grows. §2 below. |
## 1. Why the slot is derived and not persisted
The holder is *"whichever pack's stored phase is not `idle`"* — a fact already on
disk, written by the packs themselves in the per-pack history this card
introduces. A `maint_slot_holder` key would be a **second opinion about the same
thing**, and the two would disagree the first time the add-on was killed between
writing the phase and writing the holder. Derived, a controller that comes back
up mid-drain reads the drain and re-derives the holder from it; there is no
recovery path to get wrong because there is no second fact.
`CHARGE` is in `POWER_MOVING` even though the control **loop** owns the setpoint
in that phase, and that is the decision most likely to be "simplified" later.
Pack A charging from the grid at 2000 W while pack B drains 2000 W to export is
4 kW going round in a circle with the meter reading zero and both packs
believing they are behaving — the circulating power [[SAFETY-31]] exists to
detect, arriving as something this module can simply not do. `HOLD` is in the
tuple for the other half of the sentence: it moves nothing, and it **owns the
actuator**, so a second cycle starting under it would still be two
recalibrations at once.
## 2. Why the store layout at a roster of one does not change
The maintenance keys are flat — `phase`, `phase_started`, `last_completed`,
`last_start_attempt` — on every install since the add-on shipped. At one pack
there is nothing for a namespace to distinguish that pack **from**: §6.2's
identity, the same one `arbiter.actuator_for()` leans on when it routes every
claim to the site actuator at N=1. So `Maintenance` is constructed with
`unit=None` there, reads and writes the flat keys directly, and
`migrate_maint_keys()` **returns without writing anything**. A one-inverter state
file comes out of this card byte-identical, which is AC 6 and which
`test_maintenance.py` part 10 asserts by driving a whole cycle and comparing the
key set.
### The migration has its own marker, and that is not tidiness
`SCHEMA_VERSION` / `schema_version` is DEV-02's, and it is **monotone**: every
install runs that migration exactly once. This one runs **only where the roster
has more than one pack**, so a single counter could not express *"migrated on
this site, deliberately not migrated on that one"* without lying to the next
version that reads it — a one-inverter store would have to be pinned at the old
number forever, and a genuine future v2 migration could then never be told apart
from a site that simply never grew. `maint_schema_version` answers one question
and answers it honestly.
The **three rules are shared in code**, not restated: `_move_flat_keys()` is
rule 1 (never overwrite an existing namespaced value) and rule 2 (the flat keys
stay in the file), `_verify_moves()` is the read-back postcondition, and the
marker is written last. A second transcription of a migration is a second
opinion about what "it landed" means.
### The read-back postcondition is graded against the ROSTER (fix round)
**A postcondition that reads back the namespace it was handed cannot detect
being handed the wrong one.** That sentence is the whole finding, and this card
shipped it broken.
`_verify_moves()` iterated `rep.moved` — the keys it had just written — and
asked whether each read back equal. On a store where the write lands, it always
does. So a migration handed a `unit` that is on no roster wrote four keys, read
four keys back, set `verified = True`, and the caller stamped
`maint_schema_version`. Every symptom of a clean migration, and an install's
whole maintenance history parked under a name nothing will ever look up. And
because the marker was stamped, **the next start skipped the migration too** —
which is precisely the state `store.py`'s header says must never happen: a
failed migration a later version treats as done.
Reproduced on the reviewer's roster, a house with a garage pack and a shed pack,
carrying a flat `last_completed` of 2026-08-07:
| | `unit` the migration was handed | key written | pack 0 reads back | due now? |
|---|---|---|---|---|
| correct | `self.inverters[0].name` = `garage` | `unit/garage/last_completed` | `2026-08-07` | **False** |
| wrong | `inverter.UNIT_A` = `inverter-a` | `unit/inverter-a/last_completed` | `None` | **True** |
`build_roster()` names a declared roster's entries **from the config**, and its
fallback for a nameless entry is a **positional** `inverter-1` — so `inverter-a`
is not the name of anything on a declared roster, not even by default.
**The remedy, and why this one.** `migrate_maint_keys()` now takes the **roster**
in place of an `n_units` count (the count was `len(self.inverters)` at the only
call site, so the roster carries it, and a count passed beside the names is a
second opinion about how many packs there are). `_verify_moves()` grades the
namespace it wrote against those names, and a unit that is not among them is
reported as `misdirected` — a **different** failure from `lost`:
* `lost` is *the store took the write and dropped it* — a storage fault, and it
has a harmless-default case (a write of `last_completed: None` loses nothing,
because "absent" and "never recalibrated" are the same statement).
* `misdirected` is *the store did exactly as it was told, by a caller that was
wrong*. There is no harmless-default version of it: a write nobody reads is
not information, whatever its value. So anything in it fails `ok` on its own.
And **`ok` gates the marker**, so a misdirected migration is not stamped and the
next start runs it again. The argument for it is asymmetric: a re-run is
recoverable — rule 1 makes it a no-op where keys did land, and rule 2 left the
flat keys in the file to recover *from* — while a silent skip is not recoverable
at all, because the store now claims the migration happened.
### ⚠️ THE PARAGRAPH ABOVE USED TO END HERE, AND AS IT STOOD IT WAS FALSE
**The refusal lasted exactly one start. The second start stamped the marker.**
That is a correction to what the first fix round wrote down, not a new
qualification of it, and it is recorded here because a decision document that
states a guarantee the code does not provide is worse than one that says
nothing.
`_verify_moves()` graded only `rep.moved`. Measured at Controller level over
**one persisted store**, roster `["garage", "shed"]`, flat `last_completed`
2026-08-07, with the call site still handing `inverter-a` on **both** starts:
```
construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent> (withheld)
construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1 (STAMPED)
history parked under unit/inverter-a/…; marker stamped after ONE restart.
```
On start 2 the same wrong call site finds the four keys it wrote already
present, so **rule 1** — never overwrite a namespaced value — routes every one
of them to `rep.kept`. `rep.moved` is empty, the roster-grading loop iterates
over nothing, `misdirected` is empty, `ok` is True, the marker is stamped, and
start 3 takes the `from_version >= MAINT_SCHEMA_VERSION` early return **forever
— including after the call site is fixed**. Loud-and-recoverable became
silent-and-permanent after one restart: exactly the state `store.py`'s header
says must never happen, reached by the fix written to prevent it.
**The remedy is one line**, and it is sound by construction rather than by
argument: grade `set(rep.moved) | set(rep.kept)`. Every entry in *both* lists is
a `unit_key(unit, legacy)` built from the **same** `unit` argument, so grading
`kept` grades the same namespace decision `moved` does. A correct call site's
`kept` entries are under a roster name and pass; a wrong one cannot launder its
namespace by having already written it. `migrate_unit_keys()` passes
`roster=None` and returns before the loop, so it is unaffected.
**Why the check missed it, which is the part worth keeping.** Part 9b's recovery
check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` — the **corrected** name.
It measures recovery along the one path where the operator has *already fixed
the bug*. The real failure is a call site that is still wrong on the next start,
and nothing in the 19 files called `migrate_maint_keys()` twice with the **same
wrong** `unit`. The check was true, passed, and could not reach the failure its
sentence named — MUTATION.md's third defect, this time sitting inside the fix
written to close it. Part **9c** now drives two starts with the same wrong unit
over one store, asserts the marker is still withheld on the second, and asserts
the *shape* of the second report (`moved` empty, `kept` full) so it cannot
quietly revert to 9b's path.
### The marker gate is `rep.ok` and nothing weaker — both guards, each pinned
`if rep.ok:` is `not lost and not misdirected`, and until this round only the
`misdirected` half was reachable by a check that also had a non-empty `kept`.
The mutation
```
if rep.ok: -> if rep.ok or rep.kept:
```
stamps the marker on a migration that **lost a month of history** — defeating
the older, settled `lost` guard as well as the new one — and **all 19 suites
were green on it**. Part **9d** is the fixture that separates them: two keys
already namespaced (`kept` non-empty, so the mutant's `or` fires) and two keys
whose write the store drops (`lost` non-empty, so `ok` is False), with
`misdirected` empty throughout so the section cannot pass on the other guard by
accident. Measured differential on it: shipped → marker absent; mutant → marker
`1`, both reporting `ok=False lost=2 kept=2`.
**Why the fixtures had to change, and this is the part that got the card
rejected.** Nothing in the repository could see whether any of the above was
true, because `inverter.UNIT_A` **is** the string `"inverter-a"` and every
two-pack fixture in `test_maintenance.py` named its first roster entry
`inverter-a`. `self.inverters[0].name` and `UNIT_A` were therefore the same
value in every check that existed, and swapping one for the other at the call
site left **all 19 suites green**. MUTATION.md's third defect exactly — a check
whose *data* cannot reach the failure it names — and the fifth consecutive card
to carry it. Parts 9 and 11 now run on `["garage", "shed"]`, each behind a
**precondition check** asserting that the first name is not `UNIT_A`, so an edit
that makes them equal again goes red instead of making the claims vacuous.
### And the flat keys are kept LIVE, which is the half DEV-02 had to add later
Rule 2 originally said the flat keys are left in place and go stale. DEV-02
amended it, for the sign latch, to *"left in place is not enough"* — and the
same sentence holds for history. A fossil dated the day the second inverter was
declared would hand a rolled-back single-FSM add-on a `last_completed` months
old (it recalibrates a pack that does not need it) or a stale `phase` with a
`phase_started` old enough to abort on the 12 h timeout the moment it comes up.
So `UnitHistory` **mirrors the first pack's writes back onto the flat keys** for
as long as the roster has more than one entry. Only the first pack: the flat
spelling means `inverters[0]` on every install that has one, and a mirror from
pack B would overwrite pack A's rollback view with pack B's phase.
## 3. Staggering: the interlock defers, and the stagger is the residue
Two packs on one monthly schedule are due in the same hour every month. The
options were a stagger **by construction** — offset each pack's first cycle by
`interval_days / N` — or **deferral**. Deferral, for three reasons:
1. **A constructed stagger cannot remove the need for the interlock.** A forced
start, a commissioning cycle, an abort-and-retry, or a pack added mid-month
desynchronises any static offset immediately, so the interlock has to be
there anyway — and then the stagger is a second mechanism that must also be
correct, for a case the first one already handles.
2. **A deferral is observable and a stagger is not.** The deferred pack says, in
the action log and on the status page, that it is waiting, which pack it is
waiting for, and that it will run at the next start hour. A silent offset
moves an operator's configured start day and explains nothing.
3. **The stagger comes out of the deferral for free and then persists.** Pack A
completes on day 1 and is next due on day 29; pack B, deferred, completes on
day 2 and is next due on day 30. One contested month separates them by one
cycle length and they stay separated.
**Starvation was the risk and it is closed at two points.** A deferral does
**not** stamp `last_start_attempt` — that key is the post-abort cooldown, and at
90 minutes inside a 60-minute start hour, stamping it would lock the deferred
pack out of exactly the hour it is waiting for. And the winner is the
longest-waiting pack rather than the first in the roster, so a pack that keeps
aborting cannot monopolise the slot month after month while its sibling drifts —
which is this card's own defect wearing a different costume.
**The deferral must not read as a fault.** §12's 12 h and 36 h phase timeouts
mean *"the battery is not following commands"*. A deferred pack stays `IDLE`
with `phase_started` untouched, so no timeout is reachable by waiting, and the
line it logs says WAITING, names the pack it waits for and says it will run
next — none of which any failure path in `maintenance.py` says.
## 4. Rule 6 (the card calls it rule 8): the first entry's cap is no longer right
`validate.py`'s rule 6 comment said, in as many words, that
`maintenance_charge_w` was graded against `inverters[0]`'s cap because there was
exactly one `Maintenance` and it could only ever recalibrate the first pack —
and that taking `min()` would be an over-refusal, because it would refuse *"a
5000 W pack being recalibrated beside a 2000 W sibling the cycle never
touches"*. It closed: **"when `maint_unit` stops being `inverters[0]`, this line
moves with it."**
It has stopped. Every pack is recalibrated on its own schedule, so **the sibling
the cycle never touches does not exist** and the over-refusal argument has no
subject. The shaping claim is filed on whichever pack is charging, so the cap
that can contradict it is any cap on the roster and the one that contradicts it
first is the smallest.
**This refuses configurations that used to start**, and that is the point rather
than a side effect: `max_w` 5000 on pack A beside 2000 on pack B with
`maintenance_charge_w` 3000 was correct while only pack A was ever recalibrated,
and is now a cheap-window charge asking the arbiter for `hi = -3000` against a
supervised `lo = -2000` — `lo > hi`, arbiter clause 3, 0 W, on the pack nobody
was watching. Refused when it is **written**, which is rule 6's whole job. At a
roster of one `min()` over one cap is that cap: same comparison, same message,
same boundary.
## 5. The claim stays per-unit, and nothing here files a site claim
`arbiter.CLAIM_SCOPE` has classified `maintenance` as `SCOPE_UNIT` since it was
written. Two FSMs mean two claims on two actuators, which the allocator consumes
as per-unit intervals. [[ARCH-01]] is explicit that this must never become a
site claim, because per-inverter constraints filed site-wide are the
cross-inverter `lo > hi` contradiction at `arbiter.py:114`. The only line this
card changed there is **which** roster entry the claim is filed on: the one whose
state machine produced the result, rather than `inverters[0]`.
## 6. FIXED BY SAFETY-78: the site loop contradicted a drain at N>=2
Found here and pre-existing: below the pin the other pack had to charge, the
allocator refused it and the site sat at 0 W. Fixed by a site `drain` floor;
see [[SAFETY-78]] (`DECISIONS/SAFETY-78-drain-at-two.md`).
## 7. FOUND AND NOT FIXED: `migrate_unit_keys()` has the same hole, and it is a LATCH
Found while fixing §2's instance. It is the same defect one module over and it
costs a **disarmed safety guard** rather than a lost history.
`Controller.__init__` calls `migrate_unit_keys(store, UNIT_A)` — a hardcoded
constant where the maintenance migration now passes the roster's own first name.
It **has** to run before `build_roster()`, because each `Inverter` restores its
sign latch in its constructor, so there is no roster to name yet and
`_verify_moves()` is passed `roster=None`. On a **declared** roster a pre-DEV-02
`sign_tripped: true` therefore lands under `unit/inverter-a/sign_tripped`, which
no unit on that site reads.
**And the guard that exists for exactly this is defeated by the key the defect
wrote.** `_grade_store_migration()` re-arms the latch when the flat key says
tripped and *nothing namespaced is latched*. The misdirected write **is**
namespaced and **is** latched, so `namespaced_latched` is `True`, the guard
returns, and no inverter on the roster comes up tripped — on an install whose
`/data/state.json` still reads `sign_tripped: true`. That is DEV-02's own
sentence for the worst case: the state file says protected, the banner is green,
and the controller resumes commanding into the fault that tripped it.
Measured, on the same `["garage", "shed"]` roster:
```
pre-DEV-02 flat latch + declared roster ['garage', 'shed'] ->
migrated 2 flat key(s): unit/inverter-a/sign_reason, unit/inverter-a/sign_tripped
latch keys in the store: ['sign_tripped', 'site_sign_tripped', 'unit/inverter-a/sign_tripped']
roster came up tripped: [('garage', False), ('shed', False)]
the same store on the FLAT config -> [('inverter-a', True)]
```
The last line is the counterfactual: on the flat shape, where `UNIT_A` really is
the roster's only name, the same store migrates and comes up latched. So this is
a defect of the **declared roster**, not of the migration as such.
**Why it is not fixed here.** The remedy is to name the migration's target from
the config before any `Inverter` exists, which means factoring `build_roster()`'s
naming rule out of `build_roster()` so there is still only **one** spelling of
it — `inverter.py` and SAFETY-02's latch semantics, with its own mutation battery
over `test_inverter.py` and `test_watchdog.py`. MAINT-05 moves the maintenance
history and nothing about the sign latch, and a silent expansion into a safety
latch is how a rejected PR becomes a broken one.
It is pinned as a running check (`test_maintenance.py` part 13) rather than left
as a comment. ⚠️ **Its assertions state the WRONG behaviour on purpose** — read
them as the specification of a defect; the card that fixes it should expect them
to go red and should replace them.
### And its MARKER GATE had the round-3 hole too — found here, closed here
Separate from the namespace hole above, and **fixed** rather than recorded,
because closing it cost one test fixture and no source change.
`migrate_unit_keys()` gates `schema_version` on `rep.ok` exactly as
`migrate_maint_keys()` gates its own marker, and every check that reached that
gate ran on a store where **nothing** was namespaced yet — so `rep.kept` was
empty in all of them and
```
if rep.ok: -> if rep.ok or rep.kept:
```
**survived all 19 files**. What it does is stamp the schema marker on a
migration that DROPPED THE LATCH: the guard is off, the state file still reads
`sign_tripped: true`, and because the store now says the migration finished, the
next start skips the one thing that would have re-armed it. DEV-02's own worst
case, filed under *done*.
`test_inverter.py` part 10 now drops the `sign_tripped` write **beside an
already-namespaced `sign_reason`** — `lost` non-empty *and* `kept` non-empty,
which is the one shape none of the existing fixtures had — and asserts the
marker is withheld, with the accepted-baseline counterfactual beside it (habit
7). The shipped gate was correct throughout; what changed is that a check can
now see it.
@@ -1,67 +0,0 @@
# MAINT-10 — the maintenance start hour is local time, default 19:00
Owner decision 2026-09-29 (option A). `maintenance_start_hour` was compared
with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and
11:00 in winter. It is now read on the site's local clock, and the default moves
to 19 so the drain runs into the evening and night rather than the solar day.
Evening drains meet higher house loads; SAFETY-83 (merged first) lets the
peak guard outrank the drain.
## 1. Which zone
Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup,
else the container's `TZ`, else UTC. One log line says which (WARNING only for
UTC). Resolved once per start: a zone change in HA takes effect on the next
add-on restart. HA can still be starting when the add-on starts (502 from the
proxy, 2026-10-05); then `TZ` holds. HA pushes its zone to the Supervisor
(hassio `push_config`), which stores it and sets it as the add-on's `TZ`
(`docker/app.py`, Supervisor 2026.08.0), so the hour stays right. Only a `TZ` of
UTC there logs a WARNING asking for a restart. No later re-resolve: the fallback
already is HA's zone, so the rare UTC case gets a warning rather than more code.
The add-on image has tzdata (checked in `amd64-base:3.20`); the CI image does
not, so the tests grade DST on `tariff._EUTimezone` too.
## 2. DST
`maintenance.in_start_slot()` works from UTC instants only, so it does not
depend on how a tzinfo resolves a missing or doubled wall time:
- normally the slot is the local hour `start_hour`;
- clocks forward and the hour does not exist: the first hour after the jump;
- clocks back and the hour happens twice: the first pass only. Without this an
aborted start (90 min cooldown) would start again in the 120 min repeated hour.
Every day has exactly one 60 min slot for every hour 0-23: checked against
zoneinfo for Europe/Brussels, America/New_York, America/Santiago (midnight
transitions), Asia/Kathmandu (+05:45) and Australia/Lord_Howe (30 min DST)
over all of 2026.
## 3. Existing installs
The Supervisor merges the shipped defaults under the STORED options, and saving
the settings stores every key (the reference install's blob has all 76). So
almost every existing install keeps its stored 10, which now means 10:00 local
instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19.
The add-on does not rewrite a stored option: a silent migration would override
an operator's choice with no way to tell it apart from the old default. A site
that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it
back to 19 after the upgrade, or it starts two hours early. DOCS and the release
note (drafted on the PR, for the version bump) say this.
`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0.
## 4. Two inverters
`Controller` hands the same zone to every pack's `Maintenance`, so every pack's
`wants_start()` reads the local hour. `arbitrate()` is unchanged: one pack
cycles at a time, and the one that lost the slot starts in the first start
slot after the other is done (a later day: a cycle outlasts its start hour). On the day the clocks go back, a pack freed during the second pass of
a repeated hour does not start there. `test_maint10.py` part 10.
## 5. What does not change
Only the start decision (`Maintenance.wants_start`) and the Home screen's shown
start time read the zone. `test_maint10.py` parts 9 and 10 run the real
Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the
same instant): every command, claim, reason and log line is identical. A
`Maintenance` built without a zone reads UTC, as before.
@@ -1,37 +0,0 @@
# MAINT-11 - start or stop the maintenance cycle per battery
**Status: for review.** Tests: `test_maint11.py`. No option added.
**The fault.** At two or more batteries the page had one "start a battery cycle"
button, and `_maint_button()` chose the battery. An operator could not cycle a
chosen one (inverter B, new after storage, needed its first cycle).
## Rule
1. `maint_start` and `maint_abort` take an optional `unit`. With no unit they
run the old code unchanged. The Batteries screen sends the unit from a
per-battery Start cycle and Stop cycle button, at two or more batteries only.
2. **A start while any battery holds the maintenance slot is refused, not
queued.** The message names the battery that is cycling. One slot per site
stays the rule, and the scheduler is untouched.
3. Also refused, with a plain message: an unknown unit, a start on a battery
that is switched off (battery modes on), and a stop on a battery that is not
cycling. Nothing changes on a refusal.
4. A stop on the cycling battery is today's abort: the cycle ends, then every
inverter goes to 0 W.
## Why refuse and not queue
A queued start is a cycle that begins hours later, unseen, after the operator
has moved on. A refusal is immediate and says why. The scheduler already
queues by waiting time, so nothing is lost.
## What this does not cover
- Today's bare button never refused a battery that is refusing to give power
(SAFETY-73); its drain waits. The per-battery start does the same.
- A start on the battery that is already cycling is refused as "busy", where
the bare button restarts its drain. The bare button is unchanged.
- At one battery, a request that names the battery uses the new path, so a
second start is refused instead of restarting the drain. Nothing sends a name
at one battery, and the bare request is unchanged.
@@ -1,109 +0,0 @@
# MAINT-13 - a full pack that stops at 98-99 % is not a failed charge
**Status: for review.** Tests: `test_maint13.py`. No option added; every number
is a constant in `app/maintenance.py`.
**What happened (live, B).** The drain ended 2026-10-04 18:24 UTC and the charge
began. It ran in pieces behind the price gate, reached 99 % at 2026-10-06 04:00,
fell back to 98 % at 04:15 while the loop still asked about -1370 W, and never
reached `maintenance_soc_target` 100. At 06:24 (2160 min = 36 h of wall clock
after the charge began) it aborted "charge stuck at 98 %". `last_completed` was
not set, so B was still due and would have drained again from 98 % to 11 % at the
next start hour, with no notice. Three faults: a pack may never report 100 %
while the BMS balances; a failed cycle came back the next day; and the 36 h
counted hours the price gate kept the pack waiting.
## 1. The charge is complete when
1. SoC >= target (as before), or
2. SoC >= target - 3 (`CHARGE_NEAR_FULL_PCT`) and the SoC has not beaten its
best for 60 min (`CHARGE_STALL_MIN`) of allowed charging, or
3. the charging time (`CHARGE_TIMEOUT_MIN`, 2160 min) runs out at SoC >= target - 3.
Then the usual HOLD follows and `last_completed` is set after it. Each of 2 and
3 logs one plain line ("the battery takes no more charge at 98 %, so the charge
counts as done", "charge time is up at 98 %, close enough to full ...").
**Why "SoC has not beaten its best", and not asked/delivered watts.** SoC is the
one signal the live record gives us (99 at 04:00, 98 at 04:15); the asked power
was -1370 W but no delivered figure was kept, so a watt threshold (X, Y) would be
invented. SoC also covers the case where power flows but nothing is stored.
**Why 3 points.** The card's band; at target 100 it is 97, and the live pack sat
at 98-99. **Why 60 min.** A judgement, not a measurement: we have no per-minute record of
the pack taking charge near full. The one live fact is 2 h 24 min (04:00 to
06:24) without a new high, so 60 min is well inside it and long enough that a
pack still creeping up (one new high within the hour) keeps resetting the wait.
On the live run the rule fires after 60 gate-open minutes past the 99 %: 05:00
UTC at the earliest, before 06:24 if the gate was open. If the pack turns out to
step up more slowly than that, raise the constant. Falling back by one point
does not reset the wait; only a new best does.
## 2. A failed cycle backs off 7 days
A drain stuck 12 h or a charge that times out below target - 3 is a failure
(`FAIL_BACKOFF_DAYS` = 7). The machine goes IDLE as before, `last_failed` is
stored, and `wants_start()` is false until 7 days later. The operator's forced
start ignores it; a cycle that completes after the failure ends it. An operator
abort, an interlock stop and a switched-off pack are not failures.
**Why 7 days, not the 28 day interval.** A judgement: a failure may have a
short cause (several dull days with no cheap hours, a pack switched off), and
waiting the full interval leaves the coulomb counter uncalibrated for a month
more. A week is long enough that a bad pack costs one drain a week, with one
notice a week, not one a day. **Cost.** A truly faulty pack still drains once a week until someone looks;
the notice says so each time.
**Notice, once.** The tick that fails carries `failed`; the Controller sends one
persistent notification "Battery cycle did not finish" (EN, with the Dutch
below it; per pack at N=2). It is not repeated, and Home Assistant restarts
wipe it, so the status page shows a "Maintenance paused" row with the date for
as long as the wait lasts. The operator may also see the log lines.
## 3. The timeout counts only allowed time
`Controller._charge_gate_open()` says whether the pack may charge now. It is
true when no price gate is set up (no price entities, no fixed tariff: nothing
holds the charge back, and `cheap` is always false there), in a cheap window, or
while the pack is really taking charge (battery power at or below -50 W, which is
sun outside cheap hours). It is false only when a gate exists, is shut, and the
pack is not charging; a pack asked for charge that delivers nothing then does not
count, which is the case the clock cannot judge. A site with a price provider
(TARIFF-11) and no fixed tariff counts as having no gate: the provider's plan
reaches only the Tariff row, never `cheap`, so no cheap window ever opens a grid
top-up there and only the sun charges the pack, as on a site with no tariff. Its
time counts in full. When a provider plan starts to drive `cheap`,
`_price_gated` must become true for it. Whether a fixed tariff is set up is
worked out once an hour, not on every tick. Only open ticks add to `open_min`; one tick
adds at most 5 min, so a gap (add-on down) adds nothing. The stall wait pauses
while the gate is closed. The accounting is written to the state file at most
every 5 min (`charge_track`); a restart loses at most that. A state file from
before this change, or with no record for the current charge, starts from the
wall-clock minutes so far, which is the old behaviour.
**Backstop.** A gated site whose gate never opens (no cheap hours, no sun) would never time
out, so a charge also ends after 7 days of wall clock (`CHARGE_WALL_TIMEOUT_MIN`):
complete at >= target - 3, a failure below.
**MAINT-12.** This is the smallest change that makes the timeout honest: one
boolean in, one counter. MAINT-12's block plan can replace the boolean (the
minutes it plans are the allowed ones) without touching the rest. Two things it
must keep: the back-off is in `wants_start()`, so any new entry point (such as
`price_forecast.begin_phase`, which is not wired today) must go through it or
check `backoff_until()`; and `gate_open` must stay "the pack is allowed to
charge now". The peak guard pausing the charge shaping is not seen as a closed
gate: that time still counts.
## 4. What does not change, and what this does not cover
- The drain, the HOLD, the held-battery rules and `arbitrate()` are unchanged. A
backed-off pack does not want a start, so at N=2 the other pack may take the
slot. One pack at a time at N=1 and N=2.
- `last_failed` is a maintenance key and moves with the others when a second
inverter is added (`MAINT_UNIT_KEYS`). The charge record is not moved; it
restarts from the wall-clock minutes.
- It does not find out why a pack stopped. A pack that stops at 96 % and never
gets near is still a failure.
- The Dutch text is only in the notification. The status row and the log lines
are English, like the rest of the status page.
- It does not change the target: a site that wants a different stop point sets
`maintenance_soc_target`.
@@ -1,68 +0,0 @@
# MAINT-14 - no SoC reading in a maintenance cycle
**Status: for review.** Tests: `test_maint14.py`. No option added; the one new
number is a constant in `app/maintenance.py`.
**The fault.** `Maintenance.tick()` returned early when the SoC was `None`,
before any timeout check. (1) A charge on a pack whose SoC reading was lost
never ended: no timeout, no back-off, no notice, and the 7-day backstop of
MAINT-13 sat behind the same return. (2) A drain kept commanding `discharge_w`
with an unknown level, which breaks the TEL-08 rule that an unknown level never
allows discharge.
## Rules
1. **Charge, no SoC.** The charge timeout (`CHARGE_TIMEOUT_MIN`, gate-open
minutes) and the 7-day wall backstop still run. When one fires: if the last
SoC seen was >= target - 3 the charge counts as done (the MAINT-13 "close
enough to full" rule, HOLD follows); otherwise it fails: MAINT-13 back-off
and its one notification. If no SoC was ever seen, it fails.
2. **Drain, no SoC.** DRAIN holds at 0 W and owns the setpoint, exactly as HOLD
does. The 12 h drain timeout still runs and fails as before.
3. **SoC comes back.** Nothing is recorded; the drain continues at the drain
rate, the charge carries on, and the stall wait of MAINT-13 restarts from
what it was: it paused while the reading was missing, so a gap never counts
as "no more charge".
4. **HOLD** is unchanged (0 W).
5. **Log.** One line when a drain starts holding for this reason ("the battery
level is unknown, so the drain holds at 0 W until it is back"), and one line
when the level has been missing for `NOSOC_NOTE_MIN` = 15 min. Both are said
once per episode; the old line that repeated every tick is gone.
## Why
- **"Last seen", not "best seen".** A pack that was at 99 and then dropped to 50
before the reading went is not close to full. Only the last reading counts.
It is saved with the charge record (`charge_track.last`), and saved at once
when the reading first goes missing, so a restart mid-gap still knows it
(within the usual 5 min save interval otherwise).
- **Failure when nothing was seen.** With no level we cannot call a pack full.
The failure costs a 7-day wait and one notice; calling it done costs a
missed balance and a month-long false "complete". The first is the honest one.
- **Timeouts count the same minutes as with a reading.** Gate-open charging
minutes only; the wall backstop for a gate that never opens.
- **15 min.** A judgement. A poll glitch of a few minutes should not write a
line; a reading lost for a quarter of an hour is worth one. Raise it if the
action log shows noise.
- **0 W, not "stop the phase".** Aborting a drain on a short gap would throw
away the part already drained. Holding at 0 W loses nothing and the 12 h
timeout is the limit.
## Cost
- A drain that loses its reading stops draining, and the 12 h clock keeps
running. A gap of many hours can therefore fail a drain that was working. The
back-off then applies (7 days).
- A charge whose reading is lost early (last SoC below target - 3) fails at the
timeout, even if the pack was in fact filling. The same 7-day wait applies.
## What this does not cover
- **HOLD with no SoC**: closed by MAINT-15 (`MAINT-15-hold-ends-without-soc.md`).
- It does not tell the operator why the reading is missing (sensor, register,
link). It does not reuse the stale-input gate; it is the cycle's own rule.
- The charge itself is still driven by the control loop, which has its own
handling of a missing SoC (TEL-08 for the loop is a separate card).
- A restart loses at most 5 min of accounting, as in MAINT-13. The "unknown for
long" line may be said again after a restart: the episode start is in memory.
- Text added is two log lines, English only, like the other log lines.
@@ -1,47 +0,0 @@
# MAINT-15 - the balancing hold ends on time with no SoC reading
**Status: for review.** Tests: `test_maint15.py`. No option added.
**The fault.** `step()` sent every tick with no SoC to `_no_soc()`, and for
HOLD that returned 0 W without the time check. A hold that lost its reading
never ended: `last_completed` was never written and the next due date never
moved. Harmless for power (0 W), but the cycle was stuck. This was left open
in MAINT-14.
## Rule
1. **HOLD ends at `hold_min` whether or not there is a reading.** One helper,
`Maintenance._hold()`, holds the time check and is used by both paths. When
the time is up it writes `last_completed`, goes IDLE and logs the same
"cycle COMPLETE" line. Before the time is up it commands 0 W as before.
2. **Text.** The 15-min "no battery level" line in HOLD now reads "The hold
still ends at its normal time." Still once per episode.
## Why
HOLD is reached only after the charge counted as done. Every entry into HOLD
is in `step()` or `_no_soc()` and follows a charge that reached the target,
stalled near full (MAINT-13) or timed out near full (MAINT-13/14). Nothing else
sets the phase to HOLD (`_enter(HOLD` has four call sites, all after a done
charge). The hold is a wait for the BMS to balance; it never needed the level.
So a hold that ends with no reading is a completed cycle. The "cycle COMPLETE"
line is true without a reading: it names minutes of hold, not a level.
## Cost
- If the level was lost during the hold, the cycle is recorded as complete
without anyone seeing the final level. The charge had counted as done, so this
is the same trust MAINT-14 already gives a charge that ends with no reading.
## What this does not cover
- It does not check that the pack is still full at the end of the hold.
- It does not say why the reading was lost.
- With a reading, nothing changes. `test_maint15.py` section 7 compares the new
HOLD tick by tick with a frozen copy of the release/1.0 HOLD branch (phase,
setpoint, owner, events, `last_completed`) at four `hold_min` values. A wider
trace of the whole cycle (800 seeded runs against db9e231) was run outside the
repo; its script and log are kept under `scratch/MAINT-15/` of the dev
workspace, not in this repository.
- A state file written by an older version with `phase: hold` loads and ends on
time; the file is not changed in format.
@@ -1,334 +0,0 @@
# OPT-09 - the weights parameter, and what it is honestly for
**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `3d29993` (rebased onto the UX-02 merge; it read `3368231`, and before that `464d398` - each rebase moved it and review caught it each time, which is why the counts in `test_translations.py` are now computed rather than typed)
(`git merge-base HEAD 3368231`; the branch was rebased and this line used to name
the pre-rebase base, `464d398`).
## 0. What was actually wrong
`allocator.plan()` has taken a `weights` argument since ALLOC-01, `_fill()`
consumes it in two passes, and **nothing had ever passed one**. Confirmed on the
base commit:
arbiter.py:535 def resolve(claims, *, units=None, weights=None, live=None, ...)
main.py:2422 resolve(claims, units=self.units, live=self.live_units(), ...)
So `weights` was None on every tick of every install, `plan()` turned that into
1.0 per unit, and the site target was split **50/50 regardless of what either
pack could do**. The card was filed as "per-pack arbitrage sizing"; the architect
pass found the sharper content, and it is the sharper content that this branch is
mostly about.
## 1. Two instruments, and they are not interchangeable
`allocator.py` states the rule this card is built on, and it is mechanical rather
than advisory:
> WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is
> "please do not use this unit", and it is honoured while any other unit has
> headroom - but it is NOT a constraint.
`_fill()` runs a deliberate **last-resort pass** over the zero-weight units when
the preferred ones saturate, so that a preference can never manufacture a
standing error at the meter. A pack at its own cut-out that is only *weighted*
to zero is therefore still commanded to discharge the moment its sibling fills
up. So this branch ships both:
| | question | mechanism |
|---|---|---|
| `packshare.envelope()` | MAY this pack be asked, at all, in this direction? | a `pack` UNIT claim at `P_SAFETY`, `hi = 0` at the cut-out, `lo = 0` at 100 % |
| `packshare.weights()` | GIVEN that both may, which should carry more? | energy headroom in kWh, handed to `resolve()` |
## 2. ⚠️ What the weights CANNOT do
ARCH-01's convex-combination argument - the gain at the meter is
`sum(a_i * g_i)`, so it lies in `[min g_i, max g_i]` for **any** split -
guarantees that a bad split cannot **destabilise** the site. It says nothing
about whether the site can **regulate**. A unit at its energy limit has
`g_i = 0`; the combination is still inside the interval and the load it was
asked to carry simply does not move.
Measured, `test_packshare.py` part 8, two units rated 2000 W each, B sitting on
its own floor and therefore answering 0 W to any discharge command. The plant is
modelled in that file (`lag_s = 0`, obeys within one cycle, bounded by its
rating) and the loop is the real `compute()`:
| load | arm | site target | shares | grid | unmet |
|---|---|---|---|---|---|
| 1500 W | shipped | +3000 W | A +1500, B +1500 | 0 W | 0 W |
| 1500 W | weights | +1500 W | A +1500, B +0 | 0 W | 0 W |
| 1500 W | envelope | +1500 W | A +1500, B +0 | 0 W | 0 W |
| 3000 W | shipped | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
| 3000 W | weights | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
| 3000 W | envelope | +4000 W | A +2000, B **+0** | +1000 W | **+2000 W** |
Three things are worth reading off that table, and the second and third are the
uncomfortable ones:
1. At a load the healthy pack can cover, the weights alone deliver the whole
improvement. The shipped arm reaches the same meter reading only by winding
the site target to **twice the load** and standing a +1500 W command on a
battery that answers nothing, permanently saturation-frozen. That is not a
working site, it is a site with no headroom left for the next load step.
2. At a load it cannot cover, **the weights stop working entirely**. The
weighted arm and the shipped arm command the identical +2000 W into the empty
pack, because `_fill()`'s last-resort pass hands the remainder to the
zero-weighted unit. Same shares, same watts, same error.
3. **And the envelope does not rescue the delivery either - it names the
shortfall.** Same 2000 W delivered, same 1000 W standing at the meter. What
changes is that the empty pack is commanded 0 W instead of 2000 W, and 2000 W
of site target is *reported* as unplaceable instead of appearing to have been
placed.
> **Weights are a comfort choice for STABILITY and NOT for DELIVERY.** Nothing in
> this add-on shifts a unit's load to its sibling once the sibling is at its
> rating, and weighting a failing unit down is not a remedy for it.
## 2b. ⚠️ The card's own premise is only half true
The card opens with *"a unit that stops delivering its share (empty, full,
curtailed, wedged) parks the site with a standing grid error and no alarm."*
After this branch: the **ask** stops, and while the sibling has headroom the site
regulates to zero error instead of winding to twice the load. The **standing grid
error does not stop** once the sibling is at its rating - §2's table measures
1000 W standing in all three arms - and there is **still no alarm** for it, only
a report. SAFETY-42 will not alarm on a pack at its own floor; that is one of its
acceptance criteria and its evidence is that the benign and the wedged case are
indistinguishable in the power domain.
So the two cards together mean: an empty pack limiting the site is now REPORTED
(`unmet_w`, an action-log line) and still, deliberately, NOT ALARMED. Whether it
should be alarmed is a decision - and the alarm it would need is about a site's
stored ENERGY, not about an inverter's delivery, so it is not this card's and not
SAFETY-42's. Recorded rather than quietly closed against a premise this card only
half meets.
## 3. Which floor, and which ceiling - the two places this could have become policy
**The floor is `bms_soc_floor` alone, never `peakguard.pack_floor_pct()`.** That
function's conjunction with `peak_soc_floor` is right for the peak guard and
wrong here: SAFETY-25 decided (2026-09-02, Option A) that `peak_soc_floor` bounds
the peak guard **only**, ordinary self-consumption discharges straight past it,
and closing an envelope at it would turn an operator preference into a hard stop
on the grid-following loop - the memo's **deferred Option B**, built by accident.
`delivery.py`'s `account()` made the same choice one step out, for the same
reason. Graded: a pack at 15 % with a 10 % cut-out is not closed, though a 20 %
EMS floor would have closed it.
**The ceiling is 100 %, and the coarseness is the honest answer.** There is no
per-pack charge ceiling anywhere in this add-on - `delivery.py` says so at length
and calls it a card, and it is why SAFETY-42 is armed on discharge shares only.
The only other number available is `arbitrage_soc_ceiling`, which is one
site-wide **trade policy**; filing it as a `P_SAFETY` envelope on the loop would
be the mistake above in the charge direction. So the envelope closes only where
the ask is physically impossible, and everything between is left to the weights -
which is the right instrument for a graded preference and is honest about being
only a preference.
## 4. N=1, and the tripwire this had to argue past
`actuator_for()` routes **every** source to the site at one inverter, so a `pack`
interval filed there would bound the SITE TARGET rather than a share. `main.py`
therefore does not file it at all below a roster of two - the same guard, for the
same reason, that SAFETY-30's `sign_site` carries. AC 4 ("at N=1 allocation is
byte-identical") is that `if`.
`test_peakguard.py` part 10f pins the claim-source set against `main.py`'s own
AST precisely so that a state-of-charge-conditioned claim cannot arrive quietly.
`pack` is state-of-charge-conditioned, so the argument is written at that set
rather than the count edited: the number is not an option, the claim is
unit-scoped so it cannot bound the site target, it is not filed at N=1, and part
10f's own note names this as the revisit trigger - *"under ALLOC-01 the reserve
becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a ninth claim
here"*. Stage 1 is how an interval reaches the allocator and stage 1's input is
claims, so a unit-scoped claim is the shape that sentence asks for.
**What N=1 still does not get, said plainly:** nothing stops the loop discharging
into the BMS cut-out at one inverter. That is unchanged, it is SAFETY-25's
deferred option, and the BMS is what stops it.
## 5. SAFETY-42 - the same hazard from the opposite side
The seam is **the state of charge** - and they overlap in one direction, which
is what the rest of this section is about:
* **OPT-09 stops the site ASKING** a pack whose own SoC says it cannot deliver.
* **SAFETY-42 alarms** when a pack that said it could does not.
A wedged unit reads a healthy SoC, so **no envelope closes** - that half is
absolute, and it is why this card cannot stop the ask on a wedged box. A pack at
its floor is `account()`'s explained case, so SAFETY-42 stays silent - and after
this card that ask does not arrive in the first place, which removes the benign
arm from the detector's input rather than teaching it a new discriminator.
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWED SAFETY-42's DETECTION WINDOW.
THAT WAS A DETECTION REGRESSION AT N=2, IT WAS NOT FIXED HERE, AND SAFETY-43
CLOSED IT - see the block at the end of this section.** An earlier draft of
this section said "no envelope closes and no weight moves - OPT-09 cannot see it
at all". The second clause was **false**, and it was load-bearing for the claim
that the two cards do not overlap. A weight is a function of the pack's ENERGY
HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever
its headroom differs from its sibling's - which is the ordinary case this card
exists to exploit. And `delivery.delinquent()`
named a unit only while its `short_by()` gap exceeded `min_w =
Tuning.saturation_w`, and that was **500 W in absolute watts** with nothing
else beside it. Shrinking a unit's share shrinks its gap. Shrink the
share far enough and the gap drops below 500 W while the unit is still delivering
nothing, and the alarm stops firing.
Measured on the closed loop - real `compute()`, real `plan()`, real
`delinquent()`, modelled plant, B wedged at 15 % against its own 10 % floor so
nothing closes and `account()` explains nothing, i.e. a SAFETY-42 **true
positive** by that card's own definition:
load 1500 W equal weights (shipped) named on 199/200 cycles
shares {a:1500, b:1500} grid +0 W named=['inverter-b']
load 1500 W OPT-09 weights named on 0/200 cycles
shares {a:1505, b: 125} grid -5 W named=(nobody)
⚠️ That plant is modelled in `test_packshare.py`, and **inverter B does not
exist** (expected ~2026-09-12): it is two copies of A driven with different
numbers, and B's 4600 W / 20.48 kWh / 10 % floor are from the datasheet and the
serial. The claim is about the arithmetic of the split and the threshold, which
needs no hardware, and not about a GW5000S-BP.
At a load the healthy pack can cover - the regime where nothing else tells the
operator anything, `unmet_w` is 0, the meter is satisfied and no action-log line
fires - a wedged inverter goes from named on essentially every cycle to **never
named**. The suppression window widens from `site target < 2 * min_w` = 1000 W to
`site target < min_w * (w_A + w_B) / w_B`, which on that fixture is **6500 W** of
a 9600 W site; above it - once the healthy pack is at its own rating and
`_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm
returns. ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
`test_packshare.py` part 8b graded all of it and **pinned the unflattering
behaviour**, so that the day it changed a named check would move. It has moved.
### ⚠️ CLOSED BY SAFETY-43 - THIS IS NO LONGER A DISCLOSED RESIDUAL
This section used to end *"until then this is a disclosed residual [...] at two
inverters, a wedged unit with materially less energy headroom than its sibling
can go unreported at ordinary house loads"*. That sentence is **retired, not
softened**, and what replaces it is a measurement rather than an intention.
`delivery.delinquent()` is now relative to the ask **as well as** absolute:
named if short_by() > min_w OR short_by() > frac * share
with `frac = delivery.DELIVERY_SHARE_FRACTION`. A unit delivering 0 of 125 W is
100 % delinquent and is named; a unit delivering 0 of 1500 W was already named
and still is. On the **same fixture, under the same weights**, part 8b now
grades both rules in one run - `share_frac=None` is SAFETY-42's absolute arm,
exactly:
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
⚠️ **`min_w` WAS NOT LOWERED, AND THAT IS THE HALF THAT MATTERS HERE.** The
absolute arm's suppression window is still exactly the arithmetic above - 1000 W
at equal weights, 6500 W at these - and part 8b still asserts both numbers.
Lowering `saturation_w` would have traded a missed alarm for a false one on
every unit at once, which is SAFETY-41's four-attempt regime. SAFETY-43 added a
**second** way to be named and left the first where it was.
⚠️ **AND WHAT OPT-09 STILL DOES NOT DO IS UNCHANGED.** This card stops the site
ASKING a pack whose own SoC says it cannot deliver; it does not re-allocate a
wedged unit's watts, and SAFETY-43 did not make it. The alarm got louder; the
loop did not move. The derivation of `frac` - swept over the same 2640 no-fault
runs that produced `delivery_hold_s`'s floor - is in
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md`.
So the seam, stated accurately **as of SAFETY-43**: OPT-09 stops the ask, and it
still narrows the ABSOLUTE arm's window for a low-headroom unit exactly as
described above - but it no longer narrows the DETECTOR, because the detector no
longer reads only that arm.
⚠️ **And OPT-09 supplies the mechanism SAFETY-42 said was missing, without
connecting it.** That module's rejection of re-allocation rests on *"the only
re-allocation this add-on can express is ALL OR NOTHING [...] a partial
re-allocation is what would make acting safe, and the allocator has no way to
express it today"*. Passing weights **is** that expression. Wiring delinquency
into them is deliberately **not** done here: it would be acting on the alarm,
which SAFETY-42 refused with rig evidence (a unit derated to 50 % is 400 W worse
dropped than kept, and no instrument this add-on has can tell it from a wedged
one), and - see §2 - a weight cannot stop a unit being commanded anyway. That is
a card, and this paragraph is its input.
## 6. Per-pack arbitrage sizing, and the half that is deliberately not moved
`arbitrage.decide()` was handed `site_max_w` on **both** sides, so a two-pack
site would buy at the sum of the ratings whatever either pack's state of charge
was. It is now sized to the packs whose own SoC permits that direction
(`Controller.trade_power_w()`), using **arbitrage's own** bounds rather than the
BMS cut-out - sizing a trade is the trade's business; permitting a command is the
pack's.
Two honest limits:
* **The sell side is a no-op on every roster today.** `decide()` is handed
`pack_floor_soc` - the emptiest pack - so a sell is refused outright whenever
*any* pack is at or under the floor, i.e. in exactly the states where the sum
could be smaller than the site rating. The arithmetic is graded directly
(`test_packshare.py` part 9); it becomes reachable the day that gate is
per-pack.
* **Moving that gate is not this card's.** Sizing a sell off the *fullest* pack
would let the trade proceed while the emptiest pack was below
`arbitrage_soc_floor`, and the only per-pack instrument that could keep it out
of that pack is the weights - which are a preference, and `_fill()` will hand
it watts anyway. Doing it safely needs an envelope that depends on WHICH CLAIM
WON the tick, which the arbiter's partition does not express. §2 is why that is
a real objection and not a hedge.
`arbitrage.decide()` also now refuses a **0 W sizing** by name (`IDLE_NO_SIZE`),
because a `Claim.set(..., 0.0)` at `P_ARBITRAGE` is a park order that outranks
the loop - the failure the note on `claim()` is already written against. The
sizing can never be zero on a tick that trades today; that is a coincidence of
the existing gate, and this makes it structural instead.
## 7. The fixture, which is the hard part
Two packs that differ in only ONE of SoC and capacity cannot separate a
SoC-aware weighting from a capacity-aware one; two identical packs cannot
separate ANY weighting from any other. `test_packshare.py` part 1 grades the
fixture itself before anything is asserted with it - four candidate schemes
(equal, power rating, SoC only, energy), four distinct splits of the same
1000 W:
equal A +500, B +500
power A +521, B +479
soc only A +125, B +875
energy A +97, B +903
and the counterfactuals: on two identical packs all four collapse to one answer;
at the same SoC, `soc only` collapses onto `equal`; at the same capacity,
`energy` collapses onto `soc only`.
## 8. Provenance of every number
* A: **5000 W, 15.36 kWh, 10 % floor** - measured (GW5000S-BP, register 0x0560).
* B: **4600 W, 20.48 kWh, 10 % floor** - **FROM DATASHEET AND SERIAL**
(`goodwe/NOTES.md` §1.1b, GW5048D-ES `55048ESU22AW0992`). ⚠️ **Inverter B does
not exist** (expected ~2026-09-12).
* Every two-unit run in `test_packshare.py` is arithmetic over intervals and
weights, or a plant modelled in that file. `sim/plantsim.py:216`'s `INVERTER_B`
is **inverter A copied** with `measured=False`, so any two-inverter simulation
result anywhere in this project is **two copies of A**.
* No rig was taken, no hardware was touched, and nothing here is evidence about
a second GoodWe inverter.
## 9. Config
`capacity_kwh` joins the `inverters` roster as an **optional** per-entry key
(`float(0.1,200)?`) - the opposite choice to SAFETY-32's required
`bms_soc_floor`, and for that card's own reason: a missing cut-out would have to
be *defaulted*, and one default for two batteries protects the shallower pack
with the deeper pack's number, whereas a missing capacity has an honest answer -
no opinion about the split, every unit weighs 1.0, which is exactly what shipped.
It is **all or nothing across the roster** and rule 9 refuses a partial one: a
pack measured in kWh beside a sibling weighing a dimensionless 1.0 is not a
comparison, `weights()` answers "no opinion" for the whole roster in that case,
and a key that silently changes nothing is worse than one that refuses to load.
There is deliberately **no flat `capacity_kwh`**: at one inverter there is no
split to weight, so it would be an option an operator can set and nothing reads.
@@ -1,203 +0,0 @@
# OPT-11 - battery modes, per battery
A battery's mode (on/off, Solar first, Leftover solar) is a soft unit limit at
`P_MODE` = 35 that always holds 0 W. A SET above 35 (a safety stop) overrides
it; a limit above 35 (the peak guard) makes it give way only as far as that
limit needs, and a battery its mode holds back then carries only what the
others cannot (`arbiter._unit_modes()`, `_site_mode()`). Design and owner answers: `goodwe-session/OPT-11-design.md` (2026-09-27);
P1 is its section 6 P1, and the pull request lists what was measured.
- Off by default (`battery_modes_enabled`); off, commands are byte-identical to
a5dddad (`test_modes.py` part 1).
- Off is a mode range, so the peak guard can still use an Off battery when the
others cannot cover the floor (design sections 2-3). Whether Off should be
absolute is an open owner question.
- Review (PR #149): releasing only when the peak floor bound S let a trade won
above the floor keep the modes and place less than the floor, and let a
capped grid charge release an Off battery. Both are graded in
`test_arbiter.py` part 11. The loop bound leaves a side that reaches the
rail to the rail, so SAFETY-03 still flags a loop winding into it.
- P2 (HA entities): a select and a switch per battery, only while modes are
on; commands go through `handle_action()`, the page's path. A retained
command is dropped, not applied: the store already holds the last choice,
and a replay after a restart could otherwise undo a newer one. The config
topics announced are kept in the store (`mqtt_mode_configs`), so turning
modes off removes exactly those, and an install that never turned them on
publishes what ed739d4 did (`test_mqtt.py` part 10). A removed topic stays
in that list and its empty config is re-sent on each start, so a start with
no broker cannot lose it; re-sending is harmless. The HA options are the
page's English names, pinned by a test, and are not renamed with the page:
automations send them.
- In P1 only Off closes a direction; Leftover solar differs by charging last.
- P3 (price runs, `priceruns.py`, `modetrade.py`): each battery's range is
narrowed by its role this tick (hold, give, charge, rest, sell); main.py
files the intersection with the switch, so it still holds 0 W. With modes on
the site trade is filed by the modes, sized from the loop bound (so no
battery a mode or a refusal holds is asked for watts, P1's unmet share) and
the guard's headroom; `arbitrage.decide()` runs only with modes off, which
stays byte-identical to 7a00f12 (`test_modes_p3.py` part 1).
- SAFETY-46 item 2 is decided as the owner answered (2026-09-25): a trade is
a run of consecutive firm slots. It applies with battery modes on; the
modes-off trade keeps its one-period shape, because that path must not move.
- Choices that were measured, not assumed (tools/opt11p3_replay.py):
- a sale needs the guard to have measured the quarter, not to be clear: a
5 kW discharge alone reads "engaged" on export and flipped the sale every
5 s;
- a grid charge makes the others rest, so it starts only at 500 W and, once
the guard or the headroom stops it, stays off for that 15-minute period
(without: 13 flips in 4 minutes under a 3.5 kW house);
- stored energy is valued at the last cheap run for 12 h, and a charge
needs its own run to clear the spread (without: a night charge was spent
on flat morning prices, then charging went on at them);
- a run edge reseeds the loop at the house's level (a 3 kW import spike
removed) and, since OPT-15, holds it there until the meter shows the step.
- Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX hourly prices a
year earlier, modelled batteries), net EUR, lower is better: P2 19.33, P3
Solar first + Leftover solar 19.57, with a sale 19.60, the old one-period
trade 18.14. Review of #153 (same model, with a per-action split):
- the cheapest hours were at midday on 15 of 16 days, so Leftover solar (acting
as Cheap hours until P4) bought 12.2 kWh there that the sun would have
given: 7 kWh more exported, 12 kWh less solar stored. Holding for the
expensive hours saved 4 kWh of import, not enough. With no grid charge on
Leftover solar: 19.44;
- the old trade charged one hour, so the afternoon sun still fit;
- a sale earned the wholesale price (2.27 EUR for 11.4 kWh) and the house
then bought the same energy back (2.29 EUR): a wash before wear. It pays
only when the sale hour's wholesale price beats the later import price,
levies included (about 0.13 EUR/kWh above that hour's wholesale here);
- valuing the charge left at the end, wear and real Flemish levies make
selling worse and do not change the order.
- Owner decision 2026-09-29 (option A): selling is off until it can be judged
on the price the grid pays (`packmode.SELLING_ENABLED`, code kept), and
Leftover solar takes solar only until P4's forecast
(`packmode.GRID_CHARGE_MODES`); Cheap hours only still charges from the grid. Replayed
(16 days, as above): Solar first + Leftover solar 19.44 EUR net against
P2's 19.33; Solar first + Cheap hours only 19.57.
- Not done in P3: the forecast (P4), and a headroom too short for the run
moving the rest to the next cheap periods.
- OPT-15 (TEST-23 findings on 5a4cdc2, merged up to e3e4ba6,
`test_price_run_fixes.py`):
- F1, a charge stopped at a quarter edge and latched for the quarter. The
P1 averager opens a quarter only when a sample arrives, and the HA source
builds one 0.35 s after the reading, so the cycle on the boundary still
saw the old block: "no seconds left", headroom None, "no trade
(headroom)", latched for the new quarter. With modes on the guard now
projects the quarter `now` is in (`PeakTracker.project(roll=True)`,
crediting the held sample as the averager will), and only a measured
stop latches. Modes off still reads the old block (identity).
Since SAFETY-91, `next_quarter()` does the same for the guard at two
inverters (no stop there without the roll, same banked quarters); the
roll is what covers one inverter, where the old projection stops the
charge at every edge. With modes on at one inverter the roll also removes
the 1 s 0 W command at each quarter edge that SAFETY-81's sim showed, so
its modes-on digests are re-pinned (modes off equal to e3e4ba6). A
rolled projection is not graded by SAFETY-81's alarm (the old edge tick
read "no seconds left"), so a standing overload is still notified once
per quarter.
- F2, -1120 W export at a charge end against -257 W modelled. The replay
acted every 5 s on readings of that instant, and by then the lagged
battery had nearly arrived, so it never acted on a reading that still
showed the trade. The add-on cycles on each meter update (1 s on the rig)
on readings 1-2 s old: the two cycles after the reseed saw the trade's
+3300 W and slewed E up 555 W each. The loop now holds E until the meter
reaches the target (or `EDGE_HOLD_S`, 18 s; to 36 s while the battery
still moves). The replay gained `Lag` (dead time, reading age, the P1
debounce, 1 s cycles). Charge end, 800 W house: -10 to -51 W over eight
lag variants; without the hold -1370 W (meter 2 s). On e3e4ba6 the same
numbers hold. The hold gives the loop the target as its grid, so it
yields to SAFETY-95's drain tail, and any other claim that moves the
command ends it.
- F3, a charge refusal in a cheap run was never probed: the SAFETY-73 pin
stands only while the site asks that way, and nothing asked to charge.
Probing was chosen over changing the card: the probe is 200 W for 34 s
each 15 min under the same peak gates, and it wins back the run. In its
probe window the refusing battery is a charger, pinned at 200 W by its
own claim, and the others rest. A monthly drain (SAFETY-78) still comes
first, and with a day plan a probing Leftover solar battery goes only to
its planned level. At one inverter SAFETY-52 already ends a charge
refusal as soon as the house asks for power.
- Not fixed, same on e3e4ba6: with lagged readings a headroom-sized quarter
banks up to 1.4 W over the aim (48.6 W under the cap), and a battery
reading older than the meter can make a charge start look like a house
over the aim, a real guard stop.
- Review: an unmeasured stop that never latched let a headroom missing
every other cycle flip the charge 1799 times in 30 min, so the second one
in a quarter latches (at most 4 changes a quarter). A house that rises
1, 2 or 3 kW as the charge ends, from 30 s into the quarter to 15 s
before its end (two inverters, lagged readings): the hold adds at most
+33 W to that quarter against the loop without it, and the most any
quarter banks is 3431 W with the charge sized to the aim (3450 W; the cap
is 3500 W), 3456 W with a slow 8 s battery (the lag's own, the same on
e3e4ba6). The import during the hold is the rise, below the charge's
own, and a rise past that is the peak floor's, which ends the hold.
A maintenance drain, hold or charge that starts during the hold ends it
on that tick (replayed at edge + 3 s).
- OPT-20 (TEST-23 OPT-15 g7, `test_opt20.py`): a probe asks only PROBE_W.
The trade was sized at the probing battery's rating (2500 W), B's pin held
200 W of it, and the allocator put the other 2300 W on A, in its
maintenance charge phase (no mode range there, so nothing kept it out):
grid 3100 W for 34 s. Now the ask is PROBE_W for the probing battery plus
what the other chargers would have had without the probe (none: 200 W
alone), and it carries the pin only on the ticks the pin is filed (the
window's first and last cycle moved another battery's share by 200 W).
- Chosen: the probe sits on top of a headroom-sized charge, so beside a
normal charger its command is the same and the grid rises 200 W (about
7 W on the quarter) rather than that charger giving 200 W up. A probe
that the headroom or the bound cannot hold at 200 W is not filed.
- Unchanged, same on bf9019e: with a charge ask the other batteries cannot
give (one sign), so an idle-looking A that gives the house stops for the
window and the grid rises by what it gave. The trade line now says 200 W
for a probe alone; its words are UX-38's. The base P4 F4 path (a normal
trade on a battery in maintenance charge) is not touched.
## P4: the solar forecast and the day plan (2026-09-29)
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
today; `pv_forecast_tomorrow_entity` optional), read by `app/solarfc.py`.
Supported shapes: Forecast.Solar (state only), Solcast (`detailedForecast`)
and Open-Meteo Solar Forecast (`wh_period`). The unit is read, not guessed;
older than 3 h is stale (one missed hourly poll must not flag it).
- Trust: `solar_trust_pct`, default 70 (owner answer 5).
- House use: nothing the add-on recorded fitted. `tariff.db` holds grid
import per quarter, which the batteries shape. So `dayplan.HouseProfile`
keeps grid + battery per local hour for 7 days in the store
(`house_profile`), median per hour. It is net of the solar the house used,
so the solar sum never takes it below the night median. Solar readings are
not used (TEL-11). The first plan needs one day of it.
- Verdict (`dayplan.plan()`): trust x sum max(0, forecast - house) x 0.95
until the expensive run the cheap run is for. Solar first takes it first.
- Leftover solar may then charge from the grid up to min(ceiling, floor +
the house's use in the expensive runs / 0.95), less its sun share: only
the shortfall.
- Cheap hours only ignores the plan and fills to the ceiling (design
section 1; review of #156, see the replay below).
- Solar first never charges from the grid (owner, 2026-09-29).
- The level is kept unless it moves by min(1 kWh, 10 %). With no plan
(no, stale or failed forecast, no house profile, no capacity) every mode
is P3 option A exactly: `test_modes_p4.py` part 1 pins 12 digests from
5a4cdc2 (modes on, with and without an unavailable forecast).
- The plan is made with the runs (every 15 min, or at once when the forecast
comes or goes). A lost forecast drops the levels at the next cycle.
- Not done: the design's scaling by measured solar from 10:00 (it needs the
solar readings TEL-11 keeps out), the HA `goodwe_<inv>_plan` sensor, and
FCST-01.
- Replay (`tools/opt11p4_replay.py`, 16 recorded days, Solar first + Leftover
solar, net EUR, lower is better; site solar rebuilt from the recording,
forecast errors from Elia's day-ahead Belgian solar forecast):
- year-earlier hourly prices: P2 19.33, P3 option A 19.44, old one-period
trade 18.14, P4 19.15 (perfect forecast 19.14, 1.5 x too high 19.16,
missing 19.44);
- the days' real 2026 quarter-hourly prices (held out): P2 32.26, option A
32.01, old trade 31.40, P4 29.19 (perfect 29.21, too high 29.28, missing
32.01).
- Solar first + Cheap hours only, skipping its sun share (the first
build): 19.57 (same as P3) and 29.72 against P3's 29.41. The shorter,
moved runs left B lower, so later midday top-ups rested A while the sun
shone. Cheap hours now ignores the plan, which is P3 exactly.
- A forecast of no sun at all gives 19.14 and 29.33. So nearly all of the
gain is Leftover solar buying the evening's shortfall in the cheap run;
the forecast itself was worth 0.14 EUR in 16 September days.
- Bias: the site's solar is rebuilt from the same net use the replay
plays, and "recorded" keeps its hourly shape and adds only the national
daily error. Both favour P4; "national" (no house leakage) gives 29.25.
No threshold was tuned to these days. The 2026-09-24 PV recording could not
be replayed: it is only in the live Home Assistant.
@@ -1,79 +0,0 @@
# OPT-17 - the solar forecast from several entities, summed
**Status: for review.** Tests: `test_opt17.py`. Two options added.
A site whose forecast comes as several Home Assistant sensors (one per
inverter or roof side) had to build a template sensor to sum them. Now each
day can list them.
## Options
- `pv_forecast_entities` and `pv_forecast_tomorrow_entities`: lists of
sensors, default `[]`, the same shape as `pv_entities`.
- `pv_forecast_entity` and `pv_forecast_tomorrow_entity` are unchanged. An
install that uses them needs no edit.
- **Why new keys, not "string or list" in the old ones.** Measured on
Supervisor 2026.08.0 by `tools/supervisor_shape_probe.py` §J, as TEL-12's
§G did for `pv_entity`. Declared as a list, each forecast key refuses the
string an install already stores (`Invalid list for option
'pv_forecast_entity'`), and a `[]` default does not rescue it. The one
spelling that takes both, `match(.*)?`, also takes the integer 417 and
shows a plain text box, so a second sensor cannot be added in the UI.
- §J also shows: a 0.17.0 blob validates unchanged with the lists at `[]`;
the lists render as repeatable fields; a downgrade drops them and keeps
the single keys.
- `[]` in `options:` is load-bearing, as for `pv_entities`.
## Rules
1. **One key per day.** Setting the single key and the list for the same day
is refused at start, with one message: keep the list, clear the single one.
The list can hold that entity too. (TEL-12 lets `pv_entities` replace
`pv_entity` with a caution row. Here the brief asked for a refusal: a
doubled or dropped forecast changes what a battery buys, and no install
has both keys yet, so the refusal breaks nothing.)
2. **Each member in its own unit.** Every member is read as one forecast
sensor is today (`solarfc._day()`): Wh, kWh or MWh, never guessed. Then the
day's kWh are added up. A repeated id counts once.
3. **One member not usable means the day is not usable.** Missing,
unavailable, unknown, stale, not an energy unit, or a bad number: that
day's sum is not used, exactly as when the single sensor reads that way.
No today means no forecast (P3 option A). No tomorrow means today only.
4. **Hourly split.** If every member has a split, the splits are joined
(overlapping periods add up). If any member has none, the sum is spread
over the bell, as for a state-only sensor.
5. **Time.** The forecast is as old as its oldest member.
6. **One read.** All members of both days are read in one gather, inside the
gather that reads the inverters, so a slow sensor does not delay control.
7. **Shown and logged once.** With two or more members, each one not usable
is named on the status page by its place in the list (STYLE.md: no entity
ids on the page), as a caution row titled "Solar forecast" on Home. The
action log names the entity id, once when the set of failing members
changes.
8. **One member is the single key.** A one-member list reads, plans, shows
and logs exactly as the single key with that entity.
## Why
- A partial sum is a smaller number that looks real. Two of three inverters'
forecasts read as a duller day, and Leftover solar would then buy grid
energy the sun was going to give. TEL-12 took the same rule for the same
reason. No forecast is a known state with a tested fallback.
- The bell for a mixed list: joining one member's hours with another's total
has no honest shape, and the bell is the existing state-only rule.
## Cost
- One flaky member costs the whole day's forecast. A site with three
sensors loses the plan more often than one with a template sum that hides
gaps.
- Two more options (85).
## What this does not cover
- No weighting or calibration per member; FCST-01 is still the own forecast.
- Members on different integrations with different refresh times: each must
be fresh (3 h) on its own; there is no wait for a lagging one.
- The day plan itself, the trust and the house profile are unchanged.
- The Batteries screen still says only "no solar forecast now". The member
is named on the status page.
@@ -1,12 +0,0 @@
# OPT-23 and OPT-28 - the day plan counts the sun right, and the night charge covers the expensive-hour shortfall
**Status: for review.** Tests: `test_opt23_28.py`.
`dayplan.plan()` now buys, for Leftover solar batteries, what the house uses in the expensive hours less the sun counted for each hour, shared by capacity, and reads a state-only forecast from now for every span (OPT-23).
- **Shortfall.** `sum_h max(0, house_h - trust x sun_h)` over every expensive hour, `/ EFF`, from the floor. It replaces the use over the run with no sun counted, which on a day and night tariff bought for the whole day (G1, `reports/UX-69-design-report.md`). The sun before the run is unchanged. A run with its dear hours after sunset gives the old number.
- **House use.** An hour with sun is read at no less than the night base, as the sun sum reads it (the profile is net of the sun). An hour without is read as measured. An hour with no forecast counts no sun.
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by the grid (OPT-36 counts the other batteries first), as the screen says.
- **Cheap hours** still fills up; **Solar first** still never charges from the grid. The mode help says so.
- **OPT-36 (0.20.1):** Cheap hours and Solar first batteries give first; Leftover solar shares only the rest of the shortfall (`dayplan._cheap_give_kwh()`, `_solar_first_give_kwh()`). A Cheap hours battery counts the level its charge reaches: the cheap run, then later slots still worth charging in and no dearer than any slot from the expensive hours on, under the peak aim, x EFF stored. A Solar first battery counts what is left after the house's use until the hours start, each hour at no less than the night base. Live, 2026-11-02: A at 60 % went to 95 % while B at 94 % covered the hours. Owner, 2026-11-02: Solar first counts too. Test: `test_opt36.py`.
- **Not done:** the run length (OPT-25). On a dynamic tariff the Cheap hours count guesses where control will charge; a counted slot can still go unused, and that gap comes from the grid.
@@ -1,46 +0,0 @@
# OPT-27 - the night plan keeps the last fresh forecast
**Status: done on `release/1.0`, not released.** Tests: `test_opt27.py`.
**The defect.** A today sensor is stale after 3 h (`FRESH_S`) or when it is from
before local midnight. Forecast.Solar stops updating in the evening, so at night
every Forecast.Solar site had no forecast: a price mode's night cheap run needed
1.5x the cycle cost (TARIFF-24 round 2) and Leftover solar planned nothing.
Separately, one missed poll (`unavailable`) dropped the forecast at once.
**Where: `solarfc`, not the plan.** The last fresh reading of each sensor is
kept in `solarfc.Hold` and `read_sum()` uses it when a sensor has none. A hold
at the plan (`_wx_track`/`weather_held`) can only keep the 1.5x gap off. The
day plan, Leftover solar's levels, Home and the Forecast tab all read
`Controller.solar_fc`, so one kept forecast makes control and every screen agree
(UX-53) with no second rule to keep in step. `weather_held()` is unchanged: it
still keeps a run under way when a read is lost.
**The rule** (`Hold.stand_in()`). A kept reading is for one day. It covers a
sensor that is missing or stale, never one with a bad value or unit, and:
- for up to `FRESH_S` when the sensor is missing: a short outage, any time of day;
- from the end of the bell (20:00 local, `BELL_TO_H`) until local midnight, if the
reading is at most `KEPT_MAX_AGE_S` (12 h) old when used; after midnight the
tomorrow figure is today's until the bell starts (06:00), up to 15 h old
(OPT-34 changed this from midnight + 3 h);
- the day it is for: a today figure only on that day, a tomorrow figure as
tomorrow until midnight and, if the today sensors are still stale, as today's
until 06:00 (all sensors of the sum are needed, as for any sum);
- until the next fresh reading, which replaces it at once.
So a figure is never used for another day and never older than 12 hours (`KEPT_MAX_AGE_S`), except the morning case above: a 17:05 reading serves until 06:00 (12 h 55 min), a 09:05 reading only until 21:05. Until OPT-34 the figure ended at 03:00, and a charge under way lost its level there; OPT-34 closes that limit.
Daytime is as before: a sensor that stopped is stale after 3 h, and no forecast
all day is still no forecast (the night run still needs 1.5x).
**Where it is stored.** The controller's store, key `solar_fc_hold`, so an
evening restart keeps it. It is written when a reading's figure changes or
every 15 min (`SAVE_EVERY_S`), not on every poll. A damaged entry is dropped.
**The screens.** `Forecast.kept` is true when a reading older than `FRESH_S` is
in use; the forecast line then reads "Solar forecast read 17:05, kept for
tonight." (`home_words.solar.kept`, EN and NL). The event log says
"read, kept from 17:05".
**Not changed.** Control with modes off: it does not use the forecast. The
screens still show the line, so it says "kept", not "used for the plan". A
forecast that stays fresh. The hold does not look at the weather, only at the clock and the day.
@@ -1,69 +0,0 @@
# OPT-34 - the night charge keeps its level when the kept forecast ends
**Status: done on `release/1.0`, not released.** Tests: `test_opt34.py`.
**The defect** (OPT-27 rig test, F-1). The kept evening figure ended at midnight + 3 h.
With the sensors silent from 17:05 the Leftover solar battery's level went with
it in the middle of the charge: A ended at 49.8 % against 79.9 % with a fresh
figure. Whether Forecast.Solar refreshes after midnight on a live site is not
known, so the add-on does not depend on it.
**The rule.**
1. *A charge under way keeps its level* (`Controller._latch_charge()`). While
there is a forecast (fresh or kept), each battery that charges from the grid in the
cheap run takes its planned level into a latch (store key `night_charge_latch`).
With no forecast, `plan_targets()` gives the latched level, and the wide
no-forecast gap does not refuse the next slice of the run (`weather_held()`,
as TARIFF-24 does for the run under way). While a
forecast is there the latch follows the plan, up or down as a fresh figure moves
it. It lasts through every cheap run on and one price slot after the last, and it
ends at once when its end time passes, whatever the run state (it is also
ignored at a restart if it has ended). A battery that is switched off or
changed to another mode leaves it.
*Night bound (manager's call, 2026-10-31):* a run carries the latch on only if
it starts by the bell start (06:00 local) after the latch began; that run
finishes, plus one slot. Chosen over a fixed 12 h because the night tariff
(22:00-07:00) needs the 06:00-07:00 slice and 12 h from a late start would
reach into the day. With dynamic prices a chain of cheap slots cannot carry a
night's level into the next day.
2. *The evening's tomorrow figure is today's until the bell starts*, 06:00 local
(`BELL_FROM_H`, `solarfc._bell_start()`), not until midnight + 3 h.
Nothing has been produced yet, so it is still the whole day.
3. *Up to 15 h old for that case only* (`KEPT_MORNING_MAX_AGE_S`): a 17:05
reading is 12 h 55 min old at 06:00. It must have been at most 12 h old at
midnight, so a 09:05 reading, unusable since 21:05, does not come back for five
minutes after midnight. Same-day figures and the daytime rule keep 12 h and 3 h.
4. A fresh figure replaces the kept one at once, as before.
*Rig F-1 fix:* once every today sensor has given a fresh reading after
midnight, the evening figure is no stand-in for that day any more
(`Hold.replaced()`). When that reading goes stale (3 h), it is kept or not
as any today reading (OPT-27), so it is "no forecast" at night and the latch
keeps the level it set; the older 17:05 figure never comes back. Rig: A
stopped at 69.5 % after a brighter 02:00 figure, then charged back to 94.4 %
from 05:15 when the 17:05 figure returned. Test: `test_opt34.py` F-1.
**Step 1 alone is enough for the owner's shape, steps 2-3 are still needed.**
With the window put back to midnight + 3 h the owner's night still ends as a
fresh figure ends it (A 86.9 %, B 95.0 %), because the charge had started at
22:00. But a charge that has not started by 03:00 (the price dips at 04:00, or
the headroom is taken) has no level to latch: A ends at 38 % against 78 %. The
plan, the screens and a restart also need the figure itself.
**Screens.** "Solar forecast read {at}, kept for tonight." is still true until
06:00 and unchanged. After the figure ends, with a charge still going on: "No
solar forecast now. The charge under way goes on to its planned level." /
"Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau."
(`home_words.solar.latched`, only while a cheap run is on and a latched battery is below its level).
**Not used.** A figure over 15 h old; a morning figure at night (12 h); a figure
for another day; a bad value or unit; and no latch for a charge that has not
started, for a battery that is not charging, or after the cheap runs have ended
(plus one slot).
**Not changed.** Charge sizing, the peak guard, Cheap hours, modes off, arbitrage.
A fresh figure that LOWERS the level during a charge still lowers it, as before.
**Mutants** (all red in `test_opt34.py`): no latch; window back to midnight + 3 h;
15 h without a limit; no "usable at midnight" rule; the latch without the narrow
gap, without its save, or not kept through the next run; the window 6 h after
midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off or shown without a cheap run; no expiry check; no pruning of a changed mode; no night bound; a left-the-plan battery keeping its level; no `replaced()` check (F-1); `replaced()` with one of several today sensors (any, not all).
@@ -1,117 +0,0 @@
# PRICE-01 - the owner's price server as the price source, with fallbacks
**Status: for review.** Tests: `test_price_service.py`. One option added,
`price_service_token` (password). Follows [[TARIFF-10]], whose section 4 this
card answers for one provider. Design: the owner's price service note of
2026-10-05. Owner go 2026-10-05 and 2026-10-14.
## What it is
`price_provider: price_service` reads `GET https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`
with `Authorization: Bearer <token>`. The answer is the energy-charts layout
plus `covers`, so the existing columnar parser reads it. The price is wholesale
energy only; the framework adds markup and taxes, as for `generic_dayahead`.
## Rules
1. **The host is a constant in the plugin**, declared as its only egress host,
HTTPS only. The operator's `price_feed_url` is not read by this provider.
*Cost:* every client talks to the one host, and moving the server is a new
add-on version. *What replaces the per-site setting:* the egress allow-list,
TLS, and a token per client that the server can revoke.
2. **The chain is server, then energy-charts, then the saved prices**
(`app/price_chain.py`). A step counts only if it has a price for *now*: a
server that answers with old prices is skipped like one that is down. A step
down and a step up are each one log line, written when the step changes. The
operator's `price_feed_url` is cleared for the energy-charts step, which is
asked for its own feed.
3. **The saved prices are `/data/price_history.json`**, the file `PriceHistory`
already keeps (the last three Brussels days of what was published). They are
the all-in prices as stored; a change of markup shows from the next fetch
that works. A read from the store is not filed again.
4. **No price for now** means no published price covers the present moment. Not
"none for the coming hours": before about 13:00 tomorrow's prices are not
published on any healthy day, and carried hours are already marked estimated.
It sets one status row (*Prices*), one Home plan line (English and Dutch),
and one notification (*No prices*, English and Dutch), taken down when a
price returns. A start-up before the first refresh finished is not a gap.
The notification is not re-asserted after a Home Assistant core restart
(SAFETY-34 does that for latched safety alarms); the status row stays.
*Open for the owner:* if "coming hours" should mean a look-ahead of N hours,
that is a change to `gate_for()` and `PriceWatch`, not to the chain.
5. **The maintenance charge gate** (owner's decision, 2026-10-14): the charge may
run when the *current 15-minute price is at or below today's average*, today
being the Brussels day, computed from the provider's prices. This is the rule
the Home Assistant pair gave (`price_service.yaml`), so a site that moves from
the pair to the provider does not change when the maintenance charge runs.
It is not `TariffPlan.cheap_now()` (strictly below the mean of the firm
hours of the whole plan). This replaces, for this provider only,
TARIFF-10 section 4: its three reasons are answered as follows. (1) Two
writers: the pair beside a provider stays refused; `static_profile` beside
`price_service` is accepted and no longer writes `cheap`. (2) An expired or
absent plan shuts the gate (the charge waits), as an unavailable HA sensor
did. (3) The basis is the one above, not `cheap_now()`.
6. **The token is a secret.** `password?` in the schema; hidden on the settings
page (`uilayout.HIDDEN`), which can keep `price_service` but not choose it;
never put in a message by the plugin; taken out of any reason text by
`price.scrub()`; the real transport reports only a status or an exception's
name. validate.py refuses `price_service` with no token, a token with a
space or line break, and one under 16 characters (so `scrub()`, which skips
values under 4 characters, always has something to take out). Round 2: the
token is in no page and no JSON answer of the web app. Every `password`
option of the schema is shown as `********` (set) or empty, in
`/config/model` (`options`, `running`, `ui`) and in a stale refusal; the
classic page draws it as a password field; a save or a validate that sends
`********` or nothing keeps the stored value, any other value replaces it.
A token can therefore be changed but not cleared from the page.
7. **A fixed tariff beside `price_service` is allowed and quietly noted.** It is
still the plan's last fallback (`resolve_tariff` -> `_degrade`); only the
cheap window and the gate ignore it, because they read the provider's
prices. Keeping it set is the recommended setup, and clearing it triggers the
"static_profile is empty" warning, so no notification (round 2's *Fixed
tariff not used* was false and fired on every sound start; round 3 removed
it). Instead one INFO line at start (English, as every log line) and an info
note beside the setting (EN and NL, "With the price service, the fixed
tariff is only used if no prices are available."), both
only with a non-blank `static_profile` (whitespace counts as blank). At
start the add-on dismisses the old notification id, in case a pre-release
build raised it.
8. **The no-price words show only with the maintenance charge on** (owner,
2026-10-14): the status row, the Home line and the notification say
'maintenance waits', so with `maintenance_enabled` off none is shown.
## Where the gate equals the Home Assistant pair, and where it does not
Equal: `test_price_service.py` section 8 feeds the same day both ways, to the
rules in `price_service.yaml` (price at the quarter, average of today's
quarters rounded to 0.00001 EUR/kWh, `<=`), over 6 days including both
clock-change days, at three instants in every period, and through a real
`Controller` for a whole day. Every answer is the same.
Not equal, by design or by arithmetic:
- The comparison is made on all-in prices. With a flat taxes figure and a
markup and taxes of at most five decimals the offset cancels and the answer
is the same. With `price_taxes_profile` (an hour-varying levy) the all-in
prices differ by hour, so the answer can differ from the wholesale comparison
the HA sensors made. A site on that profile should check before moving.
- A quarter within 0.00001 EUR/kWh of the average can differ if markup or taxes
have more than five decimals.
- The plan is retired when no refresh has succeeded for 90 minutes
(`TARIFF_MAX_AGE_S`) and the gate shuts then. How long the HA pair kept an
answer after its REST sensor stopped updating was not measured here.
- The average is over the firm prices the provider holds for today. After a
restart in the middle of a day with only the saved part of today, it is the
average of that part.
## Not covered
- **No real answer of the server was seen.** No token was available. The
layout is the design's; `test_price_service.py` uses a mock and, for the
parser, the real energy-charts capture reshaped with the design's extra keys.
The first capture from the live server (rig test) should replace the mock.
- The 429 limit (about 60 calls an hour) is not approached: one fetch per
30 minutes, and at most two requests when the server fails.
- Battery modes read the plan as for `generic_dayahead` and were not changed.
- The `price_service` REST and template sensors in Home Assistant are not
touched; they stop being read when the pair is cleared.
@@ -1,89 +0,0 @@
# PRICE-02 - the trading verdict skips price periods that have ended
**Status: for review.** Tests: `test_price02.py`. Found by AUDIT-01 (F1).
The refresh floors the plan to the top of the hour, so on the 15-minute feed
it holds the periods of this hour that are already over. The verdict took the
cheapest and dearest over every point. With modes off it could aim at a period
that had passed and then do nothing, for up to 45 min plus the refresh lag.
With modes on the `allowed()` gate could be open only because of such a period.
Decision: `TariffPlan.arbitrage(now=...)` drops the points whose period has
ended (`end <= now`, the span `price._cover_table()` gives, as
`priceruns.firm_slots()` does). Its three callers pass their own `now`:
`arbitrage.decide()`, `Controller._mode_roles()` and `homeview.trade_verdict()`.
- Taken in the verdict, not when the plan is built: a plan built at 03:00 is
still used at 03:20, and the Home chart keeps the day's earlier bars.
- An hourly point that is still current stays in. But an hourly or static plan
is rebuilt every 30 min counted from add-on start, so it is usually off the
hour and, until the next refresh, holds the hour that has just ended. That
hour is dropped too (round 3, owner-accepted 2026-10-22). On a day/night
tariff all day hours tie and the old verdict took the first, the ended one,
so it paused the trade every hour until the refresh: 91 on/off changes over
2 days with the refresh at :10, 4 now (4 before as well, with the refresh on
the hour). With the refresh on the hour nothing changes.
- `now` is required: keyword-only, no default, like the other three arguments. A
caller that could leave it out would get the elapsed periods back and the
defect with them. A test that wants the whole series passes an instant before it.
- The sentences of this verdict in `arbitrage.py` (status row and action log,
English only) say "price period" where they said "hour": the idle one now
reads "now is not the cheapest or the dearest price period". The UI texts
that say "cheapest hour" (AUDIT-01 T1-T5) are not touched here.
- Not changed here: F2 (static profile sampled at HH:00) and F3 (carried value).
Measured: with price off, every command over the modes-off replay is
byte-identical. On a static tariff rebuilt on the hour it is too; rebuilt 20
min off the hour the commands differ (digest 79f06d6381ff3358 on release/1.0,
18de19d9fe919fe5 here), for the reason above. On the dynamic replay every command is
the same too; only a sale's sentence quotes a smaller "above the cheapest"
figure once the first cheap period is over. The identity pins in
`test_modes_p3.py` and `test_price_run_fixes.py` hide just that figure and the
word change (`opt11p3_identity.trade_digest()`).
## Ties (round 2)
The verdict takes `min`/`max` over the points still ahead and the first one wins
a tie, so with equal prices it is the first period still ahead, which is the
current one. Before, it was the first of the whole plan, often one that had
ended. Two effects follow, and both are the card's intent, not a side effect:
- Equal quarters (hour-constant data, or a feed that prints the same price in
several quarters) now act in every quarter of the cheap (or dear) block,
not in the first only. Test: `test_price02.py` part 6.
- After the cheapest period has passed, the next cheapest still ahead is the
target, if its spread still clears the cost. A hourly plan did this at every
refresh already (it started at the current hour); a 15-minute plan did it
only from the next hour on, because it kept the elapsed quarters of this one.
Measured with `scratch/PRICE-02/dev/tiesim.py` (refresh every 30 min from the
floored hour, verdict at 7 min into each period, threshold 0.05, old = the
verdict over the whole plan, new = this card):
| data | days | acting periods old -> new | action changes old -> new |
|---|---|---|---|
| real 15-minute day, energy-charts 2026-09-09 | 1 | 4 -> 5 | 6 -> 6 |
| real 15-minute days 2026-09-09 and -10 | 2 | 14 -> 26 | 24 -> 24 |
| EPEX 2025-26 hourly prices as four equal quarters | 12 | 18 -> 72 | 36 -> 20 |
| the same with random +-0.002 noise per quarter (no ties) | 12 | 18 -> 33 | 36 -> 48 |
So a real 15-minute day does not switch more often (24 -> 24), and a day of
equal quarters switches less. A synthetic day of noisy, tie-free quarters does
(3.0 -> 4.0 changes a day): each period that passes hands the target to the
next cheapest one ahead. Every change is still at a period edge, never within
one. The replay in `test_arbitrage.py` (synthetic year, hourly) gives the same
ledger on the old and the new code once its ties are broken (280 round trips,
85 held, both): it printed one maximum twice per day, and the old fixture
tested the first of them only because its plan began at midnight.
## Battery throughput (round 3)
On real 15-minute days the trade runs longer: about 1.5 to 2.5 times the
minutes spent charging or discharging, with fewer on/off switches (for example
78 -> 68, 92 -> 72), over 16 days (2026-09-02 to 09-17, a decision every
minute, refresh at :00 or :10, tomorrow's prices known always or from 13:00;
reviewer's sim). Through a modelled 10 kWh battery that is 27.5 -> 42.6 kWh
(refresh at :00, tomorrow always known); 19.6-31.9 -> 43.4-48.7 kWh in the
other three variants. Charge to
discharge reversals are unchanged (10 -> 10, 13 -> 13). More cycling is the
price of not pausing.
@@ -1,166 +0,0 @@
# SAFETY-07 - the quarter-peak projection guard (with SAFETY-24 and CFG-03)
**Status: SHIPPED.** Tests: `test_peakguard.py`. Written down by MAINT-20 from
the long module comment in `app/peakguard.py`, so the module can stay short.
Later changes have their own notes: [[SAFETY-25]], [[SAFETY-32]], [[SAFETY-45]],
[[SAFETY-79]], [[SAFETY-81]], [[SAFETY-87]], [[SAFETY-90]], [[SAFETY-125]],
[[SAFETY-126]].
## The hazard
The capacity tariff bills the month's single worst 15-minute average offtake,
with no averaging and no forgiveness. specs.md:583 records one 4998 W quarter
that moved a monthly peak from 4832 W to 5013 W and billed the whole month at
it. SAFETY-18 alarms on a frozen meter (`meter_freeze_s`, 300 s) but a frozen
meter can rail the command in 75 s (freeze.py's table, `max_w: 5000`, meter at
3000 W or more), against a 900 s quarter. The guard is the cap on that. It
reads freeze.py's `looks_frozen()`; it is not a second freeze detector.
## The mechanism: one `lo` limit at `P_PEAK`
specs.md 10.1 row 2 wants pre-emptive discharge near the limit and a hard clamp
on charging above it; 9.1 says discharge and export are never capped by the
peak guard. A lower bound on the setpoint is the only shape that does both: a
floor cannot stop the loop discharging harder (so 9.1 holds by structure), it
clamps charging when above the standing charge, and it forces discharge when
above zero.
- `P_PEAK` (50) is above `P_LOOP` (10) and below `P_MAINTENANCE` (60) and
`P_SAFETY` (100). arbiter clause 2 applies only limits at or above the
winning set's priority, so a P_SAFETY 0 W stop is not pulled off zero.
- A limit does not out-vote another limit: clause 2 takes the most restrictive
with no priority between limits. The floor (`lo >= 0`) and the maintenance
charge-only shaping (`hi <= 0`) therefore resolve to `lo > hi`, a
contradiction, which the arbiter answers with 0 W (battery parked in the
worst quarter). The rule for two crossing limits is suppression, not
priority: main.py drops the shaping while the guard is engaged
(`shaping_suspended()`). The floor is clamped to `achievable_w` to close the
only other crossing pair (the P_SAFETY rating bounds).
- Arbitrage arrives as a SET below `P_PEAK`, so clause 2 binds it with this
floor without any edit here (9.1: peak shaving outranks arbitrage).
## The floor is derived
With `projected = (acc + held * remaining) / 900`, the watts that must come off
the meter are `excess_w = 900 * (projected - ceiling) / remaining`, and the
floor is `floor_w = batt_w + excess_w`.
1. The claim is filed while the floor is positive, not while the excess is.
`excess` falls to zero as soon as the battery is already removing it, and a
guard that released there handed the actuator back and re-engaged next
cycle. The first cut did that on 2026-08-20's billed quarter: 17
engage/release cycles in 900 s, and the quarter still ended 49 W over.
Thresholding `batt_w + excess` (the offtake with the battery at zero, less
the level the quarter can afford) decays continuously, so there is no
hysteresis constant. 10.2: an EMS that oscillates is worse than none.
2. The gain 900/remaining is 1.0 at the start of a quarter and grows. The guard
is gentlest when its evidence is weakest. No minimum-evidence threshold was
added: it would delay the guard on a sustained rail from second zero.
3. `batt_w` is the measured battery power, not the command. The bill is what
flowed, and a floor built on the command winds up to the rail when the
inverter cannot follow; one built on the measurement stops rising when
delivery does.
## The ceiling
`ceiling_w = max(peak_cap_w, the month's banked peak)`. Once a month's peak
stands at 5013 W, holding later quarters to 3500 W buys nothing. Only an
`OBSERVED` reading may raise it; `NO_RECORD` and `NOT_OBSERVED` leave it at
`peak_cap_w`, so a peak store that cannot be read makes the guard stricter,
never looser. That is also why the guard does not use `headroom_w()`: a peak
read as 0 W returns full headroom, which is a guard that is not there.
SAFETY-79 aims `peak_margin_w` under the ceiling; the ceiling stays the real
limit for reports and the bill.
## Blindness comes first
The projection is graded only if the quantity under it was measured. Three
ways it was not, checked before grading:
- No open block: a fresh averager answers an all-zero `Projection`, which sits
under every ceiling. That is "no quarter", not "the quarter is fine".
- Coverage: a meter gap longer than `max_hold_s` leaves seconds the averager
does not accumulate (holding a dead meter's value forward is how a fabricated
peak gets onto a permanent record), so the projection reads low. The
threshold is `max_hold_s`, not a new number. SAFETY-90 fills gaps once
readings return.
- Frozen: samples keep arriving, coverage is perfect and the projection is a
confident zero while the real meter is at the rail.
The blind action is `floor_w = 0.0`: no importing while the billed quantity
cannot be observed. It is one-directional (forbidding charge only lowers
offtake), so a false blindness costs a charge and never pack energy.
## Why it trips where SAFETY-18 only notifies
The healthy-day false-engagement rate is zero (2026-08-23, 95 quarters;
test_peakguard.py re-measures it from the committed capture). The action is
level-triggered and unlatched: it is re-derived each cycle and releases when the
projection drops, so a false engagement costs one cycle, not a parked battery.
It is a limit, so every safety stop outranks it. It is the only guard whose
trigger is the billed quantity itself.
## Saturation is reported, not alarmed as a bug
10.3 treats an out-of-range setpoint as a bug (SAFETY-03). A peak floor above
the inverter rating is not: it is a 5900 W house on a 5000 W inverter (2026-08-20
15:45). The floor is clamped to `achievable_w`, the decision carries
`saturated`, and main.py raises one notification.
## SAFETY-24: the energy backstop the guard shipped without
The graded branch can force discharge (878 Wh in a day on the 2026-08-20 replay)
and nothing below it consulted the state of charge; the BMS was the only
backstop, which protects the pack and not the operator. `decide()` now takes
`soc` and `soc_floor` and `discharge_permitted()` answers.
1. Not a trip. When it binds, the guard stops asking for more discharge and
commands nothing; every stop in 11.2 still outranks it.
2. The action is `floor_w = 0.0` (forbids charging, forbids nothing else). A
`lo` limit cannot cap discharge, and a negative floor would destroy that.
The loop is not bounded: self-consumption may still drain the pack past the
floor. [[SAFETY-25]] measured it (2026-08-20, 19:00-22:00 UTC, 830 W mean:
the pack ends at the BMS floor, 10.0 %, whatever `peak_soc_floor` is) and the
owner decided on 2026-09-02 that the BMS stays the only backstop for
demand-driven discharge, a loop-side reserve being deferred to ALLOC-01.
3. Graded only after `wanted > 0`, where a forced discharge is asked for.
4. An unreadable SoC is not a full battery. `soc=None` and `soc_floor=None` are
floor-limited. Both arguments are required keyword-only, no default (TEST-14).
5. Failing to shave is not silent: `floor_limited=True` and one notification.
It excludes `saturated`, since it asks for nothing.
The action log says "the peak reserve", not "the SoC floor": `peak_soc_floor`
(“Peak reserve”) bounds this guard only, and the wider words would contradict
a loop that is draining the pack at that moment.
## CFG-03: two options, mirrored once
- `peak_soc_floor` is the EMS floor, not `maintenance_soc_floor` (the monthly
recalibration's drain target); sharing a key would couple features whose
right values differ. Default 20: the reference pack is 300 Ah at 51.2 V,
DoD 90, 13.8 kWh usable; the replay's 878 Wh is 6.4 %; a floor 10 points above
the 10 % cut-out leaves about 1.6 worst days of room.
- `bms_soc_floor` is the pack's own cut-out (register 0x0560, 10 on the
reference unit). It exists so validate.py rule 8 can refuse a
`peak_soc_floor` at or below it.
- Both defaults live once in `PEAK_OPTION_DEFAULTS` (TARIFF-02's pattern),
imported by validate.py and main.py and pinned to config.yaml by the test.
The module reads no option itself.
- `maintenance_soc_floor` is the ambiguous name of the two. Decision: keep it,
carry the distinction in DOCS.md; revisit if a third floor appears.
- SAFETY-07 itself added no option: `peak_cap_w` was already validated
`int(500,15000)`, and 14's `capacity_peak_limit_kw` stays declined (a kW key
beside a W key is the mismatch trap).
## Per-pack floors (SAFETY-32) and phases
`bms_soc_floor` became a roster key; `pack_floor_pct()` conjoins it with the
site's EMS floor, and rule 8 names the unit it refuses. `peak_soc_floor` stayed
one number for the site, so with cut-outs of 10 and 18 the operator sets it
above 18; the per-pack EMS floor is the open question in [[SAFETY-32]]. The
`min()` over measured SoC (`Controller.pack_floor_soc`) is still right for the
site verdict; `pack_permissions()` is the per-pack answer.
Nothing here reads a per-phase quantity: the sum/max/net choice is an open
owner question, defaulted to NET inside `billed_w()`, so this file does not
change whichever way it is answered.
@@ -1,406 +0,0 @@
# SAFETY-10 — shadow mode, and why "default-on" is not `true`
**Status:** decided and implemented on branch `SAFETY-10`.
⚠️ **AMENDED BY SAFETY-47, and three of the claims below were false when this
was merged.** §5's "the strategies are unaffected" exemption, §4's peak-guard
row and §6's evidence table are corrected in place and each correction is
marked. The amendments are marked rather than rewritten silently, because what
this card believed is part of why the defect shipped. See
`DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md`.
**Card:** SAFETY-10 — *"Shadow mode (default-on, log-only decisions)"*.
**Nothing here reached the inverter.** No deployment, no `ha addons update`, no
contact with 192.168.2.158 or 192.168.2.221. Everything below was measured in
the CI image and on TEST-23 stage 3's closed-loop rig.
---
## 0. The four things a reader should take away
1. **"Default-on" is delivered as `auto`, not as `true`, and the difference is
an install that is regulating a real battery today.** The Supervisor
composes an add-on's options as *its defaults, overlaid with the stored user
options*, so a new key with a new default reaches **every existing install**
on the next update. A boolean `shadow_mode: true` would silently stop the
reference site. §1.
2. **Shadow mode WRITES. It writes 0 W, on the ordinary heartbeat, through the
ordinary single writer.** "Commands nothing" implemented as "stops writing"
would leave the inverter latched on whatever was standing — it has no meter
timeout — and would put the ESP32 into a tripped stale-input watchdog for
the whole run. §2.
3. **The sign watchdogs are inert, and that was the decision that had to be got
right.** Fed the shadow decision they would trip within `sign_disagree_s`
and **latch a trip that survives into live operation and a restart**. §4.
4. **The loop leg's shadow decision is one slew step, not a trajectory, and
that is a real limitation of this feature.** §5.
5. ⚠️ **SAFETY-47: and neither is anything else that depends on the plant
answering back. A shadow run cannot surface a closed-loop defect at all**,
by the same construction that makes it safe — the battery is held at 0 W, so
every measured input describes a plant that did not respond. Of the three
defects §"WHAT DEFECT THIS PREVENTS" was written for, exactly one —
TARIFF-14's wrong quarter-hour — is visible from a shadow run. §5, §6.
---
## 1. The default
### The literal reading, and what it costs
`config.yaml`'s `options:` block **is** the default. There is exactly one of it,
and HA's Supervisor resolves an add-on's effective options as
```
{**config.yaml options, **the stored user options}
```
A key that has never been stored therefore takes the file's value — on a fresh
install and on an install that has been running for months, identically. There
is **no mechanism in the add-on options system** by which one key can default
differently for the two. So the card's title, taken at face value, is:
> the next `ha addons update` stops the battery at the reference site, and the
> only symptom is a battery that has stopped doing anything.
That is strictly worse than every defect shadow mode exists to catch. It is
refused.
### What was shipped instead
`shadow_mode` is a three-valued `list(auto|on|off)` defaulting to `"auto"`:
| value | resolves to |
|---|---|
| `off` | live |
| `on` | shadow, always |
| `auto` | shadow **while control is stopped**; live once it is started |
`auto` reads `Controller.auto` — the persisted start/stop state, which a fresh
install takes from `auto_start` and which the *Start / stop control* button
moves. No new persisted state, no migration, no guess:
* a **fresh** install ships `auto_start: false`, has no stored `auto` key, and
therefore comes up **in shadow mode**. It observes a day of decisions before
it is ever allowed to command anything. That is the card's intent, delivered
where it is safe;
* an install that is **controlling** is started, so `auto` resolves to live and
**not one watt changes**;
* an install that is **stopped** keeps its actuator at 0 W exactly as before —
the same number, from the same writer, on the same heartbeat. What changes is
what the status page and the action log **say**, and that the decision path
now runs and is recorded.
⚠️ **Under `auto`, pressing Start leaves shadow mode.** That is the point of
`auto`: the button means "control the battery". An operator who wants the
decisions recorded *while the loop runs* — the configuration this card exists
to make possible — sets `on` explicitly, and cannot reach it by accident.
⚠️ **An unrecognised value resolves to SHADOW, and is REFUSED at startup.**
`shadow.mode()` fails toward inaction; `validate.py` rule 11 refuses the value
by name so the reinterpretation is never silent. A typo is not a decision, and
an install silently left in shadow mode commands nothing indefinitely while its
status page calmly explains that this is intentional.
---
## 2. "Commands nothing" is a number, not a silence
The inverter holds its last command **forever** — `HANDOVER.md` §6 says so, and
`firmware/goodwe-master.yaml`'s header records what it cost when a controller
stopped refreshing: *"Illusmart went silent mid-command and the inverter held
+5000 W for 113 s"*. `firmware/goodwe-master.yaml` states the contract that follows — *"HA
must now REWRITE the setpoint every ~10 s even when the value has not
changed"* — because without a refresh, **"stale" and "steady" are the same
thing on the wire**.
So shadow mode holds the actuator at `shadow.WIRE_W` = **0.0 W**, refreshed on
the ordinary heartbeat. Two consequences were weighed and are recorded:
* **it is not silence.** A shadow mode that stopped writing would leave the
standing command up until the ESP32's 30 s watchdog noticed — and at a site
with no such board, forever;
* **it does not use an alarm as a mode.** Letting that watchdog trip for the
duration of a shadow run teaches the operator that `wd_tripped` is normal,
which is exactly the training that makes it useless when the add-on has
actually died.
### It is not a second writer
SAFETY-28's rule is one actuator, one writer. Shadow mode files **no claim**,
owns **no transport**, and calls nothing in `hass.py`. It is a **substitution**
inside `Controller._write_unit()` — the single choke point every write in this
add-on already reaches, including the heartbeat, the stop button, `shutdown()`
and `maint_abort()` — in exactly the form of the three substitutions above it
(non-finite → 0 W, unauthorised → 0 W, clamped → the rating).
It is the **last** of them. Below SAFETY-28's gate and below SAFETY-03's clamp,
so both still grade the number the decision path actually produced, and a
shadow run can report *"it would have hit the rating here"*.
---
## 3. What the run records
| surface | what it carries |
|---|---|
| status banner | `SHADOW MODE - the battery is NOT being controlled...`, **first in the cascade**, amber — red, with the latch named, if one is standing |
| status row `Shadow mode` | which rung decided, the watts it **would have commanded on this cycle**, where the inverter actually is, and `Resolution.explain()` — plus, when a measured billed quarter sized those watts, SAFETY-47's caveat saying so (`shadow.SIZED_FROM_METER`) |
| status row `Commanded` | `0 W` — the truth, which the row above reconciles with the `Why` row below it |
| action log | one line when shadow mode comes on; one line per **change of decision** (winner, rounded watts, or SAFETY-47's caveat state), carrying the arbiter's own sentence and the same caveat the row carries — `shadow.log_line()` is the one sentence both read |
| MQTT | `status: shadow`, its own value, below `failsafe` |
The action log is **edge**-logged, not level-logged: the log holds 40 rows, and
a line per cycle would flush the context of the run's own findings inside four
minutes at the 15 s cycle floor.
---
## 4. The failsafes, one by one
| failsafe | in shadow mode | why |
|---|---|---|
| SAFETY-02/30 sign watchdogs | **INERT** | They grade `Inverter.standing_w()` — what this controller has standing on the wire — and shadow mode holds that at 0 W, so they cannot arm. ⚠️ Fed the shadow decision instead they would disagree with a meter that never saw it and **latch a trip that survives into live operation and a restart**: shadow mode would manufacture the failsafe it exists to help debug. ⚠️ **Two rungs hold it there** — `last_write` (the wire gate) and `inv.target` (the re-seed) — which is MUTATION.md's masking shape, so the mutation that reproduces the hazard defeats both as a pair (M6). Driven singly, dropping the re-seed does not reproduce it at all — measured |
| SAFETY-03 clamp | **OBSERVES** | Runs on the decision, sets `clamp_last`, logs every engagement. Changes no command |
| SAFETY-28 write gate | **OBSERVES** | Same placement, same reason: the question is who chose the number |
| SAFETY-18 meter freeze | **OBSERVES**, unchanged | It takes no action live either; both halves of its conjunction are as true in shadow |
| SAFETY-07/24/25 peak guard | **OBSERVES, AND SEES ONLY THE HOUSE** | Level-triggered, unlatched, files its claim as usual, so the recorded decision is the real one *for this meter*. ⚠️ **AMENDED BY SAFETY-47.** This row used to end "SAFETY-45's limit cycle is visible from a shadow run, which is the point". It is not: every term of `wanted = batt_w + excess` is measured, and in shadow mode the battery contributes nothing to either. Measured on the rig, same hour, same unsized code — **410 of 720 cycles engaged live, 0 of 720 in shadow** |
| SAFETY-31 circulation | **OBSERVES AND MAY LATCH** | Its evidence is *measured* power. Packs pushing against each other while all are told 0 W is a real fault |
| SAFETY-42 delivery | **OBSERVES AND MAY LATCH** | Same, against a share that is 0 W |
| operator stop | **SUPPRESSED AS A CLAIM, ENFORCED AT THE WIRE** | The one deliberate change. Its `P_SAFETY` 0 W claim out-ranks everything, so leaving it in makes the recorded decision `"stopped"` on every cycle and the run answers the wrong question. The actuator ends at the same number either way, and the wire substitution is **strictly stronger**: a claim can be out-ranked, and it cannot |
| inputs missing, non-finite, allocator refusal, arbiter contradiction | **UNCHANGED** | All resolve to 0 W, which is what the wire gets anyway |
⚠️ **A latch that is already up stays up.** Nothing here clears one. The shadow
banner **names** any standing latch and goes red for it, because a latch that is
up while the controller is inert is up the moment it stops being inert.
⚠️ **Maintenance is not in that table because shadow mode does not touch it.**
The state machines are ticked on every cycle whether control is started or not,
so a stopped install already runs them against a battery that does not move.
Shadow mode inherits that, unchanged and unimproved. Recorded here rather than
quietly fixed under another card's name.
---
## 5. The limitation, stated rather than discovered later
`Controller._shadow_settle()` re-seeds the carried command (`self.target` and
each `inv.target`) to `WIRE_W` at the **end** of every shadow cycle — after
every in-cycle guard has read the decision. Three machines read that value as a
statement about the **hardware**: the loop's `prev_w` and integrator,
`standing_w()`, and SAFETY-42's share comparison. Re-seeded, they track reality,
and **the exit is safe by construction** — the carried command already equals
the standing one, so the first live cycle slews from 0 W like any other.
⚠️ **The cost is real.** The loop's `prev_w` is therefore 0 W on every shadow
cycle, so the loop leg's recorded decision is **one slew step from the standing
command** and stays there. A shadow run answers *"what would this controller
command NOW, from where the plant really is"* — a true statement about the
present — and **not** *"what trajectory would it have followed"*. The trajectory
is a closed-loop property, it did not happen, and reporting it would be a
simulation of a plant that does not exist; three cycles in, such a number
describes nothing.
⚠️ **AMENDED BY SAFETY-47 — THE PARAGRAPH THAT STOOD HERE WAS THE DEFECT.**
It read:
> The strategies are unaffected: arbitrage, the peak guard and maintenance all
> file `SET` claims at a fixed power from level-triggered inputs. That is why
> the three defects this card was written for — SAFETY-45's 28 onsets an hour,
> TARIFF-14's wrong quarter-hour, SAFETY-46's ceiling churn — are all visible
> from a shadow run, and the loop's ramp is not.
It was true until **SAFETY-45 merged, one commit before this card**. That
change makes the buy leg `min(asked, headroom)` with
```
headroom = (900 / remaining) x (ceiling - projected)
```
and `projected` reads the **open quarter's accumulator** — the meter — which is
a function of what the battery did earlier in that same quarter. The strategy
is closed-loop, and the loop's one-slew-step limitation above is not a special
case of anything: **it is the general case**, and the limitation is the whole
feature's, not the loop leg's.
**The correct statement, and it is now graded rather than asserted:**
> A shadow run cannot surface a **closed-loop** defect — one whose stimulus is
> the plant's response to the controller — because the battery is held at 0 W
> and there is no response to be the stimulus. **Which** hour a strategy picks
> and **why** are unaffected, because the clock and the price series are not
> functions of the battery. **How hard** it trades is not.
Of the three defects this card was written for:
| | visible from a shadow run? | measured |
|---|---|---|
| SAFETY-45's limit cycle | **no** | 28 charge onsets live, **1** in shadow; grid −455.5 … +3200.0 W live, **flat at the 1200 W house** in shadow; guard engaged 410/720 live, **0/720** in shadow |
| SAFETY-46's SoC churn | **no** | the SoC moves **0.000000000 pp** in a shadow run. No SoC limit can be *reached* in one |
| TARIFF-14's wrong quarter-hour | **yes**, and it is the one | which hour, at what price, clearing what spread: identical live and shadow on **720 of 720** charging cycles, at a binding cap |
Graded in `test_arbitrage.py` part **3d** — the same closed loop part 3c uses,
run once live and once in shadow — and the loop leg's own limitation stays
named in `test_watchdog.py` part 10 as `RECORDED LIMITATION`.
---
## 6. How "nothing reaches the inverter" is graded
**Not on a flag.** A check that asserted `Controller.shadow` would pass on a
build in which the flag is set and the substitution is deleted — which is
exactly mutation **M1**.
`test_clamp.py` part 8 drives the **shipped** `Controller.cycle()` and the
shipped heartbeat against a recording Home Assistant stub, and asserts on what
reached `set_number()`:
* **the precondition first** — the same stimulus with `shadow_mode: off` puts
30 non-zero writes on the wire, the largest 5000 W. MUTATION.md habit 4: a
check whose data cannot reach the failure it names is decoration;
* then `set(writes) == {0.0}`, **and** that there were 26 of them — a check
that only said "no non-zero write" would pass on the silent build too;
* entry and exit are measured on `inv.last_write` — what the inverter is
latched on, updated only on an **accepted** write — either side of the
boundary, against the same run that never entered shadow mode.
The closed-loop half is `/home/homelab/s10-mut/shadowloop.py` on TEST-23 stage
3's rig: the same hour, twice, against `plantsim.Plant` through the firmware's
own frame encoder.
⚠️ **SUPERSEDED BY SAFETY-47 — THE TABLE THAT STOOD HERE COULD NEVER HAVE GONE
RED, FOR TWO INDEPENDENT REASONS.** It read `arb_power_w` −2000 W *"identical"*,
`arb_reason` *"identical, verbatim"*, arbiter winner *"identical"*, and
concluded *"the decision happened, at the same instant, for the same reason, at
the same signed power — and the battery did not move."* The battery half is
true and is unchanged. The **decision** half was decoration:
1. **the fixture is `peak_cap_w: 15000` against a 350 W house**, so the
capacity guard never engages and SAFETY-45's sizing is in the run and inert.
The harness's own comment says so — *"at this scenario's `peak_cap_w: 15000`
it is not binding anyway"* — which is MUTATION.md habit 4 written down
beside the check it disables;
2. **every identity row reads the FIRST charging sample.** At `elapsed = 0`
the projection *is* the held level, so the two runs agree to the float there
**even at a cap that does bind**. Measured: closing reason (1) alone leaves
that comparison passing.
Both had to be closed. Re-taken below at **`peak_cap_w` 1500 / house 1200 /
`max_w` 2000**, one hour at a 5 s cycle, **across the whole run** — harness
`/home/homelab/s47-mut/shadowloop47.py`, same rig, same three transcribed
lines.
| | live | shadow |
|---|---|---|
| grid | 1224.1 … 1506.0 W — **held up at the ceiling** | **1200.0 W flat = the house, exactly** |
| SoC | 50.000019 → **51.856789 %** | 50.000000 → **50.000000 %** |
| `ARBITRAGE_CHARGE` cycles | 720 / 720 | **720 / 720** |
| first charging cycle, `arb_power_w` | −300.0000 W | **identical — and this is why the old table passed** |
| `arb_power_w`, **whole run** | −307.799 … −299.930 W | **−2000.000 … −300.000 W** |
| …cycles where the two differ | — | **716 of 720** |
| `arb_reason`, whole run | the strategy's own sentence | **differs on 719 of 720** |
| measured charging headroom | 299.93 … 307.80 W | **300.0 … 54 000.0 W** |
| final recorded command | **−302.4 W** | **−2000.0 W — 6.61× the live figure** |
| price / spread / which hour | | **identical on 720 of 720** |
| arbiter winner | `arbitrage` | **identical** |
**What the re-taken table says.** The decision still *happens*, at the same
instant, on the same hour, at the same price, for the same *kind* of reason,
and the battery still does not move — those were the card's real claims and
they hold. What does **not** hold is that the decision is the *same decision*:
its **size** is a function of a meter the battery never moved, and at this cap
the recorded figure is 6.61× what a live controller commands. 54 000 W of
"headroom" under a 1500 W ceiling is `900/remaining` multiplying a gap the
battery never closed.
⚠️ **And the SoC row is the second half of it.** 0.000000000 pp of movement is
not a curiosity: it is the proof that **no SoC-limit behaviour is reachable
from a shadow run at all**, which §5 used to promise.
SAFETY-47 puts the caveat on both surfaces that carry the number and narrows
the three documents' claims; `test_arbitrage.py` part 3d is the CI-side grader
so this class cannot recur.
⚠️ **Three lines of each harness are a TRANSCRIPTION and are marked as one.**
`priceloop.Rig` has no `_write_unit()`, no clamp and no failsafe machine
(divergence 2 of its README), so the substitution's **placement** is
transcribed there. The shipped placement is graded by `test_clamp.py` part 8,
which drives the real `Controller`. ⚠️ **And the rig's transcribed cycle
predates SAFETY-45**: `arbitrage.decide()` gained a required
`charge_headroom_w` after `478ba93`, and the harness supplies it from the
**shipped** `peakguard.charge_headroom_w()` reading the **shipped** peak
decision rather than from a constant, so SAFETY-45 stays in the run.
---
## 7. The mutation battery — 22 mutations, no survivors
Harness: `/home/homelab/s10-mut/run.py`, backups beside it. Each run restores
from backup, purges `__pycache__` **on the host**, and grades through the CI
image with the tree bind-mounted `:ro`, so the interpreter cannot write a cache
into the tree under test (TEST-15). The full table is in the branch report;
one per new behaviour, including **M1** (the substitution deleted — a write
escapes), **M2** (it returns instead of substituting — the inverter is left
latched), **M3** (it moves above SAFETY-28 and SAFETY-03 — the observers go
blind), **M8** (`auto` resolves to shadow unconditionally — the literal
default-on that stops the live install), **M12** (the stop claim is filed in
shadow — every recorded decision reads `stopped`), **M21** (the substitution
and the re-seed together) and **M22** (the re-seed moved one line earlier —
SAFETY-18 grades a 0 W no decision produced and goes permanently blind).
⚠️ **Two findings from driving it, both of the kind MUTATION.md says to expect.**
* **M2 first came back a SURVIVOR and was not one.** The "stop writing
altogether" build left `inv.last_write` as `None`, the check did `float()` on
it, and the section died with a `TypeError` — grading none of the checks
after it and reporting **zero reds**, which reads exactly like a survivor.
That is FCST-06's shape. The fixture now hands `None` back and names it as
its own check; M2 is red on three.
* **M6 had to become a PAIR, and only the battery said so.** `standing_w()`
prefers `last_write` — the 0.0 W the wire gate accepted — and falls back to
`inv.target`, which the re-seed also holds at 0.0. Either rung alone keeps
the detector inert, so dropping the re-seed **singly** came back red on the
limitation check and on **nothing about the latch**: the hazard it names was
not reproduced. TEST-16's rule with a new name on it.
* **M7/M8/M9/M19 had to MOVE SUITES.** They defeat `shadow.engaged()`, and a
defeated default stops `test_clamp.py` part 3's wired run reaching the wire
at all — aborting the file with an `IndexError` before part 8 is graded. The
red count there was a lower bound naming the saturation freeze. The mode
table now lives in `test_validate.py`, which drives no Controller.
* **`shadow_would_w = float(self.target)` is an EQUIVALENT mutation and was
measured as one.** `_shadow_settle()` runs before the re-seed, so at that
line `self.target` **is** `resolution.target_w`. It is recorded as equivalent
rather than reported as a survivor, and the non-equivalent form of the same
defect — the record becoming the **wire** — is driven instead.
### The table
Each row: the mutation, the file, the byte delta, the suite it was graded in,
and how many **named** checks went red. `⚠️` marks a row whose run did not
reach the end of its file, so its count is a **lower bound** (MUTATION.md).
| # | mutation | file | Δ | suite | reds |
|---|---|---|---|---|---|
| M1 | **a write escapes** — the substitution deleted | `main.py` | −6 B | clamp | 5 |
| M2 | **silence instead of 0 W** — it returns rather than substituting | `main.py` | −15 B | clamp | 3 |
| M3 | **the observers go blind** — it moves above SAFETY-28 and SAFETY-03 | `main.py` | +58 B | clamp | 1 |
| M4 | the site re-seed dropped — the exit resumes from a fiction | `main.py` | −36 B | clamp | 3 |
| M5 | the per-unit re-seed dropped | `main.py` | −22 B | clamp | 1 |
| M6 | **PAIRED**: `standing_w()` reads the target **and** the re-seed dropped — the sign latch that survives into live operation | `inverter.py` + `main.py` | −170 B | watchdog | 3 |
| M7 | `auto` inverted — shadow while started, live while stopped | `shadow.py` | +2 B | validate | 4 |
| M8 | **default-on for everyone** — `auto` always shadow; the literal reading that stops the live install | `shadow.py` | −15 B | validate | 2 |
| M9 | an unreadable value resolves to LIVE instead of shadow | `shadow.py` | +1 B | validate | 1 |
| M10 | `validate.py` stops refusing an unknown value | `shadow.py` | −9 B | validate | 9 |
| M11 | the loop leg gated on `auto` alone again — the decision path stops running | `main.py` | −17 B | clamp | 1 |
| M12 | the stop claim filed in shadow — every decision reads `stopped` | `main.py` | −20 B | clamp | 1 |
| M13 | the banner goes green while nothing is commanded | `shadow.py` | −2 B | clamp | 1 |
| M14 | the banner loses the standing latch | `shadow.py` | −2 B | clamp | 1 |
| M15 | the row renders the standing command instead of the decision | `shadow.py` | −8 B | clamp | 1 |
| M16 ⚠️ | the row disappears when live | `main.py` | +43 B | clamp | 1 |
| M17 | the record becomes the wire | `main.py` | −6 B | clamp | 7 |
| M18 | level-logged — the 40-row action log floods | `main.py` | −23 B | clamp | 1 |
| M19 | the two rungs render identically (masking) | `shadow.py` | +22 B | validate | 1 |
| M20 | the shipped default moves to `off` in `config.yaml` alone | `config.yaml` | −1 B | defaults_pin | 1 |
| M21 | **PAIRED**: the substitution **and** the re-seed deleted together | `main.py` | −103 B | clamp | 10 |
| M22 | the re-seed moved one line earlier — SAFETY-18 grades a 0 W no decision produced and goes permanently blind | `main.py` | +40 B | clamp | 1 |
**Survivors: none.** ⚠️ **M17's `+0 B`-adjacent sibling** —
`shadow_would_w = float(self.target)` — is recorded in §7 as **equivalent**
rather than counted here.
@@ -1,80 +0,0 @@
# SAFETY-103 - no swing on old battery readings after a drain ends
**Status: implemented 2026-10-08, for review.** Tests: `test_safety103.py`.
**The defect** (SAFETY-95 rig test, check 5, F1). While a battery settles
from its ended drain, SAFETY-95 steers on the house's point: grid + every
battery's reading - aim. The meter is 1 s old; a battery register can be
10 s old (the SAFETY-90 register model). At E+13 A's reading said 1238 W
(6.8 s old) while A was at 616 W, so the house looked ~620 W bigger: B went
860 -> 1650 -> 870 W. B's own old reading does the same the other way.
**The rule** (`main.py`, `_track_reads()`, `_drain_grid_w()`, N>=2, only
while a drain's tail settles):
1. Each cycle, per battery: the range its power can be in. That is its last
changed reading, widened by every command on its wire since (a lagging
battery moves from one toward the others, never beyond). A new reading
starts the range again.
2. A reading that changed this cycle is taken as it reads. So is the side a
battery refuses (SAFETY-73): it is not heading that way.
3. The house point is held (first cycle: the loop's start from SAFETY-95).
It moves only as far as the meter proves: to the nearest value of grid +
the batteries' ranges - aim.
So the loop no longer moves on a reading that has not caught up. A real
house step still moves it, as soon as the meter shows more than the
batteries can explain.
**Why not the tester's rule** ("a battery whose reading is older than its
last command counts at its command"). B's command changes almost every
cycle, so B is almost always at its command. That is the plain meter, which
chases A's tail: B swings 1420 W. A lag model needs the plant's time
constant, which is not known for a client's inverter. Measured on the plant
below (N=2, 1200 W, A Off; swing = a move of the others later undone):
| rule | 10 s lag | 5 s lag | 20 s lag | surplus, 5 s lag |
|---|---|---|---|---|
| release/1.0 (readings) | 760 | 840 | 150 | 800 |
| tester's: at the command | 1420 | 1020 | 1600 | 800 |
| lag model, tau 10 s | 70 | 140 | 80 | 580 |
| lag model, tau 5 s, plant 20 s | | | 600 | |
| hold all window | 0 | 0 | 0 | 0 |
| **this rule** | **0** | **0** | **0** | **130** |
Holding all window ignores a house step: a 2 kW kettle at E+5 s imported
14.4 Wh over the next 30 s (release 11.5, this rule 11.3); 2 kW more sun
exported 16.9 Wh (release 8.5, this rule 8.9).
**Before -> after** (`test_safety103.py`, `tools/safety103_plant.py`: 800
W/s ramp, 10 s lag, meter 1 s, registers 10 s at the rig's phases, B idles
at -100 W; summed swing of the others in the 23 s after the end):
| case | release/1.0 | after |
|---|---|---|
| rig check 5: N=2, 1200 W, A Off | B 1140 -> 850 -> 1630 -> 870, swing 760 W | B holds 1140 W |
| N=3, 1200 W / 4000 W, A Off | 680 / 630 W | 0 / 0 W |
| solar surplus, N=2 / N=3 | 890 / 740 W | 130 / 130 W |
| lag 5 or 20 s, registers 5 or 10 s, N=2 and N=3 | | at most 10 W |
| registers as fresh as the meter (1 s) | | rows byte-identical |
| refusal end | | rows byte-identical |
| N=1, every end | | rows byte-identical (also against the release/1.0 tree) |
**Cost.** In the window, a house change smaller than what the batteries
could still be doing is not followed until a reading catches up (at most a
register period) or the window ends (23 s). Under a surplus the others
still swing up to 130 W, a third of a slew step.
**Not covered.**
- Outside the drain tail the loop steers on the meter; readings only reach
the saturation freeze (SAFETY-96), unchanged.
- A lost link or a refusal ends the tail at once (A is out of the split, or
reads ~0 W). The swing after them is the loop's own on a lagging plant,
the same with 1 s registers and on release/1.0: under a surplus up to
1.5 kW at N=2 and 3.9 kW summed at N=3; after a lost link about 1 kW
even without one. Not this card.
- A battery that overshoots its command, or a reading offset beyond the
range, can put the true power outside the range; the meter then moves the
point, as before.
- The end cycle's start (SAFETY-95 step 2) still uses the readings; the
batteries were steady during the drain, so they are current there.
@@ -1,96 +0,0 @@
# SAFETY-107 - a refusal held under another writer is learned once
**Status: for review.** Tests: `test_safety107.py`. One inverter only; nothing
changes at N>1 (SAFETY-73's unit refusal has no "another writer" end).
**The defect** (MAINT-13 F5, MAINT-14 "seen again"). A full pack under a
maintenance charge in a cheap window takes no charge. At N=1 the action log then
showed "BATTERY REFUSES asked -2500 W ..." and "BATTERY REFUSES ENDED: another
writer" every cycle: 548 + 548 lines in 15 min on the MAINT-13 rig head,
652 + 651 in 12 min on its base, 33 + 32 in 60 s in MAINT-14's c2b. The 40-line
log flooded and the hint under the buttons changed every second, so the
MAINT-13 notice was gone at once.
**Cause** (`main.py`, the SAFETY-52 block before `compute()`). The end tested
`self.target != self.loop_w`: the wire differs from the loop's ask. A held
refusal makes the loop ask 0 W, so any claim that binds (the maintenance
charge's `hi`, the peak guard's `lo`, a trade's SET) differs from it on every
cycle. The refusal was ended against the same writer it had been learned
against. `sat_count` is not reset by that end, so `compute()` learned the same
refusal again in the same cycle. Nothing about the battery or the wire had
changed; only the log and the hint did.
**Other writers.** The same flap comes from any writer that holds the wire
while the loop is refused, as long as the evidence lasts: a trade (a SET at
P_ARBITRAGE) into a full pack, and the peak guard over an empty pack: 335
refusal lines in 15 min each at a 5 s meter (`test_safety107.py` part 2, run on
release/1.0).
A maintenance drain or hold does not flap: its SET resets `sat_count` each cycle.
**The rule** (`_refusal_update()`, `rejudged`). When another writer held the
wire, the held refusal is judged afresh: `compute()` gets `refused=0` and no
probe, exactly as before the change. Then:
1. it finds the same refusal against that writer's ask: the same episode
continues, with no line and no hint. The probe clock restarts, as it did on
the old end and start: a probe is the loop's ask, and it is not on the wire
while another writer is;
2. it does not: the refusal ends, logged "another writer", or "the battery
gives power" when the reading shows it (it was "another writer" before);
3. it finds the other direction: end, then a new start, as before. In
practice 2 comes first: SAFETY-94's evidence restarts when the ask changes
sign, so a writer that turns round ends the refusal at once and the other
way is learned ~12 cycles later (`test_safety107.py` part 3e).
**Why this rule.** The end now means what SAFETY-52 meant by it: the refusal no
longer describes what is on the wire, judged by the same test that learns it.
`compute()` sees the same inputs as before and the state after each cycle is
the same (`batt_refused`, `batt_refused_at`), so every command is unchanged.
Keeping the held refusal into `compute()` instead (mutation M5) would also
stop the lines, but it holds the loop at 0 W where the old law let it ask past
a writer's limit, which changes commands; it also removes SAFETY-52's
owner-decided "another writer" exit.
**Evidence.**
- Differential trace (`goodwe/scratch/SAFETY-107/probe/trace.py`, not in this
repo; the real Controller in the CI image, release/1.0 bf3ac79 against this
tree): 300 seeds, N=1 (153) and
N=2 (147), 1/2/5 s meter, full/empty/dead/healthy battery windows, a stepping
house, maintenance charge with the cheap window opening and closing, P1 and
the peak guard, trade windows, stop/start; 427 680 cycles, 294 seeds with a
refusal held. Every command, site target, integrator, `sat_count`, refusal
state and reason is identical. Refusal lines: 100 748 before, 2 188 after.
- The card's case: 1 line in 15 min at N=1 and N=2, 1/2/5 s meter, house
importing or exporting (release/1.0: 335-1 753 at N=1; N=2 was already 1).
**Mutations** (`tools/mutation_run.sh` on purged copies; reds in
test_safety107 / test_refusal / test_refusal_units; trace = seeds of 300 whose
commands differ from release/1.0):
| | mutation | reds | trace |
|---|---|---|---|
| M1 | `now == rejudged` -> `False` (every such cycle ends: the old flap) | 26 / 0 / 0 | 0 |
| M2 | `rejudged` always 0 (another writer never ends it) | 5 / 1 / 2 | 29 |
| M3 | the probe clock is not restarted on a continued episode | 1 / 0 / 0 | 2 |
| M4 | a probe may open while rejudged | 0 / 0 / 0 | 0 |
| M5 | the held refusal goes into `compute()` while rejudged | 2 / 1 / 2 | 27 |
| M6 | an end while rejudged always says "another writer" | 1 / 0 / 0 | 0 |
M4 is equivalent: a probe matters only if `compute()` engages in that call,
which needs `SETTLE_CYCLES` saturated against the writer, and each of those
cycles restarted the clock. The line stays because it states the rule.
**Cost.** None in power. A takeover by a different writer while the battery
still refuses is logged as an end and, ~12 cycles later, a new start against
that writer (3 lines for the episode, `test_safety107.py` part 3a).
**Not covered.**
- A perfectly flat reading under a writer. The stale-reading gate trips, stops
the site and seeds the refusal (SAFETY-52 decision 3). After the stop the
writer's ask returns fresh, `compute()` has no saturation history against it,
so the refusal ends at the first cycle and the next trip seeds it again: 2
lines per gate cycle (29 in 15 min, house exporting), the same as release/1.0.
The gate cycle itself (0 W, then the writer's ask) is the cause; stopping it
changes commands. None of the rig runs showed it: real readings move.
- At N=1 a refusal shapes only the loop. A writer above it still asks the
battery; at N>1 SAFETY-73's P_SAFETY claim binds every writer.
@@ -1,98 +0,0 @@
# SAFETY-112 - control waits for Home Assistant to finish starting
**Status: for review.** Tests: `test_safety112.py`, `test_messages.py`.
Control is unchanged. The add-on now says plainly that it is waiting.
## Cause
The Supervisor's proxy refuses every add-on request with 502 until Core's
state is `RUNNING`. The add-on reads every battery reading and writes every
power request through that proxy, so it cannot control until then. Readings
that are already live inside Home Assistant do not help.
Source, Supervisor 2026.08.0 (the image the live site ran at CFG-04):
- `supervisor/api/proxy.py`, `APIProxy.api()` (REST, used for reads and
`number.set_value`) and `APIProxy.websocket()` both start with
`if not await self.sys_homeassistant.api.check_api_state(): raise HTTPBadGateway`.
- `supervisor/homeassistant/api.py`, `check_api_state()` returns
`state.core_state == "RUNNING" or state.offline_db_migration`. The state
comes from Core's `/api/core/state`.
- Upstream `main` has the same gate in both handlers (read 2026-10-13).
- A Core restart restarts only the Core container
(`homeassistant/core.py`, `restart()`). The add-on keeps running.
The add-on itself has no wait. Nothing in `app/` reads Core's state or waits
for an event. The loop reads its inputs every second, a 502 gives `None`,
and missing inputs give 0 W. P1 backoff is not involved: the live site reads
the meter straight from the HomeWizard (`homewizard_local`), not through Home
Assistant.
## Measured
Repro stack `safety112-*`, all removed afterwards:
- Home Assistant 2026.9.4, as live.
- A test integration whose `async_setup` sleeps, so Core stays `NOT_RUNNING`.
- A fake meter and two fake inverters that update every 2 s from load.
- The Supervisor's own `APIProxy` and `HomeAssistantAPI` from the 2026.08.0
image. Only the add-on token lookup, Core's access token and the container
check are stubbed.
- The add-on on `meter_source: homewizard_local` against a fake HomeWizard.
Times are seconds after `docker restart` of Home Assistant:
| run | Core `NOT_RUNNING` | entities live in HA | Core `RUNNING` | proxy 502 | first write | wait from live readings |
|---|---|---|---|---|---|---|
| base 3050fa5, N=2 | 13.1 | 17.1 | 253.1 | 0.9-252.7 (1 132 requests) | 254.7 | 237.6 |
| base 3050fa5, N=1 | 13.1 | 18.1 | 256.1 | 1.5-255.1 (573 requests) | 257.2 | 239.1 |
| this change, N=2 | 12.2 | 17.2 | 313.2 | 1.4-312.5 (1 406 requests) | 317.6 | 300.4 |
| this change, N=1 | 12.2 | 17.2 | 255.2 | 1.2-254.1 (571 requests) | 257.1 | 240.0 |
The first write comes 1.0-4.4 s after `RUNNING`, so the whole wait is the
proxy's. With a 400 s sleep Core moved on at 300.88 s on its own (bootstrap's
stage timeout), so one slow integration holds a start stage for about 5
minutes. The whole start can take longer: the live one took 414 s.
Before this change the page said "Not ready — Battery level: no usable
reading", which sends the reader to Developer Tools for an entity that is
fine.
## Rule
1. No way around the Supervisor. The add-on still talks to Home Assistant
only through the proxy.
2. While the proxy does not answer on the API (`hass.OUTAGE.waiting()`), the
first status row is red: "Home Assistant is not answering, perhaps it is
still starting. Control resumes by itself when it answers." The banner
reads "Not ready — Waiting for Home Assistant to finish starting". The Home
pill reads "Waiting for Home Assistant", in English and Dutch.
3. A safety stop, a stalled control loop or shadow mode keeps its own banner
and state over the wait, because none of them clears by itself.
4. The log has one WARNING when Home Assistant stops answering, an INFO line
every 5 minutes while it is down, and an INFO line with the length of the
wait when it answers again.
5. Only the API counts. The meter websocket alone does not, because the meter
has its own row.
## Why it is safe
The change is text only. `cycle()`, the writes, the heartbeat, the stand-down
on shutdown and every watchdog are untouched. `test_safety112.py` part 3 shows
the same targets and writes with the flag on and off, for N=1 and N=2.
## Open, for the owner
The add-on cannot control while Home Assistant starts. These ways around it
were not taken:
- **Talk to Core directly with a long-lived token** (`http://homeassistant:8123`).
Core's own API answers while `NOT_RUNNING`. In the repro `/api/core/state`
answered from 12-13 s, and a direct `number.set_value` 10 s into
`NOT_RUNNING` returned 200 and set the value. The cost is a secret stored
at every client site, which `hass.py` exists to avoid.
- **Talk to the control board directly** (ESPHome native API), so control
does not need Home Assistant at all. This is a large change.
- **Remove the slow start at the site.** On 2026-10-12 the Solis
integration's discovery held the start. Fixing or disabling it shortens the
wait there, but not on a client site with another slow integration.
@@ -1,18 +0,0 @@
# SAFETY-118 - the peak forecast is read in its own unit
**Status: for review.** Tests: `test_safety118.py`.
`peak_forecast_entity` was read as a bare number, and anything under 50 was
multiplied by 1000 ("accept kW or W"). A W sensor reading 17 to 45 W, which a
real site did 430 times in one morning, would be taken as 17 to 45 kW. The
validator also called a kW sensor wrong, which was false for this entity.
Decision: the size rule is gone. The entity is read through `pv.watts()`, the
same unit table and reader as the solar entities, so W, kW, MW and the rest
convert by the sensor's own `unit_of_measurement`. No unit, or a unit that is
not power (kWh), means the forecast is not used, exactly as if the option were
empty: the loop cap and every non-forecast peak step still apply. One WARNING
per cause, and a status row and a settings note say the same.
No size rule can be right in both units: 30 W and 30 kW are both ordinary.
The read is still one `GET /api/states/<id>` per cycle, as `hass.number()` made.
@@ -1,99 +0,0 @@
# SAFETY-119 - no "not keeping up" when the loop takes the wire back
**Status: merged (bee7431); the settle and the budget changed in SAFETY-121**
(DECISIONS/SAFETY-121-bound-every-quarter.md: 4 cycles and 22 s, one budget
of 11). The rolling-budget sections below describe bee7431. Tests:
`test_safety119.py`.
**Cause.** When another owner (the peak guard, a maintenance charge) has set
the wire and the loop wins again, `cycle()` re-seeds the integrator from that
owner's command, and `compute()` adds `gain * error` from a meter that does
not show that command yet. On the TEST-23 rig (SAFETY-118 I-2) the guard
stepped from -2000 W to +429 W with 5 s left. A cycle later the loop's ask
crossed the +2500 W rail and beat the guard's floor, so SAFETY-85 rule 1
counted it as the loop's own bound and raised `goodwe_integrator_bound`. The
battery gave what it was told, and the cap held.
**Decision.** After a hand-back, the loop's integrator bound is not counted
until the plant's settle has passed (`SHORT_CYCLES` cycles and
`SHORT_SPAN_S`, the window SAFETY-85 grades a battery on). After that it
counts as before. An over-rating (`clamped`, a bug) on a cycle the loop
decided always counts. No command changes, and no text was added or changed.
**A rolling budget (round 3).** The settle above is per hand-back, and
losing the wire restarts it. Across all hand-backs the bound is hidden on at
most a budget of cycles in any 15 minutes (`ROLLING_BUDGET`, `ROLLING_S`); a
cycle over the budget counts. Round 1 restarted the settle at every
hand-back, so a guard and a charge taking turns hid the bound for good (0
warnings in 2 h where 28 were raised before). Round 2 gave each run of
hand-backs its own budget; at 10 s and 15 s meters each quarter then started
a fresh one and the loop's bound was never counted. A quarter-edge kick is a
few bound cycles in one quarter, a real bound that stays is many: a budget
per 15 minutes tells them apart.
**The budget follows the cycle length.** 10 cycles up to a 3.5 s cycle, 8 up
to 11 s, 6 up to 16 s, 4 beyond. The cycle length is the time between two
decided cycles (a stop, or a gap over two minutes, is not one); until it is
known, and without a monotonic clock, the budget is 8. Without a monotonic
clock the budget never refills.
**Why not one number.** Measured over 2 h (`goodwe-addon-checks:1`), the
quarter-edge case (house 800 W, cap 2500 W, no forecast, 30 seeds) and the
case below. No single budget held both at 1 to 15 s: 3 to 6 let the false
alarm back at 2 s; 7 on 10 of 30 seeds at 2 s; 8 on 1 of 30 (seed 25) at
2 s, and warned in only 1 to 3 of 7 quarters at 15 s; 9 and 10 miss quarters
at 10 s. A slow cycle needs a small budget to warn, a fast one a large budget
to stay quiet. Per band, the largest budget that still warns: up to 3.5 s,
10 (9 and 10 had no false alarm at 1 and 2 s; above 10 was not swept); up to
11 s, 8 (9 misses quarters at 10 s); up to 16 s, 6 (7 leaves two quiet
quarters in a row at 15 s, 4 false-alarms at 12 s on 7 of 30 seeds); beyond,
4 (3 false-alarms at 20 and 30 s on 14 of 30, 5 leaves five quiet quarters at
20 s).
**What holds.** Quarter-edge case, 30 seeds each: 0 notifications at 1, 2,
3, 4, 5, 10, 12, 15, 20 and 30 s. Repeated hand-backs (2 x 2500 W,
cheap-window charge, no usable forecast, cap 5000 W, house 5050 and 5100 W,
constant and with noise, 8 runs per meter):
| meter | warnings in 2 h | quarters warned (of 7 after the first) | without the fix |
|---|---|---|---|
| 1 s | 56 to 63 | 7 | 56 to 63 |
| 2 s | 49 to 56 | 7 | 54 to 56 |
| 5 s | 19 to 23 | 7 | 28 |
| 10 s | 7 to 10 | 7 | 14 |
| 15 s | 4 to 10 | 4 to 7, never two quiet in a row | 14 |
| 30 s | 3 to 7 | 3 to 5, up to two quiet in a row | 7 to 11 |
At 30 s no budget does better: 4 leaves two quiet quarters, 3 brings back
the false alarm. Single hand-back, the loop at its rail, first warning after
the hand-back compared with no fix: 10 s at 1 s, 20 s at 2 s, 40 s at 5 s
(default), 80 s at 10 s, 90 s at 15 s, 80 s at 20 s, 120 s at 30 s, 240 s at
60 s. At 60 s the rig is a different regime (the house is not over the cap
in the case above), so nothing more is claimed there. The cap held at 1 to
10 s in the quarter-edge case and at 1 to 5 s in the case above; at slower
meters the meter's lag puts quarters over it, the same with and without the
fix (commands are identical).
**Limits.** These runs have one cycle per meter reading, at a steady
interval. A 20 or 30 s cycle needs `cycle_floor_s` raised; at the default
15 s, a slower meter adds cycles on an old reading. Measured that way (11
seeds): a 15 s meter that varies by up to 30 % false-alarmed on 5 seeds, a
30 s meter on 10 (0 to 4 in 2 h, 8 to 15 without the fix); a 20 s meter on
none. The case above still warned every quarter there.
**Kept.** The loop in charge at its rail warns on the same cycle as before.
The start and a restart after a stop are not hand-backs. The quarter-edge
kick of `test_safety119.py` part 1 stays hidden (see above).
**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`,
`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ; commands and
claims are the same (`test_safety119.py` part 6).
**Not hidden.** A battery that gives less than another owner told it still
alarms through SAFETY-85 rule 2. "Always counts" for an over-rating holds on
cycles the loop decided; on others SAFETY-85 rule 1 already dropped it.
**Not changed.** The kick itself is control behaviour: for a few seconds at
a quarter's edge the loop asks up to its rail. In `test_safety119.py` part 1
that is more discharge than the guard asked at a quarter's end, and a few
seconds of up to 500 W more charge at the next quarter's start.
@@ -1,95 +0,0 @@
# SAFETY-121 - a recurring overload warns every quarter
**Status: for review.** Tests: `test_safety119.py` (parts 2, 4, 5, 6).
Follows DECISIONS/SAFETY-119-bound-after-hand-back.md.
**The defect (TEST-23, SAFETY-119 rig test F-1).** 5 s meter, 2 x 2500 W,
cap 5000 W, maintenance charge, no forecast, house 5100 W. bee7431 warned
once in 70 min (04:31:01), then not at 04:45, 05:00 or 05:15. Base 75f0804
warned at every quarter start. The loop held the wire once a quarter, at its
start, and was at its bound on 4 or 5 cycles. SAFETY-119's settle (12
cycles, 60 s at 5 s) covered all of them, and 5 cycles a quarter fit in its
budget of 8 per 900 s.
**Why the simulation said "every quarter".** `test_safety119.py` fed the
meter one cycle behind the battery reading (`delay` counts cycles: 5 s at a
5 s meter). Just after the loop leaves its rail, the guard's charge
headroom (house = meter + battery) is then off by up to 5000 W, the charge
pulls 2000 W, the guard steps in again, and the two chase each other: 8 to
10 hand-backs and about 20 bound cycles a quarter at 5 s (rig: 1 and 4-5).
With the meter read in the same cycle (`delay` 0, the rig's shape) the sim
gives the rig's numbers: 3 to 5 bound cycles a quarter at 5 s, all inside
SAFETY-119's settle, and bee7431 warns in 0 to 2 of 7 quarters. The test
now runs both shapes.
**Decision.** After a hand-back the loop's bound is hidden until 4 cycles
(`HANDBACK_CYCLES`) and 22 s (`SHORT_SPAN_S`) have passed; across all
hand-backs on at most 11 cycles (`ROLLING_BOUND_CYCLES`) in any 900 s. The
budget no longer follows the cycle length, and the cycle length is not
tracked. A stop ends the settle and keeps the budget. An over-rating always
counts. Commands are unchanged.
**Why it holds for any number of bound cycles a quarter.** The settle is
fixed: at most 3 cycles or 22 s after a hand-back, whichever is longer. A
bound that lasts past it counts on its first cycle after it. So one
hand-back a quarter, with the loop at its rail past the settle, warns in
that quarter, whether it is 1 bound cycle or 100. A bound that never lasts
past a settle counts once more than 11 are hidden within 900 s. Hidden
are only shapes with at most 11 bound cycles in 900 s, each within 3 cycles
or 22 s of a hand-back. That is the quarter-edge kick: in every steady-meter
run below it is on the 1st to 3rd cycle after its hand-back (up to 30 s at
a 30 s meter). In the rig's shape at 5 s the bound comes 30 to 55 s after
the hand-back.
**Sweep** (`goodwe-addon-checks:1`, 2 h runs, scratch/SAFETY-121/dev/).
Quarter edge = test_safety119 part 1 (house 800 W, cap 2500 W, no
forecast). Overload = part 4 (2 x 2500 W, cap 5000 W, house 5050 or
5100 W, seeds 0-3). "Delay 1" is the old sim, "delay 0" the rig's shape.
| meter | quarter edge, runs with a warning (new / bee7431 / no fix) | overload delay 1, quarters warned of 7 (new / bee7431 / no fix) | overload delay 0, 5100 W (new / bee7431 / no fix) |
|---|---|---|---|
| 1 s | 0/70 / 0/70 / 68/70 | 7 / 7 / 7 | 7 / 7 / 7 |
| 2 s | 0/70 / 0/70 / 39/70 | 7 / 7 / 7 | 7 / 7 / 7 |
| 3 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 7 / 7 (5050 W: 7 / 1-7 / 7) |
| 5 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0-2 / 7 |
| 10 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0 / 7 |
| 15 s | 0/70 / 0/70 / 40/70 | 7 / 4-7 / 7 | no fix warns 0-1 (5300 W: 7 / 0 / 7) |
| 20 s | 0/60 / 0/60 / 30/60 | 7 / 5-7 / 7 | no fix warns 0 (5300 W: 7 / 0 / 7) |
| 30 s | 0/60 / 0/60 / 30/60 | 7 / 3-5 / 7 | no fix warns 0 (5300 W: 6-7 / 0 / 6-7) |
Quarter-edge seeds: 1-30 with the meter in the same cycle, and 1-40 (1-30
at 20 and 30 s) with the test's 1-2 cycle delay. 5300 W: seeds 1-5.
Commands identical to no fix and to bee7431 in all 1013 runs (these and
the limits below). Choosing the numbers: the runs above were recorded
without the fix and replayed through candidate rules; with 3, 4 or 5
cycles, budgets 9 to 13 met both cases at 1-30 s. 8 warns at the quarter
edge (2 s, seed 25) and 14 misses quarters in the rig's shape (1 s,
5050 W), both checked in part 4. 11 is the middle, and 4 the middle of 3
to 5; with a jittery meter (below) 3 gave more false alarms, 5 more quiet
quarters (replay).
**First warning after one hand-back** (the loop at its rail; later than
no fix by): 11 s at 1 s, 22 s at 2 s, 24 s at 3 s, 25 s at 5 s (default),
30 s at 10 s, 45 s at 15 s, 60 s at 20 s, 90 s at 30 s, 180 s at 60 s.
bee7431: 10, 20, 30, 40, 80, 90, 80, 120, 240 s. In the overload runs at
1 to 30 s, the first warning was at most 90 s later than without the fix.
**Limits (measured, not fixed).**
- A meter that jitters by 30 % with `cycle_floor_s` 15 (11 seeds): with
the test's 1-2 cycle meter delay the quarter edge warns on 2 of 11 seeds
at 15 s, 2 at 20 s and 5 at 30 s (bee7431: 4, 0, 10; no fix: 11 each);
with the meter in the same cycle on none (no fix: 10, 9, 11). The overload at 5100 W leaves up
to 4 quiet quarters in a row at 15 and 20 s (bee7431: no warning at all
on some seeds); at 5200 W at most 1. Its first warning came up to 61 min
later than without the fix. The numbers depend on the jitter draw: the
review's draw gave 4, 4 and 5 of 11 at the quarter edge (bee7431: 4, 1,
9) and up to 2 and 4 quiet quarters at 15 and 20 s.
- With cycles over about 40 s, a bound that is never past a settle uses up
11 in 900 s only if the wire changes hands every few cycles; otherwise
it is never counted (part 5 prints it at 60 s).
- Hidden as designed: a real bound with the kick's shape (at most 11 cycles
in 900 s, each within 3 cycles or 22 s of a hand-back).
**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`,
`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ from
bee7431 and from no fix; commands, claims, guard and phases are the same.
@@ -1,35 +0,0 @@
# SAFETY-123 - the day plan's hour loop steps in UTC
**Status: for review.** Tests: `test_safety123.py`. No option, no text change.
`dayplan._hour_slices()` stepped to the next local hour by adding an hour to
the local wall clock and converting back. Where the zone has no tzdata
(`tariff._EUTimezone`: the CI image, a dev host, an image that loses tzdata)
the missing 02:00 of the spring change reads as summer time, which is
00:00 UTC again. At 28 March 2027 00:00 UTC `t` did not move and the list
grew until the process died. Control and the web page share one event loop,
so the whole add-on froze. `dayplan.plan()` calls it on every plan, before it
looks at a battery's mode, so a site with all batteries on Solar first reaches
it as well, if the prices give a plan.
The shipped image has tzdata (`amd64-base:3.20`), where this zone gives the
right answer, so a running add-on is not known to be hit. The fix is for any
host without it. (AUDIT-01 F4, card SAFETY-120, is the same defect.)
**Decision.** Each step adds the time left in the local hour to the UTC
instant. It is at least a microsecond, so `t` always advances and the loop
ends, in any zone, with or without tzdata. A day of 23 hours gives 23 slices
and a day of 25 hours gives 25, each at most an hour, none across a local
hour. On ordinary days the slices are the old ones.
**One change on the autumn day.** The old function (with tzdata) made one
two-hour slice for the doubled hour 02:00-03:00; it now makes two of an hour,
both labelled 2. `plan()` sums per slice, so only a forecast with sun in that
hour could tell the difference. There is none.
**Other loops.** The other code that walks local hours or days steps in UTC
or on the calendar date (`static_tariff`, `price_forecast`, `price_history`,
`price`, `maintenance`, `homeview.local_day`, `solarfc._bounds`), or has a
fixed bound. None can stall. One known inexactness stays, outside this fix:
`solarfc` spreads a state-only forecast over hours since local midnight, so on
the two change days its sun curve is an hour off the clock.
@@ -1,10 +0,0 @@
# SAFETY-125 - a grid charge under the month peak
**Status: FIXED in 0.19.2 (hotfix from 0.19.1).** Tests: `test_safety125.py`.
A grid charge is sized by `peakguard.charge_room_w()`, the guard's headroom
with the seconds since a late meter reading counted at that reading (the
projection leaves them out, SAFETY-99, the bill does not), and the guard's own
floor is unchanged. At N=1 the maintenance charge is now sized too (owner
2026-10-29; SAFETY-84 had left it out). Evidence (repro on 0.19.1, sweep,
digests): the SAFETY-125 pull request.
@@ -1,47 +0,0 @@
# SAFETY-126 - a landing error on the aim is not an overrun
**Status: FIXED on release/1.0 (after 0.19.2).** Tests: `test_safety126.py`.
**Cause.** A grid charge under the peak limit is sized to land the 15-minute
period on the aim (SAFETY-125), so the period ends 0-7 W over it. In the last
second `peakguard.excess_w()` multiplies that by 900 / remaining (3 000 at
0.3 s left, 90 000 at 0.01 s), so `decide()` wants tens of kW
(`wanted > 0`, peakguard.py `decide()`). With every battery under the peak
reserve that is floor-limited at 0 W, and `check_peak_floor_limited()`
alarmed on it at once: SAFETY-87 graded only the case where another battery
could give. With one battery able, the guard asked it for its rating for that
cycle and the Batteries "Why" said "The peak limit needs power". The
add-on's own charge is not the cause: `wanted` already counts it as
stoppable.
**Rule.** Each cycle the guard wants power, `Controller._peak_landing()`
asks `peakguard.misses_aim()`: with the batteries at 0 W from now on, does
the period end more than `ALARM_MISS_W` (10 W) over the aim? This is
`load_too_high()` with nothing to give, without `decide()`'s gain. If not, and
no real miss was seen since the guard took hold, the decision is
`within_aim`. Such a decision raises no reserve alarm (ERROR,
notification, red row), no red "Load too high" row, no "Why" peak and
no "Battery stopped limiting the peak" event hint. The claim, the floor and the
commands are unchanged.
**Still alarms.** House load the batteries cannot cover under the reserve:
the period would end over the aim with the batteries at 0 W. A step load
alarms on the same cycle as before. SAFETY-87's case and SAFETY-81's 'Load too
high' notification are unchanged.
**What the 10 W tolerance costs** (review, owner accepted 2026-10-31):
- A steadily rising load alarms a little later, once the projected miss passes
10 W (3 000 -> 4 000 W over 45 min: 56 s later). With the default 50 W
margin the alarm still comes before the limit.
- With `peak_margin_w` 0, a period can end up to 10 W over the limit without
this alarm, the tolerance 'Load too high' already accepts (SAFETY-81).
`misses_aim()` takes the house as meter plus batteries, so an export nets
off (`decide()`'s projection counts offtake only). A re-alarm while the loop's
draw on held batteries covers the house is gone too (test_safety126.py part 2,
modes off: 2 notifications, was 4).
**Not changed.** The one-cycle floor itself: at a period's last instant the
guard still asks an able battery for its rating, or stops a charge, for a
landing error. That is control, outside the alarm path. Raise a card if it
should go.
@@ -1,396 +0,0 @@
# SAFETY-13 — the output freeze gets an exit, and only where it cannot end on its own
**Status:** decided and implemented on branch `SAFETY-13`, off `release/1.0` @ `c8dcb38`.
**Card:** SAFETY-13 — *"Bound the no-start-while-saturated output block"*
([Trello](https://trello.com/c/uZjmuF7B)).
**Nothing here reached the inverter.** Every figure below is from the pure control
law run in a container against a plant model, on this branch and on a pristine
`c8dcb38` for the before-figures. No rig, no ESP32, no Home Assistant.
---
## 0. The four things a reader should take away
1. **The card's headline case is real, and the route to it is not the obvious
one.** The block does not engage on the way down into an export; the slew
limit moves the command off zero before the counter reaches 3. It engages
from the **deadband** — a site resting at its target with another writer on
the bus, counting saturated cycles while nothing is wrong — and 0 W is then a
fixed point. §2.
2. **`prev_w == 0` is not a special case, so the fix is not one either.** The
same run pins at −800 W against its own integrator standing at −2388 W, at a
non-zero `prev_w`, with
the same absence of an exit. Zero is only the instance where the pinned
magnitude is the whole of what the battery could have done. §3.
3. **The decision is not "how long", it is "which".** A divergence a *limit*
could have produced clears itself and gets no exit — `release/1.0` unchanged.
A divergence a limit could not have produced may not, and gets one. The
predicate is physical, not statistical — and it is **not** a second-writer
detector; §4.1 and §8 say what it can and cannot tell apart.
4. **Releasing the output freeze does not unfreeze the loop**, and that is what
makes the release bounded rather than merely different. §6.
5. **The bound comes from the SLOWER half of a two-sided measurement, and the
first version of this branch took the faster half.** §8 is the correction and
the measurement behind it; it is the reason `SETTLE_CYCLES` is 12 and not 9.
---
## 1. What was there, in the terms the code uses
`control.py`, in `compute()`, after the clamp and the slew limit:
```python
if frozen:
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
reason = "saturated-freeze"
```
"Magnitude may fall, never rise." At `prev_w == 0` the magnitude is zero and
`max(target, 0)` forbids any negative command, so a charge cannot **start**.
The reason is written in the file and it is a good one: `frozen` means the
inverter's reported power has diverged from the command by more than
`saturation_w` for `saturation_cycles` consecutive cycles, and at a command of
zero that means the actuator is producing power nobody in this add-on asked for.
Starting to push the other way into it is the configuration §1.5 of the
specification rules out — *"any configuration in which two writers can reach one
actuator"*.
**The defect is that it has no end.** Not the error growing, not the integrator
moving, not time. `sat_count` stops rising at its cap and the state is a fixed
point.
---
## 2. Reproducing it — and correcting the route
Driven at the live install's shape: `max_w 5000`, `slew_w 400`,
`target_grid_w -10`, `saturation_w 500`, `saturation_cycles 3`, `gain 0.6`.
Another writer holds the pack at **+700 W** whatever is commanded.
**The route that does NOT reach it.** Start at `prev_w = 0` with 2 kW of export
already standing. On `c8dcb38` the loop moves off zero at the slew limit before
the counter reaches 3 — commands −400, −800 — so by the time `frozen` is true
`prev_w` is −800 and the block that gets applied is `max(target, −800)`. The
site charges at 800 W, not 0 W, for as long as the run lasts.
**The route that does.** The site is *resting*. `grid == target_grid_w`, so the
loop is in its deadband and the command is 0 — and `saturated_now` is computed
**before** the deadband branch, so the counter climbs anyway. Three cycles later
the loop is frozen at `prev_w == 0` with nothing wrong. Then the export arrives:
| cycle | grid | actual | prev | command | integrator | sat | reason |
|---|---|---|---|---|---|---|---|
| 0–1 | −10 | +700 | 0 | 0 | 0 | 1–2 | `deadband` |
| 2–5 | −10 | +700 | 0 | **0** | 0 | 3–6 | `saturated-freeze` |
| 6 | −2000 | +700 | 0 | **0** | −1194 | 7 | `saturated-freeze` |
| 7… | −2000 | +700 | 0 | **0** | −1194 | 8→10 | `saturated-freeze` |
On `c8dcb38` the command is `0.0` on **every** cycle from there on. The
integrator has moved to −1194 W and stopped (its own freeze holds it), the site
exports 2 kW, and the battery is idle until the other writer leaves.
That is the card's sentence — *"the site can sit exporting 2 kW with an idle
battery indefinitely"* — reproduced, with the route corrected: it is entered
from the **deadband**, not from a correction that ran out of room.
---
## 3. …and `prev_w == 0` is not where it stops
The same tuning, same writer, but the loop starts from zero with the export
already standing (the route from §2 that "does not reach it"):
| | `c8dcb38` |
|---|---|
| command after the freeze engages | **−800 W**, on every cycle |
| the loop's integrator, frozen | −2388 W |
| what ends it | nothing |
So the missing exit is a property of the **output freeze**, not of the zero
case. `prev_w == 0` is the instance where the pinned magnitude happens to be
zero. A rule written for zero alone would have left −800 W exactly as unbounded
as it found it — which is why the change below is written against the freeze and
not against a special case of it.
**And this is the half that escalates at two inverters.** `actual_w` is
`site_batt_w`, the SUM (ALLOC-01, DEV-02), so the comparison is a site command
against two units' power. One unit still moving after the other has stopped is
enough to put the site into the freeze with no external actor at all.
⚠️ **What that claim is and is not.** The escalation is stated from the code —
`prev_w` is `self.target`, the site total, and `actual_w` is the site sum — and
from the single-unit runs above. **It is not measured on two inverters.**
Inverter B does not physically exist (`NOTES.md` §1.1b: on paper, from datasheet
and serial), and `sim/plantsim.py:216` `INVERTER_B` is `INVERTER_A` copied with
`measured=False`. Any two-inverter number would be two copies of A.
---
## 4. The decision: which freezes get an exit, not how long the block lasts
Three answers were on the table, and the card named them: lift after N frozen
cycles, exempt above some export magnitude, or keep it unbounded and write down
the cost.
**A plain cycle bound was rejected**, because it bounds the wrong thing. The
freeze is right to be permanent in one case and cannot be permanent in the
other, and a single N treats them alike:
| the inverter is… | can the freeze clear itself? |
|---|---|
| doing **less** than the command, in the command's own direction — a cold pack, a curtailment, an export cap, the plant still ramping | **Yes.** The command is pinned at something the inverter can reach, so it catches up, `saturated_now` goes false, and the freeze ends. Asking for more would achieve nothing anyway. |
| doing something **outside** what the command can explain — overshooting it, or the opposite sign | **No.** Clearing the freeze requires the inverter to track *our* command, and the other writer is precisely what stops that. |
**An export-magnitude exemption was rejected** for a narrower reason: it makes
the block's end depend on how big the other actor's disturbance happens to be,
which is not a property of the thing the block is about.
So the rule implemented is:
> The output freeze applies as before, **unless** the inverter's reported power
> cannot be explained by the command not being met **and** the divergence has
> outlasted the plant that would otherwise have explained it.
### 4.1 The predicate is physical
`unasked_power(prev_w, actual_w, saturation_w)`. A limit can only make the
inverter do **less** than the command, in the command's own direction. It cannot
make it overshoot the command and it cannot make it produce the opposite sign.
So everything a limit can produce lies in the closed interval spanned by `0` and
`prev_w`, widened by `saturation_w` for noise, ramp lag and idle draw.
⚠️ **What it does NOT establish is that anything outside was put there by
something else.** The first version of this branch said exactly that, in the
docstring, in `control.py` and in DOCS.md, and it is false: the predicate
compares against **one cycle's** command while the pack carries every command
before it, so our own coast-down through zero lands outside our own interval.
§8 measures it. What the predicate guarantees is only its own first sentence —
*this cycle's command does not account for this cycle's reading* — and the time
bound below is what turns that into a reason to act.
| prev | actual | interval | verdict |
|---|---|---|---|
| +2000 | +700 | [0, 2000] | a limit |
| −5000 | −700 | [−5000, 0] | a limit |
| 0 | +700 | [−500, +500] | **unasked** |
| −800 | +700 | [−1300, +500] | **unasked** |
⚠️ **At `prev_w == 0` this predicate and the saturation detector are the same
test** — asserted in `test_control.py` over 801 readings. That is not a
coincidence to tidy away: with nothing commanded there is no interval left for a
limit to live in, which is exactly why the zero case is the one that strands.
⚠️ **It is not a detector and nothing alarms on it.** A stale, wrong-signed or
noisy reading lands outside the interval too. All it decides is whether the
freeze gets an exit; being wrong about it costs a bounded command (§6).
SAFETY-30's `unit_sign_disagree_w` is the thing that alarms on sign, and it is
deliberately a different test with a different threshold.
### 4.2 The bound, and which half of the measurement it comes from
`SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)` = **12**.
⚠️ **`PLANT_SETTLE_DOWN_S` is new, and adding it is the point rather than a
cost.** `NOTES.md` §5.10 did not measure *a* settling time; it measured the same
1.78 kW step in **both directions** — "Up: ~18 s from step to deadband. Down:
~23 s from a −1784 W export excursion" (`NOTES.md:1550`). So the plant's
settling time is a **pair**, and which half is the safe one depends on which way
being wrong hurts:
| the bound | the question | the safe half | why |
|---|---|---|---|
| SAFETY-39's `SLEW_RAIL_FRAC` | may this configuration be **refused**? | **18 s**, the shorter | a looser bound refuses fewer working configurations |
| this one | may the plant still be the **explanation**? | **23 s**, the longer | a shorter bound stops believing the plant too early, and then it accuses |
The first version of this branch reused `PLANT_SETTLE_S` and got 9 — three
cycles short of the plant's own measured down-ramp, on the side that produces a
false positive. §8 is the measurement and the correction.
⚠️ **It is no longer the reciprocal of `SLEW_RAIL_FRAC`, and it never should
have been read as one.** The two were equal only while both were being derived
from the same half of the pair, and that coincidence read as a derivation. They
are 9 and 12 now; `test_control.py` asserts the inequality so they cannot be
quietly re-fused.
⚠️ **It inherits SAFETY-39's caveat about `LOOP_TICK_S`, in the opposite
direction.** A meter **slower** than 2 s makes 12 cycles cover more than 23 s, so
the freeze holds longer than the plant needs: that costs energy, never safety. A
meter **faster** makes them cover less, so the freeze can release while the
plant is still moving — bounded by §6 rather than by luck.
**`_SAT_COUNT_CAP` moved with it, from 10 to 13**, because it is written as
`SETTLE_CYCLES + 1` — a counter capped below the longest bound read from it
would silently delete the exit. That has a consequence worth stating on its own:
`config.yaml` accepts `saturation_cycles` as `int(1,10)`, so at the old bound of
9 a legal setting could sit **above** the exit and delete the block. At 12 the
whole accepted range is strictly below the bound, and the degenerate
configuration this branch used to document no longer exists. §7.
---
## 5. What actually changed, graded against `release/1.0`
`test_control.py`'s sweep still compares this law against a transcription of
`release/1.0` — the transcription was **not** taught the new rule, because an
oracle taught the change compares the law against itself. What changed is the
assertion: every case must be identical, or be a release with both of its
conditions true, or differ **in the saturation counter alone**.
⚠️ **The sweep had to be widened to keep grading anything.** Its `sat_count`
fixtures ran to 9; with the bound at 12 the counter never reached the exit and
every release silently vanished from the table, which is what the assertion on
the count caught. It runs to 12 now, at 3780 cases.
| | cases |
|---|---|
| total | 3780 |
| **bit-identical to `release/1.0`** | **3267** |
| releases | 432 |
| …of those, reason-only (the freeze was not binding) | 405 |
| …of those, **the command changes** | **27** |
| differ in the **saturation counter alone** | 81 |
All 27 command changes are at `prev_w == 0`, all with `release/1.0` commanding
exactly `0.0 W`, and every new command is **negative** — a charge start that was
refused.
⚠️ **The 81 are the counter's ceiling moving, and they reach nothing.**
`_SAT_COUNT_CAP` went from 10 to 13 with the bound, so above `release/1.0`'s own
ceiling the two counters differ. Nothing else may differ in those cases, and the
sweep asserts that: `frozen` is `sat_count >= saturation_cycles`, `config.yaml`
accepts that at most 10, and no other line in `compute()` reads the counter's
value — so a counter reading 13 instead of 10 cannot change what is commanded.
The 81 all sit at an `sc` above 10, which is a state `release/1.0` could never
hold at all.
The behaviour after the fix, on §2's run: the command holds at 0 W through
`sat_count` 3–11, releases on the **twelfth** saturated cycle, and walks
0 → −400 → −800 → **−1190 W**, where it stops — the same settling point as at
the old bound, three cycles later.
The genuine-limit case, driven for 30 cycles with the inverter curtailed to
±1000 W against a 4 kW import: `freeze-released` never appears and the command
stays pinned at +2400 W. Unchanged, deliberately.
---
## 6. Why the release cannot become windup (the card's fourth criterion)
**The integrator freeze is not lifted with the output freeze.** The command is
derived from `i_w`, then clamped, then slew-limited from `prev_w` — so a
released cycle can only move the command *toward* an accumulator that stopped
accumulating when the freeze engaged, by at most one slew step, and never past
it. `test_control.py` sweeps 107 625 states, of which **67 500 release**: no
released case leaves the interval between the previous command and the frozen
integrator, and none moves further than one slew step.
⚠️ **107 625 was previously reported as the number of released states. It is
the number of states swept**, and the assertion only ever graded the subset that
released. Both numbers are counted and printed now.
On §2's run that shows up as the settling point: **−1190 W against a frozen
integrator of −1194 W**, not the 5000 W rail. The tech lead's note on SAFETY-04
— that the carried `i_w` and `release/1.0`'s recomputed value land on the same
slew-limited command — is preserved: the `i_w=None` path is still graded against
the transcription, cycle for cycle, everywhere except the 432 releases.
⚠️ **The first form of this check was wrong and the sweep said so**, which is
left in the test file. It asserted `|target| <= |i_w|` on a released cycle and
produced `prev −5000, i_w −3000, target −4600`: the command was walking back
from −5000 toward the integrator and had not arrived. It was moving the right
way and the assertion still failed, because "never exceeds the integrator" is
not what no-windup means here.
---
## 7. What this does NOT buy, said plainly
**The site still does not reach its target while another writer is on the bus.**
It settles at the frozen integrator, not at the meter's target. What the exit
buys is that the battery stops being *idle* while the site exports; it does not
buy correct regulation against an actuator somebody else is also writing to, and
nothing in a rules engine can. A standing grid error with a second writer
present is the symptom of a configuration this add-on refuses to support.
**`freeze-released` is a place to start looking, not a finding.** It says the
divergence outlasted the plant's own measured settling time, and that is all it
says — §8. DOCS.md now tells the operator the innocent causes first (a large
load or PV swing, a plant slower than the one that was measured) and the second
writer only when the state **stands**. It is carried on `Decision.reason`, which
nothing after the freeze overwrites. No new alarm was added: SAFETY-41 removed
one that narrated normal anti-windup, and this state is not yet known to be
worth waking anybody for.
**No accepted configuration removes the block any more.** `config.yaml` accepts
`saturation_cycles` as `int(1,10)`; the bound is 12, so every accepted value is
strictly below it and the freeze always engages before its exit is due. This
paragraph used to say the opposite — at a bound of 9, settings of 9 and 10
deleted the block silently and the branch documented that as a known
consequence. Moving the bound closed it by construction. `test_control.py` pins
it against `config.yaml`'s own schema line rather than a transcription of it.
**Nothing was measured on two inverters.** See the warning in §3.
---
## 8. ⚠️ ROUND 2: the bound was derived from the wrong half, and it accused
**What was wrong.** `SETTLE_CYCLES` was `ceil(PLANT_SETTLE_S / LOOP_TICK_S)` = 9.
`PLANT_SETTLE_S` is 18 s, the **up**-ramp of `NOTES.md` §5.10's kettle step;
the same measurement recorded ~23 s coming back **down** (`NOTES.md:1550`).
SAFETY-39 takes 18 correctly — its bound is a refusal, and the looser number
refuses less. This bound asks the opposite question, and the looser number stops
believing the plant three cycles too early.
**Why that is not a tuning quibble.** The predicate has no memory. `prev_w` is
one cycle's command; the pack carries every command before it. A command that
falls **through zero** — the ordinary end of a load or PV step — leaves the pack
coasting in the old direction, so the reading has the sign the command *used* to
have and lands outside the interval spanned by 0 and the new `prev_w`.
`unasked_power()` calls that "unasked". With the bound three cycles short of the
plant's own down-ramp, the counter reaches it while the coast-down is still
going, and the law emits `freeze-released` on **one inverter with nothing else
on the bus.**
**Measured, in the container, N = 1.** One `compute()`, one plant model, one
command; no second writer anywhere in the harness. The plant is a first-order
lag whose two time constants come from the two measured settling times
(tau = t/3, since a first-order step is 95 % done at 3 tau) with
`sim/plantsim.py`'s measured 800 W/s ramp cap. It is a **model**, and it is
labelled one wherever it is used.
| | bound 9 | bound 12 |
|---|---|---|
| 1280 closed-loop scenarios (a grid of plant pairs, slews, `saturation_cycles`, load steps) | **113 fire** | **24 fire** |
| 272 load-step pairs at the measured 18 s / 23 s plant, `slew_w 400` | **62 fire** | **2 fire** |
The single case now pinned in `test_control.py`: a 4 kW export falling to
2.5 kW. At cycle 34 the command is −310 W, the pack is still at −1343.4 W
coasting down from −4860 W a few cycles earlier, `unasked_power(−310, −1343.4,
500)` is True, `sat_count` is 9, and the law says `freeze-released`. At 12 the
same run never releases.
**What the fix does not fix.** The bound makes the false positive rare on *the
plant that was measured*. It cannot make it impossible, because it is a
measurement and the plant can be slower than the measurement. Same sweep, bound
held at 12, lengthening only the plant's down-ramp: 8 of 320 scenarios at the
measured 23 s, 59 of 320 at 34 s, 268 of 320 at 50 s. That residual is why the
three statements below were corrected rather than merely re-numbered.
**Three shipped statements were falsified by this and are now rewritten:**
1. `unasked_power()`'s docstring — *"Anything outside that interval was put
there by something other than this loop."* It was our own power. The
docstring now says what the predicate does guarantee (this cycle's command
does not account for this cycle's reading) and states the coast-down as its
known blind spot.
2. `control.py`, in `compute()` — *"SO THE BOUND BELONGS TO (b) AND ONLY TO
(b)"*. It fired in case (a). The comment now says the bound is a reason to
stop believing (a), not a proof of (b), and that the release is written to be
survivable when the inference is wrong.
3. **DOCS.md — *"If you see `freeze-released` in the action log, look for the
second writer."*** This is the one that reaches a person, and on a
single-inverter install it sent them hunting something that does not exist.
Replaced by a section that gives the innocent causes first and the second
writer only when the state stands.
@@ -1,417 +0,0 @@
# SAFETY-25 — does the grid-following loop get a reserve floor?
**Status: DECIDED 2026-09-02 — Option A.** The product owner has answered §1:
**the BMS cut-out stays the only backstop for demand-driven discharge on the
grid-following loop.** **Option B — a loop-side reserve floor — is DEFERRED**,
not rejected, with an explicit revisit trigger recorded in §0c: **`ALLOC-01`**.
Option A **is the shipped behaviour**, so recording this decision changed no
code. The honesty half of SAFETY-25 — `peak_soc_floor`'s scope made true in the
config, in `DOCS.md` and on the status page — had already shipped and was
required under every option. What was missing was the answer, and the answer is
now here rather than in a Trello card that scrolls away.
**Everything from §1 down is the memo exactly as it was written before the
decision, and is deliberately left intact.** It is the record of *why*, and a
future reader arriving at the revisit trigger needs the options analysis, the
costs, the measured replay and the trap in Option B's obvious shape — not a
one-line verdict. §5 still labels Option B *"(recommended)"* and §6 still says
"Option B is what I would build"; those were the engineering recommendation put
to the owner, they are preserved as written, and **§0 is what was decided.**
Filed with SAFETY-25. Card 6a94b2b98b73123d520e69f2. The other half of that
ticket — making `peak_soc_floor`'s scope honest in the config, the docs and on
the status page — **shipped in SAFETY-25's own PR** and did not wait on this,
because it was required whichever way the answer went.
*This directory exists so a recorded decision has somewhere to live that is not a
comment in a source file nobody reads and not a Trello card that scrolls away.
`SAFETY-27` asks for a second one (the naming of the floors); it belongs here
too, and §0d says what N=2 does to it.*
---
## 0. The decision
### 0a. What was decided, and the owner's reasoning
**Option A. The BMS remains the only backstop for demand-driven discharge on the
grid-following loop. Option B is deferred until `ALLOC-01` exists.**
The reasoning is not "Option B is wrong" — §5 and §6 still stand as engineering.
It is that **deferring is the safe direction, and building now is not.**
Option B's safety proof is the ordering constraint in §5:
bms_soc_floor < loop_soc_floor <= peak_soc_floor
That proof has an unstated premise: **the reserve predicate and
`discharge_permitted()` must read the SAME state of charge.** At N=1 they do,
which is why the proof reads as airtight, and why part 10d can run it through
the real arbiter and watch the contradiction disappear.
**With two packs they read different numbers.** The reserve binds on whichever
pack is emptier and files `hi = 0`; the peak guard, reading the other pack or an
average of the two, is *not* floor-limited and files `lo = excess > 0`. Then
`lo > hi`, arbiter clause 3 fires, and the result is **0 W — the battery parked,
in the billed quarter.** That is precisely the failure §5 marks *"⚠️ fatal if
missed"*, arriving by a route the ordering rule cannot close, because
`loop_soc_floor <= peak_soc_floor` is still perfectly satisfied while it happens.
**And no config-ordering validator can catch it.** The rule §5 proposes — refuse
the bad ordering at startup, in CFG-01's manner — grades *configuration*. This is
a **runtime divergence between two readings**, not a config ordering. The startup
check would pass, every time, on the exact tree that parks the battery.
So the shape of the risk decides the direction: **Option A's cost is energy
(§5, "every watt below the reserve becomes billed import" — inverted: the pack
may sit on its own cut-out). Option B's cost, built too early, is a parked
battery in the one quarter of the month that sets the bill.** One of those is a
tariff line; the other is the failure this memo already calls fatal. Take the
one that cannot park the battery, and build Option B when its proof holds again.
### 0b. What this decision does NOT say
- It does **not** say a loop-side reserve is a bad idea. §5's protection
argument — keeping the pack off its own cut-out, which is the `MAINT-01` drift
condition — is unchanged and still wanted.
- It does **not** close §4's fifth-key question. `SAFETY-27` still owns the
naming of the floors, and any future `loop_soc_floor` still lands after it.
- It does **not** weaken §6 item 3: **the tripwire in `test_peakguard.py`
part 10 stays.** A deferred option needs its tripwire more than an open one
does, because "deferred" is exactly the state in which someone builds it by
accident. Part 10f still takes red on the line a ninth claim source is
written. Do not remove it.
### 0c. The revisit trigger: `ALLOC-01`
**`ALLOC-01` is the trigger, and it is a trigger because it changes the SHAPE of
the two claims, not because it changes anyone's mind.**
`ARCH-01` decided that the site grid target is **allocated host-side, above the
driver seam**: one integrator on P1, a sum-invariant split into per-inverter
shares, and each inverter told a function of **its own share** only. It also
decided that **per-inverter constraints are consumed by the allocator as
INTERVALS, never filed as site-level claims** — which is the structural fix for
the cross-inverter `lo > hi -> 0 W` contradiction.
Under the allocator, the two claims that collide in §0a stop sharing a claim
list at all:
| | today (N=1, no allocator) | under `ALLOC-01` |
|---|---|---|
| the loop reserve | a site `hi` at `P_LOOP` | a **per-inverter `hi`**, consumed at **stage A** as that unit's interval |
| the peak floor | a site `lo` at `P_PEAK` | still a **site `lo`**, filed at **stage B** against the site target |
**Different claim lists, evaluated at different stages, structurally unable to
cross.** The reserve bounds one inverter's share; the peak floor bounds the site
sum. There is no arbiter resolution in which one is the other's `lo` and `hi`,
so §0a's failure cannot be constructed — not "is guarded against", *cannot be
constructed*. That is the same class of argument as §5's ordering rule but a
stronger one: §5 makes the crossing harmless, `ALLOC-01` makes it unreachable.
**That is the shape in which Option B becomes safe to build.** When `ALLOC-01`
exists, reopen this memo at §5 Option B, restate the reserve as a stage-A
per-inverter interval, and the ordering rule becomes an implementation
convenience rather than the load-bearing proof.
### 0d. What has to be RE-EARNED at N=2, stated plainly
Two things this decision leans on do not survive a second pack, and saying so is
part of the decision rather than a footnote to it.
**1. `SAFETY-27` now names FIVE floors across TWO packs.** §4's table already
carries four keys plus "the loop"; two of them ship the same default of 20 from
different cards. At N=2 each of those is a floor *per pack*, and "the SoC" stops
being a scalar the config can bound — which is the same divergence that makes
§0a's contradiction possible. `SAFETY-27` inherits a harder question than the
one it was filed with.
**2. `test_peakguard.py` part 10f — the guarantee that made SAFETY-25 mergeable —
CANNOT EXPRESS TWO PACKS, and will not cover the two-pack case.** Say it flatly,
because it is the load-bearing pin (§6 item 3: PR #31 shipped this section
claiming a tripwire it did not have, and the reviewer disproved the claim by
building Option B under a green suite):
- 10f's dynamic half is a **scalar 0–100 % SoC sweep** — one number swept over
one range, in the idle and floor-limited states — against a **single-series
capture** (§7: the fixtures carry signed net power at the meter and nothing
else; the pack is modelled, and one pack is modelled).
- A two-pack exposure lives in the **difference** between two SoC readings. A
sweep over one scalar visits no point of that space, at any resolution. It
cannot fail on the divergence because it cannot represent it.
- 10f's **static** half — the AST scan pinning the eight claim sources in
`Controller.cycle()` — survives, and is the half that keeps working: a ninth
source still goes red on the line it is written, at N=2 as at N=1. It is the
*dynamic* half, the one that proves the named sources actually reach the
arbiter across the whole state space, that goes silent.
So: **10f remains a valid tripwire against building Option B today, and is not a
guarantee about N=2.** Whoever reaches the `ALLOC-01` revisit inherits the job of
re-earning it — a two-pack fixture with two SoC series, or a claim-shape argument
at stage A that does not need one. Merging a two-pack reserve on 10f's current
green is merging on a check that cannot see the failure.
---
## 1. The question
> Does the grid-following (self-consumption) loop get an SoC floor of its own, or
> is the BMS cut-out **deliberately** the only backstop under demand-driven
> discharge?
Either answer is defensible. What is not defensible is the state we have been in
for four tickets: **silence, plus a config key called `peak_soc_floor` that reads
like the answer is "yes".**
## 2. What is actually exposed — replayed, not argued
The exposure was written down three times (SAFETY-18's finding, SAFETY-24's
module note and PR body, and SAFETY-24's honestly-uncaught 13th mutation) and
never measured. SAFETY-25 measured it. The replay is `test_peakguard.py` part 10;
it drives the **shipped** `control.compute()`, `peakguard.decide()`,
`peakguard.claim()` and `arbiter.resolve()` over the committed capture
`scenarios/p1-net-2026-08-20.jsonl.gz`, with the battery and the state of charge
modelled (the captures carry meter power only — see §7).
### 2a. A sustained-load evening takes the pack to the BMS cut-out
19:00–22:00 UTC on 2026-08-20 — three hours of house load after the sun has gone,
**830 W mean, minimum 701 W, maximum 1223 W, not one watt of export in the whole
window.** The pack is the 13.8 kWh reference pack (300 Ah × 51.2 V × DoD 90),
started at **21 %** — one point above the shipped `peak_soc_floor`.
| | |
|---|---|
| pack at the start of the evening | **21.0 %** |
| pack three hours later | **10.0 %** — `bms_soc_floor` |
| energy the loop delivered | **1525 Wh** |
| control cycles with the pack below `peak_soc_floor` | **549 of 587** |
| …of those, cycles still delivering discharge | **343** |
| cycles on which the peak guard filed *any* floor | **0** |
### 2b. It is a behaviour, not a config value
The same replay, same pack, same evening, with `peak_soc_floor` set to **5, 11,
20 and 60** — the shipped value and both ends of the schema range:
peak_soc_floor = 5 -> ends at 9.9715 %, 1524.58 Wh out
peak_soc_floor = 11 -> ends at 9.9715 %, 1524.58 Wh out
peak_soc_floor = 20 -> ends at 9.9715 %, 1524.58 Wh out
peak_soc_floor = 60 -> ends at 9.9715 %, 1524.58 Wh out
Identical to four decimal places. **Raising the option does nothing, because the
guard it bounds is not the thing doing the discharging.**
### 2c. The two states coexist in the same cycle
On the same day's high-load block (15:00–17:00 UTC), with the pack started one
point *below* the floor, the guard reports `PEAK GUARD FLOOR-LIMITED` —
*"commanding no forced discharge"* — on **49 cycles**, and on **all 49** the
arbiter simultaneously grants the loop a discharge of up to **2000 W**. The
status page tells the operator the truth about the guard while the pack empties.
### 2d. Stated fairly, because overstating it is how a real finding gets ignored
This is **not** a runaway. Self-consumption discharge is demand-driven and
self-limiting: it stops when the house stops drawing, and every watt of it is
serving load that would otherwise have been imported. It is not SAFETY-18's
frozen-meter rail, and nothing here is spending the pack for a reason of its own.
What it means is narrower and still real:
- the **depth** the pack reaches on a heavy evening is set by the house, not by
anything an operator can configure; and
- `peak_soc_floor` **reads like a system-wide guarantee it is not.** A floor that
binds one consumer and not the other is worse than no floor if its name does
not say so — which is precisely what SAFETY-25's other half fixed.
## 3. Why SAFETY-24's floor cannot be extended to cover this
The peak guard files a `lo` limit — a **lower** bound on the setpoint. That
asymmetry is what makes the guard trustworthy: a reviewer proved across 19,800
combinations that it can never lower a target, never cap a discharge, never
contradict the arbiter, and never file a negative floor. `peakguard.claim()`
leaves `hi` at `+inf` as a fact about the object rather than a rule someone has
to remember.
**A `lo` limit is structurally incapable of stopping a discharge the loop itself
wants.** So the same property that makes the guard safe makes it useless here.
Raising `peak_soc_floor` cannot become the answer; §2b is that statement
measured.
## 4. The floors we already have — and this would be the fifth key
SAFETY-24 recorded a decision to keep `maintenance_soc_floor` separate with an
explicit revisit trigger: *"if a third floor appears."* **The trigger has already
fired, unremarked**, when OPT-04 shipped `arbitrage_soc_floor`.
| option | default | bounds | priority of the consumer |
|---|---|---|---|
| `maintenance_soc_floor` | 11 | the monthly recalibration's **drain target** | `P_MAINTENANCE` 60 |
| `peak_soc_floor` | 20 | the peak guard's forced discharge | `P_PEAK` 50 (a `lo` limit) |
| `bms_soc_floor` | 10 | *nothing* — it describes the pack, so rule 8 can refuse a floor that cannot bind | — |
| `arbitrage_soc_floor` | 20 | arbitrage discharge | `P_ARBITRAGE` 30 |
| **the loop** | — | **nothing** | `P_LOOP` 10 |
Two of them ship the same default (20) from different cards for different
reasons, which is how a value that looks deliberate becomes a coincidence nobody
dares change. **`SAFETY-27` owns the naming question and this memo does not
pre-empt it** — but any answer to §1 that adds a fifth key should land *after*
SAFETY-27's decision, or it adds a fifth accreted name to four.
## 5. The options
### Option A — record the BMS as the deliberate backstop; add nothing
Say plainly, in `DOCS.md` and in `freeze.py`'s finding, that demand-driven
discharge is bounded by the pack's own cut-out on purpose, and close the
question.
- **Protects:** the whole point of the product. Every watt the loop discharges is
self-consumption the house would otherwise have imported; a reserve is
literally energy bought at the import tariff and held back.
- **Costs:** on a heavy evening the pack ends at the BMS cut-out with no EMS-side
record of why. That is the SoC-drift condition **MAINT-01** exists to correct,
and the first symptom — a battery that is never available when it is wanted —
is indistinguishable from a coulomb counter that has drifted. It also means the
morning's peak-guard capacity is whatever last night left, which the peak guard
cannot influence.
- **Cost of building it:** zero. It is already the shipped behaviour, and after
SAFETY-25's honesty half it is already documented.
### Option B — a `hi` limit at `P_LOOP`, with an enforced ordering *(recommended)*
A new option `loop_soc_floor`. While `soc <= loop_soc_floor`, `main.py` files
`Claim.limit("reserve", P_LOOP, hi=0.0)`. Zero forbids **discharge** and permits
charging, which is the mirror image of the guard's `lo = 0` and one-directional
in the safe direction.
**⚠️ This shape has one trap and it is fatal if missed.** `arbiter.resolve()`
applies **every** limit at or above the winning `set` claim's priority and takes
the most restrictive, *with no notion of one limit outranking another* — that is
deliberate and is what makes the safety bounds unconditional. So when the loop
wins at `P_LOOP`, a reserve `hi = 0` and the peak guard's `lo = excess > 0` are
**both** applied, `lo > hi`, and the arbiter resolves a **CONTRADICTION to 0 W**:
the battery parked, in the one quarter of the month that sets the bill. This is
the same crossing-limits hazard `shaping_suspended()` already exists to avoid for
the maintenance charge-only clamp.
**The fix is an ordering constraint, and it is provable:** require
bms_soc_floor < loop_soc_floor <= peak_soc_floor
Then whenever the reserve binds (`soc <= loop_soc_floor <= peak_soc_floor`) the
guard is *already* floor-limited and files `lo = 0.0`, so `lo == hi == 0` and the
result is 0 W with no contradiction. Refuse any other ordering at startup, in
CFG-01's manner and with rule 8's message shape. **A `loop_soc_floor` above
`peak_soc_floor` must not be merely discouraged; it is the parked-battery bug.**
- **Protects:** the pack from routinely sitting on its own cut-out, which is the
MAINT-01 drift condition; and it gives the operator one number that means what
its name says.
- **Costs:** a fifth SoC key (see §4), a validator rule, and — the real cost —
**every watt below the reserve becomes billed import.** Set too high it turns a
working battery into a smaller one.
- **What it does *not* touch, correctly:** a maintenance DRAIN sets at
`P_MAINTENANCE` 60 and arbitrage sets at `P_ARBITRAGE` 30, both above `P_LOOP`
10, so a limit filed at `P_LOOP` is not applied to either. The monthly
recalibration still drains to `maintenance_soc_floor`; arbitrage is still
bounded by its own key. That is the right answer and it falls out of the
priorities rather than needing a special case.
- **Recommended starting value if this option is taken:** a *low* one — three
points above `bms_soc_floor`, i.e. **13 %**, well under `peak_soc_floor`'s 20.
The purpose is to keep the pack off its own cut-out, **not** to reserve energy
for anything, and an over-tight reserve is the version of this that fights
MAINT-01 from the other side: it shrinks the band the pack actually cycles in,
which is the condition a coulomb counter drifts in. It does not matter that 13
sits above `maintenance_soc_floor`'s 11 — the maintenance drain sets at
`P_MAINTENANCE` 60 and a `P_LOOP` limit is not applied to it, so the two never
interact. **The number itself is part of the owner's answer**; what this memo
offers is the derivation and the ordering constraint that makes any number in
that range safe.
### Option C — a `set` claim above `P_PEAK`
File `Claim.set("reserve", 55, 0.0)` when the pack is under the reserve. Because
clause 2 applies only limits at or **above** the winner's priority, the guard's
`lo` at 50 would then be dropped and there is no contradiction to engineer
around.
- **Protects:** the same as B, without the ordering rule.
- **Costs:** it is a **stop**, not a bound. It parks the battery outright rather
than merely forbidding discharge, so it also blocks the charging that would end
the condition, and it silently outranks peak shaving in a way that is invisible
on the priority table. This project's rule is that a floor about **energy** is
not a stop of last resort (`peakguard.py`, point 1). **Not recommended.**
### Option D — clamp inside `control.compute()`
- **Costs:** it puts precedence back inside a strategy, which is the exact thing
`arbiter.py` was built to end, and it would be invisible to `explain()`. **Not
recommended.**
## 6. Recommendation
⚠️ **This section is the recommendation that was PUT to the owner, kept as
written. It was answered on 2026-09-02 — see §0. The owner took Option A and
deferred Option B to `ALLOC-01`; item 3 below (keep the part 10 tripwire)
was taken as written and still stands.**
**Option A is honest and cheap; Option B is what I would build.** Concretely:
1. **Now (shipped with SAFETY-25):** the honesty half. `peak_soc_floor`, its
documentation and the status page say plainly that it bounds the peak guard
only. This is required under every option and it is the actual dishonesty on
the live system today.
2. **Owner's call:** A or B. If B, take it **after `SAFETY-27`'s naming decision**
so the fifth key lands into a scheme rather than onto a pile, and **after
`MAINT-01`** has run one real cycle — until then nobody has observed what this
pack's SoC reading does over a full cycle, and picking a reserve against a
reading that has never been recalibrated is picking a number against an
instrument we have not checked.
3. **Whichever way:** the tripwire in `test_peakguard.py` part 10 stays until the
question is answered, so the day someone builds a loop-side floor it goes red
and has to be updated deliberately, rather than the exposure quietly changing
under a green suite. It has two halves and they cover the two places the floor
can land:
- **10f pins the claim-source set.** Every `claims.append(...)` in
`Controller.cycle()` is read off its AST and must be one of eight named
sources, and real cycles — spied at main.py's own `resolve`, swept over the
whole 0–100 % SoC range in both the idle and the floor-limited state — must
file exactly those. **Option B is a ninth source**, and so is **Option C**'s
`set` claim, so filing either takes part 10 red on the line it is written.
**Option A** files no claim at all — it is the honesty half already
shipped, and part 10 is the record it leaves behind.
- **10a–10c replay the shipped code**, so **Option D** — a clamp inside
`control.compute()` — moves the replayed numbers instead.
⚠️ **10f is load-bearing and was added late.** PR #31 shipped this section
claiming the tripwire without it, and the reviewer disproved the claim by
building Option B in `main.py`: the entire suite, part 10 included, stayed
green, and mutation 13 went on printing *"nothing prevents it, on purpose"* on
a tree where something did. Do not remove 10f while this question is open.
## 7. What the replay is and is not
The committed captures carry **one** series — signed net power at the meter — so
the pack is modelled, and the model is stated rather than hidden:
- the captured `w` is the load presented to the meter; the modelled battery's
delivered power is subtracted from it, one telegram late (an actuation delay);
- the loop's command is the **shipped** `control.compute()` at the shipped
tuning, resolved through the **shipped** `arbiter.resolve()` against the
**shipped** `peakguard.claim()`, at a `cycle_floor_s` tick;
- SoC falls by the delivered discharge integrated over the real telegram
timestamps, on a pack of 300 Ah × 51.2 V × DoD 90;
- the BMS is modelled as a hard cut: at or below `bms_soc_floor` the modelled
battery delivers 0 W whatever is asked of it. Without that the run would be
unphysical — the BMS really does stop the pack, and the whole finding is that
it is then the **only** thing that does;
- nothing recharges the pack inside the window. One evening, one direction.
**The fixtures cannot express a low pack on their own** — there is no battery or
SoC telemetry in them — so the starting state of charge is a fixture parameter,
not a measurement. It is chosen as *one point above `peak_soc_floor`*, which is
derived from the shipped option rather than picked. Everything downstream of that
one number is the real capture and the real code.
@@ -1,474 +0,0 @@
# SAFETY-30 — what the sign watchdog compares once there is more than one inverter, and the numbers it compares with
**Status: DECIDED 2026-09-04.** The one comparison becomes **two**, asked of the
evidence that can answer each: **each unit's standing command against that
unit's own measured power** (register 0x0518, per-instance since [[DEV-02]]),
and **the sum of the standing commands against the meter**. At a roster of one
nothing changes at all — the two questions are the same sentence there, and the
detector that ships is the only one armed.
This document records the argument for that split, the campaign the four numbers
were read off, **a number that was carried and a number that refused to be**, and
the mutation ledger. Filed with SAFETY-30, card `6a9a1828e844f22fabf7fe9c`.
---
## 0. The half that was already built, and the half this card is
[[DEV-02]] shipped the **per-instance** half and nothing here re-tests it: each
`Inverter` owns its own `SignWatchdog`, the latch is namespaced under
`unit/<name>/sign_tripped`, the flat pre-DEV-02 pair is maintained as a rollback
view, and a trip on B no longer latches A. `test_inverter.py` part 3 grades that.
What was left is **the comparison itself**, and it was one line:
```python
inv.sign_wd.evaluate(stood, self.grid, self.following, now) # every unit
```
`stood` is the command **this controller put on THIS actuator**. `self.grid` is
what the **whole site** is exchanging with the network. At one inverter those are
two views of one thing — §6.2's identity — which is what makes the check sound
and what earned its record. At two they are not, and it fails in **both**
directions:
| | what happens | why no threshold fixes it |
|---|---|---|
| **false trip** | A commanded −1500 W while the meter reads +1500 W, because B is discharging 3000 W exactly as told | the meter is answering for a unit A's command says nothing about |
| **missed fault** | B commanded +1500 W and **measured** at −1500 W, beside A at +1500 W — the meter sits where a healthy site leaves it | the summation **destroys** the evidence |
NOTES.md calls SAFETY-02 the highest-value check in the safety table: 93 %
agreement while healthy against 4 % during runaway, four observed episodes, zero
false positives. **That record was earned at N=1 on the site comparison.** The
first row above spends it; the second row is the guard being absent when it
matters.
## 1. The two questions, and the two remedies
> **PER UNIT** — this unit's standing command against **its own** measured power.
> Conclusion: *the actuator I am driving is not doing what I told it.* Evidence
> about one device, so the remedy is that device: the `failsafe` SET, which
> `arbiter.CLAIM_SCOPE` has classified `SCOPE_UNIT` since it was written.
>
> **SITE** — the **sum** of the standing commands against the meter. Conclusion:
> *the site as a whole is driving the wrong way* — an inverted gain, an
> integrator wound past the rail, a strategy on the wrong side of the meter.
> That is the 2026-08-20 fault day, it is a property of the **connection**, and
> the remedy is a new SITE SET, `sign_site`, that stops every inverter.
### Why `failsafe` stays per-unit and `sign_site` is new
`CLAIM_SCOPE`'s entry for `failsafe` used to read *"the evidence is site-wide and
the remedy is not"* — a site measurement attributed to one actuator on the
strength of the N=1 identity. After this card the first half of that sentence is
simply true: the evidence **is** per-unit. The site-wide half did not disappear;
it moved to a claim that is site-scoped for the reason `circulation` is, one
entry above it: **the offending number is the site target, which no single
inverter owns.** Parking one unit on it would leave the rest of the roster
pushing the same way with the guard satisfied.
### At N=1, exactly one detector is armed
`Controller._arm_sign_watchdogs()` is the only place the roster decides anything
here, and it is a pure function of `len(self.inverters)`:
* **one inverter** — the unit detector is armed against **the meter**, at the
shipped `sign_disagree_w` / `sign_disagree_s`. The site detector is
**constructed and never evaluated**. Same comparison, same thresholds, same
reason string byte for byte, same namespaced keys and flat mirror, same
`failsafe` claim, same banner, same single status row. `test_watchdog.py`
part 7 asserts each of those, including that the site detector's window was
never so much as opened.
* **two or more** — each unit against its own 0x0518 power under the per-unit
pair, and the site detector against the meter under the site pair.
Arming both at N=1 would be two latches and two banners for one fault, and a
false-positive record measured on **one** comparison spent twice.
## 2. What this is NOT — the two neighbours, named
Two guards that overlap without saying so is how a false-alarm generator gets
built, so both are written down.
### It is not [[SAFETY-31]]
| | **SAFETY-31 — `circulation.py`** | **SAFETY-30 — the per-unit half** |
|---|---|---|
| compares | the units **against each other**: `Σ\|Bᵢ\| − \|ΣBᵢ\|` over measured power | each unit against **its own command** |
| catches | two inverters pushing **through** each other; nets to zero at the meter | a unit **not doing what it was told**; that unit is faulty or wedged |
| attributes | nothing — the quantity is defined over the roster and is identically zero for one unit | **the unit**, by name, and parks it |
| scope | SITE claim, flat latch | UNIT claim, namespaced latch |
They genuinely overlap in **one** scenario — a unit reversing while its sibling
pulls the other way — and `test_watchdog.py` part 4 asserts exactly that, on the
same fixture, with both readings printed: circulating power is over its threshold
**and** SAFETY-30 names B. Neither is the other with a different threshold. Take
two units faithfully executing an allocation with two signs: circulating power is
the whole loop and every per-unit sign check reads perfect agreement. Take one
unit doing the opposite of its share while its sibling covers: this reads the
whole disagreement and circulating power can be zero.
### It is not the saturation freeze
`compute()`'s anti-windup test is `|prev_w − actual_w| > saturation_w` (500 W). It
concludes *the inverter cannot follow*, it fires on a merely **saturated** unit as
readily as on a reversed one, it **stops the integrator winding** — an ordinary
control action taken every day — and it neither latches nor parks anything.
This asks for **opposition**: a sign, on both sides of a threshold, held
continuously. A saturated-but-obedient unit cannot produce it. And the two meet
at a number rather than overlapping in a band nobody drew — see §3.
## 3. The numbers, and where they come from
Read off `admin/goodwe`'s `sim/` two-master rig (TEST-19), used **read-only** and
run container-free. Real and imported, never transcribed: the control law
(`control.compute`, `Tuning`), the allocator (`arbiter.allocate_record` →
`allocator.plan`), the sign predicate (`watchdog.disagrees`), the wire
(`frame_check.setpoint_atomic`, the firmware's own encoder), the plant
(`plantsim.Plant`), and the per-unit reading, taken from **holding register
0x0518** — the register the live `batt_entity` binds.
**1890 closed-loop runs in which NOTHING is faulty** — every unit executes the
share the allocator gave it — over **6 gains × 5 slews**, the whole range
`config.yaml` accepts for `gain` `float(0.05,1.0)` and `slew_w` `int(50,5000)`,
across both families of stimulus that can produce legitimate opposition:
* **1050 load reversals** — 7 sizes (±300 W … ±4000 W) × 5 write staggers, the
serialised write path landing unit B's number up to four cycles after A's;
* **840 flapping loads** — 7 periods (10 s … 600 s) × 4 amplitudes.
Every run is executed **twice**: once at a roster of two and once at a roster of
one on the same stimulus. **The N=1 arm is the control**, and it is the reason
this document can separate *"a property of the check that shipped"* from *"a
property of the second inverter"* instead of asserting one and hoping.
Supervised caps are the shipped `max_w` (2000 W per unit), so the site rail is
2000 W at N=1 and 4000 W at N=2 — `Controller.site_max_w`, the roster sum.
### Reproducing it
The harness is **not committed here**, for the reason SAFETY-31's was not: it
imports `plantsim` and `frame_check`, which live in the other repository and are
not present in CI. What it does is small enough to restate: build two
`plantsim.Inverter`s from `INVERTER_A` / `INVERTER_B`, close the loop with the
add-on's own `compute()` and `arbiter.allocate_record()`, put each share on its
own wire through `Inverter.command()`, advance `Site.settle()` one control cycle,
and record `(standing command, inv.reg(0x0518))` per unit and
`(Σ standing, site.grid)` for the site. Then, for each candidate threshold, the
**longest run of consecutive cycles** on which the shipped `disagrees()` is true —
not a count of true samples, because the watchdog's window is cleared by any
disagreeing sample. Point it at a checkout with `GOODWE_ADDON_REPO`.
⚠️ MUTATION.md's note applies: those harnesses write `__pycache__` into **both**
trees.
### A. PER-UNIT — a unit's standing command vs its own 0x0518 power, N=2
| threshold | worst continuous, reversals | worst continuous, flapping | runs crossing (of 1890) |
|---:|---:|---:|---:|
| 50 W | 25.0 s | 20.0 s | 962 |
| 100 W | 20.0 s | 15.0 s | 511 |
| 150 W | 15.0 s | 15.0 s | 379 |
| 200 W | 10.0 s | 15.0 s | 258 |
| **250 W** | **10.0 s** | **10.0 s** | **194** |
| 300 W | 10.0 s | 10.0 s | 124 |
| 500 W | 5.0 s | 5.0 s | 37 |
| 700 W | 5.0 s | 5.0 s | 16 |
| 1000 W | 0.0 s | 0.0 s | 0 |
Largest opposed magnitude anywhere in the 1890 runs: **875 W**.
**The curve is flat, and that is the finding that decided the shape of this
pair.** Unlike the site comparison — where the meter is driven by the LOAD and a
flapping load can hold it opposed for minutes — a unit's own power **chases its
own command** through a bounded ramp and a first-order lag, so the opposition can
only last as long as the crossing does. Dropping the threshold from 300 W to
50 W buys 15 s of extra transient and nothing else. **So the work is done by the
window, and the threshold is put where the control law already draws its line.**
#### `unit_sign_disagree_w: 250` — schema `int(50,5000)`
`saturation_w / 2`. The check gates on **both** sides being over `min_w`, and the
two are **opposed**, so the smallest command-versus-power gap it can ever see is
`2 × min_w`. At 250 W that is exactly the 500 W at which `compute()`'s own
anti-windup test already concludes the inverter is not following. **This watchdog
therefore cannot fire on a divergence the shipped control law itself calls
ordinary tracking**, and the two guards meet at a number.
Rejected: a threshold above the observed 875 W maximum, which would gate nothing
legitimate in — and would also be blind to a unit reversing a 900 W share.
#### `unit_sign_disagree_s: 85` — schema `int(30,1800)`
Three bounds, and the value clears all three.
* **lower, from the rig — 10.0 s.** Two control cycles, the worst continuous
legitimate opposition above 250 W across all 1890 runs. It is a reversing site
whose second write lands a cycle after the first, both units then slewing
through zero behind a 10 s lag. Nothing is wrong.
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
executing its share for less than the firmware's own veto window is a *link*
fault the add-on already answers by dropping the unit from the split. Firing
inside that window would raise a second, louder alarm about a state already
being handled — the SAFETY-15/SAFETY-19 failure exactly.
* **upper, from SAFETY-02 itself — 180 s, the site hold.** A unit that is not
doing what it was told must not be parked *later* than the site would be
parked for the same watts, and this conclusion needs nothing to net out: it is
complete on the first cycle it appears.
**85 s** is seventeen control cycles: **8.5× the worst legitimate episode**,
**2.8× the firmware veto**, and **under half** the site hold. Deliberately not
60, 75, 90, 120 or 180.
### B / C. SITE — Σ standing commands vs the meter, at BOTH rosters
**Load-reversal family** (1050 runs each), worst continuous legitimate opposition:
| threshold | **N=2** | **N=1 (control)** | ratio |
|---:|---:|---:|---:|
| 100 W | 335.0 s | 175.0 s | 1.91× |
| 200 W | 260.0 s | 120.0 s | 2.17× |
| **300 W** | **215.0 s** | **110.0 s** | **1.95×** |
| 500 W | 155.0 s | 90.0 s | 1.72× |
| 700 W | 130.0 s | 70.0 s | 1.86× |
| 1000 W | 85.0 s | 35.0 s | 2.43× |
**The second inverter roughly DOUBLES the worst legitimate crossing, and at the
shipped 300 W threshold it takes it from 110 s — comfortably inside the 180 s
hold — to 215 s, which is outside it.**
The mechanism is not mysterious and is worth stating because it predicts the
ratio: the transient the 180 s hold exists to reject is the loop crossing zero
after a load reversal, and the command has to travel the whole **site rail** at
`slew_w` per cycle. At N≥2 that rail is `Controller.site_max_w`, the roster sum,
while `slew_w` is unchanged. The worst case is the corner of the config space
where that takes longest — `gain: 0.05`, `slew_w: 50`, both **schema floors** —
against a ±2200 W load reversal, which is an oven and a heat pump.
#### `site_sign_disagree_w: 300` — CARRIED, and here is the justification
The argument behind 300 W is about **noise**: *"near zero the sign is noise, and
near zero is where a healthy loop LIVES"* — the deadband holds the command still
while the meter wanders either side of `target_grid_w`. `deadband_w` is 15 W and
there is one P1 whatever is behind it. **Nothing in that argument depends on the
roster**, and the campaign agrees: the fraction of samples gated in at 300 W is
within a few percent at both rosters. Carried, with a measurement behind the
carry rather than an assumption.
#### `site_sign_disagree_s: 325` — NOT carried, and that is the point of the card
180 s is **inside** the 215 s the two-inverter campaign measures, at a
configuration the schema accepts. Carrying it would have shipped a false trip on
the check with the best record in the safety table — which is the exact failure
this card was opened to prevent, arriving from the other direction.
**325 s** is 1.51× the measured worst. It is well inside the **900 s** quarter
that bills the capacity tariff, and it is inside **every** episode the fault day
contains: 2026-08-20's four clusters run 16, 30, 77 and 77 minutes, and its
opposed runs reach 1750 s. What it costs is stated rather than hidden — about two
and a half extra minutes of wrong-direction **site** power before the stop —
and it is bought to remove a false trip a legal configuration produces.
Deliberately not 300 or 360.
**Rejected alternative:** keep 180 s and raise the site threshold to 1000 W,
where the worst is 85 s. It gives a better margin and a faster stop, and it is
wrong: it would be raising a **noise** gate to reject a **transient**, which is
what the window is for, and it would leave the site check blind to a 900 W
whole-site reversal at a 4 kW site.
### A limitation this campaign found, which SAFETY-30 does not introduce and does not fix
The **flapping-load** family behaves completely differently for the SITE
comparison, and the control arm is what makes it interpretable:
| threshold | **N=2** | **N=1 (control)** |
|---:|---:|---:|
| 300 W | 980.0 s | 1000.0 s |
| 700 W | 975.0 s | 1000.0 s |
| 1000 W | 950.0 s | 1000.0 s |
A square wave whose half period is about one control cycle opposes the command on
**every** cycle: the command chases the previous half-cycle's meter while the
meter has already reversed. **No window inside the billing quarter survives it and
no threshold up to 1000 W does either — at EITHER roster.** The N=1 column is
this document's whole reason for existing: this is a property of comparing a
lagged command against a meter, and it has nothing to do with the second
inverter.
It is not acted on here, in order of weight: it is **identical at the roster this
add-on ships**, so acting on it would be changing a live safety path on evidence
that has nothing to do with this card; a ~6 kW square wave at 0.1 Hz sustained
for a quarter of an hour is not a house (the captured healthy day's longest
opposed run is **30 s**); and the fix is not a number — it wants the detector to
notice that the disagreement is **alternating**, which is a different check.
**It is a card, and this section is its evidence.**
### D. The fault arm — because a survey of healthy data is not a derivation
210 runs with inverter B executing the **negation** of its share throughout,
sibling faithful, meter left to net:
| threshold | longest run the per-unit check detects | runs the SITE check also sees |
|---:|---:|---:|
| 250 W | 890.0 s | 210 / 210 |
| 300 W | 890.0 s | 180 / 210 |
| 700 W | 885.0 s | 112 / 210 |
| 1000 W | 885.0 s | 60 / 210 |
| 1500 W | 880.0 s | 45 / 210 |
| 2000 W | 0.0 s | 39 / 210 |
At the shipped 250 W the fault is detected continuously for the whole run in
every one of the 210 — an 85 s hold has ten times the evidence it needs. The
right-hand column is the honest half: on a *reversing* stimulus the site
comparison often does see something too, because the reversal is large enough to
move the meter. **The case it cannot see is the steady one**, and that is the
fixture `test_watchdog.py` part 4 runs: the shipped comparison is false on
**0 of 14** cycles while the per-unit check convicts B on every one.
## 4. Inverter B does not exist
Every two-inverter figure in §3 was produced with **inverter A's ramp and lag
copied onto B** (`plantsim.INVERTER_B`, `measured=False`); its 3000 W rating,
where this repository's fixtures use one, is a **hypothesis marked as one**.
[[SPIKE-04]] measures the real ones and is the revisit trigger for all four
numbers — not a reason to move any of them now.
Which figures depend on it, stated so nobody has to work it out:
* **`unit_sign_disagree_s: 85`** — most. Its lower bound is the 10.0 s crossing,
which is a direct function of B's ramp and lag. A slower second unit lengthens
it; the 8.5× is what buys headroom for that.
* **`site_sign_disagree_s: 325`** — yes, but less directly. Its 215 s is
dominated by the **rail** and by `slew_w`, which are configuration, not
hardware; B's dynamics move it at the margin.
* **`unit_sign_disagree_w: 250`** — **no.** It is `saturation_w / 2`, read off
the add-on's own control law. The rig only confirms that nothing legitimate
sits over it for long.
* **`site_sign_disagree_w: 300`** — **no.** It is the meter's noise floor and the
deadband, neither of which is a property of any inverter.
## 5. Mutation ledger
Every run on a **freshly copied tree of its own, no `__pycache__`, mounted
`:ro`** into the CI image — the structural immunity MUTATION.md records. ⚠️ The
first battery used ONE shared directory rebuilt between mutations and reported a
**RED baseline**; a container mounted it mid-copy and read a missing
`config.yaml` as a failing check. That whole run was discarded: MUTATION.md's
rule is that a run which could have been racing is not a result, and a battery
whose baseline is not green establishes nothing. The harness now copies a
directory per mutation and **refuses to report anything if the baseline is not
green**.
Byte deltas measured, not estimated; the `+0 B` ones are marked, since a
size-preserving edit is what TEST-15 turned into a false survivor once already.
| # | file | mutation | Δ | result |
|---|---|---|---|---|
| M1 | `main.py` | the per-unit comparison put back to `self.grid` at every roster | −30 B | **RED** — `test_watchdog.py`. This is the card |
| M2 | `main.py` | the per-unit comparison forced to `inv.batt` at N=1 too | −31 B | **RED** — AC 8 |
| M3 | `main.py` | the site half never evaluated | −6 B | **RED** — AC 3 |
| **M4** | `main.py` | **the site half armed at N=1 as well** | **+0 B** | **SURVIVOR on the first battery — GAP CLOSED**, then RED. See below |
| M5 | `main.py` | the `sign_site` claim never filed | −6 B | **RED** — `test_watchdog.py`. ⚠️ `test_peakguard.py` stayed **GREEN**, correctly: 10f's AST scan pins which sources CAN be filed and the append is still written; whether it is REACHED is a dynamic question no fixture there gets to |
| M6 | `main.py` | the site latch not persisted to the store | −58 B | **RED** — the flat-keys and restart checks |
| M7 | `main.py` | the operator clear leaves the site latch set | −42 B | **RED** — the clear check |
| M8 | `watchdog.py` | `arm()` clears `disagree_since` | +35 B | **RED** — "re-armed on every one of 38 cycles still trips" |
| M9 | `watchdog.py` | the reason hard-codes "a meter reading" again | +1 B | **RED** — `test_watchdog.py`. `test_inverter.py` stayed **GREEN**; its part-3 row reads the noun off `sign_wd.against` rather than out of the reason string, so the two checks bite in different places |
| M10 | `watchdog.py` | `unit_sign_disagree_w` 250 → 300 | **+0 B** | **RED** — the `saturation_w / 2` pin and the CFG-03 pin |
| M11 | `watchdog.py` | `site_sign_disagree_s` 325 → 180, i.e. **the number carried** | **+0 B** | **RED** — the measured-215 s check and the CFG-03 pin |
| M12 | `arbiter.py` | `sign_site` scoped `SCOPE_UNIT` | **+0 B** | **RED** — `test_arbiter.py` **and** `test_peakguard.py` |
| M13 | `validate.py` | the `unit_sign_disagree_w` rule removed | −126 B | **RED** — `test_validate.py` |
| **M14** | `main.py` | **`arm()` handed `self.sign_cfg` at EVERY roster** — the WIRING, not the values | −31 B | **SURVIVOR at review — GAP CLOSED**, then RED. See below |
| M15 | `main.py` | per-unit `arm()` given the right `min_w` and the SHIPPED hold — a HOLD-ONLY mis-wire | +78 B | **RED** — the same wiring pin |
| M16 | `main.py` | per-unit `arm()` given the right `min_w` and the SITE hold — hold-only again, and behaviour-preserving | +83 B | **RED** — the wiring pin, and the ONLY red in `test_watchdog.py` |
### M4 is recorded as a survivor because it was one
The check it should have gone red on read:
```python
not _c1s.site_sign_wd.tripped and _c1s.site_sign_wd.disagree_since is None
```
Arming the site detector at a roster of one left that **green**, and the reason
is worth reading because the check looked airtight. In the N=1 fixture the unit
detector trips first at its 1 s hold and parks the inverter; the site's standing
command is then **0 W**, so the site predicate is false, its window is never
opened, and `disagree_since is None` is true *whether or not the detector was
evaluated at all*. **The assertion ran, the sentence was true, and the data
could not reach the failure it named** — MUTATION.md's third failure mode,
inside the check written to prevent it.
Replaced with a **call counter installed on the object** before the run: twelve
cycles, zero calls, printed. A counter can only be zero for one reason. The
mutation was re-run against it and is red; the run that found it was discarded
along with its tree, so this is a result and not an artefact.
### M14: the VALUES were pinned and the WIRING was not
M10 and M11 mutate the numbers in `watchdog.SIGN_OPTION_DEFAULTS` and both are
red through the config pins. Neither of them touches the line that DELIVERS
those numbers to a detector:
```python
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg,
AGAINST_METER if one else AGAINST_OWN_POWER)
```
Drop the conditional — `arm(self.sign_cfg, ...)` — and every unit is armed with
SAFETY-02's meter-facing pair instead of the re-derived per-unit one. At review
that left **all 19 files green**. `grep -rn "sign_wd.cfg" test_*.py` found
exactly one threshold assertion, and it is §7's N=1 arm — `(300.0, 1.0)` —
which the mutation PRESERVES. Nothing anywhere read `inv.sign_wd.cfg` at a
roster of two.
The behavioural checks in part 3 cannot close it and it is worth saying why: B
is reversed at ~2000 W and every hold in that fixture is short, so B trips under
either pair. **A fault big enough and long enough to trip under both thresholds
is evidence about neither.**
What the survivor costs in the field is the whole product of §3: at
`config.yaml`'s own defaults the per-unit check would run at `sign_disagree_s`
**180 s instead of 85 s** — 2.1× slower to park a reversed inverter — and its
gate would move from 250 W to 300 W, so a unit reversing a share between the two
becomes invisible, while `SIGN_OPTION_DEFAULTS`, `config.yaml` and §3 all go on
describing a pair the running code does not use. With CI green.
**Closed by a wiring pin in `test_watchdog.py` part 3**, on the real
two-inverter Controller: every `inv.sign_wd.cfg` equals the per-unit pair,
`site_sign_wd.cfg` equals the site pair, and each detector's `against` is its
own instrument — all graded **against literals**, since `== self.unit_sign_cfg`
cannot tell *the per-unit pair* from *whatever this Controller was handed*.
⚠️ **And the fixture had to be changed for that pin to mean anything.**
`_sign_site` set `unit_sign_disagree_s: 1` **and** `site_sign_disagree_s: 1`
**and** `sign_disagree_s: 1`, with two of the three magnitude gates at 300 W. A
bare equality there discriminates on `min_w` alone: a mis-wiring that delivered
the right magnitude with the wrong hold would compare EQUAL to the pair it was
supposed to have. That is this document's own §5 lesson and MUTATION.md's third
failure mode — a check comparing two things that happen to be equal cannot see
the difference it names — reappearing inside the fix for it. The three pairs are
now pairwise distinct **in both coordinates** (250 W / 1 s, 320 W / 2 s,
300 W / 3 s), a PRECONDITION check asserts that distinctness so a later edit
collapsing them goes red rather than quietly vacuous, and both pairs are
**printed**. M15 and M16 exist to show the hold half actually bites: right
magnitude, wrong hold, red both times — and M16, whose wrong hold still trips
inside the drive, is red on the wiring pin and on **nothing else** in that file.
## 6. What this card does NOT close
* **The flapping-load blind spot in the SITE comparison.** §3, at length. Present
identically at the shipped roster of one; wants an alternation test, not a
number; a card of its own.
* **A unit misbehaving while it is NOT loop-driven.** Gate 1 (`following`) is
kept for the per-unit comparison, deliberately: the whole no-false-positive
argument is built on it, and the saturation freeze already grades that window.
So between the start of a maintenance cycle and its end, a unit reversing
against its own command is the freeze's to notice. Widening it is a card with a
rig campaign behind it, not an edit.
* **A sign inversion downstream of this add-on** — in the firmware, on the wire,
or in a future driver. Unchanged: our view stays self-consistent and both
comparisons stay silent. That is the witness's problem and it always was.
* **A false-alarm interaction with SAFETY-31 that nobody has to think about.**
They overlap in one scenario and both fire in it. That is correct — they
conclude different things — but an operator will see two alarms for one event.
Whether the banner should say so in one sentence is a UI card.
@@ -1,434 +0,0 @@
# SAFETY-31 — what "acted on" means for circulating power, and what it cannot mean
**Status: DECIDED 2026-09-04.** The trip **stops the site**: a `SET 0 W` claim
at `P_SAFETY` on the **site** actuator, latched, persisted, and re-announced
after a restart. This document records why that is an action rather than
theatre, given that [[HW-01]] established a wedged inverter cannot be commanded
at all — and it records the numbers the threshold and the window were read off,
because "establish the window from the rig, do not pick a round number" is an
acceptance criterion and a number without its measurement is a guess.
Filed with SAFETY-31. Card `6a99e0022b6a6c8ca15dbe3e`.
---
## 0. The two halves, and which one this card built
Half of this card was already shipped, and it is not the half below.
| | **ALLOC-01, AC 7 — the COMMAND half** | **SAFETY-31 — the MEASUREMENT half** |
|---|---|---|
| what it is | `allocator.REFUSE_SIGN` | `circulation.circulating_w()` + `CirculationWatchdog` |
| what it sees | the shares the allocator is about to hand out | each inverter's **own measured power** (`Inverter.batt`, register 0x0518) |
| what it does | **refuses** the allocation, names both units, returns no shares | **stops the site**, latches, alarms durably |
| what it covers | circulating power the allocator would have **commanded** | circulating power **nobody commanded** |
| graded by | `test_alloc.py` part 6 | `test_watchdog.py`, the SAFETY-31 section |
**They are complements, not duplicates.** The gate refuses an allocation before
it is written and cannot see one millisecond past that. This card reads the
hardware and does not care what was written. Neither replaces the other, and
**a sensor built on the allocation record would collapse the second into the
first and detect nothing** — see §2.
## 1. What "acted on" means
> **The trip files `Claim.set("circulation", P_SAFETY, 0.0)` on the SITE
> actuator. Every inverter is commanded 0 W and held there until an operator
> starts control again.**
### The objection, which is correct
[[HW-01]] is on the record: on 2026-09-02 the comms subsystem failed and the
power stage went on executing its last instruction for **6 h 25 m**, through
every command this add-on sent it. If the cause of the circulation is a unit
that has stopped listening, **a command is not a remedy**, and shipping one as
though it were is theatre.
### The answer
**A circulating loop needs two participants.** In every cause on this card's
list — a unit ignoring its share, a unit lagging it, a stale share still being
executed, a maintenance or [[SAFETY-25]] pin holding one unit while the loop
drives the other, or HW-01's wedged power stage — **exactly one participant is
the unit that stopped listening, and the other one is this controller**,
faithfully commanding the sibling to a share computed to sum with a number the
wedged unit is not actually producing.
Stopping the commanding half does two things a notification cannot:
1. **It halves the loop.** The unit that *is* still listening stops being driven
into the one that is not. That is real energy and real cycles off a real
pack, and it needs no cooperation from the wedged unit to take effect.
2. **It makes the residue visible — and this is the more important half.**
Circulating power hides *because it nets to zero at the meter*. With this
controller's contribution removed, whatever the wedged unit is still doing
stops being cancelled and appears at P1 as a plain net flow, where
SAFETY-02's sign watchdog, the peak guard and the capacity tariff can all see
it. **The fault stops being invisible.**
Measured on the [[TEST-19]] rig, and this is the number that makes the case:
in the card's own scenario — A at −3000 W, B at +3000 W, house load 0 W — the
meter reads **+0 W** and the shipped `SignWatchdog`'s predicate is **false on
every one of 30 cycles**, because it gates on `abs(grid_w) > 300` and the gate
never opens.
### What it does not do, said plainly
**It does not stop the wedged inverter. Nothing this add-on can send will.** If
the loop persists at the meter after this trip, the remaining actuator is a
human at the isolator, and the notification says so in those words.
### Why the claim is SITE-scoped
`arbiter.CLAIM_SCOPE` classifies `circulation` as `SCOPE_SITE`, beside `loop`,
`peak`, `arbitrage` and `safety`. The neighbouring `failsafe` entry looks like
it and is not: SAFETY-02 has site-wide *evidence* and a per-unit *conclusion*,
because it compares the meter against the command **this controller put on one
actuator**.
Circulating power has no such conclusion available. It is
`Σ|Bᵢ| − |ΣBᵢ|` — defined over the whole roster, **identically zero for any one
unit** — and there is no fact of the matter about which of two opposing
inverters is the offender: *a unit ignoring its share* and *a sibling faithfully
executing a share computed to sum with it* produce **identical readings**.
Filing this per-unit would require inventing an attribution the measurement
cannot support, and would park one inverter while the other went on feeding the
loop — quite possibly parking the only one still listening.
The same argument is why the latch is **flat** in the store (`circ_tripped`,
`circ_reason`) and not namespaced like SAFETY-02's. One site, one latch.
## 2. Why the sensor is MEASURED, and why the cheap version is worthless
The obvious cheap version computes the same formula over `Allocation.shares`.
It would be simpler, would never be `None`, and would be **worth nothing**, for
two independent reasons:
1. **It cannot fire.** ALLOC-01's sign-coherence gate already refuses any
allocation whose shares carry two signs. A record-derived sensor is therefore
a sensor over a set that is **guaranteed sign-coherent**. It reads zero *by
construction*, forever.
2. **It is [[MUTATION.md]]'s third failure mode.** It could only ever confirm
that the allocator agrees with itself — a check whose data cannot reach the
failure it names.
Verified rather than argued. On the rig, with inverter B wedged on a stale
discharge while PV drives the site to charge:
```
S = -2500 W shares a = -1250 W b = -1250 W <- record: ok, ONE sign, sums to S
MEASURED A = -1250 W B = +1000 W <- hardware
sensor over the RECORD : 0 W
sensor over MEASURED power : 2000 W -> trips at 190 s
```
And the card's own headline scenario, driven on the same rig — A at −3000 W,
B at +3000 W, house load 0 W:
```
METER (P1) : +0 W <- the site looks IDLE
sensor over MEASURED power : 6000 W -> trips at 100 s
SAFETY-02 SignWatchdog : predicate false on 30 of 30 cycles
```
The same pair is asserted in `test_watchdog.py` **with the precondition written
as its own check and printed**, per MUTATION.md's rule, and mutation **M10**
(the property rewritten to read `self.allocation.shares`) takes it **red**.
## 3. The numbers, and where they come from
Both were read off a curve produced on `admin/goodwe`'s `sim/` two-master rig,
used read-only: the add-on's own `compute()`, its own `allocator.plan()` and its
own `circulating_w()`, driving `plantsim.Plant` through the firmware's own
encoder, with the sensor input taken from each unit's holding register 0x0518.
**924 closed-loop runs in which nothing is faulty** — every unit executes the
share it was given — over both families of stimulus that can produce legitimate
opposition (7 reversals × 5 write staggers, and 7 flap periods × 4 amplitudes),
each swept across the **whole range `config.yaml` accepts** for the two settings
that decide how fast the site can turn around: `gain` `float(0.05,1.0)` and
`slew_w` `int(50,5000)`.
### ⚠️ THE HARNESS BEHIND EVERY FIGURE ABOVE IS NOT COMMITTED, AND UNTIL 2026-09-07 THAT MADE THEM UNCHECKABLE
**Read this before quoting any number in this section.** The paragraph below
said the campaign harness "is not committed here" and treated that as a
packaging detail. It is not: a figure produced by a script that exists in
nobody's tree cannot be re-run, cannot be reviewed, and cannot be defended when
it is contradicted. It has now been contradicted twice.
| sweep | worst peak | worst continuous | trips |
|---|---:|---:|---:|
| the 924-run campaign below — **harness uncommitted** | 1700 W | 505.0 s at 100 W; 5.0 s above 700 W | 0 / 924 |
| independent reviewer sweep, 2026-09-07, 48 honest reversals | **2750 W** | **15.0 s above 700 W** | 0 / 48 |
| `admin/goodwe` `two_master_circulation`, 840 runs — **committed** | 1048 W | 10.0 s at 100 W; 5.0 s above 700 W | **0 / 840** |
**This is not proof that anything was fabricated, and it should not be reported
as one.** The three swept different grids. What the third one adds is a
measurement of *why* the spread is that wide — on one grid, changing nothing but
the model:
| plant / stagger | worst legitimate peak |
|---|---:|
| modelled plant (lag 10 s, ramp 800 W/s), staggers up to a whole cycle | 1048 W |
| `INSTANT` (lag 0, unbounded ramp), staggers up to a whole cycle | 2250 W |
| `INSTANT`, staggers strictly under a whole cycle | **0 W** |
A factor of 2.1×, and a factor of infinity, between three defensible
descriptions of the same rig. **The plant model and the write-stagger set are
terms of the number**, and a worst case quoted without them is not a
measurement. That is enough to explain the whole spread above without anybody
having invented anything — and it is exactly what cannot be established when the
instrument is missing.
**What survives, and what does not.**
* **The window argument survives, and is now committed.** The claim that
decides `circulation_hold_s: 75` is *"the worst continuous legitimate episode
above 700 W is about one control cycle"*. All three sweeps agree on the shape
of that (5.0 s, 15.0 s, 5.0 s — one to three cycles against a fifteen-cycle
window), and **all three trip zero times**. `two_master_circulation` re-runs
it on every pull request in `admin/goodwe`.
* **The threshold curve does NOT survive as stated.** `505.0 s at 100 W` is the
number the "knee at 400 W" argument for `circulation_w: 700` rests on, and the
committed sweep measures **10.0 s** at 100 W on its grid — 50× smaller. The
curve still *falls* with the threshold (126 of 840 runs cross 100 W against 1
that crosses 700 W), so the knee is real in direction; its depth is not
established by anything anybody can run. **Do not quote 505.0 s.**
* **Nothing about the guard is compromised.** 0 trips in 924 + 48 + 840
non-faulty runs, from three independent grids.
**What would settle it:** the original harness, committed. Until then the
committed sweep is the only figure in this section anybody can check, and it is
labelled as its own grid's rather than as a reproduction.
### Reproducing it
⚠️ **THE COMMITTED VERSION IS `admin/goodwe`'s `sim/inject.py
two_master_circulation`** (`sim/sitesim.py:circulation_findings`, runnable on its
own as `python3 sim/sitesim.py --circulation`, `norig`, ~36 s, graded by
`checks.yml` on every pull request there). It produces the fault with
`plantsim.Inverter.wedge()` — a unit answering its bus and no longer steering its
power stage, which is HW-01 — and sweeps the legitimate family with the
serialised write path staggered up to a whole control cycle. The paragraph below
describes the ORIGINAL campaign, which is the one that is not committed.
The rig lives in `admin/goodwe` (`sim/`) and was used **read-only**; the harness
is not committed here because it imports that repo's `plantsim`/`frame_check`,
which are not present in this one or in CI. What it does is small enough to
restate: build `Site([Inverter.from_spec("A", INVERTER_A),
Inverter.from_spec("B", INVERTER_B)])`, close the loop with the add-on's own
`compute()` and `allocator.plan()`, put each share on its unit's wire through
`Inverter.command()` (which goes out through the firmware's real encoder), and
read the sensor from `inv.reg(0x0518)` — **the same register the live add-on's
`batt_entity` binds**, so the input is the one the shipped code gets. Point it
at a checkout with `GOODWE_ADDON_REPO`. ⚠️ MUTATION.md's note applies: those
harnesses write `__pycache__` into **both** trees, so purge both by hand before
grading a mutation through one.
Every figure below was re-run against the final shipped `CirculationConfig`
(`min_w=700`, `hold_s=75`) rather than against the draft values they were first
measured at.
### Worst CONTINUOUS legitimate opposition, by threshold
| threshold | worst continuous | runs that ever cross it |
|---:|---:|---:|
| 100 W | **505.0 s** | 281 / 924 |
| 200 W | 445.0 s | 174 / 924 |
| 250 W | 40.0 s | 132 / 924 |
| 300 W | 10.0 s | 115 / 924 |
| **400 W** | **5.0 s** | 74 / 924 |
| 700 W | 5.0 s | 33 / 924 |
| 1000 W | 5.0 s | 16 / 924 |
| 1700 W | 0.0 s | 0 / 924 |
****With the shipped `min_w=700 W` and `hold_s=75 s`, 0 of those 924 runs trip.**
The knee is at 400 W and it is sharp.** Below ~250 W a flapping load holds the
sensor above the threshold for *minutes* at a time, and no window shorter than
the 900 s billing quarter could tell that from a fault. At 400 W and above the
worst legitimate episode collapses to **5.0 s — one control cycle — and stays
there**. The worst peak on any legitimate run is **1700 W**.
That 5.0 s is the serialised write path doing exactly what it should: on a
reversing cycle inverter B's new number lands up to a whole control cycle after
A's, both then slew through zero at 800 W/s behind a 10 s lag, and **between the
two zero crossings they genuinely oppose**. Nothing is wrong. A guard that
tripped on it would be the fourth false-alarm generator of the week, and this
project's doctrine on crying wolf is explicit ([[SAFETY-15]], [[SAFETY-19]]).
### `circulation_w: 700` — schema floor `400`
`700` is **1.75× the measured knee**. The headroom is bought for one named
reason: **inverter B does not physically exist.** Its ramp and lag in the rig
are inverter A's, *copied*, carrying `measured=False`; [[SPIKE-04]] measures the
real ones. A slower or faster second unit moves the transient's shape, and the
margin above the knee is what absorbs that without re-opening this card.
**Revisit when SPIKE-04 lands, not before.**
The **schema floor is the knee itself**, 400 W, so a legally configured install
cannot land in the 445–505 s regime — ⚠️ and those two figures are from the
uncommitted campaign; the committed sweep measures 10.0 s at 100 W on its own
grid. See the disclosure at the head of this section. The floor is still the
right shape (fewer runs cross a higher threshold, measured 126/840 at 100 W
against 1/840 at 700 W); its DEPTH is not established by anything re-runnable. What the threshold costs: a circulating
loop smaller than 350 W per unit is not reported, which is deliberate — at that
scale the round-trip loss is a few watts.
### `circulation_hold_s: 75` — schema range `30..900`
Three bounds, and the value clears all three:
* **lower, from the rig — 5.0 s.** The worst continuous legitimate opposition
above 700 W across all 924 runs.
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit still
executing a stale share for less than the firmware's own veto window is a
*link* fault, and the add-on already answers it: liveness drops the unit and
the survivors carry its share, with the record saying so. Firing inside that
window would raise a second, louder alarm about a state already being handled.
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
**75 s** is fifteen control cycles: **15× the worst legitimate episode**, **2.5×
the firmware veto**, and **12× inside the quarter**. It is deliberately not 60
or 120 — a round number here would be a guess wearing a measurement's clothes,
and a check in `test_watchdog.py` asserts it is none of the values a guess
lands on.
## 4. Why the window cannot be defeated the way SAFETY-02's was
`allocator.py` records the measurement that makes SAFETY-02 blind to the
broadcast fight: the sign disagreement **flips on alternate ticks**, so
`disagree_since` is cleared between every pair of them and the 180 s hold never
accumulates.
Circulating power cannot do that. It is a **magnitude**, non-negative by
construction, and a sustained loop holds it high on *every* cycle rather than
every other one. An oscillation that genuinely alternated between opposing and
agreeing is not a circulating loop and should not be reported as one — which is
exactly the flapping-load case in §3, and it is why the window clears.
## 5. SAFETY-34: the latch is not the alarm
`persistent_notification` is **in memory**. Home Assistant restarts and the card
is gone while the fault runs on — which is precisely how the 2026-09-02 outage
went unseen for **6 h 25 m with the detector correct throughout**.
So the durable half and the visible half are joined, and the join is
`Controller._circ_announced`: a **per-process** flag, deliberately *not*
persisted. The latch in `/data/state.json` says *"this site is tripped"*; the
flag says *"this process has put that in front of a human"*. A restart clears
the flag and keeps the latch, so **the notification is re-raised on the first
cycle after every start** — which is the first cycle after a Home Assistant
restart, because the add-on restarts with it.
**One code path serves both** the fresh trip and the restart, on purpose: two
paths would be two chances for the restart one to rot unnoticed, and the restart
one is the case nobody exercises by hand. Mutations **M13** (flag initialised
from the latch) and **M18** (the call removed) both take it red.
## 6. Mutation ledger
Every run on a **freshly copied tree with no `__pycache__`, mounted `:ro`** into
the CI image — the structural immunity MUTATION.md records for CI and for
SAFETY-25 — so no stale `.pyc` could exist to be read. Byte deltas recorded;
**three are `+0 B`, the risky delta, and are marked**.
| # | file | mutation | Δ | result |
|---|---|---|---|---|
| M1 | `circulation.py` | `sum(abs(v))` → `sum(v)` in the sensor | −5 B | **RED** — the 6000 W, 2000 W and three-unit checks |
| M2 | `circulation.py` | N≤1 returns `None` instead of `0.0` | +1 B | **RED** — AC 5's nine-reading sweep |
| M3 | `circulation.py` | a missing reading at N≥2 returns `0.0` | −1 B | **RED** — "health from an absence" |
| M4 | `circulation.py` | drop `max(0.0, ...)` | — | **EQUIVALENT, not run as a claim.** For a same-sign roster the two sums accumulate the same magnitudes in the same order and are bit-identical; 700 000 random rosters produced no negative. Argued at the line and in the check's own text. |
| M5 | `circulation.py` | threshold `<=` → `<` | −1 B | **RED** — "exactly 700 W forever does not trip" |
| M6 | `circulation.py` | window `<` → `<=` | +1 B | **RED** — "trips at 75 s, not before" |
| M7 | `circulation.py` | the window removed entirely | −85 B | **RED** — both slew-transient checks |
| M8 | `circulation.py` | `None` no longer clears the window | +34 B | **RED** — the stale-reading gap check |
| M9 | `circulation.py` | the latch removed | −123 B | **RED** — "restored as tripped stays tripped" |
| **M10** | `main.py` | **the sensor derived from `self.allocation.shares`** | +60 B | **RED** — AC 2's headline check. This is the card. |
| M11 | `main.py` | the `circulation` claim never filed | −235 B | **RED** — `test_watchdog.py` **and** `test_peakguard.py` 10f/10g |
| M12 | `main.py` | the latch not persisted to the store | −112 B | **RED** — all three durability checks |
| M13 | `main.py` | `_circ_announced` initialised from the latch | +21 B | **RED** — SAFETY-34's re-raise |
| M14 | `main.py` | the operator clear leaves `_circ_announced` set | −45 B | **RED** — "the next trip would raise nothing" |
| M15 | `arbiter.py` | `circulation` scoped `SCOPE_UNIT` | **+0 B** | **RED** — `test_arbiter.py` ×3 and `test_peakguard.py` ×3 |
| M16 | `config.yaml` | schema floor `400` → `100` | **+0 B** | **RED** — the measured-knee check |
| M17 | `circulation.py` | default `700` → `500` | **+0 B** | **RED** — the CFG-03 pin and the not-a-round-number check |
| M18 | `main.py` | `await self.check_circulating()` removed | −39 B | **RED** — all four notification checks |
| M19 | `main.py` | publish `round(x or 0.0)` instead of `None` | −68 B | **survivor on the first battery — GAP CLOSED**, then RED |
| **M20** | `main.py` | **`pos, neg = opposing(...)` → `([], [])`** — the attribution clause deleted from the alarm | −102 B | **SURVIVED ALL 26 FILES on the shipped battery — GAP CLOSED**, then RED on §6a |
| M21 | `main.py` | `pos` and `neg` exchanged — the alarm names both packs, both the wrong way round | **+0 B** | **RED** — §6a ×3 (the mirrored run, and "only its own sentence") |
| M22 | `main.py` | `opposing(..., min_w=cfg.min_w / 2.0)` → `min_w=0.0` | −25 B | **RED** — §6a's resting-pack check |
**And four in `admin/goodwe`**, graded through `python3 sim/sitesim.py
--circulation` rather than through the injector — deliberately, because
`inject.py` is the file that takes the rig lock, and "run the injector to grade a
mutation" is not a sentence anybody should have to type. Byte deltas over
`plantsim.py` + `sitesim.py` together.
| # | file | mutation | Δ | result |
|---|---|---|---|---|
| S1 | `plantsim.py` | `wedge()` no longer stops the latch reaching the power stage | −20 B | **RED** — the opposing-signs precondition **and** "the watchdog did not trip" |
| S2 | `sitesim.py` | the sensor computed over the **allocation record** instead of register 0x0518 | **+0 B** | **RED** ×3 — this is M10 restated on the rig |
| S3 | `sitesim.py` | the campaign run on `plant=INSTANT` | −51 B | **RED** — the plant is a *term* of the figures; it was a SURVIVOR until that check was added, and finding out why produced §3's 1048/2250/0 W table |
| S4 | `sitesim.py` | the write stagger removed from the legitimate campaign | −19 B | **RED** — "the legitimate campaign produced no opposition at all" |
**S3 is recorded as a survivor because it was one, and it is the most useful
result in this table.** The scenario first shipped with a vacuity check whose
text claimed `plant=INSTANT` would make the sweep measure 0 W. S3 was run and
**passed**: `INSTANT` with a stagger of exactly one control cycle samples the
plant before the second unit has stepped, so it produces opposition anyway — and
a *larger* worst peak (2250 W) than the modelled plant does (1048 W). The claim
in the check's own text was wrong. It was corrected against the measurement, the
plant was pinned as an explicit term, and S3 is red now for a stated reason
rather than by accident. §3's spread table is that measurement.
**M19 is recorded as a survivor because it was one.** The first battery found no
check grading what `publish()` does when a per-unit reading is missing, so
turning *"I do not know"* into *"0 W"* on the one surface an operator actually
watches went unnoticed — the sensor's own `None` rule, defeated one layer out.
A check was added and the mutation re-run; it is red now. The run that found it
was purged, so it is a result and not an artefact.
**M20 IS RECORDED AS A SURVIVOR BECAUSE IT WAS ONE, AND IT IS THE MORE
UNCOMFORTABLE OF THE TWO.** The commit message for this card claimed *"18
mutations, all RED"*, and M1–M19 above have no entry for the notification's
attribution clause. Verification on 2026-09-07 ran it and it **survived the
whole 26-file suite**: the alarm still fires, still stops the site, still says
the meter cannot see it — and no longer says *which two packs* or *which way
round*. `circulation.py`'s own docstring calls that sentence the first thing
somebody woken by this alarm needs and *"impossible to reconstruct from the
number alone"*.
It survived for one reason, and it is not reachability: **M18 (the whole call
removed) is red**, so `check_circulating()` is exercised. The three checks on
that notification asserted `METER CANNOT SEE`, `CONTROL HAS BEEN STOPPED` and
`isolator` — and **never an inverter name**. Nothing graded the attribution.
`test_watchdog.py` §6a closes it, and closes it on a fixture that can tell the
three failures apart. It runs a roster of **`["garage", "shed", "cellar"]`** —
operator names, none of them `UNIT_A`, for test_maintenance.py §11's reason:
on a fixture whose names are the module's own constants a check cannot tell the
roster from the constant. It runs the site **twice, mirrored**, so the sentence
is not symmetric and a swap (M21, `+0 B`) is a different red from a deletion.
And it carries a **third pack resting at 5 W**, so the floor that stops a pack
in standby being named as a participant in a 6 kW loop (M22) is graded too.
## 7. What this card does NOT close
* **Inverter B's ramp and lag are placeholders.** Every transient number in §3
was produced with inverter A's parameters copied onto B (`measured=False`).
SPIKE-04 measures the real ones and is the revisit trigger for the 1.75×
margin. **Do not present any figure here as a measurement of inverter B.**
* **A wedged inverter still cannot be stopped by this add-on.** §1 says so; the
notification says so; there is no software fix and this card does not pretend
to one. It *can* now be produced on demand: `plantsim.Inverter.wedge()`.
* **The depth of the threshold curve is not established by anything committed.**
§3's disclosure. The window is; the knee's magnitude is not.
* **A loop below ~350 W per unit is not reported.** Deliberate, §3.
* **The saturation freeze fires under a wedged unit and leaves a standing grid
error.** Observed on the rig while producing §2's numbers: with B wedged, the
loop's anti-windup test compares a site target against a measured site power
that cannot track it, freezes the integrator and leaves the meter parked at
−3750 W. Nothing alarms on that today — a freeze is an ordinary control action
by design. It is a real finding, it is **not** in scope here, and it wants a
card of its own.
@@ -1,818 +0,0 @@
# SAFETY-32 — `bms_soc_floor` per pack, and the two judgement calls it left open
**Status:** decided and implemented on branch `SAFETY-32`.
**Card:** SAFETY-32 — *"One `bms_soc_floor` for two packs grades the shallower
pack against the deeper one's cut-out"*, scoped by the card's own PM PROOFREAD
to its **third** bullet only. Bullets 1 (aggregate anti-windup) and 2 (per-instance
`achievable_w`) were verified as already shipped by DEV-02 and CFG-04 and were
**not** touched — `main.py`'s `actual_w=self.site_batt_w` comment is the record
of why the first of those must stay site-level, and it still reads as written.
---
## 1. The defect, stated in the terms the code uses
`bms_soc_floor` **describes a battery**: it is the percent that pack's own BMS
cuts out at, register `0x0560` on the reference GW5000S-BP, read as 10. It was a
flat option because there was one pack.
`peak_soc_floor` **describes the site's policy**: the depth a money-saving
discharge may never reach. Rule 8 refuses `peak_soc_floor <= bms_soc_floor`,
because such a floor can never bind — the BMS wins and the status page reports a
backstop that does not exist.
At two packs of different models the two cut-outs differ, and one number for both
is wrong **in the direction that empties a pack**: with cut-outs of 10 % and 30 %
and `peak_soc_floor: 20`, the add-on **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.
---
## 2. DECISION 1 — required per entry, not defaulted from `PEAK_OPTION_DEFAULTS`
The card left this open and asked for the reason to be recorded.
**Decided: REQUIRED of every roster entry**, in `config.yaml`'s schema
(`bms_soc_floor: int(0,50)`, no trailing `?`) and in `validate.py` rule 8, which
refuses a missing one **by name**.
The reason is that a default here is not a convenience, it is **this card's own
defect wearing a different hat**. A default is one number applied to every pack,
which is exactly what the flat key was. An add-on that answers "10 %" for a pack
nobody has told it about has invented a property of somebody's battery, and the
failure mode of the invention is the one this card exists to remove — the
shallower pack protected by a floor that belongs to the deeper one. CFG-03's
rule ("defaults live in exactly one place") points the same way: the one mirror
`PEAK_OPTION_DEFAULTS["bms_soc_floor"]` is the default for the **flat** key, i.e.
for the single-inverter shape, and reusing it per entry would make one dict the
default in two shapes with two meanings.
It is also the treatment `max_w` already gets, for the same reason and by the
same argument (`inverter.py`'s `build_roster()` docstring): the per-unit numbers
that describe hardware are required, and the per-unit *sensors* are optional
because an absent sensor is a real state.
**The cost is stated rather than hidden.** This is a schema change: a roster
config written before this branch is refused by the Supervisor until each entry
gains the key, and every roster fixture in the suite needed it. Nothing in
production carries a roster today — `inverters:` ships `[]` and the second
inverter is ~8 days out — so the migration is paid by fixtures and by whoever
writes the first real roster, at a startup refusal that names the entry.
**What is NOT inherited.** An entry that omits the key does not borrow the flat
one. `Inverter.bms_soc_floor` is then `None`, `peakguard.pack_floor_pct()`
answers `None`, and `discharge_permitted()` already treats an unknown floor as
**no permission** — so even in the window before rule 8 runs (e.g. `sim/inject.py`,
which does not run the startup gate) the absence costs a shave and never a pack.
`0.0` was rejected explicitly: it is the most permissive value the key has.
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
*At N>=2 one pack at its floor no longer stops the others: [[SAFETY-87]].*
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
**is unchanged**, exactly as the card instructed. What changed is the floor it is
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
floors, each of them `max(peak_soc_floor, that pack's own cut-out)`, with a
`max()`. The two reductions pair: a minimum SoC against a maximum floor is the
conservative combination, and both move the verdict towards refusing a shave and
away from spending a pack. `min(soc) <= max(floor)` is implied by "some pack is
at or below its own floor", so the site verdict is never more permissive than
grading each pack separately would be. The converse is not true and is not
claimed — a site can be refused while every pack individually has headroom, which
costs a shave.
### 3a. ⚠️ FIX ROUND — the reduction was written and argued, and NOT GRADED
PR #48 was rejected because the argument in §4 below justified *writing* the
reduction and was then not carried into a check. Neither of the two mutations
below appears anywhere in §6's twelve-row battery: it graded *"the reduction
dropped"* and *"the bare option handed over"*, and never asked what happens when
the reduction is the **wrong one**, or when the roster handed to it is
**sliced**. Both were applied to `app/` by the reviewer and **all 19 files
stayed green**:
| mutation | Δ | why it survived |
|---|---|---|
| `binding_soc_floor()`: `max(floors)` → `min(floors)` | **0 bytes** | every roster in the suite was rule-8-valid or one pack long, so `floors` held **identical numbers** and `max`/`min`/first/last were four spellings of one answer |
| the `decide()` call site: `self.packs` → `self.packs[:1]` | **+4 bytes** | part 12g asserted `"self.packs" in <the unparsed expression>` — and `self.packs[:1]` **contains that substring** |
The zero-byte one was proved to have actually run rather than assumed:
`dis.get_instructions` showed the `LOAD_GLOBAL`s as `[float, pack_floor_pct,
any, min]`, and `binding_soc_floor([Pack("a",50,10), Pack("b",50,35)], 20.0)`
returned `20.0` where the shipped code returns `35.0`.
**The common cause is this repository's recurring defect** — a check whose DATA
cannot reach the failure it names. The one check that used a cut-out **above**
the EMS floor (12c's `_n1_deep`) ran on a roster of **ONE**, where every possible
reduction agrees. So no check in the suite ever evaluated
`binding_soc_floor()` on two packs whose floors **differ**.
**And the rule-8 degeneracy is not a defence, by §4's own words.** If
"unreachable is a claim about today's rule ordering, not a property of this
function, and `validate_startup_config()` is also `sim/inject.py`'s entry point"
is enough to *write* the reduction, it is enough to *grade* it. Through
`sim/inject.py`, which does not run rule 8 at all, `min(floors)` lets the guard
command a forced discharge to 20 % on a pack whose BMS cuts out at 35 %.
**What was added — no shipped behaviour changed, only what observes it:**
* **`test_peakguard.py` part 12c2** evaluates `binding_soc_floor()` on a roster
whose per-pack floors **all differ**. Four **simulated** cut-outs —
`30, 25, 40, 35` %, every one strictly above `peak_soc_floor: 20`, so
`pack_floor_pct()` selects the cut-out for each and `floors` *is* that tuple in
that order. The four candidate reductions are then four different numbers:
max = 40 <- the shipped answer
min = 25
first = 30 <- "grade the site against pack A", inside the function
last = 35
mean = 32.5
The extremes sit at **interior** roster positions (index 2 is the max, index 1
the min). A two-pack roster structurally cannot separate these — there `max`
and `last` coincide and so do `min` and `first` — and neither can three. Four
is the smallest roster that separates all four. The precondition is its own
check (habit 1), one pack is then raised past the old maximum (habit 7: the
**last** pack, which was neither extreme, so the line can only move if the
reduction read it), and `peak_soc_floor` is lifted above every cut-out to grade
the other side of the conjunction.
* **Part 12g is behaviour now, not source text** (MUTATION.md habit 4). Real
`Controller.cycle()`s on a two-pack roster, both packs held at the same SoC and
everything else identical, with the packs' own cut-outs as the only thing
varied:
cut-outs A 10 / B 18 % -> decide() got soc_floor=20 %, floor_limited=False
cut-outs A 10 / B 35 % -> decide() got soc_floor=35 %, floor_limited=True
The observable is `PeakDecision.soc_floor` — the argument, echoed back by the
shipped `decide()` — so the whole path from `Controller.inverters` to the
guard's argument is under test, and the site's **verdict flips** as well as the
number. `self.packs[:1]` answers 20 % both times. **Which** option key the floor
comes from stays pinned on the source in part 4, because that claim genuinely
is about the code's shape; that the option still governs is now graded
behaviourally too.
* ⚠️ **A THIRD SURVIVOR, FOUND WHILE FIXING THE FIRST TWO, AND RECORDED RATHER
THAN QUIETLY REPAIRED.** The first cut of part 12g varied only **pack B's**
cut-out — so `self.packs[-1:]`, grade the site against the **last** pack,
agreed with the full roster on both runs and stayed green across all 19 files
(+5 bytes). A two-run fixture that only ever moves the second pack pins `[:1]`
and not `[-1:]`; it is the same defect one step along, and it was in the fix
for this defect. Part 12g now runs the **same pair of cut-outs in both
orders**:
cut-outs A 10 / B 18 % -> soc_floor=20 %, floor_limited=False
cut-outs A 10 / B 35 % -> soc_floor=35 %, floor_limited=True
...the SAME PAIR SWAPPED, A 35 / B 18 % -> soc_floor=35 %, floor_limited=True
`[:1]` answers 20 % on the second run, `[-1:]` answers 20 % on the third, and
the roster answers 35 % on both. No single-position slice passes all three.
This is the mirror `12h` already ran on the SoC axis, which is where the shape
was borrowed from.
* ⚠️ **AND A FOURTH, from the same battery: the EMPTY-roster branch.** Rewriting
`binding_soc_floor([])`'s `float(soc_floor)` to `return None` (−43 bytes) was
green across all 19 files. It is defensive code — `Controller.inverters` is
never empty on a started controller, and `inputs_ok` refuses the cycle before
the guard is reached if it were — so nothing in the suite went near it. It is
**pinned rather than argued**, in both directions (the option when there is
one, `None` when there is not), because a branch with no check is a branch
that can be rewritten under a green suite. The check also records why it
cannot permit anything either way: `pack_floor_soc` is `None` at an empty
roster, and `discharge_permitted()` refuses on a `None` SoC whatever the floor
is.
* **`wired_over_limit()` gained a `prepare(ctl)` hook**, inside its retry loop so
every attempt grades the same site, which is the only window in which a second
inverter can join the roster before the telegrams are fed.
* **In-process mutation rows 22, 22b and 23** record all four survivors, so a
later edit that re-degenerates any of these fixtures goes red on a row rather
than on nothing.
### 3b. ⚠️ SECOND FIX ROUND — the fix carried the defect it was written to close
Round 2 closed both named survivors, and both of round 3's blockers are the
**same defect sitting inside that fix**: a check whose TEXT claims more than its
FIXTURE can observe. Applied to `app/` on `8501e05`, **all 19 files stayed
green** for both:
| mutation | Δ | the sentence that was too broad | why the fixture could not see it |
|---|---|---|---|
| `binding_soc_floor()`: `max(floors)` → `max(floors[-2:])` | **+5 bytes** | *"So the reduction reads **every entry**"* | 12c2's only counterfactual raised the **last** pack (`_deeper[-1]`), and **every window that ends at the roster's end follows the last pack**. On `(30,25,40,35)` both answer 40; on `_deeper` both answer 45. |
| the `decide()` call site: `self.packs` → `self.packs[:2]` | **+4 bytes** | *"what is reduced is the **ROSTER** and not a POSITION in it"* | all three `_floor_reaching_decide` runs drove a **two-pack** roster, where `self.packs[:2]` **is** `self.packs` — by exactly the argument 12c2 itself makes about two packs being unable to separate max/min/first/last. |
Round 2's *report* stated the narrow, true claim ("no **single-position** slice
passes all three runs"). The *check in the file* stated the broad one. That gap
is the whole of round 3.
**THE GENERAL LAW, stated once so the next card can use it:**
> **A fixture of N packs cannot separate two reductions that agree on N packs.**
> Two cannot separate max from min from first from last. Three cannot separate
> max from max-of-the-last-two. A single counterfactual that varies one position
> cannot separate the reduction from any window containing that position.
Its corollary is the reason this section is long: **the regress does not
terminate in a fixture.** Closing `[:1]` took two packs; `[:2]` took three;
`[:3]` takes four — and a four-pack run hands `[:4]` the same free pass. Round 3
therefore does two different things rather than adding one more pack.
**What was added — no shipped behaviour changed, only what observes it:**
* **Part 12c2 gained a ROSTER SET and a computed FAMILY TABLE.** The same four
simulated cut-outs with the **deepest moved to each of the four positions**,
and fourteen enumerated rewrites of `max(floors)` each checked against every
roster. The table is **computed and printed**, and a member with an empty kill
list takes the check **red naming it** — so a family member can no longer be
added without a fixture that reaches it. `max(floors[-2:])` is killed only by
the rosters whose deepest sits at index 0 or 1, which is precisely the data the
single last-pack counterfactual could not supply.
* **Part 12c2 gained a LENGTH SWEEP.** `binding_soc_floor()` is a pure function
over a list, which is the one place a sweep is cheap: **78 simulated rosters,
lengths 1 through 12, the deepest cut-out at every position of each.** That
closes *every* head window `max(floors[:w])` and *every* tail window
`max(floors[-w:])` of width **1 to 11** in one property rather than one
mutation at a time — and the check asserts that width **12 is NOT reached**, so
the limit is a red line rather than a silence.
* **Part 12g drives THREE-pack rosters**, with the deep cut-out at index 0, 1
**and** 2. Every positional slice of a three-pack roster is a proper subset of
`{0,1,2}` and so drops a position; the run whose deep pack sits there answers
the option, 20 %, where the roster answers 35 %. `self.packs[:2]` goes red on
the index-2 run. `_floor_reaching_decide()` now takes `*floors`, and the extra
units join through the same `second_inverter()` hook the two-pack runs use.
* **Part 12g gained a POSITIONAL-SLICE TABLE**, computed against the **real**
`Controller.packs` each of the six cycles actually ran and the **real**
`PeakDecision.soc_floor` it produced — so it measures the shipped call site's
own answer, not a re-derivation, and a survivor takes the check red.
* ⚠️ **AND A FIFTH AND SIXTH SURVIVOR, FOUND BY ROUND 3'S OWN BATTERY AND
CLOSED STRUCTURALLY RATHER THAN BY ONE MORE PACK.** `self.packs[:3]` (+4) and
`self.packs[-3:]` (+5) survive **all six** behavioural runs, because the
longest roster those runs drive is three packs long. Part 12g therefore also
pins the roster **argument's identity** on the AST: it must unparse to exactly
`self.packs`, which is false of every slice at every length. ⚠️ This is **not**
the substring test round 2 removed — `"self.packs" in expr` is *true* of every
slice; `ast.unparse(arg) == "self.packs"` is *false* of all of them. The two
halves are separated and each says what it reaches: the **runs** grade the
VALUE and reach every slice up to N=3; the **pin** grades the ARGUMENT'S
IDENTITY at any N and is blind to two things the runs are not — a rewrite of
what `Controller.packs` *returns* (the runs' `len(ctl.packs) == 3` catches
that) and the reduction *inside* `binding_soc_floor()` (12c2's).
* ⚠️ **AND ONE ROW IS RECORDED AS NOT CAUGHT.** A reduction over a window
**twelve packs wide** is indistinguishable from `max(floors)` on every roster
in the file, because the sweep stops at twelve. Unlike the call site there is
no identity claim to pin — the claim is semantic, "the maximum", and a shape
pin would refuse honest refactors of the expression. It is reported **with its
size** (it needs a 13-pack roster, on a site whose roster is ONE and whose
second unit is ~8 days out) rather than hidden.
* **Two rewrites are EQUIVALENT, not uncaught.** `max(set(floors))` and
`list(reversed(self.packs))` cannot change any answer — `max` is order- and
duplicate-invariant. The second is nonetheless red on the structural pin.
* **In-process mutation rows 24 – 27** record all four of round 3's findings,
including the not-caught one, so a later edit that re-degenerates any of these
fixtures goes red on a row rather than on nothing.
## 4. ⚠️ THE HONEST LIMITATION, and it is the reason this record exists
**Under any configuration rule 8 accepts, `binding_soc_floor()` answers
`peak_soc_floor` for every pack.** Rule 8 refuses `peak_soc_floor <= bms_soc_floor`
on *every* unit, so `max(peak_soc_floor, bms_i) == peak_soc_floor` everywhere the
add-on will actually start. The per-pack **floor** is therefore non-degenerate
only against a config rule 8 refuses.
This is not a defect and it is not hidden. It is the same argument
`validate.unit_caps_indexed()` makes about itself, in that function's own words:
> *"Rule 9(d) already refuses such a config so the two lists cannot desynchronise
> in shipped use — but 'unreachable' is a claim about today's rule ordering, not
> a property of this function, and `validate_startup_config()` is also
> `sim/inject.py`'s entry point."*
CFG-04's index desynchronisation is what happened the last time that reasoning
was left implicit. So the reduction is written, and it is graded on both sides:
`test_peakguard.py` part 12c sweeps **1775** rule-8-valid `(peak_soc_floor,
bms_soc_floor)` pairs and asserts the answer is the option in every one of them
(that is AC 5, measured over the whole option space rather than at the shipped
point), and then asserts that on the pair rule 8 *refuses* the answer is the
**cut-out** — because without that second line the sweep would be satisfied by
`soc_floor` typed straight through, and the mutation that deletes the `max()`
could never bite. It does bite: M2 on the PR.
**What IS live at a rule-8-valid roster** is the rest of the card: rule 8 now
refuses the two-model config that used to start; the permission is answered per
pack, so the alarm names the pack that stopped the site rather than "the
battery"; and an entry with no stated cut-out is withheld instead of defaulted.
## 4b. ⚠️ ROUND 4 — the sibling argument, and SPIKE-04 making this the live axis
Rounds 2 and 3 hardened the **floor** axis: the `max` inside
`binding_soc_floor()`, the roster handed to it, `Controller.packs`, and
`pack_floor_pct()`'s conjunction. Round 4's blocker is the same family at the
**sibling argument of the same call**:
```python
self.peak_guard = peakguard.decide(
...,
soc=self.pack_floor_soc, # MINIMUM <- round 4
soc_floor=peakguard.binding_soc_floor(self.packs, ...), # MAXIMUM <- rounds 2-3
```
`peakguard.py`'s own docstring names them as a pair — *"MAXIMUM here pairs with
the MINIMUM in `pack_floor_soc`"* — and a conjunction is exactly as strong as
its weaker half. On `52380c0`, with all 19 files green and verified again after
the rebase onto `3934ad7`:
| mutation in `Controller.pack_floor_soc` | bytes | before round 4 | now |
|---|---|---|---|
| `min(float(v) for v in vals[:2])` | +4 | **GREEN** | RED |
| `min(float(v) for v in vals[-2:])` | +5 | **GREEN** | RED |
| `min(float(v) for v in vals[:3])` | +4 | **GREEN** | RED |
| `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)`, the mean | | RED | RED |
The suite's separating power on the SoC axis stopped at exactly **N = 2** —
precisely the state the floor axis was in before round 3, and which round 3
itself named as its blocker 2. A fixture of N packs cannot separate reductions
that agree on N packs.
**It changes a real answer.** A three-pack roster at 50 / 60 / 11 %, emptiest
pack **third**: the shipped property answers 11 %, `vals[:2]` answers 50 %. The
site is then graded for discharge *permission* against a pack 39 points fuller
than the emptiest, and spends a pack its own BMS stops — through
`sim/inject.py`, which runs no validator rule at all. The verdict flips with it:
11 % is below the 20 % EMS floor, so the shipped site is floor-limited and sizes
no forced discharge, where the mutant is permitted and sizes a positive one.
### ⚠️ SPIKE-04 makes the differing-floor discipline the entire exposure
SPIKE-04 reads pack B as **4 × Dyness DL5.0C, DoD 90 → floor 10 %** — **FROM THE
DATASHEET AND THE SERIAL. Inverter B does not physically exist (~8 days out) and
nothing about it has been measured.** Pack A is DoD 90 → 10 % as well, and *that
one is measured*, off register `0x0560` on the reference GW5000S-BP.
So **production is degenerate on the floor axis**: both packs floor at the same
value, `max(10, 10) == 10`, and a differing floor exists **only in the
fixtures**. That is not an argument for simplifying the fixtures to matching
values — it is the argument for keeping them. `binding_soc_floor()`'s reduction
is observable *nowhere else*, and the moment a third pack, a replacement pack or
a different chemistry arrives it is observable in the field. **Anyone who
"simplifies" `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to one number deletes the only
data this repository has that can see the reduction at all**; part 12c2's
precondition check goes red if they do, and this paragraph is why.
The **SoC axis has no such degeneracy**. Two batteries at the same state of
charge is a coincidence, not a configuration, so `pack_floor_soc` is the half of
the pair that will be non-degenerate in the field from the day inverter B is
wired up.
### The treatment, transferred from round 3
* **the roster SET** — six real `Controller.cycle()` runs, the emptiest pack at
every position of a three-pack roster and both positions of a two-pack one,
the observable read off `PeakDecision.soc` (the argument, echoed) rather than
off a spy or a grep. Cut-outs held flat across all of them, so the floor axis
cannot be what moved the answers.
* **the positional-slice table and the non-slice table** — computed against the
real `Controller.inverters` each cycle carried and the real decision it
produced, printed, with a member that nothing kills taking the check red.
* **the length sweep** — the real property on real rosters of 1 to 12 inverters,
the empty pack at every position. Closes head and tail windows of width 1-11
at once. Width 12 and wider is **honestly not reached**, and asserted to be
unreached so a later sweep that lengthens the rosters must rewrite the
sentence.
* **the exact structural pin** — three of them, because there are three
reductions over roster length in this one property: `vals` is built by one
comprehension over the bare `self.inverters`; `any(... for v in vals)` reduces
the whole list; `min(... for v in vals)` reduces the whole list. Every slice
of either is red at **every** roster length, including the `[:3]` and `[:12]`
the runs and the sweep cannot see.
Unlike the floor axis, **there is no uncaught row here**: the claim at
`pack_floor_soc` is an *identity* ("this list"), so a shape pin is the right
instrument, where `binding_soc_floor()`'s claim is *semantic* ("the maximum")
and a shape pin there would refuse honest refactors. That asymmetry is why one
carries an open row and the other does not.
## 4c. ⚠️ THE ENUMERATION — what OTHER reductions over roster length are graded
This card has now been rejected three times for the same shape one step over, so
round 4 stopped closing members and enumerated the **family**. Every reduction
over `self.inverters` / `self.packs` in `Controller` was found from the AST and
mutated to a slice, one at a time, against the full 19-file suite.
| # | site | reduction | consumer | mutation | result |
|---|---|---|---|---|---|
| 1 | `pack_floor_soc` | `min` over SoC | `decide(soc=)`, `arbitrage.decide(soc=)` | `vals[:2]` +4 | **was GREEN → now RED** (12i) |
| 2 | `pack_floor_soc` | `any(v is None)` | same | `vals[:1]` +4 | RED (raises in 12d; pinned in 12i) |
| 3 | `pack_floor_soc` | the `vals` comprehension | same | `self.inverters[:2]` +4 | RED (12i sweep + pin) |
| 4 | `binding_soc_floor` | `max` over floors | `decide(soc_floor=)` | `min`, windows | RED (12c2, round 2-3) |
| 5 | call site | the roster argument | same | `self.packs[:1..3]` | RED (12g, round 3) |
| 6 | `Controller.packs` | the comprehension | `binding_soc_floor`, `pack_permissions` | `self.inverters[:2]` +4 | RED (12g three-pack runs) |
| 7 | `site_batt_w` | `sum` + `any(None)` | `decide(batt_w=)` — sizes the floor | `self.inverters[:1]` +4 | RED (test_inverter.py) |
| 8 | `site_rating_w` | `sum` of per-unit conjunction | `decide(achievable_w=)`, freeze rail | `[:1]` +4 | RED (test_inverter.py) |
| 9 | `site_max_w` | `sum` of `max_w` | the site loop's supervised bound | `[:1]` +4 | RED (test_inverter.py) |
| 10 | `site_dev_bound` | `sum` + `any(None)` | `looks_frozen`, `rail_unreachable` | `[:1]` +4 | RED (test_inverter.py) |
| 11 | `site_circulating_w` | `circulating_w` over measured power | SAFETY-31 trip | `[:1]` +4 | RED (test_watchdog.py) |
| 12 | `inputs_ok` | `all` over both readings | gates the whole cycle (§11.1 rule 1) | `[:1]` +4 | RED (test_inverter.py) |
| 13 | `units` | `tuple` of names | the arbiter's scope table | `[:1]` +4 | RED (5 files) |
| 14 | `status_payload` | `min(_dues)` over `self.maints` | the operator's "maintenance due" row | `_dues[:1]` +4 | RED (test_maintenance.py) |
| 15 | `maint_unit` | `self.inverters[self.maint_index]` | which pack the FSM drives | `[0]` −15 | RED (test_maintenance.py) |
| 16 | store migration | the roster name list | MAINT-05's key migration | `[:1]` +4 | RED (test_maintenance.py) |
| 17 | `validate_unit_ranges` | the roster argument | startup refusal | `[:1]` +4 | RED (test_inverter.py) |
| 18 | `cycle()` stale-input hold | `any(sign_wd.tripped)` | **a latched unit's failsafe SET** | `[:1]` +4 | **was GREEN → now RED** (test_watchdog.py part 8) |
| 19 | `publish()` status | `any(sign_wd.tripped)` | the MQTT status vocabulary | `[:1]` +4 | **was GREEN → now RED** (part 8) |
| 20 | `status_payload()` banner | `any(sign_wd.tripped)` | the one headline on the page | `[:1]` +4 | **was GREEN → now RED** (part 8) |
| 21 | `_grade_store_migration` | `any(sign_wd.tripped)` | SAFETY-02's re-arm guard | `[:1]` +4 | was GREEN → now RED (12j), see below |
| 22 | `cycle()` undeliverable list | `sorted(...)` over shares | the *text* of one event line | `[:1]` +4 | **STILL GREEN — honest gap, below** |
**Rows 18-20 are the finding, and they are not this card's code.** All three are
`any(i.sign_wd.tripped for i in self.inverters)`, all three are SAFETY-30's, and
all three survived a green 19-file suite for exactly the reason every blocker on
this card survived: *the only fixture that reached them was a roster of ONE,
where `self.inverters[:1]` IS `self.inverters`.* Part 3 of test_watchdog.py has
built a two-inverter roster with only inverter B latched since SAFETY-30
shipped — the precise data all three needed — and none of them read it.
Row 18 is the one with teeth. A latched unit must take the whole roster past the
stale-input hold, because that unit's `failsafe` SET is the thing parking it;
main.py's own comment says *"the trip would stop acting the moment a poll was
missed"*. With `[:1]`, a latched inverter B plus a one-cycle gap in inverter A's
SoC returns early, and B's last share stands for the heartbeat to re-emit.
test_watchdog.py part 8 now drives that with the latch at **each** position, plus
the no-latch counterfactual that proves the hold really fires.
**Row 21 — graded structurally, and argued to be redundant.** The `[:1]` there
is now red on 12j's enumeration, but I could not construct a *reachable* state in
which it changes an answer: `_grade_store_migration()` runs at startup, before
any cycle, so a unit is tripped in memory only if its own `unit/<name>/sign_tripped`
key was truthy — which makes `namespaced_latched` True and short-circuits the
disjunct. I believe it is an equivalent mutant at that call site rather than a
live hole, and I am recording the argument instead of asserting the conclusion.
**Row 22 — STILL GREEN, and reported with its size.** `", ".join(sorted(u for u
in resolution.shares if not self._can_write(u)))[:1]` truncates the **names in
one event-log line**. It is +4 bytes and no check bites. The refusal itself —
`elif {u for u in resolution.shares if not self._can_write(u)}` — is a different
expression and *is* graded (test_peakguard.py part 10h-1b), so the site still
stops; what a `[:1]` costs is an operator being told one name when two units have
no writer. It is not a roster comprehension (it iterates `resolution.shares`), so
12j does not cover it and does not pretend to. Closing it needs a three-inverter
fixture with two writer-less entries, which is ALLOC-01's fixture and not this
card's. **Filed as open, with its size and its consequence, rather than closed
quietly or left unmentioned.**
### And the family closed rather than the member
`test_peakguard.py` part 12j enumerates the family from the AST and requires
every member to take the roster **whole**, so a new reduction over a slice is red
**on the line it is written**, at every roster length, with no fixture required.
That check is what would have caught rounds 2, 3 and 4's blockers on the round
they were written instead of one review round each; it is also what found rows
18-21.
⚠️ **AND IT WAS STILL TOO NARROW — see §4d.** Round 4's version enumerated the
26 roster comprehensions inside the `Controller` class, because that is where
the four known sites were. Round 5's blockers were four reductions in
`validate.py`, one module over, over a list *derived* from the roster three
calls away from it. The pin now parses every `.py` under `app/` and finds the
roster by transitive closure from one option key: **183 reductions across 9
modules.**
## 4d. ⚠️ ROUND 5 — the same family one MODULE over, and an instrument that discovers its own scope
Round 4 enumerated the family and closed it **inside the `Controller` class**,
because that is where the four known sites lived. The review found four more
survivors immediately, in `validate.py`, on a 19/19 green suite:
| site | mutation | delta | suite |
|---|---|---|---|
| `validate.py:779` | `min(caps)` → `min(caps[:1])` | +4 B | **GREEN** |
| `validate.py:779` | `min(caps)` → `min(caps[-1:])` | +5 B | **GREEN** |
| `validate.py:778` | `caps = [cap for _, cap in caps_ix[:1]]` | +4 B | **GREEN** |
| `validate.py:893` | `min(caps_ix[-1:], key=lambda p: p[1])` | +5 B | **GREEN** |
**None of them is an equivalent mutant — a safety refusal disappears.** With
`max_w: 5000` on inverter-a beside `2000` on inverter-b and
`sign_disagree_w: 3000`, the shipped validator answers one problem —
*"sign_disagree_w is 3000 W but max_w (inverter-b) is 2000 W, so the SAFETY-02
sign-disagreement watchdog can never fire"* — and every one of the four answers
**zero**. The site starts, with a watchdog reporting `armed` on the status page
that cannot trip on pack B. That is rule 5's own failure mode reached **through**
rule 5, which is the sentence `test_inverter.py:1657` says the rule exists to
prevent. Rule 6 loses its refusal the same way.
### Why round 4's pin could not see them, and why the fix is not four more checks
`caps` and `caps_ix` are not comprehensions over `self.inverters`; they are lists
**derived** from the roster, three function calls away from it, in a module the
enumeration never opened. Closing them by hand would have produced a fifth
rejection with the same shape one step over — that is the whole record of this
card. **A hand-written scope is the same defect as a hand-written fixture**
(MUTATION.md habit 12): a check whose data cannot reach the failure it names.
### The instrument
`test_peakguard.py` part 12j now parses **every `.py` under `app/`** off disk —
no module is named anywhere in the check — and finds the roster by transitive
closure from **one seed**, the `inverters` option key:
* a function whose returns are roster-carrying is **roster-producing**
(`roster_entries` → `build_roster` → `unit_caps_indexed` → `unit_caps`);
* a name assigned one carries it, **and so does a list accumulated in a loop
over one** — which is exactly what `caps`, `roster` and `problems` are, and
what round 4's rule could not express;
* an attribute or property answering one carries it (`self.inverters`,
`Controller.packs`, `Controller.units`, `live_units()`);
* **an argument at a call site taints that parameter in the callee**, which is
how the pin walks into `peakguard.binding_soc_floor`, `arbiter.resolve`,
`allocator.plan`, `circulation.circulating_w`, `maintenance.arbitrate` and
`store.migrate_maint_keys` without being told they exist;
* and **a slice of a roster is still a roster**, so a mutation cannot cut the
taint and hide every reduction downstream of itself. An **index** is one unit
and is not.
It then requires, at every place a roster-carrying value is reduced or iterated,
that the whole of it arrives: no `[:1]`, `[-1:]`, `[:2]` or `[0]` between the
roster and the reduction.
### What it found — and the hand-written list was wrong in BOTH directions
**183 reductions across 9 modules**, out of 23 `.py` files parsed:
`main.py` 96, `allocator.py` 24, `validate.py` 19, `arbiter.py` 14,
`circulation.py` 10, `maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3,
`store.py` 3 — and 14 modules with no roster reduction at all.
The card's own suggested module list named `watchdog.py`, which reduces over no
roster at all (SAFETY-30's sign watchdogs are strictly per-unit — there is
nothing to slice), and did **not** name `maintenance.py` or `store.py`, which
both do. A pin built from that list would have made a vacuous claim about one
module and no claim at all about two others. That is the argument for computing
the scope, made with data rather than with an assertion.
The pin **independently rediscovers all four blockers** — it is told one option
key and nothing else — and finds a **fifth** beside them at
`validate.py:783`, the `min(caps_ix, key=...)` inside rule 5's message, which no
review round named.
### The counterfactual, at two depths
* **Per site, all of them.** Every enumerated site's own expression, in its own
scope, mutated with each of the four cuts this card has actually been defeated
by. **183 sites × 4 cuts = 732** mutated spellings; every one reported. The same run asserts
the predicate **accepts** the legitimate wrapped forms the package really uses
(`enumerate`, `zip`, `sorted`, `pack_permissions(packs, floor)`) and does not
treat a keyed lookup (`weights[u]` in `allocator._fill`) as a slice — which is
what four false SLICED reports cost before `shortens()` was written.
* **End to end, on the real files.** `[:1]` written into `app/validate.py` at
each blocker position, the **whole** analysis re-run over the mutated source,
and a sliced site required at that exact line — where the clean package
reports none. This is the check that would have taken PR #48 red four rounds
ago, with no fixture reaching it.
### The sweep, and the number that must not be quoted alone
All 183 sites were mutated to `[:1]`, one at a time, +4 bytes each, and each
mutant run against the full 19-file suite in `goodwe-addon-ci` (mounted `:ro`,
`python3 -B`, a fresh rsync per mutant; 0 `.pyc` in the tree before and after —
a read-only mount cannot write one, so TEST-15 is closed by construction here
rather than by discipline). **183 of 183 RED.**
⚠️ **On its own that is tautological**: with the pin in the tree every slice is
red *by the pin*. So all 51 mutants whose first red was `test_peakguard.py` were
re-run with the pin **neutralised** (`_SLICED` blanked), leaving only the
behavioural checks:
| | |
|---|---|
| **145 of 183** | a behavioural fixture in this repository separates the site from a slice of itself — red with the pin turned off |
| **38 of 183** | **nothing but this pin sees it.** Structurally closed by this card; behaviourally **UNGRADED**, and listed rather than counted as covered |
The 38: `main.py` 27, `allocator.py` 6, `validate.py` 2, `arbiter.py` 1,
`circulation.py` 1, `maintenance.py` 1. Grouped by what a `[:1]` there costs:
* **The operator's page — 15, the largest group.** `Controller.checks`
(3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370, 3400, 3458) and
`status_payload` (3556, 3566, 3578). A slice reports one unit's row where two
exist, or "1 of 2 live" reading 1 of 1. Nothing commands differently; what is
lost is the evidence a human uses to notice that it should.
* **The write path's own counts.** `write_setpoint` 1267/1272/1280 — three
`len(targets)` guards. The write itself iterates `targets` at 1287 and *is*
graded (test_inverter.py); these three are its log arithmetic and refusal
count.
* **The allocator's internals.** `_fill`'s zero-weight last-resort pool (365),
`plan`'s `dropped` (526, 653), `free` (584), `frac_order` (591). ALLOC-01
drives two and three units through the *weighted* pass; the last-resort pass
and the largest-remainder tie-break are reached, but not with enough units for
a slice to change the answer.
* **Startup and FSM plumbing.** `__init__` 235/351, `_only` 831,
`_maint_button` 805, `cycle`'s maintenance zip 1505, `send_heartbeats` 3015,
`check_output_clamp` 2445/2452, `check_circulating` 2701,
`maintenance.arbitrate` 547, `arbiter.site_resolve` 517,
`circulation.opposing` 242.
* **Two refusal-TEXT sites.** `validate._roster_problems`'s `len(entries)` (554)
and `refuse()`'s numbering loop (1263) — both shape the words of a refusal
that still happens.
* **`_grade_store_migration` (698)**, already row 21 of §4c, argued there to be
an equivalent mutant at that call site. The argument is unchanged and is still
an argument, not a proof.
⚠️ **None of the 38 is closed behaviourally by this card, and saying so is the
point.** Closing sites one at a time is what cost this card four review rounds.
The pin closes the **family** — a slice at any of them is red on the line it is
written — and this list is what a shape pin can never promise.
### And the behavioural half, because a shape pin cannot grade a meaning
The pin grades an **identity** ("the whole roster arrives"). `min` → `max` is a
zero-byte edit and invisible to it, forever. So `test_validate.py` grows a
**roster SET** for the three rules that reduce over `unit_caps_indexed()`:
rosters of width 2, 3, 4 and 5 with the one small cap at **every position** —
14 rosters × 3 rules = 42 runs of the real validator — plus a **computed family
table** of twelve candidate reductions (`max`, `first`, `last`, the mean, and
`[:1]`, `[-1:]`, `[:2]`, `[:3]`, `[1:]`, `[:-1]`, `[::2]`) each compared against
what the shipped validator actually said on the same roster. Only `min` agrees
everywhere; any member no roster kills is **printed as a named survivor** and
takes the check red.
### ⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's
Found by reading the three branches side by side — **not** by the pin, and said
that way because it is the shape of what the pin cannot see. Rule 5's sibling
branch, `unit_sign_disagree_w` (SAFETY-30's per-inverter sign watchdog), was
graded against `_number(opts, "max_w", 2000.0)` — the **flat** option — three
lines under a branch that had already stopped doing that. Both directions were
measured before the fix:
* a roster of two 2000 W units beside a leftover `max_w: 5000` from a
single-inverter install, with `unit_sign_disagree_w: 3000`, produced **zero
problems**. The site started, the status page said `armed`, and the per-unit
watchdog could not trip on **either** unit. Identical sentence to the blocker,
reached through the branch beside it;
* and a roster of two 5000 W units with no flat key was **refused** at 3000 W
against a 2000 W *default the operator never wrote*, for a threshold both
units can reach.
It takes the same `weakest` the branch above takes, from the same list, and
names the same unit. At a roster of one `unit_caps()` is `[max_w]`, so the
shipped single-inverter refusal is the same comparison, the same boundary and
the same sentence. Both directions are asserted in `test_validate.py`.
### ⚠️ WHAT THE PIN CANNOT SEE — open rows, with sizes
1. **Semantics.** `min` vs `max` vs the mean: zero-byte edits, invisible to any
shape pin. Only the behavioural sets above can see them, and they only see
the rules they drive.
2. **Direct index sites that are not reductions.** `self.inverters[0]` in
`_only()` and `actuator_of()`, `self.inverters[self.maint_index]` in
`maint_unit`, `entries[index]` in `_unit_label()`. Single-unit lookups, meant
to be, graded where they live.
3. **A subscript by a NAME.** `weights[u]`, `cap[u]`, `add[u]` in
`allocator._fill` are keyed lookups into a roster-derived mapping and are
accepted deliberately, so `caps[i]` with a variable `i` would pass. Not in
the family this card has been defeated by; buying it costs four false reports.
4. **A slice through an intermediate call.** `sorted(roster)[:1]` subscripts a
call result, not a roster reference. Nothing in `app/` is written that way
today; if something is, it would not be seen.
5. **Anything outside `app/`.** `tools/`, `sim/` and the checks themselves are
not parsed — and `sim/inject.py` reaches `validate_startup_config()`.
6. **The taint's own seam.** It crosses a call boundary only where the callee is
a plain function this package defines and the argument is positional or
keyword. A roster handed through `*args`, stored in a dict and read back, or
passed to a method on an unresolvable object, is lost at that hop — and
everything downstream goes **unenumerated** rather than reported unreachable.
That is the shape of the remaining exposure: not zero, and not measurable
from inside the check.
7. **`cycle()`'s undeliverable-unit log line** (row 22 of §4c) is still green at
+4 bytes, unchanged from round 4 and still filed open.
## 5. ⚠️ THE RESIDUAL — a per-pack EMS floor is NOT this card
`peak_soc_floor` is still **one number for the site**, so the card's own
"or the deeper pack is needlessly withheld" half is not closed and this record
does not pretend it is. With cut-outs of 10 % and 18 %, rule 8 obliges the
operator to set the EMS floor above 18, and the 10 % pack is then held above its
own need. Closing that means `peak_soc_floor` becoming a per-entry key too, which
is a second option migration and a product decision about what the site-wide
number then means. The card scoped itself to `bms_soc_floor` and explicitly told
this developer not to widen it, so it is filed here as the open question it is
rather than smuggled in.
**Also still not in this card, and named by the shipped code already:** the
charge *ceiling* is the mirror image — `pack_floor_soc` is the wrong reduction
for it, because a ceiling is the **fullest** pack and `arbitrage`'s `soc_ceiling`
takes one number. `main.py:932-936` says so, and it belongs to CFG-04/SPIKE-04's
per-pack bound. Nothing here touched it.
## 6. What was changed, and one thing that was found on the way
* `config.yaml` — `bms_soc_floor: int(0,50)` required per `inverters:` entry;
the flat key and its default are untouched and are documented as the
single-inverter shape's own cut-out.
* `app/inverter.py` — `Inverter.bms_soc_floor`; `_entry_bms_floor()` (`None`, not
a borrowed number and not `0.0`); `_flat_bms_floor()`.
* `app/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.
* `app/validate.py` — `unit_bms_floors_indexed()`; rule 8 grades every unit and
names it via the **roster** index the value came off.
* `app/main.py` — `Controller.packs`; the `decide(soc_floor=…)` reduction; the
floor-limited notification names the pack(s) at or below their own floor, at a
roster of two or more only.
* `tools/supervisor_shape_probe.py` — **re-run, and repaired.** MUTATION.md
habit 8: a committed transcript is a check and it decays. Making the key
required broke §C outright and, worse, left §D printing **PASS** on a refusal
about the *wrong key* — its "no `max_w`" case was answered `Missing option
'bms_soc_floor'`. §D is now an isolated counterfactual over a **merged,
accepted** baseline with exactly one key varied per case, asserting the key's
**name** in the refusal, and it carries a `bms_soc_floor` case in both
directions. Re-run against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`:
`all cases passed`.
* `app/inverter.py`, found by the mutation battery — `_flat_bms_floor()` was
written with the default in **two** places (the `.get()` fallback *and* the
blank test). Deleting either one survived, because each covered for the other:
MUTATION.md's masking section in three lines of option reading. It has one
defaulting line now, and M12 takes six checks red.
* **Round 3 changed no `app/` file at all.** `test_peakguard.py` only:
parts 12c2 and 12g. Its battery was **46 mutations across
`binding_soc_floor()`'s reduction, the `decide()` call site, `Controller.packs`
and `pack_floor_pct()`'s conjunction** — bytecode purged and counted before and
after each (23 → 0). **43 red, 3 survivors, all three recorded above and in the
in-process rows**: `max(floors[:12])` and `max(floors[-12:])`, which need a
13-pack roster to observe and are reported with that size rather than hidden;
and `max(set(floors))`, which is **equivalent** — `max` is duplicate-invariant,
so it cannot change an answer. `list(reversed(self.packs))` is equivalent too
(`max` is order-invariant) and is nonetheless red on the structural pin.
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
physically exist. Every second-pack number in a fixture, in the probe and in
config.yaml's commented example is **simulated and labelled as such**; only the
10 % on inverter A is read off hardware. The three-pack runs added in round 3 are simulated
twice over: there is no inverter C at all, and the shallow slots carry inverter
A's measured 10 % while the 35 % cut-out that moves along the roster is invented.
---
## 7. The probe transcript, re-run 2026-09-05 on 2026.08.0
Command (unchanged, in `tools/supervisor_shape_probe.py`'s own docstring):
```
docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" \
--entrypoint python3 \
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
/probe/supervisor_shape_probe.py
```
It reads `config.yaml` and runs Supervisor's own `AppOptions` validator in a
throwaway container. It touches no live Supervisor, POSTs nothing and installs
nothing.
⚠️ **RE-RUN AGAIN ON 2026-09-05**, because the rebase onto `67e649a` merged
SPIKE-04a's `max_w: 4600` into the same §C/§D blocks this card had rewritten,
and MUTATION.md habit 8 says a committed transcript that a document quotes is a
check. A hand-merged transcript is not a measurement of anything. The block
below is the output of that run, not of the 2026-09-04 one: B's rating reads
**4600** — its datasheet figure, still not a measurement of any unit — and every
case still passes.
```
== C. AC2: a two-inverter blob an operator would POST ==
PASS two 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_so
== D. what the schema itself refuses, per entry ==
PASS the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere else to come from
[{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600, "bms_soc_floor": 10}]
PASS an entry with no name is refused, and the refusal NAMES it
Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with no setpoint_entity is refused, and the refusal NAMES it
Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with no max_w is refused, and the refusal NAMES it
Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with no bms_soc_floor (SAFETY-32) is refused, and the refusal NAMES it
Missing option 'bms_soc_floor' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with max_w over the range is refused, and the refusal NAMES it
value must be at most 5000.0
PASS an entry with bms_soc_floor over the range (SAFETY-32) is refused, and the refusal NAMES it
value must be at most 50.0
PASS a JSON STRING where the list belongs is refused
Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller)
== E. the ROLLBACK direction, and the operator's UI ==
PASS a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse
['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert']
PASS 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
[{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer
== all cases passed ==
```
@@ -1,376 +0,0 @@
# SAFETY-37 — the freshness policy, and the one sentence in the card that had to be rewritten
**Status: DECIDED 2026-09-06.** A per-unit reading that has stopped following its
own command is reported to its consumers as **`None`**, never as a held number.
The verdict is a **conjunction**, not an age threshold, and this document records
why an age threshold is not available at this layer — which is the one place the
card's wording could not be implemented as written.
Filed with SAFETY-37. Card `6a9a7eda7932387c3acbd700`, scope widened by the
architect pass of 2026-09-06 to carry the single policy for [[TEL-06]] and
[[TEL-08]] as well.
Every figure below was measured or read out of this repository on **2026-09-06**
and is re-derived by `test_freshness.py` on every run. Where a number is carried
forward from an earlier measurement, the section and the date it was taken are
given at the number.
---
## 0. The three faces, and which one this card built
| where | the defect | this card |
|---|---|---|
| **Layer 1** — `goodwe/master/goodwe-master-stage4-failsafe.yaml:690` | the ingest-age veto tests the **VALUE** of `sensor.goodwe_p1_sample_age`, not its arrival. A dead publisher freezes it at a healthy number and the veto can never fire | **not touched.** It is firmware, behind a flash. §5 says what it owes |
| **The add-on** — `app/hass.py:69-82` | returns a held value as a value. Nothing separates "read 40 %" from "still says 40 % because nothing updated it" | **built here** |
| **The evidence** — [[HW-01]] | SoC held 40 % for 6 h 25 m while the pack drained to its floor, every detector reading healthy | the case the policy is answerable to |
The two consumers the card names — SAFETY-30's per-unit sign check and
SAFETY-31's circulating-power sensor — both read `Inverter.batt` (register
`0x0518`) and **neither had a freshness gate of any kind**. Both already do the
right thing with a `None`: `watchdog.SignWatchdog.evaluate` **clears** its window
on a `None` measurement, and `circulation.circulating_w` returns `None` for a
roster with a missing term, which clears that window too. **Neither could ever
receive one.** The gate is the piece that was missing *between* the shipped read
and the shipped checks, which is why the change to each consumer is zero lines.
---
## 1. ⚠️ The sentence that had to be rewritten, and why saying so is the finding
The card says: *"A pack legitimately resting at 40 % and a dead sensor reading
40 % are the same number; **only arrival separates them**."*
**For a signal this add-on polls out of Home Assistant's state cache, arrival
does not separate them, and cannot.** `read_inputs()` runs about once a second
(`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), and while the
entity exists Home Assistant answers `200` with a number **whether or not the
publisher behind it is alive**. So both members of the pair arrive, identically,
for ever. `test_freshness.py` part 1a builds an arrival-age gate and grades it:
the age since arrival is `0.0 s` at every instant of **both** runs. A threshold
on it is a threshold that can never fire — which is TEL-06's own defect rebuilt
one layer up.
The other half of the card's rule survives intact and is honoured absolutely:
> ⚠️ Home Assistant's `last_updated` does **not** move when a value is rewritten
> unchanged, so it cannot be the source of truth.
Neither does `last_changed`, and this project measured on the ENV-01 rig (70 s of
a frozen meter) and independently against the live house that `last_reported`
does not advance on a repeat on either serialiser either —
`p1.HomeWizardLocalSource`'s docstring records both. **`app/freshness.py` reads
no Home Assistant timestamp at all.** Every clock in the verdict is
`time.monotonic()` inside this process, stamped at the instant an arrival carried
a different number. That is what "arrival-stamped" can mean here, and it is
graded (part 1a asserts the module's body contains no `last_updated`).
**So the criterion was rewritten rather than approximated:** *a reading is stale
when it has not moved through a change it was obliged to follow.* The obligation,
not the age, is what separates the pair.
---
## 2. The verdict
```
STALE ⟺ unchanged for ≥ 35.0 s
AND |standing command now| ≥ 250 W
AND |standing command now − standing command when the reading
last changed| ≥ 250 W
```
Latched: **only an arrival carrying a different number clears it.**
`0x0518` is the inverter reporting **itself** — r = 0.998 with battery power
against r = 0.09 with the real meter (`goodwe/NOTES.md` §3.3) — and it tracks its
own command: command and actual agreeing within 4 W once settled (§5.10,
measured 2026-08-22), the register dithering ±1 W while holding (§5.11, same
day). A unit told to move 250 W and still answering the same integer watt is not
tracking.
### 2.1 Why a bare "unchanged for N seconds" threshold is not available
**Measured, not feared.** Replaying the committed 30-day capture of this house's
own 1 W-resolution power entity — `scenarios/p1-net-30d-2026-08/`, 485,841
samples straight out of Home Assistant's recorder, where a row exists only when
the state string **changed**, so the gap between two rows *is* the unchanged
duration every consumer saw:
| | |
|---|---|
| longest unchanged run over 30 days | **263.2 s** (held −899.0 W, 2026-08-11 17:04) |
| runs ≥ 35 s | 10 of 485,840 (0.0021 %) |
| runs ≥ 17 s | 386 of 485,840 (0.0794 %) |
A real power figure at 1 W resolution held **one value for four and a half
minutes with nothing whatever wrong**. So an unchanged threshold free of false
positives must be over 263 s — and 263 s is **3.5× too slow** to protect a
consumer that trips at 75 s. There is no admissible threshold. The conjunction
is not a refinement; without it the card has no implementable verdict.
⚠️ **That is the METER's register, not `0x0518`, and it is used as a surrogate
and named as one.** No 30-day capture of the inverter's own power exists — it is
not in the recorder export. The single thing this measurement is used for is the
statement above: that the legitimate unchanged duration of a power entity in this
house is *minutes*. It is not used to set the bound.
⚠️ **And every one of those long runs happened under an unmoving load** — the
worst is 263 s of a steady −899 W export, a house doing one thing. That is why
the conjunction keys on command travel: none of them arms it.
### 2.2 Why the magnitude term exists — a surviving mutation put it there
Deleting `abs(standing_w) < move_w` from `_grade()` left **every check in the
first draft green**, because in all of them a command that had *travelled*
250 W had also *ended up* above 250 W. The case that separates them is a site
being brought to **rest**: the write path is serialised, so a register can reach
its new value on the same read at which the *old* command is still standing —
`standing_at_change` then captures 1300 W, the site is commanded to 0 W, and the
travel term alone reads 1300 W of unfollowed command for as long as the site
rests. It would call a perfectly truthful +5 W reading dead. The magnitude term
is what says *a site at rest is not owed movement*, and part 4 now grades it.
**The hole that term leaves cannot mislead either consumer**, which is why it is
the right trade: a unit commanded under 250 W is one `watchdog.disagrees()` is
`False` for whatever the measurement says, and a frozen term beside a live unit
that is itself resting nets to **zero** circulating power rather than to a false
alarm. Both halves are asserted, not argued.
---
## 3. The bounds — one per signal, each with its derivation
⚠️ **One constant for both signals would be useless on SoC or trigger-happy on
power**, which is the third clause of the policy. `freshness.BOUNDS` carries them
as data with a `wired` column, and `test_freshness.py` part 7 walks the AST of
every shipped module to check that column tells the truth.
### 3.1 `batt` — `0x0518`, S16, 1 W, published every 10 s — **WIRED**
**Floor, 17.0 s** — the fastest a healthy register can be *obliged* to have
answered. Three terms, each read out of this repository:
| | | source |
|---|---|---|
| 6.0 s | the plant | `goodwe/NOTES.md` §5.9, measured 2026-08-22 with the poll temporarily at 1 s: 26 W at +1.43 s, 469 W (94 %) at +3.34 s, settled 507 W at +6.05 s |
| 10.0 s | the publisher | `firmware/goodwe-master.yaml:181`, `update_interval: 10s` on the `modbus_controller` carrying both `0x0518` and `0x050E` |
| 1.0 s | this add-on's read period | `main.run_control()`'s `await asyncio.sleep(1)` |
**Ceiling, 75.0 s** — the tighter of the two consumers, both read out of the
shipped `config.yaml`: `circulation_hold_s` **75** (line 512) and
`unit_sign_disagree_s` **85** (line 451). A gate slower than its consumer hands
over the frozen number and explains afterwards.
**Chosen: 35.0 s.** The admissible band `[2 × 17.0, 0.5 × 75.0] = [34.0, 37.5]`
contains four integers, and **the band is what is derived — 35 is a choice inside
it**, deliberately not 30 (that is `validate.FIRMWARE_INGEST_VETO_S`, and would
read as a borrowed constant nobody re-derived) and not 60 (round). Part 6
computes both ends **from `config.yaml`** and goes red if an operator default
moves: drop `circulation_hold_s` to 60 and this fails.
**`MOVE_W` = 250 W** is the **largest** admissible value, not a sensitivity
preference. The gate must arm everywhere a stale reading could mislead a
consumer; the earliest either consumer speaks is `unit_sign_disagree_w`, 250 W
(config.yaml:450), because below that `watchdog.disagrees()` is `False` whatever
the measurement says. Taking the ceiling of the admissible range minimises
spurious arming. It is pinned to `config.yaml` by part 6, not to a literal.
### 3.2 `soc` — `0x050E`, U16, 1 %, published every 10 s — **STATED, NOT WIRED**
SoC **cannot use the conjunction above.** A pack resting at its floor holds one
percent for hours with nothing wrong — the card's own example — and the standing
command says nothing about it, because 0 W is exactly what a rested pack is
given. The obliged movement for SoC is **energy, not command travel**: one
percent of pack A is ≈ **153.6 Wh** (15.36 kWh nominal, `goodwe/NOTES.md` §1.1b's
table — ⚠️ **NOMINAL and FROM DATASHEET**, not a measured usable capacity), so at
pack A's 5000 W ceiling one percent takes at least **110 s**, and at a realistic
1000 W about **553 s**.
So the bound is *"SoC unchanged while ∫|battery power| dt since its last change
exceeded K × 153.6 Wh"*, and **K cannot be chosen from anything written down
today**. Wiring it with a guessed K would be a gate that fires on a rested pack,
which is the hazard, not the fix. `Freshness` takes its obligation term from the
caller precisely so an energy integral can be handed in where command travel is
handed in for `batt` — TEL-08 needs no second mechanism.
---
## 4. What changes on a running install
| | before | after |
|---|---|---|
| healthy register, N=1 | value reaches consumers | **identical** — 60 reads of a dithering register under a reversing command, asserted in part 5 |
| healthy register, N≥2 | value reaches consumers | identical |
| **frozen register, N=1** | reads as a healthy measurement, for ever | **site stops**: `inputs_ok` False → `stale_input_s` hold → 0 W, and keeps commanding |
| **frozen register, N≥2** | per-unit sign check false-trips at 85 s and parks a healthy inverter; circulation sensor reports a loop that is not happening | both consumers get `None`, both windows **clear** |
⚠️ **The N=1 row is a behaviour change on the roster that is running in a house
today, and it is named rather than hidden.** It is also the point of the card:
HW-01 was a one-inverter site. The direction it fails in is the safe one — a
false verdict commands 0 W and keeps commanding (§11.1 rule 1), behind the
existing `stale_input_s` hold, and clears itself on the register's next move.
The counterfactual is run, not argued. `test_freshness.py` part 3 drives real
`cycle()` calls at N=2 with A's register frozen at −1200 W and B's live at
+900 W, the command travel produced by the shipped `compute()` and allocator:
```
standing on A: [0, 445, 890, 1335, 1335, ...] (the shipped control law)
gated : sign trip tick=None circulating_w=[1800, 1800, 1800, None, None, ...]
counterfact : sign trip tick=11 circulating_w=[1800, 1800, 1800, 1800, 1800, ...]
```
---
## 5. What TEL-06 and TEL-08 still owe
**[[TEL-06]] — layer 1, Blocked, and nothing in this card can reach it.** The
ESPHome veto tests the *value* of `sensor.goodwe_p1_sample_age`. This policy
lives in the add-on, on the other side of a flash, and cannot help. What TEL-06
owes is the same mechanism **inside the firmware**: a `millis()` stamp taken when
the setpoint/age entity is *written*, and a veto on `millis() − last_write_ms`
rather than on the published number. It is narrowed to that one substitution —
the policy question ("arrival, not value") is answered here; the remaining work
is a firmware stamp and a flash, which is a supervised human step.
**[[TEL-08]] — stale SoC.** It owes exactly three things, and no mechanism:
1. a **measured** usable capacity for pack A (or a defensible bound on it) — the
153.6 Wh per percent above is nominal and from the datasheet;
2. the **energy integral** since the reading last changed;
3. the constant **K** — how many percent-equivalents of throughput must pass
before an unmoved SoC is a fault.
It should consume `freshness.Freshness` with `BOUNDS["soc"]` filled in, not
restate the policy. The row is already in the table with `wired=False` and the
reason, and part 7 goes red the day that column stops being true.
**⚠️ Neither of them, and not this card, closes HW-01's whole class.** See §6.
---
## 6. Known survivors, with their size
Graded in `test_freshness.py` part 9, so they are residuals rather than
surprises.
| # | kind | what survives | size |
|---|---|---|---|
| 1 | **false positive** | an inverter physically unable to follow — saturating, thermally derating, or a BMS refusing a charge — is declared stale while telling the truth | one spurious **site stop**, behind `stale_input_s`, self-clearing the moment the register moves. It cannot drive the battery anywhere |
| 2 | **miss** | a register frozen under a command that never travels 250 W is never detected, for 400 s or for ever | ⚠️ scoped for ONE consumer, NOT both, and the earlier claim here was wrong. TRUE for the per-unit sign check: no such check can speak about that unit (`unit_sign_disagree_w` = 250 W). **FALSE for circulation.** "Contributing at most 250 W" bounds the COMMAND; once the register is frozen its REPORTED value is unrelated to the command, which is exactly HW-01's shape - commanded ~0 W, register stuck on the last large number. Measured: a unit frozen at −1200 W beside a healthy +900 W sibling yields `circulating_w` = **1800 W against the 700 W floor, 2.6× over**, with no gate armed. Found in review; part 9's S2 check asserts only `not stale` and part 4 pairs the frozen unit with a RESTING sibling, so neither graded the sentence |
| 3 | **miss — the largest** | a dead publisher whose last values **alternate**, or a cache replaying two old samples, never starts the unchanged clock at all | **this gate detects a value that STOPPED; it cannot detect a value that is WRONG.** Nothing in this add-on can. TEL-06's arrival stamp inside the firmware is the only layer that could |
Recorded because it looks like a survivor and is not: a publisher failure that
freezes **both** registers is caught on both units independently, on each unit's
own command travel and each unit's own number.
---
## 7. Mutation record — 2026-09-06
`tools/mutation_run.sh`'s rule, honoured on every run: purge first (count printed,
`0` remaining), tree mounted `:ro`, `python3 -B`, fingerprint the tree, **and
fingerprint again after the revert**. Clean tree `app/freshness.py`
`sha256:f40fa3e8819014ce` / 20,065 B, `app/inverter.py` `7e760c6e8cdfa5b7` /
30,778 B, `app/main.py` `7d7ff4ba0697ea64` / 238,535 B. Each mutant graded by
`test_freshness.py` + `test_inverter.py`.
| # | mutation | file | Δ B | result |
|---|---|---|---|---|
| M0 | none (control) | freshness | +0 | **green** — the harness is not red on everything |
| M1 | `reading()` returns `self.last` unconditionally | freshness | −24 | red, 3 named checks |
| M2 | drop the command-travel term | freshness | −59 | red |
| M3 | drop the magnitude term | freshness | −54 | **SURVIVED**, then red — §2.2. This is the finding |
| M4 | latch never holds | freshness | +13 | red, 3 named checks |
| M5 | bound 90 s (slower than both consumers) | freshness | **+0** | red |
| M6 | bound 10 s (under the physical floor) | freshness | **+0** | red |
| M7 | `MOVE_W` 700 W | freshness | **+0** | red, on part 6's `config.yaml` pin |
| M8 | the wiring in `_read_unit` removed | main | −5 | red, 3 named checks |
| M9 | reset the unchanged clock on every arrival (value-stamped) | freshness | −43 | red |
| M10 | `standing_w()` drops `last_write` | inverter | −73 | red, and takes `test_inverter.py` with it |
| M11 | `standing_at_change` refreshed on every arrival | freshness | +121 | ⚠️ **DISCOUNTED — the mutant does not parse** |
| M11b | the same defect, written parseably | freshness | +105 | red |
| M12 | `BOUNDS["soc"].wired = True` — the table lies | freshness | −1 | red |
| M13 | the wrong signal submitted (`inv.soc` for `inv.batt`) | main | +4 | red, and `test_inverter.py` too |
| M14 | one `Freshness` shared by every unit | inverter | +29 | red |
| M15 | latch cleared by a failed read | freshness | +31 | **SURVIVED**, then red — see below |
⚠️ **M5, M6 and M7 are `+0 B`, which MUTATION.md names as the risky delta.** All
three are **reds**, which is the direction stale bytecode cannot fabricate, and
every run purged with the count printed and graded a `:ro` tree that physically
cannot hold a cache.
⚠️ **M11 IS NOT A RESULT AND IS NOT COUNTED.** Its edit dedents a statement into
the middle of an `if` body, so the module raises `IndentationError` at line 312
and both files go red without a single check being evaluated. Verified by
`ast.parse` rather than assumed. **M11b** is the same defect written so it
parses, and it is a real red.
### The two survivors, and what each one taught
**M3, the magnitude term** — see §2.2. Two terms of a conjunction that move
together in every fixture are one term as far as the checks are concerned.
Recorded as `MUTATION.md` **habit 17**, twelfth instance of "a check whose DATA
cannot reach the failure it names".
**M15, the latch cleared by a failed read** — found only because the sweep went
looking for what the checks would NOT see. No fixture had ever injected a read
failure *while latched*, and it matters: a stale verdict stops the site, the site
is then commanded 0 W, and one unreachable-Home-Assistant read would drop the
latch into a state where the conjunction is no longer armed — so the held
−1200 W would come back to the consumers on the very next successful read. Part 4
now grades exactly that and M15 is red.
### And one process defect worth more than the ledger
One mutation's container run overran a two-minute command budget and the wrapper
was killed **between the edit and the revert**, leaving `app/freshness.py`
mutated on disk. Nothing announced it; the next mutation's harness cheerfully
fingerprinted and graded the mutant *plus* a second mutation. It was caught by
comparing the tree fingerprint against the recorded clean one, restored, and
verified back to byte and `sha256` before anything else ran. Same defect as stale
bytecode, different cause. Recorded as `MUTATION.md` **habit 18**: *fingerprint
after the revert, not only before the run.*
### A third, in this file's own fixture
M2 originally reported "red" with **no named check behind it**. The mutant makes
the FRESH run stale, so `test_freshness.py`'s own diagnostic
`print(f"… {sorted(set(_fresh_read))}")` raised `TypeError` comparing `None` with
a float **before check one ran**. MUTATION.md's reporting rule is *"which suite
went red, and on which named check"*, and a print that can crash first destroys
exactly that. Sorted with `key=str` now; M2 reports its check.
### Re-verified at the committed head
The 16-row sweep above graded `test_freshness.py` as it stood when each mutation
ran; the two closures (M3's part-4 check, M15's failed-read check), the `key=str`
fix and the wall-clock hardening all landed afterwards. So six mutations were
re-run against **the exact tree that is committed** —
`test_freshness.py sha256:a62f3e1f1ac856cf`, 44,913 B:
| # | result at head |
|---|---|
| M0 control | **green** |
| M2 drop the command-travel term | red, on part 1's "FRESH AND UNCHANGED is not stale" |
| M3 drop the magnitude term | red, on part 4's magnitude check |
| M8 wiring removed | red, on three named checks in parts 2 and 3 |
| M11b `standing_at_change` drifts | red, on three named checks in part 1 |
| M15 latch cleared by a failed read | red, on part 4's failed-read check |
The other ten are unaffected by those edits: each is red on a check in parts 1,
2, 4, 6, 7 or 9 that none of them touches, and a mutation red on one check
cannot be made green by adding another.
### The wall clock, which part 3 cannot avoid and therefore measures
The freshness verdict runs on an injected monotonic clock and is deterministic.
The sign watchdog inside `cycle()` cannot be injected, so part 3 runs against the
real one — which is CI-06's failure mode exactly (`test_peakguard.py` answered
two checks with the wall clock, flaked at ~20 %, and cost a wrong merge
diagnosis). So the margin is measured and asserted rather than assumed:
gate closed after 0.61 s of WALL clock, against the 2 s watchdog window (3.3x margin)
If a loaded machine ever narrows that, the check fails **naming the number**,
which is the difference between "the box was slow" and "the code is broken".
@@ -1,925 +0,0 @@
# SAFETY-38 — pinning `site_standing`, the one quantity both site guards are asked about
**Status: DECIDED 2026-09-05.** `app/main.py`'s
`site_standing = float(sum(standing.values()))` is **correct and is not
changed**. What this card adds is the thing it never had: a **behavioural**
fixture whose per-unit shares **cannot** answer for the site, so that a change
of *meaning* in that reduction goes red instead of green.
Filed with SAFETY-38, card `6a9ac95f2b78646e5a76d2e9`. It is a **coverage**
card: not one byte of `app/` moves.
---
## 0. The line, and its two consumers
```python
site_standing = float(sum(standing.values())) # app/main.py:1598
```
`standing` is `{unit name -> the value THIS controller last put on that unit's
setpoint entity}` — `inv.last_write`, i.e. the number **after**
`clamp_to_rating()` has applied `max_w` and the device's own
`[dev_min, dev_max]`. Two safety inputs are derived from it and nothing else in
`cycle()` is:
| consumer | where | what it does with it |
|---|---|---|
| **[[SAFETY-30]]**'s site sign detector | `self.site_sign_wd.evaluate(site_standing, self.grid, …)` (main.py:1619) | **latches and stops the whole site** when the total command opposes the meter for `site_sign_disagree_s` |
| **[[SAFETY-18]]**'s freeze conjunction | `looks_frozen(unchanged_s, site_standing, max_w, …)` inside `peakguard.decide(frozen=…)` (main.py:1926) | takes the peak guard **blind** and files `floor_w = 0.0` — the battery may not import while the billed quarter is unmeasurable |
Both are P0 paths. **Neither was pinned to the reduction.**
## 1. What survived, and what the rescope measured
The card opened with three demonstrated live mutants, all green across the whole
suite. Re-measured in round 2 against `release/1.0` @ `86eaef2`:
| mutation of `site_standing` | on release/1.0 @ 86eaef2 | closed by |
|---|---|---|
| `list(standing.values())[0]` — one unit's share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
| `sum(...) / max(1, len(standing))` — the mean | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
| `max(standing.values(), key=abs)` — the largest share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
### ⚠️ ROUND 1 SAID `[0]` WAS ALREADY CLOSED BY SAFETY-32. THAT WAS FALSE.
Round 1 of this card recorded `[0]` as **CAUGHT by SAFETY-32's package-wide
structural pin**, wrote that claim into four places, and told future authors
*"DO NOT RE-COVER THAT ONE HERE"*. It is wrong, and **how** it went wrong is the
part worth keeping.
**How the error was made.** The mutation was measured **once**. That single run
happened to hit `test_peakguard.py`'s [[CI-06]] flake — a **measured ~20%**
false-red rate — and the red was recorded as a **mutation kill**. Nothing else
was checked, because a red is the answer you are hoping for and it did not look
like it needed a second run.
**The correct measurement.** On `release/1.0` @ `86eaef2` carrying **only** the
`[0]` edit (+4 bytes, re-read from disk, bytecode purged by
`tools/mutation_run.sh`): the **full 21-file suite is green**, and
`test_peakguard.py` run **five times in isolation** on that same mutated tree is
**PASS PASS PASS PASS PASS**. The mutation **survives**. (The reviewer's own
independent re-measurement of the same tree hit the flake once —
PASS PASS PASS FAIL PASS — which is the same verdict plus a live demonstration
of the mechanism that produced the original error.)
**Why SAFETY-32 cannot see it, read off 12j's own enumeration rather than
argued.** Part 12j taints names *bound* to roster-carrying values and lists
*accumulated* in a loop over one. `standing` is neither: it is a dict created
empty at `main.py:1554` and filled by **subscript assignment**
(`standing[inv.name] = stood`) inside the roster loop, and a subscript store is
not a binding 12j follows. So `standing` never enters its taint set and
`sum(standing.values())` is **not a roster site** at all. Run 12j against a tree
carrying the `[0]` mutation and its enumeration lists `main.py:1553`, `1555` and
`1690` — the three `self.inverters` sites bracketing this code — and **not
`1599`**. No shape pin reaches this line; only the behavioural fixture below
does.
**The rule this cost.** `test_peakguard.py` flakes at a measured ~20%. A single
red from it proves nothing. Re-run it in isolation **at least five times** and
**name the failing check** before believing any red from it. This card is the
**third** measurement CI-06 has corrupted.
SAFETY-32's own disclosed blind spot says why the other two were always out of
reach:
> *SEMANTICS. `min` → `max` → the mean are edits invisible to any shape pin,
> permanently. Only the behavioural sets can see them.*
`sum` → mean and `sum` → `max(key=abs)` are **semantic**: identical shape,
different meaning. **No shape pin will ever close them.** That disclosure was
accurate, and is now confirmed by measurement rather than taken on trust.
`[0]` is the one that *looks* structural and therefore *looks* covered — and
that appearance is exactly what produced the false attribution above. It is
out of reach here for a different reason from the other two: not because the
shape is invisible, but because **the value never becomes a roster** in the
analysis's eyes. All three are closed by the same behavioural fixture, and
**all three needed it.**
## 2. Why the suite could not see it — habit 4, inside the site half of the very card that names it
`test_watchdog.py`'s `_sign_site` is the **only** N≥2 site fixture in the
repository. It runs at `site_sign_disagree_w: 320` with shares of roughly
−2500 W **each**. Every one of them is individually far over the gate, so on
those numbers
```
sum = −5000 W over mean = −2500 W over max = −2500 W over
```
— **all three reductions agree**, and a check whose two candidate answers happen
to be equal cannot observe a change that swaps them. That is `MUTATION.md`'s
third failure mode, and this is its ninth instance.
At a roster of **one** it is not a fixture defect but arithmetic:
`sum == mean == max == the one share`, identically, so all three mutations are
genuinely **equivalent** on a shipped install. No N=1 fixture can ever separate
them; §3c asserts that rather than leaving it to the reader.
## 3. The fixture, and the numbers it is built on
Two **simulated** units rated 220 W and 280 W, both obeying exactly, inverted
gain, house importing 2000 W. Both stand at their own rails, so `standing` is
`{-220, -280}` in roster order and the meter reads +2500 W.
| reduction | value | vs the SHIPPED 300 W gate |
|---|---|---|
| `sum` | **−500 W** | **OVER** → trips |
| `sum / len` (the mean) | −250 W | under → silent |
| `max(…, key=abs)` | −280 W | under → silent |
| `list(…)[0]` | −220 W | under → silent |
**Four distinct magnitudes — 500, 250, 280, 220 — of which exactly one clears
the gate.** The asymmetry is load-bearing twice: equal shares would collapse
mean, max and first onto one number, and putting the **smaller** unit first is
what keeps `[0]` distinct from `max(key=abs)`. The gate is left at its shipped
default so the fixture is graded against the magnitude an install runs; only the
**hold** is shortened (325 s → 2 s), because the hold is a wall clock and the
campaign behind 325 s is measured on the rig, not here.
⚠️ **220 W and 280 W are SIMULATED ratings chosen for that arithmetic and for
nothing else.** They are not measurements. In particular they are **not**
inverter B, which does not physically exist; B is a GW5048D-ES, serial
`55048ESU22AW0992`, **4600 W from its datasheet** (`goodwe/NOTES.md` §1.1b).
### 3a. The site sign detector
Shipped tree: the site latches, commands 0 W on every inverter, and the sentence
an operator reads is
`SIGN_DISAGREEMENT: commanded -500 W against a meter reading +2500 W …`. No
per-unit watchdog trips — every unit is faithfully executing its share, which is
the asymmetry the site detector exists for.
Under the mean and under `max(key=abs)`: **nothing trips, ever.** The reviewer's
tree was still commanding −500 W of charge into a +2500 W import at t = 595 s
with `following=True`.
Two counterfactuals sit beside it so the trip is attributable:
* the **same** inverted-gain site whose shares sum to −250 W — under the gate —
does **not** trip, so the trip above is the sum crossing 300 W and not the
mere presence of a second inverter;
* a site whose **device range is narrower than `max_w`** asks for −500 W while
only −250 W stands, and does **not** trip — see §5.
### 3b. The second consumer, covered rather than scoped out
`site_standing` is also `looks_frozen()`'s `commanded_w`. The same
discrimination applies at a different threshold: the site rail is
`RAIL_FRAC * site_max_w` = 0.95 × 500 = **475 W**.
| reduction | value | vs the 475 W rail |
|---|---|---|
| `sum` | **500 W** | **RAILED** → `BLIND_FROZEN`, `floor_w = 0.0` |
| the mean | 250 W | under → silent |
| `max(…, key=abs)` | 280 W | under → silent |
| `list(…)[0]` | 220 W | under → silent |
The sign is flipped from §3a on purpose: the gain is the **shipped positive**
one and the frozen meter reads import, so the site *discharges* +500 W against a
+2500 W import — the two agree in sign, SAFETY-30 has nothing to say, and this
half grades SAFETY-18 alone. Reusing §3a's inverted gain would have the site
sign latch park everything at 0 W, and the freeze conjunction could then never
be true: a check measuring the *other* guard's remedy.
⚠️ **The open quarter is backfilled with the same stuck reading, and that is
not decoration.** Without it the block is mostly uncovered, `blindness()`
answers `BLIND_COVERAGE`, and the mutant reaches *the same action by a different
route* — a check on the action alone could not then see the difference it names.
Seeded, the measurement is honest:
* shipped: `blind = BLIND_FROZEN`, `floor_w = 0.0` — no charging while the
billed quarter is unobservable;
* both mutations: **not blind at all**, `floor_w = None` — the site may go on
importing through a quarter whose billed average cannot be measured. That is
the capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
The counterfactual comes from the **same run**: on the earlier cycles the
command had not yet reached the rail and the guard did not report SAFETY-18,
with the meter equally frozen throughout — so the verdict is produced by the
command magnitude `site_standing` carries and not by `unchanged_s` alone. A low
gain (0.05, the schema floor) is what buys those below-the-rail cycles.
### 3c. At N=1, nothing changes — asserted
A real one-inverter controller is driven through the same frozen-meter
scenario. Its `standing` has a single entry, so `sum`, the mean,
`max(key=abs)` and `[0]` are printed and asserted to be **one number**; the
site sign detector is still absent (part 7 counts the calls on the object); and
SAFETY-18 still reaches `BLIND_FROZEN` on that same value. The claim "the
shipped install is unaffected" now rests on the roster rather than on the
reader.
## 4. Mutation ledger
Every run: a **fresh full-repo copy**, `__pycache__` and `*.pyc` excluded,
mounted `:ro`, graded by `tools/mutation_run.sh` (which purges, prints the tree
fingerprint, and verifies no cache appears during the run). The mutated line was
**re-read from disk** after the edit and the byte delta printed. Shipped
`app/main.py` is 234 542 bytes at `86eaef2`.
| # | mutation of `site_standing` | Δ bytes | verdict |
|---|---|---|---|
| M1 | `sum(...) / max(1, len(standing))` — the mean | +24 | **RED** |
| M2 | `max(standing.values(), key=abs)` — largest share | +9 | **RED** |
| M3 | `list(standing.values())[0]` — one share | +4 | **RED** — and **survives on release/1.0**, see §1 |
| M4 | `min(standing.values(), key=abs)` — smallest share | +9 | **RED** |
| M5 | `sum(abs(v) for v in ...)` — magnitude, sign lost | +16 | **RED** |
| M6 | `-sum(standing.values())` — sign flipped | +1 | **RED** |
| M7 | `float(self.target)` — the arbiter's site target | **−11** | **was a SURVIVOR — now RED**, see §5 |
| M8 | `sum(standing.values()) / 2.0` | +6 | **RED** |
| M9 | `sum(standing.values()) * 2.0` | +6 | **RED** |
| M10 | `sorted(standing.values())[-1]` — largest signed | +7 | **RED** |
| **M11** | **argument SWAP** at the SITE call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a |
| **M12** | **argument SWAP** at the PER-UNIT call site: `evaluate(stood, against, …)` → `evaluate(against, stood, …)` (**main.py:1560**) | **0** | **was a SURVIVOR in round 2 — now RED**, see §4c |
**Every one of the ten `site_standing` entries is red in `test_watchdog.py`.** Re-measured in round 2
on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more
than the `f72c48d` the round-1 figures were taken on): all ten still red, at the
byte deltas in the table, each on a fresh copy with the edit re-read from disk.
Adding assertions cannot turn a red green, so those verdicts carry forward to
the round-3 tree unchanged.
### ⚠️ ROUND 2 SAID THREE OF THESE SURVIVED. **EIGHT DID.**
Round 2 wrote: *"Before part 9 existed, three of these left the whole suite
green — M1, M2 and M3 — and the remaining seven were caught by SAFETY-30's own
N≥2 checks."* Round 1's wording was vaguer and harmless; naming three made it
**specific and wrong**, and it is the same species of error the round was
written to fix — **a survivor recorded as a kill**. This file contradicted it
three times on its own pages: the ledger row calls M7 *"a SURVIVOR — now RED"*,
§5 says M7 survived the whole suite, and `CHANGELOG.md` says so too.
**Re-measured in round 3, one mutation at a time, on clean `release/1.0` @
`bbc903b`** — a fresh copy each, bytecode purged (`0` remaining, printed), the
line re-read from disk, byte delta **and** `app/main.py` fingerprint printed,
mounted `:ro`, and the **FULL 21-FILE SUITE** run each time rather than
`test_watchdog.py` alone. (`release/1.0` has since moved on to `9e42cec`, which
merged [[CI-06]]; that commit touches `test_peakguard.py` and `CHANGELOG.md`
only, neither of which grades `site_standing`, so the ten verdicts below stand
on the tree they were taken on and this branch is rebased onto the newer tip.)
| # | Δ bytes | fingerprint of `app/main.py` | suite before part 9 |
|---|---|---|---|
| M1 | +24 | `362374cfb6486378` | **21/21 PASS — SURVIVOR** |
| M2 | +9 | `86573e7494f1c933` | **21/21 PASS — SURVIVOR** |
| M3 | +4 | `59fa8815e6fda4cf` | **21/21 PASS — SURVIVOR** |
| M4 | +9 | `43d852be3037f861` | **21/21 PASS — SURVIVOR** |
| M5 | +16 | `58d26de82af29a1a` | **RED** — `test_watchdog.py`, 3 checks |
| M6 | +1 | `407ddb5f375cc217` | **RED** — `test_watchdog.py`, 3 checks |
| M7 | **−11** | `2a635a4eca0a790d` | **21/21 PASS — SURVIVOR** |
| M8 | +6 | `b21eed89ff4c9240` | **21/21 PASS — SURVIVOR** |
| M9 | +6 | `0973d3caf680db65` | **21/21 PASS — SURVIVOR** |
| M10 | +7 | `0e7b7a4fed7787d0` | **21/21 PASS — SURVIVOR** |
**So: EIGHT of the ten left the whole suite green, and TWO were caught** — not
three and seven, and not the four and six a reviewer proposed either. The
correct sentence is: *before part 9 existed, eight of these ten left the whole
21-file suite green, and the only two SAFETY-30's own N≥2 checks caught were M5
and M6.*
**⚠️ AND THE TWO THAT WERE CAUGHT ARE CAUGHT FOR A REASON THAT MAKES THE CARD'S
CASE RATHER THAN WEAKENING IT.** M5 (`sum(abs(v) …)`) and M6 (`-sum(…)`) are
the only two entries in the table that **reverse the SIGN** of `site_standing`.
Every other entry changes only its **magnitude** — and `_sign_site`, the suite's
only N≥2 site fixture before this card, runs shares of about **−2500 W each**
against a **320 W** gate. `sum`, the mean, `max(key=abs)`, `min(key=abs)`,
`sorted(...)[-1]`, `[0]`, `self.target`, half the sum and twice the sum all land
on **−2500, −2500, −2500, −2500, −2500, −2500, −2500, −1250 and −5000 W**: nine
different numbers, all on the **same side of the same threshold**. The three
checks that fired on M5 and M6 grade *"the site trips and no unit does"*, the
claim it trips through, and where the latch is written — none of them grades the
**quantity**, and the sign flip reaches them only because it stops the trip
happening at all.
That is the whole thesis of this card measured rather than argued: **the
pre-existing coverage could see a sign, and nothing else.** Part 9's fixture —
per-unit shares each **below** the gate whose **sum** clears it — is the only
arrangement in which the magnitude is load-bearing, and it is what takes the
other eight red.
⚠️ **ONE FALSE RED, AND IT IS [[CI-06]]'s.** In the batch run M10 also reported
`FAIL test_peakguard.py`. Re-run in isolation **three times** and once more
through `tools/mutation_run.sh` — **four clean passes, zero failures**. The
batch script had discarded the failure detail, so the failing check cannot be
named here; what can be said is that the red did not reproduce in four attempts
and that **no claim in this card rests on it**. `test_peakguard.py` is the file
CI-06 records as flaky, and this is the behaviour it records. Believe no red
from it without three isolated re-runs.
⚠️ **ROUND 3 CAN NAME ONE, AND IT IS THE SAME FILE.** Round 3's own batch — the
ten full-suite runs above, plus §7's sibling swaps — produced **no** red from
`test_peakguard.py` on M10 or on any other ledger entry. It produced exactly one
elsewhere, on a **`static_tariff.py`-only** edit (§7b, A4), and this time the
failing check *was* captured: *"one cycle of the intent form is exactly the
delivered form plus the command standing on the wire"* — which is CI-06's
**mechanism 1 by name**, the cross-time projection. `test_peakguard.py` then
passed **four for four** in isolation on that same tree. That is the first time
this card has been able to put a name to one of these reds, and it is the name
CI-06 predicts.
⚠️ **M3 IS NOT "FOR COMPLETENESS", AND ROUND 1 SAID IT WAS.** Round 1 recorded
M3 as already closed structurally by SAFETY-32 and disclaimed it. §1 above has
the correction and the mechanism: `[0]` **survives** a full green 21-file suite
on `release/1.0` @ `86eaef2`, because `standing` is a dict filled by subscript
assignment and never enters 12j's taint set. **M3 is a real kill by this card's
fixture and by nothing else in the repository.** It is claimed here.
### 4a. ⚠️ The zero-byte survivor one line below the one this card pinned
Round 1 pinned the **value** `site_standing` and never pinned **where it goes**.
Swapping the detector's first two arguments at `main.py:1620` is a **zero-byte**
edit — the same characters, reordered — and it left the round-1 tree **21/21
green**.
What it costs is the banner. `SignWatchdog.evaluate()` formats
`commanded_w` first and `against_w` second, so under the swap part 9a's own
output reads:
```
SIGN_DISAGREEMENT: commanded +2500 W against a meter reading -500 W for 2 s …
```
The site is standing at **−500 W of charge into a +2500 W import** and the
**latching, site-stopping** banner tells the operator **the exact opposite** —
which instrument read what, inverted. `watchdog.py:658-665` names this failure
in its own words: *"a false statement about which instrument produced the
evidence, on the one line somebody woken by this alarm reads first."*
**Why round 1's check could not see it, and this is the general lesson.** It
asserted
```python
f"{sum(_share38):.0f} W" in _c38.site_sign_wd.reason
```
— that `"-500 W"` appears **somewhere** in the sentence. Under the swap it still
does: in the **meter slot**. A substring test cannot reach a failure that is
purely **positional**, and round 1's own comment claimed the opposite ("*would
put a different number in the store, the action log and the banner, and this is
where that shows*"). It shows the **number**, never its **position**. This is
MUTATION.md's *"a check whose data cannot reach the failure it names"*, in the
card that exists to complain about exactly that.
**The fix is one line and touches no `app/` code** — assert the **ordered
pair**:
```python
f"commanded {sum(_share38):+.0f} W against a meter reading "
f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason
```
Measured: with that line, M11 is **red in `test_watchdog.py` and green in the
other twenty files** — one file, one check.
### 4b. Claim wiring, re-measured rather than assumed
Round 1's reviewer disclosed it had **not** mutated the arbiter claim wiring or
[[SAFETY-31]]/circulation. Both were mutated in round 2. Both are **zero-byte**
edits to `arbiter.CLAIM_SCOPE`:
| # | mutation | Δ bytes | verdict |
|---|---|---|---|
| W1 | `"sign_site": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py` (parts 5 **and 9a**), `test_arbiter.py`, `test_peakguard.py` |
| W2 | `"circulation": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py`, `test_arbiter.py`, `test_peakguard.py` |
Part 9a's *"…and it stops the WHOLE site, through the `sign_site` claim by name"*
is one of the two checks that kills W1, so this card contributes to that
coverage rather than merely inheriting it. **W2 is caught entirely by
pre-existing checks** — SAFETY-38 adds nothing to circulation, and does not
claim to.
### 4c. ⚠️ The SAME zero-byte defect one call site over — and it is the one the shipped install can see
Round 2 closed §4a by asserting the ordered pair on the **site** banner at
`main.py:1620`. It did not touch `main.py:1560`, the **per-unit** call, and the
identical swap there —
```python
if inv.sign_wd.evaluate(stood, against, self.following, now) # shipped
if inv.sign_wd.evaluate(against, stood, self.following, now) # M12
```
— left the round-2 tree **21/21 green**. Measured: `app/main.py` 234 542 bytes
before and after, **Δ 0 bytes**, fingerprint `61aa413f…` → `e57e6026…`, bytecode
purged, tree mounted `:ro`, the line re-read from disk.
**Nothing behavioural can catch it, and that is structural rather than
unlucky.** `watchdog.disagrees()` is *symmetric* in its first two arguments —
`commanded_w * against_w < 0`, with an `abs()` gate on **both** — so the swap
cannot change a single trip decision on any input. What it changes is the
**sentence**. Measured by diffing the suite's own printed banners between the
round-3 tree and the same tree carrying only M12: **9 emitted banner lines
invert, in 3 distinct sentences.**
```
shipped: SIGN_DISAGREEMENT: commanded -2000 W against a meter reading +2000 W …
M12: SIGN_DISAGREEMENT: commanded +2000 W against a meter reading -2000 W …
shipped: … commanded +1000 W against its own measured power -1000 W …
M12: … commanded -1000 W against its own measured power +1000 W …
shipped: … commanded -2000 W against a meter reading +4000 W … (the N=1 arm)
M12: … commanded +4000 W against a meter reading -2000 W …
```
**⚠️ AND IT IS WORSE THAN THE ONE ROUND 2 CLOSED.** Part 9c asserts the SITE
detector is **not armed at a roster of one**, and inverter B does not physically
exist. So on the shipped install the **per-unit banner is the only
sign-disagreement sentence that can ever be printed** — in the action log, in
`_LOG.error`, in `status_payload()`'s headline banner, and in `sign_reason`,
which is persisted and read back after the restart the latch is designed to
survive. Round 2 pinned the N≥2-only call site and left the shipping one open.
**Closed by the ORDERED PAIR on both per-unit banners**, with both operands read
off the **observed cycle** — `inv.last_write` and `inv.batt` off the objects the
plant moved, never off the arguments the detector was handed, which would be
comparing the swap against itself:
* `test_watchdog.py` **part 3**, the N≥2 per-unit trip:
`f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W"`,
plus the persisted `unit/inverter-b/sign_reason` copy, plus a **precondition**
that the two operands are different numbers of opposite sign — without which
a transposition would produce the same string and the check would be
decoration;
* `test_watchdog.py` **part 7**, the **N=1** controller-driven trip:
`f"commanded {stood:+.0f} W against a meter reading {grid:+.0f} W"`, plus the
namespaced key **and** the flat rollback mirror, plus the same precondition.
**Measured after the fix: M12 is RED on four named checks in
`test_watchdog.py` and GREEN in the other twenty files** — one file, four
checks. M11 re-measured on the same tree: still red.
**⚠️ Neither §6a nor MUTATION.md disclosed it.** §6a's "what was NOT closed"
enumerated `circ_wd`, the eight other `CLAIM_SCOPE` entries, `actuator_of()`'s
body and the `n_units > 1` guard — not this. MUTATION.md's own closing rule, in
the file round 2 edited, is *"a variant is not covered because a near-miss of it
is."* §7 is the enumeration that would have caught both instances at once.
## 5. The survivor this card went looking for, and closed
M7 is not one of the card's three. It was found by asking the question the
recurring defect demands — *what mutation would my new checks NOT see?* — and it
is the answer: **−11 bytes**, and it survived every check above **and the whole
20-file suite**, because in every fixture built so far the arbiter's site target
and the sum of what actually stands on the wire are **the same number**.
They are not the same number when a setpoint device's own reported range is
narrower than its `max_w`. The `device` P_SAFETY claim is filed **per actuator**
(`actuator_of("device", inv)`), so it bounds each unit's **share** and never the
site target, and `clamp_to_rating()` clips again on the way out. On two units
rated 220/280 W whose devices report ±120/±130 W, the site asks for **−500 W**
and **−250 W** stands.
Which one the guards mean is already written down, in both of them: SAFETY-02
grades *"the command standing on the wire"*, and `looks_frozen()`'s docstring
says `commanded_w` is *"the EMITTED setpoint — what the arbiter resolved and
`write_setpoint()` put on the wire — not the loop's raw wish"*. [[SAFETY-20]] is
the whole card about the difference. Those sentences are now made to bite: on
the site target the detector fires on a site standing at −250 W against a
+2250 W meter — **under its own 300 W gate** — which is a latching, site-wide
false trip on an install whose inverters simply report a narrower range than the
operator typed.
## 6. Honest gaps
* **A mutation that changes the SIGN CONVENTION of `standing` itself** — i.e. in
`main.py`'s `stood = inv.last_write if inv.last_write is not None else
inv.target`, not on the reduction line — is **not** in this ledger. It is a
different line and a different card; §3a's obedience precondition would see
some of it, but that is not a measured claim and is not made here.
* **The freeze half is graded at ONE roster length (2) and one rail.** The
`RAIL_FRAC` constant, `rail_ceiling_w()`'s device-bound conjunction and
`rail_unreachable()` are `test_freeze.py`'s subject and are untouched here.
* **The site hold is 2 s, not the shipped 325 s.** The window's derivation is a
rig campaign (`DECISIONS/SAFETY-30-per-unit-sign.md` §3B) and this file does
not re-grade it. The **gate** is the shipped 300 W, which is the coordinate
this card is about.
* **Everything about inverter B here is simulated and labelled so.** No number
in this document or in the fixture is a measurement of a device that does not
yet exist.
### 6a. Round 2: what was closed, and what was NOT
Round 1's reviewer disclosed that it had not mutated SAFETY-31/circulation, the
arbiter claim wiring, or ledger entries M4/M5/M6/M8/M9/M10. Enumerated, with the
gaps left open stated rather than rounded off:
**Closed in round 2 (measured, on `86eaef2`, bytecode purged, edit re-read from
disk, byte delta printed):**
1. **M11**, the zero-byte argument swap — §4a. Red on one named check.
2. **M3**, the `[0]` reduction — re-measured, **survives on release/1.0**, closed
by part 9a and 9b. The false attribution corrected in all four places it was
committed: this file §1 and §4, `CHANGELOG.md`, and `test_watchdog.py`'s part
9 header (which previously *instructed future authors not to re-cover it*).
3. **M1, M2, M4, M5, M6, M7, M8, M9, M10** — every ledger entry re-run
independently in round 2 rather than carried forward from round 1's report.
All ten red; M1/M2/M4 re-run in the exact wrapped form the table records so
the byte deltas are the table's own (+24/+9/+9).
4. **W1**, `sign_site` claim scope — §4b. Zero bytes, red, part 9a among the
killers.
5. **W2**, `circulation` claim scope — §4b. Zero bytes, red, but **entirely on
pre-existing checks**.
**NOT closed, and not claimed:**
* **SAFETY-31's `circ_wd` detector itself** — its threshold, its hold, its
`circulating_w()` reduction over the roster. Only its **claim scope** was
mutated (W2), and that kill belongs to checks this card did not write.
Circulation's own behavioural coverage is `test_watchdog.py` part 4's and is
untouched here.
* **The other eight `CLAIM_SCOPE` entries** — `loop`, `peak`, `arbitrage`,
`safety` (site) and `device`, `supervised`, `failsafe`, `maintenance` (unit).
`CLAIM_SCOPE` has ten entries; only the two named above were mutated. `test_peakguard.py` part 10f grades the
classification statically; that is a different instrument and this card did
not re-measure it.
* **`actuator_of()`'s own body** and the `n_units > 1` guard on the `sign_site`
append at `main.py:1764` were **not** mutated.
* **The ten ledger mutations were re-graded against `test_watchdog.py` only.**
The "green in the other twenty files" half of the round-1 claim was
re-verified on `86eaef2` for **M3** and **M11** (both full 21-file runs) and is
otherwise carried forward from round 1's measurement on `f72c48d`. It is a
weaker statement than round 1 made and is written down as such.
⚠️ **Round 3 closed the other half of this**, but only in the *before*
direction: all ten were re-run on clean `release/1.0` @ `bbc903b` under the
**full 21-file suite** to establish which of them the pre-existing coverage
could see — see §4's ⚠️ paragraph, and the answer was **eight survivors, not
three**. The *after* direction — "each of the ten is red in
`test_watchdog.py` and green in the other twenty **on the round-3 tree**" —
is still measured end-to-end only for **M3, M11 and M12**. For the other
seven, "red" carries forward from round 2 (which adding assertions cannot
undo) and "green elsewhere" carries forward from round 1.
* **`test_peakguard.py`'s CI-06 flake is not fixed by this card** and is not
this card's subject. It is *characterised* here (five isolated runs on the M3
tree, all PASS) only far enough to overturn the false kill it produced.
### 6b. Round 3: what was closed, and what is still open
**Closed in round 3:**
1. **M12**, the zero-byte argument swap at the **per-unit** call site
`main.py:1560` — §4c. It was a survivor of the round-2 tree at **21/21
green**, and it is the **only** sign-disagreement banner a shipped
one-inverter install can print. Red on four named checks, green in the other
twenty files.
2. **The ledger arithmetic in §4**, which round 2 made specific and wrong. See
§4's own ⚠️ paragraph: re-measured mutation by mutation on clean
`release/1.0` @ `bbc903b`, full 21-file suite each time.
3. **The family itself is now enumerated by machine** —
`tools/symmetric_message_scan.py`, §7 — rather than by hand for a third time.
**Open, named, and NOT claimed:**
* **A3 and B1** — the only two status-page rows in which the freeze detector
ever names a device range: the **NOT COVERED** row and the **armed** row's
"below max_w because …" clause. `rail_unreachable()`'s `dev_min`/`dev_max` at `main.py:3208` and the
literal `f"only {inv.dev_min}...{inv.dev_max} W"` at `main.py:3231`. Both
measured: **0 bytes each, 21/21 green each.** Real, open members of the
family. They are **status-page rows rather than latching alarms**, and closing
them wants a fixture with a device whose two bounds are **asymmetric** — every
fixture in this repository uses `±d`, so the transposition is currently
invisible by construction — which belongs to `test_freeze.py`'s card, not to
this one.
* **Twelve census-B sites** (§7c, B2–B12) determined unpinned at the **source
level** — the distinguishing literal appears in no `test_*.py` — and **not**
mutated. Weaker evidence than A1–A4 and B1, and written down as such
— §7d says the same thing with the same list.
* **B6**, SAFETY-31's `circ_reason`, is the same shape as the defect this card
closed for SAFETY-02/30 and is *also* persisted and re-read after a restart.
§6a already declines to touch `circ_wd`; §7d says what that costs.
* Everything §6a lists stays open: `circ_wd`'s own thresholds, the eight other
`CLAIM_SCOPE` entries, `actuator_of()`'s body, and the `n_units > 1` guard.
### 6c. Round 4: what was closed, and what is still open
**The blocker round 3 shipped.** The enumeration read as complete and covered
**one syntactic sink**. Every one of census B's 16 members was a sentence that
is **appended, assigned, or passed as a log argument** (`problems.append`,
`out.append({"text": …})`, `self.reason = …`, `_LOG.info`). **Not one was a
sentence that is RETURNED.** Census A has a return bucket, but only for
symmetric **parameters**, so a method formatting its **own attributes** and
handing the string back was invisible to both — and `describe()` / `explain()` /
the reason-builders are exactly where this codebase keeps its operator
sentences. Worse than the gap: it was in **none of the five blind spots the tool
prints about itself**. A tool that asserts a completeness it does not have is
the recurring defect wearing a new hat.
**Closed in round 4:**
1. **`clamp.py:253` `ClampResult.describe()`**, pair `requested_w` /
`applied_w` — §7e C6. A survivor of the round-3 tree at **21/21 green**,
**0 bytes**. It is the §15 action-log row at `main.py:1366` **and** the
ClampWatchdog notification reason at `main.py:2452`; **no `test_*.py`
mentioned `describe()` at all.** Pinned on the ordered pair, on the
contiguous literal, at **both** consumers, with a precondition that the two
operands render differently — `test_clamp.py` part 6d. **4 checks red** under
the transposition.
2. **`peakguard.py:493` `PeakDecision.explain()`**, pair `projected_w` /
`ceiling_w` — §7e C7. Same measurement: **0 bytes, 21/21 green**; the string
`"peak guard idle"` was in no `test_*.py`. Pinned on the decision and on the
shipped status row at `main.py:3313` — `test_peakguard.py` part 13. **3
checks red.**
3. **`arbiter.py:264` (B12)** — the check whose **claim was wider than its
assertion**, carried forward from §7d. Measured this round rather than left
at source level: **0 bytes, 21/21 green** before, **`test_arbiter.py` red**
after. **2 checks red.**
4. **The tool's sinks are now stated rather than implied.** It prints what it
scans as a MESSAGE, that it now scans the RETURN sink through the returning
method's call sites, and — the part that was missing — **an explicit list of
what it does not scan at all**, largest hole first.
**⚠️ The correction round 4 owes the ticket.** The brief placed
`PeakDecision.explain()`'s idle sentence at `main.py:3299/3302`. Those two lines
are the **floor-limited** branch, where `explain()` returns `self.reason` before
the idle literal is ever built. The idle sentence reaches the page at
**`main.py:3313`** and the action log at **`main.py:1951`** (edge-logged, so it
is the RELEASE edge that writes it). The pin is on 3313; 1951 is disclosed
below, unpinned.
**Open, named, and measured — see §7f for the ledger:**
* **TEN further zero-byte survivors** found by the widened scan, each measured
at 21/21 green on this tree: **two** in `allocator.py`'s `UnitPlan.explain()`
(C1 :154, C2 :160), three in `arbiter.py`'s two `explain()`s (C4a, C4b :344,
C5 :392), two in `tariff.py` (C8, C9), two in `arbitrage.py`'s trade reasons
(B17, B18) and one in `main.py`'s SoC-floor notification (B19).
None is a control decision; every one is a sentence an operator reads.
⚠️ **CORRECTED after review, and the error is worth keeping visible.** This
bullet first said "Nine" while listing eleven, and the CHANGELOG said eleven
while listing eleven — and BOTH counted `allocator.py:162` (C3) among the
survivors when §7f records it as a KILL, red in `test_alloc.py`. The
authoritative tables in §7e and §7f were right throughout; only the summary
sentences a reader actually relies on were wrong. That is this card's own
recurring defect in prose rather than in code: a claim wider than the
measurement beneath it. Arithmetic, from §7f: 14 measured = 10 surviving +
1 already caught (C3) + 3 caught by this round (C6, C7, B12).
* **The action-log surface of the peak-guard idle line** (`main.py:1951`) is
not pinned. The status row (3313) is, and both come off the same `explain()`,
so the *sentence* is closed and the *second call site* is not — which is
precisely the distinction §4c was written about, recorded rather than assumed.
* Everything §6a and §6b list stays open.
## 7. The symmetric-predicate family — every call whose argument order is load-bearing for a MESSAGE
**Why this section exists, and it is the most useful thing in the card.** Round 2
pinned the operand order of `SignWatchdog.evaluate()` at `main.py:1620`. Round 3
found the **identical zero-byte defect at `main.py:1560`**, one call site over —
and that one is worse, because `test_watchdog.py` part 9c asserts the site
detector is **never armed at
a roster of one**, so on the shipped install the per-unit banner is the **only**
sign-disagreement sentence that can ever be printed. Twice in one card the
enumeration was written by hand and twice it was short. MUTATION.md habit 12: *a
hand-written scope is the same defect as a hand-written fixture.* So the family
is now **computed**, by `tools/symmetric_message_scan.py`.
### 7a. What makes this family invisible, and why it is not a fixture problem
Every other defect in this document is a fixture problem: *no fixture happens to
separate the mutant from the shipped code.* Build the right fixture and it goes
red. This one is not that.
Where a callee is **symmetric** in two arguments, a swap at a call site cannot
change **any** action on **any** input. `watchdog.disagrees()` is
`commanded_w * against_w < 0` with an `abs()` gate on **both** sides: the
product commutes and the two gates are the same gate. There is nothing for a
behavioural check to observe, ever — **not "no fixture separates them" but no
fixture *can*.** The only surface that moves is the **sentence**, and the only
check that can see it is one asserting the **ordered pair** (MUTATION.md habit
14) at **that** call site.
Two consequences the earlier rounds got wrong:
* **The unit of coverage is the CALL SITE, not the function.** `evaluate()`'s
operand order was pinned and the defect still shipped, because the pin was on
the *other* call.
* **A behavioural suite is not weak evidence here, it is NO evidence.** "21/21
green" is what this mutation is *guaranteed* to produce. Measured, twice: M11
at `main.py:1620` on the round-1 tree, and M12 at `main.py:1560` on the
round-2 tree.
### 7b. Census A — the callee formats its own parameters
`tools/symmetric_message_scan.py` renames each parameter pair, erases every
formatted string, canonicalises the commutative operators (`*`, `+`, `and`,
`or`, `==`, `min`/`max`, iterated tuple literals) and compares the **bodies**;
it iterates to a **fixpoint**, because the symmetry is usually one call deep —
`evaluate()` is symmetric only *through* `disagrees()`, and a single pass
reports an empty family on a tree whose answer is already known. It then
classifies each sentence by **where it goes**.
Fixpoint on this tree: **3 rounds, 17 interchangeable positional and 5 keyword
pairs across 14 functions**. 45 parameter pairs reach a sentence; **8** are
symmetric — **5** whose sentence goes only to an operator, and **3** whose
sentence is the **return value** and therefore has to be classified by hand,
because a returned string is sometimes a KEY and sometimes a SENTENCE.
| # | scan row | pair | called at | swap costs | reaches an operator via | pinned? |
|---|---|---|---|---|---|---|
| **A1** | `watchdog.py:613` `SignWatchdog.evaluate()` | `(commanded_w, against_w)` | **`main.py:1560`** — per-unit, **and the N=1 detector** | **0 B** | `sign_reason` (persisted), the action log, `_LOG.error`, `status_payload()`'s headline banner | **YES — round 3.** `test_watchdog.py` part 3 (`its own measured power`) and part 7 (`a meter reading`), on the contiguous literal with both operands read off the observed cycle. **Measured: 0 B, red on 4 named checks, green in the other 20 files.** |
| **A2** | same row, second call site | `(commanded_w, against_w)` | **`main.py:1620`** — site | **0 B** | `site_sign_reason`, the action log, the status page | **YES — round 2**, part 9a. Re-measured on the round-3 tree: still red. |
| **A3** | `freeze.py:405` `FreezeWatchdog.evaluate()` and `freeze.py:311` `rail_unreachable()` — both symmetric through `rail_ceiling_w()`'s `for bound in (dev_min, dev_max)` | `(dev_min, dev_max)` | **`main.py:3208`** (and 1928, 2755, 3196, 3219) | **0 B** | the status page's `freeze detector: NOT COVERED — the setpoint device reports a range of …` row | **NO.** Measured: swapped at `main.py:3208-3209`, **0 bytes**, `main.py` fingerprint `61aa413f…` → `2305b560…`, **21/21 green**. Disclosed in §7d, not closed. ⚠️ `rail_unreachable()` is the row the scan puts in its **RETURN** bucket: its returned string is a **sentence**, not a key, so it is a family member after all — which is exactly why that bucket says *read by hand*. |
| **A4** | `static_tariff.py:522` `_raise_gap()` | `(start, end)` | `static_tariff.py:509`, `:513` | **0 B** | the `ProfileInvalid` refusal an operator reads at startup | **YES — pre-existing.** Measured: both call sites swapped, **0 bytes**, `static_tariff.py` fingerprint `caef7fde…` → `50e9a768…`, **red in `test_static_tariff.py` on two named checks** (*"…and names the uncovered range so the operator can fix it"*, *"…and the refusal names the uncovered range"*), because `_hhmm(start)}-{_hhmm(end)` is asserted as the contiguous literal `"22:00-23:00"`. ⚠️ That batch run also reported `FAIL test_peakguard.py` on a `static_tariff.py`-only edit; the failing check is *"one cycle of the intent form is exactly the delivered form plus the command standing on the wire"*, which is [[CI-06]]'s **mechanism 1 by name**, and `test_peakguard.py` passes **four for four** in isolation on that same tree. No claim here rests on it. |
| A5–A6 | `static_tariff.py:522` `_raise_gap()` | `(start, weekday)`, `(end, weekday)` | — | — | — | **not real — the scan's one over-report.** It erases the sentence, so it cannot see that `DAY_NAMES[start]` with `start = 1320` raises `IndexError`. A mixed-type pair fails loudly, which is the opposite of this family. |
| A7–A8 | `store.py:177` `unit_key()`, `mqtt.py:222` `unit_state_key()` | `(unit, key)`, `(slug, key)` | many | 0 B | — | **not this family.** The formatted string is the **return value and it is a KEY**: a swap renames a store key and an MQTT topic, which is behaviour every ordinary check can see. MAINT-05 is the card about it. |
### 7c. Census B — the caller builds the sentence
Census A only sees a callee formatting its **own** parameters, which was gap 2
of its own honest-gap list. The other half never crosses a function boundary:
an operator sentence built where it is printed, interpolating two values that
are **interchangeable by shape**. No predicate is involved at all, so census A's
symmetry test has nothing to test. The scan pairs interpolated expressions that
are **one leaf apart** — `inv.dev_min` / `inv.dev_max`, `cfg.min_w` /
`cfg.hold_s` — which is exactly what a transposition swaps, and it keeps only
sentences whose sink is a **message**: an f-string that becomes a URL or a store
key is data, and a swap there is an ordinary behavioural change. **16 candidates** in round 3, every one of them a **zero-byte** edit — and **22** after round 4 widened the sink classifier (B17–B22 below):
| # | site | pair | reaches an operator as | pinned? |
|---|---|---|---|---|
| **B1** | `main.py:3231` | `inv.dev_min` / `inv.dev_max` | status page, `… accepts only −120…130 W` | **NO — MEASURED.** Transposed in place, **0 bytes**, `main.py` fingerprint `61aa413f…` → `ac36a97b…`, purged, `:ro`, **21/21 green**. The row then tells the operator the device accepts `130…−120 W`. |
| **B2** | `main.py:3362` | `inv.dev_min` / `inv.dev_max` | status page, `setpoint … (range −120…130 W)` | **NO** — the literal `setpoint … (range` is in no `test_*.py`. |
| **B3** | `main.py:3435` | `sign_wd.cfg.hold_s` / `.cfg.min_w` | status page, `sign watchdog: armed (trips after 180 s opposed, both over 300 W)` | **NO** — `s opposed` is in no `test_*.py`. |
| **B4** | `main.py:3460` | `site_sign_wd.cfg.hold_s` / `.min_w` | status page, the site sign row | **NO** — `armed against the sum` is in no `test_*.py`. |
| **B5** | `main.py:3491` | `circ_wd.cfg.hold_s` / `.min_w` | status page, the circulation row | **NO** — `circulation watchdog: armed` is in no `test_*.py`. |
| **B6** | `circulation.py:317` | `cfg.min_w` / `cfg.hold_s` | **`circ_reason` — persisted, and re-read after a restart** | **NO** — `test_watchdog.py` asserts only `"CIRCULATING_POWER" in reason`. **This is SAFETY-31's twin of the exact defect this card just closed for SAFETY-02/30**, on a string with the same persistence. |
| **B7** | `main.py:2454` | `self.site_max_w` / `self.target` | the clamp action-log line | **NO** — `was clamped to` is in no `test_*.py`. |
| **B8** | `main.py:3386` | `clamp_wd.engagements` / `.episodes` | status page, `engaged N time(s) in M episode(s)` | **NO** — `time(s) in` is in no `test_*.py`. |
| **B9–B11** | `main.py:3169` (three pairs) | `p1.net_w` / `.samples` / `.parse_errors` | status page, the P1 row | **NO** — `P1 meter (` is in no `test_*.py`. |
| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **ROUND 4: MEASURED, THEN CLOSED.** Round 3 left it at source level; the transposition is **0 bytes**, `arbiter.py` `fdb4ed0520eeb721` → `9111f627ef639ecc`, and it was **21/21 green**. Now red in `test_arbiter.py` on 2 checks. Round 3's reasoning was right: `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. |
| **B13** | `validate.py:723` | `meter_phases` / `meter_max_age_s` | the startup refusal | **NO** for the ordered pair. |
| **B14** | `validate.py:1211` | `arbitrage_soc_floor` / `arbitrage_soc_ceiling` | the startup refusal | **NO** for the ordered pair. |
| **B15** | `watchdog.py:667` | `cfg.min_w` / `cfg.hold_s` | the `SIGN_DISAGREEMENT` sentence's `(limits: both over 300 W, held over 180 s)` clause | **YES**, and it is worth naming because it shows the habit works. `test_watchdog.py`'s SAFETY-30 section asserts the site instance's **whole sentence with `==` against a literal**, the `(limits: …)` clause included. Full-string equality is habit 14 taken to its limit. |
| B16 | `main.py:237` | `i.name` / `i.max_w` | the roster log line | **not real** — a `str` beside a `:.0f` format. The same over-report as A5–A6: a mixed-type transposition raises rather than lying. |
| **B17** | `arbitrage.py:377` | `v.spread_eur_kwh` / `v.required_spread_eur_kwh` | the DISCHARGE decision's `reason` | **ROUND 4, NEW, and MEASURED: 0 bytes, 21/21 green.** Not visible to round 3 at all — the sentence is handed to a constructor as `reason=`, and the sink walker scored only *assignments*. The row then reads *"0.0400 above the cheapest and clear of the 0.1200 this cycle had to cover"* with the two swapped: a trade justified by a spread it did not clear. |
| **B18** | `arbitrage.py:393` | same pair, CHARGE branch | the CHARGE decision's `reason` | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** |
| **B19** | `main.py:2652` | `g.reason` / `g.soc_floor_reason` | the `goodwe_peak_soc_floor` notification body | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** The alarm then opens with the *reason the floor bound* and closes with the *guard's own conclusion*, i.e. both halves of a two-part explanation in the wrong order, in the one message SAFETY-24/25 exist to send. |
| B20–B22 | `main.py:2558`, `main.py:2652` (two more pairs) | `g.reason` / `g.ceiling_w`, `g.ceiling_w` / `g.soc_floor_reason` | the same two notifications | **new in round 4, NOT measured** — mixed types (`str` beside `:.0f`), the same over-report class as B16. |
**⚠️ Six of these twenty-two were invisible to round 3, and the reason is one
line of the sink classifier.** It scored a sentence a MESSAGE when it was
appended, logged, raised, or assigned to a `reason`-shaped **name**. This
codebase mostly builds operator sentences as `Decision(…, reason=f"…")` and
`out.append({"text": f"…"})` — a **keyword argument** and a **dict key**, which
that walker stepped straight past. Both are message sinks now.
### 7d. What this enumeration does NOT close, with sizes
* **A3 is a measured, open survivor.** `dev_min`/`dev_max` transposed in
`rail_unreachable()`'s NOT COVERED row: **0 bytes, 21/21 green.** It is
disclosed here rather than fixed, because closing it wants a `test_freeze.py`
or status-page fixture with a device whose two bounds are **asymmetric** —
every fixture in the repository uses `±d` — and that is a different file's
card. It is a **status-page row**, not a latching alarm.
* **B1 is a second measured, open survivor.** `inv.dev_min` and `inv.dev_max`
transposed in `main.py:3231`'s status row: **0 bytes**, fingerprint
`61aa413f…` → `ac36a97b…`, **21/21 green**. The page then tells the operator
the device accepts `130…−120 W`. Same disposition as A3: **A3 is the row the
freeze detector prints when the rail is UNREACHABLE, B1 the clause the ARMED
row prints when the device is what set the rail** — the two rows in which that
detector ever names a device range, and neither one's operand order is
asserted anywhere.
* **Thirteen further census-B sites are unpinned and UNMEASURED.** B2–B14 above
are determined at the **source level** — the distinguishing literal appears in
no `test_*.py`, or the check that exists asserts a substring wider than its
own claim — and were **not** mutated. That is a weaker statement than A1–A4
and B1, and is written down as one.
* **B12 is a check whose CLAIM is wider than its ASSERTION**, which is habit
14's own shape one module over. `test_arbiter.py:452` says
`scope_violations` *"names the source and the actuator it does not belong
on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Swap `c.source` and
`c.actuator` in `arbiter.py:264` and the sentence becomes
*"inverter-b: a SITE claim filed on unit 'peak'"* — and the substring is still
found. Source-level, not mutated, and not fixed here.
* **B6 is the closest thing to a repeat.** SAFETY-31's `circ_reason` is
persisted and re-read after a restart, exactly like `sign_reason`, and its
two limits are graded by a bare `"CIRCULATING_POWER" in reason`. This card
does not fix it; §6a already declines to touch `circ_wd`, and this is the
precise shape of what that declining costs.
* **The instrument's own blind spots** are printed by the instrument, every
run: semantic symmetry it cannot spell (it under-reports), census B pairs
that are the same *kind* but not the same *shape*, `%s`-style logging and
non-f-string payloads, anything outside `app/`, and the one way it
**over-reports** — it erases the sentence, so a swap that would make the
formatting itself raise is scored as changing no decision (A5–A6 above).
### 7e. Census C — the sentence an object returns about itself (round 4)
**Why there is a third census.** A and B between them covered **one syntactic
sink**. Everything either of them reported was a sentence that is appended,
assigned, or logged; **none was returned**. Census A's RETURN bucket only ever
held a callee formatting its own **parameters**, so a method formatting its own
**attributes** and handing the string back fell between them — and that is the
whole `describe()` / `explain()` / reason-builder family, which is where the
operator sentences in this codebase actually live.
**The test** is census B's — one f-string, two interpolations **one leaf apart**
— run over the RETURN sink, plus one thing B never needed: **a returned string
is not yet a message.** `store.unit_key()` and `mqtt.unit_state_key()` return
f-strings too, and swapping there renames a store key and an MQTT topic, which
every ordinary check can see (MAINT-05). So the sink of a returned sentence is
**resolved through its call sites**: it is in this family only when some caller
in `app/` hands it to an operator. That resolution matches on the **bare method
name**, which over-collects — at least four unrelated classes have an
`explain()` — so every verdict is printed **with the sites that produced it**,
and a MESSAGE verdict is a prompt to read them, not a proof.
The scan reports **53 returned pairs, 39 reaching an operator, 16 of those
sharing a format spec** (two values rendered alike, so a transposition is silent
rather than visibly broken). It **rediscovered both members this round was sent
to fix without being told where to look**, which is the only reason to trust the
rest of the list.
| # | site | pair | reaches an operator as | Δ bytes | verdict |
|---|---|---|---|---|---|
| **C6** | `clamp.py:253` `ClampResult.describe()` | `requested_w` / `applied_w` | §15 action log (`main.py:1366`) **and** the ClampWatchdog notification reason (`main.py:2452`) | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_clamp.py` part 6d, 4 checks |
| **C7** | `peakguard.py:493` `PeakDecision.explain()` | `projected_w` / `ceiling_w` | status row `main.py:3313`; action log `main.py:1951` on the release edge | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_peakguard.py` part 13, 3 checks |
| C1 | `allocator.py:154` `UnitPlan.explain()` | `self.span` / `self.why` | the arbiter/allocator log line | **0** | **SURVIVOR, 21/21 green** |
| C2 | `allocator.py:160` | `self.span` / `self.why` | the ENVELOPE VIOLATED line | **0** | **SURVIVOR, 21/21 green** |
| C3 | `allocator.py:162` | `self.span` / `self.why` | the ordinary in-envelope line | **0** | **RED** — `test_alloc.py` already asserts that whole literal. The one member of this census the suite could already see, and it says the habit works. |
| C4a | `arbiter.py:344` `UnitBounds.explain()` | `self.lo` / `self.hi` | `CONTRADICTION within the unit (device lo=… > peak hi=…)` | **0** | **SURVIVOR, 21/21 green** |
| C4b | `arbiter.py:344` | `self.lo_src` / `self.hi_src` | the same sentence, the two SOURCE names | **0** | **SURVIVOR, 21/21 green** |
| C5 | `arbiter.py:392` `Resolution.explain()` | `self.winner` / `self.bound_by` | `main.py:2236`'s `ARBITER:` action-log row and `self.reason` at `main.py:2329` | **0** | **SURVIVOR, 21/21 green** — the winner and the limiter swap names, on the line §10.1 exists to make legible |
| C8 | `tariff.py:279` `PeakReading.explain()` | `self.month` / `self.detail` | the PEAK UNREADABLE row | **0** | **SURVIVOR, 21/21 green** |
| C9 | `tariff.py:528` `Projection.explain()` | `self.projected_w` / `self.elapsed_s` | the projection line | **0** | **SURVIVOR, 21/21 green** — watts printed as seconds and seconds as watts, both `:.0f` |
**Not in the family, and the scan says why rather than dropping them silently:**
`inverter.py:338` `__repr__()` and `static_tariff.py:378` `words` are reported
**UNRESOLVED — no call site inside `app/`** (a `__repr__` is called by the
interpreter, not by a name the scan can match); `price_forecast.py:421/428` and
`tariff.py:214` `month_key()` resolve to **OTHER** — `month_key` is a **store
key**, which is MAINT-05's case and a swap there is behaviour every check sees.
### 7f. Round 4's mutation ledger
Same protocol as §4: a fresh full-repo copy per mutation, `:ro`, bytecode
purged **and the purge count printed** before the edit, the edited file
**re-read from disk**, both sides' length and sha256 printed, and the full
**21-file** suite via `tools/mutation_run.sh`. Every row is a pure
transposition, so **every Δ is 0 bytes** — verified, not assumed: `len(old) ==
len(new)` is asserted before the write and `len(after) == len(before)` after it.
**⚠️ And every row was also required to change the ANSWER.** A byte delta says
the file moved; it does not say the sentence did. Each mutated tree is rendered
through a scratch probe that evaluates the shipped f-string with **distinct
operands** and diffs it against the same render of the clean tree. **The first
cut of that probe numbered the operands by ORDER OF APPEARANCE** — so after a
transposition the other leaf took the same number and all thirteen diffs came
back **empty**, which reads exactly like "the edit is inert". Keying the value
to the **name** fixed it. That is the same defect as everything else in this
card, in the instrument this time: a measurement comparing the swap against
itself.
| mutation | file, before → after (sha256, 16) | Δ | verdict |
|---|---|---|---|
| C1 `allocator.py:154` span↔why | `10c1b26ed0099654` → `ac0c174de1ec28ad` | 0 | **SURVIVED 21/21** |
| C2 `allocator.py:160` span↔why | `10c1b26ed0099654` → `9b10730f4659befd` | 0 | **SURVIVED 21/21** |
| C3 `allocator.py:162` span↔why | `10c1b26ed0099654` → `07097bf5da31f0c6` | 0 | **RED** — `test_alloc.py` |
| C4a `arbiter.py:344` lo↔hi | `fdb4ed0520eeb721` → `009820de7ef0eff0` | 0 | **SURVIVED 21/21** |
| C4b `arbiter.py:344` lo_src↔hi_src | `fdb4ed0520eeb721` → `04cb8cf69dfc7432` | 0 | **SURVIVED 21/21** |
| C5 `arbiter.py:392` winner↔bound_by | `fdb4ed0520eeb721` → `9238c3db920da8ad` | 0 | **SURVIVED 21/21** |
| **C6** `clamp.py:253` requested↔applied | `0153133741497fa5` → `47e4e371043e3201` | 0 | **SURVIVED 21/21 before — RED after**, `test_clamp.py`, 4 checks |
| **C7** `peakguard.py:493` projected↔ceiling | `d95df61a51716c26` → `2d2ee3d0bcd51251` | 0 | **SURVIVED 21/21 before — RED after**, `test_peakguard.py`, 3 checks |
| C8 `tariff.py:279` month↔detail | `82790523dd5227c9` → `4b2147ca17a28425` | 0 | **SURVIVED 21/21** |
| C9 `tariff.py:528` projected↔elapsed | `82790523dd5227c9` → `517e9148d26a243a` | 0 | **SURVIVED 21/21** |
| B17 `arbitrage.py:377` spread↔required | `67fbf949b2938349` → `cddd8045bdeb5e29` | 0 | **SURVIVED 21/21** |
| B18 `arbitrage.py:393` spread↔required | `67fbf949b2938349` → `04a4f3ad3d255de1` | 0 | **SURVIVED 21/21** |
| B19 `main.py:2652` reason↔soc_floor_reason | `61aa413fb890433e` → `bbcbb28f476a98d0` | 0 | **SURVIVED 21/21** |
| **B12** `arbiter.py:264` source↔actuator | `fdb4ed0520eeb721` → `9111f627ef639ecc` | 0 | **SURVIVED 21/21 before — RED after**, `test_arbiter.py`, 2 checks |
**14 measured, 12 survived the full suite before this round's checks, 1 was
already caught, 3 are caught now.** The tree these were taken on is
`SAFETY-38` @ `357cb4f` rebased on `release/1.0` @ `9e42cec`; `app/main.py` is
234 542 bytes.
### 7g. What round 4 does NOT close, with sizes
* **Eleven measured zero-byte survivors are left open** — C1, C2, C4a, C4b, C5,
C8, C9, B17, B18, B19 and the three already open from round 3 (A3, B1, and the
B2–B11/B13–B14 source-level set). Each is a sentence an operator reads and
none is a control decision. They are disclosed with their measurement rather
than pinned because pinning each one wants a fixture in the file that owns the
module — `test_alloc.py`, `test_arbiter.py`, `test_tariff.py`,
`test_arbitrage.py` — and this card has now spent four rounds inside
`test_watchdog.py`'s subject. **An enumerated list with honest gaps beats a
fifth round finding a fifth instance**; what would NOT be honest is a fifth
round finding one that is **not on this list**.
* **`main.py:1951`, the action-log surface of the peak-guard idle line,** is
unpinned. The status row off the same `explain()` is pinned; the second call
site is not, and §4c is the card about exactly that costing a round.
* **The largest remaining hole is not in any census: `%s`-style lazy logging.**
`_LOG.error("… %s … %s", a, b)` puts the two operands in *arguments*, not in
an f-string, so nothing here looks at them and the transposition is the same
zero-byte edit. The tool now says so first in its NOT-SCANNED list, **with
the size measured rather than asserted: 31 such calls across 8 modules in
`app/`, each one listed**.
* **The tool's own blind-spot list was the round-4 blocker and is now two
lists.** "What this scans, and what it does not" enumerates its message sinks
from the constants themselves and then names, in order, what it never looks at:
`%s`-style logging, `.format()`/`%`/`+`/`.join()` sentences, a sentence that
leaves through a dict or dataclass and is rendered elsewhere, `yield`ed
sentences, attributes outside the `reason`-shaped set, and everything outside
`app/`. "What it can see but gets wrong" keeps the five gaps round 3 printed,
with the over-report gap now backed by a **printed format-spec column** so a
reader can tell a silent same-typed transposition from a noisy mixed-type one.
@@ -1,455 +0,0 @@
# SAFETY-39 — `slew_w` is a ratio to the rail, and `gain` is the constant that is still wrong
**Status:** decided and implemented on branch `SAFETY-39`.
**Card:** SAFETY-39 — *"`slew_w` was derived at a 1500 W clamp and runs at 5000,
so the loop outruns the plant"*.
**Nothing here reached the inverter.** The live install still runs the interim
`slew_w: 400` applied by hand on 2026-09-05 12:00, which this branch's new rule
accepts unchanged.
---
## 0. The three things a reader should take away
1. **The overshoot is reproduced in simulation, and one half of it is not
reproducible at all.** The setpoint stepping to the rail and the meter
crossing from export into import are both reproduced, on two independent
plant models. The **−5154 W battery figure is not reproducible in either**,
because both clamp the plant at its rating. An acceptance criterion written
on it would pass on the broken configuration too, so none is. §2.
2. **The deliverable is the relationship, not the number.** `slew_w: 0` now
means *follow the rail* and resolves to `SLEW_RAIL_FRAC * max_w`;
`validate.py` rule 10 refuses an explicit value faster than that against the
**site** rail. Verified by breaking it. §4, §5.
3. ⚠️ **`slew_w` does not own the over-absorption. `gain` does, and `gain` was
not changed.** Re-deriving the slew removes the ≥ 900 W step — the symptom
that escalated 2 → 1 → 13 → 43 per 30 min on the morning of the excursion —
and removes nothing else. Anyone reading this card as "the overshoot is
fixed" is reading more than was delivered. §6.
---
## 1. The defect, stated in the terms the code uses
`slew_w: 1000` was derived on 2026-08-22 against a **clamp of 1500 W**
(goodwe/`NOTES.md`, *"Simulated 2 kW load step (gain 0.6, clamp 1500)"*). Its
stated justification:
> `slew: 1000` is the default because it puts most of the correction in the first
> cycle **while still capping how far the loop can jump ahead of the inverter**.
At 1500 W that sentence is true, and the reason is the rail rather than the
number: 1000 W/cycle reaches the rail in two cycles, so the excursion is bounded
by 1500 W whatever the loop does.
The live install then went to `max_w: 5000` and the constant did not move.
`DRIVER-07` ("raise actuator power cap to full inverter rating") is still in To
Do; the cap was raised ahead of it and `slew_w` was never revisited. **Nothing
in the code is broken.** The rail moved and the derivation did not.
| | derived at | shipped default | live install |
|---|---|---|---|
| `max_w` | **1500** | 2000 | **5000** |
| `slew_w` | 1000 | 1000 | **1000** |
Measured on the owner's system, 2026-09-05 11:43–11:46:
```
11:43:55 grid -3385 W a real 3.4 kW export
11:43:56 SP 380 → 1380 → 2380 → 3380 → 4380 → 5000 +1000 per 2 s tick
11:44:47 batt -5154 W ABOVE the 5000 VA rating
11:45:55 grid +2831 W flipped to 2.8 kW IMPORT
SP 5000 → 2930 → 3840 → 3750 hunting
```
Large (≥ 900 W) setpoint steps per ~30 min that morning: `2 → 1 → 13 → 43`.
---
## 2. ⭐ Reproduced first — and what could not be
`tools/slew_sim.py --repro`, against admin/goodwe's `plantsim.Plant`, the real
`compute()`, and the firmware's own encoder on the wire. Gain 0.6, `max_w` 5000,
`slew_w` 1000, 2 s tick, one 3385 W export step at t = 10 s:
```
t grid in setpoint battery grid out reason
10 -3739 -280 290 -3675 slew-limited
12 -3675 -1280 57 -3442 slew-limited
14 -3442 -2280 -316 -3069 slew-limited
...
34 -1012 -5000 -2823 -562 i-clamped ← the rail
38 -167 -5000 -3541 156 saturated-freeze ← EXPORT → IMPORT
44 562 -4310 -4018 633 saturated-freeze
50 544 -3210 -3804 419 tracking
```
Reproduced: the setpoint stepping a full 1000 W per tick to the rail, the loop
absorbing ~640 W more than the site needed, and the meter crossing from **3.7 kW
of export to +633 W of import** — the loop manufacturing the error it then
chases. Over a family of 51 export steps (1.0–4.2 kW × 3 phases), **48 of 51**
runs contain a ≥ 900 W setpoint step.
### ⚠️ What is NOT reproducible, and why no criterion is written on it
`plantsim.Plant.limit()` clamps the plant at `p_max`. The modelled battery
**cannot exceed its rating by one watt, at any tuning, on any step, ever.**
Neither can `test_control.py`'s independent model. So:
* the **−5154 W excursion is not reproducible in simulation at all**, and this
is a property of the models rather than of the tuning;
* the card's AC *"battery power no longer exceeds `max_w` on a step of the size
observed"* is **unfalsifiable in simulation** — it passes identically on the
configuration that produced the excursion. It is the one acceptance criterion
this card had to **rewrite rather than meet**, per the board's own rule that a
third review round means the AC is wrong.
What replaced it, and what is graded in CI: **the ≥ 900 W step is gone from
every member of the family**, and **the meter excursion and the over-absorption
are measured and reported rather than claimed to be fixed.**
### The two models agree
`test_control.py`'s in-repo first-order lag and admin/goodwe's ramp-plus-lag
`plantsim.Plant` are independent, and they agree on every sign and rank:
| | in-repo model | `plantsim.Plant` |
|---|---|---|
| ≥ 900 W steps at `slew_w` 1000 | 48/51 | 48/51 |
| ≥ 900 W steps at the derived value | 0/51 | 0/51 |
| over-absorption, slew 1000 → 250 | 1005 → 1330 W | 958 → 1081 W |
| over-absorption, gain 0.6 → 0.2 | 1109 → 393 W | 987 → 435 W |
---
## 3. The derivation
The constraint, in words, and it is the 2026-08-22 sentence made checkable:
> **The loop must not traverse its full output range faster than the plant takes
> to settle.**
Three measured quantities, and each is named where it came from:
| | value | where it was measured |
|---|---|---|
| plant settling time | **18 s** | goodwe/`NOTES.md` §5.10, 2026-08-22 — a 1.78 kW kettle step driven from HA off a plug that meters its own draw. ~18 s up to the deadband, ~23 s down. **18 is taken because it is the shorter and therefore the looser bound**; this is a refusal boundary, and rules 5 and 6 refuse the impossible case only, never "close to the rail". |
| control tick | **2 s** | the live install, measured 2026-09-05 — the setpoint stepped +1000 W every 2 s. `run_control()` runs a cycle on a changed meter value or on `cycle_floor_s`, whichever comes first, so the tick is the *meter's* rate. |
| output range | `max_w` | `main.py` builds `Tuning(max_w=self.site_max_w)`, the **roster sum**. |
```
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 0.1111 (9 ticks)
slew_w ≤ SLEW_RAIL_FRAC × max_w
```
| rail | derived `slew_w` | ticks to cross | seconds to cross |
|---|---|---|---|
| 1500 (where the original was derived) | 167 W | 9 | 18 s |
| 2000 (shipped default) | 222 W | 9 | 18 s |
| **5000 (the live install)** | **556 W** | 9 | 18 s |
| 9600 (with inverter B — **datasheet, not measured**) | 1067 W | 9 | 18 s |
And the configuration that was measured failing: `slew_w` 1000 at `max_w` 5000
crosses the rail in **5.0 ticks = 10 s**, against a plant that takes 18 s.
### ⚠️ The tick is the weak term and it is not wrong in the safe direction
A **slower** meter makes this bound conservative — it refuses configurations that
would have been fine, which is visible immediately and costs one edit. A meter
**faster** than 2 s makes it loose by exactly that ratio and nothing in this
add-on would say so. `run_control()`'s `await asyncio.sleep(1)` bounds the tick
below at 1 s, so the worst case is a factor of two — but if a faster meter is
ever fitted, `LOOP_TICK_S` has to be **re-derived, not merely re-read**. Compare
`validate.FIRMWARE_INGEST_VETO_S`, which carries the same kind of copy and can
only ever be wrong the other way; that difference is why both numbers are
written out in `control.py` rather than their quotient.
### ⚠️ The rig cannot pick 556 out of the table, and is not asked to
`tools/slew_sim.py --slew`, worst case over 51 export steps:
```
slew_w 1000 ← 2026-09-05 live pair step 1000 W 48/51 ≥900 W over-absorbed 958 W
slew_w 800 step 800 W 0/51 ≥900 W over-absorbed 1107 W
slew_w 700 step 700 W 0/51 ≥900 W over-absorbed 1133 W
slew_w 600 step 600 W 0/51 ≥900 W over-absorbed 1058 W
slew_w 555 ← the derived value step 560 W 0/51 ≥900 W over-absorbed 987 W
slew_w 500 step 500 W 0/51 ≥900 W over-absorbed 1029 W
slew_w 400 ← the live interim step 400 W 0/51 ≥900 W over-absorbed 1150 W
slew_w 300 step 300 W 0/51 ≥900 W over-absorbed 1143 W
slew_w 250 step 250 W 0/51 ≥900 W over-absorbed 1081 W
```
The ≥ 900 W step disappears at 800 and stays gone. Below that the table is
**flat and slightly non-monotone**, and 800 is *better* than 556 on
over-absorption. So the rig does not choose the number — **the constraint
chooses it, and the rig says what the constraint does and does not buy.** That
is the same relationship the 2026-08-22 table had to its own three columns.
⚠️ **FORWARD REFERENCE, ADDED BY SAFETY-48 — THIS TABLE AND THIS SENTENCE ARE
TRUE OF release/1.0's LAW AND FALSE OF THE ONE THAT REPLACED IT.** Nothing above
is retracted: re-run on release/1.0 today the same rig prints the same column
(958 / 1107 / 1133 / 1058 / 987 / 1029 / 1150 / 1143 / 1081 W), flat,
non-monotone, 800 better than 556. Under SAFETY-48's lead cap the **same script
on the same plant** prints 777 / 734 / 663 / 563 / 553 / 507 / 435 / 358 / 321 W
— falling monotonically, so a slower slew is now *better* and the sentence above
inverts. The mechanism SAFETY-39 identified is exactly why: `slew_w` bounds the
OUTPUT while the integrator goes on accumulating `gain · error`, so slowing the
slew used to buy nothing; bound the integrator's lead and it starts to.
`tools/slew_sim.py --slew` now names which of the two laws it is running under
before it narrates the column, and
`DECISIONS/SAFETY-48-bounding-the-integrator-lead.md` §6 carries both tables and
the independent confirmation from `test_control.py`'s own plant model. ⚠️ **This
does NOT re-open the choice of 556**: the constraint picks the number, the rig
does not, and that is unchanged in both laws.
⚠️ **A single step is not evidence here.** The deadband, the 10 W quantisation
and the phase of the step against the tick move the result by hundreds of watts,
non-monotonically. The first cut of this derivation was a single step and ranked
gain 0.3 as *worse* than 0.6, which the family shows is backwards. Every number
above is a worst case over a family, which is how admin/goodwe grades the same
kind of quantity (`config.yaml`'s "1890 closed-loop runs", "924 runs").
---
## 4. What ships — the relationship, not the number
**`slew_w: 0` means FOLLOW THE RAIL.** `control.slew_limit_w()` resolves it to
`SLEW_RAIL_FRAC * max_w`. It is the shipped default and it is the only value
that cannot be left behind when the rail moves. This is the idiom
`integrator_max_w: 0` has carried in the same options block since it was added —
not a new concept, the same one, applied to the key that needed it.
An explicit number is still accepted **and is never silently reduced**: the live
install runs 400, and silently reducing an operator's stated bound is case (a)
in `validate.py`'s own docstring — the defect that whole module exists to
prevent.
**`validate.py` rule 10** refuses an explicit `slew_w` above
`SLEW_RAIL_FRAC × site rail`. Graded against **`sum(unit_caps(opts))`**, the
roster sum — the same number `Controller.site_max_w` builds — and *not* against
the flat `max_w` and *not* against the weakest unit. Rules 5, 6 and 8 take
`min()` because each grades one watchdog, one shaping claim, one pack. `slew_w`
is not a per-unit quantity at all: `compute()` emits the **site** target and the
allocator divides it afterwards.
⚠️ `config.yaml`'s note on `site_sign_disagree_w` says there is deliberately no
rule pairing a site quantity with `max_w`, "because … `validate_startup_config()`
is handed options rather than a roster". That objection is to grading a site
quantity against the **flat key**, and it is answered rather than ignored:
`unit_caps()` already reads the roster the add-on will build, so the roster sum
*is* available at startup, and `site_rail_w()` is that number.
---
## 5. ⭐ Verified by breaking it
`test_validate.py`, section 10 — the acceptance criterion written as a test:
```
restoring slew_w 1000 at max_w 5000 -> 1 problem(s)
slew_w is 1000 W against a site rail of 5000 W, so the loop crosses its
whole output range in 5.0 cycles - 10 s at the 2 s tick this site was
measured running - while the plant takes 18 s to settle …
```
and the four things the refusal has to say, each graded separately: which rail
it used, how long the crossing takes, which measurement it is graded against,
and what to do about it (including `0`, which cannot go stale). Plus:
* **equality starts** — at exactly the derived value the crossing takes exactly
the settling time, which is the constraint met, not violated (rule 6's case,
not rule 5's; getting it backwards would refuse the recommended value);
* **one watt of slack starts** — a boundary that is not meaningful to one watt
must not refuse on one watt;
* **the live interim `slew_w: 400` starts**, and so does the schema floor —
there is no such thing as too slow here, and refusing it would refuse the
mitigation that is holding;
* **`slew_w: 0` starts at every rating from 100 W to 15 kW.**
`test_control.py` grades the same boundary from the law's side, so the rule can
only ever refuse what the ratio says is too fast, and the two cannot drift apart.
---
## 5b. What the change found in this repository's own tests
Five files moved, and **none of them moved because a number was rounded
differently**. Each is worth reading as a finding.
**Two fixtures encoded the defect.** `test_inverter.py`'s `BASE_OPTS` paired
`slew_w: 1000` with `max_w: 5000` — the 2026-09-05 configuration exactly — and
`test_loop.py`'s `BASE` paired 1000 with 2000. Both were typed when those rails
were new; nothing in either file had noticed. Rule 10 refused them on its first
run, so those fixtures **would not start**. That is what a rule about a
*relationship* is for, and it is the clearest evidence the rule is not a
formality.
**Two reproductions depended on a default they never named.**
`test_alloc.py`'s `close_loop` and `test_freeze.py`'s "make both terms
reachable" case reached `slew_w: 1000` through `Tuning`'s default. ARCH-01's
4000 W broadcast limit cycle and SAFETY-18's proof-of-capability alarm would
both have gone quiet — reporting a measured finding as *absent* because a
default moved underneath them. Both now write the number beside the measurement
it belongs to.
**One harness had a latent off-by-one that only a ramping loop could expose.**
`test_watchdog.py` graded SAFETY-02's banner by pairing `c.grid` with
`last_write` sampled **after** `cycle()`, while the detector is evaluated
**inside** `cycle()` against the *standing* command from the previous one. At
`slew_w: 1000` the loop reached its rail in two cycles and consecutive commands
were identical, so the wrong pair and the right pair were the same numbers. At a
rail-following slew they differ by one step and the check went red. `_drive()`
now has a `pre` hook that reads the operands the detector will actually use, and
the check reads the **latching** cycle rather than the one before it. The
assertion is strictly stronger than it was.
**One tripwire had two assertions that were exact by coincidence.**
`test_peakguard.py` part 10c read `limited_discharging == limited` (49/49) and
`min_soc <= bms_soc_floor + 0.05`. At the slower slew the loop drains that pack
~82 Wh less over the same two hours and no longer touches the cut-out *inside
that window*: 48/49, low-water 10.57 % against a 10 % cut-out, 0 cycles at the
BMS limit instead of 190. **The finding is unchanged and is sharper on the
measure that carries it** — 162 cycles of granted discharge below the EMS floor,
up from 138. The two exact equalities were artefacts of one tuning, the old and
new numbers are tabulated in the file beside the checks, and the assertions now
grade the substance. ⚠️ *Loosening an assertion to get a branch green is the
first thing a reviewer should suspect here; the table is there so the suspicion
can be settled rather than argued.*
---
## 6. ⚠️ `gain: 0.6` — measured, and deliberately NOT changed
The card asks whether `gain` still holds at this clamp. **It does not, and here
is the measurement.**
`tools/slew_sim.py --gain`, `slew_w` at the derived 556 W, same 51-step family:
```
gain 0.6 ← shipped over-absorbed 987 W meter driven to +987 W
gain 0.5 over-absorbed 941 W
gain 0.4 over-absorbed 977 W
gain 0.3 over-absorbed 876 W
gain 0.2 over-absorbed 435 W
gain 0.1 over-absorbed 66 W
```
Against the slew column in §3, which is flat. The mechanism is visible in
`control.py` and is structural rather than empirical:
* **`slew_w` bounds the OUTPUT.**
* **The integrator accumulates `gain × error` per cycle** and is bounded only by
`integrator_max_w`. At gain 0.6 into a 3.4 kW error it moves **~2040 W per
tick** and reaches a 5000 W rail in **under three ticks, whatever `slew_w`
is**.
So the 2026-08-22 derivation's own sentence — *"the gain, not the slew, is the
limit by then"* — has become literally true, and `gain` is the term that owns
the over-absorption at this rail. It is written about a 1500 W rail, where
`gain × 1500 = 900 W` and the clamp caught it in two cycles; at 5000 there is
3.3× more room to wind before the clamp binds.
**Why nothing was changed:**
* `gain` is a live control-law constant on a running house. Two of them moving
in one release makes the next measurement uninterpretable — and the interim
`slew_w: 400` has been in service since 2026-09-05 12:00, so there is a clean
baseline to measure the next change against and it would be destroyed.
* A validator rule pairing `gain` with a rail would be **inventing a
relationship nobody has measured.** The bound would have to be
`gain × error_max ≤ SLEW_RAIL_FRAC × max_w`, and `error_max` is the
disturbance, which is not config and is not bounded.
* `integrator_max_w` is **not** the lever it looks like: it bounds the output as
well as the accumulator (`target = clamp(i_w, ±max_w)`), which is why setting
it to 2000 on the live system on 2026-09-05 capped the inverter to 2000 W of
its 5000 and was reverted the same day.
This is named in `validate.py`'s *"deliberately not refused"* list so that it is
a known open item rather than an omission, and it is asserted in
`test_control.py` so that nobody can quietly read this card as having fixed it.
**It wants its own ticket.**
---
## 7. N = 1 → N = 2: the rail is what moved
⚠️ **Inverter B does not exist on site** (SPIKE-04, ≈ 2026-09-12). Its figures
are from its **datasheet and serial**, not a measurement: GW5048D-ES, serial
`55048ESU22AW0992`, 4600 W, pack 4 × Dyness DL5.0C = 20.48 kWh, floor 10 %
(goodwe/`NOTES.md` §1.1b) — and the 4600 W is printed under *battery input data*,
so it may be DC, in which case the AC ceiling is nearer 4324 W.
⚠️ **And `sim/plantsim.py`'s `INVERTER_B` is inverter A copied**, carrying
`measured=False`. **Every two-inverter row below is a result about two copies of
A on a 10000 W rail, not about the ~9600 W site B implies.**
**The site rail becomes `sum(max_w)` ≈ 9600 W while `slew_w` is per-site.** The
derived value there is `0.1111 × 9600 = 1067 W` per tick, which at an even split
is 533 W per unit against each unit's own 5000/4600 W rating — ARCH-01's
split-invariance holding, as its `two_master_gain_sweep` already shows.
**Does the enforced relationship produce it automatically? Yes.** Rule 10 reads
`sum(unit_caps(opts))` and `slew_limit_w()` reads `Tuning.max_w`, which
`main.py` sets from `Controller.site_max_w`. Declaring inverter B moves both,
with no operator action and no second derivation. That is the property that
makes this a fix rather than a re-tune, and `test_validate.py` asserts the
9600 W arithmetic directly.
⚠️ **But the direction of the rule is not the one the ticket assumed, and it
must be said.** A rail that **grows loosens** this bound: `slew_w: 1000`
crosses 9600 W in 19.2 s and **passes**, while the same 1000 at 5000 W is the
configuration measured failing. That is what the constraint says and it is
correct — the bound is on how long the crossing *takes*. What the rule protects
against is the constant being left behind when the rail moves, in either
direction. **It is not a bound on how much power the site may command** (that is
each unit's own `max_w` through the arbiter's `supervised` claim) **and it does
not bound the over-absorption** (§6).
⚠️ **The ≥ 900 W symptom goes stale at N = 2 the same way the constant did.**
On the two-copies-of-A rows, 48/51 runs contain a ≥ 900 W step *at the derived
value*, because at a 10000 W rail the derived step **is** 1111 W. A check
written on the absolute 900 W would report the correct tuning as broken.
Anything grading this symptom at N ≥ 2 has to grade it as a **fraction of the
rail** too — which is this whole card, one layer out.
---
## 8. `max_w: 5000` — a recommendation, not a change
The battery reached **5154 W against a 5000 VA inverter**, so the configured cap
left no headroom for the overshoot that the loop was going to produce anyway.
Three things are worth writing down and none of them is changed here:
1. **The cap is the rating, so the overshoot has nowhere to go but past it.**
`compute()` bounds the *command* to `±max_w`; nothing bounds the plant's
response to a command at the rail, and §6 shows the loop asks for the rail on
any large step at gain 0.6. Recommend **`max_w` at 4500–4750 W**, i.e. 90–95 %
of the 5000 VA nameplate, leaving 250–500 W for the overshoot the loop still
has. This is `freeze.py`'s `RAIL_FRAC` argument applied to the cap itself.
2. **The −5154 W may not be an AC figure at all.** `batt_entity` is the pack
sensor and `max_w` is graded against `0x0518`, the inverter's **AC** power —
`config.yaml`'s roster comment already records exactly this ambiguity for
inverter B. If the battery reading is DC, 5154 W DC is ~4900 W AC and the
inverter was never over its nameplate. **Nobody has measured which**, and the
recommendation above is worth making either way.
3. **It is the owner's number.** `translations/en.yaml` marks `max_w` "THIS IS
YOUR NUMBER" precisely so it is not confused with a derived constant, and
changing it unilaterally in a pull request would undo that distinction.
`SAFETY-20`'s `min(max_w, dev_max)` settles the device side once ESPHome has
answered; the *headroom* question is a policy choice and belongs to the owner.
---
## 9. What was deliberately not done
* **`saturation_w` and the deadband were not touched.** The card forbids it and
it is right to: widening either hides the symptom. The loop is following its
command correctly; the command is wrong.
* **`gain` was not changed.** §6.
* **`max_w` was not changed.** §8.
* **`control.py`'s slew *limit* was not clamped to the bound.** An explicit
value above it is refused at startup, not quietly reduced — silently reducing
an operator's stated bound is case (a) in `validate.py`'s docstring.
* **Nothing in admin/goodwe was modified**, and nothing reached the inverter.
@@ -1,28 +0,0 @@
# SAFETY-40: the integrator bound is not a bug
**Decision.** `i_clamped` (the integrator reaching `integrator_max_w`, i.e.
anti-windup) keeps alarming through the same `ClampWatchdog`: it is counted,
notified on the same cadence, and escalates on a sustained run
(SAFETY-41). It gets its own text, a `WARNING` and an amber status row. Only
`clamped` (a command over `max_w`) and write-path clamps keep the BUG SIGNAL,
the `ERROR` and the red row.
**Why it still notifies.** On a default install `clamped` is unreachable
(`control.py`, at the integrator bound), so the integrator bound is the only
loop-side signal of a runaway such as the 14 768 W one. Silencing it would
remove that. The card says the same: "the fix is to say the true thing, not to
say less."
**Why a bug has its own budget.** A genuine over-rating must not wait up to
`REASSERT_S` behind anti-windup notices. `ClampWatchdog` tracks a separate
`last_bug_alarm`, and a bug's text is never overwritten by later anti-windup
text within an episode. Across episodes the WARNING uses its own
notification id (`goodwe_integrator_bound`), so it cannot replace a BUG SIGNAL
notice still on screen (review of #130). With only bug engagements, the cadence is the same as
before (`test_clamp.py` part 9).
**Numbers.** They come from the `Decision` of the clamping tick (`i_bound_w`,
`requested_w`, `clamped_w`, `target_w`), not from `i_w` or `self.target`.
**Open for the owner.** Whether scattered one-cycle anti-windup episodes should
notify at all, or only log and count. They notify now, as before.
@@ -1,807 +0,0 @@
# SAFETY-42 — a unit that stops delivering its share, the discriminator the card asked for, and why that discriminator does not exist
**Status: DECIDED 2026-09-06.** A new per-inverter check, `app/delivery.py`,
names an inverter that is **standing at a discharge share it is not producing**
while at least one sibling is not in that state and nothing this add-on can read
explains it. It **reports and does not act**. At a roster of one it is inert by
construction.
⚠️ **This record also FALSIFIES the discriminator the card proposed.** SAFETY-42
asked for *"saturation persisting past N while |grid error| stays above X, with N
and X derived"*. That predicate was built and run on the rig before anything was
written, and it fails in **both** directions — not for want of better N and X,
but because of what the quantity is. §2 is the measurement. This is the
SAFETY-39 shape: saying the criterion is wrong was worth more than five
near-misses at satisfying it.
Filed with SAFETY-42, card `6a9d498a49082c0086e525c1`.
---
## 0. What is uncovered, and why it is structural rather than a threshold
A unit delivering **0 W against a non-zero share** is seen by nothing:
| guard | why it is silent |
|---|---|
| [[SAFETY-30]] per-unit sign | gates on **opposition ≥ 250 W on both sides**. A unit at 0 W never opens it — it is not opposing, it is **absent** |
| SAFETY-30 site sign | the sum still **agrees with the meter in direction**; only the magnitude is short |
| [[SAFETY-31]] circulation | `Σ\|Bᵢ\| − \|ΣBᵢ\|` is **zero** when one unit does nothing — circulation needs two units pulling *against* each other |
| the saturation freeze | **fires** — and `clamp.py`:93-96 says of exactly this: *"A loop can be legally saturated all day — a cold pack, a curtailed inverter, an export limit — so a saturation-shaped alarm is one an operator learns to ignore"* |
So the one signal that does fire is the one this codebase documents as
unreadable, and that documentation is **right**. This card therefore needed a
different signal, not a louder one.
It is [[HW-01]]'s shape. On 2026-09-02 the comms subsystem failed and the power
stage went on executing its last instruction for **6 h 25 m** with all three
layers spectators. At N=2 the same fault leaves a healthy sibling regulating
alone, the site short by up to one unit's share, and nothing saying so.
## 1. The rig
Everything in §2 and §3 was measured on `admin/goodwe`'s two-master rig
(TEST-19), used **read-only** and run container-free, from a harness in a
scratch tree. Real and imported, never transcribed: the control law
(`control.compute`, `Tuning`), the allocator (`arbiter.allocate_record` →
`allocator.plan`), the per-unit envelope the shipped `supervised` and `device`
claims build, the sign predicate (`watchdog.disagrees`), the circulating sensor
(`circulation.circulating_w`), the check under test (`delivery.delinquent`,
`short_by`, `account`), the wire (`frame_check.setpoint_atomic`, the firmware's
own encoder, in and back out through `plantsim.parse`/`decode`), and the plant
(`plantsim.Plant`). The per-unit reading is **holding register 0x0518**, the one
the live `batt_entity` binds.
Modelled, and therefore not evidence about the thing modelled: the master's
failsafe machine (absent), and the two fault injections —
* **wedged** — the write never reaches the plant, so its latched command stands.
Started at 0 W, which is *"delivering 0 W against a non-zero share"*.
* **at its floor** — the write arrives and `Plant.limit()` zeroes it, because
`soc <= soc_min`. This is the benign case the acceptance criterion names.
### ⚠️ Inverter B does not physically exist
`plantsim.INVERTER_B` is **inverter A copied**, `measured=False` (NOTES.md
§1.1b; the datasheet-and-serial figures 4600 W / 20.48 kWh / floor 10 % are
deliberately *not* in the model). **Every two-inverter figure in this document
is two copies of inverter A.** §3.3 is the sensitivity sweep that sizes the
margin that costs.
### Reproducing it — ⚠️ THE HARNESS IS COMMITTED NOW (TEST-21, 2026-09-07)
⚠️ **THIS SECTION USED TO SAY THE HARNESS WAS NOT COMMITTED, AND THAT WAS THE
DEFECT TEST-21 FOUND.** A shipped safety constant whose floor the next reviewer
cannot re-run is PROC-01's shape, and PROC-01 had eleven instances in three
days. The paragraph this replaces was true — what the harness does *is* small
enough to restate — and it was not enough: a restatement in prose leaves every
axis it does not name to the next person's judgement, and §3.3 below now records
how far that moves the number. **It moved. See §3.3's second table.**
It is committed in the **other** repository, `admin/goodwe`, as
`sim/delivery_window.py`, beside `sitesim.py` — the container-free two-master
harness it is built on. From clean checkouts of both repos:
```
cd goodwe/sim
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --selftest
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --campaign --blag
```
stdlib only. No container, no HA, no esphome, **no rig and no rig lock** — it is
a `plantsim` campaign, not a rig job. About fifteen minutes for the whole thing.
⚠️ **AND IT IS IN `admin/goodwe` RATHER THAN HERE, WHICH IS CI-08's RULE
APPLIED RATHER THAN IGNORED.** It imports `plantsim` (the plant, the per-unit
wire, `Site`) and `frame_check` (the firmware's own encoder), both **owned by
that repo** and neither present in this one's CI image. Committed here it had
two possible fates and both are the coupling CI-08 has been broken by three
times: skipped for want of its imports — a green that establishes nothing — or
the plant and the encoder **restated in this tree**, which is a second copy of
something the other repo owns, free to drift, and drift in a fixture is
invisible until the number it defends is wrong. There the dependency runs one
way and stays one-directional: **this** repo's `control`, `arbiter` and
`delivery` are imported live out of whichever checkout is under test
(`$GOODWE_ADDON_REPO`), never copied — so a change to `delinquent()` moves the
number on the next run instead of silently disagreeing with a fixture. That is
the seam `sitesim.py` already stands on, which is the other reason the campaign
is its neighbour and not a third arrangement.
⚠️ **THE SELFTEST IS CI-GATED; THE CAMPAIGN IS NOT, AND THE SPLIT IS
DELIBERATE.** `admin/goodwe`'s `.gitea/workflows/checks.yml` gained one step —
`python3 -B delivery_window.py --selftest`, seconds, beside the existing
`norig, no lock` TEST-19 and TEST-20 steps, in the workflow that **already**
checks out this repo for `two_writers` and accepts that boundary in writing. So
a change **here** that moved `saturation_w`, `max_w`, `delivery_hold_s` or the
shape of `delinquent()` turns that step red rather than leaving a stale number
in this record. The **campaign** (`--campaign --blag`, ~15 minutes) is not
gated: it is a campaign, not a gate, and `failure-injection.yml`'s jobs run on
the **rig** runner, which a container-free simulation has no business holding.
What TEST-21 asked for is a command a reviewer can run from a clean checkout —
that is the block above.
⚠️ **`--selftest` IS A FALSIFIABILITY ARM, NOT A SMOKE TEST, AND IT RUNS
FIRST.** A campaign whose N=1 column and whose flapping column are 0.0 s
everywhere is indistinguishable from a campaign whose detector was never wired
up — `sitesim.broadcast_onset` exists for the same reason. So the selftest
drives a genuinely **wedged** unit through the same loop and refuses to proceed
unless the shipped `delinquent()` names it for longer than the shipped hold
(it names it for 275.0 s). Every 0.0 s in §3.3 is only worth reading because
that arm passes.
What it does, unchanged from the restatement it replaces: build two
`plantsim.Inverter`s from `INVERTER_A` / `INVERTER_B`; close the loop with the
add-on's own `compute()` on the SITE sum (`Controller.site_batt_w`) and
`arbiter.allocate_record()` over per-unit envelopes of `±min(max_w, rating)`;
put each share on its own wire through `Inverter.command()`, honouring a
configurable **write stagger** so B's number can land up to four cycles after
A's (the serialised write path); advance `Site.settle()` one control cycle; and
record, per unit, the command standing *before* the write and that unit's 0x0518
power. Then grade each cycle with the **shipped** `delivery.delinquent()` and
take the **longest run of consecutive cycles** on which it names anybody — not a
count of naming samples, because the window is cleared by any cycle that does
not.
⚠️ **SINCE [[SAFETY-43]] THE ROWS OF §3.3's TABLE ARE GRADED WITH
`share_frac=None`** — `delinquent()`'s ABSOLUTE arm alone, which is this card's
rule exactly. Said here because it is the difference between §3.3 still meaning
what it is labelled as and quietly becoming a table about a different predicate:
SAFETY-43 added a SECOND way to be named, the harness grades it in its own rows
(`--fractions`), and the numbers below are unchanged and were re-measured to
confirm it. `DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` §3 carries
those rows and what they cost — the worst legitimate episode goes from 35.0 s to
40.0 s at the shipped fraction, which does not move `delivery_hold_s`.
⚠️ **ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING.** "The
command standing before the write" is the **controller's** standing command —
`Inverter.standing_w()`, i.e. `last_write` — and **not** the far end of the
wire. A write that has been issued but has not landed yet is standing as far as
this add-on is concerned, which is the whole of the write-stagger axis. Grading
against the plant's latch instead reads a number no add-on has, and it makes
both the stagger *and a wedged unit* invisible: a unit whose writes never arrive
shows a standing command of 0 W, is never `asked`, and is never named. That is
not a conservative harness, it is a harness that cannot fail — and the rebuild
did exactly this on its first run, which is how the falsifiability arm earned
its place above.
⚠️ MUTATION.md's note applies: this harness writes `__pycache__` into **both**
trees. Run it with `python3 -B` and purge.
## 2. The card's discriminator, measured — and it does not work
### 2.1 It fires on the benign case, and no (N, X) can stop it
`Plant.limit()` returns 0 W for a discharge command into a pack at its own
floor. A wedged unit that never receives its command also produces 0 W. The two
are not merely similar:
> **Load 2000 W, equal weights, 2000 W per-unit cap, 120 control cycles.
> The two arms are IDENTICAL on all 120 cycles** in (grid, site target, each
> unit's share, each unit's measured power), to the last emitted watt. Both
> report `reason == "saturated-freeze"` on **116 of the 120** cycles (the other
> four are `slew-limited`, before it engages on cycle 4), both hold |grid error|
> over 300 W on **all 120** and settle at **1200 W**, and `circulating_w` is
> **0.0 W** in both.
A predicate whose inputs are *saturation duration* and *|grid error|* reads the
same number on both arms at every cycle. There is no N and no X. And *"a
legitimately saturated site — cold pack at its floor — does **not** alarm"* is
this card's own acceptance criterion.
`test_delivery.py` part 4 asserts that identity on the add-on's own end-to-end
fixture, so it goes red if it ever stops being true.
### 2.2 It is silent on the headline case, because a healthy sibling erases it
The standing grid error is exactly the thing a working sibling removes.
> **Load 800 W, equal weights, 2000 W per-unit cap, B wedged, 200 cycles.**
> Over the last 30 cycles the loop is saturation-frozen on **30 of 30** — so
> "saturation persisting past N" is true for every N — while the worst
> **|grid error| in that window is 30 W**. B is standing at a **+770 W**
> discharge share and delivering **0 W**.
Any X above 30 W leaves the card's predicate silent for the whole run, on the
exact fault it was written for. The card's own sentence — *"a healthy sibling
regulating alone, the site short by up to one unit's rating"* — describes the
case, and the grid error is the one number that cannot report it.
### 2.3 What that leaves
The derivation instruction stands and is honoured in §3. What could not be
derived was a threshold on the wrong quantity. Both failures are written into
`delivery.py`'s docstring as well, because the next person to read the card will
propose the same predicate.
## 3. The quantity that does work
Per unit, for a share that asks it to **discharge**:
```
short_by(i) = share_i − measured_i # watts that did not arrive
```
and the reduction that makes it readable:
> a unit is **delinquent** while it is short by more than `saturation_w` with
> **no account for it**, and the set of units in that state is a **proper
> subset** of the units that were asked to discharge.
### 3.1 The account — the only thing that separates a wedged unit from a cold pack
§2.1 says there is nothing in the power domain. There is exactly one non-power
fact this add-on holds about a pack: **its own state of charge against its own
`bms_soc_floor`**, which SAFETY-32 made a required per-entry key. `account()`
asks it through **`peakguard.discharge_permitted()`** — public since SAFETY-32
precisely so that this question has one answer — and writes its own sentence,
because that function's wording names the *peak guard's* floor and would be a
false statement here.
Three things account for a shortfall, and each is a state already being handled:
| account | why it is not this fault |
|---|---|
| the unit is not live | `live_units()` has already dropped it from the split and the action log says so — firing inside that is the SAFETY-15/19 failure |
| the unit is parked by a sign trip | it is commanded 0 W on purpose; not delivering is compliance |
| `discharge_permitted(soc, bms_soc_floor)` | the pack is at or below its own cut-out and physically cannot |
⚠️ **The floor passed in is `bms_soc_floor` alone, NOT
`peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor`.** That
conjunction is right for the peak guard and wrong here: `peak_soc_floor` bounds
a money-saving discharge only (SAFETY-25), ordinary self-consumption discharges
straight past it, and using it would **explain away** a wedged unit sitting
anywhere between the two floors.
⚠️ **Every unknown accounts for the shortfall**, i.e. fails toward silence —
the opposite direction to `discharge_permitted()`'s own caller, and the right
one here: there it withholds a discharge, here it withholds an **alarm**. The
cost is bounded, because the same absence stops the site through `inputs_ok`
(an unreadable SoC) or is refused by the roster schema (an unstated floor).
### 3.2 The proper subset — what only exists at N ≥ 2
A derate this add-on cannot see — thermal, grid-code, a DC-side restriction —
hits **every** unit that was asked. One box that has stopped listening does not.
So if every asked unit is short and unexplained, this module says **nothing**:
it has no instrument that tells that from a plant which simply cannot deliver,
and reporting it anyway would be the saturation-shaped alarm produced by the
guard written to replace it.
**At N = 1 the subset is never proper, for every input**, so the check is inert
there — not by a roster guard, but by the algebra, the same way
`circulation.circulating_w()` is identically zero at N ≤ 1. That is AC 5, and
§3.3's control arm measures it rather than asserting it.
### 3.3 The window — the one number this card chose
**2640 closed-loop runs at a roster of two in which NOTHING is faulty** — every
unit executes the share the allocator gave it — over 6 gains × 5 slews, the
whole range `config.yaml` accepts for `gain` `float(0.05,1.0)` and `slew_w`
`int(0,5000)` (0 = follow the rail), across three families:
* **1050 load reversals** — 7 sizes (±300 W … ±5000 W) × 5 write staggers;
* **840 flapping loads** — 7 periods (10 s … 600 s) × 4 amplitudes;
* **750 weighted reversals** — 5 splits (0.5, 0.7, 0.9, 0.99, 0.1) × 5 staggers
on a ±2200 W reversal. ⚠️ **This family did not exist in SAFETY-30's campaign
and it is the one that dominates here**, for a mechanical reason: an
asymmetric split gives one unit a much larger step than its sibling, so the
big one is still ramping while the small one has settled — which is precisely
the *proper subset* this check keys on.
Every run is executed a second time at a **roster of one** on the same stimulus.
**The N=1 arm is the control**, and it is what separates *"a property of the
check"* from *"a property of the second inverter"*.
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
threshold. **ORIGINAL CAMPAIGN, 2026-09-06, from the harness that was not
committed — SUPERSEDED, and kept here because a record that quietly replaced its
own numbers would be worth less than one that shows both:**
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|---:|---:|---:|---:|---:|
| 100 W | 40.0 s (608) | 0.0 s (0) | 75.0 s (576) | **0.0 s (0)** |
| 250 W | 55.0 s (490) | 0.0 s (0) | 60.0 s (480) | **0.0 s (0)** |
| **500 W** | **20.0 s (206)** | **0.0 s (0)** | **30.0 s (368)** | **0.0 s (0)** |
| 700 W | 20.0 s (125) | 0.0 s (0) | 20.0 s (174) | **0.0 s (0)** |
| 1000 W | 15.0 s (70) | 0.0 s (0) | 15.0 s (111) | **0.0 s (0)** |
| 1500 W | 10.0 s (51) | 0.0 s (0) | 10.0 s (50) | **0.0 s (0)** |
| 2000 W | 10.0 s (24) | 0.0 s (0) | 5.0 s (23) | **0.0 s (0)** |
#### ⚠️ TEST-21, 2026-09-07 — THE RE-RUN, AND 30.0 s DID NOT REPRODUCE
`admin/goodwe:sim/delivery_window.py --campaign`, same three families, same
counts, same thresholds, the shipped `delinquent()`:
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640 runs)** |
|---:|---:|---:|---:|---:|
| 100 W | 120.0 s (770) | 0.0 s (0) | 165.0 s (685) | **0.0 s (0)** |
| 250 W | 65.0 s (687) | 0.0 s (0) | 100.0 s (590) | **0.0 s (0)** |
| **500 W** | **30.0 s (460)** | **0.0 s (0)** | **35.0 s (403)** | **0.0 s (0)** |
| 700 W | 25.0 s (342) | 0.0 s (0) | 30.0 s (318) | **0.0 s (0)** |
| 1000 W | 15.0 s (4) | 0.0 s (0) | 25.0 s (131) | **0.0 s (0)** |
| 1500 W | 0.0 s (0) | 0.0 s (0) | 20.0 s (43) | **0.0 s (0)** |
| 2000 W | 0.0 s (0) | 0.0 s (0) | 0.0 s (0) | **0.0 s (0)** |
**The floor is 35.0 s, not 30.0 s** — weighted family, and the worst single run
is `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W reversal. The
argmax is printed with the table on purpose (see below): a worst case sitting on
an edge of the grid would be a number about the grid.
⚠️ **AND BY THAT STANDARD, TWO OF ITS FOUR AXES ARE ON AN EDGE. Measured, at the
argmax's other axes held fixed:**
stagger 0 -> 1 -> 2 -> 3 -> 4 0.0 15.0 25.0 30.0 35.0 s
weights (A) at stagger 4 0.5 -> 20.0 0.7/0.9/0.99 -> 0.0
0.10 -> 35.0 s
Strictly increasing in stagger and maximal at 4, the largest modelled stagger;
and maximal at the most asymmetric split. It is interior only in `gain` (2nd of
6) and `slew_w` (3rd of 5).
⚠️ **SO THE HEADLINE IS 0.0 s AT stagger=0, AND 35.0 s IS ENTIRELY A PRODUCT OF
THE MODELLED SERIALISED-WRITE STAGGER.** That stagger is declared MODELLED at
`sim/delivery_window.py:60-64` — it is a term of this figure, not a detail of
it. A site whose two writes land in the same cycle does not produce this episode
at all.
This does not move `delivery_hold_s`: 185 s must cover the worst case the
modelled site can produce, and a stagger of four cycles is inside what a real
two-inverter write path can do. But a reader entitled to ask "is 35.0 s about
the plant or about the grid?" deserves the answer, and the answer is: **partly
about the grid, and the grid's shape is the record's own, fixed by §3.3 before
this rebuild ran.** Found in review of TEST-21; the PR body had claimed the
argmax was interior, and it is not.
**WHAT REPRODUCED, EXACTLY:**
* the **N=1 control column is 0.0 s at every threshold**, in every run. That is
§3.2's algebra measured, and it is the claim the compatibility promise rests
on. (This rebuild ran the control arm over all three families — 2640 runs —
where the original ran 1890, i.e. the two families whose stimulus is not
weighted. The weighted stimulus at a roster of one is the same stimulus with
inert weights, and it too reports nothing at any threshold.)
* the **flapping family is 0.0 s at every threshold**, in all 840 runs. The
proper-subset rule really does defeat the family that defeated SAFETY-30's
site window.
* **45.0 s at twice A's lag**, to the cycle. See the B-lag table below.
**WHAT DID NOT:**
* the headline. **35.0 s against a recorded 30.0 s** — one control cycle worse,
and 20.0 s → 30.0 s on the reversal family, two cycles worse.
* **the non-monotonicity.** §3.3 finding 3 read the original 55.0 s at 250 W
against 40.0 s at 100 W as a property of the reduction. **This rebuild's
curve is monotone decreasing on both N=2 families.** The *mechanism* finding 3
describes is real and is still the reason not to read either table as a
sensitivity curve — lowering the threshold really can turn a proper subset
into the whole roster and break the run — but **this grid does not exhibit
it**, so the claim now rests on the mechanism alone and no longer on a
measurement. That is a weaker claim than the one this record made, and it is
written down as weaker.
* the low-threshold rows, badly: 120.0 s / 165.0 s at 100 W against 40.0 s /
75.0 s. Nothing ships at 100 W, but the size of that gap is the honest measure
of how much of the original table was a property of axes nobody wrote down.
⚠️ **WHY THE NUMBERS MOVED, AS FAR AS IT CAN BE ESTABLISHED.** §3.3 fixes the
*counts* of every axis (6 gains × 5 slews; 7 reversal sizes; 7 flap periods × 4
amplitudes; 5 staggers; and the 5 splits, which it lists) and the *ranges*
(`config.yaml`'s `gain` `float(0.05,1.0)` and `slew_w` `int(0,5000)`). It does
**not** fix the individual values, the run length, or the per-unit cap. The
rebuild spread them over the admissible ranges and **prints every one of them
above its own table**, so the caveat travels with the number instead of living
in somebody's memory. The residual disagreement — one to two control cycles at
the shipped threshold — is inside the sensitivity to that choice; the
low-threshold rows are not, and are reported as not.
⚠️ **AND THE HARNESS WAS NOT ADJUSTED UNTIL IT AGREED.** The one substantive
correction made during the rebuild was made *against* the doc's number, not
towards it, and it is recorded in §1: grading on the plant's latch rather than
the controller's standing command produced a harness that could not name even a
wedged unit. Fixing that made the numbers **larger**. Nothing else was tuned.
Findings 1-3 below are the original campaign's, kept with finding 3 now
qualified by the re-run above:
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
threshold.** That is the compatibility promise measured, not argued.
2. **The flapping family is zero everywhere too** — and it is the family that
defeated SAFETY-30's site window (980 s at two inverters, 1000 s at one, no
window inside the billing quarter surviving it). It cannot touch this check,
because a common stimulus makes both units short at the same time and the
subset stops being proper. That is the proper-subset rule earning its place
on a case it was not designed for.
3. **The curve is NOT monotone** — 55.0 s at 250 W against 40.0 s at 100 W —
and that is a property of the reduction rather than noise: lowering the
threshold puts MORE units into the short set, which can turn a proper subset
into the whole roster and **break** the run. Both the numerator and the
denominator move. Do not read this table as a sensitivity curve.
#### The watts side is not a new option — it is `Tuning.saturation_w`
`main.py` hands the check `self.tuning.saturation_w` (500 W) and there is no
`delivery_w` key anywhere. That is a derivation, not a saving:
* it is already **the gap at which `compute()` itself concludes the inverter is
not following** and freezes the integrator. Below it the shipped control law
calls the divergence ordinary tracking, and an alarm under a threshold the
control law calls normal is an alarm about nothing;
* `saturation_cycles` (3) consecutive cycles over that same gap are needed
before the freeze engages, so **every cycle this counts is a cycle the freeze
has already seen**. This is a reduction over the freeze's input, not a second
opinion about it;
* CFG-03 already tracks three places a default has to be kept in step. A fourth
copy could drift, and the drift that matters has a direction: an operator who
raises `saturation_w` has told the loop that a bigger gap is ordinary
tracking, and a check that stayed put would then alarm on it.
**Rejected:** a `delivery_w` option, for the three reasons above. A margin above
`saturation_w` (the way `circulation_w` sits 1.75× above its knee) was also
rejected: there is no knee here — the table above is not monotone — so a margin
would be a preference, and all the margin this check needs is in the window.
#### `delivery_hold_s: 185` — schema `int(30,900)`
Four bounds, and the value clears all four.
* **lower, from the rig — 35.0 s** (TEST-21, 2026-09-07; **the original record
said 30.0 s and it did not reproduce**). The worst continuous legitimate
episode at the shipped threshold across all 2640 runs — weighted family, worst
single run `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W
reversal; 30.0 s on reversals, 0.0 s on flapping. Re-run it with
`admin/goodwe:sim/delivery_window.py --campaign` (§1).
⚠️ **THIS IS NOW STRICTLY THE BINDING LOWER BOUND, WHICH IT WAS NOT BEFORE.**
At 30.0 s it coincided with the firmware's veto below and a reader could not
tell which of the two was doing the work. At 35.0 s the rig's number is on its
own, above the firmware's — so **the rig is what stops `delivery_hold_s` going
low**, and the schema's `int(30,900)` floor no longer sits above the worst
legitimate episode. An operator who sets it to the schema minimum of 30 s is
now **below** a measured legitimate episode and will get false reports; the
option is marked *derived, do not change* and this is what that marking is
for. Raising the schema floor to 35 is a change to a shipped option's
contract and is left to its own card rather than smuggled in here.
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
executing its share for less than the firmware's own veto window is a *link*
fault the add-on already answers by dropping it from the split. Reporting
inside that window would be a second, louder alarm about a state already
being handled — the SAFETY-15/SAFETY-19 failure exactly.
* **margin, from inverter B not existing.** ⚠️ **THIS ROW IS A SENSITIVITY
STUDY ON A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, AND THE ORIGINAL
WORDING ("MEASURED, not felt") OVERSTATED IT.** Inverter B is not installed
(~2026-09-12; NOTES.md §1.1b). The campaign's B is **A copied**, `measured=
False`, so the 35.0 s above is **two identical machines** and every row below
is a *what-if*: what a slower second unit would cost this window. **No row
here is B's lag.** What it is genuinely evidence for is the SHAPE of the
margin — which term the window has to be sized against — and that is what it
is used for. Sweeping a *different* second unit through the weighted family
(750 runs each). Second column is the original record, third is TEST-21's
re-run with the committed harness (`--blag`):
| B lag | B ramp | 2026-09-06 (uncommitted) | **TEST-21 re-run** | runs naming |
|---:|---:|---:|---:|---:|
| 10.0 s (A) | 800 W/s (A) | 30.0 s | **35.0 s** | 403/750 |
| 20.0 s | 800 W/s | 45.0 s | **45.0 s** | 454/750 |
| 10.0 s | 400 W/s | 30.0 s | **35.0 s** | 411/750 |
| 20.0 s | 400 W/s | 45.0 s | **50.0 s** | 454/750 |
| 40.0 s | 200 W/s | 90.0 s | **75.0 s** | 478/750 |
**The LAG moves it roughly linearly and the RAMP barely moves it**, which is
the original finding and it survives — with one correction: *"the RAMP does
not move it at all"* is too strong. Halving the ramp is worth 0 s at A's lag
and **+5 s at twice it** (45.0 → 50.0), so the ramp is a second-order term
rather than no term. The shortfall lasts as long as the unit's first-order lag
holds it more than `saturation_w` below its share; at these step sizes the
ramp is rarely the binding one. So the number to carry margin against is still
B's lag — and a hypothetical unit **four times slower than A** produces
**75.0 s**, where the original record said 90.0 s. ⚠️ Note the direction: this
row moved **in the margin's favour**, while the floor above moved against it.
Neither was adjusted to suit the other.
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
**185 s** is thirty-seven control cycles. Against the TEST-21 re-run it is
**5.29×** the measured worst legitimate episode (35.0 s), **2.47×** the
hypothetical four-times-slower second unit (75.0 s), **6.2×** the firmware veto
(30 s) and **4.9×** inside the quarter (900 s). ⚠️ Deliberately **not 180** —
that is SAFETY-02's site hold, and a reader must not be able to think this was
carried across — and not 150 or 200.
⚠️ **185 STANDS, AND HERE IS THE ARITHMETIC RATHER THAN THE ASSURANCE.** The
record claimed 6.2× the measured worst; the re-run makes that **5.29×**. All
four bounds still clear, none of them narrowly, and the *ordering* of the
argument is unchanged: the rig's floor is the binding one, the quarter is the
ceiling, and 185 sits between them with margin at both ends. So the value is
**not changed** — a shipped safety constant is not moved because its margin
went from 6.2× to 5.3× while every bound still holds, and moving it would
invalidate SAFETY-41's four-attempt false-trip regime for nothing. What IS
changed is the claim: this record no longer says 6.2×, and no longer says 30.0
s, because neither is what the harness produces.
**What it costs, stated rather than hidden:** up to 185 s of a site running
short by one unit's share before anybody is told. Against a fault that ran
6 h 25 m unnoticed, and against a guard that takes **no action**, the window
buys its margin with reporting latency and nothing else.
## 4. It reports. It does not act — and the strongest argument is for acting
Card requirement 3 asks which, and why. The answer is recorded with the
evidence **against** it first, because "alarm-only" is the weaker-looking answer.
**Command it harder — no.** HW-01 settles it: the power stage executed its last
instruction for 6 h 25 m through every command this add-on sent. A command is
not a remedy for a box that is not listening.
**Stop the site, as SAFETY-31 does — no**, and the asymmetry with SAFETY-31 is
exact. Circulating power is stopped because *this controller is one of the two
participants*: removing our half halves the loop and un-hides the residue at a
meter that was netting it to zero. Here this controller is **not** a participant,
nothing is netting, and the sibling is the only thing still delivering. Parking
it takes the site from "short by one unit" to "short by all of them".
**Re-allocate its share to the sibling — no, and NOT because the loop already
does it.** That comfortable answer is false, and the rig says so. Dropping the
wedged unit from `live` — the mechanism `live_units()` already uses for a dead
link — is a real improvement at loads the site cannot cover on one unit's
*share*:
| B wedged, per-unit cap 2000 W | kept in the split | dropped from the split |
|---|---|---|
| load 800 W | settles inside 100 W from cycle 6 | from cycle 8 |
| load 1500 W | **800 W delivered, 700 W error, NEVER inside 100 W in 150 cycles** | 1500 W delivered, inside 100 W from cycle 14 |
| load 2000 W | **800 W delivered, 1200 W error, never settles** | 2000 W delivered, inside 100 W from cycle 8 |
The mechanism is the saturation freeze doing its job on a site comparison that a
non-delivering unit has poisoned: `|site target − site batt|` stays over
`saturation_w` forever, so the target cannot rise and the sibling's *share*
never grows. That is not three cycles of latency; it is the difference between a
site that recovers and one that does not.
**What refuses it anyway** is that the only re-allocation this add-on can
express is **all or nothing**. There is no way to say *"give the sibling the
watts that did not arrive and leave this unit the ones that did"*. And a unit
delivering **part** of its share is short by more than `saturation_w` too, with
nothing here to explain it — so this detector names it, and cannot tell a 50 %
derate from a wedged box, because no instrument this add-on has can:
| B derated to 50 %, load 3000 W, cap 2000 W | site delivers | B contributes | grid error |
|---|---:|---:|---:|
| kept in the split | 2400 W | 800 W | 600 W |
| **dropped** | **2000 W** | **0 W** | **1000 W — 400 W worse** |
**SAFETY-42 names B in that run.** So the remedy would make the site worse, in a
case the detector cannot rule out, on a live control law, driven by a brand-new
alarm. A **partial** re-allocation is what would make acting safe, and the
allocator has no way to express it today. That is a card, and this section is
its evidence.
`test_delivery.py` part 5 asserts the decision as a **counterfactual** rather
than by reading the code: the same fixture run with the report tripping and with
it never tripping produces byte-identical grid, site target, shares and measured
power on every cycle.
## 5. Requirement 4: the saturation freeze is untouched
`control.py` is not modified by this card, at all. The freeze still shapes the
loop; this reports on it. `test_delivery.py` part 6 asserts what each
neighbouring guard concludes on the same fixture — SAFETY-30 per-unit silent,
SAFETY-30 site silent, SAFETY-31 identically zero, the freeze firing at the
same counter on **both** arms — which is the honest version of "every existing
guard is silent", including the one that is not.
## 6. Surviving a restart (SAFETY-34)
`persistent_notification` is **in memory**. Home Assistant restarts, the card is
gone, and the fault runs on — precisely how the 2026-09-02 outage went unseen
for 6 h 25 m with the detector correct throughout. It matters more here than
anywhere else in this add-on, because SAFETY-02 and SAFETY-31 at least leave a
**stopped** site for somebody to notice; this one leaves a site that looks like
it is working.
* durable half: `unit/<name>/delivery_tripped` / `delivery_reason`, namespaced
per unit for `store.unit_key()`'s reason, written **reason first**.
* visible half: `_delivery_announced`, a per-**process** set of unit names,
deliberately not persisted. A restart empties the set and keeps the latch, so
the alarm is re-raised on the first cycle of every process — **one code path**
for a fresh trip and for a restart into an existing one, `check_circulating()`'s
argument.
* **no flat mirror**, where SAFETY-02 has one. `store.py`'s rule 2 keeps the flat
keys so a rollback still finds its latch, and the version being rolled back to
drives exactly one actuator — a roster at which this check is inert.
* one **notification id per unit**, built from the same `unit_slugs()` TEL-07
announces the MQTT entities under. Two wedged inverters must produce two cards,
not one replacing the other.
* it **latches**, and not for SAFETY-02's reason. Nothing here removes its own
evidence, because nothing here changes what is commanded. It latches so that a
unit which stopped delivering for minutes and then resumed cannot erase its own
record — the intermittent fault is the one an operator most needs told about.
Cleared by the same **Start** press as the others.
* the status row is `ok: False, warn: True` — this list's **caution**. Every
other latched row means the site is at 0 W and a red cross is the truth; this
one means the site is still running and quietly short, and a red cross would
say the controller had stopped.
## 7. What this does NOT catch, said plainly
* **Charge shares are not graded.** The account gate needs a bound this
controller reads in the commanded direction. There is one for discharge
(`bms_soc_floor`, required of every roster entry since SAFETY-32) and **none**
for charge. A pack in constant-voltage taper legitimately takes hundreds of
watts less than it is told for tens of minutes, and arming that direction would
fire on the taper of every §12 maintenance charge. The same fault in the other
direction wants the same treatment; what it needs first is a per-pack charge
ceiling this add-on does not have. **That is a card.**
* **Every unit wedged at once is not caught.** The subset is not proper, and
there is no instrument here that separates that from a plant which simply
cannot deliver. The site is short at the meter in that case, which is where the
existing guards can see it.
* **A pack that refuses above its configured floor is reported.** That is
deliberate: either the unit has stopped delivering, or `bms_soc_floor` does not
describe that battery. The notification says both, in those words.
* **A unit with a share of 0 W is not graded**, and neither is it a peer. With
only one unit asked to discharge there is nothing to compare against.
## 8. Mutation ledger
Every run on a **freshly copied tree of its own, `__pycache__` purged and the
count printed, mounted `:ro`, `python3 -B`** — MUTATION.md's rule, followed
because the first SAFETY-30 battery reused one directory, a container mounted it
mid-copy, read a missing `config.yaml` as a failing check, and the whole run was
discarded. **The baseline is green** (23/23) or nothing below is a result. Byte
deltas measured, not estimated; the `+0 B` ones are marked, since a
size-preserving edit is what TEST-15 turned into a false survivor once already.
| # | file | mutation | Δ | result |
|---|---|---|---|---|
| M1 | `delivery.py` | the SoC account removed — a cold pack at its floor alarms | −31 B | **RED** — `test_delivery.py`. This is the card |
| M2 | `delivery.py` | the proper-subset rule removed — a whole-site cause names every unit | −34 B | **RED** |
| **M3** | `delivery.py` | **the `len(asked) < 2` fast path relaxed to `< 1`** | **+0 B** | **SURVIVOR — and an EQUIVALENT MUTANT.** See below |
| M4 | `delivery.py` | the strict bound relaxed to `>=` — a gap the control law calls tracking is named | +1 B | **RED** |
| **M5** | `delivery.py` | `share <= 0` relaxed to `share == 0` — charge shares graded | **+0 B** | **SURVIVOR on the first battery — GAP CLOSED**, then **RED**. See below |
| M6 | `delivery.py` | `delivery_hold_s` 185 → a round 300 | **+0 B** | **RED** — the CFG-03 pin |
| M7 | `main.py` | the watts threshold no longer `Tuning.saturation_w` (×4) | +6 B | **RED** |
| M8 | `main.py` | the latch never persisted — nothing survives a restart | −35 B | **RED** |
| M9 | `main.py` | `_delivery_announced` never set — one notification per cycle | −34 B | **RED** |
| M10 | `main.py` | `peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor` used instead of the pack's own cut-out | +151 B | **RED** — the between-the-floors fixture |
| **M11** | `main.py` | **`inv.is_live(now)[0]` replaced by a literal `True`** | −15 B | **SURVIVOR at review — GAP CLOSED**, then **RED**. See below |
| M12 | `inverter.py` | the latch not restored from the store on start-up | −46 B | **RED** |
**11 of 12 red. One survivor, argued rather than left.**
### M5 was a real gap, and the two obvious checks did not close it
`test_delivery.py` already asserted `short_by(-2000, 0) is None` and
`short_by(-2000, -100) is None` — and **both stayed green under the mutant**.
For a charge share that is *under*-delivered, `share − measured` is negative and
the `gap > 0` test answers `None` anyway, so those two inputs cannot separate
`share <= 0` from `share == 0`. The one input that can is a unit moving **more**
than its charge share — `-2000 W` told, `-3000 W` measured, `share − measured` =
`+1000 W` — which the mutant reports as a shortfall. It is not one; it is
something else driving that pack, which is SAFETY-30's and SAFETY-31's question.
The check now uses that input and the mutant is red.
This is MUTATION.md's third failure mode in miniature: **the assertion ran, the
sentence was true, and the data could not reach the failure it named.**
### M11 was a wiring gap the pure checks structurally could not see
`account(live=False, …)` was already graded — as a **function**. What was not
graded was that `cycle()` actually *asks* `inv.is_live(now)`, and replacing that
call with a literal `True` left every check green, because **no fixture had a
unit whose writes were being rejected**. A dropped unit still has its last
*accepted* command standing on the wire (`write_setpoint` leaves `last_write`
alone on a rejected write) and still measures nothing, so it looks exactly like a
wedged one to every number this check reads — and reporting it would be the
second, louder alarm about a state already being handled that this module takes
its 30 s floor from. `test_delivery.py` part 6 now builds that unit (with
`rejected_since` back-dated past `LINK_STALE_S`, because the fixture runs in two
seconds and the timer is `test_inverter.py`'s to grade) and the mutant is red.
### M3 is recorded as a survivor because it is one — and it is EQUIVALENT
`len(asked) < 2` → `len(asked) < 1`. With **one** asked unit, `unaccounted` is a
subset of a one-element set, so it is either empty — `not unaccounted` returns
`{}` — or equal to `asked` — `len(unaccounted) == len(asked)` returns `{}`.
There is no third case, and with **zero** asked units both spellings return `{}`
as well. **No input separates them**, so no check can, and one written to try
would be a check about nothing.
⚠️ **And finding it corrected the module's own docstring, which is the more
useful half.** That paragraph claimed `len(asked) < 2` was *what makes N=1
inert*. It is not: the **proper-subset rule** is, and the line is a fast path and
a signpost. A false attribution of the mechanism in the file's own prose is
exactly what this repository keeps finding, and a mutation that survives is how
it surfaced. The line stays — it tells a reader at a glance that a one-inverter
install never reaches any of this — with the equivalence written down beside it,
the way `circulation.circulating_w()` records its own `max(0.0, …)`.
### Not separately mutated, and said rather than left implicit
`account()`'s `parked` branch is graded as a function (part 1) but its **wiring**
— `parked=inv.sign_wd.tripped` — is not mutated on its own. Part 6's reversed-unit
fixture exercises the path end to end and asserts the account it produces, which
is weaker than a mutation and is stated as such.
## 9. Round 2 — the thresholds this check reads, and which of them are pinned at a **changed** config
Everything in §8 mutated the *logic*. This section asks a narrower question that
§8 never asked, and it found two live survivors of the whole 23-file suite:
> for each number `main.py` hands this reduction, is the check still red when
> that number is replaced by a **literal equal to the value the fixtures use**?
That is not pedantry about style. Two of these numbers are **options**, and
`delivery.MIN_W_IS_SATURATION_W` and the `delivery_hold_s` block in `config.yaml`
both make a promise about them *in shipped, operator-readable prose*: there is no
fourth copy of a default to keep in step, and no way for the two to drift,
because an operator who raises `saturation_w` has told the loop that a bigger gap
is ordinary tracking. A literal at the call site keeps every fixture green and
removes exactly that promise — and the promise is why the design was chosen over
a fourth option key.
### The enumeration
| # | what the call site reads | mutated to | Δ | before round 2 | now |
|---|---|---|---|---|---|
| R5 | `self.tuning.saturation_w` | `250.0` | −19 B | **SURVIVED all 23 suites** | red — part 4b(a) |
| R6 | `bms_floor=inv.bms_soc_floor` | `10.0` | −13 B | **SURVIVED all 23 suites** | red — part 4b(b) |
| R7 | `arm(self.delivery_cfg)` | `arm(DeliveryConfig())` | −1 B | red | red — part 4 already ran at a hold of 1 s, not the shipped 185 s |
| R8 | `inv.is_live(now)[0]` | `inv.is_live(now, 3600.0)[0]` | +8 B | red | red — part 6's link-dead fixture |
| R9 | `bms_floor=inv.bms_soc_floor` | the **conjoined** floor, `max(pack cut-out, site peak_soc_floor)` | +105 B | red | red — and now red **twice**: the pre-existing between-floors check at shipped floors, and part 4b(b)'s shallow arm at a cut-out that is not shipped |
**R7 and R8 were already pinned across a changed setting, and R5 and R6 were
not.** The difference is entirely in the fixtures, not in the code:
`delivery_hold_s` had to be moved off 185 s for any of this file to run inside a
two-second test, and part 6 had to back-date `rejected_since` past
`LINK_STALE_S` to build a dropped unit at all. Nothing ever forced
`saturation_w` off 500 or a `bms_soc_floor` off 10 — so those two sat at the
shipped value in every fixture, and a literal equal to the shipped value is
invisible to a fixture that only ever uses the shipped value. **That is this
repository's recurring defect stated in one line: a fixture whose values cannot
reach the failure it names.**
`LINK_STALE_S` (R8) is the one number here with **no option behind it** — it is a
module constant in `inverter.py` and no test reassigns it — so "pinned across a
changed config" is not available for it, and is not claimed. What part 6 pins is
that *widening* the window changes the answer. Spelling `is_live(now, 30.0)` at
the call site would be an equivalent mutant today and would only drift if the
constant moved, which is a source change and not a config change.
### Why R5 and R6 are not equivalent mutants — the inputs that separate them
Both are separating inputs found by asking where the shipped answer and the
literal's answer can differ, and both are configs the shipped schema accepts.
**R5, watts.** Two copies of inverter A, B wedged, `saturation_w: 1800` (inside
its own schema `int(100,2000)`), load 1600 W. B's standing share and its
shortfall then land strictly **between** `250.0` and the configured 1800 W:
* head → `named = {}` — the operator raised the threshold and this check moved
with them, which is the whole promise;
* R5 → `named = {inverter-b}` — an alarm at a gap the control law has been told
is ordinary tracking.
At the shipped `saturation_w: 500` **both** name it, which is why every fixture
in part 4 is blind: their gaps are all above both thresholds.
**R6, percent.** The literal is invisible because `bms_soc_floor: 10` is what
`config.yaml`'s own `inverters:` example states and what every fixture in
`test_delivery.py` used. Two arms, on either side of it, both inside the schema
`int(0,50)` and both `validate.py` rule-8-valid:
* a pack at **15 %** against its own **25 %** cut-out is genuinely cold. Head
stays silent; R6 grades it against 10 %, finds it "permitted", and **names an
empty pack** — the crying-wolf failure this card exists to remove.
* a pack at **8 %** against its own **5 %** cut-out is genuinely wedged. Head
names it; R6 grades it against 10 %, calls it a cold pack, and **loses the
fault**.
One arm on each side is what leaves no constant at all: a single deeper arm
kills `10.0` but not `50.0`, a single shallower arm kills `10.0` but not `0.0`.
The two arms are byte-identical in the power domain on every cycle — the
configured cut-out is the only thing that differs, which is §3.1's claim made
again at cut-outs that are not the shipped one.
### What is still not pinned this way, said rather than left implicit
`parked=inv.sign_wd.tripped` and `standing[inv.name]`/`inv.batt` are wiring, not
thresholds: there is no "value the fixture happens to use" for a boolean or for a
measurement, so this question does not apply to them, and §8's last note already
records that the `parked` wiring is graded end-to-end rather than by mutation.
@@ -1,381 +0,0 @@
# SAFETY-43 — delinquency measured against the ask, not against a flat 500 W
**Status: closed.** `delivery.delinquent()` names a unit whose `short_by()` gap
exceeds `min_w` **or** exceeds a fraction of that unit's own share. The fraction
is `delivery.DELIVERY_SHARE_FRACTION = 0.85`, derived on the campaign in §3.
⚠️ **INVERTER B DOES NOT PHYSICALLY EXIST** (expected ~2026-09-12; NOTES.md
§1.1b). `plantsim.INVERTER_B` is inverter A **copied**, `measured=False`, and
`test_packshare.py`'s plant is modelled in that file. **Every two-inverter
figure in this document is two copies of A.** Nothing here is a measurement of
inverter B.
## 0. The defect, and why it is the quiet regime that matters
[[OPT-09]] passes SoC- and capacity-aware weights to the allocator. A weight is
a function of a pack's **energy headroom**, not of what the pack delivers, so a
**wedged** unit — one that accepts its share and produces nothing — is weighted
down exactly like a legitimately low-headroom one. `delinquent()` read an
**absolute** gap, `min_w = Tuning.saturation_w = 500 W`. Shrinking the share
shrinks the gap. Measured end to end on the real loop (`test_packshare.py`
part 8b: real `compute()`, real `plan()`, real `packshare.weights()`, real
`delivery.account()`, real `delinquent()`; B wedged at a healthy 15 % against its
own 10 % cut-out, so no envelope closes and `account()` explains nothing — a
SAFETY-42 **true positive** by that card's own definition):
load 1500 W equal weights shares {a:1500, b:1500} named on 199/200
load 1500 W OPT-09 weights shares {a:1505, b: 125} named on 0/200
A unit delivering **0 of 125 W** is 100 % delinquent and 125 W short. Only the
absolute threshold disagreed.
⚠️ **THE SUPPRESSED REGIME IS THE DANGEROUS ONE.** At a load the healthy sibling
covers, the meter is satisfied (grid within 5 W of zero on that fixture),
`unmet_w` is 0 because both units *accepted* their shares, no envelope closes and
no action-log line fires. The delinquency alarm is the **only** instrument that
would have told the operator the site was quietly running one inverter.
## 1. What was NOT done, and it is the larger half of this card
⚠️ **`min_w` WAS NOT LOWERED.** It is `Tuning.saturation_w` — the gap at which
the control law itself concludes the inverter is not following — and it exists to
stop **normal saturation** being reported as delinquency. Lowering it trades a
missed alarm for a false one on every unit at once. [[SAFETY-41]] spent **four
attempts** on notification cadence in this system and the owner's words were
*"i'm getting really annoyed by the notifications in my HA"*.
So the change is an **`or`**, never a looser first arm:
named if short_by() > min_w OR short_by() > frac * share
Everything `min_w` named before is still named. Nothing `min_w` refused becomes
noisy **unless the unit is delivering almost none of its ask**, which is not
saturation-shaped at all. `test_delivery.py` part 1 runs all seven of its
existing `delinquent()` fixtures under **both** rules — `share_frac=None` is
SAFETY-42's rule exactly — and requires identical answers on every one.
The absolute arm's suppression window is therefore **unchanged**: still
`site target < 2·min_w` = 1000 W at equal weights, still
`site target < min_w·(w_A+w_B)/w_B` = 6500 W under OPT-09's weights on part 8b's
fixture. Part 8b still asserts both numbers, now as evidence that the first arm
did not move.
## 2. The evidence is a diff between two rules on ONE run
`delinquent()` takes `share_frac`, and **`None` means the absolute arm alone**.
That parameter exists for one reason: so the red and the green come out of one
run over one tree instead of out of two commits that a reader has to trust.
`test_packshare.py` part 8b grades every cycle twice:
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
load 1500 W equal weights absolute arm alone named on 199/200 cycles
load 1500 W equal weights the shipped rule named on 200/200 cycles
The equal-weights arm gains **exactly one cycle** — the first, where the slew
limit holds the split at 500/500, a 500 W gap is not *over* a 500 W threshold
(the absolute bound is strict) and the relative arm names a box delivering none
of its 500 W. That single cycle is the whole of what this card costs an arm that
was already firing.
⚠️ **THE PIN THAT INVERTED.** Part 8b was written by OPT-09 to pin the
**suppressed** behaviour, and its comment said: *"if it ever goes red because the
alarm fired, read the card that made it fire before 'fixing' this line."* This is
that card. The pin now asserts the opposite answer and keeps the old one beside
it, measured rather than remembered.
## 3. ⭐ Deriving the fraction
⚠️ **DERIVED ON THE SAME CAMPAIGN AS `delivery_hold_s`'s FLOOR**, not chosen.
`admin/goodwe:sim/delivery_window.py` — the harness [[TEST-21]] committed —
gained a second grading axis: the same **2640 no-fault runs at N=2** (three
families × 6 gains × 5 slews × write staggers; every unit executes the share the
allocator gave it) and the same **2640 N=1 control runs**, graded at the shipped
`min_w = 500 W` for each candidate fraction, on every cycle of every run — so a
pass grades every fraction it is given, and the absolute arm beside them, over
one tree and one set of runs. Every row in a pass is comparable to every other
row in it, run for run.
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
python3 -B delivery_window.py --selftest --campaign --fractions
⚠️ **THE TABLE BELOW WAS PRODUCED BY TWO PASSES; `FRACTIONS` NOW HOLDS ALL
TWENTY SO IT TAKES ONE.** The derivation ran the shipped ten
`(0.25 … 0.99)` and then the ten that resolve the low end
`(0.05 … 0.65)` with only that tuple edited between them. **The absolute-arm
control row came out identical in both** (30.0 / 0.0 / 35.0 / 0.0, same argmax),
which is what says the two passes are one campaign. A table a reviewer cannot
reproduce without knowing to edit a line nobody wrote down is PROC-01's shape in
miniature, so the committed `FRACTIONS` was widened to all twenty in review: the
command above now reproduces every row below.
### 3.1 The curve
Worst **continuous** legitimate episode (the measure `DeliveryWatchdog` actually
sees — its window is cleared by any cycle that does not name), runs naming in
brackets:
| `share_frac` | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640)** |
|---:|---:|---:|---:|---:|
| absolute only | 30.0 s (460) | 0.0 s (0) | 35.0 s (403) | **0.0 s (0)** |
| 0.05 | 85.0 s (826) | 0.0 s (0) | 90.0 s (616) | **0.0 s (0)** |
| 0.10 | 75.0 s (824) | 0.0 s (0) | 90.0 s (607) | **0.0 s (0)** |
| 0.15 | 70.0 s (836) | 0.0 s (0) | 70.0 s (609) | **0.0 s (0)** |
| 0.20 | 70.0 s (839) | 0.0 s (0) | 60.0 s (629) | **0.0 s (0)** |
| 0.25 | 55.0 s (832) | 0.0 s (0) | 60.0 s (611) | **0.0 s (0)** |
| 0.30 | 55.0 s (840) | 0.0 s (0) | 50.0 s (614) | **0.0 s (0)** |
| 0.35 | 55.0 s (840) | 0.0 s (0) | 55.0 s (618) | **0.0 s (0)** |
| 0.40 | 50.0 s (840) | 0.0 s (0) | 60.0 s (658) | **0.0 s (0)** |
| 0.45 | 45.0 s (840) | 0.0 s (0) | 45.0 s (627) | **0.0 s (0)** |
| 0.50 | 45.0 s (840) | 0.0 s (0) | 45.0 s (636) | **0.0 s (0)** |
| 0.55 | 40.0 s (840) | 0.0 s (0) | 40.0 s (636) | **0.0 s (0)** |
| 0.60 | 40.0 s (840) | 0.0 s (0) | 40.0 s (645) | **0.0 s (0)** |
| 0.65 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
| 0.70 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
| 0.75 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
| 0.80 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
| **0.85** | **35.0 s (840)** | **0.0 s (0)** | **40.0 s (635)** | **0.0 s (0)** |
| 0.90 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
| 0.95 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
| 0.99 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
Worst of the three families, which is what `delivery_hold_s` has to cover:
frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
⚠️ **THE FIRST ROW IS THE CONTROL AND IT REPRODUCED SAFETY-42 §3.3 EXACTLY** —
30.0 s / 0.0 s / 35.0 s, and the same argmax (`gain=0.2 slew_w=1000 stagger=4
split=0.10/0.90`, ±2200 W reversal). It is measured **in the same run** as every
row below it, which is what makes those rows a comparison rather than a claim.
### 3.2 What the curve says
* ⚠️ **THERE IS NO CLIFF ANYWHERE IN THE CURVE, AND THAT IS THE FINDING.** From
**0.45 to 0.99** every figure is 40.0 or 45.0 s — within **one control cycle
(5.0 s)** of every other — and below 0.45 it rises **gradually**: 60.0 s at
0.25, 70.0 s at 0.15, 90.0 s at 0.05. A reviewer asking "why 0.85 and not
0.90?" is asking about a difference this campaign cannot resolve, and the
honest answer is that it cannot.
* ⚠️ **IT IS NOT MONOTONE** — 0.40 reports 60.0 s where 0.35 reports 55.0, and
0.65–0.80 report 45.0 s where 0.55/0.60 and 0.85–0.99 report 40.0 — and that is
SAFETY-42 §3.3's own mechanism (c) rather than noise in the plant: **a more
sensitive rule puts MORE units into the short set, which can turn a proper
subset into the WHOLE roster and END the episode.** The reduction is not
monotone in its own threshold. Do not read this column as a sensitivity curve
in the ordinary sense.
* **The shoulder is at about 0.45**, and it is a shoulder rather than an edge:
the reversal family's naming count saturates at 840 of 1050 runs from 0.30
upward, so below the shoulder what grows is the LENGTH of an episode, not the
number of runs that have one.
* **The N=1 column is 0.0 s at every fraction, in all 2640 one-inverter runs.**
The compatibility promise is measured for the new arm, not argued.
* **The flapping family is 0.0 s at every fraction, in all 840 runs.** A common
stimulus makes both units short at once and the subset stops being proper —
the property that defeated SAFETY-30's site window still holds.
### 3.3 ⚠️ "Zero false namings" is not available at ANY fraction
The card asked for *the largest fraction that yields zero false namings across
the whole no-fault campaign*. **There is none.** Every fraction adds naming
cycles: the reversal family goes from 460 runs naming to 840, the weighted family
from 403 to 635. A relative arm that never fired on a legitimate transient would
be a relative arm that never fired at all — the write stagger lands B's number up
to four cycles late, and a unit whose new command has not arrived is, for those
cycles, delivering a small fraction of what is standing on its wire.
What **is** zero at every fraction is **false alarms**:
> the longest continuous legitimate episode anywhere in the 20-point sweep is
> **90.0 s** — at 0.05, the most sensitive fraction measured — against a
> `delivery_hold_s` of **185 s**. Nothing in 2640 no-fault runs comes within
> **95 s** of notifying an operator, **at any fraction measured**. At the
> shipped 0.85 it is 40.0 s, i.e. 145 s of slack.
That is the quantity SAFETY-41 cares about and it is the one this card had to
protect. Stated as margin, at the shipped fraction: **185 s is 4.62×** the worst
legitimate episode, where the absolute arm alone gave 5.29×.
### 3.4 The bounds 0.85 answers to
* **LOWER, from the campaign** — the shoulder at about **0.45**, below which the
added episode starts to grow (60.0 s at 0.25, 90.0 s at 0.05). 0.85 is 0.40
above it.
* **UPPER, from the card's own case** — strictly **below 1.0**. At 1.0 the arm
can only fire on a unit that is *importing* while asked to discharge, which is
SAFETY-30's fault and not this one, so a unit delivering **none** of its share
(gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
wedged box reporting a small standby figure instead of a clean zero.
* **UPPER, from the hold** — every fraction in the flat range leaves
`delivery_hold_s` between **4.11×** (45.0 s) and **4.62×** (40.0 s) the worst
legitimate episode, well inside the four bounds SAFETY-42 §3.3 requires of it.
**`delivery_hold_s` IS NOT CHANGED BY THIS CARD.**
* ⚠️ **NOT DERIVED, AND SAID SO: the exact value inside [0.45, 1.0).** The
campaign cannot separate them. 0.85 is the midpoint of the argument above —
0.40 of margin above the shoulder, 0.15 below the ceiling — and a
re-derivation that moved it to 0.90 would be moving a number this campaign
never resolved. It is not moved for that reason.
## 4. The positive control — what the relative arm must still NOT say
A card that adds a way to raise an alarm has to show what it did **not** add.
`test_packshare.py` part 8b runs the same fixture, the same weights and the same
125 W share with the one fact that makes the shortfall legitimate changed:
| fixture | B's share | named | why it is silent |
|---|---|---|---|
| B wedged at a healthy 15 % (the fault) | 77–154 W | **200 / 200** | — it is the fault |
| B at its own 10 % cut-out | **0 W, all 200 cycles** | **0 / 200** | it is never **asked** |
| B delivering **half** its share | 120 W | **0 / 200** | short by 50 %, inside both arms |
### ⚠️ The cut-out row does NOT grade `account()`, and this record said it did
**Corrected in review.** The first draft of this section claimed the wedged and
cut-out arms had *"identical power traces — 0 W against a positive share, 100 %
of the ask missing"* and that *"what separates them is `account()`"*. **Every
part of that was false**, and the check that carried it was **vacuous**:
* at its own cut-out the pack's usable energy **above that cut-out is zero**, so
`packshare.weights()` answers **0.0** for it, `_fill()`'s preferred pass gives
it nothing, the healthy sibling covers the load alone so the last-resort pass
never runs, and its share is **0 W on all 200 cycles**;
* a unit with a 0 W share is not in `asked`, so `len(asked) < 2` returns `{}`
**before either arm is evaluated** and `account()` is **never consulted**;
* the traces are not identical — the cut-out arm's share is **0 W on every
cycle** (asserted in part 8b and printed on its line) while the wedged arm's
is **125 W** at the last cycle (asserted in the GREEN check) and takes four
distinct values 77/125/133/154 W across the 200, measured by re-running the
same loop with per-cycle recording during review — and "100 % of the ask" is
0/0.
Measured, by deleting the gate the claim rested on:
cut-out arm, account() live named 0/200, distinct B shares: [0]
cut-out arm, account() DELETED named 0/200, distinct B shares: [0]
wedged arm, account() live named 200/200, shares [77,125,133,154]
⚠️ **The fact that replaces it is better, not weaker.** At its own cut-out
**OPT-09 stops asking the pack at all**, so SAFETY-43's relative arm is never
reached — a **second, independent protection sitting upstream of `account()`**:
the allocator declines to ask before the detector has to decline to alarm. Part
8b now asserts the 0 W share and prints it, and runs the arm a second time with
`account()` deleted to show which mechanism is doing the work.
⚠️ **It was invisible for one reason worth keeping**: that line was the only one
in part 8b that did not print its shares. `inverter-b+0` would have been on
screen from the first run. Every line in that part prints them now, and the
derated control's vacuity guard now asserts **its own** unit is asked, rather
than re-asserting a different run's 200/200 — which is exactly why the old guard
could not see this.
**The account gate is graded where it is real**, on a positive 125 W share, in
`test_delivery.py` part 1 (*"THE ACCOUNT GATE IS UNTOUCHED BY IT"* — the same
0 of 125 W with the shortfall explained names nobody). That part also carries
that the relative bound is **strict** (a gap of exactly `frac · share` is not
over it) and that the proper-subset rule still refuses a site-wide cause (both
units delivering none of a 125 W share names nobody).
## 5. N=1 is byte-identical, and it is asserted rather than argued
The live install is **N=1**. Two independent statements, each graded against the
thing that would break it (`test_packshare.py` part 8b):
1. **`allocator.plan()` returns before `weights` is read at all.** Graded by
handing it a `weights` mapping that **raises on any read**: at a roster of one
it answers identically to the same call with no weights; at a roster of two
the same mapping is read and raises, so the tripwire is a real one.
2. **`delinquent()` answers `{}` at a roster of one for every input** — 84
combinations of (share, measured, rule), including **0 of 125 W**, the exact
input this card added an arm for. `len(asked) < 2` returns before either
threshold is compared, so the fraction is never even multiplied.
Nothing in this card is reachable at one inverter. It must land before inverter B
is commissioned (~2026-09-12), not before the next deploy.
## 5b. Mutation — the fraction is pinned from BOTH directions
⚠️ **RUN, NOT REASONED ABOUT.** Each mutation was applied to a tree **copied
fresh** from this branch and mounted `:ro` inside the checks image, so no `.pyc`
could go stale — MUTATION.md's defect, and all three of these are **reds**, the
direction that defect cannot fabricate. Recorded in `test_packshare.py` part 11
as M12–M14.
| mutation | named checks | where |
|---|---:|---|
| **M12** drop the relative arm, keep `gap > min_w` — *the card itself reverted* | **7** | `test_delivery.py` part 1 (3: the 0-of-125 W naming, the proportion in the sentence, the strict relative bound) + `test_packshare.py` 8b (4: GREEN back to 0/200, the equal arm's 200 back to 199, the 6000 W arm back to 195/199, the derated control's guard). `test_alloc.py` / `test_control.py` stay green — nothing outside this check moves. |
| **M13** `DELIVERY_SHARE_FRACTION` → **1.0** — *too loose* | **7** | part 1 (2) + 8b (5), including the PRECONDITION that the fraction is strictly below 1.0. At 1.0 the card is a no-op: gap/share of exactly 1.0 is not *over* 1.0. |
| **M14** `DELIVERY_SHARE_FRACTION` → **0.05** — *too sensitive* | **6** | part 1 (2: the strict bound, and *"IT TOOK NOTHING AWAY"* stops being true) + part **4b** (3: the `saturation_w` pin goes **vacuous**, the relative arm naming B at every threshold) + 8b's derated control, where a unit delivering half its share starts being named. |
M13 and M14 are the pair that matters: the constant cannot be moved in **either**
direction without a named check moving with it.
### ⚠️ Two of these counts were wrong, and the reason is worth keeping
**Corrected in review.**
* **M12 and M13 read 1 and 1 in `test_delivery.py`, not 3 and 2.** Part 1 printed
the alarm sentence with a bare subscript, `delinquent(_tiny, 500)["b"]`. On any
tree where the relative arm is broken or absent that raises `KeyError` and
**aborts the file at part 1**, leaving parts 2–8 ungraded — so the recorded
coverage of a mutation run read *smaller* than the truth. The direction is safe
(an abort is a red, and MUTATION.md's defect can only fabricate a green), but a
figure that does not reproduce is a figure that does not reproduce. It is a
`.get` now, with the reason written beside it, and the counts above are from
re-runs with the guard in place.
* **M14 was recorded as 4 named checks in `test_delivery.py` and it is 5** — part
1's pre-existing *"a shortfall AT the threshold is not one"* also goes red. That
one under-claimed, so it never inflated the evidence; it is still a recorded
number that did not reproduce.
## 6. ⚠️ [[CFG-07]] is adjacent and is NOT absorbed here
CFG-07 is that `delivery_hold_s` is declared `int(30,900)` in `config.yaml`'s
schema while the measured worst legitimate episode at the shipped threshold is
**35.0 s** — so the schema offers an operator a value (30) that is **below a
measured legitimate episode** and would report on ordinary saturation.
**This card makes it worse, and by exactly one control cycle.** The relative arm
takes the worst legitimate episode from **35.0 s to 40.0 s** at the shipped
fraction, so the band of schema-legal but false-tripping values widens from
`[30, 35]` to `[30, 40]`. The floor the schema should have is now **40 s**, not
35 s. It is not fixed here: raising a schema floor is an option-surface change
with its own compatibility argument (an existing config carrying `35` would start
being refused), and folding it into a safety fix would hide it. **CFG-07 should
be re-derived against 40.0 s, not 35.0 s.**
⚠️ **WHEN IT HAS TO CLOSE: BEFORE INVERTER B IS COMMISSIONED (~2026-09-12).**
Not before the next deploy. The band is **unreachable at N=1** — `delinquent()`
returns `{}` for every input at a roster of one, so no value of
`delivery_hold_s` can false-trip on the install that is running today — and the
live install is N=1 until B exists. Commissioning B is the moment the band
becomes reachable, and that is the deadline this card hands it.
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE.** It exists as a
`[[CFG-07]]` mention in `CHANGELOG.md` and in this section, and nowhere else —
so there is no file for the 40.0 s to be recorded in, and a reader who goes
looking will find a card reference with no record behind it. Whoever takes
CFG-07 writes `DECISIONS/CFG-07-*.md` first; the number it has to answer to is
**40.0 s**, measured here, and the harness that produces it is
`admin/goodwe:sim/delivery_window.py --campaign --fractions`.
## 7. Provenance of every number in this document
| number | where it came from |
|---|---|
| 199/200, 0/200, 200/200, shares 1505/125 | `test_packshare.py` part 8b, run |
| 35.0 s absolute-arm floor, and its argmax | `delivery_window.py --campaign`, re-run here, reproduces SAFETY-42 §3.3 |
| every row of §3.1 | `delivery_window.py --campaign --fractions`, two passes over the same campaign with `FRACTIONS` changed between them |
| 1000 W / 6500 W suppression windows | part 8b's arithmetic, asserted there |
| 185 s, 5.29× | SAFETY-42 §3.3 / TEST-21, unchanged by this card |
| 4.62× | 185 / 40.0, printed by the campaign |
| 0/200 on both positive controls, and B's 0 W share at the cut-out | part 8b, run and asserted |
| 0 W cut-out share / 125 W wedged share | part 8b, asserted and printed |
| 77/125/133/154 W (the wedged arm's four distinct shares) | the same loop re-run in review with per-cycle recording — NOT part 8b's printed line, which shows the last cycle only |
| M12/M13/M14 named-check counts (7 / 7 / 6) | re-run in review on fresh `:ro` trees, after the `KeyError` abort in part 1 was guarded |
| 84 single-unit rosters | part 8b, run |
⚠️ Every two-inverter figure above is **two copies of inverter A**.
@@ -1,221 +0,0 @@
# SAFETY-45 — the trade is SIZED by the capacity guard, not switched by it
**Status: closed.** `peakguard.charge_headroom_w()` publishes the watts the open
billed quarter can still afford; `arbitrage.decide()` takes it as a required
keyword-only argument and sizes the BUY leg to it; `main.py` passes the measured
guard's own number at its one call site. `peakguard.decide()` is **not changed** —
byte-identical to `release/1.0` at `478ba93`, `sha256(decide) = 200090d0835a3a55`,
7252 B on both trees.
⚠️ **THE HOUSE LOAD IN EVERY FIGURE BELOW IS SYNTHETIC.** TEST-23 stage 3's
`priceloop.py` drives a flat modelled house against a modelled pack
(`plantsim.Plant`, 800 W/s ramp into a 10 s lag, 15 360 Wh). The PRICES are real,
the decision code is the shipped code, the billed-quarter accumulator is
`app/p1.py`'s own — the load is not. Nothing here is a measurement of the live
site at 192.168.2.158, which this work did not touch.
## 0. The defect, measured
TEST-23 stage 3 found a sustained limit cycle with arbitrage armed and a charge
decision active. Re-measured here on the same rig against the same checkout,
metrics recomputed rather than copied — a **charge onset** is a cycle at which
`arbitrage.decide()` transitions into `ARBITRAGE_CHARGE`, which is the edge the
rig already logs:
| `peak_cap_w` | house | onsets/h | mean period | grid swing | billed quarters (W) |
|---|---|---|---|---|---|
| 1500 | 1200 W | **28** | 131.1 s | **−455.5 … +3200.0 W** | 1490.1 / 1496.5 / **1508.8** |
| 3500 *(shipped)* | 1800 W | 8 | 509.3 s | +127.5 … +3800.0 W | **3511.3 / 3536.0 / 3559.1** |
| 3500 *(shipped)* | 3000 W | 32 | 114.8 s | +1202.0 … +5000.0 W | **3518.9 / 3542.2 / 3556.7** |
| 5000 | 1200 W | 1 | — | +1240.6 … +3200.0 W | 3169.5 / 3200.0 / 3200.0 |
The last row is the **negative control**: `load + max_w = 3200 W` never reaches
the cap, so there is no cycle to remove. The bite rule is `load + max_w >
peak_cap_w`, which at the shipped `peak_cap_w: 3500` / `max_w: 2000` is any house
drawing over 1500 W in the cheapest quarter.
§10.1's ordering did win on the money — the guard kept the quarters within tens
of watts of the cap — but **every billed quarter in the three biting rows
finished OVER it**, which is the thing §9.1 bills. §10.2: *"an EMS that
oscillates is worse than no EMS, both for hardware wear and — per §9.1 — for the
peak it is trying to avoid."*
## 1. ⚠️ The mechanism is NOT mainly the quarter boundary, and the card says it is
The card, and `.cache/s45.md` before it, describe the loop as *"at each quarter
boundary the projection resets to 0, the guard disengages, and arbitrage re-arms
at full power."* **That is not what the 28 onsets are.** Their timestamps, off
the run above:
1, 231, 351, 471, 581, 696, 806, 896, 1136, 1256, 1371, 1486, 1596,
1711, 1811, 2061, 2176, 2296, 2411, 2521, 2626, 2716, 2971, 3091,
3206, 3321, 3431, 3541 (s, quarter boundaries at 900/1800/2700)
**23 of the 27 gaps are 90–120 s, mean 111.5 s, entirely inside one quarter.**
The four long gaps — 230, 240, 250, 255 s — are precisely the four that SPAN a
boundary, so the boundary **lengthens** the interval rather than causing an
onset. Only three onsets land within 15 s of a boundary and one of those is
`t = 1`, the start of the run.
The real cycle, read off the recorded fields at t = 1000…1180 s:
1. the trade charges at `−2000 W`; the meter sits at 3200 W against a 1500 W
ceiling; `wanted = batt_w + excess` is **negative** (−132 → −8 W), so
`decide()` files **nothing** and arbitrage is not constrained at all;
2. `wanted` drifts up as the quarter's own history worsens and the `900 /
remaining` gain grows, and crosses zero at t = 1055 with **`floor_w = 5.3 W`**;
3. that 5 W floor flips `arbitrage.guard_state()` to `True`, which refuses the
**whole trade** — so the setpoint goes from −2000 W to +5 W and the
grid-following loop, which targets grid = 0 and not grid = ceiling, takes the
actuator and discharges to **+1870 W**, exporting 420 W;
4. the projection collapses, `wanted` goes negative, the guard releases, and
arbitrage re-arms at **`max_w`**. 115 s later, again.
So the defect is **a binary interlock keyed on a continuous quantity**, and the
quarter boundary is one of the ways it is crossed rather than the cause. The
card's other claim — *"projection 3092 W against a 1509 W ceiling, `engaged=False`,
for 170 consecutive seconds"* — reproduces, but it is the **first** such window
(`first_engaged_t_s = 151.0`); the steady-state silent window is ~40–65 s.
None of this changes what the fix has to do. It is recorded because a card that
says "the boundary is the trigger" invites a fix in the boundary handling, and
such a fix would have removed four of twenty-eight onsets.
## 2. What was rejected, and why
### 2a. Reverting `decide()`'s `wanted <= 0.0` rung — rejected, and it is a trap
That rung is 2026-08-20's anti-bang-bang. Its own comment records the cost of not
having it: 17 engage/release cycles in 900 s and a quarter finishing 49 W over
the limit it had spent a quarter of an hour holding. It is untouched, pinned on
the AST in `test_peakguard.py` 13g, and `tools/replay_ac3.py` reproduces the same
190/190, 110 engaged cycles, 5 blind, 878 Wh on this branch as on `478ba93`.
### 2b. Filing the affordable charge as a NEGATIVE `lo` — rejected
Arithmetically this is the prettiest answer: `lo = wanted` with no sign gate is
continuous, is exactly the control law `decide()` already computes, and would
have put the constraint on the arbiter where it binds every claimant rather than
only arbitrage. It was rejected on two grounds, in this order:
1. ⚠️ **IT CREATES A NEW `lo > hi` CONTRADICTION CLASS AGAINST THE MAINTENANCE
SHAPING.** `main.py`'s cheap-window maintenance charge files
`Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` — a NEGATIVE `hi`.
`arbiter.py` clause 2 applies every limit at or above the winner's priority
and takes the most restrictive, with **no notion of one limit out-ranking
another**; a peak `lo = −300 W` against a shaping `hi = −1000 W` is `lo > hi`,
which resolves to **0 W — the battery parked, in the one quarter of the month
that sets the bill**. That is the exact failure `peakguard.shaping_suspended()`
exists to prevent, and the only way to close it would be to key that
suppression on "a claim exists" — which, on every site this card is about,
means the maintenance charge-only shaping is suspended **permanently**. A
silent, large change to a different subsystem is not a price this card should
pay.
2. It changes the meaning of `PeakDecision.engaged` at five call sites outside
this module — `arbitrage.guard_state()`, `shaping_suspended()`,
`claim()`, `main.py`'s edge-logged action line and `main.py`'s status row —
each of which today means "the guard is HOLDING" and would silently start
meaning "a claim exists".
`test_peakguard.py` part 5's sweep asserts **no negative floor, ever**, and the
reason it gives ("a `lo` limit is incapable of capping discharge or export only
while it is non-negative") is, strictly, not the true reason — a `lo` of any sign
caps neither. The invariant is still right and is still worth holding; §2b above
is the argument that actually supports it, and it is recorded here rather than
left for the next person to re-derive.
### 2c. A backoff, cooldown or hysteresis constant in `arbitrage.py` — rejected
The card asks whether the second half of the fix belongs in arbitrage: *"a trade
refused at 14:59 and re-armed at 15:00 has learned nothing from the refusal."*
It does belong there, but as a **size**, not as a memory. `arbitrage.py`'s
hysteresis note is explicit that the switching decision is "which hour is it" and
that **no timer is needed and none is invented**; `peakguard.py`'s design note
spends a paragraph on why it carries no hysteresis constant. A cooldown would be
a second control loop compensating for a limit the first one declined to file,
and its constant would outlive whatever it was tuned against. The trade now
re-arms at `ceiling − load` instead of at `max_w`, which is what "learning from
the refusal" means when the refusal is a measurement.
## 3. What was done, and the precedent it follows
`control.maintenance_charge_floor()` has capped MAINTENANCE charging at *"the
headroom left under the site's cap"* since the loop was written, and its
docstring says why:
> "A maintenance charge is the only thing this system does that is big enough and
> long enough to set that peak, so it is capped by the headroom left under the
> site's cap."
⚠️ **THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED.** Price arbitrage is a
second thing that is big enough and long enough — it commands `max_w` for a whole
tariff interval, deliberately, against the meter — and it was given no such cap.
This card gives it the same shape, with one difference that matters:
`maintenance_charge_floor()` sizes itself from `peak_forecast_entity`, which is
OPTIONAL and empty on a default install — the exact hole SAFETY-25 had to close
in `shaping_suspended()`. `charge_headroom_w()` comes off `decide()`, which runs
wherever P1 does.
**The number is `wanted_w` read in the other direction.** `decide()` already
computes `wanted = batt_w + excess` as the DISCHARGE that lands the quarter on the
ceiling; a negative `wanted` is a permitted CHARGE of `−wanted`. No new
arithmetic, no new constant.
Two properties make it safe to hand a strategy, and both are swept rather than
argued (`test_peakguard.py` part 13):
* ⚠️ **IT IS INVARIANT IN THE BATTERY.** Raise the charge by dW and the meter
rises by dW, so `excess` rises by exactly dW while `batt_w` falls by dW:
`wanted` does not move. Measured across `batt_w` from −5000 W to +2000 W on one
banked quarter — the headroom is 1350.0 W at every one of the six points while
the `excess` those runs see moves through six distinct values. It is a
**feed-forward** number, so consuming it adds no feedback path around the plant.
The fixed point is `charge = ceiling − load`, with the meter ON the ceiling and
`wanted` parked a whole `ceiling − load` clear of the engagement boundary.
* **HOLDING IMPLIES ZERO OR UNKNOWN.** Across 1080 swept combinations, not one
ENGAGED decision answers a positive headroom, so the function can never hand a
caller permission the guard's own `lo` is at that instant refusing.
## 4. After, on the same rig, same command, same checkout
| `peak_cap_w` | house | onsets/h | grid swing | energy bought | billed quarters (W) |
|---|---|---|---|---|---|
| 1500 | 1200 W | 28 → **1** | 3655.5 → **282.0 W** | 180.0 → **285.2 Wh** | **1500.0 / 1500.0 / 1500.0** |
| 3500 | 1800 W | 8 → **1** | 3672.5 → 1687.6 W | 1652.9 → 1616.1 Wh | **3500.0 / 3500.0 / 3500.0** |
| 3500 | 3000 W | 32 → **1** | 3798.0 → **474.7 W** | 482.9 → 475.3 Wh | **3500.0 / 3500.0 / 3500.0** |
| 5000 | 1200 W | 1 → 1 | **identical** | **identical** | **identical** |
* **One onset** means the trade starts once and runs for the whole window. The
battery is trading 100 % of the hour where it used to trade 43 %, 92 % and 54 %.
* **Every billed quarter lands exactly on the cap and none over it**, where every
quarter in the three biting rows used to finish over.
* **The money.** At 1500/1200 the site buys **58 % MORE** energy, because a
continuous 300 W trade beats a 2000 W trade that is cancelled for 57 % of the
hour. At the two shipped-default rows it buys **2.2 % and 1.6 % less** — and in
exchange the worst quarter drops from 59 W over the cap to 0 W over it. The
capacity tariff bills the month's single worst quarter with no averaging and no
forgiveness (specs.md:583), so that is not a trade-off, it is the ordering
§10.1 asks for.
* The remaining 1687.6 W at 3500/1800 is the opening **ramp**, not a cycle: the
pack goes from 0 to 1700 W of charge in about ten seconds and the meter with
it. One onset, no export, no reversal.
* The last row is bit-identical before and after. **This card costs nothing on
the installs it is not about.**
## 5. What this does NOT fix, with sizes
* **TEST-23 finding 1 — the export overshoot at the END of a trade** (356–938 W,
≈0.37 × `max_w`) is untouched and is a different card. It happens when the
cheap interval ends and the grid-following loop takes back an actuator that was
holding a charge; nothing here changes the handover.
* **A house whose load ALONE exceeds the cap** (`load > peak_cap_w`) still puts
`wanted > 0`, still engages the guard, and still refuses the trade outright
through the interlock — correctly, since you cannot buy in a quarter you are
already over on. The loop then drives to grid = 0 rather than to grid =
ceiling, which is more discharge than the guard asked for. Not swept here: all
four (cap, load) pairs on the record have `load < cap`.
* **`arbitrage.claim()` still files the SIZED number, but the ARBITER can clamp
it further** and the strategy is not told. The reason line names the guard's
headroom, not the arbiter's `bound_by`. Pre-existing; not widened.
* **The container leg was not re-run.** `hadrive.py` needs `test23s3-addon`
started, and that container is deliberately stopped because it is a second
writer on the setpoint entity under measurement. The in-process rig's twelve
transcribed lines are pinned by `test_arbitrage.py` 3b's AST check on
`main.py`'s call site, which is the line this card adds to them.
@@ -1,319 +0,0 @@
# SAFETY-47 — what a shadow run can show, and what it cannot
**Status:** decided and implemented on branch `SAFETY-47`.
**Card:** SAFETY-47 — *"shadow mode's claims, and the number on the status
row"*.
**Nothing here reached the inverter.** No deployment, no `ha addons update`, no
contact with 192.168.2.158 or 192.168.2.221. Everything below was measured in
the CI image and on TEST-23 stage 3's closed-loop rig.
⚠️ **This is not a write-path defect and nothing in the write path was
touched.** SAFETY-10's substitution, re-seed, authorisation gate and clamp are
unchanged. What was wrong is what three merged documents **say** a shadow run
can show you, and a number on the status row that can be **6.61×** what a live
controller would command.
---
## 0. The four things a reader should take away
1. **Shadow mode cannot surface a closed-loop defect. Not partially — not at
all**, and by the same construction that makes it safe. §1.
2. **The "strategies are unaffected" exemption was true until SAFETY-45 merged,
one commit before SAFETY-10.** Sizing the buy leg to the billed-quarter
headroom made the strategy closed-loop. §2.
3. **The recorded watts are the right answer to a question nobody asked, and
they are CAVEATED rather than re-computed.** Re-computing would mean the
recorded decision was no longer the decision the shipped path reached, which
is the one thing shadow mode has to be able to say. §3 records what was
rejected.
4. **SAFETY-10 §6's committed evidence could never have failed, for two
independent reasons, and closing either one alone closes nothing.** §4.
---
## 1. Why the answer is "not at all", and not "not very well"
Shadow mode holds every actuator at `shadow.WIRE_W` and re-seeds the carried
command to it at the end of each cycle. So every **measured** input — the
meter, the billed-quarter accumulator behind it, the battery power, the state
of charge — describes a plant that **did not respond**.
A closed-loop defect is, by definition, one whose stimulus **is** the plant's
response to the controller. No response, no stimulus, nothing to see. This is
not an implementation gap:
* feeding the guards a **simulated** plant instead is exactly what SAFETY-10 §4
refuses for the sign watchdogs, and the reason generalises — a controller
reacting to a plant that does not exist drifts further from reality every
tick, and three cycles in its numbers describe nothing;
* and SAFETY-10 §5 already conceded the same fact for the grid-following loop
("one slew step, not a trajectory"). That was never a property of the loop.
**It is the general case**, and the loop leg was simply where it had already
been noticed.
**So the closed-loop answer comes from a closed-loop rig (TEST-23 stage 3) or
from a live run. There is no third place, and saying that plainly is worth more
than the three sentences it replaces.**
### Measured, on the rig, at a cap that binds
`peak_cap_w` 1500 / house 1200 / `max_w` 2000, one hour at a 5 s cycle. Of the
three defects SAFETY-10 was written for:
| | visible? | measured |
|---|---|---|
| SAFETY-45's limit cycle | **no** | 28 charge onsets live, **1** in shadow. Grid −455.5 … +3200.0 W live, **flat at the 1200 W house** in shadow. Guard engaged 410/720 cycles live, **0/720** in shadow. (Taken with SAFETY-45 reverted — that is the defect being looked for.) |
| SAFETY-46's SoC churn | **no** | the SoC moves **0.000000000 pp** in a shadow run. The SoC is the pack's integral of what the pack was commanded, and it was commanded 0 W. No SoC limit can be *reached* in a shadow run at all |
| TARIFF-14's wrong quarter-hour | **yes**, and it is the only one | the price lookup's inputs are the clock and the price series, neither a function of the battery. Which hour, at what price, clearing what spread: **identical live and shadow on 720 of 720 charging cycles, at a binding cap** |
### What a shadow run genuinely is for
*Which* strategy wins, on *which* hour, at *which* price, for *which* reason,
against which limits — the wrong-quarter-hour class, a trade armed on a day it
should not be, a refusal whose sentence names the wrong subsystem, a clamp that
would have engaged, a standing failsafe latch. That is a real and useful set,
and it is the set the documents now claim.
---
## 2. The exemption, and exactly how much of it survives
All three documents carried a version of:
> The **strategies** are unaffected — price arbitrage, the capacity guard and
> maintenance all ask for a fixed power **from inputs that do not depend on
> what the battery did**.
**True before SAFETY-45, false after it.** SAFETY-45 makes the buy leg
`min(asked, headroom)`, and
```
headroom = (900 / remaining) x (ceiling - projected)
```
where `projected` reads the **open quarter's accumulator** — the meter — which
is a function of what the battery did earlier in that same quarter. SAFETY-45
merged one commit before SAFETY-10, so the exemption was stale on the day it
was written.
**What survives, and it is now graded rather than asserted:**
> **Which** hour a strategy picks, and **why**, are unaffected — the clock and
> the price series are not functions of the battery.
> **How hard** it trades is not.
⚠️ **The peak guard is not in the surviving half either.** Every term of
`wanted = batt_w + excess` is measured, so in a shadow run the guard engages
only if the *house alone* would put the quarter over the ceiling. Its
**ceiling** and its **reasons** are worth reading from a shadow run; its
**engagements** are not evidence of anything.
---
## 3. The number on the status row — decided, and what was rejected
At `peak_cap_w` 1500 under a 1200 W house the status row read **"WOULD HAVE
COMMANDED −2 000 W"**. A live controller commands **−302.4 W**. Measured on the
shipped build:
| cap / house | live commanded | shadow recorded | over by |
|---|---|---|---|
| 1500 / 1200 | −302.4 W | **−2000.0 W** | **6.61×** |
| 3500 / 3000 | −502.6 W | −2000.0 W | 3.98× |
| 3500 / 2000 *(shipped cap)* | −1502.6 W | −2000.0 W | 1.33× |
| 3500 / 1800 | −1702.6 W | −2000.0 W | 1.17× |
| 15000 / 350 | −2000.0 W | −2000.0 W | 1.00× |
⚠️ **The arithmetic is not wrong.** `900/remaining` is correct for a meter that
is genuinely under the ceiling, and in a shadow run the meter genuinely *is*
under it, because the battery is not charging. **The recorded number is the
right answer to a question nobody asked** — and the last row of that table is
the fixture SAFETY-10's own evidence was taken on, which is why nothing
reported it.
### What shipped
1. **Unconditionally**, the row and the log line stop forecasting: the watts
are introduced as what would have been commanded **ON THIS CYCLE**. No
number a shadow run records is a prediction of a live run, so no state
should read like one.
2. **Conditionally**, whenever a **measured** billed quarter sized the number
being recorded, both surfaces carry `shadow.SIZED_FROM_METER`:
> ⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE
> HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same
> trade against a meter the battery is moving, so this is an UPPER BOUND and
> not a prediction; how far under it a live run lands is a closed-loop
> question and a shadow run cannot answer it.
⚠️ **"Upper bound", and not "smaller", and the difference was measured.**
Charging only *adds* offtake, so a live `projected` is never below a shadow one
and the headroom never above it — with **equality at the start of every
quarter**, where `900/remaining` is 1.0 and neither plant has moved. Measured
pairwise over 3600 charging cycles in five configurations: **0 cycles below,
730 exactly equal.** A sentence reading "smaller" would have been false on
every one of those 730, and the first draft of this card said "smaller".
⚠️ **And it names no factor.** How far under the bound a live run lands is a
property of the site. A number invented for the row would be a second helping
of the defect the sentence exists to disclose.
⚠️ **The predicate is NOT keyed on whether the trade was capped, and that is
the trap this card nearly walked into.** `arbitrage`'s own sentence says *"SIZED
BY THE CAPACITY-TARIFF GUARD"* only when the headroom came out **below** the
ask — which in a shadow run is the **start** of the quarter, where the two runs
still agree to the float. By the end, `900/remaining` has multiplied the
headroom **past** the ask, the trade is **not** capped, the sentence is silent,
and the recorded number is at its **most** wrong. A caveat keyed on it would be
quietest exactly where it is needed loudest. `arbitrage.sized_from_meter()` is
keyed on the headroom having been **measured**, so it fires on both.
`test_arbitrage.py` part 3d-8 drives the inversion.
### Rejected, and why
* **Re-compute the number so it does not amplify** — e.g. record
`ceiling − projected` (gain 1.0) instead of the sized figure. It matches the
live answer almost exactly at this cap, and it is the **worst** of the
options. It would be a **shadow-only sizing**: the recorded decision would no
longer be the decision the shipped path reached, while the arbiter, the
clamp, SAFETY-28's gate, the action log and MQTT all ran on the real one. A
second copy of the sizing arithmetic living in the display layer is
MUTATION.md's transcribed-predicate defect on an operator-facing surface, and
it generalises to no other strategy. SAFETY-10's whole safety argument is
that shadow mode is a **substitution at the wire**, not a second controller;
this would have made it a second controller everywhere except the wire.
* **Record nothing where the sizing depends on measured state.** Tempting —
"no number beats a number 6.61× out". Rejected on three counts: it blanks the
row exactly when arbitrage is trading, which is when a commissioning engineer
most wants it; the action log's edge key is the rounded watts, so blanking
them either floods or silences the log; and a surface that flickers between a
number and a dash is harder to read than a number with a standing caveat.
The narrow version of this — dropping the watts only — was the closest
runner-up.
* **Record the unsized ask alongside the sized one.** It adds a number without
adding a warning. The unsized ask is `max_w`, a constant the operator
configured; on the shipped 3500/2000 row both figures are 2000 W, so the pair
is exactly as misleading as the single figure was.
* **A caveat on every cycle in every state.** Rejected as the *whole* answer,
accepted as half of it: the unconditional part is a two-word narrowing of the
verb ("on this cycle"), not a warning, and the warning proper is conditional
so that it still means something when it appears.
---
## 4. SAFETY-10 §6's evidence, and why it could not fail
§6 tabulated `arb_power_w` −2000 W *"identical"*, `arb_reason` *"identical,
verbatim"*, arbiter winner *"identical"*. Two independent reasons it could
never have gone red:
1. **the fixture is `peak_cap_w: 15000` against a 350 W house**, so the guard
never engages and SAFETY-45's sizing is in the run and inert. ⚠️ **The
harness's own comment says so** — *"at this scenario's `peak_cap_w: 15000`
it is not binding anyway"* — which is MUTATION.md habit 4 written down
beside the check it disables;
2. **every identity row reads the FIRST charging sample.** At `elapsed = 0` the
projection *is* the held level, so the two runs agree to the float there
**even at a cap that does bind**.
⚠️ **AND THAT SECOND REASON IS THE ONE A REVIEWER CANNOT SEE FROM A DIFF.**
Widening the fixture alone — the obvious fix — leaves the `[0]` comparison
passing. Measured, not reasoned: at 1500/1200 the first charging cycle is
−300.0000 W in **both** runs while 716 of the 720 cycles differ. Part 3d-2
grades that agreement **as an expectation**, so nobody rebuilds a check on
`[0]` believing it covers something.
This is habit 4's **twelfth** instance in this repository, its **second**
inside a committed evidence artifact, and — like the seventh and the eleventh —
it was in a **measuring instrument** rather than in a check.
§6 is re-taken at `peak_cap_w` 1500 / house 1200 / `max_w` 2000, across the
whole run. Harness `/home/homelab/s47-mut/shadowloop47.py`.
---
## 5. The grader, so this class cannot recur
`test_arbitrage.py` part **3d** — placed beside part 3c, which is SAFETY-45's
own closed-loop regression, and built on the **same** `s45_loop()` harness so
the two runs share every line of shipped code and differ in **one argument**:
```
s45_loop(sized=True) the live hour
s45_loop(sized=True, shadow=True) the same hour, battery held at WIRE_W
s45_loop(sized=False, shadow=True) SAFETY-45's own defect, watched from
inside shadow mode
```
Three lines of `s45_loop()` stand in for `Controller._write_unit()`'s
substitution and `_shadow_settle()`'s record and re-seed. They are marked as a
**transcription**; `test_clamp.py` part 8 is the check on the shipped
placement, and it drives the real `Controller`. Everything that produces a
number — the averager, the projection, the guard, the headroom, the strategy,
the arbiter, the row and the log sentence — is the shipped one.
What 3d grades, in order: that the cap **binds** (the precondition the old
evidence lacked); that the first charging cycle agrees **anyway**; that the
whole run diverges, and by how much; that the record is an **upper bound**, and
the two-point arithmetic that makes it one; what survives of the exemption;
that SAFETY-45's limit cycle and the guard's engagements are **not** visible;
the four rungs of `sized_from_meter()`, each reached with the other three clear
(TEST-16); both operator surfaces; the `capped`-keying trap; and main.py's
three call sites, pinned on the AST.
⚠️ **The section carries its own precondition into every check.** A mutation
that stops either run trading left the lists empty, the first `min()` raised,
the file died before part 4 and the run reported **zero reds — which reads
exactly like a survivor**. That is FCST-06's shape and it was **driven**, not
imagined. `s47check()` now fails every check in the section when the stimulus
was not produced, and the one division in it answers `nan` rather than raising.
---
## 6. The mutation battery — 14 mutations, no survivors
Harness `/home/homelab/s47-mut/run.py`, backups inside it. Each run restores
from backup, purges `__pycache__` **on the host**, and grades through the CI
image with the tree bind-mounted `:ro`, so the interpreter cannot write a cache
into the tree under test (TEST-15). Fingerprints are printed before the first
run and re-checked after the last restore.
| # | mutation | file | Δ | reds |
|---|---|---|---|---|
| M1 | the row drops the caveat branch | `shadow.py` | −63 B | 2 |
| M2 | **the trap**: `sized_from_meter()` keyed on the trade being CAPPED — silent where the record is multiples out | `arbitrage.py` | +33 B | 5 |
| M3 | the CHARGE rung dropped — a sell carries a caveat §9.1 makes false | `arbitrage.py` | −30 B | 1 |
| M4 | the measured-headroom rung dropped | `arbitrage.py` | −18 B | 1 |
| M5 | the arbiter-winner rung dropped — a caveat on somebody else's number | `arbitrage.py` | −33 B | 1 |
| M6 | `main.py` renders the row with `sized_from_meter=False` | `main.py` | −23 B | 1 |
| M7 | `main.py` drops `winner=` — the rung exists and is never reached | `main.py` | −1 B | 1 |
| M8 | `main.py` sizes the trade from a literal — SAFETY-45 out of the shipped path, which the hoist put at risk | `main.py` | −41 B | 2 |
| M9 | the headroom assignment moves out of `cycle()` | `main.py` | −39 B | 1 |
| M10 | `sized_from_meter` gains a `False` default (TEST-14) | `shadow.py` | +8 B | 1 |
| M11 | `ON THIS CYCLE` deleted — the row forecasts again | `shadow.py` | −14 B | 1 |
| M12 | `log_line()` drops the caveat — the page discloses, the record does not | `shadow.py` | −63 B | 2 |
| M13 ⚠️ | `main.py` re-assembles the log sentence at the call site | `main.py` | **+0 B** | 1 |
| M14 ⚠️ | `wanted = batt_w − excess` — the headroom stops being monotone in the battery and the row's UPPER BOUND sentence becomes false | `peakguard.py` | **+0 B** | 38 |
**Survivors: none.**
⚠️ **M13 and M14 are `+0 B`, which MUTATION.md names as the risky delta.** Both
were confirmed on disk by `sha256` before the run started — the harness prints
the fingerprint of every graded file and the hash moved for each — and every
run is purged, so neither is at risk of the stale-`.pyc` failure.
⚠️ **Two findings from driving it, both reported rather than tidied away.**
* **M14's first form was aimed at the wrong direction and correctly survived.**
`min` → `max` in the buy leg's sizing makes the shadow record *larger*, which
is still an upper bound, so 3d-3b was right not to fail. The mutation that
actually inverts the bound is the sign of the excess term in `wanted`. A
survivor that is right is worth more than a red aimed at the wrong claim.
* **M12 was a survivor before the fix it forced.** Deleting the caveat from the
action-log line left **all 29 files green**: the only check that could see
that line drives a fixture with `meter_source: off`, where no arbitrage
decision — and therefore no caveat — is reachable. The sentence was an
f-string inside `Controller._shadow_settle()`, i.e. a surface no check could
grade. It is `shadow.log_line()` now, for the reason `words()` is a function,
and M13 is the mutation that puts it back at the call site.
@@ -1,551 +0,0 @@
# SAFETY-48 — the slew step ceiling reaches the integrator, and what it bounds is the LEAD
**Status:** decided and implemented on branch `SAFETY-48`, on top of `TEL-10`.
**Card:** SAFETY-48 — *"the integrator goes on winding while the command is
slew-limited"*.
**Nothing here reached the inverter.** The live install still runs `slew_w: 400`
applied by hand on 2026-09-05 12:00 and `release/1.0`'s control law. This branch
does not deploy and does not bump the version.
---
## 0. The four things a reader should take away
1. **It is a missing case, not a wrong constant, and the arithmetic says so.**
The integrator moves `gain * error` a cycle and the command moves at most
`slew_w`, so the integrator outruns the command whenever
`|error| > slew_w / gain` — 667 W at the live shape. SAFETY-39's retune made
`slew_w` **smaller**, which makes that threshold **lower**. §1.
2. ⚠️ **THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, AND THAT IS WHY
IT IS NOT WHAT SHIPPED.** Reusing the asymmetric freeze on a carried slew
flag cuts export 13 % and moves the worst quarter-hour average **offtake**
from 3739 W to **3935 W**, with the mean quarter +2.7 W — and on the billed
quarter it is worse at **every plant lag where release/1.0 produces a peak
at all** (a tie at lag 0, worse at 2.5 / 5.0 / 7.5 / 10.0 s) and at **every
top-N cut of the quarters**, which makes this the least lag-sensitive result
in the card. The harm is a tail redistribution inside quarters that already
net to offtake, not a weaker answer to import in aggregate. §3, §3a.
3. **What ships bounds the LEAD at one slew step, only where the slew is what
stopped the command, and only in the leading direction.** The robust result
is the **export reduction — 39.6 → 34.1 kWh, and it falls at every plant and
tuning tried** — with wasted throughput 4.67 → 3.19 kWh, sign reversals
2992 → 2770 and median recovery 232 → 199 s. ⚠️ **The billed quarter is not
something this card can claim.** It reads 3739 → **3434 W** at the fitted
plant lag of 7.5 s; at every lag ≤ 5.0 s — including the one that best
reproduces the recorded export — release/1.0 is *already* at 3434 W and this
card moves it by exactly zero. The lag is not identified by the data. §4,
§3a.
4. **Two committed numbers moved and neither is a silent edit.** SAFETY-13's
released command settles lower, because there is less wound integrator to
settle onto; and SAFETY-39's *"the slew does not own the over-absorption"* is
now **false — inverted, on its own independent plant model.** §5, §6.
---
## 1. The defect, in the terms the code uses
`compute()` freezes the integrator with an asymmetric rule, and the comment
beside it states the requirement outright:
> the requirement is that the INTEGRATOR stop accumulating, not only the command
But `frozen` is computed from **one** signal:
```python
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w # not tracking
```
Slew-limiting never enters it. When the command hits the step ceiling the
integrator keeps winding at `gain * error` until it reaches `integrator_max_w`,
and the unwind afterwards is `integrator_max_w / slew_w` cycles — 12.5 at the
live shape, about a minute at the meter's measured tick.
⚠️ **AND THE RETUNE MADE IT MORE FREQUENT, NOT LESS.** `|error| > slew_w / gain`
is 1667 W at `slew_w: 1000` and **667 W** at `slew_w: 400`. The measured median
grid error at the onset of a recorded episode is 1114 W — above the second
threshold and below the first. This is why SAFETY-39 did not fix it, and the
mechanism is the same one §6 shows inverting.
## 2. How it was measured, and what the measurement cannot say
`tools/replay_recorded_loop.py`, over **16.00 days / 256 252 meter instants** of
the live install's own recorder, exported read-only.
**Real:** the disturbance (`D = grid + batt` at every recorded meter instant —
the house with this loop's own battery taken back out, which is exogenous and
therefore valid to drive a counterfactual with); the tick (the meter's own
recorded instants, median **5.01 s**); and the control law, imported out of the
checkout. The "before" law is the **same file** with the SAFETY-48 block removed
by a named substitution and exec'd into a fresh namespace — never a
transcription — and a missing or duplicated anchor is a **refusal** with
`NOTHING HAS BEEN MEASURED` on it, because an instrument that quietly compared
the shipped law with itself would report the card as changing nothing.
**Modelled:** the inverter (pure delay 7.5 s + first-order lag 2.0 s +
`batt = 0.973 × cmd − 22.4 W`, fitted on this same recording above the SoC
floor, residual **375 W RMS**); the pack (SoC integrated, discharge refused at
or below the floor); and **everything else the arbiter does** — the recorded
setpoint is the arbiter's output after the peak guard, the SoC clamps,
maintenance and arbitrage, while the replay runs `compute()` alone.
⚠️ **THE ABSOLUTE FIGURES ARE PESSIMISTIC BY ABOUT A THIRD AND THE TOOL PRINTS
IT ON EVERY RUN.** Replaying the before-law reproduces 39.6 kWh of export where
the install actually exported **29.4 kWh** over the same window. The delta is
what this instrument is for: both laws run on the same ticks, the same
disturbance, the same plant and the same seed.
⚠️ **THE SoC FILTER ON THE PLANT FIT IS THE WHOLE FIT, AND IT IS A FINDING IN
ITS OWN RIGHT.** This install spends **36 % of its ticks at or below a 10 % SoC
with the command standing near 2 kW and the battery delivering nothing.** Fitted
over all ticks the recording says `batt = 0.50 × cmd − 396 W, r = 0.70` — a
plant that follows half the command, which no inverter does. Above the floor the
same data says `batt = 1.00 × cmd − 28 W, r = 0.977`. The floor is the PACK and
is modelled separately.
## 3. The form the card asked for, and the number that refused it
The card asked for the asymmetric rule already in service for saturation, on a
flag carried across cycles the way `sat_count` is. It was built — from
release/1.0's own source, one substitution per change — and measured on the same
16 days through the same pipeline:
| | release/1.0 | **form A** — carried slew flag + the asymmetric freeze | **SAFETY-48** — the lead cap |
|---|---:|---:|---:|
| export kWh | 39.61 | 34.43 | **34.07** |
| import kWh | 116.07 | 110.62 | 111.20 |
| battery sign reversals | 2992 | **2656** | 2770 |
| wasted throughput kWh | 4.67 | **2.99** | 3.19 |
| **worst 15-min offtake W** | 3739 | ⚠️ **3935** | **3434** |
| mean quarter-hour offtake delta | — | ⚠️ **+2.73 W** | **−2.39 W** |
| worst quarter-hour delta | — | +992 W | +532 W |
| recovery median s | 232 | **184** | 199 |
| recovery p99 s | 849 | **781** | 850 |
| recovery max s | 901 | 890 | 898 |
| unrecovered (> 900 s) | 53 | **26** | 31 |
| \|i_w − prev_w\| p99 W | 3361 | 1534 | **800** |
| \|i_w − prev_w\| max W | 9200 | 6912 | **800** |
**Form A wins on four rows and loses on the one that is billed.** The capacity
tariff (*capaciteitstarief*) bills the month's worst quarter-hour average
offtake, and form A raises it by **196 W** — and raises the MEAN quarter by
2.7 W, so this is systematic and not one unlucky quarter. It does so in **five
of the six** plant and tuning variants in `--sensitivity`; the sixth is a tie.
The per-cycle mechanism is that a frozen integrator stops at
`prev + gain·error(first cycle)`, which is `(1 − gain)` short of what the site
needs — 40 % short at `gain: 0.6`.
⚠️ **AND THE SENTENCE THAT USED TO FOLLOW THAT ONE WAS TOO BIG FOR THE DATA.**
It read *"for an IMPORT it is offtake, so a large import is answered too weakly
for as long as the ramp lasts"*. The aggregate does not show a weaker answer to
import: form A cuts total import by **5.45 kWh** and export by **5.18 kWh**
against release/1.0, and paired tick by tick it delivers **8.56 kWh LESS**
offtake energy on the ticks release/1.0 spent importing (lower on 38 229 such
ticks, higher on 19 669). What form A moves is the **TAIL, within quarters that
already net to offtake** — the worst quarter goes 3739 → 3935 W, the top five
3260 → 3300 W, the top fifty 1880.0 → 1882.7 W. The capacity tariff bills the
worst quarter and nothing else, so a redistribution that leaves the mean alone
and lifts the maximum is exactly the trade that is refused here. The conclusion
is unchanged; the causal sentence is now the one the data supports.
⚠️ **THE CARD SAID "do not invent a second rule", AND THE MEASUREMENT SAYS TO.**
That instruction is recorded here rather than quietly dropped. What ships is a
different rule; the paragraph above is why.
## 3a. ⚠️ The plant lag is NOT identified by this recording, and the billed quarter is the claim that depends on it
`fit_plant()` picks `lag 7.5 s` because it minimises **battery-power RMS**. That
objective barely distinguishes the candidates — 375.3 W at 7.5 s against 376.4 W
at 2.5 s, **0.3 %** — while the tool's *own* printed fidelity measure, replayed
export against the **29.43 kWh this install actually exported**, prefers 2.5 s by
the whole of the 35 % gap the header warns about. Two measures on the same data
disagree, and nothing here breaks the tie.
So the sweep has to be read as a sensitivity, not as a confirmation:
| plant lag | fit RMS W | replayed export kWh (release/1.0) | **worst 15-min offtake W:** release/1.0 | form A | SAFETY-48 | mean quarter ΔW |
|---:|---:|---:|---:|---:|---:|---:|
| 0.0 | 392.3 | 23.5 | **3434** | 3434 | **3434** | +0.53 |
| 2.5 | **376.4** | **28.9** ← nearest 29.43 | **3434** | ⚠️ 3732 | **3434** | −0.59 |
| 5.0 | 378.0 | 32.1 | **3434** | ⚠️ 3711 | **3434** | −1.02 |
| 7.5 | **375.3** ← chosen | 39.6 | 3739 | ⚠️ 3935 | **3434** | −2.39 |
| 10.0 | 384.2 | 75.4 | 3954 | ⚠️ 4326 | **3434** | −4.65 |
(The RMS column is `fit_plant()`'s own inner sweep pinned to one lag — best `tau`
and a re-fitted gain/offset at that lag. The export and quarter columns follow
`--sensitivity`'s convention instead: only the lag is substituted into the
global fit, everything else held. That is why the lag 5.0 and 10.0 export
figures are the ones already printed in §7b.)
⚠️ **AT EVERY LAG ≤ 5.0 s THE IMPROVEMENT IS NOT AN IMPROVEMENT — IT IS AN
ABSENCE.** release/1.0's worst quarter is *already* 3434 W there and SAFETY-48
changes it by **exactly zero**. §7b's paragraph used to read the `3434 → 3434`
rows as "the direction does not move"; that is the wrong reading. The direction
has nothing to move: at those plants release/1.0 never produces a quarter worse
than 3434 W in the first place, and the 3739 → 3434 W headline exists only at
the single longest lag `fit_plant()` happens to select. **The peak claim is a
property of the plant model, not of the card.** What the card can claim on the
billed quantity is that it does not make it worse, at any lag.
⚠️ **AND THE ONE RESULT HERE THAT IS NOT LAG-SENSITIVE IS THE FORM-A
REJECTION.** Form A's worst quarter is the worst of the three at **every lag
where release/1.0 produces a peak at all** — 3732, 3711, 3935, 4326 against
release/1.0's 3434, 3434, 3739, 3954 at lags 2.5 / 5.0 / 7.5 / 10.0, and a tie
at lag 0.0 where both sit at 3434. It is worse at every top-N cut of the
quarters at the fitted plant (top-1 3934.6 against 3738.7, top-5 3299.7 against
3260.5, top-10 2890.6 against 2871.0, top-50 1882.7 against 1880.0, top-100
1481.7 against 1479.0), and it raises the mean quarter. §3 understated this: the rejection does not rest on one unlucky
quarter at one plant, which is exactly the objection a lag-sensitive result
invites.
⚠️ **WHAT WOULD SETTLE IT IS NOT IN THIS RECORDING.** Both objectives above are
computed from the same four recorded entities, and they disagree; nothing else
in the capture breaks the tie, and this card did not go looking for a third
objective after seeing which lag each of the first two preferred. A driven step
response — the command timestamped where it is written rather than where Home
Assistant recorded it — is what identifies a lag. That is a rig measurement and
it is not this card. Until it exists, the export reduction is what this card
delivers and the peak figure is a plant-model artefact.
## 4. What ships
```python
slew = slew_limit_w(tuning)
slewed = max(prev_w - slew, min(prev_w + slew, target))
if slewed != target:
reason = "slew-limited"
_lead_cap = SLEW_LEAD_STEPS * slew
if target > slewed:
i_w = min(i_w, slewed + _lead_cap)
elif target < slewed:
i_w = max(i_w, slewed - _lead_cap)
target = slewed
```
Four properties, and each of them is a decision:
* **It is inside the branch**, and ⚠️ **that placement was a SURVIVOR of all
thirty files until a check was written for it.** Applying it to every cycle
instead — `i_w = max(target - cap, min(target + cap, i_w))` after
`target = slewed`, −92 B — is **bit-identical on the shipped configuration**:
0 of 256 246 ticks differ over the 16-day recording. The two separate **only**
where `integrator_max_w` is set ABOVE `max_w` — the configuration `control.py`
says is the only way its `clamped` reason is reachable. There the integrator
is *deliberately* allowed above the rail, and an unconditional cap would drag
it back to the command every cycle and make the option inert.
`test_telemetry.py` part 4 drives that configuration, and the unconditional
form is red on it.
⚠️ **THE COUNT OF SEPARABLE STATES IS DECORATION AND THREE SWEEPS DISAGREE
ABOUT IT.** This card's grid gave **2050 of 257 400**; an independent review
measured **1220 of 246 064**; a third, smaller grid (four tunings × 7 `prev_w`
× 9 `grid_w` × 5 `actual_w` × 8 `i_w` including `None` × 4 `sat_count`) gives
**776 of 40 320** — and that third one is spelled out here precisely because
the first two are not, which is the whole reason they cannot be reconciled.
No count here should be read as exact without the grid beside it. All three
agree on the load-bearing part, which is **where**: every separable state sits
in the `integrator_max_w > max_w` tuning and nowhere else.
⚠️ **AND AN EARLIER DRAFT OF THIS BULLET GAVE THE WRONG REASON AND THE WRONG
NUMBER.** It said the two forms differ on 118 ticks because an unconditional
cap would pull the integrator toward a command the SATURATION freeze is
pinning. That 118 was measured against a *different* pair — a band applied
after quantisation, at the very end of `compute()` — and the saturation story
does not apply to the placement actually being compared, which sits before
the freeze. The figures above are the ones that were measured on the pair the
sentence names.
⚠️ **AND THE SATURATION STORY IS NOT MERELY INAPPLICABLE — IT IS BACKWARDS.
THE NARROW CAP THAT SHIPPED *DOES* MOVE A SATURATION-FROZEN INTEGRATOR.** It
sits before the freeze block and is not gated on `frozen`. Measured on the
same 16 days at the live shape: of **79 160** frozen ticks the cap changed
`i_w` on **1095**, and on **679** of those it moved it further out on the side
it was already on — putting back magnitude the freeze had just let fall, which
is the one direction the freeze's rule ("may fall, never rise") exists to
forbid. Worst case **2424 W**, at
`prev_w −3400 W, grid +4697 W, actual −4144 W, sat_count 2 → 3`: the freeze
leaves the integrator at **−176 W**, the slew binds, and the cap drags it to
**−2600 W**. It never overrides a *same-cycle* refusal — the freeze clipped
`moved` on 75 512 ticks and the slew bound on none of them — but the claim
that this placement leaves the frozen path alone is false, and §5 is where
that shows up in a committed number.
* **It is written one-sided, and that is a CLARITY choice, not a correctness
one.** ⚠️ **An earlier draft of this file and of the comment in `control.py`
said the two-sided band `max(slewed - cap, min(slewed + cap, i_w))` was a bug
that would drag a TRAILING integrator up toward the command. That was
invented, and the sweep said so: the two spellings agree on 205 920 of
205 920 cases** across four tunings, every sign of `prev_w`, `grid_w`,
`actual_w` and `i_w`, and the `i_w=None` path. `target` is monotone in `i_w`,
so when the slew binds `i_w` lies on the same side of `slewed` as `target`
does and the other clamp cannot reach. The one-sided form stays because it
NAMES the side the lead is on; the equivalence is recorded so the next reader
finds a measurement instead of an argument.
* **It moves `i_w` and not `target`.** This cycle's command is already decided;
what is bounded is what gets CARRIED. That is also why the exhaustive
`i_w=None` equivalence sweep in `test_control.py` is untouched — it compares
`(target_w, sat_count, frozen, reason)`, none of which this moves.
* **The cap is in STEPS, not watts**, for SAFETY-39's reason: a watt figure is a
constant that outlives the `slew_w` it was derived against. `SLEW_LEAD_STEPS`
moves with `slew_limit_w()` and therefore with the rail.
⚠️ **ONE STEP, AND NOT ZERO.** `i_w = slewed` is full back-calculation, and
`control.py`'s own docstring says what that costs: passing `i_w=None` *"reduces
this function to the exact velocity form it replaced"*. A zero cap makes `i_w`
identically the command on every cycle, which is that collapse written from the
other end. Measured, it is **better** — export 32.02 kWh against 34.07, mean
quarter −3.69 W against −2.39 — and it is still refused, because it throws away
the reason the accumulator was split out at all. That 2 kWh is the price of
keeping the bounded form, and it is stated rather than hidden.
⚠️ **TWO LEADS, AND CONFUSING THEM IS HOW A CHECK GETS WRITTEN WRONG.** The cap
bounds `i_w − target`, the command this cycle EMITS, at one step — which is what
makes the unwind one cycle. `Decision.i_w − prev_w`, which is what TEL-10's
`loop_lead` publishes, is measured against the command the PREVIOUS cycle left
standing, and the emitted command is itself one step further on: the published
figure is bounded by **two** steps. Both bounds are asserted, each against its
own number.
### The two recorded deadlocks, driven rather than argued
`control.py` names two encodings that produce a permanently stuck loop, and a
gate written on the integrator is exactly the edit that brings them back.
Neither is reachable here and both are test cases in `test_telemetry.py` part 4:
* **the shrink-only encoding** — *"only corrections that shrink |i_w|"* is
unsatisfiable for both signs whenever the correction is larger than twice the
integrator, and the loop then sits at its last value forever (measured before
the fix: 0 W held indefinitely into a 2 kW import). SAFETY-48 is not a shrink
rule; it caps the lead beyond one slew step and only while the slew binds.
* **`i_w == 0.0`** — `main.py` resets the integrator to exactly 0.0 on every
stop and every reseed, and *"may not wind further in the direction it is
already pushing"* has no referent there. SAFETY-48 does not touch that gate;
the first push from zero is driven and asserted to move.
And a third, specific to this card: **the cap cannot latch.** The flag is not
carried, and after the cap `|i_w − slewed| = cap`, so the next cycle's command
covers it in one step and the slew stops binding — after which the integrator
accumulates freely again. The driven check asserts the command still walks all
the way to the rail on a railed export.
## 5. What this card COSTS, stated rather than smoothed
**SAFETY-13's release buys back less.** That card's own scenario — a second
writer holding the pack at +700 W, the command pinned, then released — settles
at **−800 W** where release/1.0 settled at −1190 W against an integrator of
−1194 W; and its sibling run settles at **−1200 W** where release/1.0 settled at
−2390 W. The property AC 4 asserts is unchanged: the released command stops at
the frozen integrator and not at the rail.
⚠️ **AND THE TWO MOVED FOR TWO DIFFERENT REASONS, WHICH THE ONE-LINE EXPLANATION
"how far that integrator had been allowed to wind" COVERS ONLY HALF OF.** Gating
the cap on `not frozen` — one line, `if frozen: _lead_cap = float("inf")`, so
the cap becomes a no-op while frozen — puts **part 5 back at release/1.0's
−1190 W exactly**. That run moved because the cap **fires on frozen cycles**
(§4). **Part 4(b) does not come back**: it is −1200 W gated and −1200 W shipped,
against release/1.0's −2390 W, because *that* one moved for the winding reason.
Both literals in `test_control.py` were re-baselined **with the reason beside
them**, and the second is now asserted against `i_w` rather than against a
number.
**The alternative that avoids it was measured and rejected.** Gating the cap on
`not frozen` as above gives export **34.71 kWh** (against 34.07), worst quarter
**3434 W** (the same as shipped), wasted **3.49 kWh** (against 3.19) — and a
worst observed lead of **4961 W**, i.e. no bound at all on the quantity this
card exists to bound. A bound with an exception large enough to drive a truck
through is not a bound. ⚠️ These four figures replace an earlier set (34.9,
3461 W, 3.5 kWh, 4966 W) that does not reproduce on this tree with the gate
spelled as above; the gate spelling is written out here so the next reader can
re-run it rather than re-derive it.
**One quarter-hour in 1534 is 532 W worse.** The billed figure is a MAXIMUM, and
at the fitted plant lag the maximum improves (3739 → 3434 W); no quarter under
SAFETY-48 reaches release/1.0's own worst. The mean moves the right way. The
tail is reported because a maximum over 1534 quarters is decided by one of them
— and §3a is the reason that improvement is not claimed as a benefit of the
card: at every shorter plant lag release/1.0's own maximum is already 3434 W.
## 6. The independent confirmation, which arrived as a failing check
`test_control.py`'s SAFETY-39 section drives a family of 51 synthetic export
steps through a **first-order plant this file writes itself** — no relation to
the replay, no recorded data in it at all. It carried this assertion:
> ⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION … Quartering the slew from 1000
> to 250 does not reduce the over-absorption at all
and it was right about the mechanism and therefore wrong about the conclusion:
`slew_w` bounds the OUTPUT while the integrator accumulates `gain · error`, so a
slower slew left the integrator winding for longer. Worst case over the family:
| `slew_w` | release/1.0 | this branch |
|---|---:|---:|
| 1000 | 1005 W | **729 W** |
| 0 (the rail, 556) | 1109 W | **550 W** |
| 400 (the live interim) | 1285 W | **424 W** |
| 250 | 1330 W | **326 W** |
**The ranking inverts.** Before: slower is worse, monotonically. After: slower is
better, monotonically. Two plant models that share nothing agree on the sign,
which is worth more than either alone — and it means SAFETY-39's *"gain is the
next constant to re-derive"* is now only half true: with the lead bounded, the
slew owns a share of the over-absorption it did not own before. `gain` is still
not touched here, for SAFETY-39's own reason: two live control-law constants
moving in one release makes the next measurement uninterpretable.
⚠️ **AND THE RIG SAYS THE SAME THING, WHICH MEANS TWO COMMITTED TRANSCRIPTS
CHANGED MEANING UNDER THIS CARD.** `tools/slew_sim.py --slew` runs the same
derivation on admin/goodwe's `plantsim.Plant`, a third model again. Worst-case
over-absorption over its 51-step family, `slew_w` from 1000 down to 250:
| `slew_w` | 1000 | 800 | 700 | 600 | 556 | 500 | 400 | 300 | 250 |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| release/1.0 | 958 | 1107 | 1133 | 1058 | 987 | 1029 | 1150 | 1143 | 1081 |
| **this branch** | 777 | 734 | 663 | 563 | 553 | 507 | 435 | 358 | 321 |
release/1.0's row is flat and non-monotone and **800 really is better than 556**
on that column — SAFETY-39 measured it correctly and its §3 sentence is true of
the law it was written against. This branch's row falls monotonically. So
SAFETY-39's record is not retracted; it is given a **forward reference**, and
`slew_sim.py`'s own narration now reads `SLEW_LEAD_STEPS` out of the control law
it imported and says which of the two tables it is printing. MUTATION.md habit 8
— a committed transcript is a check, and a transcript whose prose contradicts
its own table is a failing one.
## 7. Verified by breaking it
**The law.** `test_telemetry.py` part 4 and `test_control.py`'s SAFETY-39
section. The mutation table is in `test_telemetry.py`'s header.
**The instrument** (MUTATION.md habit 8 — anything under `tools/` that a
document quotes is subject to the battery):
| # | mutation to `app/control.py` | what the instrument did |
|---|---|---|
| I2 | the whole SAFETY-48 block deleted | **REFUSED**, exit 1, `NOTHING HAS BEEN MEASURED` — not a zero delta |
| I3 | the anchor line itself edited | **REFUSED**, same message |
| I3b | `SLEW_LEAD_STEPS = 1000` — the cap present but inert | **every row +0.00**, which is the honest report that the block is doing nothing |
| I4 | `SLEW_LEAD_STEPS = 0` — full back-calculation | every row moves further; this is the rejected form's number in §4 |
I3b is the one that matters: it proves the delta in §3 is caused by the block
and not by the two runs differing for some other reason.
**The law**, driven in the CI image on a purged tree, fingerprinted before,
after and after the revert:
| # | mutation to `app/control.py` | bytes | reds |
|---|---|---:|---|
| L1 | the whole SAFETY-48 block deleted | −195 B | 5 in `test_telemetry.py`, 4 in `test_control.py` |
| L2 | `SLEW_LEAD_STEPS = 0` — the velocity form | +0 B | 4 + 3 |
| L3 | `SLEW_LEAD_STEPS = 12` — a cap above the rail | +1 B | 4 + 3 |
| L4 | the cap applied unconditionally, outside the slew branch | −92 B | 1 + 0 — **and it was a SURVIVOR of all thirty files before that check existed** |
| L5a | the `min`/`max` FUNCTION names exchanged | +0 B | 5 + 5, one of them `test_control.py`'s own "frozen integrator never deadlocks" over 13 041 states |
| L5b | the two BRANCH BODIES exchanged — the other reading of "the cap on the wrong side" | +0 B | 5 + 4, and it does **not** reach the deadlock sweep |
| L6 | the lead measured against `prev_w` rather than the command emitted | +0 B | 6 + 3 |
⚠️ **L5 AND L6 ARE BOTH ZERO BYTES** — `min`/`max` and `slewed`/`prev_w` are
each the same length — so no fingerprint and no byte delta can see either, and
one of L5a's reds is `test_control.py`'s own *"frozen integrator never
deadlocks"* sweep over 13 041 states, written by SAFETY-13 for a different
reason.
⚠️ **THREE OF THESE COUNTS WERE WRONG AND ARE RE-MEASURED ABOVE, WHICH IS
MUTATION.md's REPORTING RULE 3 APPLIED TO THIS CARD'S OWN TABLE.** The earlier
row gave L5 as 2 + 5 and L6 as 4 + 3; driven again in the CI image on a `cp -a`
of this tree, purged, `:ro`, one file at a time, they are **5 + 5** and
**6 + 3**. ⚠️ **AND "L5" NAMED TWO DIFFERENT MUTATIONS.** "`min`/`max` swapped"
can mean the function names exchanged or the branch bodies exchanged; they are
both red, they are both the cap on the wrong side, and they give different
counts (5 + 5 against 5 + 4) and reach different checks. A row that writes one
number for a mutation a reader cannot re-derive from its own description is a
row that cannot be re-run, so both spellings are listed. The directions in the
original table were all correct; the counts were decoration, and decoration in
a mutation table reads as evidence.
## 7b. The transcript, quoted rather than summarised
`python3 tools/replay_recorded_loop.py --sensitivity`, on this branch, 58 s:
```
THE RECORDING: 256252 meter instants over 16.00 days, loop-2026-09-02_2026-09-17
tick p5 4.77 s median 5.01 s p95 7.86 s (control.LOOP_TICK_S says 2.0)
the recorded command: extremes -5000 / +5000 W -> max_w 5000; most common step above 100 W is 400 W, 9924 times -> slew_w 400
the fitted inverter: lag 7.5 s, tau 2.0 s, batt = 0.9731 x cmd -22.4 W, residual 375 W RMS above the SoC floor
FIDELITY - the BEFORE law replayed against the recording it came from
setpoint r=0.887 mean +601 W simulated vs +739 W recorded
grid r=0.682 mean +195 W simulated vs +239 W recorded
export 39.61 kWh simulated vs 29.43 kWh recorded -> the model is +35 % PESSIMISTIC about the loop's tracking
THE A/B, same ticks, same disturbance, same plant, same seed
release/1.0 SAFETY-48 delta
export kWh 39.61 34.07 -5.54 the cost
import kWh 116.07 111.20 -4.86
battery sign reversals 2992.00 2770.00 -222.00
wasted throughput kWh 4.67 3.19 -1.48
worst 15-min offtake W 3738.67 3433.94 -304.74 ⚠️ THE BILLED ONE
recovery events 549.00 612.00 +63.00
recovery median s 232.06 199.04 -33.01
recovery p99 s 849.00 849.94 +0.94
recovery max s 901.06 898.40 -2.66
unrecovered (>900 s) 53.00 31.00 -22.00
|i_w - prev_w| p99 W 3361.12 800.00 -2561.12
|i_w - prev_w| max W 9200.00 800.00 -8400.00
slew-limited ticks 16392.00 11654.00 -4738.00
mean quarter delta W -2.39 over 1534 quarters
worst quarter delta W +532.27
SENSITIVITY - the ranking, not the watts
variant export kWh worst quarter W mean quarter dW
plant lag 5.0 s 32.1 -> 27.2 3434 -> 3434 -1.02
plant lag 10.0 s 75.4 -> 68.3 3954 -> 3434 -4.65
plant tau 8 s 56.5 -> 49.8 3995 -> 3605 -3.30
gain 1.0135, offset -26 46.8 -> 39.3 3713 -> 3434 -3.27
slew_w 555.6 (the rail) 36.5 -> 34.4 3434 -> 3434 -0.17
gain 0.3 28.1 -> 27.0 3434 -> 3434 +0.16
(58 s)
```
⚠️ **`--sensitivity` IS THE RANKING AND NOT THE WATTS.** The export figure moves
by a factor of two across these plants; what does not move is the direction —
**on export**. ⚠️ **THE `worst quarter W` COLUMN IS NOT THE SAME KIND OF
EVIDENCE AND THIS PARAGRAPH USED TO TREAT IT AS IF IT WERE.** Three of its six
rows read `3434 -> 3434`, and that is not "the direction holds": it is
release/1.0 *already sitting at 3434 W* on those plants, with nothing for the
card to improve. §3a sweeps the lag properly and finds the same thing at every
lag ≤ 5.0 s. The billed-quarter improvement in the A/B table above exists at one
plant, the one `fit_plant()` selects by an objective that separates it from its
neighbour by 0.3 %.
Two rows deserve naming rather than rounding: at `slew_w: 555.6` (the rail) and
at `gain: 0.3` the mean quarter-hour delta is −0.17 W and **+0.16 W** — ties,
not improvements. Both are regimes where `|error| > slew_w / gain` is rarely
satisfied (1333 W at `gain: 0.3`, 926 W at the rail against a measured median
onset error of 1114 W), so there is little for the card to bite on. That is the
mechanism agreeing with itself, and it is also the honest answer to "would this
still be worth shipping if the tuning moved": at `gain: 0.3` it would not be.
## 8. What this card does NOT establish
* **Nothing here drives an inverter.** The replay is a model with a 375 W
residual and no arbiter in it. The closed-loop answer comes from the rig or
from a live run — SAFETY-47 records why a shadow run cannot give it.
* **The live tick is 5.0 s, not `LOOP_TICK_S`'s 2.0 s**, measured off 9924
recorded 400 W steps and off the meter's own cadence. It is wrong in the SAFE
direction (a slower meter makes `validate.py` rule 10 conservative) and is NOT
changed here — re-deriving `SLEW_RAIL_FRAC` is a refusal boundary on a live
install and belongs to its own card. It is recorded in the tool's header and
printed on every run.
* **The brief's plant figure was close but not exact.** It quoted
`delivered = 1.0135 × command − 26 W, r = 0.992` at a 4–6 s publish lag. On
this 16-day window, above the SoC floor, the same relation measures
`1.00 × command − 28 W, r = 0.977` at a best-fit pure delay of **7.5 s**. The
conclusion — the inverter is a pure follower and the plant is not the problem
— survives; the coefficients are the ones in §2.
* **The brief's cost figure is larger than this measurement.** It quoted ~14 kWh
of avoidable export and 65 % of site export inside 7.9 % of the time. Measured
here: **5.5 kWh** avoidable of 39.6 kWh modelled (29.4 kWh recorded), with
42 % of export inside the 6.3 % of ticks that are slew-limited and 69 % within
±60 s of one, over 15.3 % of the time. The concentration claim holds; the
avoidable-kWh figure does not, and the difference is not reconcilable from
here because the reconstruction it came from was discarded.
@@ -1,110 +0,0 @@
# SAFETY-52 - a battery that refuses is asked for 0 W
**Decided 2026-09-27 by the owner, on the phase A plan (option D3). All four
questions answered yes.**
## The defect
At the BMS cut-out the battery gives ~0 W whatever it is asked. The saturation
freeze (`control.py`, `frozen`) then holds the request where it stood, and its
SAFETY-13 exit cannot fire, because a reading of ~0 against a large ask lies
inside the interval a limit can produce. The request stood at +5000 W for
11.87 h on 2026-09-06 and at +1940 W for 3 h 55 m on the bus on 2026-09-19. It
moved again only when the grid error changed sign. SAFETY-48's lead cap only
changes where `i_w` parks (one slew step ahead instead of at the rail). When
the request moved after the reading went flat, the stale-reading gate won
instead and ran SAFETY-59's ~70 s trip cycle.
## What was decided
1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages
in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is
below `REFUSED_BELOW_W` (100 W, the probe's own answer) against an ask beyond
`saturation_w`. The drop to 0 skips
the slew limit (it only removes an ask nobody meets). The direction is
carried like `sat_count` (`refused=`, `Decision.refused`).
2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`,
`PROBE_S`; twice `BATT_OBLIGED_S`). A reading of at least 100 W in the held
direction ends the refusal, during a probe or not. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm
the gate. The clock is main.py's `now_mono`; `compute()` stays clockless.
3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for
control (`Controller._refusal_from_stale`). This is what stops the ~70 s
cycle. The log keeps SAFETY-56's two-explanation wording.
4. **No SoC in the loop.** SAFETY-25 Option A stands. Nothing reads SoC.
Other exits: the loop wants the other direction (charge is accepted at the
floor), control stopped or started, another writer on the wire (the arbiter
reseed). A gap in the readings keeps the refusal, because the gate's own stop is
what seeds it.
**One inverter only.** At N>1, OPT-09's `pack` limit already closes an empty
battery's discharge end and moves its share. There, a one-unit gate trip must
not refuse the site. `compute()` is passed `refused=None` there, and that path
is release/1.0's law unchanged (the `i_w=None` equivalence sweep in
`test_control.py` still holds).
## Evidence
`test_refusal.py` drives the real Controller and gate (the 09-06 and 09-19
shapes, recovery with no sign change, the probe cadence, every exit).
`tools/replay_refusal.py` replays the 16-day capture, same model as
`replay_recorded_loop.py`, and should be read for deltas:
| plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min |
|---|---|---|---|---|---|---|
| fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W |
| fitted | new | 130 (133 after review) | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W |
| offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W |
| offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W |
**Traced in review (#141).** The +22 W worst quarter is the old law's worst
quarter too. There the old law stands at +2060 W on a pack giving 0 W, and the
new law asks 0 W, which the fitted plant answers with its -22 W offset: 22 W
charged from the grid. The fit above the floor reads `batt = cmd - 28 W`, so a
real inverter asked 0 W may do the same. Of the 71 new-law trips with no
old-law trip within 2 min, 59 come 85-97 s after an answered probe. The offset has lifted the modelled pack
just above its floor, the probe is answered, control ramps, the pack empties
again mid-ramp, and the gate trips, which re-seeds the hold. The other 12
follow an other-direction exit the same way. On a site this is one
`STALE_READING` ERROR line per answered probe at most, and only on a battery
that answers a probe and then cuts out again. Catching the re-cut before the
gate needs a faster engage than `SETTLE_CYCLES`, which accuses a healthy slow
battery, so it is left to SAFETY-58/59. After the review changes below the
fitted-plant row reads 133 trips (the delivery exit now also catches answers
that arrive after the probe window); the other figures are unchanged. Rejected alternatives and their figures are in
the phase A plan: bounding the integrator by demand made the worst quarter
+541 W worse or cycled the gate 3368 times, and an SoC envelope needs the
configured floor to be right and needs SoC.
## Review changes (#141)
- **"Gives nothing" is below 100 W, not below 500 W.** At `saturation_w` a
battery derated to 100-499 W (cold, a tapering battery) was held at 0 W for
~93 % of the time: 300 W derated gave 86 Wh in 4 h against 1 200 Wh on the
old law. The capture has 0.11 h in that band, so the replay does not move.
- **Any delivery ends the hold, not only an answered probe.** With
`meter_poll_s: 1` the 12 cycles last 12 s, less than a healthy battery's
reading lag (up to `BATT_OBLIGED_S`, 17 s). That engaged on a healthy step and
held it for 15 min. Now the late reading ends it within seconds (2 s held in
`test_refusal.py` part 5b).
- **N>1 is stated as a limit in DOCS.** The pack limit there covers an empty
battery only when its cut-out level is configured right.
## Not covered
A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a
genuine limit (SAFETY-13 case a) and still holds its request.
## What SAFETY-58 and SAFETY-59 must take into account
- **SAFETY-58** (the first read after a rest trips at once): a probe never arms
the gate (200 < 250 W), but the refusal seeding reads the trip. If 58 delays
the first verdict, the seed arrives later. The 09-19 shape then needs the
compute-side engagement (12 cycles) to win, which it does only while the
request stands still. Keep `test_refusal.py` part 4 green.
- **SAFETY-59** (DOCS says a ~70 s pattern proves a failed register): with this
change a refusing battery no longer produces the pattern at one inverter. The
DOCS text should say so, and that the `battery refuses` reason is the
discriminator the add-on now has. At N>1 the old pattern can still occur.
- If either changes `MOVE_W`, `PROBE_W` must stay below it.
`test_refusal.py` part 2 checks the inequality.
@@ -1,181 +0,0 @@
# SAFETY-56 — what the gate cannot tell apart, said in the sentence
**Text only. No behaviour changes in this card**, and §5 is the measurement that
says so: the same stimuli through the tree before and after produce a
**byte-identical** trace of every verdict, every `reading()`, every `inputs_ok`
and every readiness row's `(ok, warn)`.
---
## 1. The defect
0.10.1 is regulating a real GW5000S-BP. Two messages tell the operator that
healthy hardware has failed.
**The log line**, as emitted on the reference site on 2026-09-21:
> SAFETY-37: inverter-a battery-power register **is not following its own
> command** - STALE_READING: -3 unchanged for 36 s while the standing command
> moved 800 W to +1600 W - **the reading is not following**, so it is reported
> as MISSING rather than as a measurement … Treating it as MISSING, which
> commands 0 W.
**The readiness row**, at the same moment:
> NOT READY — battery power: `sensor.kelder_goodwe_master_stage1_goodwe_inverter_ac_power`
> **is missing or not numeric**
That entity was present, numeric and updating.
**SAFETY-37's conjunction is correct and is not touched by this card** —
`unchanged > 35 s AND |command| > 250 W AND |travel| > 250 W`. A register
genuinely stuck against a real command is exactly what it should catch, and a
pack that cannot comply is a state the site must stop in too. What was wrong is
what the sentences **concluded** from it.
## 2. Two worlds satisfy the conjunction
| | the register | the pack |
|---|---|---|
| **DEAD** | has stopped reporting reality | is doing what it was told |
| **REFUSED** | is reporting reality correctly | cannot comply — the BMS is refusing at its floor |
A refused pack holds still under any setpoint, so an honest register publishes
the same unchanged number a dead one does. **Nothing `freshness.py` is handed
separates them.** All three sentences asserted DEAD.
## 3. The live episode was REFUSED, and it is measured, not argued
Off the committed capture `scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz`,
whose bytes and decoder are already graded by `test_freshness.py` part 10a:
| | |
|---|---|
| the register | answered **-3 W for 850 s**, 10:51:24 → 11:05:34 |
| the commands during that hold | **+260, +660, +1060 W** — discharge only, never a charge |
| pack SoC throughout | **11 %** |
| first **charge** command after the restart | 11:05:31 at **-400 W** |
| the register moves | **11:05:34** — **3 s**, inside one 17.0 s obligation window |
**A register that answers a reversed command inside one obligation window had
not stopped reporting.** What would not move was the pack. The sentence the
operator read named the one thing in the loop that was telling the truth.
⚠️ What this does **not** establish: that no register is ever intermittently
wrong. It excludes a register that had simply stopped; it cannot exclude one
that was wrong and then right. That is the point — the new sentence claims
neither.
## 4. The fix, and why it is three sentences and not one helper
Each message states **what was observed** and names **both** explanations.
| where | before | after |
|---|---|---|
| `app/freshness.py` `_grade()` | *"…+1600 W - the reading is not following, so it is reported as MISSING…"* | *"…+1600 W. **TWO THINGS LOOK LIKE THIS AND THIS GATE CANNOT TELL THEM APART**: the register may have stopped reporting reality, or the source may be unable to comply with the command - a pack the BMS is refusing holds still under any setpoint, and an honest register then reads exactly like a frozen one. Either way the number cannot be read as this command's effect, so it is reported as MISSING…"* |
| `app/main.py` `_read_unit()` | *"SAFETY-37: %s battery-power **register is not following its own command** - %s."* | *"SAFETY-37: %s battery-power reading **did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER** - %s."* |
| `app/main.py` `checks()` | *"{label}: {entity} **is missing or not numeric**"* | *"{label}: **no usable value this cycle from** {entity} - **and this row cannot tell which of two things that is**: the entity is missing or not numeric, or it is publishing a number that this controller is withholding (a freshness gate does exactly that, and names itself in a row of its own). Confirm the entity in Developer Tools before replacing it"* |
**Three sentences, three wordings, no shared helper.** The first two are a log
line about a **behaviour**; the third is a banner about a named **entity**, and
the two answer different questions ("what did the register do" against "can I
trust this entity"). A helper serving both would have to drop whichever half
the other caller does not need, and the half it dropped would be the
operator's next step. The log line names the pair **once**, inside the `%s` it
already interpolates, rather than in both of its own halves.
**No new input, and SoC is not wired.** `BOUNDS["soc"]` stays *STATED, NOT
WIRED*; telling the two worlds apart needs the pack floor (SAFETY-32's number)
and SoC (TEL-08's wiring), and a message honest about its own ambiguity needs
neither. Part 11b drives the two worlds at **50 %** and **11 %** SoC and
asserts the sentence does not move one byte.
**The readiness row does not branch.** `inv.batt_fresh.stale` is on the same
object at the same moment, and the row deliberately does not ask it: the same
loop renders the grid and SoC rows, which have no gate, and a branch per row
would be a second place where "why is this None" is decided. The gate already
has a surface — `stale_note()` rides `liveness_notes()` a few rows below with
the whole STALE_READING sentence in it — so the operator's discriminator is
*"is that row there?"*, and part 11d asserts that row is **present in REFUSED
and absent in ABSENT**.
## 5. Behaviour is byte-identical, measured
`tools/safety56_behaviour_trace.py` prints two documents from one stimulus set.
The `behaviour` half contains **no sentence**: only `stale`, `reading()`,
`releases`, `held`, the window bookkeeping, `inputs_ok`, and every readiness
row's `(ok, warn)`. Run in the CI image, purged, `:ro`, against
`origin/release/1.0` **archived out of git** and against this branch:
| | |
|---|---|
| the committed live capture | **2390 reads**, 7 verdict edges |
| a swept grid of command × register schedules | **66 cells × 120 reads** — 1060 stale reads, 1309 `MISSING` readings, 6 releases |
| six driven controller worlds through the shipped read path | DEAD, REFUSED, ABSENT, HEALTHY, RELEASE, RETRIP — every read's `(batt, inputs_ok, stale)` and every `checks()` row's `(ok, warn)` |
| **result** | `6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996c` **on both trees** |
⚠️ **AND THE CONTROL IS INSIDE THE TOOL, BECAUSE AN IDENTITY BETWEEN TWO TREES
THAT ARE THE SAME TREE IS HABIT 4.** `--compare` refuses to report "behaviour
unchanged" unless the `sentences` halves **differ**. This card's first
invariance run was exactly that mistake — it archived a branch whose edits were
not committed yet, so `before` and `after` were the same code and the identity
was perfect and worthless.
Both of the tool's refusals are **driven**, not asserted:
| | |
|---|---|
| the same file compared with itself | `REFUSED: the sentences halves are IDENTICAL…`, exit **2** |
| `MOVE_W` 250.0 → 240.0 in the after tree | `BEHAVIOUR MOVED at 'sweep_stale_reads' / 'sweep_missing_reads' / 'sweep_trace'`, exit **1** |
| this card | `OK: sentences differ, behaviour is byte-identical.`, exit **0** |
## 6. The mutation battery
Each in its own `mktemp`'d copy (habit 18), purged on the host, mounted `:ro`,
graded by `tools/mutation_run.sh` in the CI image.
| | mutation | Δ bytes | named reds |
|---|---|---|---|
| M1 | `_grade()` reverted to *"the reading is not following"* | -402 | **3** |
| M2 | the log line reverted to *"register is not following its own command"* | -80 | **2** |
| M3 | the row reverted to *"{entity} is missing or not numeric"* | -619 | **2** |
| M4 | *register* and *source* transposed between the two explanations | **0** | **1** |
| M5 | the second explanation deleted, the first kept | -216 | **1** |
| M6 | *"CANNOT TELL THEM APART"* replaced by *"IN ORDER OF LIKELIHOOD"* | -13 | **1** |
| M7 | the REFUSED world's ground truth set to the DEAD world's | 0 | **2** |
| M8 | the row's second cause deleted | -309 | **1** |
| M9 | `label` and `entity` transposed in the row | **0** | **3** |
| M10 | M1 + M2 + M3 together, i.e. exactly what 0.10.1 ships | -1101 | **5** |
| M11 | the accusation literals replaced by one that matches nothing | 0 | **1** |
⚠️ **M9's first run was a defect in this card's own check, and it is the one
worth reading.** The row was fetched with `[...][0]`. Transposing `label` and
`entity` makes the row unfindable, so the run died on an `IndexError` — a red
with **no name attached**, with every check after that line **ungraded**. That
is FCST-06's shape exactly. `_s56_row()` now returns a named absence instead of
raising, and a precondition grades it: **0 named reds before, 3 after.**
⚠️ **M11 is the other one aimed inward.** `not any(...)` over an empty list is
True, and so is one over three mistyped literals. The positive control runs the
same predicate against the three sentences **as 0.10.1 shipped them** and
requires it to find an accusation in every one, and every literal to match at
least one of them (habit 7: vary one thing from an accepted baseline).
## 7. What part 11 does not grade
* It does not show the live episode was a BMS refusal beyond doubt — see §3.
* It does not grade English. A sentence that named both explanations and then
said something useless about them would pass.
* The `no entity configured` branch beside the rewritten row is untouched and
ungraded: a blank entity really is the only thing it can be.
* The **grid-power and battery-SoC rows** go through the same rewritten line and
have no freshness gate today, so for them the second cause is a hypothesis
about this controller rather than a live possibility. That is weaker than the
battery-power case and is the price of not branching per row. A gate on either
signal — TEL-08 would be one — makes it exact.
* **SAFETY-58 is not touched** (the first read after a long rest trips before a
healthy register could answer), and neither is the 710/790 disagreement
between `test_freshness.py` and the changelog. Both are separate cards; the
second has a **fourth site** at
`DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md:274` that a fix written
off the PR table alone would miss.
@@ -1,388 +0,0 @@
# SAFETY-57 — the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
**Status:** decided and implemented on branch `SAFETY-57`, off `origin/master` @ `6f5e8e3`
(content-identical to `release/1.0` @ `8a57361`).
**Card:** SAFETY-57 — *"the freshness gate has no exit reachable from inside the state it creates"*.
**Nothing here reached the inverter.** No contact was made with 192.168.2.158 or
192.168.2.221. The one live system touched is `sniffer@192.168.2.167`, a
**passive** RS-485 listener with no address on the bus, and it was read only.
---
## 0. The five things a reader should take away
1. **The live episode is reproduced from the wire, not modelled.** The capture
is committed, the decoder is committed, and the box that produced it was
running the defect — so the `0 W` in the trace *is* the add-on's own
`inputs missing → command 0 W`. §2.
2. **Option 2 from the card — "do not arm the gate when the command is 0 W" —
already holds, and it is not the bug.** `_grade()` has required
`|standing| >= MOVE_W` since SAFETY-37. The trip happened at **+1060 W** and
the stuck state is at **0 W**; they are different moments. §3.
3. **The decision is not "how long", it is "when".** The verdict is withdrawn
when the **obligation that produced it is no longer standing** — SAFETY-13's
shape one module over. The 17.0 s is the obligation window that was already
derived in the file, taken at the **floor** of its band, and which half is
safe is the **opposite** of SAFETY-13's. §4.
4. **It costs 52 % exposure on a permanently dead register, and that number is
in the suite so it cannot be forgotten.** It is payable only because one
re-grading window (35 s) is shorter than both consumer holds (75 s, 85 s).
**No detection SAFETY-37 had is given back.** §5.
5. **The battery had been rescuing this code by accident.** 93 trips in the
committed 16-day capture; median wait for the BMS keep-alive pulse that
cleared them **143 s**, worst **5 h 57 m**. §6.
---
## 1. What was there, in the terms the code uses
`freshness.py`, `Freshness.submit()` and `_grade()`:
```python
if self.changed_mono is None or value != self.held:
...
self.stale = False # the only place the latch is cleared
def _grade(self, standing_w, now_mono):
if self.stale or self.changed_mono is None:
return # sets the latch; never clears it
```
and `main.py:1777`, `inv.batt = inv.batt_fresh.reading()`, where `reading()`
answers `None` while `stale`. `inputs_ok` sees the `None`, the site holds for
`stale_input_s`, then files `Claim.set("safety", P_SAFETY, 0.0, "inputs
missing")` and keeps filing it.
The reason the latch exists is written in the class docstring and it is a good
one: without it the verdict oscillates at about 1 Hz, because declaring the
reading stale drops the command below `MOVE_W`, which un-arms the conjunction,
which hands the held number straight back.
**The defect is that the only thing that ends it is an arrival carrying a
different number — and the state's own output is what stops the register being
asked for one.** The log line promised the opposite, in the same sentence that
announced the 0 W:
> *Treating it as MISSING, which commands 0 W. It clears itself the moment the
> register reports a different value.*
---
## 2. Reproducing it, from the wire
`scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz` is 3840
verbatim frames off the RS-485 pair, 10:30–11:10 Europe/Brussels, decoded by
`tools/decode_bus_capture.py`. The decoder reports **0 unparsed, 0 bad CRC,
0 unknown shapes**, and its mode→sign convention is **graded off the capture**
rather than trusted: 298 commands at or under −1000 W are each answered one
obligation window later by a negative register reading, and not one positive.
| capture time | on the wire |
|---|---|
| 10:51:14 | `0x0518` = **−447 W**; the loop is charging |
| 10:51:19 | the command reverses: EMS **mode 3**, 260 W of discharge |
| 10:51:24.7 | `0x0518` = **−3 W**. The pack is at **11 %**, the BMS refuses the discharge, the inverter idles. **The register is healthy and truthful.** |
| 10:51:29.7 | the command reaches **+1060 W**. 800 W of travel |
| 10:52:21.7 | the setpoint is written **0 W** — this is the add-on's own §11.1 rule 1, recorded on the wire |
| …850.0 s | `0x0518` does not change value |
| …790.0 s | the setpoint does not change value |
| ~11:04 | **a person restarts the add-on** — not visible in this file, and the only thing that ends the episode |
| 11:05:31.7 | the restarted loop commands **−400 W of charge**, which the pack accepts at 11 % |
| 11:05:34.7 | `0x0518` = **−137 W** |
⚠️ **THE CAPTURE IS CLOSED-LOOP FOR THE WHOLE PARKED INTERVAL**, which is why
this is a reproduction and not a simulation: the box was running 0.10.0, so
every command in the trace is the shipped code's own. The only event inside the
window the file cannot account for is the human restart.
Replaying the shipped `_read_unit` loop at 1 Hz against those two series
(`test_freshness.py` part 10b):
| | stale at | clear at | parked |
|---|---|---|---|
| **before** (`release_s = inf`) | 10:52:00 | 11:05:35 | **815 s**, and `reading()` is `None` on all **812** reads from the trip to the first command a human put on the wire, with the register at −3.0 W in every one of them |
| **after** | 10:52:00 | **10:52:39** | **39 s**, with `0x0518` still answering the same **−3 W** |
⚠️ **THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS GRADED RATHER THAN
CLAIMED.** With an infinite bound `_release()` never fires, `_open_window()` is
only ever called where the register moves, and the window pair is the
change-stamp pair — byte-for-byte the pre-SAFETY-57 conjunction. Part 10b
asserts that by driving part 1's own fresh/stale pair through both bounds and
comparing the verdict streams.
The replay also found a **one-read trip at 10:48:14** that clears one second
later. That is a pre-existing false positive and it is now recorded as
survivor 5 — §7.
---
## 3. What the card's option 2 actually buys, which is nothing here
The card offered *"do not arm the gate when the command being graded is 0 W"*
and warned that it might hold already. **It does, and it has since SAFETY-37**:
`_grade()` returns at `abs(standing_w) < self.bound.move_w`, and
`test_freshness.py` part 4 has a named check on that term, added because a
mutation removing it survived.
The warning in the card is the right one and it is worth writing down: **the
trip and the stuck state are different moments.** The gate armed at **+1060 W**,
where the conjunction is entirely correct — a command travelled 800 W and the
register did not move for 35 s. The site then went to **0 W** *because of the
verdict*. An entry-side rule cannot reach a state that is entered legitimately
and then persists. **The defect is the exit, and nothing else in the conjunction
is touched by this card.**
Option 3 — probing with a small non-zero command — was not taken and does not
need to be. It writes to a real actuator to test an assumption, and §4's rule
does not need a probe: the site's *normal* control resuming is the test, and it
is gated behind the same `inputs_ok` everything else is.
---
## 4. The decision: which verdicts get an exit, and what the bound is
> The stale verdict holds as before, **unless** the obligation that produced it
> is no longer standing — `|standing| < MOVE_W` — **and** it has been gone
> longer than the register would have needed to answer it. On a release the
> latch clears and a **new grading window** opens at that instant.
This is [[SAFETY-13]]'s §4 in a different module, and deliberately so: that card
established the house answer to "a latch with no exit" and its answer was *not*
a plain time bound but a **predicate plus a time bound**, where the predicate
says whether the state can end on its own. Here the predicate is that **the
question is no longer being asked.** A register under a 0 W command is obliged
to be nowhere, so a verdict about whether it is following cannot be tested, and
a verdict that cannot be tested may not stand for ever.
### 4.1 Why the window has to re-open, and what happens if it does not
`_grade()` compares the register's unchanged time against `unchanged_s` and the
command's travel against the command standing when the window opened. If a
release cleared the latch **without** re-opening the window, the first read at
which the resumed command travels 250 W would re-trip immediately — the
register would never get the 17 s in which it could answer, and the live
episode's recovery (a charge the pack **accepts**) would be killed inside two
seconds. Mutating exactly that (`graded_from` re-anchored to `changed_mono`,
+82 B) takes four named checks red.
The new window is `BATT_UNCHANGED_S` = **35 s**, the same bound as entry,
because it is the same question — *does this register follow the commands issued
from now on?* — and there is no principled shorter answer to it. It is also the
number the exposure argument in §5 rests on, so it is not free to move.
### 4.2 `RELEASE_S` = `BATT_OBLIGED_S` = 17.0 s, and which half is safe
The quantity needed is *has the register had its chance to answer the command
standing now?*, and `freshness.py` already derives it as the floor of
`BATT_UNCHANGED_S`'s band:
```
6.0 s plant goodwe/NOTES.md §5.9, measured 2026-08-22
10.0 s publisher firmware/goodwe-master.yaml:181, update_interval: 10s
1.0 s this add-on run_control()'s await asyncio.sleep(1)
```
⚠️ **IT IS THE FLOOR OF ITS BAND, NOT A SENSITIVITY PREFERENCE** — the mirror of
`MOVE_W` taking the **ceiling** of its own for the mirrored reason.
| | the question | the safe half | why |
|---|---|---|---|
| SAFETY-13's `SETTLE_CYCLES` | may the plant still be the **explanation**? | the **longer** half | a shorter bound stops believing the plant too early, and then it accuses |
| this one | may the verdict still be **tested**? | the **shorter** half | being wrong *long* is the failure being fixed; being wrong *short* costs one re-grading window, and §5 bounds that |
Below 17.0 s the release pre-empts the **cheaper exit** — the register answering
the new 0 W command by itself, which clears the latch on evidence rather than on
a timer. That is graded behaviourally rather than asserted: a register that
answers at +16 s clears by **movement** at `release_s = 17.0` with `releases`
still 0, and by **timer** at `release_s = 5.0`.
The family is computed and printed (habit 10): `5, 16, 17, 18, 35, inf` give
six distinct release instants, so no check in the file can confuse two of them.
### 4.3 Two clocks, and the operator reads the register's
`changed_mono` / `unchanged_s()` remain facts about the **register**: after a
release they go on reporting that it has not moved for 850 s, because it has
not. `graded_from` / `graded_s()` are the **verdict's** window. They coincide
until the first release. Collapsing them either lies to the operator or defeats
the re-grading, and both directions are mutated red.
The stale sentence's `unchanged for N s` slot carries the **register's** number;
a re-trip appends a clause naming the window and the grade count, so the two
durations cannot be read as one number that moved (habit 14).
---
## 5. What it costs, measured
Driven against a register frozen for good, 1201 reads, shipped defaults
(`test_freshness.py` part 10c):
| | parked | the held number reaches a consumer | releases |
|---|---|---|---|
| before | 1166 s | **35 reads (2.9 %)** | 0 |
| after | 571 s | **630 reads (52.5 %)** | 17 |
⚠️ **52 % IS PAID EVERY CYCLE, NOT OCCASIONALLY.** The period is
`stale_input_s` (15 s) + `RELEASE_S` (17 s) parked, then one 35 s window.
It is payable because of one inequality, and it is the inequality
`test_freshness.py` part 6 has graded against the shipped `config.yaml` since
SAFETY-37:
```
35 s (the re-grading window) < 75 s (circulation_hold_s) < 85 s (unit_sign_disagree_s)
```
Every unbroken run of exposure is **exactly one window**, so neither consumer
can reach a verdict inside one, and the re-trip that ends each run hands them a
`None`, which **clears** their windows rather than accumulating into them.
**And nothing SAFETY-37 detects is given back**, which is the half that makes
this a trade rather than a regression: a permanent `None` clears those same two
windows for ever as well, so on the shipped-before module those detectors are
*equally* unable to speak about the unit. What the held number does reach for
35 s is `site_batt_w`, the anti-windup input of `control.compute()` — which
answers a large unexplained divergence by **freezing the output**, the
conservative direction and SAFETY-13's business — and the command itself is
derived from the **P1 meter**, never from `batt`.
### 5.1 The narrowing this does NOT take, named so the next card can
The live episode's register refused a **discharge** into an 11 % pack and then
followed a **charge** three seconds later. A refusal in one direction is not
evidence about the other, and a gate that knew the pack's floor could re-grade
on the first command that crosses it instead of on a timer — which would shrink
the 52 % above. That needs SoC, which this module deliberately does not take
(see the `soc` row and TEL-08), and the pack floor, which is SAFETY-32's number.
It is **not needed to give the state an exit**, and folding it in here would
make the exit depend on a second measurement being right.
---
## 6. The measurement that says the old exit was luck
`scenarios/loop-2026-09-02_2026-09-17/` replayed at 1 Hz through the shipped
conjunction (`test_freshness.py` part 8b): **93 trips in 16 days.** The only
thing that could clear any of them was the register carrying a different number,
and that took:
| | |
|---|---|
| minimum | 0.6 s |
| **median** | **143.4 s** |
| maximum | **21440.1 s** (5 h 57 m) |
| at or over the live episode's 710 s | **17 of 93** |
The short waits are the BMS keep-alive pulse — SAFETY-50 measured its ceiling at
508 W — twitching `0x0518` and satisfying the clearing rule **by accident**.
Over a fifth of the trips are cleared in under 20 s by something nothing in this
add-on asked for.
⚠️ **WHAT THAT REPLAY IS AND IS NOT.** The capture runs 2026-09-02…17 and
0.10.0 — the first version carrying this gate — was cut on 2026-09-16, so the
setpoint column is an **ungated** controller's. That makes each trip an honest
register-versus-command divergence off the real bus, and it makes the post-trip
command column something a gated box would **not** have written. The figure
taken from it is therefore the only one that does not depend on the command at
all — how long this register goes without changing value — and it **under**-states
the wait, because a site parked at 0 W gives the register even less reason to
move than the one recorded here.
---
## 7. Survivors, and the two things the battery found
The battery is 22 mutations over `app/freshness.py`, `app/main.py` and
`tools/decode_bus_capture.py`, plus one over the committed capture itself. All
run through `ci_run.sh`, a mirror of `checks.yml`'s own step: a purged copy of
the tree, bind-mounted `:ro`, one file at a time, `PYTHONDONTWRITEBYTECODE=1`.
Per MUTATION.md habit 18 the harness's scratch file is `mktemp`ed per
invocation and the tree is fingerprinted **after the revert** as well as before;
every revert restored the recorded sha256 and byte count.
**21 red, 1 argued equivalent, 0 unexplained survivors.** The table is in the
PR body. Two of them are worth keeping here.
### 7.1 The equivalent mutant, argued rather than rounded off
Swapping `_release()` and `_grade()` in `submit()` is a **zero-byte** edit and
survived the whole battery. **It is not a missing fixture and no fixture can
close it**: `_grade()` acts only when `|standing| >= move_w` and `_release()`
only when `|standing| < move_w`, so at most one of them can do anything on any
read, whichever runs first.
⚠️ **THE FIRST VERSION OF THE CODE CLAIMED THE ORDER WAS LOAD-BEARING**, in a
`⚠️` comment, with a reason that sounded right. It was false, and the battery is
what said so — **MUTATION.md habit 22 exactly, one card later: an explanation in
a comment is a claim, and this one had not been run.** The comment now states
the disjointness, and part 10 pins **that** — at 0, 100, 249, 250, 251 and
1300 W — instead of pinning an order that is not observable.
### 7.2 The sibling that survived, which is habit 11 earning its place
`_release()`'s obligation test is `abs(standing_w) >= self.bound.move_w`.
Swapping `move_w` for `unchanged_s` — comparing **watts against 35** — survived
the file at **+5 B**, because **every fixture rested at exactly 0 W**, and 0 is
below both 35 and 250. They separate only in the band between them, and a site
can sit there: a peak-guard floor, a shadow substitution, any claim that wins
with a small non-zero number. Closed by a three-point fixture at 0 / 100 / 300 W
that asserts the 100 W case releases and the 300 W case does not.
### 7.3 ⚠️ And the instrument lied once, which is habit 16's second half
The first form of the `graded_from` mutation was
`float(self.changed_mono or now_mono)`. It reported a **survivor at +21 B**, and
the tree fingerprint showed the edit really was on disk and really did run.
`changed_mono` is **0.0** in the dead-register fixture, and `0.0 or x` is `x` —
so the mutant was a *no-op on the fixture*, for a Python truthiness reason that
has nothing to do with the property under test. Rewritten as an explicit
`is None` test (+82 B) it is red on four named checks.
**A mutation is subject to every habit in MUTATION.md, including habit 4.** A
purge, a fingerprint and a byte delta all say the edit is present; none of them
says the edit *changed the answer on this stimulus*. MUTATION.md gains habit 19
for it.
---
## 8. What this does NOT fix, said plainly
**It does not tell a BMS refusal apart from a dead register**, and it does not
try to. Both are re-graded on the same timer. §5.1 names what telling them apart
would need.
**It does not make the diagnosis right.** The line the operator reads still says
*"battery-power register is not following its own command"*, which is wrong in a
different way when the pack is simply empty. That wording is **SAFETY-56's**,
together with `main.py`'s NOT READY row, and it is deliberately untouched here —
two cards editing one sentence is how one of them silently loses. Only the
**escape clause** was rewritten, because this card is what made the old one
false, and part 10h has a named check asserting the diagnosis half is still
there so that rewrite has to be a deliberate act.
> ⚠️ **SINCE SUPERSEDED, WHICH IS WHAT THAT CHECK WAS FOR.** [[SAFETY-56]] is
> the deliberate act: all three sentences now name **both** explanations and
> accuse neither, and part 10h's check is inverted — it requires the accusation
> to be **gone**. Nothing else in this section moved; the escape clause above is
> still this card's and is untouched by it. See
> `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`, and
> `test_freshness.py` part 11 for the graded pair of worlds.
**Survivor 5 is older than this card and is not closed by it.** After a long
rest the unchanged clock is already past 35 s, so the **first** read carrying a
travelled command trips the gate — before the 17.0 s in which a healthy register
could have answered it. The live capture contains one (10:48:14, cleared one
second later). Its size is one `stale_input_s` hold, during which `cycle()`
returns at `inputs missing {n}s` and commands nothing differently. Fixing it
means measuring the unchanged clock from when the **obligation** appeared rather
than from the register's last move, which re-derives the 35.0 s bound and is a
different card.
**Nothing was measured on two inverters.** Inverter B does not physically exist
(`goodwe/NOTES.md` §1.1b). Every two-unit figure in `test_freshness.py` is two
copies of A's behaviour with different numbers passed in.
**No deploy is proposed.** 0.10.0 is on the reference install and this branch's
CHANGELOG row is filed under it only because `test_version_gate.py` part 11b
requires the newest heading to be a version. Moving it, and deciding whether a
version is cut, is a PKG card's.
@@ -1,106 +0,0 @@
# SAFETY-61 — the control task's death ends the process
## 0. The defect
`amain()` started `run_control()` as a bare task and waited on `stop`. Nothing
watched the task. An uncaught exception in `read_inputs()`, `cycle()` or
`publish()` ended the task silently, and `stop` was never set. The web UI, P1
and tariff tasks kept running, and the Supervisor saw a healthy container. The
traceback appeared only at shutdown. The ESP32's layer-1 veto zeroed the
inverter about 30 s later, so the hardware was safe. The pack then sat at 0 W
indefinitely while the add-on showed green.
`test_control_task.py` reproduces this on the pre-fix tree. The child process
reaches its 40 s deadline, has written no 0 W, and has logged nothing.
## 1. Decision: exit with status 70 after the ordinary wind-down
A done-callback on the control task (`control_task_done()`) handles the death.
It logs the traceback at CRITICAL at the moment of death, then sets `stop`.
`amain()` then runs the same wind-down that SIGTERM runs (`shutdown()` →
`command_site_zero()`, then cancel, then clean up). After that it raises
`ControlTaskDied`, and `main()` turns that into `SystemExit(70)` (sysexits
EX_SOFTWARE). A normal stop and `run_control()`'s own refusal path both set
`stop` before the task ends, so the callback ignores them. The refusal still
exits with 78.
**Why not restart the loop in the same process:**
* `cycle()` writes controller state before its awaits: `cycle_at`,
`peak_guard`, `allocation`, then `target`/`reason`, then the writes. An
exception between those steps leaves the controller half-updated. A loop
restarted in the same process would continue from a state that no boot ever
produces. A process restart rebuilds the controller from the Store, which is
the path every boot already runs.
* An unbounded retry is the invisible spin the card forbids. A bounded retry
would reimplement the Supervisor watchdog's own throttle: 10 restarts per
30 min, and 5 attempts with backoff (`apps/const.py`).
## 2. What happens after the exit, verified rather than assumed
* **Exit status reaches Docker.** The add-on image (s6-overlay, `init: false`,
`CMD /run.sh`) was run with `exec python3 -c 'raise SystemExit(70)'`.
`docker inspect` gave ExitCode 70. The same test with exit 0 gave 0, and an
uncaught exception gave 1.
* **Supervisor 2026.08.0** (`ghcr.io/home-assistant/amd64-hassio-supervisor`,
source read from the image):
* A non-zero container exit is recorded as `ContainerState.FAILED`
(`docker/monitor.py`). The Supervisor logs *"App … exited with non-zero
exit code 70"* at ERROR (`apps/app.py container_state_changed`).
* The Supervisor restarts the add-on **only if the per-install Watchdog
toggle is on**. `watchdog_container()` returns when `not self.watchdog`.
The toggle defaults to **off** (`apps/validate.py`:
`vol.Optional(ATTR_WATCHDOG, default=False)`).
* Add-on containers get **no Docker restart policy**. The only
`restart_policy=` in the Supervisor belongs to the observer.
* `boot: auto` in `config.yaml` only controls starting at host boot. The
`watchdog:` key in `config.yaml` is a health-check URL, not a restart
policy, and this add-on does not set it.
* **So on a default install the add-on stays stopped after this exit.** The UI
shows it as stopped, its log holds the traceback, and the Supervisor log holds
an ERROR line. The pack is at 0 W, which is the same physical state as before
this card. The difference is that 0 W is written immediately instead of after
the ~30 s veto, and the add-on no longer shows green. With the Watchdog toggle
on, the Supervisor restarts the add-on.
* **Not verified:** an actual restart by a running Supervisor. That would need a
live Supervisor, and the only one available is the live house.
## 3. Left open for the owner
* Whether `DOCS.md` should tell installers to enable Watchdog. DOCS.md is not
this card's file.
* Setting the Watchdog toggle from inside the add-on (`/addons/self/options`)
was rejected here, because it would change a user setting without the user
knowing.
## 4. Scope: the siblings, and why they are not supervised
* `run_tariff_refresh()` and the P1 HTTP and HA transports already catch
`Exception` inside their loops by design, and the values they feed are
age-bounded downstream.
* The P1 MQTT source *returns* on purpose when it is misconfigured. Making every
long-lived task's end exit the process would turn that deliberate fail-safe
into a restart loop.
* Only the control task is supervised.
## 5. `hass.py`: an undecodable body is an absence
A non-JSON `200` served as `text/html` was **already** treated as an absence,
because aiohttp's `ContentTypeError` is a `ClientError`. The reads that actually
escaped were these three:
* a malformed body labelled `application/json`, which raises `JSONDecodeError`
(a `ValueError`);
* a JSON body that is not an object, which raises `AttributeError` in
`number()` and `limits()`;
* an undecodable error body in `call()`, which raises `UnicodeDecodeError`.
All three were measured against aiohttp 3.13.5. The first two now read as
`None`, and the third is now a failed write (`False`).
## 6. Status row
`checks()` now starts with a *control loop* row showing how long ago the loop
last completed a pass. The row turns red once that age exceeds
`FIRMWARE_INGEST_VETO_S`. The heartbeat is sent from this loop, so past that age
layer 1 has zeroed the inverter.
@@ -1,104 +0,0 @@
# SAFETY-73 - a battery that refuses, with two or more inverters
**Decided 2026-09-27 by the owner, on the phase A plan (option R). All four
new questions answered yes. SAFETY-52's four decisions carry over.**
## The defect
SAFETY-52 held a refusing battery at 0 W at one inverter only. At N>1
(`main.py` passed `refused=None`) nothing closed a unit that gives ~0 W while
its state of charge reads above its configured cut-out, so the allocator kept
handing it its weighted half. Measured on the real `Controller`, two units,
house 3000 W, A cut out:
| register | law | B gives | grid | A's gate trips / h |
|---|---|---|---|---|
| flat -4 W | old | 533 W | +2471 W | 46 (each stops the whole site) |
| flat -4 W | new | 2000 W (its rating) | +1004 W | 1 |
| jitter | old | 1910 W | +1094 W | 0 |
| jitter | new | 2000 W | +1004 W | 0 |
House 1200 W, which B alone carries: old +671 W (flat) and +284 W (jitter)
standing import, new -16 W. Both cut out: old keeps asking two empty batteries
(+1910 W each on the jitter shape), new holds the site at 0 W.
## What was decided
1. **A refusing unit is closed in that direction** by its own P_SAFETY unit
claim, source `refused`. It engages when the unit gives under
`REFUSED_BELOW_W` (100 W) for `SETTLE_CYCLES` (12) cycles, spanning at least
`REFUSED_SPAN_S` (22 s), in one direction while asked more than `PROBE_W`
(200 W), or when its stale-reading gate
trips on a held reading under 100 W. The allocator then hands its share to
the others within their own limits (owner decision 1). It is a hard unit
limit that always intersects, so OPT-11's P_MODE layer fits inside it.
2. **It binds every strategy's ask on that unit** (decision 2): peak guard,
arbitrage and maintenance are placed around it like the loop.
3. **A refusal to give power above the configured cut-out** logs INFO plus one WARNING ending
in `REFUSES_ABOVE_CUT_OUT` (decision 3). SAFETY-42/43 no longer report such a
unit: its share is 0 W, and `account()` treats it as parked.
4. **All units refusing the same way** pass that sign to `compute()`, so the
site holds 0 W with SAFETY-52's law (decision 4). Probes go one unit at a
time.
5. **Probe:** every 15 min for 34 s the claim becomes a pin at +-200 W and the
others carry the rest. The pin stands only while the resolved site target
asks at least 200 W its way; otherwise it would make a sibling take the
opposite direction, so it falls back to the closed limit for that cycle.
6. **Exits:** a reading of 100 W or more in the held direction (probe or not);
100 W or more taken the other way for 12 cycles (charge at the floor; one
cycle of the loop overshooting through zero is not enough); stop or start.
On every exit the site target and integrator restart from the watts on the
wire, or the reopened unit is handed a parked remainder at once.
**Why 200 W and not `saturation_w`.** A unit's share is often small: a 400 W
night load split two ways asks 200 W of each. With 500 W the empty battery
kept a phantom share that SAFETY-43's relative arm then reported as not
delivering, the ERROR decision 3 replaces. Shares of 200 W or less that give
under 100 W are "too small to call" in `account()`: the sibling covers the
meter and nothing is asked of the empty battery that matters.
**Why one direction throughout.** On TEST-19's flapping family a command that
swaps sign each cycle reads ~0 through the plant's lag: 42 engagements in 21
no-fault runs without the rule, 0 with it.
**N=1 is unchanged.** Every SAFETY-73 path is gated on `len(inverters) > 1`.
`test_refusal_units.py` part 6 runs SAFETY-52's whole suite with every
SAFETY-73 method made to raise.
## Evidence
- `test_refusal_units.py`: cases a, b and c on both register shapes against
the old behaviour, probes and their spacing, one-at-a-time probing, the
recovery reseed, each exit, the small-ask and small-site-target cases, the
WARNING, the status row and shadow mode. Mutations U1-U13 in its docstring.
- **No false engagements.** TEST-19's no-fault campaign (admin/goodwe
`sim/delivery_window.py` families, 2640 runs at N=2 and 2640 at N=1, every
unit executing its share) graded with the engage clause: 0 engagements, the
longest qualifying streak 5 cycles against 12. The grader is
`goodwe-session/scratch_s73/s73_nofault.py`.
- **Recovery.** Case a, A recovering into a house that fell below the parked
target: worst grid +0 W with the reseed. Phase A measured -1059 W without the
reseed and the site hold.
## Review changes
- **The evidence must span 22 s, not only 12 cycles.** At a 1 s meter the 12
cycles were 11 s, shorter than a healthy reading's lag (dead time plus a
10-17 s publish). The reviewer's harness (real `Controller`, both units with
6 s dead time and a 13-17 s publish, load steps 300/3000 W every 2 min) then
refused both healthy units 10-16 times an hour, and each time the site held
0 W: +50-85 Wh import an hour and a +4.6 kW grid peak against +3.7 kW without
the refusal. At a 2 s tick there were none. `REFUSED_SPAN_S` is
`(SETTLE_CYCLES - 1) * LOOP_TICK_S`, so 2 s and slower ticks are unchanged.
- **The cut-out WARNING is for a refusal to give power only.** A full battery
that stops taking charge is always above its cut-out level, and the WARNING
told the installer to check a setting that has nothing to do with it.
## Not covered
- A unit that gives a steady part of its ask (100 W or more) is a limit, as at
N=1. SAFETY-42/43 still report it.
- `pack_permissions` stays SoC-based, so the peak guard may size a shave with a
refusing battery counted. The shortfall shows as `unmet_w`.
- A wedged unit (HW-01) reading ~0 W is now shown as a refusal, not reported as
not delivering. Its share still moves, which is the gain SAFETY-42 measured.
@@ -1,43 +0,0 @@
# SAFETY-78 - a maintenance drain at two inverters
**Status: DECIDED 2026-09-29.** Fixes MAINT-05 §6 (OPT-11 P1 F1, UX-13 P3
check 5, OPT-11 P3 F4). Tests: `test_safety78.py`.
**The defect.** At N>=2 the drain pins its pack (a unit SET at
`P_MAINTENANCE`) and the site target is split around the pin. Whenever the
site target was below the pin, the other pack had to charge. The allocator
refuses opposite directions (SAFETY-25 §0a, SAFETY-31), so the site went to
0 W and logged `ARBITER: REFUSED` every cycle. That covers a house below the
drain rate, a grid-charge trade, and a house above it too: after a refusal the
loop restarts from 0 W and its 400 W slew step is below the pin again.
**The fix.** While a pack drains at N>=2, main.py files a site limit
`drain`, `lo` = what the pin really places (`allocator.pin_w()`: the drain
rate inside that pack's own hard limits), at `P_MAINTENANCE`. So the site
target is never below the pin and the other pack only adds power in the same
direction. Opposite splits stay forbidden.
- The order is the arbiter's own: a safety SET (100) outranks the limit and
the pin, so everything parks; the peak floor (50) and the drain floor are
both applied, so the higher wins; a trade or the loop below the drain is
lifted to it.
- Below the drain rate the drained pack exports the rest, as the drain does at
N=1 (`maintenance.py`: "forced discharge, exports the surplus"). Holding it
to the house load would make N=2 drain differently from N=1; that is an
owner question, not this fix.
- A draining pack that can place nothing (a refusal, a stale link) adds no
floor, and the wait is logged once per episode.
- With battery modes on, no pack is a grid charger while another drains
(`modetrade.decide(draining=)`, why `cycle`), and the other battery's card
says why (`other_cycle`). Not while the drain waits: no floor is filed
then, so the other battery may charge from solar.
- Not filed at N=1, where the drain is the site SET itself: byte-identical
(`tools/safety78_identity.py`, which hashes the claims too).
**Not changed (measured in the PR #154 review, follow-ups).**
- At N=1 a drain (60) outranked the peak floor (50): a 5000 W house imported
3000 W against a 2500 W limit. Fixed by [[SAFETY-83]].
- At N=2 a maintenance CHARGE in a cheap window has the same stall: the
charging pack's `hi = -floor` against the loop asking the other to give is
refused on 141 of 261 cycles. The mirror fix is a site `hi`, which can meet
the peak floor's `lo` (0 W), so it is not this card. Fixed by [[SAFETY-84]].
@@ -1,41 +0,0 @@
# SAFETY-79 — the peak guard keeps a margin below the peak limit
**Status: DECIDED 2026-09-28 (owner decision).**
## Decision
The peak guard aims `peak_margin_w` under the limit it holds: under
`peak_cap_w`, or under a month peak already banked above it
(`quarter_target_w()`). `peak_margin_w` is a setting: default 50 W, range
0-500 W, whole watts. An install without the key gets 50.
**Why (owner):** the guard aimed exactly at the cap, and in the OPT-11 P1 test
a quarter banked 3501.9 W against a 3500 W limit (OPT-11-P1-test-report.md,
check 4). A peak even 1 W over still sets the billed peak. It is a setting and
not a constant because the add-on is sold to several sites.
**Banked peak (review, 2026-09-29):** the PR first aimed at a banked month
peak as it stood. That can bank a few W over it, which raises the bill the
same way; and a peak banked between cap - margin and the cap (common in the
month an install upgrades, since the old guard held quarters at the cap) was
aimed at exactly, so one overshoot went over the cap. The margin now applies
under it too. Cost: up to `peak_margin_w` of a peak already paid for goes
unused (more discharge, less charge headroom); no bill cost. Undo is one line
in `quarter_target_w()`.
## What uses which number
- **the aim:** `peakguard.decide()` (the claim's floor, and so arbitrage's
`charge_headroom_w()` and OPT-11's give-way, which read it), and, as cap -
margin, `shaping_suspended()` and `maintenance_charge_floor()` (the forecast
checks, via `Controller.peak_aim_w()`).
- **the real cap (reporting and billing):** `PeakTracker` (month peak,
headroom, 80 % warning), the decision's `ceiling_w` and every notice and
status row built from it, Home's limit, and `validate.config_notes()`.
## Checks
`peak_margin_w: 0` is byte-identical to ed739d4 (test_peak_margin.py part 1,
digests from tools/safety79_identity.py, which also runs a maintenance charge
with a forecast). validate.py rule 12 refuses a value outside 0-500, not
whole, or not below `peak_cap_w`.
@@ -1,63 +0,0 @@
# SAFETY-81 — no false "Load too high for battery" alarms
**Status: 2026-09-29, owner go for the card. Review kept the 10 W tolerance
as a constant and added the 10 s hold.**
## Defect
The SAFETY-79 test (SAFETY-79-test-report.md, F1/F2) saw 6 false alarms in 7.
The alarm fired whenever `decide()` clamped its floor to the rating
(`saturated`). Three things set that while the quarter landed on target:
- **Double count.** `decide()` sizes from the held meter reading plus the
battery reading. When the meter sample predates the battery's last increase,
the increase is counted twice. It happens under noise, and at each quarter
start, where the battery is back while the held sample is still the spike.
- **The aim, not the cap.** `saturated` is graded against cap − margin, so at
margin 50 noise needs 50 W less to trip it (F2).
- **Last seconds.** The gain 900 / remaining turns a few W over the aim into
thousands of W wanted, with the battery far from its rating.
## Decision
The alarm needs `saturated` and `peakguard.load_too_high()`: with the battery
at full power from now on, the quarter still ends more than `ALARM_MISS_W`
(10 W) over the real limit (`ceiling_w`). That is the miss itself, without the
gain. The battery reading it uses is the lower of this cycle's and the last
one's. It must hold for `ALARM_HOLD_S` (10 s). At most one notification per
quarter, because near the threshold the test flickers with the meter; the
ERROR log line is written for every episode.
**Why 10 W:** at margin 0 the guard itself lands up to +5.7 W over (SAFETY-79
test, check 5), and those misses are real but tiny; the alarm would fire in
the last second of most guarded quarters. The margin (default 50) is the fix
for them. Cost: a real miss of 10 W or less is not alarmed; at a capacity
tariff of the order of EUR 50 per kW a year, 10 W in one month is cents.
Review measured +13 W on a 10 kW two-unit site at margin 0, not saturated, so
no alarm: the tolerance only matters when the battery is at its rating. A
constant, not an option: `peak_margin_w` is the owner's knob.
**Why the hold (review):** the test assumes the load now lasts the rest of
the quarter. A load over cap + battery for ~5 s every 30 s (`sine4500_fast`,
a cycling hob or heater) alarmed at each quarter start while the quarter
landed under the aim. The hold stops that.
**Cost:** a real alarm comes 10-12 s later than on 5a4cdc2 (`step6500` at
1211 s, not 1201 s); a slow ramp 20 s later (`ramp6500`), as it waits for the
real cap. While the battery refuses or its reading is lost, the alarm waits
until the quarter is lost even at full battery (27-60 s later in review runs);
those faults have their own alarms. A load over cap + battery for more than
10 s still alarms even if the quarter later lands under. `ALARM_HOLD_S = 0`
removes the hold.
## Not changed: the 1 s spike itself
The cycle after a quarter boundary and before the new quarter's first meter
sample still sees 0 s left, files no claim, and an Off battery drops to 0 W for
one cycle. That is the same edge tick as OPT-15 F1, so it is left to OPT-15.
Control is unchanged here: every command in `tools/safety81_sim.py` is
byte-identical to 5a4cdc2 (test_safety81.py part 1).
The double count also moves control, not only the alarm: on a late sample the
guard asks for the full rating for one cycle (edge4600 at 902 s: 2000 W where
1150 W holds the aim). Left to a follow-up card.
@@ -1,34 +0,0 @@
# SAFETY-83 - the peak guard outranks the maintenance drain at N=1
**Status: DECIDED 2026-09-29 (owner go, P1).** Follows SAFETY-78's "Not
changed" list. Tests: `test_safety83.py`.
**The defect.** At N=1 the drain is the site SET at `P_MAINTENANCE` (60). The
peak floor is a limit at `P_PEAK` (50), and clause 2 applies only limits at or
above the winner, so the floor was ignored and the house imported what the
drain left. A 5000 W house under a 2500 W limit and a 2000 W drain banked
3000 W quarters; on the owner's settings (limit 3500, drain 2500, margin 50) a
breach starts at about 5950 W.
**The fix.** While a drain owns the setpoint at N=1 and the peak floor asks
for more than the drain rate, `main.py` (`_peak_over_drain()`) refiles that
peak claim at `P_MAINTENANCE`. The arbiter then applies it: the target is the
floor, still inside the safety limits, and a safety SET (100) still parks
everything. Where the floor is at or below the drain rate nothing changes.
- The order in effect is now the same at N=1 and N=2: safety limits and the
SAFETY-73 refusal > peak guard (with the SAFETY-79 margin) > maintenance >
modes > trade > loop (`arbiter.py`, beside the priorities).
- The drain keeps counting: the battery discharges at least the drain rate on
every cycle, so the cycle is not reset or stalled; it only ends sooner.
- Below the drain rate the rest is still exported (owner, 2026-09-29).
- Identity: `tools/safety78_identity.py --diff` against 35cdffe changes only
cycles where the guard asked for more than the drain; after such an episode
the commands are identical again and only the log and the idle loop's
integrator differ. N=2 is unchanged.
**Not changed.**
- Between the drain rate and the peak aim, the house still imports what the
drain leaves (N=1 behaviour kept by the identity). At N=2 the loop covers it.
- A maintenance HOLD (0 W, 120 min, battery full) still outranked the peak
guard at N=1. Fixed by [[SAFETY-86]].
@@ -1,47 +0,0 @@
# SAFETY-84 - a maintenance charge at two inverters
**Status: DECIDED 2026-09-30.** Follows SAFETY-78's "Not changed" list.
Tests: `test_safety84.py`; identity: `tools/safety84_identity.py`.
**The defect.** In a cheap window the charging pack files its own
`hi = -charge` (P_MAINT_SHAPE). At N>=2 the loop's site target sat above it
(a house to cover, or its 400 W slew step from 0 W), so the other pack had to
give while this one charged. The allocator refuses opposite directions
(SAFETY-31), so the site sat at 0 W whatever the house
(`tools/safety84_identity.py` on 9d5b7d1: 436 of 480 cycles refused). N=1
was fine.
**The fix.**
- While a pack's own limits make it charge at N>=2, `main.py` files a site
limit `charge`, `hi` = what that pack's own `hi` places
(`allocator.hi_w()`, whole W as the allocator rounds). The site target is
never above it, so the other pack only adds power in the same direction.
Opposite splits stay forbidden. Mirror of SAFETY-78's `drain` floor.
- Peak guard first (owner order, SAFETY-83). The charge is sized to the
quarter's headroom under the aim (`charge_headroom_w()`, cap or banked peak
minus `peak_margin_w`), so it charges less instead of stopping: the meter
sits on the aim and the guard does not need to step in. Where the house
alone is over the aim the guard engages, the shaping is suspended as before
(`shaping_suspended()`) and no `charge` is filed, so the guard's `lo` and
this `hi` never meet in one cycle (no `lo > hi`, no park at 0 W).
Without the headroom sizing the aim still held (the suspension did it), but
a battery gave power back on 285 of 540 cycles at a 3000 W house
(test_safety84.py part 2).
- A site stop (a safety SET above P_MAINT_SHAPE) sizes the charge to 0 W:
the unit `hi` is a hard limit, so a charge under a 0 W stop needs the other
pack to give. It was refused, with an ARBITER line, on most cycles of the
stop (test_safety84.py part 4). The stop still parked the site at 0 W.
- A pack that cannot take the charge (a refusal, a full pack, a stale link)
files no ceiling; the other pack follows the loop.
- Not filed at N=1: byte-identical (`tools/safety84_identity.py`, which hashes
every claim, in and out of a cheap window and under a banked peak).
**Not changed.**
- At N=1 the charge is not sized to the headroom: the guard still suspends it
and the battery gives until the quarter is safe. The same sizing would fit
there; that is an owner question, not this fix. (SAFETY-125, 0.19.2: the
owner said yes, and N=1 is sized the same way.)
- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N.
- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up"
alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line
comes as often at N=2 as at N=1 (2 in 480 cycles).
@@ -1,91 +0,0 @@
# SAFETY-85 - no false "not keeping up" while another owner sets the battery
**Status: implemented 2026-10-01, for review.** Tests: `test_safety85.py`.
**The defect.** "Battery is not keeping up" (SAFETY-40, `goodwe_integrator_bound`)
fired about every 2.5 min during maintenance (drain, charge, HOLD) and under the
peak guard, while the battery gave exactly what it was told. Each warning
re-created the HA notification. Rig counts: 24 in an hour (SAFETY-83 F1), 43 in
7 episodes (SAFETY-86), 35 in 16 (SAFETY-87), 12 in 6 (SAFETY-84). The page
counter counted cycles (861), not episodes.
**Cause, measured on the real Controller.**
- N=1: when a higher claim decides, `cycle()` re-seeds the integrator from the
target each cycle, then `compute()` adds `gain * error` for a grid error the
loop does not own. Target + kick crosses the rail and sets `i_clamped`. The
carried integrator is not wound (SAFETY-48 caps the lead at one slew step),
so control comes back one slew step at a time.
- N>=2: a maintenance pin is a unit claim. The loop stays the site winner and
winds to its rail for watts the pinned battery may not give. When the pin
lifts, the site jumps: 2500 W in one cycle when a HOLD ends under a 4500 W
house, 1390 W when a charge pin lifts (`s84 n2_charge_dear`, t=1800 s).
**Decision.** Three rules; the alarm, its text and its id are unchanged.
1. **The loop's clamp counts only on a cycle the loop decided.** The winner is
the loop. Its number is on the wire, or only a rating (`device`,
`supervised`) cut it. No bound held it, and nothing was unmet.
(`Controller._loop_alarm_input`.)
2. **A battery another owner set to discharge alarms when it gives less than
it was told.** This covers every battery on a cycle the loop did not
decide, and a battery with its own pin otherwise. The test is more than
`saturation_w` short of the least it stood at over `SHORT_CYCLES` cycles
and `SHORT_SPAN_S` (the plant's measured settle, SAFETY-13/73), for
`saturation_cycles` cycles in a row (`clamp.delivery_short`). It covers a
real shortfall during maintenance or under the peak guard. Before, that
shortfall was silent unless the kick happened to reach the rail (house
1000 W, drain 2000 W, battery gives nothing: 0 warnings before, 4 in 10 min
now).
3. **At N>=2, while maintenance holds a battery, the loop is bounded** by
OPT-11's `loop_bound()` (what the batteries may place). It does not wind,
and control comes back one slew step a cycle. Why maintenance only: that is
where the jump was measured and where the owner order puts maintenance
above the loop. Other N>=2 limits (pack cut-out, SAFETY-73 refusal) keep
the old law. Rule 1 still silences them (the target is unmet), so they no
longer warn either.
Fixing the windup was chosen over only ignoring the wound part, because at
N>=2 the wound integrator moves real watts at hand-back. At N=1 there was
nothing to fix: re-seeding already prevents windup.
**What SAFETY-03/40 protect is kept.** With the loop in charge, the integrator
bound still warns (a runaway, a house above the rating), and a saturated
inverter under the loop's own command still does not alarm (SAFETY-03 AC 2):
rule 2 grades only batteries another owner set.
**The counter.** `ClampWatchdog.bound_episodes` counts episodes that had a
bound engagement. The status row shows it ("N times since start").
`bound` (cycles) stays for the log and the tests.
**Re-pinned digests.** Every change is intended; nothing at N=1 moves a watt.
| digest | what changed |
|---|---|
| s78 `n1_drain_owner` | the false clamp rows are gone from the log; commands identical |
| s78 `n2_drain`, s84 `n2_charge`, `n2_charge_banked`, s79 `n2_maint` (test_peak_margin) | loop reason (`mode-bound`), integrator and loop claim while bounded; commands identical |
| opt11 `n2_maint` (test_modes) | A drops out at t=1655 s: B follows the house at once, 8 cycles differ (max 920 W) |
| s84 `n2_charge_dear` | A's pin lifts at t=1800 s: 400 W a cycle, not a 1390 W jump; the surplus step after it; 98 cycles |
test_safety86's "without the ceiling the log floods" now needs the ceiling
and the bound both removed: either one alone keeps the loop inside what the
batteries can give. The `alarmed()` helpers in test_safety84/86 also read
SAFETY-81's quarter stamp, because ALLOCATOR rows can push its row out of
the 40-row log.
**Review (PR #163).** The first draft graded each battery against last
cycle's command and graded charges too. On plantsim's battery (10 s lag) at
the live 2 s meter, every drain or charge start warned once, and an N=2 drain
or HOLD under the peak guard with a stepping house warned 15 times an hour. A
maintenance charge tapering near full warned 11 times in 50 min, which
SAFETY-42 section 7 had ruled out. With the settle window and discharge only:
0 in all of those cases. A battery that gives nothing still warns, lagging
or not. At a hypothetical 20 s lag, it warns at most twice an hour. The cost:
a battery that does not take a charge another owner set is not named by this
alarm (the same gap SAFETY-42 states; the charge has its 36 h timeout).
`test_safety85.py` part 7.
**Not changed, open for the owner.**
- With the loop in charge and the house above the batteries' total rating,
it still warns (SAFETY-40). On the rig's 2 x 2000 W N=2, most of SAFETY-84's
count came from houses of 4500 and 6500 W.
- TEL-10's `Loop reason` reads `mode-bound` while the maintenance bound holds
the loop, with modes off. DOCS lists the value with both meanings.
@@ -1,47 +0,0 @@
# SAFETY-86 - the peak guard outranks the maintenance HOLD
**Status: DECIDED 2026-09-30 (owner).** Follows SAFETY-83's "Not changed"
list. Tests: `test_safety86.py`.
**The defect.** The HOLD (battery resting full after the maintenance charge)
is a SET of 0 W at `P_MAINTENANCE` (60), above the peak floor (`P_PEAK`, 50),
so the floor did not bind. At N=1 a 6500 W house banked 6500 W quarters and
'Load too high' fired (SAFETY-83 rig test, check 4). At N=2 only the other
battery gave: 4500 W against a 3950 W aim (SAFETY-84 rig test, check 6).
**The fix** (`main.py`, `_peak_over_hold()`).
- N=1: during a HOLD a peak floor above 0 W is refiled at `P_MAINTENANCE`, as
for a drain (SAFETY-83). The battery gives the floor; the Why reads "raised
for the peak limit".
- N>=2: the holding battery's pin is raised to the floor minus what the other
live batteries may place (their hard `hi`, or their pin). The others give
first, the holding one only the shortfall. Why this split and not the
allocator's shares: the hold is maintenance and outranks the others' use
(owner order), and the owner rule for an Off battery is the same, "give way
only for the shortfall". A battery Off by its mode (P_MODE, below
maintenance) therefore still gives before the holding one.
- N>=2, while the holding battery gives: a site `hold` ceiling at the floor
(P_MAINTENANCE), so the loop does not ask for watts the hold keeps back.
Without it the moving pin re-logged the ALLOCATOR "could not be placed"
row each cycle and filled the action log (review, PR #161).
- Below the aim nothing changes: the floor is 0 W, the hold is 0 W, the rows
are identical to before (test_safety86.py part 2).
- A site stop (a SET above `P_PEAK`) decides alone: nothing is raised at N>=2,
and the refiled floor at N=1 stays under `P_SAFETY`. If another battery is
pinned to charge (a SAFETY-73 probe) the pin is not raised, so the site is
never split both ways.
- The hold resumes by itself: the rule is evaluated each cycle, so when the
guard lets go the command is 0 W again.
**The level dips below the target: the hold finishes anyway.** Owner
decision 2026-10-01: the HOLD ends on its clock (`maintenance_hold_min`) even
when the peak guard pulls the level down; no top-up. A top-up would re-enter
CHARGE (up to 36 h, `CHARGE_TIMEOUT_MIN`) and keep the site's one maintenance
slot meanwhile.
**Not changed.**
- The peak guard still grades the emptiest battery (SAFETY-32): at N=2 with
B under `peak_soc_floor` the floor is limited and A holds even when it is
full (SAFETY-87).
- "Battery is not keeping up" during maintenance (SAFETY-85), the N=2 charge
flapping (SAFETY-88) and the N=1 charge headroom sizing are untouched.
@@ -1,83 +0,0 @@
# SAFETY-87 - the peak guard uses every battery that can still give
**Status: DECIDED 2026-10-01 (owner card).** Follows SAFETY-86's "Not
changed" list. Tests: `test_safety87.py`; identity:
`tools/safety87_identity.py`.
**The defect.** At N>=2 the guard graded the emptiest battery against the
highest floor ([[SAFETY-32]] §3). One battery under `peak_soc_floor` made the
whole site floor-limited: the claim asked for 0 W and the reserve alarm fired,
even with the other battery full. Measured: a maintenance charge (A at 11 %)
raised the alarm 10 times while both batteries gave 2000 W and the quarter
landed on the aim (SAFETY-84 rig test, check 3); a HOLD (A at 99 %, B at
15 %) left A at 0 W and banked 4051 W, raising the month peak (SAFETY-86
review; rig case 7a: 4563.8 W).
**The fix.**
- `decide()` and its call are unchanged. At N>=2 `Controller._peak_on_able()`
then grades each battery on its own floor (`peakguard.reserve_split()`).
One at or under it is *held*. The others that are live and not refusing
(SAFETY-73) are *able*.
- A floor-limited decision is re-taken on the able batteries
(`peakguard.place_on_able()`): their own permission (min SoC against max
floor, among them), and a floor of `min(need, able_w)`. `able_w` is their
rating plus the drain a held battery gives anyway (its maintenance pin).
- The held batteries weigh 0 in the split while the site gives
(`arbiter.resolve(reserve=)`), so the floor lands on the able ones. During a
HOLD, a held battery is not counted among the "others" (`_peak_over_hold()`),
so the holding battery covers the need.
- Floor-limited now means: some battery is held and the able ones cannot hold
the aim. The claim still asks them for `able_w`. The alarm fires only if
they miss the real limit at full power for `ALARM_HOLD_S`, the same test as
'Load too high' (`_reserve_short()`), so a one-cycle miss at a quarter's end
is not an alarm. With nothing able (all held, or the rest refusing or
dropped) it is the old state: 0 W, immediate alarm.
- The alarm and the row name `held`, each battery graded on its own floor. The
old code named batteries against the site's highest floor.
- Page: the two peak rows carry `runs`. A red `runs` row gives the banner its
title without "Not ready", and the Home page is not shown as held at 0 W.
Rows that do stop control still outrank it.
**How SAFETY-32's concern is still covered.** SAFETY-32 took min(SoC) against
max(floor) so the guard never sizes a forced discharge a battery's own BMS
stops. Now no battery at or under its own floor is asked for the peak: it is
graded on its own floor, and an unread SoC or unknown cut-out counts as held.
The min/max pairing still decides permission, over the able batteries. The
roster verdict `decide()` echoes (`soc`, `soc_floor`) is kept, so SAFETY-32's
call-site and reduction checks still bite. What changed is only that one held
battery no longer stops the others. One exception, with battery modes on: see
Open.
**Maintenance pins beside a held battery** (review). A draining battery that
may still give is raised from its drain rate towards its rating for the peak
floor, after the others (`_drain_over_reserve()`; peak > drain), else the
floor's rest landed on the held battery. In a HOLD, a held battery counts its
own pin (a drain) among the others, and a held battery pinned to charge stops
the raise (never split both ways).
**Before -> after** (test_safety87.py, rig of 2 x 2500 W, aim 3450 W):
| case | before | after |
|---|---|---|
| charge, A 11 %, house 4500 | 3 reserve alarms, quarter 3450 | 0 alarms, 3450 |
| HOLD, A 99 % / B 15 %, house 5500 | A 0 W, B 2488 W, 3 alarms | A 2054 W, B 0 W, 0 alarms, 3450 |
| HOLD, house 6500 | A 0 W, B 2488 W, 4051 W | A 2500 W, B 0 W, 4014 W, 1 alarm (A alone is short) |
| HOLD, month peak 4000, house 6000 | A 0 W, B 2488 W, 3551 W, alarms | A 2054 W, B 0 W, 3950 (peak - margin), 0 alarms |
| both under the reserve | alarm | alarm (unchanged) |
| N=1, every scenario | | byte-identical |
**Open, for the owner.**
- The loop still draws a held battery (SAFETY-25 option A: the reserve binds
the peak guard only). It gives last, after the able ones. So with house 6500
in the charge case, B alone cannot hold the aim, the alarm fires as
specified, and the loop then lands the quarter on the aim from A. The text
says "cannot keep … alone", not "will go over", so it stays true.
- Battery modes on, A Off and B held: when B alone can place the floor, OPT-11
keeps A resting and B gives it. When B cannot, A gives first. Making Off give
before a held battery in the first case needs a third allocator tier. The
same at N=3: A Off, B held, C able, floor 4000 W gives C 2500, B 1500, A 0.
Until then, with modes on, a held battery can give for the peak.
- The notification and row text live in `messages.py`, which has no NL layer
yet (UX-19). The Home page text, which has NL, is unchanged.
**Not changed.** N=1. 'Load too high' (SAFETY-81). SAFETY-85, -88, -89, -90.
@@ -1,81 +0,0 @@
# SAFETY-90 - the peak guard goes on after a P1 meter gap
**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`.
**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the
quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must
not reach the bill record). `peakguard.blindness()` then called the projection
blind for the rest of the quarter, so the floor was 0 W. During a HOLD the
battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2,
3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review).
The page still read "Keeping the grid near zero". At start-up mid-quarter the
same rule left the start-up quarter blind, and a held battery was drawn as last
resort (SAFETY-91 test, O2).
**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`,
`Controller._fill_peak_gaps()`).
- Once readings are back, every second of a silence longer than
`meter_max_age_s` counts at `gap_w = max(the limit, the import just before
the gap, the import just after it)`, where "import" is the grid plus the
batteries' discharge, i.e. with the batteries at 0 W. The gap's first
`meter_max_age_s`, which the averager credits at the last reading, is topped
up to `gap_w` too. The guard then grades the quarter as usual, so it is not
blind and gives what keeps the quarter at the aim.
- **The bound.** The count is at or above the true import as long as, during
the gap, the house load (less solar) stayed at or under the higher of the loads seen just
before and just after it, or under the limit, and the batteries did not
start charging (during a gap they hold their last command, then go to 0 W).
It never counts the gap below the limit. A load that rises above all three
only inside the gap is the one case it can miss.
- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s
to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in
battery energy.
- **While the gap lasts** nothing changes: the reading is too old to steer by,
the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as
before.
- **Before the first reading** (start-up or restart mid-quarter) the
add-on was down, so the batteries were at 0 W (it sets 0 W when it stops,
the ESP32 watchdog after 30 s). Those seconds count like a gap, at
`gap_w(the limit, the import seen first)`; after a restart the restored
reading's held `meter_max_age_s` is topped up the same way. The bound is
the gap's, with only the "after" side known. A load the batteries cannot
make up then raises "Load too high", which is true: that quarter is going
over. The page says so: the status row "Peak guard: part of this
15-minute period was not measured, counted as high use" and the home line
"Part of this 15-minute period was not measured." / "Een deel van dit
kwartier is niet gemeten." until the quarter ends.
*Review:* the first version counted them at the aim, with "nothing is
known" as the reason. The first reading does show the load, and with the
batteries at 0 W the import was that load, not the aim: a 60 s restart
during a 6500 W HOLD landed 3653 W against 3450 W.
- The bill record (`quarter_blocks`) is unchanged: it still banks only what
was measured. The estimate steers the guard and is never banked.
- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and
the watts it was counted at.
- No option. The threshold is `meter_max_age_s`, the limit and the aim are
`peak_cap_w` and `peak_margin_w`.
**Before -> after** (`test_safety90.py`, quarter as the meter really averages
it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
| case | before | after |
|---|---|---|
| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 |
| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 |
| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 |
| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 |
| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 W |
| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 |
| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 |
| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 |
| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 |
| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 |
| meter never drops, N=1 and N=2 | | rows identical |
**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by
less than `meter_max_age_s` is not a gap, and the seconds since it are not
counted in the projection (on any quarter, not only after a gap). Near a
quarter's end the 900/remaining gain turns that into a large negative excess
and the floor drops for a cycle or more. Counting those seconds at the held
reading would fix it, but it changes every quarter's last seconds on a
healthy meter; it is handed back as its own card.
@@ -1,74 +0,0 @@
# SAFETY-91 - a held battery is drawn only after the others are full
**Status: DECIDED 2026-10-01 (owner: held battery last, SAFETY-25 option A
kept).** Follows SAFETY-87. Tests: `test_safety91.py`.
**The defect** (SAFETY-87 rig test, F1 and F2). At N>=2 a battery at or under
its own peak reserve was held back (weight 0) only on cycles where the peak
guard asked for power.
- F1: B draining at 1000 W, A held at 15 %, house 5500. Once the grid was
near zero the loop set the target, A had its normal weight and B's drain
pin bound, so the loop drew A at 2500 W for the whole quarter (15.0 ->
10.7 %) while B stayed at 1000 W. In the next quarter A reached its
cut-out, and B stayed pinned at the peak floor (~2422 W), not its 2500 W.
- F2: in a HOLD (A holding, B held), at a quarter's edge the guard saw a block
with no seconds left and asked for nothing for one cycle. The HOLD's
ceiling lapsed, A went back to 0 W and the loop's target went to B: 2500 W
for 1 s.
**The fix** (`main.py`, N>=2 only).
- `_held_back()`: the batteries at or under their own reserve while another
can still give (live, not refusing), graded as in `_peak_on_able()`. They
weigh 0 in every split where the site gives, not only for the peak. With
nobody able (all held, or the rest refusing or dropped) nobody is held back
and the split is as before.
Review: once held back, a battery stays so until its SoC reads more than 1
point above its reserve. A reading flipping 20/21 % otherwise swapped
~1000 W between the batteries on each flip (grid +-500 W with A 10 s and
B 3 s lag). The guard's own permission (SAFETY-87) is unchanged.
- `_drain_rise()`: while a battery is held back, a draining battery's pin
becomes a floor at its drain rate (`lo`), so it rises towards its rating
before the held one gives. Not for a held battery's own drain, not under a
site stop, not for a battery that is not live.
- `tariff.next_quarter()`: past a block's end, before the next block's first
telegram, the guard grades the next quarter, projected from the held
reading (the averager credits it there). Not when that reading is too old
to be credited (`max_hold_s`).
**What stays.** SAFETY-25 option A: once every able or draining battery is
at full power, the loop still draws a held battery (`allocator._fill()`'s
last-resort pass). With house 5500 and 2 x 2500 W, A still gives 2500 W
after B is full. Nothing is placed on a battery at its cut-out: OPT-09's
pack limit closes it (`hi = 0`), and the drain rise lets B take the rest.
**Before -> after** (`test_safety91.py`, 2 x 2500 W, lagging battery 10 s):
| case | before | after |
|---|---|---|
| B drain 1000, A held, house 4000 | A 2500, B 1000 | A 1500, B 2500 |
| same, house 5500 | A 2500, B 1000 | A 2500, B 2500 (option A) |
| cycles A asked while B not full | 612 of 900 | 0 |
| A at its cut-out, house 5500: B | 1572 W mean | 2500 W, A 0 W |
| HOLD, late telegram at the edge | A 0, B 2470 for 1 cycle | A 2050, B 0 |
| idle, A held, house 2000 | A 1005, B 1005 | A 0, B 2020 |
| N=1, every scenario | | byte-identical |
**Why N>=2 only.** At N=1 there is no other battery to give first. The edge
roll would also fix the N=1 one-cycle dip at a quarter's edge (SAFETY-86
rig, "A dropped to 0 W for 1 s"), but this ticket keeps N=1 byte-identical;
extending it is a one-line change for a later ticket.
**Re-pinned.** `test_modes.py` `n2` and `n2_off`: from t=1600 battery A is
at its 20 % reserve, so B carries the house and A gives last (100 of 540
cycles differ). With SAFETY-87's held rule both are a5dddad's again, and the
test grades that.
Two fixtures changed with it: `test_safety87.py` part 10's "without the
raise" run also turns off `_drain_rise()` (else B rises anyway), and
`test_peakguard.py` 10g and `test_watchdog.py`'s two-inverter rig give
their second battery a cut-out, as rule 8 requires (an unknown cut-out is held, so A alone would carry the cycle).
**Not changed.** The peak guard's own rules (SAFETY-87). A drain with
nobody held stays a pin (SAFETY-78). An Off battery beside a held one
(SAFETY-87 Open). The first cycle after start, before any quarter is
measured, can still put the loop's first step on a held battery in a HOLD.
SAFETY-88, -89, -90, -92, -93, -94.
@@ -1,60 +0,0 @@
# SAFETY-93 - the maintenance charge end hands back smoothly
**Status: implemented 2026-10-02, for review.** Tests: `test_safety93.py`.
**The defect** (SAFETY-85 rig test, check 4; also on 9358781). At N=2, when
A's maintenance charge ended, B went 0 -> -1600 W in one cycle and charged
from the grid with the house at 6000 W. The HOLD end was already smooth
(SAFETY-85).
**Cause.** During the charge the site target holds A's charge (SAFETY-84's
`charge` ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the
loop starts its next step from last cycle's site target. The slew limit
moves the site total by 400 W (-2000 -> -1600), and the only battery left
to place it on is B. The same happens when the charge times out or the
battery is switched Off (A free, its -2000 W shared out). With the charge
suspended by the guard and A giving under the loop, B took A's share in
one step (675 W).
**The fix** (`main.py`, `_reseed_charge_end()`, N>=2 only). On the cycle a
battery leaves CHARGE, the loop's start point (`self.target`, and the
integrator with it) is what the other batteries stood at plus that
battery's new command: its pin (HOLD or abort, 0 W), or, if it is free, its
last discharge (a charge stops). The loop then moves from there one slew
step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's
pin, as before; it lowers the grid import. The end is kept until the loop
next runs, so a missed poll on that cycle (the input hold) does not lose it.
Review: a charge also ends, for the site, when the battery is still in
CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73)
drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a
1200 W house). So the end is noted after the unit claims are filed: a
battery leaves the charge when it leaves CHARGE, is not live, or its own
claims contradict. The guard suspending the charge (SAFETY-84) is no end.
Why a start-point reseed, and not a per-battery slew in the allocator: the
jump comes from one stale number at one edge, the kind of edge SAFETY-73
(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed.
The change stays at that edge; nothing else at N>=2 moves.
**Before -> after** (`test_safety93.py`, 2 x 2500 W, 10 s lag, 2 s meter):
| case | before | after |
|---|---|---|
| rig case: house 6000, A charges 2000 to the end | B 0 -> -1600, charges 4 cycles | B 0 -> 400 -> 800 ..., never charges |
| house 1200 | B 0 -> -1600 (2800 W from the grid) | B steps of 400, never charges |
| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 |
| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W |
| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 |
| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 |
| N=1, both charge ends above | | byte-identical |
No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and
N=2) are byte-identical to f3e409c; none of their scenarios reaches a
charge end.
**Not changed.** The peak guard's floor still moves the site in one step
when it engages (a house step, a quarter edge): the guard outranks the slew
(owner order). A HOLD pin still drops the holding battery to 0 W at once
and SAFETY-86 gives way to the others first. SAFETY-88 (charge flapping
above the aim) is untouched.
@@ -1,102 +0,0 @@
# SAFETY-94 - a battery that lags or reverses is not frozen or refusing
**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py`
(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open.
## The defects
1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last
change. After a rest that clock was already past 35 s, so the first read
after a new command tripped it, before the register could answer. Live
2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a
rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was
1 s after its command; each register answered within 11 s. This is
SAFETY-58's shape.
2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES`
frozen cycles if the reading was under 100 W at that moment. The frozen
cycles include a reversal through the battery's lag. At a 1 s meter the
12th cycle comes as a battery reversing out of a charge passes 0 W
(SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held
0 W, the battery settled at 0 W, never gave 100 W, and waited for the
15-minute probe. N>1 already required 22 s in the band, so it was safe
at a 10 s lag but not at 20 s.
## What changed
- **The gate counts only time the register was asked to move**
(`freshness.Freshness._oblige`). A read counts the time since the last
read when, at that read, the command is 250 W or more and has moved 250 W
from where it stood at the register's last change. A register change
(or a SAFETY-57 release) restarts the count. A command flapping in and
out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip
at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged.
- **The bound is unchanged at 35 s and now means what its derivation
says.** The floor was always "how long a healthy register takes to answer
a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band
`[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3,
6 and 11 s after the command.
- **Refusal evidence is the same at every roster size**
(`main.Controller._gave_nothing`, SAFETY-73's count moved into a method).
The reading must stay under 100 W, one way, while asked over 200 W, for 12
cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and
needs it on top of its own freeze test. `None` (compute alone) keeps the
old law.
- **The evidence restarts when the reading moves 50 W towards the ask**
(`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live
readings wander by under 25 W at rest. A first-order lag crossing the band
gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s.
This also covers B, whose reading is 100 W below its power.
## Bounds and worst-case catch
| fault | before | after | budget |
|---|---|---|---|
| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds |
| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same |
| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) |
| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged |
| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s |
Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes
the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
## Before -> after
- Live replay (part 1): 4 trips -> 0.
- Refusing B (its -100 W idle reading): see SAFETY-97.
- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs
(part 2): before 27 stale trips and 1 refusal, after 0 and 0.
- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3
refused, N=2 6 / 1; after 0 / 0.
- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips
answered within 20 s: 21 -> 1. Two of the removed trips were not quick:
a register held 351 s under a command hovering at the 250 W travel edge
(under 35 s of asking before it moved; survivor 2's shape), and a
refusing battery the gate had caught only on a passing ramp (SAFETY-52's
refusal covers it).
## SAFETY-58 and SAFETY-74
- **SAFETY-58 is fixed** by this change: its card asks for the clock to
start with the obligation and for the bound to be re-derived. Both are
done above. `test_freshness.py` survivor 5 is now graded as closed.
- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly
flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2
before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a
command that moved is exactly what the gate is for; telling "limited"
from "frozen" needs a second signal (the meter, or SoC).
## Known gaps
- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2
as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred
on each unit's learned idle reading. `SAFETY-97-idle-offset.md`.
- A command that asks only part of the time (a fraction d) is caught after
35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s).
SAFETY-30's per-unit check restarts when its command stops opposing the
held reading, which a lapsing ask normally does, so 35 < 85 holds there.
SAFETY-31's circulation check (75 s) ignores the command, so its margin
holds only while d >= 0.47.
- A register frozen under a command that stays near the 250 W travel edge
is caught late or not at all (survivor 2).
@@ -1,69 +0,0 @@
# SAFETY-95 - a maintenance drain that ends hands back smoothly
**Status: implemented 2026-10-06, for review.** Tests: `test_safety95.py`.
**The defect** (SAFETY-93 review, O1). At N=2, when A's drain ended (A
switched Off, say) with the house at 1200 W, B went 0 -> +1600 W in one
cycle and exported. It is the mirror of SAFETY-93 on the drain end.
**Cause.** During a drain the site target carries A's drain floor
(SAFETY-78, `drain`, 2000 W) above what the house needs. At the end A's
command drops (pin, Off, abort, stale link, refusal), but the loop slews
from last cycle's target (2000 -> 1600) and only B is left to place it.
Under a solar surplus B gave 1600 W into the export.
**The rule** (`main.py`, `_reseed_drain_end()`, `_drain_grid_w()`, N>=2
only). On the cycle a drain ends:
1. The start point is what the others stood at plus A's new command (its
pin, else 0 W; a dropped battery is sent 0 W), as SAFETY-93 does.
2. If the house needs more, the start is raised to the house's own point
(live batteries' readings + grid - target, as `_reseed_run_edge()`),
but never above last cycle's start. So the others cover the house at
once and never take over the drain's export.
3. While A settles (SAFETY-96's window: within the deadband, at most
`PLANT_SETTLE_DOWN_S`), the loop's error is the house's point minus its
start, not the meter. The meter still shows A's tail and the others'
climb; chasing it sent B down and then up again.
4. Then the loop moves one slew step a cycle.
A drain ends when its battery leaves DRAIN, is not live, its claims
contradict, or it will not give (SAFETY-73's limit outranks the pin
without a contradiction, so the refusal is read from the battery; +1 only,
as `_held_back()`). Today a contradiction on a drain only arises through
a refusal; the clause stays because a contradiction sends A 0 W, which is
an end (tested directly). The end is kept
until the loop runs (a missed poll). A dropped draining battery is this
rule's, not `_reseed_drop()`'s. Order in one cycle: drop/rejoin
(SAFETY-96), charge end (SAFETY-93), drain end (this) last; each moves the
start by its own battery, and the drain end's raise reads the start the
others left.
**Why not SAFETY-93's rule alone** (step 1, then slew). Measured on the
same plant: at 1200 W the grid imports 849 W and B goes to -465 W
(charging) while the loop chases A's tail; at N=3 and 4000 W it imports
784 W where base imports 42 W, as the others give up A's share of the house.
The house point keeps the grid at its aim; the cap at last cycle's start
keeps it from exporting.
**Before -> after** (`test_safety95.py`, 2500 W batteries, 10 s lag, 2 s
meter; base = the same tree with the change taken out):
| case | before | after |
|---|---|---|
| drain done / Off / timeout, N=2, 1200 W | B 0 -> 1600, +390 W commanded export, grid then +559 W | B 0 -> 1210, grid within 40 W of its aim |
| the same, N=3, 1200 W | B, C 0 -> 800, grid +695 W | 0 -> 605, grid +39 W |
| the same, solar surplus | B gives 1600 W into the surplus | B never gives; site moves 400 W a cycle |
| refusal, N=3, 1200 W | B, C step 1020 W, +2000 W commanded export | step 20 W, +70 W |
| N=3, 4000 W | others take A's share at once | the same (grid +1 W, base +42 W) |
| abort (site zeroed by the operator) | climbs from 0, grid +669 W | climbs from 0, grid +16 W |
| stale link (board watchdog 25 s) | | as base, but N=3 4000 W: grid +676 W (base +786) |
| N=1, every drain end | | byte-identical |
`tools/opt11_identity.py` n2_maint moved on purpose (A's drain refused at
1655 s; B stays at the house's 1420 W instead of 2500 W). Re-pinned in
`test_modes.py`. The other 45 digests are byte-identical.
**Not changed.** The peak guard's floor still binds (arbiter, after the
loop). Held batteries still give last; the idle clamp and the freeze test
are as they were. A charge end (SAFETY-93) and a trade end are unchanged.
@@ -1,59 +0,0 @@
# SAFETY-96 - a battery the loop cannot move does not freeze it
**Status: implemented 2026-10-06, for review.** Tests: `test_safety96.py`.
**The defect** (SAFETY-93 rig test). At N=2 the saturation freeze compares
the site target with the site sum of the readings. After A's link went
stale, A was out of the split but its reading (-2000 W, still charging) was
in the sum: "saturation freeze (800 W vs -1903 W)", B held at 1200 W for
~236 cycles until the circulation trip. After a charge timeout or Off at
6000 W the loop froze ~14 s at 800 W while A came out of its charge. At a
drop, B also took A's whole share in one step.
**The rule** (`main.py`, `_held_out_w()`, `_reseed_drop()`, N>=2 only).
A battery is held out of the freeze test, counted at its command instead of
its reading, while:
- it is out of the split (writes refused for `LINK_STALE_S`);
- its last write was refused (its board may have gone to 0 W on its own
watchdog);
- it settles after a step the loop did not make: a charge end (SAFETY-93:
HOLD, timeout, Off, refusal, stale link) or a rejoin (then every battery,
as the split is made again). Settling ends when its reading is within the
deadband of its command, or after `PLANT_SETTLE_DOWN_S` (23 s, the
measured plant settle).
Its real power is not lost and not counted twice: the meter sees it, and
the loop follows the meter. Only the freeze stops reading it as the loop's.
The freeze still fires on the other batteries' own lag and on unplaced
watts.
At a drop the battery's share leaves the loop's start point, so the others
take over one slew step a cycle. At a rejoin it enters at its reading.
**Not a drop.** A missing reading or a stale register stops the site
(`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum:
it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30)
is pinned at 0 W and stays in the sum. New: no refusal is learned on a
battery whose writes are refused; its board going to 0 W is no refusal. Its
count is kept, not reset, so a link that refuses 1 write in 3 does not hide
a battery that really refuses (review).
**Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not
about the remaining battery's own lag):
| case | before | after |
|---|---|---|
| A drops while giving, A holds 1200 W | 128, B steps 1210 W | 0, B stays |
| A drops while charging, A holds -2000 W | 78 | 0 (circulation trip stays: 4 kW goes round) |
| A drops while charging, board to 0 W | 5 | 0 |
| A drops then comes back, A holds 1200 W | 46, grid +1.1 kW | 0, grid within 250 W |
| charge timeout at 6000 W | 7, B held at 400 W | 0, B climbs |
| A switched Off at 6000 W | 6, B held at 800 W | 0, B climbs |
| N=1, N=2 with no drop | | byte-identical |
**Not changed.** At a rejoin the allocator splits again at once (A 0 ->
1205, B 2410 -> 1205 W); the site total does not step. A battery that keeps
charging on its own after its link drops still trips the circulation check
(it is real circulation). A trade end (`_reseed_run_edge`) still starts
from the full site sum.
@@ -1,118 +0,0 @@
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
review.** Owner decision 2026-10-04: the band's centre is clamped to
+-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`).
Closes SAFETY-94 review finding F1.
## The defect
B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W
at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W.
Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing
B sat on the band's edge and never latched:
- N=2: never caught, before or after SAFETY-94.
- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared
rule (12 cycles in the band in a row) never did (review probe, 1200 s).
## Design
- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No
new option: a required one is a setup step every client gets wrong, and an
optional one is a second source to reconcile. An override can be added
later if a site needs one.
- **When it learns:** control runs; the unit is not refused (unit or site),
stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for
120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more
than 50 W, and while refused it does not learn, so its refusal cannot be
learned as idle. A capped battery under a big ask is not sampled either.
- **How:** the first value is the mean of 120 s of samples; then it follows
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`)
is dropped (a register held at a giving value). The band is centred on
the value clamped to 75 W (`IDLE_MAX_W`; owner decision below).
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
5 W move) and restored, clamped to 250 W. Without it a battery refusing from the
start is never quiet, so never learned, so never caught.
- **Until learned the idle reading is 0 W**: the rule as it was.
## Not chosen: the GoodWe LAN integration's DC battery power
Both inverters also appear through HA's GoodWe integration (B:
`sensor.kelder_dyness_battery_power`, 0 W at idle; found read-only on the
live HA 2026-10-04). Pointing `batt_entity` at it removes B's offset by
configuration, but:
- the band must be safe without it: other installs may not have it;
- it is polled over UDP on the inverter's Wi-Fi, a second channel that can
drop while the board keeps reading (HW-01 lost both once); a missing
reading stops the site;
- SAFETY-37's 35 s bound is derived from the board's 10 s publish; the
integration's poll interval (believed 10 s, not checked here) and latency
are not measured;
- DC power is not 0x0518, which SAFETY-30/31/42 were measured against.
It stays an install choice to test on the rig, not a code change here. With
it, the learned idle reading is simply ~0 W.
## Where it applies
Every test of "gives nothing / gives / takes" now uses `reading - idle`:
| check | change |
|---|---|
| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred |
| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred |
| N=1 end in `_refusal_update` | centred |
| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once |
| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred |
| SAFETY-42 "parked" (small ask, gives nothing) | centred |
| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level |
| SAFETY-57 release | unchanged: it reads the command only |
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
## Bounds: owner decision 2026-10-04 (review R1)
Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W.
- **Why a clamp under the band.** A wrong idle reading (taught during a
rest while the reading shows something else, e.g. a grid top-up of a unit
held at 0 W, or restored onto a swapped inverter) more than about 100 W off
hid every refusal on that battery, at N=1 and N=2; with the first +-250 W
clamp the rig missed a refusing A whenever the error was 110-250 W. At
+-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W
while refusing is caught whatever was learned or restored, and a wrong idle
cannot refuse a healthy 0 W battery (its ask is over 200 W).
- **The trade-off.** Options: keep +-250 W (covers idle readings to about
+-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen);
"old band or centred band" (rejected: a correct B would need to give 200 W
to answer its 200 W probe).
- **Cost.** A battery whose idle reading is beyond about +-170 W is centred
at most 75 W out, so its refusal falls outside the band: it behaves as in
0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its
clamped centre. B is caught across -150..+90 W of error in its idle
reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2.
- A frozen register is still caught by the stale gate at 35 s of asking.
- Recovery from a wrong value needs quiet time: tau 30 min.
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
| refusing | meter 1 s | 2 s | 5 s |
|---|---|---|---|
| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) |
| A, N=2 | 28 (28) | 28 (28) | 110 (110) |
| B, N=1 | 28 (never) | 28 (never) | 65 (never) |
| B, N=2 | 24 (never) | 24 (never) | 60 (never) |
A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once
A is cut, so the loop cycles on the 15 s floor; the same run on the PR before
SAFETY-97 gives 110 s. B restored after a restart, refusing from the start:
24 s; with nothing saved: never.
## Before -> after
- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s
meters, both register shapes, idle learned or restored (96 runs): 0 stale,
0 refused. Reversals, lag 10/20 s, idle restored: 0.
- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched).
@@ -1,273 +0,0 @@
# TARIFF-02 - price provider plugins, and the canonical shape (with later additions)
**Status: SHIPPED.** Tests: `test_price.py`, `test_price_service.py`. Written
down by MAINT-20 from the long comments of `app/price.py` so the module can stay
short. Related: [[TARIFF-07]], [[TARIFF-09]], [[TARIFF-10]], [[TARIFF-14]],
[[TARIFF-15]], [[PRICE-01]].
## Where it lives
specs.md 2.2 puts this at `ems/ingest/prices/`; that layout is not being built.
Owner decision 2026-08-30: everything ships in `goodwe_controller/app/`. The
behaviour in specs.md 9.3 / 8.3 / 11.4 is unchanged and authoritative.
## The canonical format (9.3)
`{"prices": [{"start": "2026-01-15T00:00:00+01:00", "eur_per_kwh": 0.1834,
"provisional": false}]}`. `start` is tz-aware at the Brussels offset for that
instant, the price is EUR/kWh and all-in, `provisional` is per point (8.3: no
irreversible action on a provisional price). Three fields; a fourth would change
the wire format every store, fixture and captured body is written against.
## Three rules the module is shaped by
1. **Double-counting markup is refused, given a correct declaration.** A plugin
returns `RawPoint`s with the figure its source published and declares in
`capabilities().sources` which of {ENERGY, MARKUP, TAXES} it already holds.
`assemble()` (framework only) adds the complement from `price_markup_eur_kwh`
/ `price_taxes_eur_kwh`. A plugin cannot reach those two numbers by any route:
`PluginOptions` raises `FrameworkOption`, and review of PR #16 wrote a
double-counting plugin three more ways (`opts._o`, `price_option()`, and the
public `PRICE_OPTION_DEFAULTS`), so `register()` also refuses any plugin
whose own code mentions a framework key (`code_leaks()`). The guarantee is
exactly this: the complement is added once *given a correct `sources`*. A
plugin declaring `{ENERGY}` for an all-in feed comes out one markup high and
no code can catch that. test_price.py checks that a plugin's docstring lines
(`SOURCES:` / `The framework ADDS:`) agree with its declaration, not that
either is true. `code_leaks()` strips docstrings before searching (they must
name the components) and fails open on missing source (a frozen build must
still start; CI asserts source is found for every shipped plugin).
2. **The product type is declared, not inferred from the id.** `capabilities().
product` is a `PriceProduct`, rides on the series, and `volatility_regime()`
reads it (8.3: real-time products widen thresholds). A plugin id appears only
in `PROVIDERS`, `RETIRED_PROVIDERS` and its own class (AC2). No shipped plugin
declares `REALTIME` since [[TARIFF-07]]; `_WIDE`, `Uncertainty.REALTIME_PRODUCT`
and `WIDEN_FACTOR`'s 2.5 are kept, and test_price.py section 10 declares
REALTIME from a class outside this file to keep them live.
3. **"No price data" is a type, not an empty list.** 11.4: no data means static
profile, provisional plan, no arbitrage; all-zero prices mean arbitrage is
merely pointless. `PriceRead.series()` refuses an empty list;
`PriceRead.no_data(reason)` carries `.reason` and nothing else, so
`min(read.points)`, `for p in read.points` and `.get("points", [])` raise
`Absence`; `.match(*, series, no_data)` takes both handlers as required
keywords. Same shape as `sim/riglock.py` after TEST-04 (read its `Absence`
block before changing this). Two ways past it survive and are not worth code:
`vars(read).get("points", [])`, and `bool(read)` being True for both
variants (a falsy `no_data` would turn `if read:` into a guard by accident;
use `.match`).
## Timezone without `zoneinfo`
The Alpine image has no `tzdata`, so `ZoneInfo("Europe/Brussels")` would raise
on the client's Pi. Every instant is UTC and rendered at its fixed offset, so
the October 02:00-03:00 hour that happens twice is two instants (`02:00+02:00`
and `02:00+01:00`); a naive-local-hour key or a 24-slot array is wrong on both
transition days (test_price.py counts points on both 2026 transitions). The EU
rule: CEST from 01:00 UTC on the last Sunday of March to 01:00 UTC on the last
Sunday of October. Review of PR #16 matched it against `zoneinfo` over 149 040
hourly instants 2020-2036 with zero mismatches. Residual risk is political (EU
abolishes the change): one edit in `brussels_offset()`.
## The feed range (TARIFF-09)
`generic_dayahead.fetch()` used to ignore `since`/`until` and request the bare
URL, so the feed answered ONE Brussels day and 8.3 dropped everything past today
as provisional: at 09:00 over a 48 h horizon 15 firm and 33 carried, at 23:00 1
firm and 47 carried. `feed_days()` returns the Brussels calendar days (first,
last, both inclusive; the API takes dates) that the half-open `[since, until)`
touches: Brussels, never UTC (22:30 UTC in summer is already tomorrow); `until`
exclusive, so local midnight belongs to the day before; an empty or inverted
window collapses to one day. `api.energy-charts.info` capture:
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`.
`dated_feed_url()` refuses a base that already has a query string (appending
would give `?bzn=BE?bzn=BE&start=...`). An operator's `price_feed_url` override
is used verbatim and loses the range, on purpose: rewriting an operator's URL
reorders and re-escapes it, and both providers declare a second host
(`web-api.tp.entsoe.eu`) whose parameters are not `start`/`end`. The cost is
logged at fetch time, naming the horizon consequence. The URL rule is shared by
`generic_dayahead` and `custom_formula` (TARIFF-15), taking `plugin_id` as an
argument so AC2 holds. The "range is used" promise is not mechanically enforced
(a rule would be satisfied by an invented parameter name); test_price.py pins
which plugins consume the range as a literal roster.
## Absence and the covering window
See [[TARIFF-14]] for the cover table: a point covers from its own start to the
next point's start, never more than an hour; the last point gets the span of the
one before; sorted at construction. `covers()` delegates to `at()` (a second copy
of the rule is a second place to be wrong); `window_points()` grades only the
`start` against `[since, until)` and is not a coverage query. `at()` is a linear
scan over about 192 entries on the 30 s path; a bisect is not worth a second
way to be wrong. A duplicated start (not reachable through `load_prices()`)
gives the earlier entry a zero-width window.
## Taxes components (TARIFF-18)
`price_taxes_eur_kwh`'s default (0.06 before) is the sum of
`FLEMISH_TAXES_COMPONENTS_EUR_KWH`, each row one line of the Eneco residential
card for Flanders, September 2026, including 6 % VAT, in EUR/kWh;
test_price.py section 1b re-adds them. It is per DSO: the distribution row runs
5,28 c (Midden-Vlaanderen) to 6,70 c (West), so the all-in Flemish total runs
about 0.117 to 0.131; a site outside Antwerpen types its own. The default sits
near the low end on purpose: the scalar cancels in `max - min`, and its one
decision is the 17 negative-price guard in arbitrage.py, which tests the
absolute all-in sell price. Too high carries a genuinely negative hour above
zero (export into paid-for disposal); too low only declines a trade that would
have paid. `energiebijdrage` is a row of zero: from 1 August 2026 it is merged
into the bijzondere accijns, and the zero row makes the double-count a visible
decision. Moving a shipped default changes which configs start: TARIFF-17's rung
in validate.py refuses `price_taxes_profile` together with a non-default scalar,
comparing against the default (the Supervisor merges every schema key into the
stored blob, 70 of 71 keys on the reference install, CHANGELOG 0.9.0), so a
superseded shipped default (0.06) must still read as untouched, and the rung's
sentence names both figures.
## One in-code mirror of option defaults (CFG-03)
Option defaults live in config.yaml's `options:` and `schema:` and main.py's
`.get()` fallbacks. `PRICE_OPTION_DEFAULTS` is the single in-code mirror for the
price options: validate.py imports it, main.py has no `o.get("price_...", X)`
fallbacks, no dataclass carries a copy, and test_price.py section 1 reads
config.yaml and asserts equality key for key. `static_profile` (TARIFF-03),
`price_taxes_profile` (TARIFF-17), `price_formula` (TARIFF-15),
`price_export_formula` (TARIFF-16) and the OPT-04 keys live there for the same
reason, not in their own modules (a second dict would be a fourth location).
Details:
- The tables / formulas are in `FRAMEWORK_KEYS`: a plugin reading one could add
taxes a second time per hour, or fold an injection curve into a consumption
price. `BANNED_IN_PLUGIN_CODE` derives from that set.
- `arbitrage_soc_floor` / `arbitrage_soc_ceiling` are not the maintenance keys
(a balancing cycle is not a trading reserve).
- `cycle_cost_threshold_eur` is 0.10 because 10.1's name says so, not because it
makes anything trade: on Belgian day/night numbers the static spread is about
0.08, so a static-tariff site does not trade at this default (test_arbitrage.py
section 7). Whether 0.10, or FCST-03's 2.5 / 1.5, is right is an owner
decision.
- `price_service_token` is a secret (`SECRET_OPTIONS`); `scrub()` removes secret
values from any text built from an error.
## Widening (FCST-03)
One path, a set of active uncertainty sources (real-time product 8.3, no weather
data 11.4). The combination rule is `max`, not a product (2.5 x 1.5 would give a
threshold nothing on a Belgian tariff clears). The factors are policy defaults,
not derived (no arbitrage ever ran); they differ so `max`, `sum` and `product`
are distinguishable in a check. `widen_factor()` refuses a string (a silent 1.0
would report a widening that is not applied).
## Injection price (AC4, TARIFF-16)
`export_price_eur_kwh()` is a separate scalar. It was said to be so because
nothing dynamic exists to route; that was false (Eneco publishes `0,1 x BELPEX-H
- 1,188`, ENGIE `(B x eSpot_15) - A`). The true reason: an injection curve has
its own coefficients and VAT treatment (injection is VAT-exempt, consumption is
6 %). TARIFF-16 added `price_export_formula`, read by `export_formula()` through
the same parser as the consumption leg (`parse_export_formula()` is
`parse_price_formula()` with its option name moved). `export_price_at()` falls
through to the scalar and ignores the index, correctly. A negative answer is a
real answer (Eneco's formula is below zero for any index under 11,88 EUR/MWh);
nothing floors it. Nothing under `app/` calls either reader to take a decision:
arbitrage prices the sell side as displaced import. A malformed leg is refused at
startup by validate.py; `taxes_profile()` and `export_formula()` still handle it
loudly at run time. `static_tariff` is imported lazily in `taxes_profile()`
because it imports this module at module scope.
## Taxes at each point's own instant (TARIFF-17)
`taxes_at()` takes a scalar or a period table (duck-typed on `price_at`, because
price.py must not import static_tariff.py). A non-flat component matters: ORES
bills `dag 13,27 / nacht 7,39` c, a 5,88 c gap coinciding with the cheap spot
hours, and flattening it understates the spread. `assemble()` must evaluate it
per point; hoisting the lookup out of the loop collapses the feature back into a
level (test_price.py section 24 grades which hour is cheapest, since a
spread-magnitude assertion passes on the hoisted version). On a scalar, `0.0 +
markup` is exactly `markup` in IEEE 754, so scalar sites get identical bytes.
The table wins over the scalar; validate.py refuses configs where both speak.
## Retired providers (TARIFF-07)
`RETIRED_PROVIDERS` exists so `eneco_eddy` and friends are not refused as typos:
they were shipped, the operator chose them, and every failed fetch fell back
silently to `static_profile`. No alias to `generic_dayahead`: they published
retail prices (`B x spot + A`) and it reads wholesale; an alias would invent the
site's two coefficients (behind the klantenzone login; CREG tariff-card pages
404, VREG unreachable). Each value is the id-specific half of the refusal (the
endpoint, what it answered on 2026-09-13, why no replacement can be built);
validate.py owns the framing. `register()` refuses at import to register a
retired id again, because `pid in PROVIDERS` going true silences the named
refusal; re-shipping is a decision recorded by removing it from
`RETIRED_PROVIDERS` in the same commit. See [[TARIFF-07]].
## Parsers (TARIFF-08)
The only parser is columnar: `https://api.energy-charts.info/price?bzn=BE` gives
parallel `unix_seconds` / `price` arrays plus `unit`. The earlier row parser read
`doc["data"]`, a shape no feed ever published, graded by fixtures written from
the same guess, so every fetch ended `SourceUnavailable` and 11.4 fell back to
static silently. Two shapes, two functions: a parser that sniffs can decide
wrong, and a wrong-but-parseable response becomes a price.
`_points_from_columnar_json()` refuses:
- unequal arrays (`zip()` would truncate the evening peak, and a series missing
the expensive hours is traded on while no-data switches arbitrage off);
- a unit other than the declared one (EUR/kWh under the same keys would be 1000
times too small and `MAX_PLAUSIBLE_EUR_KWH` bounds only the large side);
- naive time: `unix_seconds` are UTC (`fromtimestamp(t, timezone.utc)`).
Rendering at the Brussels offset is `assemble()`'s, once, for every plugin.
A `null` price is dropped and logged (the pairing is reliable, the source said
it published none; `_Series.at()` answers the hole with `Absence`);
`scenarios/fetch_epex_be.py` dropped 0 nulls in 12 months to 2026-08-31. The
feed is quarter-hourly since Belgium's 15-minute MTU (2025-10-01); nothing
assumes either granularity (test_price.py section 2b replays both).
`_validate_raw()` accepts negative prices (windy-Sunday hours are the year's best
charging; 17 keeps the regression) and rejects NaN, infinity, an implausible
magnitude, naive datetimes, and duplicate or out-of-order starts.
## Transport and plugin details
- `check_host()` splits the scheme before parsing the host. The first version
stripped `len("https://")`, which on `http://` ate two characters of the host
(`pi.energy-charts.info`), so a non-TLS feed failed the allow-list and the check
"plain http is refused" passed even with the TLS rule deleted. Found by
mutation in review of PR #16.
- The default transport imports aiohttp lazily so `manual_csv` works with no HTTP
stack. With `headers` (price_service only) it answers in the safe words of
`status_words()` and follows no redirects (the host is fixed; a redirect would
take the token somewhere the allow-list never saw).
- `manual_csv`: the CSV is all-in (the plugin cannot know what a typed number
contains); `load_prices()` warns when markup / taxes are set non-zero under a
plugin that sources them. No transport, aiohttp, network or tz database: it is
the offline escape hatch and the TEST-01 replay ingest path.
- `custom_formula` (TARIFF-15) declares `{ENERGY, MARKUP}`, not `ALL_IN`: a
card's `Tariefformule` is the energy component only (Eneco tabulates network
tariffs separately; ENGIE excludes network, transport, levies, excise), so
`ALL_IN` would drop about 12 c/kWh. The risk left is an operator folding
network charges into `offset`; the card self-check in custom_formula.py refuses
that, and grades the coefficients against the card, not the card against
reality. No expression evaluation: the string is settable over HTTP through
the ingress config page, so `eval` would be RCE in a root container holding
`SUPERVISOR_TOKEN`. It reuses `generic_dayahead`'s feed, URL and parser. It
keeps `scale=1.0`, so its `RawPoint`s carry EUR/MWh in a field called
`eur_per_kwh`: contained (destructured on the next line, never leaves the
method; `_validate_raw()` would refuse an escape).
- `startup_preview()` is a hook (TARIFF-15), not a line in `load_prices()`: only
the plugin knows what went into its arithmetic, and the index beside the
all-in figure makes a factor of 1000 obvious. It must not raise into the control
path; the id is recorded before the hook runs so a failing preview is not
retried every 30 minutes.
- `load_prices()` returns, never raises, except `FrameworkOption` (a plugin trying
to read the markup; turning it into "no price data" would hide the defect).
- `price_provider_id()` is one spelling of the selected id because the same
expression was written at three call sites (TARIFF-10). validate.py keeps its
own reader on purpose: it validates the raw blob.
- `price_provider_selected()` (TARIFF-10): the background refresh used to start
only when `arbitrage_enabled` (ships false) was on, so a configured site
fetched nothing (40 h soak: zero feed requests, `present: false` on all 802
samples). It is not a trading switch: `arbitrage.enabled()` answers that.
- `price_service` (PRICE-01): `FALLBACKS` and `KEEP_LAST` are empty/False for
every earlier plugin, so `price_chain.py` is not used for them.
`TOKEN_REFUSED` is the one phrase the fallback chain reads to tell "the owner
must act" from "the server is down". `covers` lists complete days only; an hour
missing inside a day is a hole, not a free hour.
@@ -1,230 +0,0 @@
# TARIFF-07 — three plugins that never fetched a price, and the one mechanism they leave behind
**Status: DECIDED 2026-09-13 — withdraw `engie_dynamic`,
`totalenergies_dynamic` and `eneco_eddy`. Keep `PriceProduct.REALTIME` and the
widening machinery. Refuse a retired id by name rather than aliasing it.**
Filed with TARIFF-07. Follows [[TARIFF-08]], which found the same class of
defect one layer down — a parser graded against its own guess — and left the
question of these three open.
---
## 1. The endpoints, verified rather than taken on report
The card said the three endpoints were dead. They were re-checked by hand on
**2026-09-13** before anything was deleted, because "the endpoint is dead" is
exactly the kind of claim this repository keeps finding to be second-hand:
| id | endpoint | observed |
|---|---|---|
| `engie_dynamic` | `https://api.engie.be/dynamic/be` | **HTTP 404**, body `{"message":"no Route matched with those values","request_id":"..."}` — a Kong gateway with no route configured |
| `totalenergies_dynamic` | `https://api.totalenergies.be/prices/dynamic` | **TLS failure**. The handshake completes and the server presents `CN=pge-apigee-dev.direct-energie.com`; `api.totalenergies.be` is not in the SAN list, so the connection is closed before any request is sent |
| `eneco_eddy` | `https://api.eneco.be/eddy/prices` | **HTTP 404**, `content-length: 1`, `server: Microsoft-IIS/10.0` — an empty body from a live host that does not serve this path |
All three failed. Had any one of them answered, this card would have stopped
and asked, because an endpoint that answers changes the decision from
"withdraw" to "fix the parser".
## 2. Why this is not "unfinished", and why no replacement can be written
An unfinished plugin has a right endpoint and a wrong parser. These have
neither, and the reason is structural:
* **ENGIE's dynamic price is `(B × eSpot_15) − A`, and `B` and `A` are
PER-CONTRACT.** They live behind the customer's klantenzone login. No
unauthenticated public URL can return *a given customer's* price, so the
endpoint this plugin wanted cannot exist for anybody to build.
* **There is no regulator index to read the coefficients from.** CREG's
tariff-card pages 404; VREG was unreachable. Every coefficient found in
research came from a supplier's own PDF, which is a document, not a feed.
* **"Eddy" is not an Eneco product at all.** Eneco's dynamic product is
**"Zon & Wind Dynamisch"**, and it is indexed to **Belpex/EPEX day-ahead** —
not to imbalance. The plugin was wrong about the product, the product type
and the URL simultaneously.
So the three were not withdrawn for being broken. They were withdrawn for
describing tariffs that no public endpoint can serve.
## 3. What a site configured on one of these has actually been running
⚠️ **This is the half that decides what the migration is.** §11.4 turns a
failed fetch into a silent fall back: static profile, plan provisional, no
arbitrage. That is the *correct* degradation, and it says nothing at all about
the cause. Every fetch these three ever made raised `SourceUnavailable`.
Therefore a site configured on one of them has been running on
`static_profile` — **or on nothing at all, where no profile was set** — since
the day the id was entered, and **nothing on its status page has ever differed
from a working provider that simply never found a cheap hour**.
The migration is therefore **not** "keep their prices working". There were no
prices. It is *"tell them their prices never worked"*, which is why the answer
is a refusal and not a fallback.
### Why not a silent alias to `generic_dayahead`
It is the friendlier-looking option and it is wrong. These published **retail**
prices; `generic_dayahead` reads **wholesale**. Aliasing needs the site's own
`B` and `A`, which §2 has just established nobody has. An alias would have to
**invent the numbers a battery is priced against** — the same defect as a
parser graded against its own guess, one layer further from anything that
reports. The scalar `price_markup_eur_kwh` cannot stand in for `B` either: it
is an addend, not a multiplier, and it is `float(0,1)` so it cannot even hold a
negative `A`.
## 4. ⚠️ The `config.yaml` schema is deliberately WIDER than the registry
`price_provider` is a `list(off|…)` enum, and the Supervisor validates the
options file against it **before the add-on starts**.
So the obvious edit — delete the three ids from the enum along with the classes
— has a consequence that runs exactly opposite to this card's purpose:
```
operator's config says price_provider: eneco_eddy
|
+-- id NOT in the schema enum
|
+-- Supervisor rejects the options file
|
+-- add-on never starts
|
+-- validate.py never runs
|
+-- the named refusal is NEVER SEEN
```
The operator gets an opaque `expected one of ...` from a layer they did not
configure, on **exactly the sites the withdrawal exists to inform**.
**Decision: keep all three ids in the schema enum.** `PROVIDERS` shrinks to
two; the enum stays at six. The cost is that the dropdown still offers three
dead ids to a *new* operator — and that is the cheaper failure, because
choosing one is refused at the next startup **by name**, with the endpoint, the
observation and the alternative, which is strictly more than a schema
rejection would say. Both translations label them withdrawn in the option
description.
`app/price.py`'s `RETIRED_PROVIDERS` is the list. `test_price.py` section 1
asserts the schema offers exactly `PROVIDERS | RETIRED_PROVIDERS | {"off"}` —
derived, not typed — and `register()` refuses to re-register a retired id, so
the registry and the retired table cannot both claim one and the refusal cannot
be taken away by an `@register` somebody adds back.
## 5. ⚠️ `PriceProduct.REALTIME` and the widening path are KEPT with no shipped declarer
`eneco_eddy` was the **sole** declarer of `PriceProduct.REALTIME`. Deleting it
orphans:
* `_WIDE = frozenset({PriceProduct.REALTIME})`
* `Uncertainty.REALTIME_PRODUCT`
* `WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT] = 2.5`
**They stay.** The reasoning, stated here so the next reader finds intent
rather than an apparent dead branch:
1. A reviewer finding a mechanism with **zero callers** would reasonably delete
it. That is good instinct applied to a missing fact, and this file is the
fact.
2. It is the **AC2 seam**. The whole point of `capabilities().product` is that
a new supplier is a new class and nothing else: declare `REALTIME`, get the
wide regime, edit no framework code. Deleting the enum member would mean the
first genuine imbalance product has to **rebuild** that seam — and rebuilding
it is precisely the "editing the optimiser to add a supplier" that AC2
forbids.
3. It costs nothing. Two enum members and a dict entry.
**⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME` UNTIL ONE DOES.** Both
shipped plugins (`generic_dayahead`, `manual_csv`) are day-ahead. §9.2's row 2
is unoccupied. That is not the same as it having no mechanism, and the
difference is what this section exists to record.
It is **pinned, not merely documented**. `test_price.py` section 5 asserts:
* that **no shipped plugin** declares `REALTIME` — so if one ever does, the
check goes red and the right fix is to **edit this record**, not to delete
the seam;
* that the widening still works **from a declaration alone** —
`volatility_regime`, `uncertainty_of` and `widen_factor` are graded on a
`ProviderCapabilities` the test file builds;
* and section 10 registers `_NewSupplier`, a class defined **outside
`price.py`** that declares `REALTIME` and is asserted to land in the wide
regime. That class is now the only live declarer in the tree, which makes the
seam something this suite exercises end to end rather than a constant it
reads.
## 6. What else went, and one thing that did not
`_points_from_json()` went with the three. It read a `{"data": [{...}]}`
row-per-point document that **no feed anybody here has ever reached
publishes** — the shape was invented alongside these plugins, and the only
bodies ever fed to it were fixtures written from the same guess. TARIFF-08
found that loop on the response side for `generic_dayahead`; this is the rest
of it. `needs_credentials` went too: the three were its only setters and
nothing read it.
The shape survives in **one** place and in one direction: `row_shape_body()` in
`test_price.py`, feeding `generic_dayahead` so that section 9 grades a row
document as a **failure** for it rather than a second dialect it might sniff.
It is named for what it is.
⚠️ **One check had to be rebuilt rather than deleted, and saying so matters.**
`test_price.py` section 4b proved that `code_leaks()`'s docstring strip is
load-bearing by finding a shipped docstring that a raw text scan would
false-positive on. The docstring that did that was **`engie_dynamic`'s** — it
spelled `price_markup_eur_kwh` in full. With it gone, no shipped docstring
spells a full framework key, so the check would have **passed on a tree with
the strip deleted**: a check that had quietly stopped being one, which is the
third failure mode in `MUTATION.md`. The stimulus is now constructed in the
test file (`_DocumentedPlugin`), and the shipped set is asserted clean
separately.
## 7. Mutations driven
Every check added or rebuilt here was driven red. Harness:
`/home/homelab/.cache/t07-mut/`, which rebuilds the tree from the worktree and
purges `__pycache__` per `MUTATION.md`'s rule.
| | mutation | Δ | red on |
|---|---|---|---|
| M1 | delete the retired branch from `_price_problems()` | −2785 B | 7 checks in `test_price.py` |
| M2 | move the retired branch **after** the unknown-id branch | **+0 B** | the same 7 |
| M3 | make `been_running` constant (drop the empty-`static_profile` case) | −212 B | 2 |
| M4 | drop the "WHAT TO DO" half of the refusal | −874 B | 1 |
| M5 | `RETIRED_PROVIDERS = {}` | −1099 B | 5, then a traceback |
| M6 | drop the three ids from `config.yaml`'s enum — §4's trap | −47 B | 2 |
| M7 | delete `register()`'s retired guard | −1101 B | 2, then a traceback |
| M8 | `_WIDE = frozenset()` | −23 B | 3 + 4 across two suites |
| M9 | `WIDEN_FACTOR[REALTIME_PRODUCT]` 2.5 → 1.5 | **+0 B** | 1 + 1 |
⚠️ **M2 and M9 have a byte delta of zero**, which `MUTATION.md` names as the
risky case. The harness rebuilds the tree from source on every run and purges
`__pycache__`, so there is no inherited cache to read; and stale bytecode
manufactures **survivors**, never reds, so a red from a `+0 B` mutation is not
at risk from that defect. M2's grep confirmed the branch had moved on disk.
⚠️ **M5 and M7's red counts are LOWER BOUNDS.** Both runs ended in a traceback
with checks after it ungraded — FCST-06's rule: read the output, not the count.
M7's traceback is itself informative: with the guard gone the re-registration
**succeeds**, `PROVIDERS` is polluted with a class that has no fixture, and the
suite dies downstream. That is the failure the guard prevents, arriving loudly.
## 8. What this card did not do
* **It did not pick a version number.** The change is backward-incompatible — a
config legal on the current version is refused on the next — and the
CHANGELOG says so prominently, in the shape PKG-07 established for `slew_w`.
Which number it belongs to depends on whether the current image has already
been built and pulled, which is a fact about the live box that this branch
cannot observe. Whoever deploys decides.
* **It did not implement the formula provider.** `B × spot + A` with per-site
coefficients is the real answer for a Belgian dynamic customer and is a
separate card. `DOCS.md` references it as planned and is explicit about what
`generic_dayahead` can and cannot express in the meantime — in particular
that it cannot represent a negative offset, and that
`price_markup_eur_kwh: float(0,1)` could not hold one anyway.
* **It did not rewrite released CHANGELOG history.** The two historical entries
that called `eneco_eddy` an imbalance product are annotated in place with a
correction naming this card, rather than silently edited. The record of what
the project believed is part of the record.
@@ -1,141 +0,0 @@
# TARIFF-09 — asking the feed for the horizon, and what `price_feed_url` means
Two decisions. The first had no defensible alternative once it was measured;
the second did, and the alternative is argued below rather than dismissed.
---
## 0. The measurement this card exists for
`generic_dayahead.fetch()` accepted `since` and `until` and requested the bare
`https://api.energy-charts.info/price?bzn=BE`. That URL names no range, and the
feed answers **one Brussels day**.
Measured on the merged tree (`4257e38`) at 2026-09-10 09:00 CEST, 48 h horizon,
live feed, yesterday seeded through the shipped read path:
```
60 firm points (15 whole hours) + 33 carried hours of 48 requested
carried: 24 h on 2026-09-11, 9 h on 2026-09-12
arbitrage(0.10): allowed, spread 0.4645, buy 11:45 and sell 19:45 -- BOTH on 2026-09-10
firm local days = [2026-09-10]
```
Everything past today was carry-forward, flagged provisional, and §8.3 drops
provisional hours from the trade set. **The optimiser never saw tomorrow as
firm, at any hour.** The ceiling was structural rather than incidental: with a
one-day window, at hour *H* of the local day a 48 h horizon is at most
**24−H hours firm and 24+H carried** — 15/33 at 09:00, and **1/47 at 23:00**,
where the remaining firm spread (0.0464 EUR/kWh) no longer even clears the
default 0.10 round-trip threshold and arbitrage is refused outright.
That is not what FCST-04's fill was built for. FCST-04 covers the
pre-publication window until the ~13:00–14:00 CET auction, after which a
healthy feed hands over tomorrow in full.
**The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z:
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns **192 quarter-hours across
two local days**, committed unmodified as
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`. The
plugin simply never asked.
After the change, same code path, same window, a feed serving both days:
**39 firm hours and 9 carried**, firm across **two** Brussels days, and the
permitted cycle buys on one day and sells on the other. Graded in
`test_price_history.py` section 1c against both feed states.
---
## 1. The range is derived on the **Brussels** calendar, from `since`/`until`
`since` and `until` are **instants**; the API takes **dates**. Those are not
the same question, and the conversion is where the day gets lost.
`brussels_date_range()` returns two inclusive `date`s:
* the first is `brussels(since).date()`. **Not `since.date()`, and not the UTC
date.** 22:30 UTC in summer is already tomorrow in Belgium, so a UTC-derived
start asks for the day the controller has just left and drops the day it is
standing in — for the last one or two hours of *every* day. This is
`price_history._bdate()`'s lesson one layer up, with a worse consequence:
`_bdate()` misfiles an hour, this never fetches it.
* the last is `brussels(until).date()`, **stepped back a day when `until`
falls exactly on local midnight**, because `until` is exclusive and that
instant is the first tick of a day nobody asked for. Without it every
whole-hour horizon requests one extra day — harmless on this feed, but it
makes the request a claim the caller never made.
* an empty or inverted window collapses to a single day rather than producing
a backwards range, which no feed has a defined answer for.
Both ends are graded, and both are driven red by mutation — including the two
that swap `brussels()` for the UTC date (`test_price.py` section 2c).
---
## 2. `price_feed_url` is the **exact URL**, used as given. The plugin appends
nothing.
The alternative — merge our `start`/`end` into whatever query string the
operator supplied — was considered and rejected. Three reasons, in the order
they decided it:
1. **`start`/`end` are energy-charts' spelling, and this provider declares a
second host.** `generic_dayahead.capabilities().egress_hosts` is
`("api.energy-charts.info", "web-api.tp.entsoe.eu")`. That second host's
day-ahead date parameters have not been verified by this card and are not
guessed at here — but they are not `start`/`end`. So an override to the
*other declared host* — a case the declaration itself anticipates — is
exactly where merging appends two parameters that host ignores, producing
the short horizon anyway, with a URL nobody typed as the receipt.
2. **Merging means re-serialising a URL somebody else wrote.** Parsing and
rebuilding reorders parameters, collapses duplicates and re-escapes values.
"Override the feed" is what the option already says it means, and an
override that is silently rewritten is not one.
3. **Blind appending is not on the table at all.** `dated_feed_url()` refuses
to add a query string to a base that already has one, so
`?bzn=BE?bzn=BE&start=…` cannot be produced by this module even by a later
edit that folds the parameters back into the constant.
⚠️ **The cost is real and it is therefore NOT a doc footnote.** An overriding
operator gets exactly the short horizon this card exists to fix. So the plugin
emits a `WARNING` on the shipped `goodwe.price` logger at **fetch time**, naming
the consequence — one Brussels day, the rest carried and flagged, §8.3 dropping
them from the trade set — and naming the `start..end` it *would* have asked
for. §11.4 is what makes a price defect quiet; an operator who has taken the
short horizon has to be able to find out from the log that they did.
The warning is graded, with a control that the default path stays silent, and
a mutation that downgrades it to `debug()` takes that check red.
**Fetch time rather than startup**, because that is where the URL actually in
use is known. The trade-off is one line per refresh (the tariff task, not the
30 s control tick). If the status page ever grows a price-source row, this is
the sentence it should carry.
---
## 3. The plugin contract: `fetch(self, opts, since, until, transport)`
The signature promises every plugin the range, and until this card **none of
the four networked plugins used it**. The obvious follow-up is to enforce the
promise. It is **not** enforced, and the reason is TARIFF-08's:
Three of the five shipped plugins point at endpoints nobody has ever verified
(TARIFF-07 is the open decision on withdrawing them). A rule obliging them to
pass a date range would be satisfied by an **invented parameter name** — which
is the parser-graded-against-its-own-guess defect moved from the response to
the request, and it would ship looking like a guarantee.
What exists instead is a **roster pin** in `test_price.py` section 2c: which
plugins reference their `since`/`until` is asserted as a literal, derived from
the registry and the AST. A sixth supplier, or a change to any of the three,
has to edit that line and make the decision rather than inherit the silence.
It is named as a source-shape pin, not as evidence that any feed parses;
`generic_dayahead`'s behaviour is graded separately by the URL it really
requests.
**Recommendation for whoever picks up TARIFF-07:** enforce it at the moment
those three endpoints are either verified or withdrawn, not before. If they are
withdrawn, `generic_dayahead` and `manual_csv` are the whole roster and the
rule is one line — `manual_csv` reads a whole file and is windowed downstream,
so it would be the single documented exemption.

Some files were not shown because too many files have changed in this diff Show More