Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22c016fadd | ||
|
|
6b85acb0f1 | ||
|
|
b30797e1f9 | ||
|
|
7ef3d23245 | ||
|
|
b6f41f736c | ||
|
|
753f700539 | ||
|
|
f594543988 | ||
|
|
d48c6b971d | ||
|
|
e941bf7f62 | ||
|
|
e306c74cb8 | ||
|
|
591c35ed01 | ||
|
|
3030f72b9e | ||
|
|
3fe3c7acb0 | ||
|
|
186e13ceca | ||
|
|
df88b323f5 | ||
|
|
38f6680639 | ||
|
|
06f95e0a64 | ||
|
|
0c7e786ed5 | ||
|
|
ab6fbe2f9e | ||
|
|
1c59cfeaf5 | ||
|
|
acbdf41248 | ||
|
|
950472662e | ||
|
|
6ea84a46f6 | ||
|
|
48cd5f31ca | ||
|
|
20f9f4969d | ||
|
|
b68f288fef | ||
|
|
e8fb596b99 | ||
|
|
d0b053b150 | ||
|
|
f630ed949b | ||
|
|
d5b1ef19b3 | ||
|
|
b7267eb51a | ||
|
|
e498dd4c1c | ||
|
|
3cb9d3b7d9 | ||
|
|
99321892cb | ||
|
|
182102eeaf | ||
|
|
1e4bad8aa7 | ||
|
|
6ea79f1043 | ||
|
|
29fa39eda7 | ||
|
|
d64790b3e2 | ||
|
|
83911ed637 | ||
|
|
7b4877d56d | ||
|
|
358fbb0172 | ||
|
|
504e8daac3 | ||
|
|
f6ecd5ff03 |
+13
-1
@@ -18,5 +18,17 @@
|
||||
# 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
|
||||
RUN apk add --no-cache python3 py3-aiohttp py3-paho-mqtt git
|
||||
|
||||
+179
-14
@@ -18,7 +18,9 @@
|
||||
# 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 seven lines of git below.
|
||||
# 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
|
||||
|
||||
on:
|
||||
@@ -26,15 +28,53 @@ on:
|
||||
branches: [release/1.0, master]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
# ⚠️ PER PULL REQUEST, NOT A BARE NAME. A shared group holds one running and
|
||||
# one pending run, so a bare name lets a second PR supersede a first PR's run
|
||||
# before any runner has picked it up - which is exactly what happened to the
|
||||
# other repo's run 521 (four jobs, `started_at` at the Unix epoch,
|
||||
# `runner_id: 0`, concluded `cancelled`, and the PR merged anyway). Do not
|
||||
# replace this with a constant.
|
||||
group: addon-checks-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
# ⚠️ 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:
|
||||
@@ -58,19 +98,132 @@ jobs:
|
||||
# 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.
|
||||
- name: checkout into a path the host shares (git; no node for actions/checkout)
|
||||
#
|
||||
# ⚠️ 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 "$(echo "$URL" | sed "s#://#://x-access-token:${TOKEN}@#")"
|
||||
git fetch -q --depth 1 origin "$GITHUB_REF" || git fetch -q --depth 1 origin "$GITHUB_SHA"
|
||||
git checkout -qf FETCH_HEAD
|
||||
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
|
||||
|
||||
- name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile)
|
||||
run: |
|
||||
set -eu
|
||||
@@ -101,10 +254,22 @@ jobs:
|
||||
# hwsim section was skipped - so "test_freeze.py: PASS" below is a weaker
|
||||
# claim than it looks. That belongs to whoever owns test_freeze.py next;
|
||||
# naming it here is the least this gate can do about it.
|
||||
# ⚠️ 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 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
|
||||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
|
||||
cd /w/goodwe-addon/goodwe_controller
|
||||
set -- test_*.py
|
||||
if [ "$1" = "test_*.py" ]; then
|
||||
|
||||
+31
-1
@@ -57,6 +57,14 @@ python3 test_control.py && python3 test_maintenance.py && python3 test_arbiter.p
|
||||
&& python3 test_loop.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`
|
||||
@@ -75,7 +83,24 @@ 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 `sign_tripped` from `/data/state.json`.
|
||||
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
|
||||
@@ -165,6 +190,11 @@ 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.
|
||||
|
||||
@@ -19,6 +19,8 @@ goodwe_controller/ the add-on
|
||||
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
|
||||
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)
|
||||
@@ -82,7 +84,12 @@ python3 test_control.py
|
||||
python3 test_maintenance.py
|
||||
```
|
||||
|
||||
Both must pass before shipping any change. They are not unit-test theatre —
|
||||
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 —
|
||||
each assertion corresponds to a rule whose absence produced an observed failure
|
||||
on real hardware, and the comments in `control.py` say which.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,417 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,316 @@
|
||||
# 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)`.
|
||||
|
||||
### Reproducing it
|
||||
|
||||
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. 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 |
|
||||
|
||||
**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.
|
||||
|
||||
## 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.
|
||||
* **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.
|
||||
+242
-8
@@ -55,6 +55,82 @@ Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
|
||||
those serve cached values, and a stale reading here ends the maintenance charge
|
||||
phase having charged nothing.
|
||||
|
||||
### More than one inverter (`inverters`)
|
||||
|
||||
The six keys above describe **one** inverter, and if that is your site there is
|
||||
nothing to do here — `inverters` ships empty and empty means *"the flat keys
|
||||
above describe the one inverter"*. **An install that upgrades into 0.6.0 reads
|
||||
identically to before: same entities, same rating, same behaviour, no operator
|
||||
action.**
|
||||
|
||||
To run two, declare them. Each entry names its own actuator, its own rating and
|
||||
its own sensors:
|
||||
|
||||
```yaml
|
||||
setpoint_entity: "" # ⚠️ must be cleared — see below
|
||||
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: sensor.goodwe_b_battery_power
|
||||
max_w: 3000
|
||||
```
|
||||
|
||||
| per-entry key | required | meaning |
|
||||
|---|---|---|
|
||||
| `name` | yes | Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so **two units may not share one** |
|
||||
| `setpoint_entity` | yes | This inverter's own ESPHome `number.*_goodwe_setpoint_w` |
|
||||
| `max_w` | yes | The supervised cap on what may be written to **this** inverter. The site's ceiling is the **sum** |
|
||||
| `soc_entity` | | This pack's state of charge |
|
||||
| `batt_entity` | | This unit's battery power, `+` = discharging |
|
||||
| `batt_invert` | | Flip if needed |
|
||||
|
||||
⚠️ **Nothing is inherited.** An entry that leaves out `soc_entity` has **no SoC
|
||||
sensor** — it does *not* fall back to the flat one. That is deliberate: every
|
||||
per-device value became per-instance in 0.5.x precisely so that no inverter is
|
||||
ever graded against a sibling's numbers, and a silent fallback would rebuild
|
||||
that in YAML. `name`, `setpoint_entity` and `max_w` are required of every entry
|
||||
for the same reason: there is no rating to borrow.
|
||||
|
||||
⚠️ **Clear the flat `setpoint_entity` when you declare a roster,** or the add-on
|
||||
refuses to start and says so. It is the single-inverter form and it is not read
|
||||
once a roster exists, so leaving it set means one of two things: it names an
|
||||
entity an entry also names — two writers on one actuator, which specs §1.5 makes
|
||||
a non-goal and the add-on refuses outright — or it names a different one, which
|
||||
nothing would ever write, leaving a site running quietly smaller than its
|
||||
configuration reads. `soc_entity`, `batt_entity`, `batt_invert` and `max_w`
|
||||
cannot be blanked (the schema gives them no empty value); they are simply not
|
||||
read, and the add-on writes one line into its action log at every start saying
|
||||
so.
|
||||
|
||||
⚠️ **Two inverters naming the same `setpoint_entity` are refused,** whichever
|
||||
keys they are declared under and whatever the capitalisation. Two independent
|
||||
setpoints on one inverter is the arbitration problem this add-on exists not to
|
||||
have.
|
||||
|
||||
⚠️ **Update the add-on first, then add the roster.** Home Assistant validates the
|
||||
options you save against the schema of the version that is *installed*, so
|
||||
`inverters` cannot be set before 0.6.0 is installed. The order is: update, then
|
||||
configure.
|
||||
|
||||
⚠️ **Going the other way — downgrading with a roster configured — is recoverable
|
||||
but NOT uneventful, and the add-on will not start until you refill a field.**
|
||||
The *schema* side is fine: the older version's schema has no `inverters` key, so
|
||||
it drops the key with a warning rather than refusing the whole blob (measured —
|
||||
`DECISIONS/CFG-04-inverter-roster.md` §E). The *add-on* side is not. Rule 9(e)
|
||||
requires the flat `setpoint_entity` be **cleared** when a roster is declared, so
|
||||
a config that downgrades cleanly is by construction a config whose flat
|
||||
actuator is blank — and the older version has nothing else to write to. It stops
|
||||
at startup validation with a refusal naming `setpoint_entity`, which is loud and
|
||||
in the log rather than silent, but it is an outage until someone puts the
|
||||
single-inverter entity back in that field. **Before downgrading, write down the
|
||||
`setpoint_entity` of the unit you want to keep running.**
|
||||
|
||||
### P1 meter ingestion
|
||||
|
||||
`meter_entity` above expects one signed sensor, which usually means a template
|
||||
@@ -272,7 +348,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
|
||||
| option | default | meaning |
|
||||
|---|---|---|
|
||||
| `max_w` | 2000 | Hard limit on what may be commanded. Start low, raise after commissioning |
|
||||
| `max_w` | 2000 | Hard limit on what may be commanded. Start low, raise after commissioning. **Not read when `inverters` is declared** — each entry carries its own, and the site ceiling is their sum |
|
||||
| `gain` | 0.6 | Correction per cycle. **At the limit — do not raise** |
|
||||
| `slew_w` | 1000 | Maximum change per cycle |
|
||||
| `deadband_w` | 15 | Ignore errors smaller than this |
|
||||
@@ -306,7 +382,13 @@ does. That is deliberate on both counts:
|
||||
always vanishes the instant the trip works. A watchdog that took that as proof
|
||||
the fault was over would hand the inverter straight back to the loop that was
|
||||
driving the wrong way, three minutes later, forever.
|
||||
- The trip is written to `/data/state.json` and restored on startup. Restarting
|
||||
- The trip is written to `/data/state.json` — under
|
||||
`unit/<inverter>/sign_tripped`, one key per inverter since DEV-02, so a trip
|
||||
caused by one inverter cannot latch another — and restored on startup. The
|
||||
pre-DEV-02 flat `sign_tripped` is written and cleared alongside it as the
|
||||
**rollback view**, so downgrading the add-on past DEV-02 still finds the
|
||||
latch, and a trip taken while the install is rolled back is re-armed by the
|
||||
version that comes back up rather than silently lost. Restarting
|
||||
the add-on is the first thing most people try when they see a red banner, and
|
||||
a latch that a restart clears would bring the controller back **armed, into the
|
||||
same fault, unattended** — and under a Supervisor that restarts a crashed add-on
|
||||
@@ -430,7 +512,7 @@ help - see "Why the tuning is what it is".
|
||||
|---|---|
|
||||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
||||
| `peak_cap_w` | The site's capacity-tariff target |
|
||||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** Not optional and not the same thing as `maintenance_soc_floor` — see below |
|
||||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
|
||||
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start |
|
||||
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
|
||||
|
||||
@@ -552,8 +634,13 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
|
||||
- **`peak_soc_floor` (20 %)** is where the forced discharge stops. Below it the
|
||||
guard files a **0 W** floor instead of a sized one: charging stays blocked
|
||||
(that can only ever lower your offtake), and no further discharge is asked for.
|
||||
- **It does not stop the ordinary self-consumption loop.** That loop still has no
|
||||
SoC guard of its own. This bounds what the *peak guard* spends for money.
|
||||
- **⚠️ IT BOUNDS THE PEAK GUARD ONLY. IT IS NOT A PACK-WIDE RESERVE, AND THE NAME
|
||||
READS LIKE ONE.** The ordinary self-consumption loop — the grid-following
|
||||
control law that holds your meter at zero — has **no SoC floor of any kind**.
|
||||
It will discharge straight past this number and keep going until the pack's own
|
||||
BMS cut-out stops it. What you are promised by `peak_soc_floor: 20` is *"the
|
||||
capacity-peak guard will not spend the pack for money below 20 %"*, and
|
||||
**nothing more**. See below for what that costs on a real evening.
|
||||
- **When it binds you are told**, once per episode, with notification
|
||||
`goodwe_peak_soc_floor` and a status-page row reading `PEAK GUARD
|
||||
FLOOR-LIMITED`: the quarter **will** go over the ceiling, so shedding load is
|
||||
@@ -572,6 +659,59 @@ Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the
|
||||
BMS cut-out is about 1.6× the guard's worst measured day. If your pack is
|
||||
smaller, or your quarters are worse, raise it.
|
||||
|
||||
#### ⚠️ What `peak_soc_floor` does **not** protect (SAFETY-25)
|
||||
|
||||
**On a sustained-load evening this add-on will take your battery to the BMS
|
||||
cut-out, whatever `peak_soc_floor` says.** That is measured, not argued. Replayed
|
||||
on the committed 2026-08-20 capture, 19:00–22:00 UTC — three hours of house load
|
||||
after the sun has gone, 830 W average, not one watt of export in the window — with
|
||||
the 13.8 kWh reference pack started at 21 %, one point above the shipped floor:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| pack at the start of the evening | **21 %** (one point above `peak_soc_floor`) |
|
||||
| pack three hours later | **10.0 %** — the `bms_soc_floor` cut-out |
|
||||
| energy the loop took out | **1525 Wh** |
|
||||
| control cycles below `peak_soc_floor` | **549 of 587**, and **343** of those still delivering discharge |
|
||||
| cycles on which the peak guard filed *any* floor | **0** |
|
||||
| same replay at `peak_soc_floor` = 5, 11, 20, 60 | **the same 10.0 %, to four decimal places** |
|
||||
|
||||
That last row is the point: **this is a property of the control law, not a value
|
||||
you can configure.** Raising `peak_soc_floor` to 60 % changes nothing at all,
|
||||
because the guard it bounds is not the thing doing the discharging.
|
||||
|
||||
**And the two can happen in the same cycle.** On the high-load block of the same
|
||||
day (15:00–17:00 UTC) with the pack below the floor, the guard reports
|
||||
`PEAK GUARD FLOOR-LIMITED` — *"commanding no forced discharge"* — on 49 cycles,
|
||||
and on **all 49 of them** the loop is simultaneously being granted a discharge of
|
||||
up to 2000 W by the arbiter. The status page is telling you the truth about the
|
||||
guard while the battery empties.
|
||||
|
||||
**Why it is built this way, and why SAFETY-24 could not fix it.** The peak guard
|
||||
files a `lo` limit — a *lower* bound on the setpoint. That asymmetry is exactly
|
||||
what makes the guard safe: it is provably incapable of lowering a target, capping
|
||||
a discharge or contradicting the arbiter. The same property makes it incapable of
|
||||
stopping a discharge the loop itself wants. A floor on the guard cannot become a
|
||||
floor on the pack by being raised.
|
||||
|
||||
**Is this a runaway?** No. Self-consumption discharge is demand-driven and
|
||||
self-limiting: it stops when the house stops drawing, and every watt of it is
|
||||
serving load you would otherwise have imported. It is not SAFETY-18's frozen-meter
|
||||
rail. What it means is that the *depth* your pack reaches on a heavy evening is
|
||||
set by your house, not by any number in this file, and the BMS is the only thing
|
||||
underneath it.
|
||||
|
||||
**This is now a recorded decision, not an open question.** On **2026-09-02** the
|
||||
product owner took **Option A**: the BMS cut-out is **deliberately** the only
|
||||
backstop for demand-driven discharge, and a loop-side reserve floor is
|
||||
**deferred** rather than rejected. The options, their costs, the measured replay
|
||||
and the reasoning are in
|
||||
[`DECISIONS/SAFETY-25-loop-soc-floor.md`](DECISIONS/SAFETY-25-loop-soc-floor.md),
|
||||
together with the revisit trigger (`ALLOC-01`, the host-side share allocator)
|
||||
under which a reserve becomes safe to build. **Nothing about the behaviour above
|
||||
changed** — Option A is what has always shipped. What changed is that this
|
||||
section now describes a choice somebody made, and you can read why.
|
||||
|
||||
### The winding-loop alarm (the output clamp)
|
||||
|
||||
Every setpoint this add-on writes is clamped to `max_w` before it leaves the
|
||||
@@ -640,7 +780,18 @@ The add-on alarms when **both** of these are true at once:
|
||||
|
||||
- the meter's own document (power figure *and* both kWh registers) has not
|
||||
changed for `meter_freeze_s` — 300 s by default; **and**
|
||||
- the command standing on the wire is at the rail, within 5 % of `max_w`.
|
||||
- the command standing on the wire is at the rail, within 5 % of the ceiling
|
||||
the command can actually reach.
|
||||
|
||||
**That ceiling is `min(max_w, the setpoint device's own rating)`, not `max_w`.**
|
||||
The emitted command is clipped to the device's reported range as well as to
|
||||
`max_w`, so on a 4700 W inverter configured with `max_w: 5000` a rail measured
|
||||
at 4750 W is one the command can never stand at — the guard could not fire, and
|
||||
the status page said `armed` anyway. It now measures the rail against the
|
||||
smaller number and the page names it, saying *"below max_w because …"* when the
|
||||
device is what capped it. (On an install that started, the two are equal: a
|
||||
`max_w` above the device rating is refused at startup. This matters when the
|
||||
rating could not be read at boot — see below.)
|
||||
|
||||
Neither half is usable alone. A bare "unchanged for N seconds" rule false-trips
|
||||
at the exact operating point this controller aims for — at a converged −10 W a
|
||||
@@ -666,6 +817,13 @@ that reads the meter's own document; on `ha_dsmr`, `mqtt_p1`, `ha_signed` and
|
||||
`off` there is no signal to threshold and the status page says **NOT COVERED**
|
||||
rather than pretending the guard is armed.
|
||||
|
||||
The same row appears for a second reason: a setpoint device that reports a range
|
||||
from which no command magnitude is available in both directions leaves no rail
|
||||
to measure at all, and a 0 W rail would be satisfied by *every* command. That is
|
||||
reported as **NOT COVERED** too, and the detector stays silent rather than
|
||||
degenerating into the bare "unchanged for N seconds" rule this design exists to
|
||||
avoid.
|
||||
|
||||
| option | default | meaning |
|
||||
|---|---|---|
|
||||
| `meter_freeze_s` | 300 | Seconds of an unchanged measurement, *while the command is at the rail*, before the alarm |
|
||||
@@ -679,6 +837,82 @@ unchanged is 12 W, which is inside the 15 W deadband — so the command there is
|
||||
parked at 0 W and the two halves of the rule cannot both be true. At the schema
|
||||
floor of 60 s that figure is 60 W and only the margin is left.
|
||||
|
||||
### The circulating-power alarm (SAFETY-31)
|
||||
|
||||
> **Only at two or more inverters.** At the shipped roster of one this quantity
|
||||
> is identically zero for every reading, no claim is ever filed, and the status
|
||||
> page says *n/a* rather than pretending to watch it.
|
||||
|
||||
Two inverters can move a great deal of energy while the meter reads almost
|
||||
nothing. Inverter A charging at 3 kW while inverter B discharges at 3 kW nets
|
||||
**~0 W at P1**: the site looks idle, and in reality the packs are being cycled
|
||||
through two round-trip conversion losses continuously, for no benefit.
|
||||
|
||||
**Nothing else in this add-on can see it, and that is structural rather than an
|
||||
oversight.** Every other guard is a consumer of the *net* figure at the grid
|
||||
connection — the sign-disagreement failsafe gates on `abs(grid_w) > 300 W` and
|
||||
that gate never opens at a netted zero; the capacity-peak guard and the tariff
|
||||
both bill grid exchange. Circulating power is precisely the component of
|
||||
per-inverter power that the sum destroys.
|
||||
|
||||
So the add-on computes a quantity none of them were computing:
|
||||
|
||||
```
|
||||
circulating_w = Σ|Bᵢ| − |ΣBᵢ|
|
||||
```
|
||||
|
||||
Zero when the units pull the same way. `2 × min(|B_a|, |B_b|)` when they oppose
|
||||
— twice the power going round the loop, which is what both conversion stages are
|
||||
processing between them. It is published as
|
||||
`sensor.goodwe_circulating_power`, deliberately **beside** the grid figure:
|
||||
*"grid 0 W, circulating 6000 W"* is the whole fault in two dashboard rows, and
|
||||
no arithmetic on the grid figure alone can produce the second one.
|
||||
|
||||
**It is derived from each inverter's own measured power, not from what the
|
||||
controller believed it commanded.** That matters because the allocator already
|
||||
*refuses* to command opposite directions (ALLOC-01's sign-coherence gate), so a
|
||||
figure computed from the allocation record would read zero by construction and
|
||||
detect nothing. What this catches is circulation **nobody commanded** — a unit
|
||||
ignoring or lagging its share, a stale share still being executed, or an
|
||||
inverter doing something of its own accord.
|
||||
|
||||
| option | default | meaning |
|
||||
|---|---|---|
|
||||
| `circulation_w` | 700 | Watts of measured opposition below which nothing is reported |
|
||||
| `circulation_hold_s` | 75 | Seconds it must stand there continuously before the trip |
|
||||
|
||||
**Why 700 W and 75 s, and why neither is a round number.** Both were read off
|
||||
924 closed-loop runs on the two-master test rig in which *nothing* was faulty,
|
||||
swept across the whole range the schema accepts for `gain` and `slew_w`. A
|
||||
momentary opposition is **legitimate**: writes to the inverters are serialised,
|
||||
so on a reversing cycle one unit gets its new number up to a control cycle
|
||||
before its sibling, both slew through zero, and between the two crossings they
|
||||
genuinely oppose. The worst *continuous* legitimate opposition, by threshold,
|
||||
was **505 s at 100 W, 40 s at 250 W, 10 s at 300 W and 5.0 s — one control
|
||||
cycle — at 400 W and above**. The knee is 400 W and it is the schema floor;
|
||||
below it, ordinary operation would hold the sensor over the line for minutes and
|
||||
no usable window could tell that from a fault. 700 W carries margin above the
|
||||
knee because the second inverter's ramp and lag are not yet measured. 75 s is
|
||||
15× the worst legitimate episode, 2.5× the firmware's own 30 s link-veto window,
|
||||
and comfortably inside the 900 s quarter that bills the capacity tariff.
|
||||
|
||||
**What happens when it trips, and what it honestly cannot do.** The site is
|
||||
commanded **0 W on every inverter** and latched there until you press start.
|
||||
That is not aimed at making a misbehaving inverter stop — if a unit has stopped
|
||||
listening, no command will reach it. It does two things that do not need its
|
||||
cooperation: it removes *this controller* as one of the two participants in the
|
||||
loop, and it makes anything still happening **visible at the meter** instead of
|
||||
cancelled at it, where the ordinary guards can see it. **If the grid reading
|
||||
does not move after the trip, one inverter is no longer listening and the
|
||||
remaining actuator is a human at the isolator.**
|
||||
|
||||
**The alarm survives a Home Assistant restart.** The latch is written to `/data`
|
||||
and the notification is re-raised on the first cycle after every start —
|
||||
`persistent_notification` lives only in memory, and an alarm that vanishes with
|
||||
a restart is how a real outage on this system went unseen for over six hours.
|
||||
Restarting does **not** clear the trip; starting control from the add-on UI
|
||||
does, and only after you have read why.
|
||||
|
||||
### Energy price providers
|
||||
|
||||
> **A fetched price moves energy only when you switch arbitrage on.** This
|
||||
@@ -903,6 +1137,6 @@ abort one.
|
||||
## Status entities
|
||||
|
||||
If an MQTT broker is available the add-on publishes setpoint, grid power,
|
||||
battery power, state of charge, maintenance phase and controller status by MQTT
|
||||
discovery. This is observability only — the controller works fine without a
|
||||
battery power, circulating power, state of charge, maintenance phase and
|
||||
controller status by MQTT discovery. This is observability only — the controller works fine without a
|
||||
broker, and MQTT problems can never affect control.
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
# Mutation runs in this repository — and the one way they lie
|
||||
|
||||
Nearly every safety claim in this add-on was established the same way: change
|
||||
the shipped module, run the suites, watch one go **red**, put the module back,
|
||||
watch it go **green**. There is no coverage tool here and there is not meant to
|
||||
be — the check that matters is whether a *check* notices when the behaviour it
|
||||
names is removed.
|
||||
|
||||
That method has exactly one silent failure mode, and TEST-15 exists because we
|
||||
walked into it.
|
||||
|
||||
## The defect
|
||||
|
||||
FCST-03's developer reverted `self.provisional` to `here.provisional`, re-ran,
|
||||
and reported the mutation as a **survivor**. It was not. CPython decides a
|
||||
cached `.pyc` is still valid from two numbers:
|
||||
|
||||
* the source file's **size in bytes**, and
|
||||
* the source file's **mtime truncated to whole seconds**.
|
||||
|
||||
`self` and `here` are the same length, and the revert happened in the same
|
||||
second as the edit. Neither number moved, so the interpreter loaded the
|
||||
bytecode it already had. Confirmed at the time by disassembly: **the source read
|
||||
`here`, the running code loaded `self`.** The suite had graded code that was not
|
||||
there — no error, no warning, exit status 0.
|
||||
|
||||
The same collision, in the other direction, can leave a **red** standing on
|
||||
correct source after a revert that never took.
|
||||
|
||||
### Which way it lies, and why that scopes everything
|
||||
|
||||
A mutation that never took effect leaves the suite **green**, and a green run of
|
||||
a mutated tree is reported as a survivor. So stale bytecode **manufactures
|
||||
survivors**; it cannot manufacture a red. The false-red case exists but
|
||||
announces itself, because a developer chases a failure they cannot explain.
|
||||
|
||||
Two consequences, and both are used below:
|
||||
|
||||
* a recorded **"all red, no survivors"** result is not at risk from this defect;
|
||||
* a recorded **survivor** — and a recorded *zero*, such as a coverage-audit
|
||||
count of 0 — is exactly what this defect produces.
|
||||
|
||||
`test_control.py`'s coverage-audit table says the same thing in its own terms:
|
||||
the error is one-directional, so every figure there is a lower bound at worst.
|
||||
|
||||
## The rule
|
||||
|
||||
**Purge `__pycache__` before every mutation run.** Not afterwards, not usually,
|
||||
not when the edit looks big enough.
|
||||
|
||||
```sh
|
||||
cd goodwe_controller
|
||||
tools/mutation_run.sh # every test_*.py in this directory
|
||||
tools/mutation_run.sh . test_tariff.py # or just the ones you are watching
|
||||
tools/mutation_run.sh "$GOODWE_ADDON_REPO/goodwe_controller" # from elsewhere
|
||||
```
|
||||
|
||||
The harness purges, prints a `sha256`/byte-count fingerprint of every `app/*.py`
|
||||
it is about to grade, runs each suite under `python3 -B`, and fails if a cache
|
||||
appeared during the run. Diff two fingerprints and the file you mutated is the
|
||||
line that changed — which is also how a mutation report proves *which source*
|
||||
produced it.
|
||||
|
||||
### What does **not** fix this
|
||||
|
||||
| | writes bytecode | reads a stale `.pyc` |
|
||||
|---|---|---|
|
||||
| `python3 -B` | no | **yes** |
|
||||
| `PYTHONDONTWRITEBYTECODE=1` | no | **yes** |
|
||||
| purging `__pycache__` | (unchanged) | **no — nothing to read** |
|
||||
|
||||
Both flags are *write-side only*. A mutation run always has a cache already,
|
||||
because the previous run wrote it, so a harness that only adds `-B` closes
|
||||
nothing. This is measured, not read off documentation: `test_bytecode.py`
|
||||
part 2 arranges the collision and shows both flags still running the old code.
|
||||
|
||||
`-B` is still worth passing — it stops **this** run leaving a cache for the
|
||||
**next** edit to inherit, which is the one nobody is thinking about three
|
||||
mutations into a batch. Purge for this run, `-B` for the next one.
|
||||
|
||||
### And not a `sleep`
|
||||
|
||||
One-second mtime resolution is the *mechanism*, not the enemy. Waiting out the
|
||||
clock would slow every run down, and would still only cover the edit somebody
|
||||
remembered to slow down. Stop trusting mtime instead.
|
||||
|
||||
## Reporting a mutation
|
||||
|
||||
State the things a reviewer cannot reconstruct afterwards:
|
||||
|
||||
1. **the byte delta** of the mutated file (`+5 B`, `+0 B`). `+0 B` is the risky
|
||||
one — say so;
|
||||
2. **that the mutated line was on disk** when the run started (the harness
|
||||
fingerprint, or a `grep` of the line);
|
||||
3. **which suite went red, and on which named check** — "the suite is red" and
|
||||
"the check that watches this is red" are different claims;
|
||||
4. for a **survivor**, that the run was purged. An unpurged survivor is not a
|
||||
result.
|
||||
|
||||
## The other thing a mutation sweep cannot see: masking (TEST-16)
|
||||
|
||||
Stale bytecode is not the only way a survivor lies. **Two guards can mask each
|
||||
other**, and a sweep that mutates one line at a time is structurally blind to it.
|
||||
|
||||
TEST-16 is the worked example. `static_tariff.arbitrage()` refuses a
|
||||
one-firm-hour series at `len(firm) < 2`, and refuses a flat series at
|
||||
`spread == 0.0`. A single firm hour *also* has a spread of zero. So:
|
||||
|
||||
* weaken the firm-pair rung alone → the flat rung refuses the stimulus anyway;
|
||||
* weaken the flat rung alone → the tiny-spread stimulus is still refused, just
|
||||
for the wrong reason;
|
||||
* and the suite, grading `kind == "refused"`, reports **nothing either way**.
|
||||
|
||||
Both rungs could then be weakened *together* and still nothing would report —
|
||||
which is precisely the state in which the next edit to either one opens a real
|
||||
hole. It survived four rounds of review on TARIFF-03 and FCST-03.
|
||||
|
||||
Two rules come out of it, and both are cheap:
|
||||
|
||||
1. **Grade the reason, not just the verdict.** "Refused" and "refused for the
|
||||
right reason" are different claims, and a masked rung is the case where the
|
||||
first is true and the second is false. A refusal sentence is what the
|
||||
operator reads; it is also the only observable that moves when one rung
|
||||
quietly absorbs another's work.
|
||||
2. **When two rungs can answer the same stimulus, mutate them together.** One
|
||||
extra run per suspected pair. If a sweep finds two survivors in the same
|
||||
function, that pair is the first thing to try — TEST-16's two survivors were
|
||||
adjacent lines in one function and neither was individually live.
|
||||
|
||||
## The third way a check lies: its DATA cannot reach the failure it names
|
||||
|
||||
Stale bytecode manufactures survivors from a mutation that never ran. Masking
|
||||
manufactures them from a second guard doing the first one's work. This one
|
||||
manufactures them from a **fixture**: the check runs, the assertion is
|
||||
evaluated, the sentence above it is true — and the numbers it compares are
|
||||
arranged so that the failure it describes could not have changed the answer.
|
||||
|
||||
It is not hypothetical and it is not rare. Six instances in two weeks; three
|
||||
were found by review, two - CFG-04's fixture pair - by an author's own mutation
|
||||
battery, which is the cheaper place, and the sixth by review again, in the one
|
||||
place nobody had thought to point the rule at: not a check in the suite, but a
|
||||
COMMITTED EVIDENCE ARTIFACT four documents cite.
|
||||
|
||||
* **ARB-03** shipped a call-site scan asserting that every `claims.append` in
|
||||
`cycle()` routes through `actuator_of()`. Its own parse **saw 3 of the 8
|
||||
appends**; the other five were invisible to it and would have passed however
|
||||
they were routed.
|
||||
* **ALLOC-01** shipped a replay generator to hunt for envelope escapes. It found
|
||||
**0**. A widened generator over the same allocator found **39,991**.
|
||||
* **DEV-02** shipped `"each unit's supervised bound is ITS OWN max_w — give
|
||||
inverter B a different rating and the claim filed on inverter B moves with
|
||||
it"`, comparing inverter B's bound against `_b2.max_w`. The fixture defaulted
|
||||
B's rating to A's, so it compared `(-5000, 5000)` against `(-5000, 5000)` —
|
||||
**a number against itself**. Filing both per-unit bounds from
|
||||
`self.inverters[0]` left **all 19 files green**.
|
||||
* **CFG-04** wrote *"an entry that omits `soc_entity` has NO SoC sensor and does
|
||||
not borrow the flat one"* against a fixture whose **flat `soc_entity` was
|
||||
blank**. Inherited and not-inherited were both `""`, so the mutation that
|
||||
makes `build_roster()` fall back to the flat key survived. The fixture now
|
||||
sets a real entity there, and the check asserts that the flat value is present
|
||||
as well as that the entry's is empty.
|
||||
* **CFG-04** also wrote *"main.py runs the per-unit range check
|
||||
UNCONDITIONALLY"* as a **substring test over `app/main.py`'s source**. The
|
||||
mutation that defeats it puts the call back inside `if entity:` — one level of
|
||||
indentation — and the substring is still there. That is habit 4 below in a new
|
||||
costume: the check asserted against the TEXT of the thing under test rather
|
||||
than against its behaviour. It now drives the shipped `run_control()` on a
|
||||
two-inverter config, **under a deadline**, because the mutation's real symptom
|
||||
is that the controller enters its loop and never returns.
|
||||
* **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule
|
||||
reaching outside the suite. The probe is the committed evidence that
|
||||
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
|
||||
quoted it as answering *"Missing option 'inverters' in root"*. §B validated
|
||||
the reconstructed LIVE options blob, which is missing many required keys
|
||||
unrelated to this card, so its refusal named whichever of THOSE the validator
|
||||
reached first — `meter_freeze_s`, `p1_phase_export_entities` and
|
||||
`sign_disagree_w` on three different runs. The quoted string was never
|
||||
produced by the instrument. And the assertion was a bare **`not ok`**: delete
|
||||
`inverters: []` from `config.yaml` and §B went on printing **PASS**, blind to
|
||||
the exact regression it was cited for. §B is now an isolated counterfactual —
|
||||
the shipped defaults, and the same defaults minus that one key — asserting on
|
||||
the **key name in the refusal**; the mis-cited blob survives, relabelled, as
|
||||
§B2. Re-run with the default deleted it prints `2 CASE(S) FAILED` and exits 1.
|
||||
|
||||
⚠️ **A committed transcript is a check, and it decays like one.** Nothing in
|
||||
this document had been pointed at `tools/`, because the artifacts there are
|
||||
filed as *measurements* rather than as *checks* — and a measurement whose
|
||||
assertion cannot fail is exactly the thing this section is about. Two habits
|
||||
follow, and both are habit 5 and 6 below.
|
||||
|
||||
Note what these have in common with a masked rung: the mutation *runs*, and the
|
||||
suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly
|
||||
can see it, because the code under test really did behave identically on the
|
||||
stimulus it was given. The defect is upstream of the assertion, in what the
|
||||
check was handed.
|
||||
|
||||
**The rule, and it is one line:**
|
||||
|
||||
> **Any check asserting "X differs from Y" must be run once with X and Y
|
||||
> actually made different.**
|
||||
|
||||
Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
|
||||
1. **Write the PRECONDITION as its own check.** `test_peakguard.py` 10g asserts
|
||||
that A and B have different ratings *before* it asserts that B's bound is
|
||||
B's. If a later edit makes them equal again, the precondition goes red rather
|
||||
than the claim going quietly vacuous.
|
||||
2. **Assert against literals, not against the expression under test.**
|
||||
`== [(-_b2.max_w, _b2.max_w)]` cannot distinguish "B's number" from "the
|
||||
number this fixture happens to have twice"; `== (-3000.0, 3000.0)` beside a
|
||||
printed `A = 5000` can.
|
||||
3. **Print what the check saw.** Every one of the three above would have been
|
||||
caught by a human reading one line of output — `supervised bounds by
|
||||
actuator: {'inverter-a': (-5000, 5000), 'inverter-b': (-5000, 5000)}` is
|
||||
self-evidently not evidence of a difference.
|
||||
|
||||
4. **Assert on the BEHAVIOUR, not on the source text of the thing under test.**
|
||||
A substring test over a module is a check on how the code is spelled;
|
||||
whitespace, an alias or a moved line defeats it while the property it names
|
||||
is gone. `test_inverter.py` part 10 has one of each — a source pin for a
|
||||
claim that genuinely IS about the code's shape, and a driven `run_control()`
|
||||
for a claim about behaviour — and the second replaced the first after the
|
||||
mutation battery walked straight through it.
|
||||
5. **A check whose failure mode is a HANG needs a deadline.** Otherwise the
|
||||
mutation that defeats it produces a CI timeout with no name attached, which
|
||||
reads like flaky infrastructure and gets retried rather than read.
|
||||
6. **Assert on the CONTENT of a refusal, not on the fact that one happened.**
|
||||
`not ok` is satisfied by any error at all, including one about a different
|
||||
key on a different blob. `"Missing option 'inverters' in root" in out` goes
|
||||
red the moment the refusal stops being about the thing it is cited for. This
|
||||
is habit 2 in the negative direction: a refusal, like a difference, has to be
|
||||
asserted against a literal.
|
||||
7. **Vary ONE thing in a counterfactual, and vary it from an ACCEPTED
|
||||
baseline.** `supervisor_shape_probe.py` §B validates the shipped defaults
|
||||
first, precisely so the refusal on the next line has nowhere else to come
|
||||
from. Without the accepted baseline the second line is a refusal of unknown
|
||||
provenance, which is what §B used to print.
|
||||
8. **A committed transcript is a check.** Anything under `tools/` that a
|
||||
document quotes is subject to every rule above, and to the mutation battery:
|
||||
break the property, re-run the instrument, and confirm the transcript goes
|
||||
red. `inverters: []` was deleted from `config.yaml` and the probe re-run
|
||||
against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` for
|
||||
exactly that reason.
|
||||
|
||||
And the mutation-table consequence: **a variant is not covered because a
|
||||
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||
static routing pin catches. The *bound* variant — right actuator, wrong
|
||||
numbers — is a different defect with a different fix, and it was absent. Both
|
||||
are in the table now.
|
||||
|
||||
## Where each surface stands
|
||||
|
||||
* **Local and dev-harness runs — the exposed one.** This is where FCST-03 hit
|
||||
it. `goodwe_controller/__pycache__` and `app/__pycache__` are gitignored and
|
||||
are sitting in most working trees right now. Use the harness.
|
||||
* **CI — structurally immune, and it was before this card.** `.gitea/workflows/
|
||||
checks.yml` bind mounts the checkout `-v "$WS:/w:ro"`, so the interpreter
|
||||
cannot write a `__pycache__` into the tree under test at all; measured for
|
||||
TEST-15, and it fails *silently* — no error, exit status 0. Each job also
|
||||
`rm -rf`s its workspace and clones afresh, so nothing crosses between jobs on
|
||||
the shared runner. `PYTHONDONTWRITEBYTECODE=1` is now set there too, as belt
|
||||
and braces: the `:ro` is one character wide and the next person debugging a
|
||||
write will be tempted by it.
|
||||
* **The shipped add-on — not affected, and this is closed.**
|
||||
`goodwe_controller/Dockerfile` has carried `PYTHONDONTWRITEBYTECODE=1` since
|
||||
it was written. There is no deploy-side finding here; do not re-open it.
|
||||
* **The infra repo's `sim/` harnesses — exposed, and NOT fixed by this card.**
|
||||
`sim/replay_sign.py` and friends import the add-on out of
|
||||
`$GOODWE_ADDON_REPO` and write `__pycache__` into *both* trees
|
||||
(`goodwe/sim/__pycache__` exists today). SAFETY-02's headline validation is a
|
||||
replay, not a suite, so a mutation graded through it carries the same
|
||||
exposure. `tools/mutation_run.sh` takes the tree to purge as its first
|
||||
argument precisely so that repo can call it, but the change belongs in that
|
||||
repo and some of those harnesses want the rig. **Anyone mutating a module
|
||||
graded by a replay must purge both trees by hand until then.**
|
||||
|
||||
## Survey — mutation claims of 2026-08-25 … 2026-09-01
|
||||
|
||||
Scoped **by the property, not by re-running everything**. A claim is at risk
|
||||
only if a mutation could have preserved the file's byte count *and* been
|
||||
reverted inside the same second, *and* the claim is of the kind stale bytecode
|
||||
can fabricate (see "which way it lies"). Sources: `git log release/1.0` and the
|
||||
commit and PR bodies.
|
||||
|
||||
| claim | verdict | why |
|
||||
|---|---|---|
|
||||
| **FCST-03** M9 `self.provisional`→`here.provisional` (dd40c56, 71cd784) | **re-verified** | this is the original false survivor. Re-run with the cache cleared, M9 is caught. The shipped source was correct throughout. |
|
||||
| **FCST-03** X1, X6 degenerate stimuli (71cd784) | **not at risk** | run after the purge discipline landed in that branch. |
|
||||
| **SAFETY-24** 11 author + 9 reviewer mutations (e709554, 4601682) | **not at risk** | the commit records them run under `python -B` with a purged `__pycache__` and real byte deltas. Its one survivor is an equivalent mutant, argued structurally rather than by a green run. |
|
||||
| **SAFETY-26** tolerance sweep over `arbitrage.py` and `static_tariff.py`, 2 survivors (2b8b8cb) | **not at risk** | purge, printed `+5 B` delta, mutated line asserted present, tree re-read before each run. One survivor is explicitly recorded at `+0 B` — the risky delta — and was produced *under* the purge. **Both survivors are now closed by TEST-16** (`test_static_tariff.py` section 9), which also found that the two of them *masked each other* — each was individually non-live only because the other rung caught its stimulus, an arrangement single-mutation testing cannot see. Its own ledger is 7 mutations, 4 of them at `+0 B`, all red. |
|
||||
| **OPT-04** mutations reported on PR #22 (bdd7f18) | **not at risk** | `test_arbitrage.py`'s header records the exact command: purge, then `python3 -B`. |
|
||||
| **TEST-17** `checks()` dropping `when` (504e8da) | **not at risk** | a *red* claim, and dropping an argument changes the file length. Neither half of the collision is available. |
|
||||
| **SAFETY-07** wiring mutations B1/B2/B3 and `test_peakguard.py` parts 7 and 10 (2d570d8, e7c95bf) | **not at risk** | those mutations are **executed in-process**, monkeypatched onto the imported module. No source file is edited, so no `.pyc` is consulted. |
|
||||
| every **"all red, no survivors"** claim in the window — TARIFF-02 (24/0), TARIFF-03 (11), MAINT-04 (10), SAFETY-18 (6+4), SAFETY-03 (6), TEL-04 (10), TEL-05 (6) | **not at risk** | direction. Stale bytecode turns a red into a green survivor, never the reverse. A red is evidence that *something* changed under the check. |
|
||||
| **TEST-06**'s deleted assertion, "no new notification while parked at 0 W" (c34b5a2) | **not at risk / superseded** | it was recorded as undecidable and then *falsified* — `test_freeze.py` records the one-line mutation that takes the file red 9 times. The stronger later claim is a red one. |
|
||||
| **TARIFF-01** reviewer survivor: `record_block()` ignoring its `policy` argument (0dbd485) | **re-verified — still a survivor** | re-run here through `tools/mutation_run.sh` against the 15 suites that were on the record, all green with `billed_w(block, policy)` mutated to `billed_w(block, POLICY_NET)`. The recorded diagnosis holds: every block reaching the store is single-phase, where NET, SUM and MAX collapse. **Now CLOSED by TEST-13** (`test_tariff.py` section 7), which round-trips section 7's own unbalanced `(3000, 500, -200) W` block THROUGH the store under each policy - three distinct banked figures, so a hardcoded policy takes at least two of them red. Third instance of "a stimulus evaluated where the quantity it grades is degenerate", after WITNESS-02's span normalisation and TARIFF-01's own DST window. |
|
||||
| **SAFETY-25** 8 mutations against `test_peakguard.py` part 10, then 9 more after review, all red | **not at risk** | each run against a tree copied fresh with `__pycache__` excluded and mounted `:ro`, so no `.pyc` existed to go stale - the same structural immunity recorded for CI above. Byte deltas recorded on the PR; none is `+0 B`. All are **reds**, which is the direction this defect cannot fabricate. ⚠️ The second round exists because the first round's mutations were all aimed at DISARMING the guard, and the claim part 10 actually made was about the guard being FIXED - the reviewer built the fix and the suite stayed green. Bytecode was never the issue there; an unaimed battery was. |
|
||||
| **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. |
|
||||
| **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. |
|
||||
|
||||
**Nothing here says a past result is wrong.** The two survivors re-run above
|
||||
both stood. What changed is that they are now on the record as measured rather
|
||||
than assumed, and the rest are labelled honestly. The still-unverified rows are
|
||||
cheap to close — one harness run each — and the right moment is when somebody
|
||||
next touches that module, not in a re-run campaign nobody will read.
|
||||
@@ -0,0 +1,654 @@
|
||||
"""One site grid target, divided across N inverters so that the shares SUM to it.
|
||||
|
||||
⚠️ THE REASON THIS EXISTS, AND IT IS NOT EFFICIENCY. The control primitive in
|
||||
this system is METER EMULATION: the master reports a FABRICATED grid reading
|
||||
(0x056E direction, 0x056F magnitude, FC 0x10, every 1-2 s) and the inverter
|
||||
regulates ITSELF to it. So one number means "the battery moves X W" to this
|
||||
add-on and "the grid is at X W" to the inverter - an identity that holds ONLY at
|
||||
N=1. Hand two inverters the same target and each supplies it IN FULL: the loop
|
||||
gain at the meter becomes N times the per-unit gain.
|
||||
|
||||
That is measured, not argued. On admin/goodwe's two-master rig (TEST-19,
|
||||
`sim/sitesim.py --table`, the add-on's own `compute()` in the loop and the
|
||||
firmware's own encoder on the wire):
|
||||
|
||||
N=2 gain 0.6 BROADCAST worst export -400 W (N=1: +10 W)
|
||||
N=2 gain 1.0 BROADCAST commands [1000, 2000, 1000, 0] W,
|
||||
grid [0, -2000, 0, +2000] W, tail p2p 4000 W,
|
||||
FOREVER, and not one guard fires.
|
||||
|
||||
`gain: 1.0` is what config.yaml:374 accepts as its documented maximum. The sign
|
||||
watchdog is evaluated on every one of those cycles and never trips, because the
|
||||
disagreement flips on alternate ticks and never holds its 180 s window. So the
|
||||
broadcast fault is not merely inefficient: it is invisible to every detector
|
||||
this repository has.
|
||||
|
||||
THE FIX IS STRUCTURAL, WHICH IS WHY IT IS A MODULE AND NOT A TUNING CHANGE
|
||||
=========================================================================
|
||||
ONE integrator on P1, above the split (§10.3, and `compute()` is that
|
||||
integrator). Its output S is the SITE's grid target. This module turns S into
|
||||
per-unit shares with one invariant:
|
||||
|
||||
sum(shares) + unmet_w == S, with unmet_w == 0 whenever the envelopes allow
|
||||
|
||||
and each inverter is then told a function of ITS OWN SHARE, never of the shared
|
||||
measurement. Write s_i = a_i * S with sum(a_i) = 1. Each unit closes its own
|
||||
loop with per-unit gain g_i, so the gain seen AT THE METER is
|
||||
|
||||
gbar = sum(a_i * g_i)
|
||||
|
||||
which is a CONVEX COMBINATION of the per-unit gains and therefore lies in
|
||||
[min g_i, max g_i] FOR ANY SPLIT. Compare the broadcast case, a_i = 1 for every
|
||||
unit, where the same sum is N * g and grows without bound in N.
|
||||
|
||||
⚠️ THAT IS THE WHOLE ARGUMENT, AND IT IS WHY THE WEIGHTS ARE A COMFORT CHOICE.
|
||||
Nothing above constrains the a_i beyond summing to 1. So SoC, thermals and pack
|
||||
wear may set them freely and a BAD SPLIT CANNOT DESTABILISE THE SITE - it can
|
||||
only be unkind to one pack. That property is the reason §6.2 chose share
|
||||
allocation over a regulator/ballast arrangement, in which the ballast unit's
|
||||
share is a function of the shared measurement and the gain question comes back.
|
||||
|
||||
⚠️ 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, because the SUM is the safety
|
||||
property and stranding capacity behind a preference would leave a standing
|
||||
error for the site integrator to chase forever. To make a unit genuinely
|
||||
unusable, close its envelope or leave it out of `live`. See `_fill()`.
|
||||
|
||||
WHAT THIS MODULE MUST NOT DO
|
||||
============================
|
||||
⚠️ IT MUST NOT READ P1. One integrator on the site measurement, above the split.
|
||||
A second reader of the meter is a second controller, and two controllers on one
|
||||
actuator is the failure the whole arbiter exists to prevent. Nothing in here
|
||||
takes a measurement of any kind: the inputs are S, the per-unit intervals, and
|
||||
per-tick liveness. That is checkable by reading the imports, and it is why there
|
||||
are none beyond the standard library.
|
||||
|
||||
⚠️ IT MUST NOT CACHE AN ENVELOPE. `plan()` is a pure function called once per
|
||||
tick with that tick's intervals. A unit's available range moves for reasons that
|
||||
have nothing to do with the allocator - a stale link, a pack at its floor, a
|
||||
device rating that failed to read - and an allocator holding a construction-time
|
||||
envelope over-allocates into a unit that cannot take it. There is no state here
|
||||
to hold one; that is deliberate and it is graded.
|
||||
|
||||
⚠️ ROUNDING IS A SAFETY PROPERTY. The setpoint register is 1 W (control.py says
|
||||
so where it quantises). A per-unit `round()` biases the sum by up to N/2 W every
|
||||
tick, and at 1-2 Hz a systematic 1 W bias is a PERMANENT standing error the site
|
||||
integrator will chase forever - it is not a rounding artefact, it is a slow
|
||||
offset with a control loop wound around it. So the sum is computed in INTEGER
|
||||
ARITHMETIC from an integer target and the fractional parts are settled by
|
||||
LARGEST REMAINDER (Hamilton): floor everything, then hand the leftover watts one
|
||||
at a time to the units with the largest discarded fraction. The sum cannot drift
|
||||
because no float ever enters it.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from math import ceil, floor, inf
|
||||
|
||||
# The tolerance on a float comparison of watts. Envelopes arrive as floats from
|
||||
# config and from device registers; this is the width below which two of them
|
||||
# are the same number. It is never used on the sum - see the module docstring.
|
||||
EPS = 1e-9
|
||||
|
||||
|
||||
# --- why a unit is not in the envelope set, and why the whole thing refused ---
|
||||
# Named rather than spelled inline so that main.py, SAFETY-28's write gate and
|
||||
# the tests can all agree on which case fired without matching English.
|
||||
DROP_STALE = "stale link"
|
||||
DROP_CONTRADICTION = "contradicts itself"
|
||||
DROP_NO_INTEGER = "no integer setpoint in its interval"
|
||||
|
||||
REFUSE_SCOPE = "mis-routed claim"
|
||||
REFUSE_EMPTY = "no unit available"
|
||||
REFUSE_SIGN = "opposite directions"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitPlan:
|
||||
"""What ONE unit was told this tick, and why it was that and not more.
|
||||
|
||||
This is the per-unit half of the allocation record. `why` is written for an
|
||||
operator reading an action log at 2 a.m., not for a developer: it says which
|
||||
mechanism chose the number - the weighted fill, an envelope that ran out, a
|
||||
claim of the unit's own that outranked the site.
|
||||
"""
|
||||
unit: str
|
||||
share_w: int
|
||||
lo: float
|
||||
hi: float
|
||||
weight: float
|
||||
why: str
|
||||
pinned: bool = False
|
||||
clamped: bool = False
|
||||
dropped: bool = False # not commanded at all - see `within_envelope`
|
||||
|
||||
@property
|
||||
def span(self) -> str:
|
||||
return (f"[{'-inf' if self.lo == -inf else format(self.lo, '.0f')}, "
|
||||
f"{'+inf' if self.hi == inf else format(self.hi, '.0f')}]")
|
||||
|
||||
@property
|
||||
def within_envelope(self) -> bool:
|
||||
"""Is the number this unit was told ACTUALLY inside the interval?
|
||||
|
||||
⚠️ THIS IS ASKED, NOT ASSUMED. The record is what SAFETY-28 gates on,
|
||||
and a record that describes a share it did not check is worth less than
|
||||
no record: the half-open clamp defect shipped a line reading
|
||||
`inverter-a -> +500 W in [2000, +inf] (weighted share)` - the record
|
||||
stating, in the same breath, both the floor and the share below it, and
|
||||
agreeing with itself. The clamp is fixed, so this should now be true on
|
||||
every plan the allocator builds; that is exactly why it is cheap to
|
||||
assert rather than to trust.
|
||||
|
||||
A DROPPED unit is always within: it was told nothing. Its interval may
|
||||
be a contradiction (`lo > hi`), which no number is inside, and printing
|
||||
that as a violation would cry wolf on the one case that is already
|
||||
reported by name.
|
||||
"""
|
||||
if self.dropped:
|
||||
return True
|
||||
return self.lo <= self.share_w <= self.hi
|
||||
|
||||
def explain(self) -> str:
|
||||
if self.dropped:
|
||||
return (f"{self.unit} -> not commanded, envelope {self.span} "
|
||||
f"({self.why})")
|
||||
if not self.within_envelope:
|
||||
# Unreachable as the allocator stands. If it ever is reachable
|
||||
# again, the log says so in the line an operator is already reading
|
||||
# rather than in a line nobody wrote.
|
||||
return (f"{self.unit} -> {self.share_w:+d} W OUTSIDE {self.span} "
|
||||
f"({self.why}) - ENVELOPE VIOLATED, DO NOT WRITE")
|
||||
return f"{self.unit} -> {self.share_w:+d} W in {self.span} ({self.why})"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Allocation:
|
||||
"""EVERY TICK PRODUCES ONE OF THESE, including the ticks that refused.
|
||||
|
||||
⚠️ IT IS DESIGNED TO BE GATEABLE, because SAFETY-28 gates the write path on
|
||||
it. That means three things, and each is a decision rather than a
|
||||
convenience:
|
||||
|
||||
* `ok` is ONE boolean and it is the only thing a gate has to read. A gate
|
||||
that must parse a message, or check three fields in the right order, is a
|
||||
gate somebody gets wrong.
|
||||
* a REFUSAL still carries the record - `plans`, `dropped` and the reason -
|
||||
so the gate can say WHY it stopped the write instead of only that it did.
|
||||
A refusal that returns nothing is a refusal nobody can debug.
|
||||
* `unmet_w` is separate from `ok`. An allocation that could not place every
|
||||
watt is still SAFE TO WRITE and strictly better than commanding zero; it
|
||||
is the site target that was not fully met, not a broken allocation. Fusing
|
||||
the two would make a saturated inverter park the whole site.
|
||||
|
||||
`sum_w` is the integer the shares are required to add up to, and
|
||||
`quantisation_w` is the ONE rounding that produced it from `target_w`. Both
|
||||
are here so that "the shares sum to S" can be checked by a reader rather
|
||||
than believed: `sum(shares.values()) + unmet_w == sum_w` is an identity over
|
||||
integers, and `sum_w - target_w` is a single site-level rounding of at most
|
||||
half a watt that is NEVER distributed into a per-unit bias.
|
||||
"""
|
||||
target_w: float
|
||||
sum_w: int
|
||||
shares: dict = field(default_factory=dict)
|
||||
plans: tuple = ()
|
||||
dropped: tuple = () # ((unit, why), ...) - order is roster order
|
||||
unmet_w: int = 0
|
||||
quantisation_w: float = 0.0
|
||||
refused: str | None = None
|
||||
passthrough: bool = False # N<=1: the share IS the site target, unrounded
|
||||
# SAFETY-28. WHICH TICK PRODUCED THIS RECORD, as an opaque token minted by
|
||||
# the caller - `Controller.alloc_tick`, incremented once per resolution.
|
||||
#
|
||||
# ⚠️ IT IS AN INPUT, NOT A COUNTER THIS MODULE KEEPS. `plan()` is a pure
|
||||
# function and part 10 of test_alloc.py grades that by asserting the module
|
||||
# holds no state at all; a serial number incremented in here would be
|
||||
# exactly the cached-across-ticks thing the envelope rule forbids, wearing a
|
||||
# different hat. So the tick is handed in and handed back out, and the
|
||||
# freshness question is answered by whoever owns the clock.
|
||||
#
|
||||
# ⚠️ AND IT DEFAULTS TO None, WHICH THE WRITE GATE REFUSES. A record built
|
||||
# without a tick is not "tick zero", it is a record that cannot say when it
|
||||
# was made - and the gate fails closed on it. Every test in this repository
|
||||
# that calls `plan()` for its arithmetic gets None and does not care;
|
||||
# main.py stamps every record it puts on the write path.
|
||||
tick: int | None = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""The one boolean SAFETY-28's write gate reads."""
|
||||
return self.refused is None
|
||||
|
||||
def authorises(self, unit: str, value: float, tick: int | None) -> str | None:
|
||||
"""Why `unit` may NOT be written `value` on tick `tick`, or None if it may.
|
||||
|
||||
⚠️ THIS IS THE QUESTION SAFETY-28 REPLACED A CONFIG-SHAPE PROXY WITH, and
|
||||
it lives on the record rather than in main.py so that the record is the
|
||||
thing that answers it. Before this, "two writers can never reach one
|
||||
actuator" was enforced by `len(validate.ACTUATOR_WRITE_KEYS) == 1` -
|
||||
true, but a statement about the OPTIONS SCHEMA. Add a per-inverter
|
||||
config (CFG-04) and there are two real writers with that length still 1:
|
||||
the check stays green while the property it protects is gone.
|
||||
|
||||
Asking the record instead makes the property mechanical. A number
|
||||
reaches the wire only if the single allocator placed it, on THIS tick,
|
||||
for THIS unit. A second writer with its own arithmetic cannot produce
|
||||
one, because it does not appear in a record; and it cannot borrow an old
|
||||
one, because the tick has moved on.
|
||||
|
||||
⚠️ `unmet_w` IS NOT CONSULTED AND MUST NOT BE. An allocation that could
|
||||
not place every watt is still SAFE TO WRITE - see this class's own
|
||||
docstring. Fusing the two would let one saturated inverter park the
|
||||
whole site, which is a worse outcome than the hole this gate closes.
|
||||
|
||||
⚠️ NOR IS 0 W ITS BUSINESS. The caller answers that one before it asks
|
||||
this, and the reason is at `Controller.write_refusal()`: a gate that can
|
||||
block the failsafe is worse than the hole it closes.
|
||||
"""
|
||||
if tick is None or self.tick is None or self.tick != tick:
|
||||
return (f"the allocation record on hand was made on tick "
|
||||
f"{self.tick!r} and this is tick {tick!r} - a record whose "
|
||||
f"envelopes have moved cannot authorise a write")
|
||||
if not self.ok:
|
||||
return f"the allocator refused this tick ({self.explain()})"
|
||||
if unit not in self.shares:
|
||||
return (f"{unit} has no share in this tick's allocation "
|
||||
f"({self.explain()})")
|
||||
share = self.shares[unit]
|
||||
if value != share:
|
||||
return (f"{value:+.0f} W is not {unit}'s share of this tick's "
|
||||
f"allocation, which is {share:+.0f} W ({self.explain()})")
|
||||
# ⚠️ THE RECORD IS ASKED WHETHER IT AGREES WITH ITSELF. `within_envelope`
|
||||
# exists because the half-open clamp defect once shipped a plan reading
|
||||
# `inverter-a -> +500 W in [2000, +inf]` - stating the floor and a share
|
||||
# below it in one breath - and `UnitPlan.explain()` renders that as
|
||||
# ENVELOPE VIOLATED, DO NOT WRITE. This is the DO NOT WRITE.
|
||||
#
|
||||
# Unreachable at the shipped roster of one, where the pass-through's
|
||||
# interval is (-inf, +inf) and every number is inside it. Graded anyway,
|
||||
# on a constructed record, because the branch PR #40's reviewer replaced
|
||||
# with `if False:` was unreachable too and the whole suite stayed green.
|
||||
for p in self.plans:
|
||||
if p.unit == unit and not p.within_envelope:
|
||||
return (f"the record contradicts itself: {p.explain()}")
|
||||
return None
|
||||
|
||||
def explain(self) -> str:
|
||||
"""One line an operator can act on."""
|
||||
if self.refused is not None:
|
||||
return (f"REFUSED to allocate {self.target_w:+.0f} W: "
|
||||
f"{self.refused}")
|
||||
if self.passthrough:
|
||||
return f"pass-through {self.target_w:+.0f} W (one unit, nothing to divide)"
|
||||
body = ", ".join(f"{u}{s:+d}" for u, s in sorted(self.shares.items()))
|
||||
out = f"{self.sum_w:+d} W as {body}"
|
||||
if self.unmet_w:
|
||||
out += f", {self.unmet_w:+d} W UNMET"
|
||||
if self.dropped:
|
||||
out += (" - dropped "
|
||||
+ ", ".join(f"{u} ({w})" for u, w in self.dropped))
|
||||
return out
|
||||
|
||||
|
||||
def _int_envelope(b):
|
||||
"""A unit's interval, narrowed to the integers a 1 W register can hold.
|
||||
|
||||
⚠️ NARROWED INWARD, ALWAYS. `ceil` on the floor and `floor` on the ceiling,
|
||||
so every integer in the result is inside the real interval. Rounding the
|
||||
other way would let a share of exactly the rating be commanded on a unit
|
||||
whose rating is 2499.5 W, which is the one direction an envelope must never
|
||||
move: the envelope is what `device` and `supervised` filed, i.e. what the
|
||||
hardware said it can do.
|
||||
|
||||
Infinities pass through untouched - an absent bound is not a large bound,
|
||||
and `ceil(-inf)` is not a number. A unit that filed nothing this cycle has
|
||||
exactly that interval, which is why this case is the common one at N>=2 and
|
||||
not an edge.
|
||||
"""
|
||||
lo = -inf if b.lo == -inf else ceil(b.lo - EPS)
|
||||
hi = inf if b.hi == inf else floor(b.hi + EPS)
|
||||
return lo, hi
|
||||
|
||||
|
||||
def _clamp_int(v, lo, hi):
|
||||
"""`v` as an int, pulled inside `[lo, hi]`. EACH END IS APPLIED ON ITS OWN.
|
||||
|
||||
⚠️ THE TWO ENDS ARE INDEPENDENT AND MUST BE TREATED SO. An earlier form
|
||||
substituted `v` itself for a missing bound and fed both into one
|
||||
`min(max(...))`, which reads as "an absent bound cannot bind" but is not
|
||||
what it does: with `hi == inf` the whole expression collapses to
|
||||
`min(max(v, lo), v) == v`, so a MISSING UPPER BOUND SILENTLY DISCARDED THE
|
||||
LOWER ONE. `_clamp_int(0, 2142, inf)` returned 0 - a share below a 2142 W
|
||||
floor the unit itself filed - and every caller here inherited it.
|
||||
|
||||
So: skip the end that is infinite, apply the end that is not, and never let
|
||||
one end's absence reach the other. `UnitBounds()` defaults to
|
||||
`(-inf, +inf)`, and this helper is the whole of the ticket's first
|
||||
deliverable ("each share clipped to that unit's UnitBounds interval"), so a
|
||||
one-sided envelope is the ordinary case, not an edge.
|
||||
"""
|
||||
v = int(v)
|
||||
if lo != -inf:
|
||||
v = max(v, lo)
|
||||
if hi != inf:
|
||||
v = min(v, hi)
|
||||
return v
|
||||
|
||||
|
||||
def _fill(order, weights, cap, amount):
|
||||
"""WATER-FILL: place `amount` watts across `order`, capped, by weight.
|
||||
|
||||
`amount` and every `cap` are NON-NEGATIVE magnitudes; the direction has
|
||||
already been taken out by the caller. Returns `(add, leftover)`.
|
||||
|
||||
THE LOOP IS THE STANDARD ONE and it terminates in at most len(order) rounds:
|
||||
share the remaining amount in proportion to weight; any unit whose share
|
||||
would exceed its cap is PINNED AT ITS CAP and dropped from the active set;
|
||||
repeat with what is left. A unit that is not capped in a round is not capped
|
||||
in any later round either, so nothing is revisited.
|
||||
|
||||
⚠️ AND THEN THE SECOND PASS, WHICH IS THE POLICY DECISION IN THIS FUNCTION.
|
||||
Units with weight 0 are excluded from the weighted rounds - that is what a
|
||||
zero weight is for. But if the weighted set fills up and watts remain, they
|
||||
are offered to the zero-weight units, EQUALLY, rather than being reported as
|
||||
unmet. The reason is the module docstring's: the weights are a comfort
|
||||
choice and the sum is the safety property, so a preference must never
|
||||
manufacture a standing error at the meter that the site integrator will then
|
||||
chase. To make a unit genuinely unusable, close its envelope - `cap` is
|
||||
obeyed absolutely and is never overridden here.
|
||||
"""
|
||||
add = {u: 0.0 for u in order}
|
||||
rem = float(amount)
|
||||
|
||||
for pool in (
|
||||
[u for u in order if weights[u] > 0.0],
|
||||
[u for u in order if weights[u] <= 0.0], # the last-resort pass
|
||||
):
|
||||
# Inside the last-resort pass every weight is 0, so the proportional
|
||||
# step needs a weight of its own; equal shares is the only choice that
|
||||
# does not invent a preference the caller did not express.
|
||||
w = {u: (weights[u] if weights[u] > 0.0 else 1.0) for u in pool}
|
||||
active = [u for u in pool if cap[u] > 0.0]
|
||||
while rem > EPS and active:
|
||||
total = sum(w[u] for u in active)
|
||||
capped = [u for u in active if rem * w[u] / total >= cap[u] - add[u]]
|
||||
if capped:
|
||||
for u in capped:
|
||||
rem -= cap[u] - add[u]
|
||||
add[u] = cap[u]
|
||||
active = [u for u in active if u not in capped]
|
||||
else:
|
||||
for u in active:
|
||||
add[u] += rem * w[u] / total
|
||||
rem = 0.0
|
||||
return add, rem
|
||||
|
||||
|
||||
def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
|
||||
site_priority=None, site_key="site", tick=None) -> Allocation:
|
||||
"""Divide the site grid target `target_w` across the units in `bounds`.
|
||||
|
||||
`bounds` unit name -> that unit's interval for THIS TICK (arbiter stage 1,
|
||||
`UnitBounds`). Read per tick, never cached - see the module
|
||||
docstring. Duck-typed on `.lo`, `.hi`, `.gate`, `.pinned_w`,
|
||||
`.pinned_by` and `.contradiction` so that this module does not
|
||||
import the arbiter and cannot grow a dependency on claims.
|
||||
`weights` unit -> non-negative comfort weight. Missing units weigh 1.0.
|
||||
`live` the units whose master has reported recently enough to be
|
||||
commanded, or None for "all of them". A unit not listed is
|
||||
DROPPED and the record says so; the survivors then carry the
|
||||
whole site target, which is a real change in what the site can
|
||||
do and must never happen silently (AC 6).
|
||||
`scope_errors` the arbiter's mis-routing complaints. See REFUSE_SCOPE below.
|
||||
`site_priority` the priority of the SITE claim that chose `target_w`. It is
|
||||
what a unit's own winning SET is weighed against - see PINS.
|
||||
`site_key` the name to use when there are no units at all.
|
||||
`tick` an opaque token identifying the tick this record is FOR, handed
|
||||
straight back on the record. SAFETY-28's write gate compares it
|
||||
against the tick it is writing on, so that a record cannot be
|
||||
replayed after its envelopes have moved. It is an input rather
|
||||
than something this module counts, for the reason on the field.
|
||||
|
||||
⚠️ N<=1 IS A PASS-THROUGH AND IS BYTE-IDENTICAL TO WHAT SHIPPED. The share
|
||||
IS the site target, as a float, unclamped and unrounded. That is §6.2's N=1
|
||||
identity - with one inverter the fabricated grid reading and the battery
|
||||
setpoint are the same number - and it is also the compatibility promise:
|
||||
every existing test in this repository replays through here and must see the
|
||||
number it saw before ARB-03. Do not "tidy" this branch into the general path
|
||||
by giving it a roster of one; the general path rounds to an integer and
|
||||
applies the envelope, and both are behaviour changes on the live install.
|
||||
"""
|
||||
units = tuple(sorted(bounds))
|
||||
|
||||
# --- REFUSAL 1: the claim list that produced S is known to be mis-routed --
|
||||
# ⚠️ THIS IS THE ANSWER TO ARB-03's OPEN QUESTION, AND IT IS A REFUSAL
|
||||
# RATHER THAN AN INPUT. `scope_violations()` reports a claim filed on an
|
||||
# actuator its source's scope does not permit. It is tempting to CONSUME
|
||||
# one - to read a unit-scoped claim that landed on the site as an allocation
|
||||
# input - and that is wrong twice over. First, by the time this function
|
||||
# runs the mis-routed claim has ALREADY been partitioned by `resolve()`, so
|
||||
# its effect is baked into S or into somebody's interval and cannot be
|
||||
# undone from here; consuming it would mean re-routing, which would make
|
||||
# `actuator` advisory and hand every filing site a second chance to be
|
||||
# wrong. Second, and worse, promoting a unit claim that landed on the site
|
||||
# requires guessing WHICH unit it belonged to, and there is no information
|
||||
# here to guess with - `actuator_for()` exists precisely because that answer
|
||||
# is chosen where the claim is built. Applying it to ALL units would put one
|
||||
# inverter's rating on a sibling that does not have it.
|
||||
#
|
||||
# So what is left is that S itself may be wrong, and dividing a number that
|
||||
# may be wrong is worse than not dividing it. Refuse, and let main.py fail
|
||||
# toward inaction and shout - which is the same answer clause 3 of the
|
||||
# arbiter's own rule already gives to a contradiction.
|
||||
#
|
||||
# ⚠️ IT IS ABOVE THE N<=1 PASS-THROUGH ON PURPOSE, and that is the one place
|
||||
# this card touches the pass-through's behaviour. A correctly routed
|
||||
# one-inverter cycle CANNOT produce a scope violation - `scope_violations()`
|
||||
# only complains about a unit source on the site when the site has named
|
||||
# units, and a site source on a unit requires somebody to have hand-written
|
||||
# `actuator=` past `actuator_for()` - so this cannot fire on the shipped
|
||||
# install, and where it does fire the input is a known bug rather than a
|
||||
# state to pass through.
|
||||
if scope_errors:
|
||||
return Allocation(
|
||||
target_w=float(target_w), sum_w=int(round(float(target_w))),
|
||||
shares={}, plans=(), dropped=(),
|
||||
unmet_w=int(round(float(target_w))),
|
||||
quantisation_w=float(int(round(float(target_w)))) - float(target_w),
|
||||
refused=f"{REFUSE_SCOPE}: " + "; ".join(scope_errors), tick=tick)
|
||||
|
||||
if len(units) <= 1:
|
||||
only = units[0] if units else site_key
|
||||
share = float(target_w)
|
||||
return Allocation(
|
||||
target_w=float(target_w), sum_w=int(round(share)),
|
||||
shares={only: share},
|
||||
plans=(UnitPlan(only, int(round(share)), -inf, inf, 1.0,
|
||||
"N<=1: the share IS the site target"),),
|
||||
passthrough=True, tick=tick)
|
||||
|
||||
w = {u: 1.0 if weights is None else float(weights.get(u, 1.0))
|
||||
for u in units}
|
||||
for u, wu in w.items():
|
||||
if wu < 0.0 or wu != wu:
|
||||
raise ValueError(f"weight for {u!r} is {wu!r}; weights are "
|
||||
"non-negative comfort factors, not signed targets")
|
||||
|
||||
# ⚠️ THE TARGET IS ROUNDED ONCE, HERE, AND NOWHERE ELSE. Everything below
|
||||
# this line is integer arithmetic, which is what makes "the shares sum to S"
|
||||
# an identity rather than a tolerance. `round()` is banker's rounding: over a
|
||||
# long replay it is unbiased, where `int(x + 0.5)` would add a systematic
|
||||
# half-watt in the discharge direction. In practice `target_w` arrives from
|
||||
# `compute()` already quantised to `step_w` (10 W by default) so this is a
|
||||
# no-op on the live path; it is not a no-op for a peak-shaving floor or an
|
||||
# arbitrage setpoint, which are not quantised.
|
||||
sum_w = int(round(float(target_w)))
|
||||
quant = float(sum_w) - float(target_w)
|
||||
|
||||
# --- the envelope set, rebuilt from scratch every tick -------------------
|
||||
dropped = []
|
||||
ilo, ihi = {}, {}
|
||||
considered = []
|
||||
for u in units:
|
||||
b = bounds[u]
|
||||
if live is not None and u not in live:
|
||||
# AC 6. The survivor is about to carry this unit's share and the
|
||||
# record has to say so; `dropped` is how it says it, and main.py
|
||||
# logs the transition.
|
||||
dropped.append((u, DROP_STALE))
|
||||
continue
|
||||
if getattr(b, "contradiction", False):
|
||||
# A unit contradicting ITSELF - its own `lo` above its own `hi`.
|
||||
# Stage 1 already decided that parks THAT unit and not the site,
|
||||
# which is the whole point of the partition; here it means the unit
|
||||
# has no interval to allocate into.
|
||||
dropped.append((u, DROP_CONTRADICTION))
|
||||
continue
|
||||
lo, hi = _int_envelope(b)
|
||||
if lo > hi:
|
||||
# An interval that contains no integer. The register is 1 W, so
|
||||
# there is no number this unit could be commanded that both bounds
|
||||
# accept - dropping it is the only answer that does not violate one.
|
||||
dropped.append((u, DROP_NO_INTEGER))
|
||||
continue
|
||||
ilo[u], ihi[u] = lo, hi
|
||||
considered.append(u)
|
||||
|
||||
def refuse(why):
|
||||
return Allocation(
|
||||
target_w=float(target_w), sum_w=sum_w, shares={}, plans=(),
|
||||
dropped=tuple(dropped), unmet_w=sum_w, quantisation_w=quant,
|
||||
refused=why, tick=tick)
|
||||
|
||||
# --- REFUSAL 2: nobody is available ------------------------------------
|
||||
if not considered:
|
||||
return refuse(f"{REFUSE_EMPTY}: "
|
||||
+ ", ".join(f"{u} ({why})" for u, why in dropped))
|
||||
|
||||
# --- PINS: a unit's own SET, weighed against the site's -------------------
|
||||
# ⚠️ THIS IS THE DECISION ARB-03 DEFERRED TO HERE, IN ITS OWN WORDS: "Stage 1
|
||||
# states the constraint and records what it is gated on; deciding whether it
|
||||
# applies is stage 3's business." `UnitBounds.gate` is the priority of the
|
||||
# unit's own winning SET; `site_priority` is the priority of the SITE claim
|
||||
# that chose S. Clause 1 of the arbiter's rule, transposed to a site with
|
||||
# more than one actuator: the higher priority wins, and a tie goes to the
|
||||
# unit, because a claim about one pack is more specific than a claim about
|
||||
# the connection and specificity is what a tie has left to break on.
|
||||
#
|
||||
# A pinned unit is NOT in the water-fill. It is doing something of its own -
|
||||
# a maintenance drain, a failsafe park - and what it does still counts
|
||||
# toward the site sum, so the free units carry the REMAINDER. That is not
|
||||
# the allocator compensating for a disturbance, which would be a second
|
||||
# controller; it is the shares summing to S, which is the invariant. The
|
||||
# meter's own answer arrives next tick through the one integrator above.
|
||||
#
|
||||
# ⚠️ AND site_priority=None MEANS "NO SITE SET WAS NAMED", so every pin
|
||||
# stands. `resolve()` always passes the real one. A direct caller that
|
||||
# passes nothing is saying it has no site claim to weigh, and honouring the
|
||||
# unit's own highest-priority claim is the only safe reading of that.
|
||||
pinned = {}
|
||||
for u in considered:
|
||||
b = bounds[u]
|
||||
gate = getattr(b, "gate", None)
|
||||
if gate is None:
|
||||
continue
|
||||
if site_priority is None or gate >= site_priority:
|
||||
pv = getattr(b, "pinned_w", None)
|
||||
if pv is None:
|
||||
continue
|
||||
pinned[u] = _clamp_int(round(float(pv)), ilo[u], ihi[u])
|
||||
|
||||
free = [u for u in considered if u not in pinned]
|
||||
remainder = sum_w - sum(pinned.values())
|
||||
|
||||
# --- WATER-FILL over the free units --------------------------------------
|
||||
# `base` is the point of each envelope nearest zero: 0 for an ordinary
|
||||
# symmetric rating, and the forced end for an envelope that does not contain
|
||||
# zero. Filling from there rather than from `lo` is what keeps a unit that
|
||||
# is merely ALLOWED to charge from being TOLD to.
|
||||
base = {u: _clamp_int(0, ilo[u], ihi[u]) for u in free}
|
||||
placed = sum(base.values())
|
||||
need = remainder - placed
|
||||
up = need >= 0
|
||||
cap = {u: (ihi[u] - base[u]) if up else (base[u] - ilo[u]) for u in free}
|
||||
add, leftover = _fill(free, w, cap, abs(need))
|
||||
|
||||
exact = {u: base[u] + (add[u] if up else -add[u]) for u in free}
|
||||
|
||||
# --- LARGEST-REMAINDER ROUNDING -----------------------------------------
|
||||
# Floor every share, then hand out the discarded watts one at a time, worst
|
||||
# discarded fraction first. The sum is integer arithmetic end to end, so it
|
||||
# cannot drift; ties break on the unit name so that the same inputs give the
|
||||
# same allocation on every tick and on every host.
|
||||
share = {u: int(floor(exact[u] + EPS)) for u in free}
|
||||
for u in free: # floors can never leave the envelope,
|
||||
share[u] = _clamp_int(share[u], ilo[u], ihi[u]) # but say so anyway
|
||||
debt = remainder - sum(share.values())
|
||||
frac_order = sorted(free, key=lambda u: (-(exact[u] - floor(exact[u])), u))
|
||||
step = 1 if debt > 0 else -1
|
||||
while debt != 0:
|
||||
moved = False
|
||||
for u in (frac_order if debt > 0 else list(reversed(frac_order))):
|
||||
if debt == 0:
|
||||
break
|
||||
nxt = share[u] + step
|
||||
if (step > 0 and nxt <= ihi[u]) or (step < 0 and nxt >= ilo[u]):
|
||||
share[u] = nxt
|
||||
debt -= step
|
||||
moved = True
|
||||
if not moved:
|
||||
break
|
||||
|
||||
shares = dict(pinned)
|
||||
shares.update(share)
|
||||
unmet = sum_w - sum(shares.values())
|
||||
|
||||
# --- REFUSAL 3: SIGN COHERENCE, so circulating power is not expressible ---
|
||||
# ⚠️ REFUSED, NOT AVERAGED. Two shares of opposite sign mean one pack
|
||||
# discharging into the AC bus while another charges off it: energy round-
|
||||
# tripping through two conversion stages for no reason, and - this is the
|
||||
# part that makes it a safety property rather than an efficiency one -
|
||||
# INVISIBLE. The meter sees the net, which is exactly S, so the site
|
||||
# integrator is content; each unit sees a target it is meeting, so no
|
||||
# per-unit guard fires; the sign watchdog compares a command against the
|
||||
# meter and both agree. Nothing in this repository would ever report it.
|
||||
#
|
||||
# It cannot arise from an ordinary tick: the fill only ever moves shares in
|
||||
# ONE direction from a base that is zero whenever the envelope contains
|
||||
# zero, so opposite signs require an envelope that EXCLUDES zero (a forced
|
||||
# floor) or a pin. Those are the cases worth refusing, and the refusal is a
|
||||
# site-level one because the alternative - dropping the offending unit and
|
||||
# re-filling - would silently override a claim that outranked the loop.
|
||||
pos = sorted(u for u, s in shares.items() if s > 0)
|
||||
neg = sorted(u for u, s in shares.items() if s < 0)
|
||||
if pos and neg:
|
||||
return refuse(
|
||||
f"{REFUSE_SIGN}: {', '.join(pos)} would discharge while "
|
||||
f"{', '.join(neg)} charges - circulating power, which nets to "
|
||||
f"{sum_w:+d} W at the meter and is invisible to every guard")
|
||||
|
||||
plans = []
|
||||
for u in units:
|
||||
drop = dict(dropped).get(u)
|
||||
if drop is not None:
|
||||
plans.append(UnitPlan(u, 0, bounds[u].lo, bounds[u].hi, w[u],
|
||||
f"DROPPED: {drop}", dropped=True))
|
||||
continue
|
||||
if u in pinned:
|
||||
by = getattr(bounds[u], "pinned_by", None) or "its own claim"
|
||||
plans.append(UnitPlan(u, pinned[u], ilo[u], ihi[u], w[u],
|
||||
f"pinned by {by} at priority "
|
||||
f"{bounds[u].gate}", pinned=True))
|
||||
continue
|
||||
at_edge = share[u] in (ilo[u], ihi[u])
|
||||
why = "weighted share"
|
||||
if at_edge and cap[u] - add[u] <= EPS and abs(need) > EPS:
|
||||
why = "envelope full - its surplus went to the others"
|
||||
elif w[u] <= 0.0 and share[u] != base[u]:
|
||||
why = "last resort: the preferred units were full"
|
||||
plans.append(UnitPlan(u, share[u], ilo[u], ihi[u], w[u], why,
|
||||
clamped=at_edge))
|
||||
|
||||
return Allocation(target_w=float(target_w), sum_w=sum_w, shares=shares,
|
||||
plans=tuple(plans), dropped=tuple(dropped),
|
||||
unmet_w=int(unmet), quantisation_w=quant, tick=tick)
|
||||
@@ -25,11 +25,52 @@ Clause 2 is the interesting one. It is what makes "money outranks maintenance"
|
||||
a consequence of the priorities instead of a special case somebody can forget:
|
||||
the maintenance charge-only limit binds the loop, but a higher-priority peak
|
||||
shaving claim simply is not bound by it.
|
||||
|
||||
⚠️ AND WHICH ACTUATOR IS A CLAIM ABOUT? THAT QUESTION HAS AN ANSWER AGAIN.
|
||||
specs.md §6.1 declared `actuator: str` as the first field of `Claim` and the
|
||||
implementation shipped without it, so every claim was implicitly about "the one
|
||||
and only actuator" and `resolve()` returned one scalar. ARB-03 restores the
|
||||
field, because §6.2 decided the site grid target is ALLOCATED across several
|
||||
inverters and never broadcast to each of them - and an allocator needs a seam to
|
||||
occupy that a single scalar does not have.
|
||||
|
||||
So resolution is now THREE STAGES, and they are separate functions so that a
|
||||
test can construct a case at any one of them:
|
||||
|
||||
1. `pre_resolve()` - each inverter's OWN claims become an interval for that
|
||||
unit. One unit's claims, one unit's bounds.
|
||||
2. `site_resolve()` - the SITE claims become one grid target S. This is the
|
||||
rule above, unchanged, and it is deliberately the same
|
||||
code it was: at N=1 there are no unit claims, so this
|
||||
stage sees the whole list and answers exactly what the
|
||||
shipped arbiter answered.
|
||||
3. `allocate()` - THE SEAM. It turns S into per-inverter shares subject to
|
||||
stage 1's intervals. ALLOC-01 filled it: the allocator
|
||||
itself is `app/allocator.py` and the reasoning lives
|
||||
there, because it consumes intervals and a number and
|
||||
must never learn about claims, priorities or P1.
|
||||
|
||||
⚠️ THE STRUCTURAL PROPERTY, AND IT IS THE REASON FOR THE SPLIT. A per-inverter
|
||||
constraint is consumed by the allocator as an INTERVAL; it is never promoted
|
||||
into the site claim list. So a unit `hi` and a site `lo` are never terms of the
|
||||
same `lo > hi` test, and the contradiction clause below - which resolves to 0 W,
|
||||
a battery parked inside a billed quarter - cannot be reached across two
|
||||
inverters. That is DECISIONS/SAFETY-25-loop-soc-floor.md §0c, made mechanical:
|
||||
not "guarded against", NOT EXPRESSIBLE. Filing a unit constraint as a site claim
|
||||
to "keep it simple" is the whole bug, and `actuator_for()` is what stops a
|
||||
strategy doing it by hand.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from math import inf
|
||||
|
||||
# ⚠️ ONE WAY ONLY. `allocator` knows nothing about claims, priorities or
|
||||
# this module - it consumes intervals and a number - so the dependency
|
||||
# cannot become a cycle and the allocator stays testable on its own, which
|
||||
# is what lets test_alloc.py sweep it hundreds of thousands of times
|
||||
# without building a claim list for each one.
|
||||
from . import allocator
|
||||
|
||||
# Priorities live here and nowhere else. The moment these become magic numbers
|
||||
# scattered through the strategies, the whole point of this module is lost.
|
||||
P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
|
||||
@@ -50,6 +91,150 @@ P_LOOP = 10 # ordinary grid-following
|
||||
SET = "set"
|
||||
LIMIT = "limit"
|
||||
|
||||
# --- actuators, and the two scopes a claim can have -------------------------
|
||||
# ⚠️ §6.2: THE GRID CONNECTION IS AN ACTUATOR TOO. It is the one every inverter
|
||||
# at the site shares, and two inverters regulating against one meter are two
|
||||
# writers on it - which is the failure this module exists to prevent, one level
|
||||
# up from the one it was built for. So it gets a name, and claims about it are
|
||||
# filed on that name rather than on nothing in particular.
|
||||
ACTUATOR_SITE = "site"
|
||||
|
||||
SCOPE_SITE = "site" # about the grid connection: one of these per site
|
||||
SCOPE_UNIT = "unit" # about one pack, one inverter, one device
|
||||
|
||||
# ⚠️ THE CLASSIFICATION, AND IT IS THE REAL OUTPUT OF ARB-03. Each entry is read
|
||||
# off what the claim is ABOUT at its construction site in main.py, not off which
|
||||
# module builds it. Getting an entry wrong is not cosmetic: a per-inverter
|
||||
# reserve filed as a site `hi` against the peak guard's site `lo` is `lo > hi`,
|
||||
# clause 3, 0 W - the battery parked in the billed quarter that SAFETY-25's memo
|
||||
# calls fatal. The reasoning is recorded here, at the line, because this table is
|
||||
# where a ninth source will be classified and the reasoning is what a future
|
||||
# reader needs in order to classify it correctly.
|
||||
CLAIM_SCOPE = {
|
||||
# --- SITE: about the grid connection ---------------------------------
|
||||
# main.py: `Claim.set("loop", P_LOOP, decision.target_w, ...)`. compute()
|
||||
# regulates `grid_w` - the P1 measurement - to zero. Its subject is the
|
||||
# meter, and §6.2's whole architecture is ONE integrator on P1 whose output
|
||||
# is then split. A site claim, and the one the split exists for.
|
||||
"loop": SCOPE_SITE,
|
||||
# main.py: `peakguard.claim(...)`. The quantity is the 15-minute average
|
||||
# OFFTAKE AT THE GRID CONNECTION, which is what the Belgian capacity tariff
|
||||
# bills. There is one quarter-hour peak per site, not one per pack.
|
||||
"peak": SCOPE_SITE,
|
||||
# main.py: `arbitrage.claim(...)`. It buys and sells at the grid against a
|
||||
# day-ahead price. The price is a property of the connection, and the energy
|
||||
# it trades crosses the meter.
|
||||
"arbitrage": SCOPE_SITE,
|
||||
# main.py: `Claim.set("safety", P_SAFETY, 0.0, "stopped" | "inputs missing")`.
|
||||
# ⚠️ READ BOTH CONSTRUCTION SITES BEFORE MOVING THIS ONE. Neither is about a
|
||||
# pack. "stopped" is the operator switching control off for the SITE, and
|
||||
# "inputs missing" is the SITE's own telemetry gone - the meter this whole
|
||||
# controller regulates against. Both must stop every inverter, and a site
|
||||
# SET at P_SAFETY is how that is said once rather than N times.
|
||||
"safety": SCOPE_SITE,
|
||||
# main.py: `Claim.set("circulation", P_SAFETY, 0.0, "circulating power")`.
|
||||
# SAFETY-31, and this one is a SITE claim for a reason worth writing down,
|
||||
# because the neighbouring `failsafe` entry looks like it and is not.
|
||||
#
|
||||
# `failsafe` is SAFETY-02: site-wide evidence, per-unit conclusion. It
|
||||
# compares the meter against THE COMMAND THIS CONTROLLER PUT ON ONE
|
||||
# ACTUATOR, so what it learns is about that actuator and the remedy is that
|
||||
# actuator.
|
||||
#
|
||||
# Circulating power is not a property any single inverter has. It is
|
||||
# `sum(|B_i|) - |sum(B_i)|`, defined over the whole roster and 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 the
|
||||
# same reading. 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. The evidence is site-level and so is the
|
||||
# remedy: stop commanding, everywhere, and say why.
|
||||
"circulation": SCOPE_SITE,
|
||||
|
||||
# --- UNIT: about one pack, one inverter, one device -------------------
|
||||
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
|
||||
# "rating")`. The numbers are read off ONE inverter's own reported min/max.
|
||||
# Another inverter has another rating; there is no site rating.
|
||||
"device": SCOPE_UNIT,
|
||||
# main.py: `Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w")`.
|
||||
# `max_w` is the supervised cap on what this controller may WRITE to a
|
||||
# setpoint entity - §11.3's "the actuator is capped below the inverter
|
||||
# rating". It bounds a write to one device, like `device` does and for the
|
||||
# same reason, so it is scoped like `device`.
|
||||
"supervised": SCOPE_UNIT,
|
||||
# main.py: `Claim.set("failsafe", P_SAFETY, 0.0, "sign disagreement")`.
|
||||
# ⚠️ THE EVIDENCE IS SITE-WIDE AND THE REMEDY IS NOT, and that is why this
|
||||
# entry needs its reasoning written down. SAFETY-02 compares P1 - a site
|
||||
# measurement - against `standing`, THE COMMAND THIS CONTROLLER PUT ON THIS
|
||||
# ACTUATOR. What it concludes is "the unit I am driving is pushing power the
|
||||
# wrong way", and what it does is park that unit. At N>=2 the same evidence
|
||||
# cannot be attributed to a sibling nobody here commands, so a site-wide
|
||||
# remedy would be this watchdog stopping an inverter it never graded.
|
||||
"failsafe": SCOPE_UNIT,
|
||||
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
|
||||
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
|
||||
# it only shapes. §12's monthly recalibration is a PACK procedure - drain
|
||||
# this pack to its floor, charge this pack to full, recalibrate this pack's
|
||||
# coulomb counter. Two packs recalibrate on their own schedules.
|
||||
"maintenance": SCOPE_UNIT,
|
||||
}
|
||||
|
||||
|
||||
def actuator_for(source: str, unit: str, units=()) -> str:
|
||||
"""Which actuator a claim from `source` belongs on, given the site's roster.
|
||||
|
||||
⚠️ ROUTE THROUGH THIS, DO NOT PASS `actuator=` BY HAND. An unclassified
|
||||
source raises `KeyError` here rather than defaulting to the site, which is
|
||||
the one direction that is not safe to default in: a per-inverter constraint
|
||||
that lands in the site claim list is exactly the `lo > hi` this card exists
|
||||
to make unreachable. A ninth claim source has to be classified in
|
||||
CLAIM_SCOPE before it can be filed, and test_peakguard.py part 10f is the
|
||||
other half of that - it goes red on the line the ninth append is written.
|
||||
|
||||
⚠️ AND AT N=1 EVERY SOURCE ROUTES TO THE SITE. That is not the classification
|
||||
being ignored, it is §6.2's identity: with one inverter the fabricated grid
|
||||
reading and the battery setpoint are the same number, so the grid connection
|
||||
and the only unit ARE the same actuator and there is nothing to allocate.
|
||||
Splitting the list at N=1 would be a behaviour change on a live safety path -
|
||||
the `failsafe` and `maintenance` SET claims would stop competing with the
|
||||
site's for the winner, `explain()` would name a different source, and
|
||||
`loop_is_following()` reads that name. The identity holds only at N=1, so
|
||||
this function is the ONE place that stops relying on it, the day the roster
|
||||
grows.
|
||||
"""
|
||||
if CLAIM_SCOPE[source] == SCOPE_SITE:
|
||||
return ACTUATOR_SITE
|
||||
return ACTUATOR_SITE if len(units) <= 1 else unit
|
||||
|
||||
|
||||
def scope_violations(claims) -> list:
|
||||
"""Claims filed on an actuator their source's scope does not permit.
|
||||
|
||||
A list of one-line complaints, empty when the routing is right. This is the
|
||||
check `actuator_for()` cannot make on its own, because a caller can always
|
||||
write `actuator=` by hand: it grades the claim list that actually reached the
|
||||
arbiter, whatever built it.
|
||||
"""
|
||||
out = []
|
||||
# ⚠️ THE SECOND TEST IS CONDITIONAL ON THE SITE HAVING NAMED UNITS AT ALL,
|
||||
# and that is not laxity. At N=1 §6.2's identity holds and a per-inverter
|
||||
# source filed on the site is CORRECT - it is what `actuator_for()` returns.
|
||||
# The direction that produces `lo > hi` only exists once there is a second
|
||||
# actuator for the constraint to have belonged to.
|
||||
split = any(c.actuator != ACTUATOR_SITE for c in claims)
|
||||
for c in claims:
|
||||
scope = CLAIM_SCOPE.get(c.source)
|
||||
if scope is None:
|
||||
out.append(f"{c.source}: not classified in CLAIM_SCOPE")
|
||||
elif scope == SCOPE_SITE and c.actuator != ACTUATOR_SITE:
|
||||
out.append(f"{c.source}: a SITE claim filed on unit {c.actuator!r} - "
|
||||
f"it would bound one inverter's share instead of the grid")
|
||||
elif scope == SCOPE_UNIT and c.actuator == ACTUATOR_SITE and split:
|
||||
out.append(f"{c.source}: a UNIT claim filed on the site while the "
|
||||
f"site has named units - it can cross a site limit")
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Claim:
|
||||
@@ -60,15 +245,76 @@ class Claim:
|
||||
lo: float = -inf # for LIMIT
|
||||
hi: float = inf # for LIMIT
|
||||
reason: str = ""
|
||||
# ⚠️ specs.md §6.1 DECLARES THIS FIELD FIRST AND IT IS LAST HERE, ON PURPOSE.
|
||||
# The spec's point is that a claim NAMES its actuator; the tuple position is
|
||||
# not the point, and it is load-bearing for two things that are. Every
|
||||
# construction in this repository passes `source` first, and
|
||||
# test_peakguard.py part 10f reads `Claim.set(...)`/`Claim.limit(...)`'s
|
||||
# FIRST POSITIONAL ARGUMENT off main.py's AST to pin the claim-source set -
|
||||
# the tripwire that guards SAFETY-25's deferred option. Moving `source` off
|
||||
# position 0 would silently blind that scan, which is this repository's named
|
||||
# failure mode. So the field is restored where it costs nothing.
|
||||
#
|
||||
# ⚠️ AND ITS DEFAULT IS THE SITE, EXPLICITLY. Not None, not "". A claim that
|
||||
# does not name an actuator is a claim about the grid connection and says so;
|
||||
# "unspecified" is what the shipped code already had, and it is what made
|
||||
# every claim implicitly about the one and only actuator.
|
||||
actuator: str = ACTUATOR_SITE
|
||||
|
||||
@staticmethod
|
||||
def set(source: str, priority: int, value: float, reason: str = "") -> "Claim":
|
||||
return Claim(source, priority, SET, value=value, reason=reason)
|
||||
def set(source: str, priority: int, value: float, reason: str = "", *,
|
||||
actuator: str = ACTUATOR_SITE) -> "Claim":
|
||||
return Claim(source, priority, SET, value=value, reason=reason,
|
||||
actuator=actuator)
|
||||
|
||||
@staticmethod
|
||||
def limit(source: str, priority: int, lo: float = -inf, hi: float = inf,
|
||||
reason: str = "") -> "Claim":
|
||||
return Claim(source, priority, LIMIT, lo=lo, hi=hi, reason=reason)
|
||||
reason: str = "", *, actuator: str = ACTUATOR_SITE) -> "Claim":
|
||||
return Claim(source, priority, LIMIT, lo=lo, hi=hi, reason=reason,
|
||||
actuator=actuator)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitBounds:
|
||||
"""What one inverter's OWN claims say about that inverter's share.
|
||||
|
||||
STAGE 1's output, one per unit, and the thing ALLOC-01 consumes. It is an
|
||||
INTERVAL plus the two facts an allocator cannot reconstruct from an interval:
|
||||
|
||||
* `pinned_w` - the value that unit's own winning SET asked for. A SET is not
|
||||
a bound: `resolve()` CLAMPS a set value into the limits rather than
|
||||
intersecting with it, and folding a 2500 W drain into `[2500, 2500]` would
|
||||
turn "clamped to the rating" into "contradicts the rating". So it travels
|
||||
beside the interval, not inside it.
|
||||
* `gate` - the priority of that winning SET, or None. Clause 2 applies a
|
||||
limit only at or above the winner's priority, and at N>=2 the winner may be
|
||||
a SITE set chosen at stage 2, i.e. AFTER this. Stage 1 states the
|
||||
constraint and records what it is gated on; deciding whether it applies is
|
||||
stage 3's business. Guessing here is how a maintenance charge-only `hi`
|
||||
would come to bind a peak discharge it does not outrank.
|
||||
|
||||
`contradiction` is a unit contradicting ITSELF - its own `lo` above its own
|
||||
`hi`. It parks that unit and says so; it does not park the site, which is the
|
||||
entire difference this card is about.
|
||||
"""
|
||||
lo: float = -inf
|
||||
hi: float = inf
|
||||
lo_src: str | None = None
|
||||
hi_src: str | None = None
|
||||
gate: int | None = None
|
||||
pinned_w: float | None = None
|
||||
pinned_by: str | None = None
|
||||
contradiction: bool = False
|
||||
|
||||
def explain(self) -> str:
|
||||
if self.contradiction:
|
||||
return (f"CONTRADICTION within the unit ({self.lo_src} lo="
|
||||
f"{self.lo:.0f} > {self.hi_src} hi={self.hi:.0f})")
|
||||
span = (f"[{'-inf' if self.lo == -inf else format(self.lo, '.0f')}, "
|
||||
f"{'+inf' if self.hi == inf else format(self.hi, '.0f')}]")
|
||||
if self.pinned_by is not None:
|
||||
return f"{span} around {self.pinned_by} -> {self.pinned_w:.0f} W"
|
||||
return span
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -78,6 +324,32 @@ class Resolution:
|
||||
bound_by: str | None = None # which limit actually changed it, if any
|
||||
contradiction: bool = False
|
||||
considered: list = field(default_factory=list)
|
||||
# --- the three-stage fields. ⚠️ APPENDED, AND DEFAULTED, ON PURPOSE: every
|
||||
# existing construction of this type is positional in its first five fields
|
||||
# and `target_w` still means exactly what it meant - the SITE grid target.
|
||||
# main.py assigns it to `self.target` unchanged.
|
||||
unit_bounds: dict = field(default_factory=dict) # stage 1, per actuator
|
||||
shares: dict = field(default_factory=dict) # stage 3, per actuator
|
||||
allocated: bool = True # False when stage 3 declined to split
|
||||
scope_errors: list = field(default_factory=list) # mis-routed claims
|
||||
# ALLOC-01, appended for the reason the four above were: `target_w` still
|
||||
# means the SITE grid target and every existing construction is untouched.
|
||||
#
|
||||
# `allocation` is stage 3's full record - what each unit was told and why,
|
||||
# what could not be placed, what was dropped. `shares` and `allocated` are
|
||||
# two fields OF that record, kept at the top level because main.py and four
|
||||
# test files already read them there. ⚠️ IT IS THE RECORD, NOT THE PAIR,
|
||||
# THAT SAFETY-28 GATES THE WRITE PATH ON: `allocated` says the allocator
|
||||
# stands behind the split, and `unmet_w` says how much of the site target it
|
||||
# could not place, and fusing those two would make one saturated inverter
|
||||
# park the whole site.
|
||||
allocation: object | None = None
|
||||
# The priority of the SITE claim that chose `target_w`. Stage 3 weighs a
|
||||
# unit's OWN winning SET against it - see `allocator.plan()`'s PINS note -
|
||||
# and getting it from here rather than recomputing it is what stops the two
|
||||
# rules drifting apart: this is set by the same line that picks the winner
|
||||
# `explain()` names.
|
||||
winner_priority: int | None = None
|
||||
|
||||
def explain(self) -> str:
|
||||
"""One line an operator can act on - this is the point of the module."""
|
||||
@@ -88,7 +360,107 @@ class Resolution:
|
||||
return f"{self.winner} -> {self.target_w:.0f} W"
|
||||
|
||||
|
||||
def resolve(claims: list) -> Resolution:
|
||||
def pre_resolve(unit_claims: list) -> UnitBounds:
|
||||
"""STAGE 1. One inverter's own claims -> that inverter's interval.
|
||||
|
||||
Clauses 1 and 2 of the rule, transposed: the unit's highest-priority SET
|
||||
names what the unit wants and gates which of the unit's limits apply, and the
|
||||
most restrictive of those limits gives the interval. Read UnitBounds on why
|
||||
the SET value is not folded into the interval.
|
||||
|
||||
⚠️ THE INPUT IS ONE UNIT'S CLAIMS AND NOTHING ELSE. A site claim reaching
|
||||
this function would become a bound on one inverter's share - the mirror image
|
||||
of the fault this card removes, and the reason `resolve()` partitions rather
|
||||
than filters twice.
|
||||
"""
|
||||
sets = sorted((c for c in unit_claims if c.kind == SET),
|
||||
key=lambda c: c.priority, reverse=True)
|
||||
gate = pinned_w = pinned_by = None
|
||||
if sets:
|
||||
gate, pinned_w, pinned_by = sets[0].priority, sets[0].value, sets[0].source
|
||||
|
||||
lo, hi = -inf, inf
|
||||
lo_src = hi_src = None
|
||||
for c in unit_claims:
|
||||
if c.kind != LIMIT or (gate is not None and c.priority < gate):
|
||||
continue
|
||||
if c.lo > lo:
|
||||
lo, lo_src = c.lo, c.source
|
||||
if c.hi < hi:
|
||||
hi, hi_src = c.hi, c.source
|
||||
return UnitBounds(lo, hi, lo_src, hi_src, gate, pinned_w, pinned_by,
|
||||
contradiction=lo > hi)
|
||||
|
||||
|
||||
def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
scope_errors=(), site_priority=None) -> tuple:
|
||||
"""STAGE 3. THE SEAM, AND SINCE ALLOC-01 IT IS OCCUPIED. `(shares, allocated)`.
|
||||
|
||||
⚠️ THE ALLOCATOR ITSELF IS `app/allocator.py` AND THE REASONING LIVES THERE.
|
||||
This is the adapter: it keeps the two-value shape every caller in this
|
||||
repository already reads, and it exists as a separate name so that the
|
||||
arbiter's three stages are still three functions a test can enter at any
|
||||
one of them. `allocator.plan()` returns the full record - use that when you
|
||||
need `unmet_w`, `dropped` or the per-unit reasons; `resolve()` stashes it on
|
||||
`Resolution.allocation` for exactly that.
|
||||
|
||||
What §6.2 fixed and what this delivers:
|
||||
|
||||
* SUM-INVARIANT. `sum(shares) + unmet_w == round(target_w)`, in integer
|
||||
arithmetic, so it cannot drift. Rounding is largest-remainder because a
|
||||
per-unit `round()` biases the sum by up to N/2 W every tick and at 1-2 Hz
|
||||
that is a PERMANENT standing error the site integrator chases forever.
|
||||
* EACH UNIT IS TOLD A FUNCTION OF ITS OWN SHARE, never of the shared
|
||||
measurement. That makes the gain at the meter a convex combination of the
|
||||
per-unit gains for ANY split, which is why the weights are a comfort
|
||||
choice and a bad split cannot destabilise the site.
|
||||
* WITH AT MOST ONE UNIT IT IS STILL A PASS-THROUGH, byte-identical to what
|
||||
ARB-03 shipped: the share IS the site target, as a float, unrounded and
|
||||
unclamped. §6.2's N=1 identity, and the compatibility promise every
|
||||
existing replay in this repository depends on.
|
||||
|
||||
⚠️ AND IT STILL DECLINES, WHICH IS NOT THE SAME LIST OF CASES AS BEFORE.
|
||||
ARB-03 declined at N>=2 because it had no allocator; that trigger is gone
|
||||
and the branch is NOT. It now fires when no safe split exists at all: a
|
||||
mis-routed claim, no unit left in the envelope set, or a split that could
|
||||
only be made by commanding one pack to charge while another discharges.
|
||||
Read `allocator.plan()` for each. main.py's refusal path and
|
||||
test_peakguard.py part 10h were re-aimed onto those rather than deleted -
|
||||
PR #40's reviewer proved that guard was unreachable and untested once, and
|
||||
removing its trigger without giving it a new one would rebuild the hole.
|
||||
"""
|
||||
return allocate_record(target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=scope_errors,
|
||||
site_priority=site_priority)[:2]
|
||||
|
||||
|
||||
def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
scope_errors=(), site_priority=None, tick=None) -> tuple:
|
||||
"""`allocate()` plus the record - `(shares, allocated, allocation)`.
|
||||
|
||||
`tick` is SAFETY-28's freshness token: it identifies the tick the record is
|
||||
FOR and is handed straight through to `allocator.plan()`. Left None the
|
||||
record is UNSTAMPED, which the write gate refuses - so a caller that intends
|
||||
the record to authorise a write must pass one. Every existing caller in this
|
||||
repository wants the arithmetic and not the authority, and gets None.
|
||||
"""
|
||||
rec = allocator.plan(target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=scope_errors,
|
||||
site_priority=site_priority, site_key=ACTUATOR_SITE,
|
||||
tick=tick)
|
||||
return rec.shares, rec.ok, rec
|
||||
|
||||
|
||||
def site_resolve(claims: list) -> Resolution:
|
||||
"""STAGE 2. The SITE claims -> one grid target.
|
||||
|
||||
⚠️ THIS FUNCTION IS THE SHIPPED ARBITER, UNCHANGED. It is a separate name and
|
||||
the same code, deliberately: at N=1 nothing is filed on a unit, so `resolve()`
|
||||
hands it the whole claim list and it answers what it has always answered.
|
||||
Every check in test_arbiter.py, test_watchdog.py, test_validate.py and
|
||||
test_peakguard.py replays through here unedited, which is the proof that
|
||||
ARB-03 changed no behaviour on the live path.
|
||||
"""
|
||||
sets = sorted((c for c in claims if c.kind == SET),
|
||||
key=lambda c: c.priority, reverse=True)
|
||||
|
||||
@@ -113,7 +485,8 @@ def resolve(claims: list) -> Resolution:
|
||||
|
||||
if lo > hi:
|
||||
return Resolution(0.0, winner.source, bound_by=None,
|
||||
contradiction=True, considered=considered)
|
||||
contradiction=True, considered=considered,
|
||||
winner_priority=winner.priority)
|
||||
|
||||
target = max(lo, min(hi, winner.value))
|
||||
bound_by = None
|
||||
@@ -122,4 +495,80 @@ def resolve(claims: list) -> Resolution:
|
||||
if binder is not None:
|
||||
bound_by = f"{binder.source}({binder.reason})" if binder.reason else binder.source
|
||||
|
||||
return Resolution(float(target), winner.source, bound_by, False, considered)
|
||||
return Resolution(float(target), winner.source, bound_by, False, considered,
|
||||
winner_priority=winner.priority)
|
||||
|
||||
|
||||
def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
tick=None) -> Resolution:
|
||||
"""The three stages, in order, over one cycle's claims.
|
||||
|
||||
`units` is the site's roster of inverters. Left None it is derived from the
|
||||
claims themselves, which is right for a caller that files a claim for every
|
||||
unit every cycle (main.py does - `supervised` is unconditional) and wrong for
|
||||
one that does not, so main.py passes the real roster rather than let a silent
|
||||
unit look like a smaller site.
|
||||
|
||||
`weights` and `live` are ALLOC-01's two per-tick inputs to stage 3, and both
|
||||
are per-TICK on purpose. `weights` is the comfort choice - SoC, thermals,
|
||||
wear - and cannot destabilise the site whatever it says. `live` is the set
|
||||
of units whose master has reported recently enough to be commanded; a unit
|
||||
left out of it is DROPPED from the envelope set and the record says so,
|
||||
because the survivor carrying a dead unit's share silently is the failure
|
||||
SAFETY-30 is about. ⚠️ NEITHER MAY BE CACHED BY A CALLER. A unit's range
|
||||
moves for reasons unrelated to the allocator - a stale link, a pack at its
|
||||
floor, a device rating that failed to read - so an envelope read once at
|
||||
construction over-allocates into a unit that cannot take it.
|
||||
|
||||
⚠️ AND THE ARGUMENT HAS TO BE HONOURED, WHICH IS NOT THE SAME AS EXISTING.
|
||||
Ignore it - derive always - and a unit that filed nothing this cycle ceases
|
||||
to exist: a site of two reads as a site of one and stage 3 passes the whole
|
||||
grid target through to the inverter that spoke, which is ARCH-01's fault
|
||||
with its own evidence removed. That mutation survived the whole suite until
|
||||
test_arbiter.py part 7 graded it on a SILENT unit.
|
||||
|
||||
⚠️ `tick` IS SAFETY-28's FRESHNESS TOKEN AND IT IS NOT OPTIONAL ON THE LIVE
|
||||
PATH. It identifies the tick this resolution is FOR, and it ends up on
|
||||
`Resolution.allocation.tick`, where the write gate compares it against the
|
||||
tick it is writing on. A record with no tick cannot authorise a write, which
|
||||
is why main.py passes `self.alloc_tick` and why a test that only wants the
|
||||
arbiter's arithmetic can leave it out and be sure it has not accidentally
|
||||
minted a licence to write.
|
||||
|
||||
⚠️ THE PARTITION IS BY `actuator`, AND IT IS A PARTITION, NOT A FILTER. Every
|
||||
claim lands in exactly one stage. That is the whole structural argument: a
|
||||
unit's `hi` and the site's `lo` are never both terms of the same `lo > hi`
|
||||
test, so SAFETY-25 §0a's cross-inverter contradiction - 0 W inside the billed
|
||||
quarter - is not something to guard against, it is something that cannot be
|
||||
written down.
|
||||
"""
|
||||
site_claims = [c for c in claims if c.actuator == ACTUATOR_SITE]
|
||||
if units is None:
|
||||
units = {c.actuator for c in claims if c.actuator != ACTUATOR_SITE}
|
||||
units = tuple(sorted(units))
|
||||
|
||||
# STAGE 1 - each unit's own claims become that unit's interval.
|
||||
bounds = {u: pre_resolve([c for c in claims if c.actuator == u])
|
||||
for u in units}
|
||||
|
||||
# STAGE 2 - the site claims become the grid target. At N=1 `site_claims` IS
|
||||
# `claims`, so this is the shipped arbiter on the shipped list.
|
||||
out = site_resolve(site_claims)
|
||||
|
||||
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
|
||||
#
|
||||
# ⚠️ THE ORDER MATTERS: `scope_violations()` IS COMPUTED BEFORE THE SPLIT,
|
||||
# NOT AFTER, because stage 3 consumes it. A claim filed on an actuator its
|
||||
# source's scope does not permit means the number about to be divided may
|
||||
# itself be wrong, and dividing a number that may be wrong is worse than
|
||||
# not dividing it - `allocator.plan()` carries the full argument, including
|
||||
# why the mis-routed claim is NOT promoted into somebody's envelope. This
|
||||
# was the open question ARB-03 left, and it is answered here rather than
|
||||
# left as a log line nothing grades.
|
||||
out.scope_errors = scope_violations(claims)
|
||||
out.unit_bounds = bounds
|
||||
out.shares, out.allocated, out.allocation = allocate_record(
|
||||
out.target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=out.scope_errors, site_priority=out.winner_priority,
|
||||
tick=tick)
|
||||
return out
|
||||
|
||||
@@ -146,7 +146,7 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .arbiter import P_ARBITRAGE, Claim
|
||||
from .arbiter import ACTUATOR_SITE, P_ARBITRAGE, Claim
|
||||
from .price import Absence, price_option
|
||||
|
||||
_LOG = logging.getLogger("goodwe.arbitrage")
|
||||
@@ -425,5 +425,9 @@ def claim(decision: ArbitrageDecision) -> Claim | None:
|
||||
"""
|
||||
if not decision.trading or decision.power_w is None:
|
||||
return None
|
||||
# ⚠️ ON THE SITE ACTUATOR, EXPLICITLY. A trade is bought and sold at the
|
||||
# grid: the price is a property of the connection and the energy crosses the
|
||||
# meter. So this is a claim on the SITE target, which the allocator then
|
||||
# divides - not an instruction to any one pack.
|
||||
return Claim.set(SOURCE, P_ARBITRAGE, float(decision.power_w),
|
||||
decision.action)
|
||||
decision.action, actuator=ACTUATOR_SITE)
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""SAFETY-31 - the power two inverters move through each other, which the meter cannot see.
|
||||
|
||||
Inverter A charging at 3 kW while inverter B discharges at 3 kW nets ~0 W at
|
||||
P1. The site looks idle. In reality 3 kW is going round a loop through two
|
||||
conversion stages continuously, for no benefit, and the wear on both packs is
|
||||
real even though every instrument in this add-on says nothing is happening.
|
||||
|
||||
WHY NOTHING EXISTING SEES IT, AND IT IS STRUCTURAL RATHER THAN AN OVERSIGHT
|
||||
==========================================================================
|
||||
Every consumer of the site measurement is a consumer of the NET. SAFETY-02's
|
||||
sign-disagreement watchdog gates on `abs(grid_w) > cfg.min_w` (300 W by
|
||||
default) - with the grid at ~0 that gate never opens, on any cycle, for any
|
||||
duration. The peak guard bills the quarter-hour average AT THE GRID
|
||||
CONNECTION. The capacity tariff bills grid exchange. `freeze.looks_frozen()`
|
||||
grades P1 against the site rail. There is one meter, it reads the sum, and
|
||||
circulating power is exactly the component of per-unit power that the sum
|
||||
DESTROYS. So this cannot be fixed by tuning a threshold on any existing
|
||||
detector; it needs a quantity none of them are computing.
|
||||
|
||||
THE QUANTITY
|
||||
============
|
||||
circulating_w = sum(|B_i|) - |sum(B_i)|
|
||||
|
||||
Zero when every unit pulls the same way - then sum(|B_i|) == |sum(B_i)| by the
|
||||
triangle equality. `2 * min(|B_a|, |B_b|)` at N=2 when they oppose, which is
|
||||
twice the power going round the loop and is the right scale: that is what both
|
||||
conversion stages are processing between them. It is non-negative always, it is
|
||||
zero at N<=1 identically, and it needs no threshold to be interpretable - the
|
||||
number IS the phenomenon in watts.
|
||||
|
||||
⚠️ IT IS COMPUTED FROM MEASURED PER-INVERTER POWER, AND THAT IS THE WHOLE CARD.
|
||||
=============================================================================
|
||||
The obvious cheap version reads the allocation record - the shares the
|
||||
allocator just handed out - and reports the same formula over those. It would
|
||||
be simpler, it would never see a `None`, and it would be worth NOTHING, for two
|
||||
independent reasons.
|
||||
|
||||
The first is that it cannot fire. ALLOC-01 shipped the sign-coherence gate as
|
||||
its AC 7 (`allocator.REFUSE_SIGN`, graded by `test_alloc.py` part 6): an
|
||||
allocation whose shares carry two signs is REFUSED, both units named, no shares
|
||||
returned. So a sensor over the allocation record is a sensor over a set the
|
||||
allocator has already guaranteed is sign-coherent. It reads zero by
|
||||
construction, forever, and a check written against it passes forever.
|
||||
|
||||
The second is the one MUTATION.md names as the third way a check lies. A sensor
|
||||
derived from the allocation record can only ever confirm THE ALLOCATOR AGREES
|
||||
WITH ITSELF. Every fault in this card's list is a divergence between what was
|
||||
commanded and what the hardware is doing:
|
||||
|
||||
* a unit ignoring or lagging its share;
|
||||
* a stale share still being executed by one inverter;
|
||||
* SAFETY-25's deferred loop floor, or a maintenance drain, pinning one unit
|
||||
while the loop drives the other;
|
||||
* an inverter doing something of its own accord - and THIS HARDWARE HAS DONE
|
||||
THAT TWICE. On 2026-09-02 the comms subsystem failed and the power stage
|
||||
went on executing its last instruction for 6 h 25 m (HW-01). Through all of
|
||||
it the allocation record was impeccable.
|
||||
|
||||
In every one of those the command side is coherent and the MEASUREMENT side is
|
||||
not. So the input is `Inverter.batt` - this unit's own power, read from its own
|
||||
`batt_entity`, which on the live system is holding register 0x0518, the
|
||||
inverter's own AC power. DEV-02 made that per-instance; before it there was one
|
||||
`batt` scalar on the Controller and this sensor could not have been written at
|
||||
all.
|
||||
|
||||
⚠️ AND THIS IS THE MEASUREMENT HALF OF A PAIR. ALLOC-01's gate is the COMMAND
|
||||
half: the allocator can no longer COMMAND circulating power. This module is the
|
||||
detection half: circulating power the allocator never commanded. Neither one
|
||||
replaces the other and neither is redundant - the gate refuses an allocation
|
||||
before it is written, and cannot see a millisecond past that; this reads the
|
||||
hardware and does not care what was written. Do not "unify" them.
|
||||
|
||||
THE TRIP, AND WHY A THRESHOLD ALONE IS A FALSE-ALARM GENERATOR
|
||||
==============================================================
|
||||
A momentary opposition is LEGITIMATE. Writes to the inverters are serialised
|
||||
(one `hass.call()` at a time - see `Inverter`'s docstring on what a hung write
|
||||
to A costs B), so on a cycle where the site target reverses, one unit receives
|
||||
its new command up to a whole control cycle before its sibling. Both then slew
|
||||
through zero, with a real ramp rate and a real first-order lag, and BETWEEN THE
|
||||
TWO ZERO CROSSINGS THE UNITS GENUINELY OPPOSE. Nothing is wrong; the site is
|
||||
turning around.
|
||||
|
||||
This project's doctrine on crying wolf is explicit (SAFETY-15, SAFETY-19) and
|
||||
this is exactly the shape that generates it. So the trip is a threshold AND a
|
||||
sustained window, and BOTH numbers were measured on the TEST-19 two-master rig
|
||||
rather than chosen - see `CIRC_OPTION_DEFAULTS` for the measurements and
|
||||
`DECISIONS/SAFETY-31-circulating-power.md` for the runs.
|
||||
|
||||
⚠️ THE WINDOW ACCUMULATES, WHICH IS WHY THIS IS NOT SAFETY-02's PROBLEM AGAIN.
|
||||
The reason the sign watchdog is blind to the broadcast fight is that the
|
||||
disagreement FLIPS SIGN on alternate ticks, so `disagree_since` is cleared
|
||||
between every pair of them and the 180 s hold never accumulates (allocator.py's
|
||||
docstring records the measurement). 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 on every other one. An oscillation that genuinely
|
||||
alternated between opposing and agreeing is not a circulating loop and should
|
||||
not be reported as one.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
# CFG-03. THE ONE IN-CODE COPY OF THESE DEFAULTS. `config.yaml`'s `options:`
|
||||
# block is the source of truth an operator edits and the Supervisor reads;
|
||||
# `schema:` holds the bounds. This dict exists for the reason
|
||||
# `maintenance.MAINT_OPTION_DEFAULTS` does - the Dockerfile does not put
|
||||
# config.yaml in the image, so the running add-on cannot read it - and
|
||||
# `test_watchdog.py` pins the two together by READING config.yaml, so a drift
|
||||
# goes red in CI rather than shipping.
|
||||
CIRC_OPTION_DEFAULTS = {
|
||||
# --- THE THRESHOLD, AND IT IS READ OFF A CURVE ------------------------
|
||||
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 924 closed-loop
|
||||
# runs in which NOTHING is faulty - every unit executes the share the
|
||||
# allocator gave it - over both families of stimulus that can produce
|
||||
# legitimate opposition: single reversals (7 sizes x 5 write staggers) and a
|
||||
# flapping load (7 periods x 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 worst CONTINUOUS opposition, as a function of the threshold:
|
||||
#
|
||||
# 100 W -> 505.0 s 300 W -> 10.0 s 700 W -> 5.0 s
|
||||
# 200 W -> 445.0 s 350 W -> 10.0 s 1000 W -> 5.0 s
|
||||
# 250 W -> 40.0 s 400 W -> 5.0 s 1700 W -> 0.0 s
|
||||
#
|
||||
# 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 - 505 s at 100 W -
|
||||
# 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. So this number is not a sensitivity
|
||||
# preference; it is the edge of the regime in which the guard is
|
||||
# answerable at all.
|
||||
#
|
||||
# 700 W is 1.75x the knee, and the margin 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 headroom above the knee is what absorbs that without re-opening this
|
||||
# card. Revisit it when SPIKE-04 lands, not before.
|
||||
#
|
||||
# What it costs: a circulating loop smaller than 350 W per unit is not
|
||||
# reported. That is deliberate - at that scale the round-trip loss is a few
|
||||
# watts and the wear is proportionate to it.
|
||||
"circulation_w": 700,
|
||||
|
||||
# --- THE WINDOW -------------------------------------------------------
|
||||
# Two lower bounds, one upper, and the chosen value clears all three.
|
||||
#
|
||||
# LOWER, from the rig: 5.0 s. The worst continuous legitimate opposition
|
||||
# above 700 W across all 924 runs is exactly one control cycle - a
|
||||
# reversing site whose second write lands a cycle after the first, which
|
||||
# is what the serialised write path does on every reversal.
|
||||
#
|
||||
# LOWER, from the firmware: `inverter.LINK_STALE_S`, 30 s. A unit still
|
||||
# executing a stale share for less than that is a LINK fault, and the
|
||||
# add-on already has an answer for 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 that is already
|
||||
# being handled, which is the SAFETY-15/SAFETY-19 failure exactly.
|
||||
#
|
||||
# UPPER, from the bill: 900 s, the quarter that sets the capacity tariff.
|
||||
# A circulating loop must be caught and acted on well inside one.
|
||||
#
|
||||
# 75 s: fifteen control cycles - 15x the worst legitimate episode this rig
|
||||
# can produce, 2.5x the firmware's own veto window, and 12x inside the
|
||||
# quarter. ⚠️ IT IS DELIBERATELY NOT 60 OR 120. A round number here would be
|
||||
# a guess wearing a measurement's clothes, and the three bounds above are
|
||||
# what the number has to answer to.
|
||||
"circulation_hold_s": 75,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CirculationConfig:
|
||||
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
|
||||
|
||||
min_w: float = float(CIRC_OPTION_DEFAULTS["circulation_w"])
|
||||
hold_s: float = float(CIRC_OPTION_DEFAULTS["circulation_hold_s"])
|
||||
|
||||
|
||||
def circulating_w(powers):
|
||||
"""`sum(|B_i|) - |sum(B_i)|` over MEASURED per-inverter power, in W.
|
||||
|
||||
`powers` one measured power per inverter, in roster order, + = discharge.
|
||||
A unit whose reading did not arrive is `None`.
|
||||
|
||||
Returns a non-negative float, or `None` when the answer is unknown.
|
||||
|
||||
⚠️ N<=1 IS IDENTICALLY ZERO AND RETURNS ZERO EVEN FOR A MISSING READING
|
||||
(AC 5). With one term `sum(|B|) - |sum(B)| == |B| - |B| == 0` for EVERY
|
||||
value of B, including a value nobody read. Returning `None` there would be
|
||||
reporting an unknown where the algebra has no room for one, and it would put
|
||||
a shipped one-inverter install permanently in the "cannot tell" state on a
|
||||
quantity that cannot be anything but zero. Nothing changes at the shipped
|
||||
roster; that is the compatibility promise this card makes.
|
||||
|
||||
⚠️ AND AT N>=2 A MISSING READING IS `None`, NOT ZERO, WHICH IS THE OPPOSITE
|
||||
DIRECTION AND FOR A REASON. Zero would be this sensor reporting HEALTH from
|
||||
an absence - the precise shape of the 2026-09-02 outage, where a subsystem
|
||||
stopped answering and its silence read as calm for 6 h 25 m. An unknown
|
||||
circulating figure is an unknown, it CLEARS the trip's window rather than
|
||||
accumulating it (see `CirculationWatchdog.evaluate`), and the site is
|
||||
already stopped for a missing per-unit reading anyway - `inputs_ok` requires
|
||||
every unit's `batt`, and §11.1 rule 1 commands 0 W without consulting this.
|
||||
"""
|
||||
vals = list(powers)
|
||||
if len(vals) <= 1:
|
||||
return 0.0
|
||||
if any(v is None for v in vals):
|
||||
return None
|
||||
vals = [float(v) for v in vals]
|
||||
# ⚠️ max(0.0, ...) IS DEFENSIVE, AND IT IS AN EQUIVALENT MUTANT - SAID HERE
|
||||
# RATHER THAN LEFT FOR THE NEXT MUTATION SWEEP TO REDISCOVER. The triangle
|
||||
# inequality makes this non-negative mathematically, and in floating point
|
||||
# it is non-negative too: for a same-sign roster `abs(v)` is the list itself
|
||||
# up to one sign, so the two sums accumulate THE SAME MAGNITUDES IN THE SAME
|
||||
# ORDER and are bit-identical; for a mixed-sign roster the two differ by
|
||||
# 2*min(...), which is enormous beside any rounding. Searched, not assumed -
|
||||
# 700 000 random rosters of 2 to 12 units across 10 orders of magnitude
|
||||
# produced no negative result.
|
||||
#
|
||||
# So removing this line changes no observable and no check here would go
|
||||
# red. It stays because the cost is one call and the failure it would
|
||||
# prevent - a power sensor publishing -1e-16 W, which is the kind of value
|
||||
# somebody eventually writes a `< 0` branch against - is out of proportion
|
||||
# to it. Do not report its survival as a gap in the checks.
|
||||
return max(0.0, sum(abs(v) for v in vals) - abs(sum(vals)))
|
||||
|
||||
|
||||
def opposing(powers, min_w: float = 0.0):
|
||||
"""The units pulling each way right now, as `(discharging, charging)` names.
|
||||
|
||||
Indices, not names - the caller owns the roster order. Used for the operator
|
||||
sentence: "which two inverters are pushing against each other" is the first
|
||||
thing somebody woken by this alarm needs, and reconstructing it from the
|
||||
number alone is impossible.
|
||||
|
||||
`min_w` ignores units below a magnitude, so a unit resting at 3 W of
|
||||
standby draw is not named as a participant in a 3 kW loop.
|
||||
"""
|
||||
pos = [i for i, v in enumerate(powers)
|
||||
if v is not None and float(v) > float(min_w)]
|
||||
neg = [i for i, v in enumerate(powers)
|
||||
if v is not None and float(v) < -float(min_w)]
|
||||
return pos, neg
|
||||
|
||||
|
||||
class CirculationWatchdog:
|
||||
"""ONE PER SITE, evaluated once per control cycle.
|
||||
|
||||
⚠️ ONE PER SITE, WHERE SAFETY-02's IS ONE PER ACTUATOR, AND THE ASYMMETRY IS
|
||||
THE WHOLE ATTRIBUTION ARGUMENT. `SignWatchdog` concludes "THE UNIT I AM
|
||||
DRIVING is pushing power the wrong way" - evidence about one actuator, so
|
||||
the remedy is that actuator (arbiter.CLAIM_SCOPE calls `failsafe` a UNIT
|
||||
claim for exactly this reason). Circulating power is not a property any one
|
||||
inverter has. It is a property of a PAIR, it is defined by a sum across the
|
||||
roster, and there is no fact of the matter about which of the two is the
|
||||
offender: a unit ignoring its share and a sibling faithfully executing a
|
||||
share computed to sum with it produce identical readings. So the quantity is
|
||||
site-level, the watchdog is site-level, and the remedy below is site-level.
|
||||
|
||||
Latches, for `SignWatchdog`'s reason: the trip removes its own evidence. The
|
||||
moment the site stops commanding, the loop it detected either persists (a
|
||||
wedged unit, now visible at the meter) or vanishes (the add-on was one of
|
||||
the two participants) - and in the second case a non-latching detector would
|
||||
de-assert and start the whole thing again, three minutes at a time, forever.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: CirculationConfig | None = None, *,
|
||||
tripped: bool = False, reason: str = ""):
|
||||
self.cfg = cfg or CirculationConfig()
|
||||
self.over_since: datetime | None = None
|
||||
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED - SAFETY-34, and it is the
|
||||
# reason this card exists in the shape it does. `persistent_notification`
|
||||
# is IN MEMORY: Home Assistant restarts and the card is gone, with the
|
||||
# fault still running. That is precisely how the 2026-09-02 outage went
|
||||
# unseen for 6 h 25 m while the detector had been correct for hours. The
|
||||
# latch lives in `/data/state.json` - the add-on's only durable volume -
|
||||
# and `main.py` RE-RAISES the notification from it on every start.
|
||||
self.tripped = bool(tripped)
|
||||
self.reason = reason if self.tripped else ""
|
||||
# The worst figure seen during the current episode, for the operator
|
||||
# sentence. Not persisted: it is colour on a reason string that IS.
|
||||
self.peak_w = 0.0
|
||||
|
||||
def evaluate(self, circ_w, now: datetime) -> bool:
|
||||
"""Advance the detector one cycle. Returns True while tripped.
|
||||
|
||||
`circ_w` this cycle's `circulating_w()` over MEASURED per-unit power,
|
||||
or None when a reading is missing. None CLEARS the window
|
||||
rather than accumulating it - see the module docstring, and
|
||||
`SignWatchdog.evaluate` on the identical decision for a stale
|
||||
meter. A frozen figure held against a moving one manufactures
|
||||
a fault in about a window's worth of cycles.
|
||||
`now` the caller's clock, so this is testable and replayable.
|
||||
"""
|
||||
if self.tripped:
|
||||
# Latched. Do not re-evaluate: the trip removed the evidence.
|
||||
return True
|
||||
|
||||
if circ_w is None or float(circ_w) <= self.cfg.min_w:
|
||||
self.over_since = None
|
||||
self.peak_w = 0.0
|
||||
return False
|
||||
|
||||
circ_w = float(circ_w)
|
||||
self.peak_w = max(self.peak_w, circ_w)
|
||||
if self.over_since is None:
|
||||
self.over_since = now
|
||||
return False
|
||||
|
||||
held = (now - self.over_since).total_seconds()
|
||||
if held < self.cfg.hold_s:
|
||||
return False
|
||||
|
||||
self.tripped = True
|
||||
self.reason = (
|
||||
f"CIRCULATING_POWER: {circ_w:.0f} W measured going round between "
|
||||
f"the inverters (peak {self.peak_w:.0f} W) for {held:.0f} s "
|
||||
f"continuously - the meter cannot see it, because it nets to zero "
|
||||
f"there (limits: over {self.cfg.min_w:.0f} W, held over "
|
||||
f"{self.cfg.hold_s:.0f} s)")
|
||||
return True
|
||||
|
||||
def held_s(self, now: datetime) -> float:
|
||||
"""How long the current opposition has stood, for the status page.
|
||||
|
||||
Takes the clock rather than reading one, for `SignWatchdog.held_s`'s
|
||||
reason: every observable on this object is a pure function of what it
|
||||
was handed, and an unclocked function is one nothing can test.
|
||||
"""
|
||||
if self.over_since is None:
|
||||
return 0.0
|
||||
return (now - self.over_since).total_seconds()
|
||||
|
||||
def reset(self) -> None:
|
||||
self.over_since = None
|
||||
self.tripped = False
|
||||
self.reason = ""
|
||||
self.peak_w = 0.0
|
||||
@@ -46,8 +46,18 @@ the validation did not run or no longer holds - `hass.limits()` returns
|
||||
(None, None) whenever ESPHome is not up yet, and the range checks are skipped
|
||||
rather than guessed at - and that is a louder fault than the first stage, not a
|
||||
quieter one. It therefore alarms too, through the same channel, with the bound
|
||||
named. This is NOT SAFETY-20; nothing here changes what `freeze.py` measures its
|
||||
rail against.
|
||||
named.
|
||||
|
||||
⚠️ SAFETY-20 UPDATED THE SECOND HALF OF THAT SENTENCE. It used to read "this is
|
||||
NOT SAFETY-20; nothing here changes what `freeze.py` measures its rail against",
|
||||
and that was true of this module and false of the system: this stage is exactly
|
||||
what clips the emitted command below `RAIL_FRAC * max_w`, which is how a device
|
||||
rating disarmed the freeze detector while the page said `armed`. `freeze.py` now
|
||||
takes `dev_min`/`dev_max` and measures its rail against
|
||||
`min(max_w, |dev_min|, |dev_max|)`. THE CLAMP ITSELF IS UNCHANGED - it still
|
||||
clamps to `max_w` first and to the device range second, for the reasons above,
|
||||
and it still does not `min()` the two. What changed is only that the detector
|
||||
downstream now knows this stage exists.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHERE THE WIND ACTUALLY SHOWS UP - READ THIS BEFORE "SIMPLIFYING" THE CALLER
|
||||
|
||||
+181
-15
@@ -18,6 +18,17 @@ THE FAILURE NEEDS NO FAULT ANYWHERE ELSE. A frozen-but-talking meter is
|
||||
- there is no SoC guard on the grid-following loop at all. `soc_floor` belongs
|
||||
to the maintenance cycle; the BMS is the sole energy backstop.
|
||||
|
||||
⚠️ STILL TRUE, AND NOW FILED. SAFETY-24 added `peak_soc_floor` and
|
||||
OPT-04 added `arbitrage_soc_floor`, so the sentence above is no longer
|
||||
"there is one SoC floor and it is maintenance's" - but neither of the new
|
||||
ones binds THIS loop, and `peak_soc_floor`'s name reads as though it does.
|
||||
SAFETY-25 replayed the exposure (test_peakguard.py part 10) and recorded
|
||||
the options in DECISIONS/SAFETY-25-loop-soc-floor.md. DECIDED 2026-09-02,
|
||||
Option A: the BMS is the sole energy backstop for demand-driven discharge
|
||||
ON PURPOSE. A loop-side reserve is DEFERRED, not rejected - its revisit
|
||||
trigger is ALLOC-01. So the sentence above is a recorded decision, not an
|
||||
oversight and no longer an open question.
|
||||
|
||||
The loop reads a constant error and integrates it. Time from freeze to the rail,
|
||||
replaying the real `control.compute()` at 15 s cycles:
|
||||
|
||||
@@ -151,12 +162,77 @@ THE RAIL TERM, PRECISELY
|
||||
reason SAFETY-02 uses the emitted value: the wire carries the emitted number.
|
||||
|
||||
RAIL_FRAC is 0.95 and is a constant rather than an option. The command is
|
||||
quantised to `step_w` (max 100) and clamped to `max_w` (min 100), so at the rail
|
||||
the emitted value IS `max_w` on every schema-legal combination; the 5 % is slack
|
||||
for a device rating that clips `max_w` slightly in `write_setpoint()`, not a
|
||||
tuning knob. Making it configurable would offer a way to widen the one term that
|
||||
quantised to `step_w` (max 100), so at the rail the emitted value IS the ceiling
|
||||
on every schema-legal combination; the 5 % is quantisation slack, not a tuning
|
||||
knob. Making it configurable would offer a way to widen the one term that
|
||||
carries the whole no-false-positive argument.
|
||||
|
||||
⚠️ SAFETY-20 - AND THE CEILING IS NOT `max_w`. THE 5 % USED TO BE DESCRIBED AS
|
||||
"slack for a device rating that clips `max_w` slightly in `write_setpoint()`",
|
||||
AND THAT SENTENCE WAS THE BUG. 5 % of slack does not absorb a device rating; it
|
||||
inverts the guard. `write_setpoint()` clamps the emitted command to `max_w` AND
|
||||
THEN to the setpoint device's own `[dev_min, dev_max]` range (clamp.py,
|
||||
`clamp_to_rating()`), and the arbiter files the same range as a P_SAFETY limit
|
||||
claim, so the largest magnitude that can ever stand on the wire is
|
||||
|
||||
min(max_w, |dev_min|, |dev_max|)
|
||||
|
||||
not `max_w`. Measured on the shipped predicate at `max_w: 5000` against a device
|
||||
of +-4700 - the exact case the ticket is named for:
|
||||
|
||||
looks_frozen(unchanged_s=3600, commanded_w=4700, max_w=5000) -> False
|
||||
|
||||
The command is pinned at the hardware's rail, the meter has been frozen for an
|
||||
hour, and term 2 is unsatisfiable BY CONSTRUCTION because 4700 < 4750. Any
|
||||
device rating at or below `RAIL_FRAC * max_w` disarms the conjunction outright.
|
||||
The status page then printed `armed (...command is over 4750 W)`, which is the
|
||||
"disarmed guard that advertises itself as armed" failure validate.py rule 5 and
|
||||
SAFETY-15/19 all exist to prevent - so `rail_ceiling_w()` below takes the device
|
||||
range and the rail is measured against the smaller number.
|
||||
|
||||
WHY THE SMALLER OF THE TWO DEVICE BOUNDS, AND NOT A SIDE-AWARE PAIR. The rail is
|
||||
a MAGNITUDE test against what clamp.py calls a magnitude clamp - it binds import
|
||||
and export identically - so the ceiling here is a magnitude that is available in
|
||||
BOTH directions. The alternative considered was a per-sign rail:
|
||||
`min(max_w, dev_max)` for a positive command, `min(max_w, |dev_min|)` for a
|
||||
negative one. That is arithmetically tighter and it is NOT wrong - on a device
|
||||
range of -500..+5000 it would grade a -480 W charge command as railed, which the
|
||||
symmetric ceiling reaches only by dropping the discharge rail to 475 W too. It
|
||||
was rejected on cost, not on correctness: a per-sign rail is two rails, and two
|
||||
rails need two coverage verdicts (`dev_min == 0` is a degenerate charge rail
|
||||
whatever the discharge side can do) on a status row that carries one number and
|
||||
in a predicate that takes one magnitude. That is a bigger change than this
|
||||
defect earns, and the ONLY configuration that distinguishes the two - a device
|
||||
range narrower than `max_w` on one side - is refused at startup by CFG-01 (see
|
||||
the reachability note below). Between the two, the symmetric ceiling can only
|
||||
ever LOWER the rail, which widens term 2 rather than disarming it; term 1
|
||||
(`unchanged_s >= hold_s`) is the term carrying the no-false-positive argument,
|
||||
and it is untouched. Whoever needs the tighter version should take both status
|
||||
rows with it.
|
||||
|
||||
⚠️ AND WHEN THE CEILING IS ZERO THE RAIL IS NOT "LOW", IT IS GONE. A device that
|
||||
accepts no command in one direction at all (`dev_max == 0`, or `dev_min == 0`)
|
||||
gives a ceiling of 0 W, and `abs(commanded_w) >= 0` is TRUE FOR EVERY COMMAND
|
||||
INCLUDING 0 W. Term 2 would not be disarmed there, it would be degenerately
|
||||
true, and the detector would collapse into the bare `unchanged_s > N` threshold
|
||||
TEL-05 refused - false-tripping at the converged operating point we aim for.
|
||||
That case is reported as NOT COVERED, through the row the unsupported-transport
|
||||
case already uses, and `looks_frozen()` returns False rather than firing.
|
||||
|
||||
⚠️ HOW REACHABLE IS ANY OF THIS - READ THIS BEFORE "SIMPLIFYING" IT AWAY.
|
||||
`validate_startup_config()` rule 2 (CFG-01) already REFUSES TO START on
|
||||
`max_w > dev_max` or `-max_w < dev_min`, and `run_control()` re-runs that gate
|
||||
once ESPHome is up and the real rating is known. So on any install that reaches
|
||||
the control loop with a rating it could read, `min(max_w, |dev_min|, |dev_max|)`
|
||||
IS `max_w` and everything above is arithmetically dead. That is not an argument
|
||||
against it - it is clamp.py's argument for its own second stage, word for word:
|
||||
the case where this bites is the case where the startup gate did not run or no
|
||||
longer holds (`hass.limits()` returns (None, None) whenever ESPHome is not up,
|
||||
and the range checks are then skipped rather than guessed at, so a rating that
|
||||
arrives late is never re-read), and a guard that quietly assumes the gate ran is
|
||||
a guard that reports armed in exactly the state it cannot fire in. See AC 4's
|
||||
decision, recorded in validate.py under WHAT IS DELIBERATELY *NOT* REFUSED.
|
||||
|
||||
⚠️ NOT GATED ON `following`, and that is deliberate. A maintenance charge
|
||||
commands against the meter on purpose and can sit at the rail for hours - but it
|
||||
does so while pushing kilowatts through the meter, so the FIRST term is what
|
||||
@@ -192,6 +268,8 @@ otherwise would be worse than the gap.
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .clamp import is_commandable
|
||||
|
||||
# See "THE RAIL TERM, PRECISELY" above. A constant, on purpose.
|
||||
RAIL_FRAC = 0.95
|
||||
|
||||
@@ -203,13 +281,72 @@ class FreezeWatchdogConfig:
|
||||
hold_s: float = 300.0
|
||||
|
||||
|
||||
def rail_w(max_w) -> float:
|
||||
"""The magnitude at or above which a command counts as railed."""
|
||||
return abs(float(max_w)) * RAIL_FRAC
|
||||
def rail_ceiling_w(max_w, *, dev_min=None, dev_max=None) -> float:
|
||||
"""The largest command magnitude that can actually stand on the wire.
|
||||
|
||||
SAFETY-20, and the whole point of the ticket: this is NOT `max_w`. The
|
||||
emitted command is bounded by `max_w` AND by the setpoint device's own
|
||||
`[dev_min, dev_max]` range - twice, in `clamp_to_rating()` and again as the
|
||||
arbiter's `device` P_SAFETY limit claim - so the reachable ceiling is the
|
||||
smallest of the three magnitudes. See "THE RAIL TERM, PRECISELY" for why it
|
||||
is the smaller of the two device bounds rather than a per-sign pair, and for
|
||||
why this is arithmetically dead on any install CFG-01 let start.
|
||||
|
||||
`dev_min` / `dev_max` are `hass.limits()`'s answer and are None whenever
|
||||
ESPHome could not be reached. UNKNOWN IS NOT ZERO: a missing bound means
|
||||
this add-on applies no device clamp at all on that side (clamp.py's second
|
||||
stage is skipped unless BOTH are known), so an unknown bound must not lower
|
||||
the ceiling. `is_commandable()` is write_setpoint()'s own predicate, reused
|
||||
rather than retyped: a NaN rating must not become a 0 W ceiling.
|
||||
|
||||
Returns 0.0 when the device can command nothing - see rail_unreachable().
|
||||
"""
|
||||
ceiling = abs(float(max_w))
|
||||
for bound in (dev_min, dev_max):
|
||||
if is_commandable(bound):
|
||||
ceiling = min(ceiling, abs(float(bound)))
|
||||
return max(0.0, ceiling)
|
||||
|
||||
|
||||
def rail_unreachable(max_w, *, dev_min=None, dev_max=None) -> str:
|
||||
""""" when the rail is a real threshold, else WHY it is not. SAFETY-20 AC2.
|
||||
|
||||
The status page renders this as a NOT COVERED row - the same row, for the
|
||||
same reason, that an unsupported transport already gets. It is a second
|
||||
reason to use that surface, not a new mechanism: in both states the honest
|
||||
report is "this guard is not watching", and the one thing this project has
|
||||
decided repeatedly it will not ship is a page that says `armed` instead.
|
||||
|
||||
⚠️ THE UNREACHABLE CASE IS NOT "THE RAIL IS HIGH", IT IS "THE RAIL IS 0 W".
|
||||
A ceiling of zero makes `abs(commanded_w) >= 0` true for every command
|
||||
including a parked 0 W one, so term 2 is not disarmed but DEGENERATELY TRUE,
|
||||
and the conjunction decays into the bare `unchanged_s > N` threshold TEL-05
|
||||
refused - which false-trips at the converged operating point this controller
|
||||
aims for. Silence plus a NOT COVERED row is strictly better than that.
|
||||
"""
|
||||
if not max_w or not is_commandable(max_w):
|
||||
return (f"max_w is {max_w!r}, so there is no ceiling to measure a rail "
|
||||
f"against")
|
||||
if rail_ceiling_w(max_w, dev_min=dev_min, dev_max=dev_max) > 0.0:
|
||||
return ""
|
||||
return (f"the setpoint device reports a range of {dev_min}...{dev_max} W, "
|
||||
f"which leaves no command magnitude available in both directions, "
|
||||
f"so the rail term would be true of every command including 0 W")
|
||||
|
||||
|
||||
def rail_w(max_w, *, dev_min=None, dev_max=None) -> float:
|
||||
"""The magnitude at or above which a command counts as railed.
|
||||
|
||||
Measured against the REACHABLE ceiling, not against `max_w` - SAFETY-20.
|
||||
Callers that do not know the device range pass none and get the old
|
||||
behaviour, which is correct precisely when there is no device clamp to
|
||||
account for.
|
||||
"""
|
||||
return rail_ceiling_w(max_w, dev_min=dev_min, dev_max=dev_max) * RAIL_FRAC
|
||||
|
||||
|
||||
def looks_frozen(unchanged_s, commanded_w, max_w,
|
||||
cfg: FreezeWatchdogConfig) -> bool:
|
||||
cfg: FreezeWatchdogConfig, *, dev_min=None, dev_max=None) -> bool:
|
||||
"""The predicate, and the ONLY place the conjunction is written down.
|
||||
|
||||
Public for the same reason `watchdog.disagrees()` is: the checks that grade
|
||||
@@ -220,12 +357,28 @@ def looks_frozen(unchanged_s, commanded_w, max_w,
|
||||
`unchanged_s` seconds since the meter served a different measurement, or
|
||||
None on a transport that cannot know (see the module note).
|
||||
`commanded_w` the EMITTED setpoint standing on the wire.
|
||||
`max_w` the configured output bound the rail is measured against.
|
||||
`max_w` the configured output bound.
|
||||
`dev_min` / the setpoint device's own reported range, or None when it
|
||||
`dev_max` could not be read. SAFETY-20: the rail is measured against
|
||||
`min(max_w, |dev_min|, |dev_max|)`, because that - not
|
||||
`max_w` - is what the wire can carry.
|
||||
|
||||
⚠️ THE DEVICE BOUNDS ARE KEYWORD-ONLY AND DEFAULT TO None, WHICH IS THE
|
||||
CONSERVATIVE DIRECTION AND ALSO THE HONEST ONE. A caller that does not know
|
||||
the rating gets a rail measured against `max_w` - correct, because when this
|
||||
add-on has no rating it applies no device clamp either (clamp.py's second
|
||||
stage needs both bounds), so `max_w` really is the ceiling on that path.
|
||||
"""
|
||||
if unchanged_s is None or commanded_w is None or not max_w:
|
||||
return False
|
||||
# SAFETY-20. A ceiling of 0 W would make term 2 true of EVERY command; that
|
||||
# is the degenerate case, not the armed one. Silence, and the status page
|
||||
# says NOT COVERED - see rail_unreachable().
|
||||
if rail_unreachable(max_w, dev_min=dev_min, dev_max=dev_max):
|
||||
return False
|
||||
return (float(unchanged_s) >= cfg.hold_s
|
||||
and abs(float(commanded_w)) >= rail_w(max_w))
|
||||
and abs(float(commanded_w))
|
||||
>= rail_w(max_w, dev_min=dev_min, dev_max=dev_max))
|
||||
|
||||
|
||||
class FreezeWatchdog:
|
||||
@@ -249,24 +402,37 @@ class FreezeWatchdog:
|
||||
self.reason = ""
|
||||
self.episodes = 0
|
||||
|
||||
def evaluate(self, unchanged_s, commanded_w, max_w, now: datetime) -> bool:
|
||||
def evaluate(self, unchanged_s, commanded_w, max_w, now: datetime, *,
|
||||
dev_min=None, dev_max=None) -> bool:
|
||||
"""Advance one cycle. Returns True ONLY on the rising edge.
|
||||
|
||||
`now` is the caller's clock so this stays testable and replayable - the
|
||||
same discipline as `SignWatchdog.evaluate()`.
|
||||
same discipline as `SignWatchdog.evaluate()`. `dev_min`/`dev_max` are
|
||||
the setpoint device's reported range; see looks_frozen().
|
||||
"""
|
||||
if not looks_frozen(unchanged_s, commanded_w, max_w, self.cfg):
|
||||
if not looks_frozen(unchanged_s, commanded_w, max_w, self.cfg,
|
||||
dev_min=dev_min, dev_max=dev_max):
|
||||
self.active = False
|
||||
self.since = None
|
||||
self.reason = ""
|
||||
return False
|
||||
|
||||
# ⚠️ SAFETY-20. THE REASON QUOTES THE CEILING IT ACTUALLY USED, not
|
||||
# `max_w`. The old line said "at the 5000 W rail (... command over
|
||||
# 4750 W)" on an install whose device stopped at 4700 W, i.e. it named
|
||||
# a threshold the command could not reach - the same untruth the status
|
||||
# page was telling, printed into the log and the HA notification where
|
||||
# it would outlive the page.
|
||||
ceiling = rail_ceiling_w(max_w, dev_min=dev_min, dev_max=dev_max)
|
||||
capped = ("" if ceiling >= abs(float(max_w))
|
||||
else f", capped from max_w {float(max_w):.0f} W by the "
|
||||
f"setpoint device's own rating")
|
||||
self.reason = (
|
||||
f"METER_FROZEN: the meter has answered {float(unchanged_s):.0f} s "
|
||||
f"without its measurement changing while the command stands at "
|
||||
f"{float(commanded_w):+.0f} W, at the {float(max_w):.0f} W rail "
|
||||
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W rail{capped} "
|
||||
f"(limits: unchanged over {self.cfg.hold_s:.0f} s, command over "
|
||||
f"{rail_w(max_w):.0f} W)")
|
||||
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W)")
|
||||
if self.active:
|
||||
return False
|
||||
self.active = True
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"""One inverter, as an OBJECT rather than as a set of Controller scalars.
|
||||
|
||||
WHAT THIS FIXES, in one sentence: every piece of per-device state in this
|
||||
add-on used to be a single scalar on the Controller, and several of those
|
||||
scalars are SAFETY INPUTS - so a second inverter would have been graded against
|
||||
the first one's numbers.
|
||||
|
||||
The scalars, and what each one was silently asserting:
|
||||
|
||||
`dev_min` / `dev_max` ONE pair, read from ONE `setpoint_entity`. A second
|
||||
inverter's rating was never read at all, so its clamp,
|
||||
its freeze rail and the peak guard's `achievable_w`
|
||||
were all inverter A's.
|
||||
`soc` ONE state of charge into the maintenance FSM,
|
||||
SAFETY-24's pack floor and arbitrage's bounds. An
|
||||
average lets the emptier pack be driven below its own
|
||||
floor; a maximum parks the fuller one.
|
||||
`batt` ONE battery power into the anti-windup saturation
|
||||
detector - see the SITE/UNIT note at the bottom of
|
||||
this docstring, because this one goes the OTHER WAY.
|
||||
`sign_wd` ONE SignWatchdog, whose own docstring says "one
|
||||
instance per actuator", latched under FLAT store keys.
|
||||
A trip caused by B latched A across restarts and one
|
||||
operator action cleared both.
|
||||
`last_write` ONE write bookkeeping, and ONE serialised `hass.call()`
|
||||
behind it: a hung write to A ate B's 30 s layer-1
|
||||
budget.
|
||||
|
||||
⚠️ WHAT IS DELIBERATELY *NOT* HERE, AND IT IS THE HALF THAT IS EASY TO GET
|
||||
BACKWARDS. THERE IS NO `Tuning` ON THIS OBJECT AND THERE IS NO INTEGRATOR.
|
||||
§6.2 and ALLOC-01's stability argument are ONE integrator on P1, ABOVE the
|
||||
split: the site target S is divided into per-unit shares, so the gain seen at
|
||||
the meter is a CONVEX COMBINATION of the per-unit gains and a bad split cannot
|
||||
destabilise the site. Give each inverter its own `Tuning` and somebody will
|
||||
eventually call `compute()` with it, and the moment two integrators regulate
|
||||
one meter the whole argument is gone - that is the two-writers failure the
|
||||
arbiter exists to prevent, one level up.
|
||||
|
||||
The one field of `Tuning` that genuinely IS per-device is `max_w`, the
|
||||
supervised cap on what may be WRITTEN to one setpoint entity (§11.3), and it is
|
||||
here as `max_w`. The SITE `Tuning` the loop runs on takes its bound from the
|
||||
roster - `sum(inv.max_w)` - so at N=1 it is byte-identical to the shipped
|
||||
number and at N>=2 it is what the site can actually deliver. That is the whole
|
||||
of "the rating stops being a scalar"; a per-unit Tuning would be three more
|
||||
numbers nobody reads and one more place a limit has to be kept in step (CFG-03
|
||||
already tracks three).
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
SITE OR UNIT? THE TWO HALVES OF THE N=1 IDENTITY, AND THEY MOVE IN OPPOSITE
|
||||
DIRECTIONS
|
||||
|
||||
At N=1 §6.2's identity holds: the fabricated grid reading and the battery
|
||||
setpoint are the same number, so "the site" and "the inverter" are the same
|
||||
thing and every scalar above was RIGHT. Splitting them is not one change, it is
|
||||
two, and they point opposite ways:
|
||||
|
||||
COMMANDING becomes per-unit. One setpoint entity per inverter, one rating,
|
||||
one clamp, one watchdog, one heartbeat.
|
||||
SENSING, for the loop, becomes SITE. `compute()` regulates the METER. Its
|
||||
`actual_w` is the anti-windup input to
|
||||
`abs(prev_w - actual_w) > saturation_w`, where
|
||||
`prev_w` is the SITE target. Feed it ONE unit's
|
||||
power at a roster of two and that test compares
|
||||
a site number against a unit number, trips the
|
||||
freeze permanently and leaves a standing grid
|
||||
error.
|
||||
|
||||
That second half is MEASURED, not argued. On admin/goodwe's two-master rig
|
||||
(`tools/alloc_sim.py --splits --feedback unit`), with the loop fed one
|
||||
inverter's power the aggregate trace at the meter becomes a function of the
|
||||
split - up to 510 W apart at gain 0.2, 250 W at gain 0.4, 50 W at gain 0.6 -
|
||||
while the allocator's shares sum to S in every one of those runs. The
|
||||
counterfactual is the proof: change that ONE input to the site total and every
|
||||
split is bit-identical at every gain the schema accepts. ALLOC-01's review
|
||||
isolated it and test_alloc.py part 8 graded it as a disclosed defect waiting on
|
||||
this card.
|
||||
|
||||
So this object owns `batt` - THIS inverter's own power, which is what a
|
||||
per-unit consumer needs - and the Controller sums them into the site figure the
|
||||
loop is fed. Getting that backwards reintroduces the divergence.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
LIVENESS, AND WHY IT IS A METHOD WITH A REASON
|
||||
|
||||
`Controller.live_units()` decides which inverters may be commanded this tick,
|
||||
and `allocator.plan()` DROPS what it does not list - the survivors then carry
|
||||
the whole site target. A wrong answer there is a real change in what the site
|
||||
can deliver, so `is_live()` answers one question and says which: CAN THIS
|
||||
CONTROLLER PUT A NUMBER ON THIS INVERTER RIGHT NOW.
|
||||
|
||||
ONE way it cannot, and exactly one: every write has been REJECTED for a whole
|
||||
`LINK_STALE_S` window. Not one rejection - `hass.set_number()` returns False for
|
||||
a transient 400 as readily as for a device that has gone - but a continuous run
|
||||
of them with no acceptance in between.
|
||||
|
||||
⚠️ AND NOT "IT HAS NO `setpoint_entity`", THOUGH THAT IS ALSO A UNIT THIS
|
||||
CONTROLLER CANNOT COMMAND. The two look the same and want opposite answers. A
|
||||
link that WAS working and stopped is an event: the survivors carry its share,
|
||||
the record says so, and the site goes on running at reduced strength, which is
|
||||
the best available outcome. A roster entry with no writer configured at all is a
|
||||
CONFIGURATION that cannot be delivered - it was never reachable, nothing has
|
||||
gone wrong on the wire, and quietly running the site at part strength on a
|
||||
roster somebody wrote down wrong is how a half-driven site reads as healthy on
|
||||
every dashboard. That one belongs to main.py's refusal branch, which commands
|
||||
0 W and NAMES the inverter; putting it here would absorb it into the ordinary
|
||||
drop and delete the last trigger that branch has. See `Controller.cycle()`.
|
||||
|
||||
⚠️ AND NOT "ITS SENSORS ARE MISSING" EITHER. A pack whose SoC did not answer is
|
||||
a third fault with a third remedy: `inputs_ok` already stops the whole site at
|
||||
P_SAFETY for that, and dropping the unit from the SPLIT instead would hand its
|
||||
share to a sibling while the site kept regulating - the opposite of what a
|
||||
missing measurement should cause. Liveness is about the WRITE PATH, and only
|
||||
about a write path that has stopped answering.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from .clamp import ClampResult
|
||||
from .store import unit_key
|
||||
from .watchdog import SignWatchdog, SignWatchdogConfig
|
||||
|
||||
# How long every write to one inverter must have been REJECTED before this
|
||||
# controller stops claiming it can command it.
|
||||
#
|
||||
# ⚠️ IT IS LAYER 1'S OWN WINDOW, NOT A NEW OPINION ABOUT LINKS. The ESP32
|
||||
# commands 0 W and keeps commanding it after ~30 s without a refresh
|
||||
# (`validate.FIRMWARE_INGEST_VETO_S`), so a link this controller has not been
|
||||
# able to write for a whole 30 s is a link the hardware is about to take away
|
||||
# from it anyway. Claiming it as commandable past that point is the add-on
|
||||
# telling the allocator it can deliver watts that the firmware has already
|
||||
# decided it will not. No config key: CFG-03 tracks three places a default has
|
||||
# to be kept in step and this does not earn a fourth - it is derived from a
|
||||
# constant the firmware and validate.py already share.
|
||||
LINK_STALE_S = 30.0
|
||||
|
||||
# The name of the inverter a SINGLE-INVERTER config describes. ⚠️ A NAME, NOT A
|
||||
# CONFIGURATION - it exists so that a per-inverter claim has an actuator to be
|
||||
# filed ON, which is what makes the site/unit split something a check can grade
|
||||
# rather than a comment. It lives here rather than in main.py now that the
|
||||
# roster does.
|
||||
#
|
||||
# ⚠️ CFG-04 DID NOT MAKE THIS "THE FIRST ONE". It is the name the flat-keys path
|
||||
# gives its one unit, and nothing else; a declared `inverters:` roster names its
|
||||
# own entries and this constant is not consulted. The store migration
|
||||
# (`migrate_unit_keys`) still targets it, because the flat pre-DEV-02 keys can
|
||||
# only ever have belonged to a single-inverter install.
|
||||
UNIT_A = "inverter-a"
|
||||
|
||||
# The per-unit keys this object keeps in the Store, namespaced by unit name.
|
||||
# ⚠️ THE VALUES ARE THE PRE-DEV-02 FLAT KEY NAMES, and that is what makes the
|
||||
# migration mechanical rather than transcribed: `store.migrate_unit_keys()`
|
||||
# reads this same mapping, so a key renamed here cannot silently stop being
|
||||
# migrated. See store.py.
|
||||
UNIT_STORE_KEYS = ("sign_tripped", "sign_reason")
|
||||
|
||||
|
||||
class Inverter:
|
||||
"""ONE inverter: its entities, its rating, its watchdogs, its store keys.
|
||||
|
||||
Constructed once per configured inverter and held in `Controller.inverters`.
|
||||
Everything on it is about THIS device; nothing on it is about the site.
|
||||
|
||||
⚠️ `hass` IS NOT A CONSTRUCTOR ARGUMENT. This object is built in
|
||||
`Controller.__init__`, which runs before any I/O and, in four existing
|
||||
checks, with `hass=None` entirely. The device range is read LATER, by
|
||||
`Controller.run_control()`, from THIS unit's own `setpoint_entity` - which
|
||||
is the whole of "each instance's rating is read from its own entity".
|
||||
"""
|
||||
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
store=None, sign_cfg=None):
|
||||
self.name = str(name)
|
||||
self.setpoint_entity = str(setpoint_entity or "")
|
||||
self.soc_entity = str(soc_entity or "")
|
||||
self.batt_entity = str(batt_entity or "")
|
||||
self.batt_invert = bool(batt_invert)
|
||||
# The supervised cap on what may be written to THIS setpoint entity.
|
||||
# See the module docstring on why this is a number and not a `Tuning`.
|
||||
self.max_w = float(max_w)
|
||||
|
||||
# --- the device's own reported range, read from its own entity -------
|
||||
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
|
||||
# often not up when the add-on boots, and (None, None) is the honest
|
||||
# report that the range checks were skipped rather than invented.
|
||||
self.dev_min = None
|
||||
self.dev_max = None
|
||||
|
||||
# --- this unit's own measurements ------------------------------------
|
||||
self.soc = None
|
||||
self.batt = None
|
||||
|
||||
# --- what this unit was last told, and what it last accepted ---------
|
||||
# `target` is THIS unit's share of the site target from the last
|
||||
# resolution - the number its heartbeat re-emits and the number
|
||||
# SAFETY-02 grades against the meter. At N=1 it IS the site target,
|
||||
# because the allocator is a pass-through there.
|
||||
self.target = 0.0
|
||||
self.last_write = None
|
||||
self.last_write_at = None
|
||||
self.last_write_ok = None
|
||||
# ⚠️ TWO TIMESTAMPS, NOT ONE FLAG. `last_write_ok` is the last write's
|
||||
# verdict and it flips on a single transient 400; liveness needs a RUN
|
||||
# of rejections, so `rejected_since` records when the current run
|
||||
# started and is cleared by any acceptance.
|
||||
self.rejected_since = None
|
||||
# The heartbeat's own deadline, on the event loop's monotonic clock.
|
||||
# ⚠️ PER UNIT, WHICH IS THE WHOLE OF AC 6. One shared `last_beat` and
|
||||
# one serialised write meant a hung call to A pushed B's refresh past
|
||||
# layer 1's ~30 s veto - i.e. one inverter's transport fault zeroing a
|
||||
# healthy sibling. Starts at 0.0, which is what the single scalar did.
|
||||
self.last_beat = 0.0
|
||||
|
||||
# --- SAFETY-03's per-write evidence ----------------------------------
|
||||
# What THIS unit's last write clamped, or None before it has run. The
|
||||
# ALARM (`ClampWatchdog`) stays one per controller - clamp.py says so in
|
||||
# its own docstring, and it is an anti-flood counter over an event bus
|
||||
# rather than a property of a device - but the EVIDENCE is per unit,
|
||||
# because a clamp on B says nothing about A.
|
||||
self.clamp_last: ClampResult | None = None
|
||||
|
||||
# --- SAFETY-02, one instance per actuator, under NAMESPACED keys -----
|
||||
# ⚠️ THE WATCHDOG'S OWN DOCSTRING SAID "one instance per actuator" WHILE
|
||||
# THE CONTROLLER HELD EXACTLY ONE. Restored from THIS unit's own keys,
|
||||
# so a trip caused by B latches B and leaves A commanding - and an
|
||||
# operator clearing one does not silently clear the other.
|
||||
self.store = store
|
||||
data = getattr(store, "data", {}) if store is not None else {}
|
||||
self.sign_wd = SignWatchdog(
|
||||
sign_cfg or SignWatchdogConfig(),
|
||||
tripped=bool(data.get(self.key("sign_tripped"), False)),
|
||||
reason=str(data.get(self.key("sign_reason"), "") or ""))
|
||||
|
||||
# -- store ---------------------------------------------------------------
|
||||
def key(self, name: str) -> str:
|
||||
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
|
||||
return unit_key(self.name, name)
|
||||
|
||||
def store_set(self, name: str, value) -> None:
|
||||
if self.store is not None:
|
||||
self.store.set(self.key(name), value)
|
||||
|
||||
# -- the numbers other modules ask this object for -----------------------
|
||||
def rating_w(self) -> float:
|
||||
"""The largest magnitude this unit can be commanded, in W.
|
||||
|
||||
`min(max_w, |dev_min|, |dev_max|)` - the supervised cap and whatever the
|
||||
device itself reports, whichever is smaller. It is the same conjunction
|
||||
`freeze.rail_ceiling_w()` computes for the freeze rail and the same one
|
||||
main.py hands the peak guard as `achievable_w`, which is why it is
|
||||
written once here: three transcriptions of a rating is three chances for
|
||||
inverter B to be graded against inverter A's number.
|
||||
|
||||
A bound that could not be read is SKIPPED, not treated as zero: (None,
|
||||
None) means ESPHome was not up, and answering 0 W there would say this
|
||||
inverter can do nothing.
|
||||
"""
|
||||
out = abs(float(self.max_w))
|
||||
for bound in (self.dev_min, self.dev_max):
|
||||
if bound is not None and math.isfinite(float(bound)):
|
||||
out = min(out, abs(float(bound)))
|
||||
return out
|
||||
|
||||
def has_writer(self) -> bool:
|
||||
return bool(self.setpoint_entity)
|
||||
|
||||
# -- liveness ------------------------------------------------------------
|
||||
def note_write(self, ok: bool, now: datetime) -> None:
|
||||
"""Record one write's verdict. The only thing that moves liveness."""
|
||||
self.last_write_ok = bool(ok)
|
||||
if ok:
|
||||
self.rejected_since = None
|
||||
elif self.rejected_since is None:
|
||||
self.rejected_since = now
|
||||
|
||||
def is_live(self, now: datetime, stale_s: float = LINK_STALE_S):
|
||||
"""`(live, why_not)` - may this controller command this unit THIS TICK?
|
||||
|
||||
Read the module docstring before widening this - in particular on why a
|
||||
unit with NO `setpoint_entity` is deliberately still "live" here and is
|
||||
refused one layer up instead. The allocator DROPS what `live_units()`
|
||||
does not list and the survivors carry the whole site target, so a false
|
||||
"not live" is a site target divided among fewer inverters than exist -
|
||||
and at a roster of one it is the allocator refusing outright and the
|
||||
site commanding 0 W.
|
||||
|
||||
⚠️ ANSWERED PER TICK, NEVER CACHED, for `allocator.plan()`'s reason: a
|
||||
link goes stale between one cycle and the next.
|
||||
"""
|
||||
if self.rejected_since is not None:
|
||||
held = (now - self.rejected_since).total_seconds()
|
||||
if held >= float(stale_s):
|
||||
return False, (f"every write rejected for {held:.0f} s "
|
||||
f"(limit {float(stale_s):.0f} s)")
|
||||
return True, ""
|
||||
|
||||
def link_note(self, now: datetime) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
if not self.has_writer():
|
||||
return (f"{self.name}: NO SETPOINT ENTITY - this controller cannot "
|
||||
f"command it, and the site refuses rather than running at "
|
||||
f"part strength")
|
||||
live, why = self.is_live(now)
|
||||
if not live:
|
||||
return f"{self.name}: NOT COMMANDABLE - {why}"
|
||||
if self.rejected_since is not None:
|
||||
held = (now - self.rejected_since).total_seconds()
|
||||
return (f"{self.name}: writes rejected for {held:.0f} s - still "
|
||||
f"commandable until {LINK_STALE_S:.0f} s")
|
||||
return ""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (f"<Inverter {self.name} sp={self.setpoint_entity!r} "
|
||||
f"max_w={self.max_w:.0f} dev=({self.dev_min}, {self.dev_max})>")
|
||||
|
||||
|
||||
def roster_entries(opts: dict) -> list:
|
||||
"""The `inverters:` option, normalised to a list of dicts. Never None.
|
||||
|
||||
Separate from `build_roster()` because validate.py needs to grade the SAME
|
||||
list this builds from, and a second transcription of "what counts as an
|
||||
entry" is a second answer to it. A non-list, or a list with non-dict
|
||||
members, comes back as it was so the validator can name what it found -
|
||||
dropping the junk here would hand the validator a clean list and a config
|
||||
nobody refused.
|
||||
"""
|
||||
entries = opts.get("inverters")
|
||||
if entries is None or entries == "":
|
||||
return []
|
||||
if not isinstance(entries, (list, tuple)):
|
||||
return [entries]
|
||||
return list(entries)
|
||||
|
||||
|
||||
def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
"""The site's inverters, in roster order.
|
||||
|
||||
TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is
|
||||
`inverters:` empty?
|
||||
|
||||
EMPTY the five flat keys - `setpoint_entity`, `soc_entity`,
|
||||
`batt_entity`, `batt_invert`, `max_w` - describe the ONE inverter,
|
||||
exactly as the shipped scalars did. This is what every install
|
||||
that upgrades into this version reads, without an operator
|
||||
touching anything, and it is byte-identical to what DEV-02
|
||||
shipped. config.yaml's default is `[]` for precisely that reason,
|
||||
and Supervisor 2026.08.0 refuses the whole options blob without
|
||||
it - see the options-block note on `inverters:`.
|
||||
|
||||
NON-EMPTY each entry describes one inverter and the flat keys describe
|
||||
none of them. ⚠️ NOTHING IS INHERITED. An entry that omits
|
||||
`soc_entity` has no SoC sensor; it does NOT borrow the flat one,
|
||||
because "this unit's number came from somewhere else" is the
|
||||
defect DEV-02 spent a card removing and a fallback here would
|
||||
rebuild it in three lines. `name`, `setpoint_entity` and `max_w`
|
||||
are required of every entry - the schema enforces it on the
|
||||
Supervisor path, `validate.py` rule 9 enforces it for sim/inject.py
|
||||
and the checks, which reach this function with any dict at all.
|
||||
|
||||
⚠️ THIS FUNCTION REFUSES NOTHING, AND THAT IS ON PURPOSE. A refusal here
|
||||
happens inside `Controller.__init__`, before the startup gate has run and
|
||||
before anything can render the reason to an operator; validate.py's rule 9
|
||||
is the place that says no, with a message, through `refuse()`. So this
|
||||
builds the most honest roster it can out of whatever it was handed - a
|
||||
missing name becomes a positional one, a missing rating becomes 0.0 W - and
|
||||
every one of those states is something rule 9 has already refused by the
|
||||
time a control loop exists. Keep the two in step: a shape this tolerates
|
||||
that rule 9 does not name is a shape that runs.
|
||||
"""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
# ⚠️ THE UNCHANGED PATH. Same name, same entities, same rating, same
|
||||
# store keys as before CFG-04 - an install that upgrades reads
|
||||
# identically, which is AC 3.
|
||||
return [Inverter(
|
||||
UNIT_A,
|
||||
setpoint_entity=opts.get("setpoint_entity", ""),
|
||||
soc_entity=opts.get("soc_entity", ""),
|
||||
batt_entity=opts.get("batt_entity", ""),
|
||||
batt_invert=bool(opts.get("batt_invert")),
|
||||
max_w=float(opts.get("max_w", 2000)),
|
||||
store=store, sign_cfg=sign_cfg)]
|
||||
|
||||
roster = []
|
||||
for i, entry in enumerate(entries):
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
# ⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries
|
||||
# both called `inverter-a` would share one Store namespace, so one
|
||||
# unit's SAFETY-02 latch would be the other's - a fault on B latching A
|
||||
# is the exact failure DEV-02's namespaced keys removed. Rule 9 refuses
|
||||
# a nameless entry outright; this only decides what the refusal is
|
||||
# allowed to call it in the meantime.
|
||||
name = str(entry.get("name") or "").strip() or f"inverter-{i + 1}"
|
||||
roster.append(Inverter(
|
||||
name,
|
||||
setpoint_entity=entry.get("setpoint_entity", ""),
|
||||
soc_entity=entry.get("soc_entity", ""),
|
||||
batt_entity=entry.get("batt_entity", ""),
|
||||
batt_invert=bool(entry.get("batt_invert")),
|
||||
# ⚠️ NOT `opts["max_w"]`. See the docstring: a rating an entry did
|
||||
# not state is not the flat one, it is a rating rule 9 refuses.
|
||||
max_w=_entry_max_w(entry),
|
||||
store=store, sign_cfg=sign_cfg))
|
||||
return roster
|
||||
|
||||
|
||||
def _entry_max_w(entry: dict) -> float:
|
||||
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
|
||||
|
||||
0.0 rather than a borrowed number, and rather than an exception: rule 9
|
||||
refuses `max_w` missing or unparseable by name, and a roster that cannot be
|
||||
built at all would refuse it with a traceback instead.
|
||||
"""
|
||||
try:
|
||||
return float(entry.get("max_w"))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
+1803
-190
File diff suppressed because it is too large
Load Diff
@@ -38,17 +38,112 @@ CHARGE_TIMEOUT_MIN = 2160
|
||||
# After an abort, do not immediately re-trigger in the same start hour.
|
||||
RESTART_COOLDOWN_MIN = 90
|
||||
|
||||
# ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR EVERY `maintenance_*`
|
||||
# OPTION, and the only place in the add-on where one of these numbers is typed.
|
||||
#
|
||||
# WHAT WENT WRONG WITHOUT IT. Until this card each default was written down
|
||||
# three times - config.yaml's `options:` block, an `opts.get(key, <literal>)`
|
||||
# fallback in main.py, and this dataclass - and MAINT-04 proved they had already
|
||||
# drifted: config.yaml said 2000, the other two said 2500. An options dict with
|
||||
# `maintenance_charge_w` ABSENT was therefore ACCEPTED by validate.py's rule 6
|
||||
# (which read 2000) and then BUILT by main.py at 2500, against a 2000 W ceiling
|
||||
# - the exact `lo > hi` contradiction rule 6 exists to refuse, resolving to 0 W
|
||||
# with `contradiction=True`. Unreachable through the Supervisor, which always
|
||||
# supplies every key; fully reachable from sim/inject.py and from the test
|
||||
# suite, which is where anyone debugging maintenance actually is.
|
||||
#
|
||||
# WHERE THE SINGLE SOURCE ACTUALLY IS: config.yaml's `options:` block. It is
|
||||
# what the Supervisor reads and what the operator edits, so it wins by
|
||||
# definition. This dict is a MIRROR of it and not a second opinion:
|
||||
# test_maintenance.py pins every entry by READING the shipped file, so a drift
|
||||
# goes red in CI instead of shipping. That is TARIFF-02's `PRICE_OPTION_DEFAULTS`
|
||||
# and SAFETY-24's `PEAK_OPTION_DEFAULTS` pattern, now applied to the family that
|
||||
# named the card. A mirror rather than a runtime read because the Dockerfile
|
||||
# copies `app/` and `run.sh` into the image and NOT config.yaml - the add-on
|
||||
# cannot open the file it is configured by, so there is nothing to read at 03:00
|
||||
# on a client's Pi, and a parser that could fail at import is a new way for the
|
||||
# add-on not to start.
|
||||
#
|
||||
# ⚠️ DEFAULTS ONLY. NO BOUNDS LIVE HERE, and that is a decision, not an
|
||||
# omission. The RANGE of each key (`maintenance_charge_w: int(500,5000)`) lives
|
||||
# in config.yaml's `schema:` block and nowhere else, because the Supervisor is
|
||||
# what enforces it and the Supervisor reads only that file. What this module
|
||||
# would gain by mirroring the bounds too is a fifth place to disagree with.
|
||||
# validate.py's rule 6 is NOT a range and does not belong here either: it is a
|
||||
# relation BETWEEN TWO KEYS (`maintenance_charge_w` against `max_w`) that the
|
||||
# schema grammar cannot express at all, which is the whole reason that rule is
|
||||
# written in Python.
|
||||
#
|
||||
# ⚠️ KEYED BY THE OPTION NAME, not by the MaintConfig field name. The dict is
|
||||
# what `from_options()` indexes while reading the operator's dict, so the two
|
||||
# strings have to be the same string; a `charge_w` key here would reintroduce a
|
||||
# transcription step between config.yaml and the reader.
|
||||
MAINT_OPTION_DEFAULTS = {
|
||||
"maintenance_enabled": False,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_start_hour": 10,
|
||||
# ⚠️ 2000, NOT THE 2500 THIS SHIPPED AS, and config.yaml carries the
|
||||
# argument: both rates must stay at or below `max_w` (2000) or the
|
||||
# cheap-window charge shaping asks the arbiter for `hi = -charge_w` against
|
||||
# a supervised `lo = -max_w` and contradicts itself into 0 W. Raising max_w
|
||||
# is what unlocks a faster cycle - raise it FIRST, then these.
|
||||
"maintenance_discharge_w": 2000,
|
||||
"maintenance_charge_w": 2000,
|
||||
"maintenance_soc_floor": 11,
|
||||
"maintenance_soc_target": 99,
|
||||
"maintenance_hold_min": 120,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MaintConfig:
|
||||
enabled: bool = False
|
||||
interval_days: int = 28
|
||||
start_hour: int = 10
|
||||
discharge_w: float = 2500.0
|
||||
charge_w: float = 2500.0
|
||||
soc_floor: float = 11.0
|
||||
soc_target: float = 99.0
|
||||
hold_min: int = 120
|
||||
"""The maintenance cycle's settings, already parsed.
|
||||
|
||||
⚠️ EVERY DEFAULT IS READ OUT OF `MAINT_OPTION_DEFAULTS`, never typed. A
|
||||
literal in this class is what made `MaintConfig()` a THIRD opinion about
|
||||
what a missing key means - see the block above - so `MaintConfig()` and
|
||||
`MaintConfig.from_options({})` and validate.py's fallback are now
|
||||
arithmetically the same number by construction rather than by vigilance.
|
||||
"""
|
||||
|
||||
enabled: bool = bool(MAINT_OPTION_DEFAULTS["maintenance_enabled"])
|
||||
interval_days: int = int(MAINT_OPTION_DEFAULTS["maintenance_interval_days"])
|
||||
start_hour: int = int(MAINT_OPTION_DEFAULTS["maintenance_start_hour"])
|
||||
discharge_w: float = float(MAINT_OPTION_DEFAULTS["maintenance_discharge_w"])
|
||||
charge_w: float = float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"])
|
||||
soc_floor: float = float(MAINT_OPTION_DEFAULTS["maintenance_soc_floor"])
|
||||
soc_target: float = float(MAINT_OPTION_DEFAULTS["maintenance_soc_target"])
|
||||
hold_min: int = int(MAINT_OPTION_DEFAULTS["maintenance_hold_min"])
|
||||
|
||||
@classmethod
|
||||
def from_options(cls, opts: dict) -> "MaintConfig":
|
||||
"""Build from an add-on options dict.
|
||||
|
||||
⚠️ THE ONLY PLACE IN THE ADD-ON THAT READS A `maintenance_*` OPTION.
|
||||
main.py used to do this inline as eight `opts.get(key, <literal>)`
|
||||
calls, which is eight opportunities to type a number that config.yaml
|
||||
does not ship - and one of them had already been taken. Moving the read
|
||||
next to the mirror means the fallback cannot be anything but the mirror,
|
||||
because there is no dict literal in sight of it to disagree with.
|
||||
|
||||
⚠️ `key in opts`, not `opts.get(key) or ...`. A shipped `false` and a
|
||||
shipped `0` are values an operator can legitimately set, and the truthy
|
||||
form would quietly replace both with the default - which is this card's
|
||||
own bug wearing a different hat.
|
||||
"""
|
||||
def _opt(key):
|
||||
return opts[key] if key in opts else MAINT_OPTION_DEFAULTS[key]
|
||||
|
||||
return cls(
|
||||
enabled=bool(_opt("maintenance_enabled")),
|
||||
interval_days=int(_opt("maintenance_interval_days")),
|
||||
start_hour=int(_opt("maintenance_start_hour")),
|
||||
discharge_w=float(_opt("maintenance_discharge_w")),
|
||||
charge_w=float(_opt("maintenance_charge_w")),
|
||||
soc_floor=float(_opt("maintenance_soc_floor")),
|
||||
soc_target=float(_opt("maintenance_soc_target")),
|
||||
hold_min=int(_opt("maintenance_hold_min")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -43,6 +43,19 @@ SENSORS = [
|
||||
("grid", "goodwe_grid_power", "Grid power", "W", "power", "measurement", None),
|
||||
("battery", "goodwe_battery_power", "Battery power", "W", "power", "measurement", None),
|
||||
("soc", "goodwe_battery_soc", "Battery SoC", "%", "battery", "measurement", None),
|
||||
# SAFETY-31. `sum(|Bi|) - |sum(Bi)|` over MEASURED per-inverter power: the
|
||||
# power going round between the inverters, which the grid sensor above
|
||||
# CANNOT SHOW because it nets to zero there. The two are published side by
|
||||
# side on purpose - "grid 0 W, circulating 6000 W" is the whole phenomenon
|
||||
# in two rows of a dashboard, and no arithmetic on the grid figure alone can
|
||||
# ever produce the second one.
|
||||
#
|
||||
# ⚠️ Published as 0 at the shipped one-inverter roster, not omitted, and
|
||||
# that is a deliberate difference from `p1_age` below. An absent entity is
|
||||
# right for a watchdog input nothing is feeding; this is a quantity whose
|
||||
# value is KNOWN at N=1 - identically zero, for every reading - and a
|
||||
# dashboard row reading 0 W is the honest report of it.
|
||||
("circulation", "goodwe_circulating_power", "Circulating power", "W", "power", "measurement", "mdi:sync-alert"),
|
||||
("phase", "goodwe_maintenance_phase", "Maintenance phase", None, None, None, "mdi:battery-sync"),
|
||||
("status", "goodwe_controller_status", "Controller status", None, None, None, "mdi:heart-pulse"),
|
||||
# ⚠️ THE LIVE ENTITY IS `sensor.goodwe_p1_sample_age`, NOT what this
|
||||
|
||||
@@ -286,6 +286,25 @@ the one place the question is answered. Four things about the shape of it:
|
||||
discharge, which is the thing SAFETY-07 introduced. A loop-side SoC guard
|
||||
is a different lever with a different right value.
|
||||
|
||||
⚠️ SAFETY-25 MEASURED HOW FAR PAST, so this is no longer a caveat that has
|
||||
to be taken on trust. On the committed 2026-08-20 capture, 19:00-22:00 UTC
|
||||
- three hours of house load after the sun has gone, 830 W mean, zero export
|
||||
- the 13.8 kWh reference pack started one point ABOVE `peak_soc_floor`
|
||||
ends the evening at 10.0 %, i.e. at `bms_soc_floor`, having delivered
|
||||
1525 Wh; 343 of 587 control cycles deliver discharge with the pack already
|
||||
under this floor, and the guard files no floor at all on any of them. The
|
||||
same replay at `peak_soc_floor` = 5, 11, 20 and 60 ends at the SAME 10.0 %,
|
||||
which is the finding stated as sharply as it can be: THE EXPOSURE IS A
|
||||
PROPERTY OF THE CONTROL LAW, NOT A VALUE ANYONE CAN CONFIGURE. And on the
|
||||
same day's high-load block the two states coexist - 49 cycles reporting
|
||||
PEAK GUARD FLOOR-LIMITED while the arbiter grants the loop up to 2000 W of
|
||||
discharge in every one of them. test_peakguard.py part 10 is the replay;
|
||||
DECISIONS/SAFETY-25-loop-soc-floor.md carries the options and the
|
||||
recommendation, and the product owner DECIDED it on 2026-09-02: Option A,
|
||||
the BMS stays the only backstop for demand-driven discharge, with a
|
||||
loop-side reserve DEFERRED to ALLOC-01. This floor's scope is therefore
|
||||
settled rather than provisional - it bounds the peak guard, on purpose.
|
||||
|
||||
3. IT IS GRADED ONLY WHERE IT IS NOT DEGENERATE. The SoC question is asked
|
||||
AFTER `wanted > 0` is established - i.e. only where a forced discharge is
|
||||
actually being asked for. Asked earlier it would grade a quantity that
|
||||
@@ -353,7 +372,7 @@ not need re-testing. Nothing here indexes a phase.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .arbiter import P_PEAK, Claim
|
||||
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
|
||||
from .control import peak_at_risk
|
||||
from .tariff import QUARTER_S
|
||||
|
||||
@@ -373,6 +392,12 @@ CEILING_MONTH_PEAK = "the month's banked peak"
|
||||
# notification all have to say WHICH, and "floor-limited" alone is unactionable.
|
||||
FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this "
|
||||
"cycle, and an unreadable SoC is not a full battery")
|
||||
# ⚠️ SAFETY-25. "the peak guard's SoC floor", not "the SoC floor", and the
|
||||
# possessive is load-bearing: this string is what the status page, the action log
|
||||
# and the operator notification all print, and `peak_soc_floor` bounds THIS GUARD
|
||||
# ONLY. Widening it to "the SoC floor" would make the one sentence an operator
|
||||
# reads at the moment the battery stops shaving into a claim about the pack that
|
||||
# the grid-following loop is at that instant disproving.
|
||||
FLOOR_AT_SOC_FLOOR = "state of charge is at or below the peak guard's SoC floor"
|
||||
|
||||
SOURCE = "peak"
|
||||
@@ -719,7 +744,14 @@ def claim(decision: PeakDecision) -> Claim | None:
|
||||
# when the battery stops charging. Spelling them the same way would make the
|
||||
# SAFETY-24 case indistinguishable from the SAFETY-18 one on the one line
|
||||
# that gets read.
|
||||
# ⚠️ ON THE SITE ACTUATOR, EXPLICITLY. The billed quantity is the quarter's
|
||||
# average offtake AT THE GRID CONNECTION - one per site, whatever the site
|
||||
# has behind it - so this floor bounds the site target and is divided by the
|
||||
# allocator along with it. It is never a bound on one unit's share: SAFETY-25
|
||||
# §0c's table has this `lo` at the SITE stage precisely so the deferred loop
|
||||
# reserve's per-inverter `hi` cannot become its counterpart in a `lo > hi`.
|
||||
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
|
||||
reason=("blind" if decision.blind
|
||||
else "SoC floor" if decision.floor_limited
|
||||
else "quarter peak"))
|
||||
else "quarter peak"),
|
||||
actuator=ACTUATOR_SITE)
|
||||
|
||||
@@ -39,6 +39,38 @@ _LOG = logging.getLogger("goodwe.store")
|
||||
# commanding -1000, -2000 W before re-tripping 180 s later. Under a supervisor
|
||||
# that restarts a crashed add-on that is a loop, three minutes of wrong-direction
|
||||
# power at a time, forever.
|
||||
#
|
||||
# ⚠️ AND SINCE DEV-02 THE TWO SAFETY-02 KEYS ARE THE *LEGACY* SPELLING. They are
|
||||
# FLAT - one `sign_tripped` for a controller that now holds a LIST of inverters -
|
||||
# so a trip caused by inverter B would latch inverter A across restarts, and one
|
||||
# operator action would clear both. The LIVE latch is per inverter, under
|
||||
# `unit/<name>/...`, and that is the one the watchdog is restored from.
|
||||
#
|
||||
# ⚠️ THE FLAT PAIR IS NOT DEAD, THOUGH: it is the ROLLBACK VIEW. It is written
|
||||
# alongside the namespaced keys on a trip and cleared alongside them on the
|
||||
# operator's clear, so an add-on that is DOWNGRADED past this schema still finds
|
||||
# the latch it should - and so that a trip taken while the install was rolled
|
||||
# back is still visible to the version that comes back up. Rule 2 of
|
||||
# `migrate_unit_keys()` and `Controller._grade_store_migration()` carry the
|
||||
# argument; the short version is that a latch is only worth persisting if it
|
||||
# survives the version change an operator makes while staring at the banner.
|
||||
#
|
||||
# ⚠️ AND SAFETY-31's PAIR IS FLAT ON PURPOSE, WHICH IS THE OPPOSITE DECISION TO
|
||||
# THE ONE ABOVE AND NEEDS ITS OWN SENTENCE. `circ_tripped` / `circ_reason` are
|
||||
# NOT in `LEGACY_UNIT_KEYS` and must never be moved into a unit namespace.
|
||||
# Circulating power is `sum(|B_i|) - |sum(B_i)|` - a quantity DEFINED over the
|
||||
# whole roster, which is identically zero for any single unit. 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 read identically. Namespacing this latch would mean inventing an
|
||||
# attribution the measurement cannot support, and would then let an operator
|
||||
# clear "inverter B's" half of a site-wide loop. One site, one latch.
|
||||
#
|
||||
# It is persisted for SAFETY-34's reason, which this add-on has already paid
|
||||
# for once: `persistent_notification` is IN MEMORY. Home Assistant restarts and
|
||||
# the card is gone while the fault runs on - exactly how the 2026-09-02 outage
|
||||
# went unseen for 6 h 25 m with the detector correct throughout. main.py
|
||||
# re-raises the notification from this key on every start.
|
||||
DEFAULTS = {
|
||||
"phase": "idle",
|
||||
"phase_started": None,
|
||||
@@ -46,8 +78,192 @@ DEFAULTS = {
|
||||
"last_start_attempt": None,
|
||||
"sign_tripped": False,
|
||||
"sign_reason": "",
|
||||
"circ_tripped": False,
|
||||
"circ_reason": "",
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DEV-02: PER-INVERTER KEYS, AND THE MIGRATION IS THE RISKY HALF
|
||||
#
|
||||
# ⚠️ A LATCH THAT SILENTLY FAILS TO MIGRATE IS A DISARMED SAFETY GUARD ON AN
|
||||
# INSTALL THAT BELIEVES IT IS PROTECTED. SAFETY-02's whole reason for being
|
||||
# persisted is that "restart the add-on" is the FIRST thing an operator staring
|
||||
# at a FAILSAFE banner tries, and a latch held only in memory comes back armed,
|
||||
# into the fault, unattended. A latch that is written down under a key nothing
|
||||
# reads any more is exactly the same failure with a longer paper trail: the file
|
||||
# still says `sign_tripped: true`, the banner is green, and the controller
|
||||
# resumes driving the wrong way.
|
||||
#
|
||||
# So this is a MIGRATION WITH A POSTCONDITION, not a copy. `migrate_unit_keys()`
|
||||
# reports what it did AND reads the result back; `Controller.__init__` grades
|
||||
# that report and refuses to come up quietly if the latch did not survive. The
|
||||
# test that grades it BREAKS the migration and watches the guard fire.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# 0 = the flat, one-inverter layout that shipped before DEV-02.
|
||||
# 1 = per-inverter keys under `unit/<name>/...`.
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_KEY = "schema_version"
|
||||
|
||||
# Which flat keys belong to ONE inverter, and therefore move into its namespace.
|
||||
# ⚠️ THE MAINTENANCE KEYS ARE NOT IN HERE AND THAT IS DELIBERATE. `phase`,
|
||||
# `phase_started`, `last_completed` and `last_start_attempt` belong to the
|
||||
# maintenance state machine, of which this add-on still holds exactly ONE - a
|
||||
# monthly recalibration is a pack procedure and per-pack SCHEDULING is a card of
|
||||
# its own, not something to infer from a key rename. Moving them here would
|
||||
# quietly re-home an install's whole maintenance history under a unit name; they
|
||||
# stay where they are, untouched, which is the other half of "migrates without
|
||||
# losing its latch OR ITS HISTORY".
|
||||
LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")
|
||||
|
||||
|
||||
def unit_key(unit: str, key: str) -> str:
|
||||
"""The namespaced Store key for `key` on inverter `unit`.
|
||||
|
||||
⚠️ ONE SPELLING, IN ONE PLACE. `Inverter.key()` calls this and so does the
|
||||
migration, so a check can assert that what was migrated is what is read.
|
||||
Two transcriptions of a key name is one copy plus a future disagreement
|
||||
about which one holds the latch - and the copy that drifts is always the one
|
||||
that reads.
|
||||
"""
|
||||
return f"unit/{unit}/{key}"
|
||||
|
||||
|
||||
class MigrationReport:
|
||||
"""What `migrate_unit_keys()` did, and whether it can be believed.
|
||||
|
||||
⚠️ IT CARRIES `verified`, WHICH IS READ BACK FROM THE STORE RATHER THAN
|
||||
ASSUMED FROM THE ASSIGNMENT. That is the difference between this and a
|
||||
two-line copy: the caller is able to ask "is the latch that was in the file
|
||||
before now under the key the watchdog reads", and a `False` there is a
|
||||
disarmed guard, which is an ERROR and not a log line nobody sees.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.ran = False # did anything need moving
|
||||
self.from_version = 0
|
||||
self.moved = {} # new key -> value written
|
||||
self.kept = [] # keys already namespaced, left alone
|
||||
self.verified = True # every move read back equal
|
||||
self.problems = [] # human-readable, one per failed move
|
||||
# ⚠️ THE SUBSET THAT MATTERS, AND THE DISTINCTION IS NOT PEDANTRY. A
|
||||
# store that drops a write of `sign_tripped=False` has lost the
|
||||
# DEFAULT - the same statement "absent" already makes, so nothing is
|
||||
# gone. One that drops a write of `True` has lost A LATCH. Only the
|
||||
# second is a disarmed safety guard, and only the second may be allowed
|
||||
# to shout: an alarm that fires on the harmless case is an alarm an
|
||||
# operator learns to dismiss before the real one arrives.
|
||||
self.lost = []
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""Nothing that CARRIED STATE failed to land."""
|
||||
return not self.lost
|
||||
|
||||
def explain(self) -> str:
|
||||
if not self.ran:
|
||||
return (f"store already at schema {self.from_version}; no "
|
||||
f"per-inverter migration needed")
|
||||
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
|
||||
f"namespaces: " + ", ".join(sorted(self.moved)))
|
||||
if self.kept:
|
||||
out += " (left alone, already namespaced: " + ", ".join(sorted(self.kept)) + ")"
|
||||
if self.problems:
|
||||
out += " - PROBLEMS: " + "; ".join(self.problems)
|
||||
if self.lost:
|
||||
out += " - STATE LOST: " + "; ".join(self.lost)
|
||||
return out
|
||||
|
||||
|
||||
def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
"""Move the pre-DEV-02 flat per-inverter keys into `unit`'s namespace.
|
||||
|
||||
`unit` is the FIRST inverter in the roster, which on every install that can
|
||||
possibly have a flat key is the only one it ever had. A store written by a
|
||||
version that knew about more than one inverter is already at
|
||||
SCHEMA_VERSION and is not touched.
|
||||
|
||||
THE THREE RULES, each one a case that would otherwise be a wrong answer:
|
||||
|
||||
1. IT NEVER OVERWRITES AN EXISTING NAMESPACED VALUE. A store that has both
|
||||
spellings has been written by this version, and the namespaced one is
|
||||
where the WATCHDOG is restored from. Preferring the flat one HERE would
|
||||
resurrect a trip the operator has already cleared, because a migration
|
||||
runs before anything can tell the two apart. Deciding between them is
|
||||
main.py's job and it has the one rule that can: the flat key carries new
|
||||
information only when NOTHING namespaced is latched.
|
||||
2. THE FLAT KEYS ARE LEFT IN THE FILE, AND THEY ARE KEPT LIVE. Deleting
|
||||
them buys tidiness and costs a rollback: an operator who downgrades the
|
||||
add-on after a migration would find no latch at all, which is the
|
||||
disarmed guard again in the other direction. Left in place, a downgrade
|
||||
finds the trip it had.
|
||||
|
||||
⚠️ AND "LEFT IN PLACE" IS NOT ENOUGH ON ITS OWN, WHICH IS WHAT THIS RULE
|
||||
USED TO SAY. The sentence here read "they go stale from this moment -
|
||||
nothing writes them again - and the direction that staleness fails in is
|
||||
a SPURIOUS latch, never a missing one". That was true of the latch this
|
||||
migration MOVES and false of every trip that happens AFTERWARDS: nothing
|
||||
flat was written for those, so a rollback found `sign_tripped: false` and
|
||||
came up DRIVING. So main.py mirrors both keys on a trip and clears both
|
||||
on the operator's clear. The flat pair is the ROLLBACK VIEW of the latch,
|
||||
maintained, not a fossil - which is also what lets main.py's
|
||||
`_grade_store_migration()` run on every start and catch a trip that
|
||||
happened while the install was rolled back (see its docstring: the flat
|
||||
key saying tripped while no inverter came up tripped is the one
|
||||
disagreement that can occur, and it is a DOWNGRADE-WINDOW LATCH).
|
||||
3. THE VERSION MARKER IS WRITTEN LAST. A crash between the copies and the
|
||||
marker leaves the migration to run again, and rule 1 makes running it
|
||||
again a no-op. A marker written first would leave a half-migrated store
|
||||
looking finished.
|
||||
"""
|
||||
rep = MigrationReport()
|
||||
data = getattr(store, "data", {})
|
||||
rep.from_version = int(data.get(SCHEMA_KEY, 0) or 0)
|
||||
if rep.from_version >= SCHEMA_VERSION:
|
||||
return rep
|
||||
|
||||
rep.ran = True
|
||||
for legacy in LEGACY_UNIT_KEYS:
|
||||
target = unit_key(unit, legacy)
|
||||
if target in data:
|
||||
rep.kept.append(target)
|
||||
continue
|
||||
# ⚠️ `DEFAULTS[legacy]` RATHER THAN A LITERAL. An install that never
|
||||
# tripped has no `sign_tripped` in its file at all, and the value it
|
||||
# then lands on has to be the one `Store.load()` would have supplied -
|
||||
# otherwise "absent" and "never tripped" stop being the same statement,
|
||||
# which is the distinction store.py's own header is about.
|
||||
value = data.get(legacy, DEFAULTS.get(legacy))
|
||||
store.set(target, value)
|
||||
rep.moved[target] = value
|
||||
|
||||
# THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION. This
|
||||
# is the difference between this function and a two-line copy: the caller
|
||||
# can ask "is the latch that was in the file before now under the key the
|
||||
# watchdog reads", through the same `unit_key()` the watchdog's constructor
|
||||
# reads through, and a `False` there is a guard that is off on an install
|
||||
# whose state file says it is on.
|
||||
for target, value in rep.moved.items():
|
||||
got = getattr(store, "data", {}).get(target, "<<missing>>")
|
||||
if got == value:
|
||||
continue
|
||||
rep.verified = False
|
||||
note = f"{target} was written as {value!r} and reads back as {got!r}"
|
||||
rep.problems.append(note)
|
||||
legacy = target.rsplit("/", 1)[-1]
|
||||
if value != DEFAULTS.get(legacy):
|
||||
rep.lost.append(note)
|
||||
|
||||
if rep.ok:
|
||||
store.set(SCHEMA_KEY, SCHEMA_VERSION)
|
||||
else:
|
||||
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
|
||||
# next start rather than be recorded as finished - the one thing worse
|
||||
# than a failed migration is a failed migration a later version skips.
|
||||
_LOG.error("per-inverter store migration LOST STATE and has not been "
|
||||
"marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: str = "/data/state.json"):
|
||||
|
||||
@@ -150,6 +150,32 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
permits equality because equality there leaves one reachable point that
|
||||
works; here it leaves none.
|
||||
|
||||
9. An `inverters:` roster that cannot mean what it says. [(a) and (b)]
|
||||
CFG-04's five cases are enumerated on `_roster_problems()`. Three of them
|
||||
are (b) - an entry with no `setpoint_entity`, or no rating, is a unit that
|
||||
takes a share of the site target every cycle and delivers none of it, on a
|
||||
status page that reports a site running at full strength. Two are (a): two
|
||||
entries sharing a name share one Store namespace, so one unit's SAFETY-02
|
||||
latch IS the other's and an operator clearing one clears both; and a flat
|
||||
`setpoint_entity` left set beside a declared roster is an actuator the
|
||||
configuration names and nothing writes.
|
||||
|
||||
⚠️ RULES 5 AND 6 CHANGED SHAPE FOR THIS, and that is the part worth
|
||||
reading. Both compared a threshold against the flat `max_w`, which was
|
||||
right while there was exactly one inverter and is a PERMISSIVE READER the
|
||||
moment there are two: `max_w: 5000` left over from a single-inverter
|
||||
install, beside a roster of two 2000 W units, passed rule 5 at
|
||||
`sign_disagree_w: 3000` while the watchdog could fire on NEITHER unit -
|
||||
rule 5's own failure mode, reached through rule 5. They now read
|
||||
`unit_caps()`, which is the roster's caps and, at one inverter, is
|
||||
`[max_w]` exactly.
|
||||
|
||||
What rule 9 deliberately does NOT do is grade a rating against a device.
|
||||
`dev_min`/`dev_max` here are one device's range and there are now N of
|
||||
them, none of which has answered at startup. `validate_unit_ranges()` is
|
||||
the per-unit form and main.py runs it from `run_control()`, once every
|
||||
unit's own ESPHome has replied.
|
||||
|
||||
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
|
||||
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
|
||||
@@ -170,6 +196,29 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
- `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the
|
||||
clamp names itself in the arbiter's explain() - see rule 6. Warned about,
|
||||
not refused.
|
||||
- A device rating that makes SAFETY-18's freeze rail unreachable. SAFETY-20's
|
||||
AC 4, decided here rather than left implicit: **NO NEW RULE, and rule 2 is
|
||||
the reason.** The configuration the ticket is about is `max_w` above the
|
||||
setpoint device's rating, which is rule 2 EXACTLY - already refused, on the
|
||||
identical predicate, since CFG-01, and re-run by `run_control()` once
|
||||
ESPHome is up and the real rating is known. A second rule phrased as
|
||||
"`RAIL_FRAC * max_w` exceeds the device rating" would fire on a strict
|
||||
subset of the configurations rule 2 already refuses (`0.95 * max_w > dev_max`
|
||||
implies `max_w > dev_max`), so it could never be the rule that stopped a
|
||||
start, and its whole visible effect would be a second entry in a refusal
|
||||
list that already names the problem.
|
||||
|
||||
⚠️ AND IT WOULD NOT COVER THE CASE THAT REMAINS, WHICH IS WHY THE STATUS ROW
|
||||
IS NOT A CONSOLATION PRIZE. The residue after rule 2 is the state where
|
||||
`hass.limits()` answered `(None, None)` - ESPHome not up when the add-on
|
||||
booted - so the range checks were SKIPPED rather than guessed at, and the
|
||||
rating is never re-read afterwards. There is nothing to refuse at startup in
|
||||
that state, by construction: the number a refusal would need does not exist
|
||||
yet. That is a runtime coverage question, and the runtime surface -
|
||||
`freeze.rail_unreachable()` and the NOT COVERED row it feeds - is where it
|
||||
is answered. Compare rule 5, which CAN be settled at startup because both
|
||||
`sign_disagree_w` and `max_w` are config and are both known at boot.
|
||||
|
||||
- `peak_soc_floor` below `maintenance_soc_floor`, or above it. They are
|
||||
DIFFERENT QUANTITIES (SAFETY-24: a drain target versus a spend limit) and
|
||||
every ordering of the two is a legitimate site configuration. Refusing on
|
||||
@@ -179,6 +228,8 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
import logging
|
||||
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .inverter import roster_entries
|
||||
from .maintenance import MAINT_OPTION_DEFAULTS
|
||||
from .p1 import is_enabled, validate_meter_options
|
||||
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS
|
||||
@@ -205,17 +256,307 @@ _LOG = logging.getLogger("goodwe.config")
|
||||
# a matching integer means a matching rule.
|
||||
FIRMWARE_INGEST_VETO_S = 30.0
|
||||
|
||||
# ⚠️ The complete set of option keys through which this add-on can reach an
|
||||
# actuator. specs §1.5 makes "any configuration in which two writers can reach
|
||||
# one actuator" a non-goal, and the way that non-goal is ENFORCED here is by
|
||||
# construction: there is exactly one such key, so a second writer is not
|
||||
# expressible in the options schema at all. sim/inject.py's `two_writers`
|
||||
# scenario asserts on this tuple - if a second write path is ever added, that
|
||||
# scenario goes red and somebody has to decide how the two are arbitrated
|
||||
# BEFORE the config that allows both can ship.
|
||||
# ⚠️ THE OPTION KEYS UNDER WHICH A WRITER CAN BE DECLARED - AN ENUMERATION, AND
|
||||
# SAFETY-28 DEMOTED IT TO ONE. This tuple used to BE the guarantee. The comment
|
||||
# that stood here said specs §1.5 makes "any configuration in which two writers
|
||||
# can reach one actuator" a non-goal, and that the non-goal was ENFORCED by
|
||||
# construction: exactly one such key, so a second writer is not expressible in
|
||||
# the options schema at all.
|
||||
#
|
||||
# That was true, and it graded THE SHAPE OF THE OPTIONS SCHEMA rather than the
|
||||
# writes. Add CFG-04's per-inverter config - a list of inverters, each with its
|
||||
# own `setpoint_entity` - and there are two real writers with `len()` of this
|
||||
# tuple still equal to 1. The check stays green while the property it protects
|
||||
# is gone, which is this repository's named failure mode: a check that observes
|
||||
# something other than what it claims to observe.
|
||||
#
|
||||
# So the guarantee moved to `actuator_writers()` below, which counts WRITERS PER
|
||||
# ACTUATOR rather than keys, and finds them wherever in the options tree they
|
||||
# are declared. This tuple survives only because a census has to know which key
|
||||
# NAMES an actuator; it is not a bound on how many writers there can be, and
|
||||
# nothing should ever assert on its length again.
|
||||
ACTUATOR_WRITE_KEYS = ("setpoint_entity",)
|
||||
|
||||
|
||||
def _collect_writers(node, path: str, out: list) -> None:
|
||||
"""Walk the options tree, collecting `(where, entity)` for every writer.
|
||||
|
||||
⚠️ STRUCTURAL, NOT NAME-BASED ON THE CONTAINER, and that is the point. A
|
||||
census that looked for a key called `inverters` would be guessing the name
|
||||
CFG-04 has not chosen yet, and a guess that is wrong goes GREEN while two
|
||||
writers exist - the same defect one level along. This walks whatever shape
|
||||
the options happen to have and reports every `setpoint_entity` in it,
|
||||
however deeply nested and under whatever container.
|
||||
"""
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
here = f"{path}.{k}" if path else str(k)
|
||||
if k in ACTUATOR_WRITE_KEYS:
|
||||
entity = str(v or "").strip()
|
||||
if entity:
|
||||
out.append((here, entity))
|
||||
continue
|
||||
_collect_writers(v, here, out)
|
||||
elif isinstance(node, (list, tuple)):
|
||||
for i, v in enumerate(node):
|
||||
_collect_writers(v, f"{path}[{i}]", out)
|
||||
|
||||
|
||||
def actuator_writers(opts: dict) -> dict:
|
||||
"""actuator -> the configured writers that can reach it. INSTANCE-AWARE.
|
||||
|
||||
The replacement for `len(ACTUATOR_WRITE_KEYS) == 1`. It answers HOW MANY
|
||||
THINGS CAN WRITE TO EACH ACTUATOR, which is the property §1.5 actually
|
||||
names, and it keeps answering it when the schema grows a per-inverter list:
|
||||
two inverters with two entities are two writers on two actuators and fine,
|
||||
two declarations naming ONE entity are the non-goal and are refused.
|
||||
|
||||
The key is the entity id, lowercased - Home Assistant entity ids are
|
||||
lowercase by construction, and folding case here means a config cannot
|
||||
smuggle a second writer past the census on `Number.X` versus `number.x`.
|
||||
|
||||
⚠️ SAY WHAT THIS STILL DOES NOT OBSERVE, because the argument that demoted
|
||||
the tuple applies to its replacement too. This is a census of CONFIGURED
|
||||
writers. A second write path added in CODE - a `hass.set_number()` call next
|
||||
to some new strategy - declares nothing and is invisible here, exactly as it
|
||||
was invisible to the tuple. That hole is not closed at startup and cannot
|
||||
be: it is closed at runtime, by `Controller.write_refusal()`, which refuses
|
||||
any value the single allocator did not place on this tick and does not care
|
||||
what the options look like. The two checks are deliberately different
|
||||
kinds - one grades the config, one grades the writes - and only the second
|
||||
one survives a schema this file has not seen yet.
|
||||
"""
|
||||
found: list = []
|
||||
_collect_writers(opts, "", found)
|
||||
census: dict = {}
|
||||
for where, entity in found:
|
||||
census.setdefault(entity.lower(), []).append(where)
|
||||
return census
|
||||
|
||||
|
||||
def unit_caps(opts: dict) -> list[float]:
|
||||
"""Each configured inverter's supervised cap, in roster order.
|
||||
|
||||
⚠️ WHY THIS EXISTS AT ALL, AND IT IS THE PERMISSIVE-READER PROBLEM IN THIS
|
||||
FILE. Rules 5 and 6 both compare a threshold against "max_w", and both of
|
||||
them are really comparing it against WHAT ONE UNIT MAY BE COMMANDED - the
|
||||
arbiter files `supervised` per inverter, from `inv.max_w`, and there is no
|
||||
such thing as a site rating on the wire. At one inverter those are the same
|
||||
number and reading the flat option was right. At two they are not: a config
|
||||
with `max_w: 5000` left over from a single-inverter install and a roster of
|
||||
two 2000 W units would pass rule 5 at `sign_disagree_w: 3000` while the
|
||||
watchdog could not fire on EITHER unit - the disarmed-guard-reporting-armed
|
||||
state those rules exist to refuse, reached through the rule itself.
|
||||
|
||||
So the caps come from the roster the add-on will actually build
|
||||
(`inverter.roster_entries()`, not a second reading of the option), and fall
|
||||
back to the flat `max_w` only where that roster is the flat one too.
|
||||
"""
|
||||
return [cap for _, cap in unit_caps_indexed(opts)]
|
||||
|
||||
|
||||
def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]:
|
||||
"""`unit_caps()`, but each cap carries the ROSTER INDEX it came from.
|
||||
|
||||
⚠️ WHY THE INDEX HAS TO TRAVEL WITH THE NUMBER. This function SKIPS entries
|
||||
with no usable `max_w`, so position i in the caps list is not position i in
|
||||
the roster the moment one entry is malformed. Rules 5 and 6 used to feed a
|
||||
caps-list index straight into `_unit_label()`, which indexes `entries` - so
|
||||
on a roster whose first entry has a junk `max_w`, the refusal named the
|
||||
WRONG INVERTER. An operator told to lower a number on `inverter-a` when the
|
||||
number is on `inverter-b` is being sent to the wrong device by the very
|
||||
message that exists to stop that. 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.
|
||||
"""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
cap = _number(opts, "max_w", 2000.0)
|
||||
return [] if cap is None else [(0, cap)]
|
||||
caps: list[tuple[int, float]] = []
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
cap = _number(entry, "max_w", None)
|
||||
if cap is not None:
|
||||
caps.append((index, cap))
|
||||
return caps
|
||||
|
||||
|
||||
def _unit_label(opts: dict, index: int) -> str:
|
||||
""""" at a single-inverter config, " (inverter-b)" at a roster.
|
||||
|
||||
The messages below have to read identically on the install that upgrades
|
||||
into this version - it is the same configuration, refused for the same
|
||||
reason - and have to NAME THE UNIT the moment there is more than one, or an
|
||||
operator with three inverters is told a number is wrong and not which one.
|
||||
"""
|
||||
entries = roster_entries(opts)
|
||||
if len(entries) <= 1:
|
||||
return ""
|
||||
entry = entries[index] if index < len(entries) else {}
|
||||
name = ""
|
||||
if isinstance(entry, dict):
|
||||
name = str(entry.get("name") or "").strip()
|
||||
return f" ({name or f'inverters[{index}]'})"
|
||||
|
||||
|
||||
def _roster_problems(opts: dict) -> list[str]:
|
||||
"""Rule 9 (CFG-04): the `inverters:` roster, refused where it cannot mean
|
||||
what it says.
|
||||
|
||||
⚠️ THE SUPERVISOR ALREADY ENFORCES MOST OF THIS AND THAT IS NOT A REASON TO
|
||||
DROP IT. `config.yaml`'s schema makes `name`, `setpoint_entity` and `max_w`
|
||||
required per entry, and Supervisor 2026.08.0 refuses a blob that omits one
|
||||
by name - measured, not assumed. But `validate_startup_config()` is also
|
||||
`sim/inject.py`'s entry point and every check's, and those reach it with a
|
||||
dict the Supervisor never saw; the same argument `price_provider` makes two
|
||||
rules up. A rule the Supervisor duplicates costs nothing and covers the
|
||||
callers it does not.
|
||||
|
||||
Five things are refused, and each is a config that would otherwise RUN:
|
||||
|
||||
a. `inverters` is not a list. `roster_entries()` wraps a scalar so this
|
||||
can name it; without the rule the roster would be one inverter built
|
||||
out of a string's `.get`, i.e. an immediate AttributeError.
|
||||
b. An entry with no usable `name`. Store keys are namespaced BY NAME
|
||||
(`store.unit_key`), so two nameless entries would share one namespace
|
||||
and one unit's SAFETY-02 latch would silently be the other's.
|
||||
c. Two entries with the SAME name - (b) reached the long way round.
|
||||
d. An entry with no `setpoint_entity`, or no usable `max_w`. The first is
|
||||
a unit this controller cannot command; the second is a unit whose
|
||||
supervised claim would be `+-0 W`, which is a unit pinned at zero by a
|
||||
missing key rather than by a decision.
|
||||
e. A flat `setpoint_entity` alongside a declared roster. The flat keys are
|
||||
the SINGLE-inverter shape; leaving one set while a roster is declared
|
||||
means one of two things and neither is safe. If it names an entity an
|
||||
entry also names, SAFETY-28's census below already refuses it as two
|
||||
writers on one actuator. If it names a DIFFERENT one, nothing writes
|
||||
it, the site runs quietly smaller than the configuration reads, and the
|
||||
one place it would show up is a status page nobody compares against the
|
||||
config. Refused rather than ignored, for §1.5's reason and for this
|
||||
card's: a config that silently becomes a different config is worse than
|
||||
one that refuses to load.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
raw = opts.get("inverters")
|
||||
if raw is None or raw == "" or raw == []:
|
||||
return problems
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return [f"inverters is {type(raw).__name__} {raw!r}, and it must be a "
|
||||
"LIST of inverter objects - one per inverter, each naming its "
|
||||
"own name, setpoint_entity and max_w. A single inverter needs "
|
||||
"no list at all: leave `inverters` empty and the flat "
|
||||
"setpoint_entity/soc_entity/batt_entity/max_w keys describe it."]
|
||||
|
||||
entries = roster_entries(opts)
|
||||
seen: dict = {}
|
||||
for i, entry in enumerate(entries):
|
||||
where = f"inverters[{i}]"
|
||||
if not isinstance(entry, dict):
|
||||
problems.append(
|
||||
f"{where} is {type(entry).__name__} {entry!r}, and every entry "
|
||||
"must be an object with at least name, setpoint_entity and "
|
||||
"max_w.")
|
||||
continue
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if not name:
|
||||
problems.append(
|
||||
f"{where} has no name. Every inverter needs one: the Store "
|
||||
"namespaces this unit's SAFETY-02 latch, its heartbeat and its "
|
||||
"write bookkeeping under it, so two unnamed units would share "
|
||||
"one namespace and a trip caused by one would latch the other. "
|
||||
"Any short identifier works - inverter-a, inverter-b, garage.")
|
||||
elif name in seen:
|
||||
problems.append(
|
||||
f"{where} and {seen[name]} are both called {name!r}. The Store "
|
||||
"keys every per-unit latch under the unit's name, so two units "
|
||||
"with one name share one SAFETY-02 latch and one heartbeat "
|
||||
"deadline - a trip caused by either would stop both, and an "
|
||||
"operator clearing it would clear both. Give them different "
|
||||
"names.")
|
||||
else:
|
||||
seen[name] = where
|
||||
who = f"{where} ({name})" if name else where
|
||||
if not str(entry.get("setpoint_entity") or "").strip():
|
||||
problems.append(
|
||||
f"{who} has no setpoint_entity, so this add-on cannot command "
|
||||
"it. It would sit in the roster, take a share of the site "
|
||||
"target every cycle and deliver none of it, while the status "
|
||||
"page showed a site running at full strength. Give it the "
|
||||
"ESPHome number entity that carries ITS setpoint, or remove "
|
||||
"the entry.")
|
||||
if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0:
|
||||
problems.append(
|
||||
f"{who} has no usable max_w (got {entry.get('max_w')!r}). It is "
|
||||
"the supervised cap on what may be written to THIS inverter, it "
|
||||
"is not shared with any other unit and it is deliberately not "
|
||||
"inherited from the flat max_w - a rating borrowed from a "
|
||||
"sibling is exactly what DEV-02 removed. State this unit's own "
|
||||
"rating in watts.")
|
||||
|
||||
flat_sp = str(opts.get("setpoint_entity") or "").strip()
|
||||
if entries and flat_sp:
|
||||
problems.append(
|
||||
f"setpoint_entity is set to {flat_sp} AND an inverters list of "
|
||||
f"{len(entries)} is declared. The flat setpoint_entity/soc_entity/"
|
||||
"batt_entity/max_w keys are the SINGLE-inverter shape and are not "
|
||||
"read at all once a roster exists, so this config declares an "
|
||||
"actuator that nothing will ever write - a site running quietly "
|
||||
"smaller than its own configuration. Move that inverter into the "
|
||||
"list as its own entry and clear setpoint_entity.")
|
||||
return problems
|
||||
|
||||
|
||||
def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
|
||||
"""Rule 2, per inverter, against each unit's OWN reported device range.
|
||||
|
||||
⚠️ THE READER THIS REPLACES FAILED PERMISSIVE, WHICH IS THE DIRECTION THAT
|
||||
HIDES A DEFECT. `run_control()` re-ran rule 2 behind `if
|
||||
self.o.get("setpoint_entity")` - the FLAT key - because the flat options
|
||||
were the only per-device options there were. Declare a roster and that key
|
||||
is blank by rule 9(e), so the whole re-check was skipped and NO inverter's
|
||||
`max_w` was ever graded against the device it is written to. That is rule
|
||||
2's own failure mode restored by a config shape: the operator's stated
|
||||
ceiling is not the ceiling and nothing says which won.
|
||||
|
||||
`units` is the roster (anything with `.name`, `.setpoint_entity`, `.max_w`,
|
||||
`.dev_min`, `.dev_max`). A unit whose device did not answer is SKIPPED, not
|
||||
guessed at, for the reason in this module's docstring: ESPHome is often not
|
||||
up when the add-on boots and (None, None) means "not read", not "zero".
|
||||
|
||||
`skip_entity` is the flat `setpoint_entity` when the caller has already
|
||||
graded it through `validate_startup_config(opts, dev_min, dev_max)`. At a
|
||||
single-inverter config that is the one unit, and grading it twice would
|
||||
report the same conflict twice under two wordings.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
skip = str(skip_entity or "").strip().lower()
|
||||
for unit in units:
|
||||
entity = str(getattr(unit, "setpoint_entity", "") or "").strip()
|
||||
if not entity or entity.lower() == skip:
|
||||
continue
|
||||
cap = float(getattr(unit, "max_w", 0.0) or 0.0)
|
||||
dev_min = getattr(unit, "dev_min", None)
|
||||
dev_max = getattr(unit, "dev_max", None)
|
||||
name = getattr(unit, "name", entity)
|
||||
if dev_max is not None and cap > float(dev_max):
|
||||
problems.append(
|
||||
f"{name}: max_w is {cap:.0f} W but {entity} accepts at most "
|
||||
f"{float(dev_max):.0f} W - THE DEVICE WINS, so the {cap:.0f} W "
|
||||
f"configured for this inverter would silently never happen. "
|
||||
f"Lower this entry's max_w to {float(dev_max):.0f} W or less.")
|
||||
elif dev_min is not None and -cap < float(dev_min):
|
||||
problems.append(
|
||||
f"{name}: max_w is {cap:.0f} W, so this controller may command "
|
||||
f"{-cap:.0f} W, but {entity} accepts no less than "
|
||||
f"{float(dev_min):.0f} W - THE DEVICE WINS on the charge side. "
|
||||
f"Lower this entry's max_w to {abs(float(dev_min)):.0f} W or "
|
||||
"less.")
|
||||
return problems
|
||||
|
||||
|
||||
class ConfigRefused(Exception):
|
||||
"""The configuration is wrong. Not "something broke" - see main.py's handler.
|
||||
|
||||
@@ -241,9 +582,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
"""
|
||||
problems: list[str] = []
|
||||
|
||||
# --- 1. an actuator to write to ------------------------------------------
|
||||
writers = [k for k in ACTUATOR_WRITE_KEYS
|
||||
if str(opts.get(k, "") or "").strip()]
|
||||
# --- 1. an actuator to write to, and AT MOST ONE WRITER PER ACTUATOR -----
|
||||
writers = actuator_writers(opts)
|
||||
if not writers:
|
||||
problems.append(
|
||||
"setpoint_entity is not set, so this add-on has no actuator: every "
|
||||
@@ -251,8 +591,45 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
"ESP32 heartbeat that holds off the layer-1 failsafe never happens. "
|
||||
"It would run, look healthy, and move no energy at all. Set it to "
|
||||
"the ESPHome number entity that carries the setpoint.")
|
||||
# SAFETY-28. The §1.5 non-goal, graded on the writers rather than on the
|
||||
# count of option keys. Read `actuator_writers()` for why the old check went
|
||||
# green on the very config this one is here to refuse.
|
||||
#
|
||||
# ⚠️ AND IT REFUSES A DUPLICATE DECLARATION EVEN WHEN BOTH NAME THE SAME
|
||||
# ENTITY, which is the case an author of the per-inverter config will meet
|
||||
# first: keep `setpoint_entity` as inverter A's and add A to the list again
|
||||
# and there are two config sites aimed at one actuator, each with its own
|
||||
# neighbouring options. "They happen to be the same entity, so it is fine"
|
||||
# is precisely the reasoning that makes two writers invisible. Declare each
|
||||
# actuator exactly once and the refusal does not fire.
|
||||
for actuator, who in sorted(writers.items()):
|
||||
if len(who) > 1:
|
||||
problems.append(
|
||||
f"{len(who)} configured writers can reach the one actuator "
|
||||
f"{actuator}: {', '.join(who)}. specs §1.5 makes any "
|
||||
"configuration in which two writers can reach one actuator a "
|
||||
"non-goal - two independent setpoints on one inverter is the "
|
||||
"arbitration problem this add-on refuses to have, and at "
|
||||
"runtime it is a meter-emulation target being written twice a "
|
||||
"second by two strategies that cannot see each other. Give "
|
||||
"each actuator exactly one writer, or point the second one at "
|
||||
"its own entity.")
|
||||
|
||||
# --- 9. CFG-04's roster, graded BEFORE anything reads a per-unit number --
|
||||
# ⚠️ FIRST, THOUGH IT IS THE NEWEST RULE. Rules 5 and 6 below now ask the
|
||||
# roster what one unit may be commanded, so a roster that is nonsense has to
|
||||
# have been named as nonsense before they read it - otherwise an entry with
|
||||
# no `max_w` silently contributes no cap, `min()` sees a shorter list, and
|
||||
# the operator is told about a sign watchdog when the real problem is a
|
||||
# missing rating one line up.
|
||||
problems.extend(_roster_problems(opts))
|
||||
|
||||
# --- 2. the configured ceiling against the device's own rating -----------
|
||||
# ⚠️ THE FLAT `max_w`, AND ONLY THE FLAT ONE. `dev_min`/`dev_max` are ONE
|
||||
# device's range - the flat `setpoint_entity`'s - so this rule can only ever
|
||||
# speak for the single-inverter shape. The per-unit form of it is
|
||||
# `validate_unit_ranges()`, which main.py runs once every unit's own device
|
||||
# has answered; it is not called from here because at startup no device has.
|
||||
max_w = _number(opts, "max_w", 2000.0)
|
||||
if max_w is None:
|
||||
problems.append(f"max_w {opts.get('max_w')!r} is not a number")
|
||||
@@ -331,18 +708,37 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# below max_w, so threshold == max_w is already unsatisfiable. Anything
|
||||
# BELOW max_w is reachable and therefore fine - this refuses the disarmed
|
||||
# case only, not "close to the rail".
|
||||
#
|
||||
# ⚠️ AND IT IS GRADED AGAINST THE SMALLEST UNIT'S CAP, NOT AGAINST THE FLAT
|
||||
# `max_w`. There is one SignWatchdog PER INVERTER (DEV-02) and each grades
|
||||
# its own unit's standing command, which the arbiter bounds by THAT UNIT's
|
||||
# `supervised` claim - so the watchdog that can never fire is the one on the
|
||||
# smallest inverter, and a threshold under the site's largest cap says
|
||||
# nothing about it. At one inverter `unit_caps()` is `[max_w]` and this is
|
||||
# the identical comparison it always was.
|
||||
sign_w = _number(opts, "sign_disagree_w", 300.0)
|
||||
# ⚠️ (index, cap) PAIRS, NOT BARE CAPS. `unit_caps()` skips entries with no
|
||||
# usable `max_w`, so `caps.index(weakest)` was an index into the CAPS list
|
||||
# being handed to `_unit_label()`, which indexes the ROSTER - and the message
|
||||
# named the wrong inverter as soon as the two lengths differed. The index
|
||||
# travels with the number now, so the name is the name of the unit the
|
||||
# number came off.
|
||||
caps_ix = unit_caps_indexed(opts)
|
||||
caps = [cap for _, cap in caps_ix]
|
||||
weakest = min(caps) if caps else None
|
||||
if sign_w is None:
|
||||
problems.append(f"sign_disagree_w {opts.get('sign_disagree_w')!r} is not a number")
|
||||
elif max_w is not None and max_w > 0 and sign_w >= max_w:
|
||||
elif weakest is not None and weakest > 0 and sign_w >= weakest:
|
||||
where = _unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
||||
problems.append(
|
||||
f"sign_disagree_w is {sign_w:.0f} W but max_w is {max_w:.0f} W, so "
|
||||
f"sign_disagree_w is {sign_w:.0f} W but max_w{where} is "
|
||||
f"{weakest:.0f} W, so "
|
||||
"the SAFETY-02 sign-disagreement watchdog can never fire: the "
|
||||
"arbiter never lets a command exceed max_w, and the watchdog needs "
|
||||
f"a command over {sign_w:.0f} W. It would sit there reporting "
|
||||
"'armed' on the status page while being incapable of tripping - the "
|
||||
f"one runaway failure mode this system has actually observed. Lower "
|
||||
f"sign_disagree_w below {max_w:.0f} W (300 W is the measured "
|
||||
f"sign_disagree_w below {weakest:.0f} W (300 W is the measured "
|
||||
"default), or raise max_w.")
|
||||
|
||||
# --- 6. a maintenance charge the ceiling can actually accommodate --------
|
||||
@@ -356,40 +752,55 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# leave the trap armed for the one operator action - ticking
|
||||
# maintenance_enabled - that cannot possibly be expected to change what a
|
||||
# charge rate means.
|
||||
# ⚠️ THE FALLBACK IS config.yaml's SHIPPED VALUE, and it has to be kept
|
||||
# equal to it - that is the whole point of AC3. It is reachable only when a
|
||||
# caller hands over a dict with the key missing (sim/inject.py, a test); the
|
||||
# Supervisor always supplies every key from config.yaml, so on a real
|
||||
# install the argument is always present.
|
||||
# ⚠️ THE FALLBACK IS config.yaml's SHIPPED VALUE, READ RATHER THAN TYPED
|
||||
# (CFG-03). It is reachable only when a caller hands over a dict with the
|
||||
# key missing (sim/inject.py, a test); the Supervisor always supplies every
|
||||
# key from config.yaml, so on a real install the argument is always present
|
||||
# and this default is dead code. That is precisely why it went stale
|
||||
# unnoticed for a whole card: MAINT-04 moved config.yaml to 2000 while
|
||||
# main.py and MaintConfig still said 2500, so a dict with the key ABSENT was
|
||||
# accepted here at 2000 and then built by main.py at 2500 - the
|
||||
# contradiction this rule exists to catch, in the one case it could not see.
|
||||
# The KNOWN MISMATCH block that used to stand here, and the tripwire in
|
||||
# test_validate.py that pinned it, are both DELETED: the divergence is
|
||||
# closed rather than recorded. `maintenance.MAINT_OPTION_DEFAULTS` is now
|
||||
# the one in-code mirror of config.yaml for this family - the same move
|
||||
# rule 8 below already makes with `PEAK_OPTION_DEFAULTS` - and
|
||||
# test_maintenance.py pins it by reading the shipped file.
|
||||
#
|
||||
# ⚠️ KNOWN MISMATCH, LEFT DELIBERATELY AND FLAGGED RATHER THAN FIXED HERE:
|
||||
# `main.py` still falls back to 2500 in two places
|
||||
# (`opts.get("maintenance_charge_w", 2500)`), as does maintenance.py's
|
||||
# MaintConfig dataclass. So a dict with the key ABSENT is accepted by this
|
||||
# rule at 2000 and then built by main.py at 2500 - the contradiction this
|
||||
# rule exists to catch, in the one case it cannot see. It is unreachable on
|
||||
# a Supervisor install and those files are another ticket's right now.
|
||||
# THE REAL FIX is one source of truth for option defaults; until then the
|
||||
# divergence is PINNED BY A CHECK rather than by this comment - see
|
||||
# test_validate.py, "the KNOWN cross-file default divergence". It asserts
|
||||
# the mismatch as it stands and goes red if EITHER side moves, including
|
||||
# when somebody closes it in main.py, at which point this block and that
|
||||
# check both get deleted.
|
||||
charge_w = _number(opts, "maintenance_charge_w", 2000.0)
|
||||
# ⚠️ AND IT IS GRADED AGAINST THE FIRST UNIT'S CAP, NOT THE SMALLEST AND NOT
|
||||
# THE FLAT ONE. Rule 5 takes the weakest unit because there is a watchdog on
|
||||
# every one of them; there is exactly ONE `Maintenance` in this add-on and
|
||||
# `Controller.maint_unit` names the pack it recalibrates - the first in the
|
||||
# roster, because the cycle's history lives under un-namespaced Store keys.
|
||||
# The shaping claim is filed on THAT actuator and on no other, so the cap
|
||||
# that can contradict it is THAT unit's. Taking `min()` here would refuse a
|
||||
# perfectly workable site - a 5000 W pack being recalibrated beside a 2000 W
|
||||
# sibling the cycle never touches - which is the over-refusal this file's
|
||||
# docstring says gets a validator switched off. When `maint_unit` stops
|
||||
# being `inverters[0]`, this line moves with it.
|
||||
charge_w = _number(opts, "maintenance_charge_w",
|
||||
float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"]))
|
||||
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON. `caps[0]` is the first
|
||||
# unit WITH A USABLE CAP, which is `inverters[0]` only while every earlier
|
||||
# entry has one; `_unit_label(opts, 0)` was hard-wired to `inverters[0]`
|
||||
# regardless. Both come off the one pair now.
|
||||
maint_index, maint_cap = caps_ix[0] if caps_ix else (0, None)
|
||||
maint_where = _unit_label(opts, maint_index)
|
||||
if charge_w is None:
|
||||
problems.append(
|
||||
f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number")
|
||||
elif max_w is not None and max_w > 0 and charge_w > max_w:
|
||||
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
|
||||
problems.append(
|
||||
f"maintenance_charge_w is {charge_w:.0f} W but max_w is "
|
||||
f"{max_w:.0f} W, so a maintenance charge in a cheap window can "
|
||||
f"maintenance_charge_w is {charge_w:.0f} W but max_w{maint_where} is "
|
||||
f"{maint_cap:.0f} W, so a maintenance charge in a cheap window can "
|
||||
"never happen: the shaping asks the arbiter for at most "
|
||||
f"{-charge_w:.0f} W while the supervised limit allows no less than "
|
||||
f"{-max_w:.0f} W, which is a contradiction it resolves to 0 W - "
|
||||
f"{-maint_cap:.0f} W, which is a contradiction it resolves to 0 W - "
|
||||
"THE CEILING WINS, and what you would see is an ARBITER "
|
||||
"CONTRADICTION line that reads like a bug rather than like these "
|
||||
f"two numbers. Lower maintenance_charge_w to {max_w:.0f} W or less, "
|
||||
f"or raise max_w to at least {charge_w:.0f} W.")
|
||||
f"two numbers. Lower maintenance_charge_w to {maint_cap:.0f} W or "
|
||||
f"less, or raise max_w to at least {charge_w:.0f} W.")
|
||||
|
||||
# --- 7. two price sources on one site ------------------------------------
|
||||
problems.extend(_price_problems(opts))
|
||||
@@ -397,10 +808,12 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# --- 8. an EMS energy floor the BMS does not already enforce -------------
|
||||
# ⚠️ THE FALLBACKS ARE peakguard.PEAK_OPTION_DEFAULTS, NOT LITERALS, and
|
||||
# that is CFG-03 read as a rule rather than as a complaint. Rule 6's own
|
||||
# comment documents a LIVE divergence in this repo caused by typing 2000
|
||||
# here and 2500 in main.py; this card refuses to add a second instance of
|
||||
# it. The dict is the single in-code mirror, pinned to config.yaml by
|
||||
# test_peakguard.py, and main.py reads the same one.
|
||||
# comment used to document a LIVE divergence in this repo caused by typing
|
||||
# 2000 here and 2500 in main.py; SAFETY-24 refused to add a second instance
|
||||
# of it, and CFG-03 has since closed the first. The dict is the single
|
||||
# in-code mirror, pinned to config.yaml by test_peakguard.py, and main.py
|
||||
# reads the same one - exactly as maintenance.MAINT_OPTION_DEFAULTS now is
|
||||
# for the `maintenance_*` family.
|
||||
soc_floor = _number(opts, "peak_soc_floor",
|
||||
PEAK_OPTION_DEFAULTS["peak_soc_floor"])
|
||||
bms_floor = _number(opts, "bms_soc_floor",
|
||||
@@ -442,17 +855,28 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# words on the status page every cycle, and the cycle still completes -
|
||||
# slower, and eating into maintenance.DRAIN_TIMEOUT_MIN. Loud and it moves
|
||||
# energy, so it is neither (a) nor (b): warned, not refused.
|
||||
disch_w = _number(opts, "maintenance_discharge_w", 2000.0)
|
||||
if disch_w is not None and max_w is not None and max_w > 0 and disch_w > max_w:
|
||||
# ⚠️ AND THE FALLBACK COMES FROM THE SAME MIRROR AS RULE 6's. CFG-03's PM
|
||||
# proofread caught this one: `maintenance_discharge_w` had the IDENTICAL
|
||||
# three-way divergence (config.yaml 2000, MaintConfig 2500) and the card
|
||||
# named only the charge side. Closing one key and leaving its twin is how a
|
||||
# single-source fix gets reproduced as a single-source bug one line down.
|
||||
# ⚠️ AND AGAINST THE MAINTENANCE UNIT'S CAP, for rule 6's reason: it is the
|
||||
# same one actuator being clamped, so grading the warning against the flat
|
||||
# `max_w` would warn about a site number while the clamp is a unit number.
|
||||
disch_w = _number(opts, "maintenance_discharge_w",
|
||||
float(MAINT_OPTION_DEFAULTS["maintenance_discharge_w"]))
|
||||
if (disch_w is not None and maint_cap is not None and maint_cap > 0
|
||||
and disch_w > maint_cap):
|
||||
_LOG.warning(
|
||||
"maintenance_discharge_w %.0f W is above max_w %.0f W. Not refused - "
|
||||
"the drain phase SETS the setpoint and the arbiter clamps it to "
|
||||
"%.0f W and names the clamp ('limited by supervised(max_w)'), so "
|
||||
"nothing is silent and the cycle still completes. But it drains at "
|
||||
"%.0f W, not the %.0f W you configured, and the drain phase has a "
|
||||
"720 min timeout. Lower it to %.0f W or raise max_w to remove the "
|
||||
"maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not "
|
||||
"refused - the drain phase SETS the setpoint and the arbiter clamps "
|
||||
"it to %.0f W and names the clamp ('limited by supervised(max_w)'), "
|
||||
"so nothing is silent and the cycle still completes. But it drains "
|
||||
"at %.0f W, not the %.0f W you configured, and the drain phase has "
|
||||
"a 720 min timeout. Lower it to %.0f W or raise max_w to remove the "
|
||||
"surprise.",
|
||||
disch_w, max_w, max_w, max_w, disch_w, max_w)
|
||||
disch_w, maint_where, maint_cap, maint_cap, maint_cap, disch_w,
|
||||
maint_cap)
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
@@ -97,15 +97,17 @@ Four gates, and each one exists to exclude a specific thing that is NOT a fault:
|
||||
caller says whether the grid-following loop actually won the arbitration this
|
||||
cycle, and the timer only accumulates when it did.
|
||||
|
||||
⚠️ 1500 W, NOT THE DEFAULT 2500 W, AND THE REASON IS NOT COSMETIC. At the
|
||||
shipped defaults `maintenance_charge_w` is 2500 and `max_w` is 2000, so the
|
||||
cheap-window claim is `hi = -2500` against a supervised `lo = -2000`: lo > hi,
|
||||
which arbiter.py resolves as a CONTRADICTION and 0 W, not as a -2500 W charge.
|
||||
The example above is written at 1500 W because that is a combination the
|
||||
schema and the arbiter both actually produce. (The contradiction at the
|
||||
defaults is a real pre-existing bug - a default maintenance charge silently
|
||||
never happens in a cheap window - but it is maintenance's, not this module's,
|
||||
and it is filed separately. Do not "fix" it here.)
|
||||
⚠️ 1500 W, NOT 2500 W, AND THE REASON IS NOT COSMETIC. The example has to be
|
||||
a charge the arbiter actually produces. When this was written the shipped
|
||||
`maintenance_charge_w` was 2500 against a `max_w` of 2000, so the cheap-window
|
||||
claim was `hi = -2500` against a supervised `lo = -2000`: lo > hi, which
|
||||
arbiter.py resolves as a CONTRADICTION and 0 W, not as a -2500 W charge - a
|
||||
default maintenance charge that silently never happened. MAINT-04 refused that
|
||||
pair in validate.py and moved the shipped rate to 2000; CFG-03 gave the
|
||||
default one source of truth so main.py and MaintConfig could not go on
|
||||
answering 2500 to a dict that had never said so. The number here stays 1500
|
||||
because the point of the example is a charge STRICTLY INSIDE the ceiling, not
|
||||
one that happens to sit exactly on it.
|
||||
|
||||
2. The strict `< 0` product. "Opposed" and "neither side is zero" in one
|
||||
expression.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.5.2"
|
||||
version: "0.6.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -39,6 +39,59 @@ options:
|
||||
batt_invert: false
|
||||
setpoint_entity: ""
|
||||
|
||||
# --- ARCH-01 / CFG-04: the inverter roster ---------------------------------
|
||||
# ⚠️ EMPTY IS NOT "UNCONFIGURED", IT IS THE SINGLE-INVERTER CONFIGURATION, and
|
||||
# it is what every install that upgrades into this version lands on. An empty
|
||||
# list means THE FIVE FLAT KEYS ABOVE DESCRIBE THE ONE INVERTER - same
|
||||
# entities, same rating, same behaviour, no operator action - which is the
|
||||
# only migration story this key was allowed to have. `build_roster()` reads
|
||||
# this and nothing else decides the shape.
|
||||
#
|
||||
# Declared, it REPLACES those five keys rather than extending them. Each entry
|
||||
# names its own actuator, its own rating and its own sensors:
|
||||
#
|
||||
# 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: sensor.goodwe_b_battery_power
|
||||
# max_w: 3000
|
||||
#
|
||||
# ⚠️ NOTHING IS INHERITED FROM THE FLAT KEYS BY AN ENTRY THAT OMITS IT, and
|
||||
# that is the whole of DEV-02 restated as a config rule. `soc`, `batt` and the
|
||||
# rating became per-instance precisely so that no inverter is ever graded
|
||||
# against a sibling's numbers; an entry silently inheriting `soc_entity` would
|
||||
# rebuild that defect in YAML. `name`, `setpoint_entity` and `max_w` are
|
||||
# therefore REQUIRED of every entry (the schema below enforces it, and
|
||||
# validate.py rule 9 enforces it again for the callers the Supervisor never
|
||||
# sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH
|
||||
# SENSOR - `inputs_ok` already stops the site for that - and never means
|
||||
# "use the other one's".
|
||||
#
|
||||
# ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a
|
||||
# roster is declared is two config sites aimed at actuators, one of which
|
||||
# nothing writes; if it names an entity an entry also names, SAFETY-28's
|
||||
# writer census refuses it as two writers on one actuator, and if it names a
|
||||
# different one the add-on would run a site quietly smaller than the config
|
||||
# reads. Rule 9 refuses both rather than picking one.
|
||||
#
|
||||
# ⚠️ THE SHAPE WAS MEASURED, NOT ASSUMED. A list of objects is not used
|
||||
# anywhere else in this file, and whether Supervisor's options validator
|
||||
# accepts one was an open question - `static_profile` carries its structure as
|
||||
# a JSON string in a `str?` for exactly that doubt. It was settled by running
|
||||
# Supervisor 2026.08.0's OWN `AppOptions` validator (the version this site
|
||||
# runs) against both candidates: tools/supervisor_shape_probe.py is the
|
||||
# script, DECISIONS/CFG-04-inverter-roster.md is what it printed and why the
|
||||
# list won. The default `[]` is load-bearing in that record: WITHOUT it the
|
||||
# validator answers `Missing option 'inverters' in root` for every existing
|
||||
# options blob, and the upgrade leaves an add-on that cannot start.
|
||||
inverters: []
|
||||
|
||||
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
|
||||
# `off` keeps the original single meter_entity path above, so an existing
|
||||
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
|
||||
@@ -134,7 +187,41 @@ options:
|
||||
# still inside the 900 s quarter that bills the capacity tariff.
|
||||
meter_freeze_s: 300
|
||||
|
||||
# --- SAFETY-31: circulating power ------------------------------------------
|
||||
# ⚠️ CFG-03. THESE TWO LINES ARE THE SOURCE OF TRUTH FOR THESE DEFAULTS, and
|
||||
# `app/circulation.py`'s `CIRC_OPTION_DEFAULTS` is the ONE in-code copy - the
|
||||
# Dockerfile does not put this file in the image, so the running add-on cannot
|
||||
# read it. test_watchdog.py pins the copy by READING these lines, so a drift
|
||||
# goes red in CI rather than shipping. Add a third option in three places -
|
||||
# options:, schema:, CIRC_OPTION_DEFAULTS - and in no fourth.
|
||||
#
|
||||
# Circulating power is sum(|Bi|) - |sum(Bi)| over MEASURED per-inverter power:
|
||||
# two inverters pushing against each other, which NETS TO ZERO AT THE METER
|
||||
# and is therefore invisible to every other guard in this add-on. Both numbers
|
||||
# below were measured on the TEST-19 two-master rig over 924 closed-loop runs
|
||||
# in which nothing was faulty; circulation.py carries the curve they were read
|
||||
# off and the argument for each. They are NOT sensitivity preferences.
|
||||
#
|
||||
# Both are ignored at one inverter, where the quantity is identically zero.
|
||||
circulation_w: 700
|
||||
circulation_hold_s: 75
|
||||
|
||||
# --- maintenance -----------------------------------------------------------
|
||||
# ⚠️ CFG-03. THESE EIGHT LINES ARE THE SOURCE OF TRUTH FOR THE MAINTENANCE
|
||||
# DEFAULTS. The Supervisor reads this file and the operator edits this file,
|
||||
# so nothing else in the add-on gets an opinion about what a missing
|
||||
# `maintenance_*` key means. There is exactly ONE in-code copy -
|
||||
# `app/maintenance.py`'s `MAINT_OPTION_DEFAULTS` - because the Dockerfile does
|
||||
# not put this file in the image and the running add-on therefore cannot read
|
||||
# it; test_maintenance.py pins that copy by READING these lines, so a drift
|
||||
# goes red in CI rather than shipping. main.py and validate.py both read the
|
||||
# mirror and type no number of their own.
|
||||
#
|
||||
# Before this card the same defaults were written down three times and had
|
||||
# already disagreed: this block said 2000 while main.py and MaintConfig said
|
||||
# 2500, so an options dict with the key ABSENT passed validation at 2000 and
|
||||
# then ran at 2500 against a 2000 W ceiling - a silent 0 W arbiter
|
||||
# contradiction. Changing one of these numbers now moves every consumer.
|
||||
maintenance_enabled: false
|
||||
maintenance_interval_days: 28
|
||||
maintenance_start_hour: 10
|
||||
@@ -186,6 +273,26 @@ options:
|
||||
# worst modelled guard day is 878 Wh, i.e. 6.4 % - so 10 points above the
|
||||
# pack's own cut-out leaves the guard ~1.6 of its worst observed days of room
|
||||
# and still never reaches the BMS.
|
||||
#
|
||||
# ⚠️⚠️ SAFETY-25 - WHAT THIS DOES NOT BOUND, AND IT IS THE HALF THE NAME HIDES.
|
||||
# peak_soc_floor BOUNDS THE PEAK GUARD ONLY. IT IS NOT A PACK-WIDE RESERVE.
|
||||
# Ordinary self-consumption - the grid-following loop that holds the meter at
|
||||
# zero - has no SoC floor of any kind, so on a sustained-load evening it will
|
||||
# discharge STRAIGHT PAST this number and go on until the pack's own BMS
|
||||
# cut-out (bms_soc_floor) stops it. Replayed on the committed 2026-08-20
|
||||
# capture, 19:00-22:00 UTC: a 13.8 kWh pack starting at 21 % ends the evening
|
||||
# at 10.0 %, and 343 of 587 control cycles deliver discharge with the pack
|
||||
# already below this floor. Setting this key higher does not change that by
|
||||
# one tenth of a percent - the same replay at every value the schema accepts
|
||||
# (5..60) ends at the same 10.0 %.
|
||||
#
|
||||
# The reason is structural, not an oversight: the peak guard files a `lo`
|
||||
# limit (a LOWER bound on the setpoint), which is what makes it provably
|
||||
# unable to cap a discharge or contradict the arbiter - and therefore also
|
||||
# unable to stop one the loop itself wants. See peakguard.py's module note and
|
||||
# DECISIONS/SAFETY-25-loop-soc-floor.md, which records the options for a
|
||||
# loop-side reserve and is waiting on the product owner. Until that is
|
||||
# answered, the BMS is the only floor under self-consumption.
|
||||
peak_soc_floor: 20
|
||||
# The pack's OWN cut-out percent - what the BMS enforces whatever is set
|
||||
# above. Register 0x0560 reads 10 on the reference GW5000S-BP, consistent with
|
||||
@@ -315,6 +422,32 @@ schema:
|
||||
batt_invert: bool
|
||||
setpoint_entity: str
|
||||
|
||||
# CFG-04. THE ONE LIST-OF-OBJECTS IN THIS FILE, and see the options block for
|
||||
# the measurement that says Supervisor 2026.08.0 accepts it: nested types are
|
||||
# enforced per entry, a missing required key names the key, a scalar where the
|
||||
# list belongs is refused, and the UI renders it as a repeatable section.
|
||||
#
|
||||
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
|
||||
# no `name` has no Store namespace and would share another unit's SAFETY-02
|
||||
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
|
||||
# no `max_w` would have to borrow a rating, which is the sibling-grading
|
||||
# defect DEV-02 removed. The optional pair is the sensors, whose absence
|
||||
# honestly means "this unit reports none".
|
||||
#
|
||||
# ⚠️ THIS BLOCK IS DECLARED BEFORE `max_w:` ON PURPOSE. `max_w` now appears
|
||||
# TWICE under `schema:` - once nested here, once flat below - and a reader
|
||||
# that matches a stripped line takes the nested one FIRST. That is the
|
||||
# first-match trap this card had to fix in three checks; leaving the nesting
|
||||
# first keeps the trap baited, so the fix stays graded rather than assumed.
|
||||
# test_inverter.py part 12 asserts the ordering is still hostile.
|
||||
inverters:
|
||||
- name: str
|
||||
setpoint_entity: str
|
||||
max_w: int(100,5000)
|
||||
soc_entity: str?
|
||||
batt_entity: str?
|
||||
batt_invert: bool?
|
||||
|
||||
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
|
||||
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
|
||||
# telegram whose phase count disagrees is rejected at ingest and logged, so a
|
||||
@@ -363,6 +496,38 @@ schema:
|
||||
# app/freeze.py.
|
||||
meter_freeze_s: int(60,3600)
|
||||
|
||||
# SAFETY-31. No "off" value, for SAFETY-02's and meter_freeze_s's reason:
|
||||
# disarming a safety alarm is not a supported configuration.
|
||||
#
|
||||
# ⚠️ THE FLOOR OF 400 IS NOT A RECOMMENDATION, IT IS THE MEASURED EDGE OF THE
|
||||
# REGIME IN WHICH THIS GUARD CAN BE ANSWERED FOR. On the TEST-19 rig, the
|
||||
# worst CONTINUOUS opposition produced by a site where nothing is faulty - a
|
||||
# flapping load reversing every control cycle, swept over the whole range this
|
||||
# schema accepts for `gain` and `slew_w` - is 5.0 s at a 400 W threshold and
|
||||
# above, 10.0 s at 300 W, 40.0 s at 250 W and 505.0 s at 100 W. Below the
|
||||
# knee, legitimate operation holds the sensor over the threshold for MINUTES
|
||||
# and no window shorter than the 900 s billing quarter could tell that from a
|
||||
# fault. A configurable value under 400 W would be a false-alarm generator
|
||||
# that this project's own doctrine (SAFETY-15, SAFETY-19) forbids shipping.
|
||||
circulation_w: int(400,5000)
|
||||
# The floor of 30 is `inverter.LINK_STALE_S`: below the firmware's own veto
|
||||
# window a unit still executing a stale share is a LINK fault the add-on
|
||||
# already handles by dropping it from the split, and firing inside that window
|
||||
# would raise a second, louder alarm about a state already being managed. The
|
||||
# ceiling of 900 is the quarter that bills the capacity tariff - a circulating
|
||||
# loop must be caught and acted on well inside one.
|
||||
circulation_hold_s: int(30,900)
|
||||
|
||||
# ⚠️ CFG-03 - THIS IS WHERE A RANGE LIVES, AND THE ONLY PLACE IT LIVES. The
|
||||
# `options:` block above holds the DEFAULT; this block holds the BOUNDS, and
|
||||
# `MAINT_OPTION_DEFAULTS` deliberately mirrors the former and not the latter -
|
||||
# the Supervisor is what enforces a range and the Supervisor reads only this
|
||||
# file, so a second copy of `int(500,5000)` in Python would be a place to
|
||||
# disagree and nothing else. What is NOT expressible here is a relation
|
||||
# BETWEEN keys: `maintenance_charge_w` must also stay at or below `max_w`, the
|
||||
# schema grammar has no way to say so, and that is why validate.py's rule 6 is
|
||||
# written in code instead. Add a ninth maintenance option in three places -
|
||||
# options:, schema:, MAINT_OPTION_DEFAULTS - and in no fourth.
|
||||
maintenance_enabled: bool
|
||||
maintenance_interval_days: int(1,90)
|
||||
maintenance_start_hour: int(0,23)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,23 @@
|
||||
Each assertion is a precedence question someone will eventually ask in the
|
||||
field: "why did it charge during a peak?", "why did it discharge while the
|
||||
maintenance cycle was charging?", "what happens if two things disagree?".
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from math import inf
|
||||
|
||||
from app.arbiter import (
|
||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY,
|
||||
Claim, resolve,
|
||||
ACTUATOR_SITE, CLAIM_SCOPE, P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE,
|
||||
P_MAINTENANCE, P_PEAK, P_SAFETY, SCOPE_SITE, SCOPE_UNIT, UnitBounds,
|
||||
Claim, actuator_for, allocate, pre_resolve, resolve, scope_violations,
|
||||
site_resolve,
|
||||
)
|
||||
|
||||
fails = []
|
||||
@@ -21,6 +31,15 @@ def check(name, cond):
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def _raises(fn):
|
||||
"""True if `fn()` raises - a check that something is REFUSED, not defaulted."""
|
||||
try:
|
||||
fn()
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
print("basics")
|
||||
r = resolve([])
|
||||
check("no claims at all means 0 W", r.target_w == 0.0 and r.winner == "failsafe")
|
||||
@@ -92,6 +111,391 @@ check("charging is clamped by the low limit", r.target_w == -1000)
|
||||
r = resolve([Claim.set("loop", P_LOOP, -200), charge_only])
|
||||
check("charge-only permits charging", r.target_w == -200)
|
||||
|
||||
# =============================================================================
|
||||
# ARB-03: the actuator specs.md declared, and the three stages
|
||||
# =============================================================================
|
||||
# ⚠️ EVERY CHECK ABOVE THIS LINE IS UNEDITED, AND THAT IS ITSELF THE FIRST
|
||||
# ACCEPTANCE CRITERION. Not one of them names an actuator, so every claim they
|
||||
# build is a SITE claim - which is exactly the state main.py is in at N=1 - and
|
||||
# they still answer what they answered before the field existed. If a line above
|
||||
# had to be changed to accommodate `actuator`, stage 2 would not be the shipped
|
||||
# arbiter and the refactor would not be pure.
|
||||
|
||||
print()
|
||||
print("ARB-03 1. the field, and what its absence means")
|
||||
|
||||
lone = Claim.set("loop", P_LOOP, 900)
|
||||
check("a claim built without naming an actuator carries one anyway",
|
||||
lone.actuator == ACTUATOR_SITE)
|
||||
# ⚠️ THE TYPE ITSELF, NOT ONLY THE TWO HELPERS. Measured: with only the helper
|
||||
# checked, emptying the DATACLASS default survives this suite untouched, because
|
||||
# `Claim.set()` and `Claim.limit()` pass their own default down and the field's
|
||||
# never runs. Anything that builds a `Claim(...)` directly - a future strategy,
|
||||
# a `dataclasses.replace()` - would have got the unspecified actuator that this
|
||||
# card exists to remove.
|
||||
check("...and so does one built through the dataclass directly, which is the "
|
||||
"constructor the two helpers do not cover",
|
||||
Claim("loop", P_LOOP).actuator == ACTUATOR_SITE)
|
||||
check("...and it is an explicit SITE, not None and not an empty string. "
|
||||
"'unspecified' is what the shipped code had, and 'unspecified' is what "
|
||||
"made every claim implicitly about the one and only actuator",
|
||||
isinstance(lone.actuator, str) and lone.actuator != ""
|
||||
and lone.actuator is not None)
|
||||
check("...and `source` is still the first positional argument, which is what "
|
||||
"test_peakguard.py part 10f's AST scan reads off main.py to pin the "
|
||||
"claim-source set. specs.md declares `actuator` first; the tuple position "
|
||||
"is not the part of that declaration that carries meaning, and moving "
|
||||
"`source` off zero would blind the tripwire that guards SAFETY-25",
|
||||
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
|
||||
|
||||
print()
|
||||
print("ARB-03 2. the split: eight sources, each classified where it is BUILT")
|
||||
# The classification is graded against what each claim is ABOUT, restated here
|
||||
# so that the table in arbiter.py and the expectation in a test are not the same
|
||||
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
|
||||
# own AST, so a ninth source cannot arrive without both files noticing.
|
||||
SITE_SOURCES = {
|
||||
"loop": "regulates grid_w, the P1 measurement - one meter, one site",
|
||||
"peak": "the quarter-hour average offtake AT the grid connection",
|
||||
"arbitrage": "buys and sells at the grid against a day-ahead price",
|
||||
"safety": "'stopped' is the site's switch, 'inputs missing' is the "
|
||||
"site's own meter - neither is about a pack",
|
||||
# SAFETY-31, and it is the entry most easily filed on the wrong side.
|
||||
"circulation": "sum(|Bi|) - |sum(Bi)| is defined OVER THE ROSTER and is "
|
||||
"identically zero for any one unit - there is no fact of "
|
||||
"the matter about which of two opposing inverters is the "
|
||||
"offender, so there is nothing to file it per-unit on",
|
||||
}
|
||||
UNIT_SOURCES = {
|
||||
"device": "one inverter's own reported min/max rating",
|
||||
"supervised": "max_w caps what may be WRITTEN to one setpoint entity",
|
||||
"failsafe": "site-wide evidence, per-unit remedy: SAFETY-02 grades the "
|
||||
"command THIS controller put on THIS actuator",
|
||||
"maintenance": "the monthly recalibration is a pack procedure",
|
||||
}
|
||||
for _src, _why in sorted(SITE_SOURCES.items()):
|
||||
print(f" site {_src:<11} {_why}")
|
||||
for _src, _why in sorted(UNIT_SOURCES.items()):
|
||||
print(f" unit {_src:<11} {_why}")
|
||||
check("all NINE claim sources are classified, and only those nine",
|
||||
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
|
||||
check("the five that are about the GRID CONNECTION are site claims",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
|
||||
check("the four that are about a PACK, a DEVICE or one write are per-inverter",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
|
||||
check("a source nobody classified cannot be routed at all - actuator_for() "
|
||||
"raises rather than defaulting to the site, because defaulting to the "
|
||||
"site is the failure this card removes",
|
||||
_raises(lambda: actuator_for("reserve", "inverter-a", ("inverter-a",))))
|
||||
|
||||
print()
|
||||
print("ARB-03 3. at N=1 the site IS the unit, so nothing moves")
|
||||
# §6.2's identity: with one inverter the fabricated grid reading and the battery
|
||||
# setpoint are the same number, so there is one actuator and it is both.
|
||||
ONE = ("inverter-a",)
|
||||
TWO = ("inverter-a", "inverter-b")
|
||||
check("with one inverter every source routes to the site, per-inverter ones "
|
||||
"included - that is the identity, not the classification being ignored",
|
||||
all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE))
|
||||
check("...and with none configured at all, likewise",
|
||||
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
|
||||
check("with TWO the identity breaks and the split becomes real: the four "
|
||||
"per-inverter sources route to the unit this controller drives, the five "
|
||||
"site sources stay on the grid connection",
|
||||
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
|
||||
== {**{s: "inverter-a" for s in UNIT_SOURCES},
|
||||
**{s: ACTUATOR_SITE for s in SITE_SOURCES}})
|
||||
|
||||
print()
|
||||
print("ARB-03 4. stage 2 IS the shipped arbiter (AC 3)")
|
||||
# ⚠️ NOT ASSERTED, REPLAYED. Every claim list this file resolves above is run
|
||||
# through BOTH entry points and every field the shipped code reads off a
|
||||
# Resolution is compared. `resolve()` at N=1 is `site_resolve()` on the same
|
||||
# list, so a single edit inside stage 2 would show up in every check above as
|
||||
# well as here - and one that only affects the wrapper shows up only here.
|
||||
REPLAY = [
|
||||
[],
|
||||
[Claim.set("loop", P_LOOP, 900)],
|
||||
[Claim.set("loop", P_LOOP, 900), Claim.set("maintenance", P_MAINTENANCE, 2500)],
|
||||
[Claim.set("loop", P_LOOP, 4000),
|
||||
Claim.limit("supervised", P_SAFETY, lo=-2000, hi=2000, reason="max_w")],
|
||||
[Claim.set("loop", P_LOOP, 4000), Claim.limit("a", P_SAFETY, hi=3000),
|
||||
Claim.limit("b", P_SAFETY, hi=1500)],
|
||||
[Claim.set("loop", P_LOOP, 900), charge_only],
|
||||
[Claim.set("loop", P_LOOP, 900), charge_only, Claim.set("peak", P_PEAK, 2500)],
|
||||
[Claim.set("peak", P_PEAK, 9000), charge_only,
|
||||
Claim.limit("device", P_SAFETY, lo=-5000, hi=5000, reason="rating")],
|
||||
[Claim.set("loop", P_LOOP, 900), Claim.set("safety", P_SAFETY, 0, "inputs missing")],
|
||||
[Claim.set("loop", P_LOOP, 500), Claim.limit("x", P_SAFETY, lo=1000),
|
||||
Claim.limit("y", P_SAFETY, hi=200)],
|
||||
[Claim.set("loop", P_LOOP, -3000),
|
||||
Claim.limit("supervised", P_SAFETY, lo=-1000, hi=1000)],
|
||||
[Claim.set("loop", P_LOOP, -200), charge_only],
|
||||
[Claim.set("arbitrage", P_ARBITRAGE, -2000, "buy"),
|
||||
Claim.limit("peak", P_PEAK, lo=800.0, reason="quarter peak"),
|
||||
Claim.limit("supervised", P_SAFETY, lo=-2000, hi=2000, reason="max_w")],
|
||||
]
|
||||
|
||||
|
||||
def _shape(r):
|
||||
return (r.target_w, r.winner, r.bound_by, r.contradiction,
|
||||
list(r.considered), r.explain())
|
||||
|
||||
|
||||
_diff = [i for i, cl in enumerate(REPLAY)
|
||||
if _shape(resolve(cl)) != _shape(site_resolve(cl))]
|
||||
print(f" replayed {len(REPLAY)} claim lists through both entry points")
|
||||
check("NOT ONE of them resolves differently - target, winner, binder, "
|
||||
"contradiction flag, the considered list and the operator line are equal "
|
||||
"on every one. At N=1 the three stages are the shipped arbiter with "
|
||||
"bookkeeping either side of it", _diff == [] and len(REPLAY) >= 12)
|
||||
check("...and stage 3 hands the whole target to the one actuator there is, "
|
||||
"which is the N=1 identity rather than an allocation",
|
||||
resolve([Claim.set("loop", P_LOOP, 900)]).shares == {ACTUATOR_SITE: 900.0})
|
||||
check("...and says it allocated, because at N<=1 there is nothing to divide",
|
||||
resolve([Claim.set("loop", P_LOOP, 900)]).allocated)
|
||||
|
||||
print()
|
||||
print("ARB-03 5. THE CONTRADICTION THAT CANNOT BE WRITTEN DOWN (AC 4)")
|
||||
# SAFETY-25 §0a, constructed. A per-inverter reserve says "this pack must not
|
||||
# discharge" (`hi = 0`); the peak guard says "the site must deliver at least
|
||||
# 1500 W" (`lo = 1500`). At N=1 those are two bounds on ONE number and they
|
||||
# genuinely cross - which is today's behaviour, and today's hazard, and is left
|
||||
# exactly as it is. Filed on TWO actuators they are bounds on two different
|
||||
# numbers, and there is no expression in which one is the other's counterpart.
|
||||
SITE_SIDE = [Claim.set("loop", P_LOOP, -1500.0, "grid"),
|
||||
Claim.limit("peak", P_PEAK, lo=1500.0, reason="quarter peak")]
|
||||
CROSSED = SITE_SIDE + [Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0,
|
||||
reason="charge-only")]
|
||||
SPLIT = SITE_SIDE + [Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0,
|
||||
reason="charge-only", actuator="inverter-b")]
|
||||
r_crossed, r_split = resolve(CROSSED), resolve(SPLIT)
|
||||
print(f" both on one actuator: {r_crossed.explain()}")
|
||||
print(f" the `hi` on inverter-b: {r_split.explain()} "
|
||||
f"inverter-b bounds {r_split.unit_bounds['inverter-b'].explain()}")
|
||||
check("filed on ONE actuator the pair still contradicts and still parks the "
|
||||
"battery at 0 W in the billed quarter. That is the shipped behaviour and "
|
||||
"this card does not change it - at N=1 the two really are bounds on one "
|
||||
"number", r_crossed.contradiction and r_crossed.target_w == 0.0)
|
||||
check("filed on TWO the site keeps its floor: the unit `hi` is not a term of "
|
||||
"the site's `lo > hi` test, so there is no contradiction to flag",
|
||||
not r_split.contradiction and r_split.target_w == 1500.0)
|
||||
check("...and the constraint is not lost, it is CARRIED - inverter-b's own "
|
||||
"interval still says 'do not discharge', for the allocator to honour when "
|
||||
"it divides the 1500 W", r_split.unit_bounds["inverter-b"].hi == 0.0
|
||||
and r_split.unit_bounds["inverter-b"].hi_src == "maintenance")
|
||||
check("...and the site target is bit-for-bit what the site claims alone "
|
||||
"resolve to, so the unit claim contributed NOTHING to it - which is the "
|
||||
"'cannot be expressed' rather than 'is guarded against'",
|
||||
_shape(site_resolve(SITE_SIDE))[:4] == _shape(r_split)[:4])
|
||||
|
||||
# Swept rather than argued: no per-inverter source, at any bound, on any unit,
|
||||
# can reach the site's limits.
|
||||
_SWEEP_N = _SWEEP_BAD = 0
|
||||
for _src in sorted(UNIT_SOURCES):
|
||||
for _unit in ("inverter-a", "inverter-b"):
|
||||
for _lo, _hi in ((0.0, inf), (-inf, 0.0), (2500.0, inf), (-inf, -2500.0),
|
||||
(900.0, 100.0)):
|
||||
_r = resolve(SITE_SIDE + [Claim.limit(_src, P_MAINT_SHAPE, lo=_lo,
|
||||
hi=_hi, actuator=_unit)])
|
||||
_SWEEP_N += 1
|
||||
if _r.contradiction or _r.target_w != 1500.0:
|
||||
_SWEEP_BAD += 1
|
||||
print(f" swept {_SWEEP_N} (per-inverter source x unit x bound) filings "
|
||||
f"against a live site floor: {_SWEEP_BAD} disturbed the site target")
|
||||
check(f"NOT ONE of the {_SWEEP_N} filings contradicts the site floor or moves "
|
||||
f"the site target - including the last bound of each row, which is a unit "
|
||||
f"contradicting ITSELF (lo 900 > hi 100). A unit's own impossible pair "
|
||||
f"parks that unit; it does not park the site",
|
||||
_SWEEP_BAD == 0 and _SWEEP_N >= 40)
|
||||
_self_bad = resolve(SITE_SIDE + [Claim.limit("maintenance", P_MAINT_SHAPE,
|
||||
lo=900.0, hi=100.0,
|
||||
actuator="inverter-b")])
|
||||
check("...and that unit says so on its own bounds, so the fault is reported "
|
||||
"where it was made rather than swallowed",
|
||||
_self_bad.unit_bounds["inverter-b"].contradiction
|
||||
and "CONTRADICTION" in _self_bad.unit_bounds["inverter-b"].explain())
|
||||
|
||||
print()
|
||||
print("ARB-03 6. stage 1: one unit's claims, one unit's interval")
|
||||
mb = pre_resolve([Claim.limit("device", P_SAFETY, -5000, 5000, "rating"),
|
||||
Claim.limit("supervised", P_SAFETY, -2000, 2000, "max_w"),
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, reason="charge-only")])
|
||||
print(f" a unit filing rating, max_w and charge-only: {mb.explain()}")
|
||||
check("the most restrictive of the unit's own limits is the interval",
|
||||
mb.lo == -2000 and mb.hi == 0.0 and mb.hi_src == "maintenance"
|
||||
and mb.lo_src == "supervised")
|
||||
owned = pre_resolve([Claim.set("maintenance", P_MAINTENANCE, -2500, "charge"),
|
||||
Claim.limit("supervised", P_SAFETY, -2000, 2000, "max_w"),
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, reason="charge-only")])
|
||||
print(f" a unit whose maintenance owns the setpoint: {owned.explain()}")
|
||||
check("a unit's own winning SET gates its own limits exactly as clause 2 does "
|
||||
"at the site - the P_MAINT_SHAPE ceiling is not applied to a "
|
||||
"P_MAINTENANCE drain that outranks it", owned.hi == 2000.0)
|
||||
check("...and the SET's value travels BESIDE the interval, not inside it. "
|
||||
"Folding -2500 into [-2500, -2500] would turn 'clamped to max_w' into "
|
||||
"'contradicts max_w', which is a different answer and a wrong one",
|
||||
owned.pinned_w == -2500 and owned.pinned_by == "maintenance"
|
||||
and owned.gate == P_MAINTENANCE and not owned.contradiction)
|
||||
|
||||
print()
|
||||
print("ARB-03 7 / ALLOC-01: the seam, and what now occupies it")
|
||||
# ⚠️ THIS PART WAS "the seam is a seam, and NOT an allocator" AND IT GRADED THE
|
||||
# DECLINE. ALLOC-01 filled the seam, so the decline at N>=2 is gone - and the
|
||||
# checks that graded it are RE-AIMED rather than deleted, because the two halves
|
||||
# they were really about are both still true and both still worth a line: the
|
||||
# N<=1 pass-through is byte-identical, and the seam still does not BROADCAST.
|
||||
check("with no units the whole target goes to the site actuator",
|
||||
allocate(1500.0, {}) == ({ACTUATOR_SITE: 1500.0}, True))
|
||||
check("with one unit the share IS the site target - §6.2's N=1 identity, "
|
||||
"pass-through. ⚠️ A FLOAT, UNROUNDED AND UNCLAMPED: this is the "
|
||||
"compatibility promise every N=1 replay in this repository depends on, "
|
||||
"and the general path below does neither of those things",
|
||||
allocate(1500.0, {"inverter-a": UnitBounds()})
|
||||
== ({"inverter-a": 1500.0}, True))
|
||||
check("...and it is byte-identical on a fractional target too, which is where a "
|
||||
"'tidy this into the general path' refactor would show up: the general "
|
||||
"path rounds to the 1 W register, the pass-through must not",
|
||||
allocate(1500.4, {"inverter-a": UnitBounds()})
|
||||
== ({"inverter-a": 1500.4}, True))
|
||||
_shares, _ok = allocate(1500.0, {"inverter-a": UnitBounds(),
|
||||
"inverter-b": UnitBounds()})
|
||||
print(f" two units, ALLOC-01: shares={_shares} allocated={_ok}")
|
||||
check("⚠️ AND THE ONE THING THE SEAM MUST NEVER DO, WHICH IS THE WHOLE OF "
|
||||
"ARCH-01: it does not BROADCAST. Hand the site target to each inverter "
|
||||
"and each supplies it IN FULL - the gain at the meter becomes N times the "
|
||||
"per-unit gain, which admin/goodwe's two-master rig measures as a 4000 W "
|
||||
"peak-to-peak limit cycle at gain 1.0 with not one guard firing. This "
|
||||
"line went red on ARB-03's decline and it goes red on a broadcast, which "
|
||||
"is the point: it grades the OUTPUT, not the mechanism",
|
||||
1500.0 not in _shares.values())
|
||||
check("...it allocates instead, and the shares SUM to the site target exactly. "
|
||||
"That is the safety property: sum(s_i) = S is what makes the gain at the "
|
||||
"meter a convex combination of the per-unit gains for ANY split, so the "
|
||||
"weights are a comfort choice and a bad split cannot destabilise the site",
|
||||
_ok is True and sum(_shares.values()) == 1500
|
||||
and sorted(_shares) == ["inverter-a", "inverter-b"])
|
||||
check("...and an odd target still sums exactly, which is the half a per-unit "
|
||||
"round() gets wrong: 1501/2 is 750.5 twice, and two roundings of that are "
|
||||
"either 1500 or 1502. Largest remainder gives 751 and 750",
|
||||
sum(allocate(1501.0, {"inverter-a": UnitBounds(),
|
||||
"inverter-b": UnitBounds()})[0].values()) == 1501)
|
||||
check("⚠️ AND THE DECLINE IS NOT GONE, IT IS RE-AIMED. It is still correct when "
|
||||
"no safe split exists at all, and PR #40's reviewer proved that branch was "
|
||||
"unreachable and untested once already - removing its trigger without "
|
||||
"giving it a new one would rebuild that hole. Here: every inverter's "
|
||||
"master stale, so there is no envelope set to allocate into",
|
||||
allocate(1500.0, {"inverter-a": UnitBounds(),
|
||||
"inverter-b": UnitBounds()}, live=()) == ({}, False))
|
||||
|
||||
# ⚠️ AND `resolve()` HAS TO HONOUR THE ROSTER IT IS GIVEN, WHICH IS NOT THE SAME
|
||||
# THING AS HAVING A `units` PARAMETER. Left None the roster is DERIVED from the
|
||||
# claims, and that derivation is right only for a caller that files a claim for
|
||||
# every unit every cycle. Ignore the argument and derive it always - `if units
|
||||
# is None:` -> `if True:` - and a unit that filed nothing this cycle simply
|
||||
# CEASES TO EXIST: the site of two looks like a site of one, the seam passes the
|
||||
# whole grid target through to the single inverter that spoke, and ARCH-01's
|
||||
# fault is built with the evidence for it removed. That mutation left this whole
|
||||
# suite green until this check was written (PR #40 review), so it is graded on
|
||||
# the one observation that distinguishes the two - a SILENT unit.
|
||||
_silent = resolve([Claim.set("loop", P_LOOP, 900.0),
|
||||
Claim.limit("device", P_SAFETY, -2000, 2000, "rating",
|
||||
actuator="inverter-a")],
|
||||
units=TWO)
|
||||
print(f" roster of two, only inverter-a files: bounds="
|
||||
f"{sorted(_silent.unit_bounds)} allocated={_silent.allocated} "
|
||||
f"shares={_silent.shares}")
|
||||
check("resolve() HONOURS the roster it is handed rather than inferring one from "
|
||||
"who happened to speak. inverter-b filed nothing this cycle and is still "
|
||||
"a unit: it gets its own interval, and the site is still a site of TWO, "
|
||||
"so the target is SPLIT across two inverters",
|
||||
sorted(_silent.unit_bounds) == ["inverter-a", "inverter-b"]
|
||||
and _silent.shares == {"inverter-a": 450, "inverter-b": 450})
|
||||
check("...and that is the whole cost of ignoring the argument, stated as the "
|
||||
"number it would produce: derive the roster from the claims instead and "
|
||||
"the site is a site of ONE, so the seam passes ALL 900 W through to "
|
||||
"inverter-a - the silent inverter's absence read as a smaller site, and "
|
||||
"inverter-a told to do the whole site's work on its own. ⚠️ ALLOC-01 "
|
||||
"makes this mutation WORSE, not better: before, both answers refused, so "
|
||||
"only the roster listing distinguished them; now the wrong one produces a "
|
||||
"plausible number that is twice what inverter-a should be given",
|
||||
resolve([Claim.set("loop", P_LOOP, 900.0),
|
||||
Claim.limit("device", P_SAFETY, -2000, 2000, "rating",
|
||||
actuator="inverter-a")]).shares
|
||||
== {"inverter-a": 900.0})
|
||||
check("...and left None the roster IS derived from the claims, which is the "
|
||||
"documented default every N=1 replay above depends on - so the parameter "
|
||||
"changes the answer only where the two disagree",
|
||||
resolve([Claim.set("loop", P_LOOP, 900.0),
|
||||
Claim.limit("device", P_SAFETY, -2000, 2000, "rating",
|
||||
actuator="inverter-a")], units=None).allocated is True)
|
||||
|
||||
print()
|
||||
print("ARB-03 8. verified by breaking it: a claim routed to the wrong stage (AC 6)")
|
||||
# The `peak` floor, misfiled on a unit. It is the site's floor - if it lands on
|
||||
# one inverter's interval the SITE keeps no floor at all, and the loop is free to
|
||||
# go on charging through the quarter that sets the month's bill.
|
||||
mis_peak = SITE_SIDE[:1] + [Claim.limit("peak", P_PEAK, lo=1500.0,
|
||||
reason="quarter peak",
|
||||
actuator="inverter-b")]
|
||||
r_mis = resolve(mis_peak)
|
||||
print(f" peak floor misfiled on inverter-b: {r_mis.explain()} "
|
||||
f"scope_errors={r_mis.scope_errors}")
|
||||
check("the mis-routing is CAUGHT: scope_violations names the source and the "
|
||||
"actuator it does not belong on", len(r_mis.scope_errors) == 1
|
||||
and "peak" in r_mis.scope_errors[0])
|
||||
check("...and the reason it must be caught, measured rather than argued: with "
|
||||
"the floor on a unit the SITE has no floor, so the loop charges 1500 W "
|
||||
"into the quarter the guard was trying to shave",
|
||||
r_mis.target_w == -1500.0 and resolve(SITE_SIDE).target_w == 1500.0)
|
||||
check("a UNIT source filed on the site at a site that HAS named units is "
|
||||
"caught too - that is the direction that produces `lo > hi`",
|
||||
scope_violations(SITE_SIDE + [
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0),
|
||||
Claim.limit("device", P_SAFETY, -1, 1, actuator="inverter-b")]) != [])
|
||||
check("...and at N=1, where every source legitimately routes to the site, the "
|
||||
"same list is clean. The check grades ROUTING, not tidiness",
|
||||
scope_violations(SITE_SIDE + [
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0)]) == [])
|
||||
check("an unclassified ninth source is a violation wherever it is filed",
|
||||
scope_violations([Claim.limit("reserve", P_LOOP, hi=0.0)]) != [])
|
||||
check("and the eight, routed by actuator_for() at a two-inverter site, are "
|
||||
"clean - which is what says the router and the checker agree",
|
||||
scope_violations(
|
||||
[Claim.limit(s, P_SAFETY, -1, 1,
|
||||
actuator=actuator_for(s, "inverter-a", TWO))
|
||||
for s in CLAIM_SCOPE]) == [])
|
||||
|
||||
# --- ALLOC-01: AND NOW SOMETHING HAPPENS ABOUT IT ---------------------------
|
||||
# ⚠️ ARB-03 LEFT THIS DELIBERATELY INERT - `scope_violations()` logged and did
|
||||
# not change the resolution, and nothing graded the consequence. ALLOC-01 had to
|
||||
# decide, and decided the allocator CONSUMES it as a REFUSAL rather than as an
|
||||
# input. The reasoning is at `allocator.plan()`'s REFUSAL 1 and the short form
|
||||
# is: the mis-routed claim has already been partitioned by the time stage 3
|
||||
# runs, so its effect is baked into S or into somebody's interval; "consuming"
|
||||
# it as an allocation input would mean RE-ROUTING it, which needs a guess about
|
||||
# which inverter it belonged to that nothing here can make. What is left is that
|
||||
# S itself may be wrong, and dividing a number that may be wrong is worse than
|
||||
# not dividing it.
|
||||
print(f" and stage 3's answer to it: {r_mis.allocation.explain()}")
|
||||
check("⚠️ the mis-routing is no longer only a log line: stage 3 REFUSES to "
|
||||
"allocate on a claim list it has been told is mis-routed, so main.py "
|
||||
"fails toward inaction and shouts instead of dividing a number that may "
|
||||
"be wrong. That is the same answer clause 3 already gives a contradiction",
|
||||
r_mis.allocated is False and r_mis.shares == {}
|
||||
and "mis-routed" in r_mis.allocation.refused)
|
||||
check("...and it is NOT consumed as an ENVELOPE, which is the tempting reading "
|
||||
"and the wrong one: a unit-scoped claim that landed on the site names no "
|
||||
"unit, so promoting it means either guessing one or applying one "
|
||||
"inverter's rating to a sibling that does not have it. Nothing here is "
|
||||
"promoted - the refusal carries no shares at all",
|
||||
r_mis.allocation.plans == () and r_mis.allocation.dropped == ())
|
||||
check("...and a correctly routed cycle is unaffected, which is what says this "
|
||||
"grades ROUTING and not the presence of units: the same site claims with "
|
||||
"the floor where it belongs allocate normally",
|
||||
resolve(SITE_SIDE, units=TWO).allocated is True)
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -62,6 +62,13 @@ THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
||||
aged out, an hour the series does not cover, an unread state of charge and a
|
||||
peak guard that never ran are five DIFFERENT reasons - and none of them is
|
||||
an answer. Section 5 drives all five and asserts they are distinguishable.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import ast
|
||||
@@ -506,6 +513,16 @@ check(f"...while THE SAME MAGNITUDES with the sign flipped all DISCHARGE, so "
|
||||
# Not fixed here on purpose. D and F are TARIFF-03's rungs, they need probes in
|
||||
# test_static_tariff.py rather than in this file, and the card is explicit that
|
||||
# its fourth criterion is a look and not a refactor.
|
||||
#
|
||||
# ✅ CLOSED BY TEST-16, in test_static_tariff.py SECTION 9, and the two rows
|
||||
# above are kept as the diagnosis rather than as an open finding. D is pinned by
|
||||
# a sweep of small-but-real spreads down to 0.000001 EUR/kWh at
|
||||
# min_spread_eur_kwh=0.0; F is pinned by a ONE-FIRM-HOUR series graded on WHICH
|
||||
# RUNG'S SENTENCE came back, which is the only way it goes red without borrowing
|
||||
# the flat rung; and the masking is denied by a table that grades every stimulus
|
||||
# on its rung's attribution rather than on `kind`. Both are red on their own and
|
||||
# five checks are red with BOTH weakened at once. Ledger in that section's
|
||||
# header. Nothing in app/ changed - these rungs were correct all along.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Runnable check for TEST-15 - the mutation harness's own honesty.
|
||||
`python3 test_bytecode.py`
|
||||
|
||||
Every other file in this directory grades the add-on. This one grades the thing
|
||||
that grades the add-on, because in this repository a mutation run IS the
|
||||
evidence: "I broke it, the suite went red, I put it back, the suite went green"
|
||||
is how nearly every safety claim here was established.
|
||||
|
||||
THE DEFECT, found by FCST-03's developer and reproduced below. CPython decides a
|
||||
cached `.pyc` is still good from two numbers: the source's SIZE, and its
|
||||
MTIME TRUNCATED TO WHOLE SECONDS. Reverting `self.provisional` to
|
||||
`here.provisional` moves neither - same length, same second - so the interpreter
|
||||
loaded the bytecode it already had. Confirmed then by disassembly: the source
|
||||
read `here`, the running code loaded `self`. The mutation was reported as a
|
||||
survivor when in truth it had never run.
|
||||
|
||||
WHICH DIRECTION IT LIES IN. A mutation that never took effect makes the suite
|
||||
stay GREEN, so it is reported as a survivor: stale bytecode MANUFACTURES
|
||||
SURVIVORS. The opposite - a revert that never took, leaving a red on correct
|
||||
source - is possible too but announces itself, because a developer chases an
|
||||
unexplained failure. That asymmetry is why the coverage-audit table in
|
||||
test_control.py is a lower bound rather than nonsense, and it is what the survey
|
||||
in MUTATION.md is scoped by.
|
||||
|
||||
WHAT THIS FILE PINS, in the order the card's acceptance criteria name it:
|
||||
|
||||
AC1 The defect reproduces (part 1), the two mitigations that DO NOT fix it
|
||||
are shown not to (part 2), the one that does is shown to (part 3), and
|
||||
the shipped harness is shown to close it end to end on a tree that would
|
||||
otherwise mis-grade (part 4). Executed, not reasoned: every part builds a
|
||||
real package, imports it in a real subprocess, and reads back which
|
||||
value the running code returned.
|
||||
AC2 The guarantee is where a developer writing a mutation reads it: part 5
|
||||
requires EVERY test_*.py in this directory to point at MUTATION.md. The
|
||||
list is derived from the tree, so a new suite that joins without the
|
||||
pointer is red - CI-01's rule, for CI-01's reason.
|
||||
AC4 Part 6 pins the two properties that make CI immune, on the workflow file
|
||||
itself.
|
||||
|
||||
⚠️ NOTHING HERE SLEEPS. One-second mtime resolution is the mechanism; a harness
|
||||
that waits out the clock would be slower on every run and would still only cover
|
||||
the edit somebody remembered to slow down. Part 1 REPRODUCES the collision
|
||||
deliberately (it retries until two writes land in the same second, which takes
|
||||
milliseconds) rather than avoiding it.
|
||||
|
||||
⚠️ This file writes only under a temporary directory. It must stay that way: CI
|
||||
mounts the checkout READ-ONLY, and a check that needs to write into the tree
|
||||
would fail there for a reason that has nothing to do with what it grades.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or use
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ. See MUTATION.md.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
if cond:
|
||||
print(f" ok {name}")
|
||||
else:
|
||||
print(f" FAIL {name}")
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The fixture. `who()` returns one of two names that are THE SAME LENGTH, so
|
||||
# swapping them leaves the file byte count identical - the first half of
|
||||
# CPython's staleness test. The second half is the whole-second mtime, which
|
||||
# part 1 arranges by writing both versions inside one second.
|
||||
#
|
||||
# It is deliberately not a copy of a shipped module: the property under test is
|
||||
# CPython's, and using real code here would make this file go red whenever that
|
||||
# module changed for unrelated reasons.
|
||||
# ---------------------------------------------------------------------------
|
||||
V_OLD = 'def who():\n self = "SELF"\n here = "HERE"\n return self\n'
|
||||
V_NEW = 'def who():\n self = "SELF"\n here = "HERE"\n return here\n'
|
||||
assert len(V_OLD) == len(V_NEW), "the fixture must be size-preserving or it grades nothing"
|
||||
|
||||
PROBE = "from app.m import who\nprint(who())\n"
|
||||
# The harness runs `test_*.py` files and grades them by exit status, so the
|
||||
# probe wears that shape in part 4: it FAILS when it sees the pre-mutation value.
|
||||
TEST_PROBE = (
|
||||
"from app.m import who\n"
|
||||
"v = who()\n"
|
||||
"print('RUNS:', v)\n"
|
||||
"raise SystemExit(0 if v == 'HERE' else 1)\n"
|
||||
)
|
||||
|
||||
|
||||
def run(tree, args, env_extra=None, script="probe.py"):
|
||||
env = dict(os.environ)
|
||||
env.pop("PYTHONDONTWRITEBYTECODE", None)
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
out = subprocess.run(
|
||||
[sys.executable] + args + [script],
|
||||
cwd=tree, env=env, capture_output=True, text=True,
|
||||
)
|
||||
return out.stdout.strip()
|
||||
|
||||
|
||||
def caches(tree):
|
||||
return sorted(str(p.relative_to(tree)) for p in Path(tree).rglob("__pycache__"))
|
||||
|
||||
|
||||
def seed(tree):
|
||||
"""Leave `tree` holding V_NEW on disk and V_OLD in its bytecode cache.
|
||||
|
||||
Returns True when the collision was actually arranged. Both writes must land
|
||||
in the same whole second; at a few milliseconds per cycle that is the normal
|
||||
case, and the caller retries rather than asserting on one attempt, because a
|
||||
machine that happens to cross a second boundary mid-cycle would otherwise
|
||||
report a defect in CPython instead of a slow disk.
|
||||
"""
|
||||
app = Path(tree) / "app"
|
||||
shutil.rmtree(app, ignore_errors=True)
|
||||
app.mkdir(parents=True)
|
||||
(app / "__init__.py").write_text("")
|
||||
(Path(tree) / "probe.py").write_text(PROBE)
|
||||
(Path(tree) / "test_probe.py").write_text(TEST_PROBE)
|
||||
|
||||
m = app / "m.py"
|
||||
m.write_text(V_OLD)
|
||||
t0 = m.stat().st_mtime
|
||||
run(tree, []) # compiles V_OLD, writes app/__pycache__
|
||||
m.write_text(V_NEW) # the "revert": same size, same second
|
||||
t1 = m.stat().st_mtime
|
||||
return int(t0) == int(t1) and caches(tree) != []
|
||||
|
||||
|
||||
def seeded_tree(stack):
|
||||
tree = tempfile.mkdtemp(prefix="test_bytecode.")
|
||||
stack.append(tree)
|
||||
for _ in range(40):
|
||||
if seed(tree):
|
||||
return tree
|
||||
return None
|
||||
|
||||
|
||||
tmpdirs = []
|
||||
print("stale bytecode (TEST-15)")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n1. the defect: a same-size edit inside one second is not seen at all")
|
||||
# ---------------------------------------------------------------------------
|
||||
tree = seeded_tree(tmpdirs)
|
||||
check("the collision can be arranged at all (same size, same second)", tree is not None)
|
||||
if tree is None:
|
||||
print(" cannot continue - the fixture never landed two writes in one second")
|
||||
raise SystemExit(1)
|
||||
|
||||
src = (Path(tree) / "app" / "m.py").read_text()
|
||||
check("the source on disk says HERE", "return here" in src and "return self" not in src)
|
||||
check("...and a plain interpreter runs SELF anyway", run(tree, []) == "SELF")
|
||||
# Stated as its own check because this is the whole ticket in one line: what the
|
||||
# file says and what the process executes have come apart, with no error, no
|
||||
# warning, and a zero exit status.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n2. the two mitigations that do NOT close it")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ READ THIS BEFORE 'FIXING' A HARNESS WITH -B ALONE. Both of these stop
|
||||
# bytecode being WRITTEN. Neither stops a stale one being READ, and a mutation
|
||||
# run always has one already: the previous run wrote it. The advice in
|
||||
# test_control.py's coverage audit said `-B` first and CLEAR the cache second;
|
||||
# the order is not a style choice, the clear is the half that works.
|
||||
check("`python3 -B` over an existing cache still runs SELF", run(tree, ["-B"]) == "SELF")
|
||||
check(
|
||||
"PYTHONDONTWRITEBYTECODE=1 over an existing cache still runs SELF",
|
||||
run(tree, [], {"PYTHONDONTWRITEBYTECODE": "1"}) == "SELF",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n3. the purge closes it, and -B keeps the next run clean")
|
||||
# ---------------------------------------------------------------------------
|
||||
for p in Path(tree).rglob("__pycache__"):
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
check("after the purge, the plain interpreter runs HERE", run(tree, []) == "HERE")
|
||||
check("...and it has written a fresh cache back", caches(tree) != [])
|
||||
|
||||
tree2 = seeded_tree(tmpdirs)
|
||||
check("second fixture seeded", tree2 is not None)
|
||||
if tree2:
|
||||
for p in Path(tree2).rglob("__pycache__"):
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
check("purge + -B runs HERE", run(tree2, ["-B"]) == "HERE")
|
||||
check("...and leaves NO cache for the next mutation to inherit", caches(tree2) == [])
|
||||
# The second half is why the harness passes -B at all. Purging before a run
|
||||
# protects THIS run; not writing one protects the NEXT edit, which is the
|
||||
# one nobody is thinking about when they are three mutations into a batch.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n4. the shipped harness, executed against a tree that would mis-grade")
|
||||
# ---------------------------------------------------------------------------
|
||||
harness = HERE / "tools" / "mutation_run.sh"
|
||||
check("tools/mutation_run.sh exists", harness.is_file())
|
||||
check("...and is executable", os.access(harness, os.X_OK))
|
||||
|
||||
tree3 = seeded_tree(tmpdirs)
|
||||
check("third fixture seeded", tree3 is not None)
|
||||
if tree3 and harness.is_file():
|
||||
# NON-VACUITY FIRST. If the naked run below were already green, the harness
|
||||
# run after it would prove nothing - it would be passing on a tree that
|
||||
# needed no help. This is the same trap test_peakguard.py part 7 documents:
|
||||
# a mutation harness that patches nothing passes beautifully.
|
||||
naked = subprocess.run(
|
||||
[sys.executable, "test_probe.py"], cwd=tree3, capture_output=True, text=True
|
||||
)
|
||||
check("without the harness the tree grades the STALE source (rc!=0)", naked.returncode != 0)
|
||||
check("...and says so: it saw SELF", "SELF" in naked.stdout)
|
||||
|
||||
got = subprocess.run(
|
||||
["sh", str(harness), tree3], cwd=tree3, capture_output=True, text=True
|
||||
)
|
||||
check("the harness grades the source on disk (rc==0)", got.returncode == 0)
|
||||
check("...having reported the purge", "no bytecode cache" in got.stdout)
|
||||
check("...and it leaves no cache behind", caches(tree3) == [])
|
||||
if got.returncode != 0:
|
||||
print(got.stdout[-2000:])
|
||||
print(got.stderr[-2000:])
|
||||
|
||||
# The harness must also refuse to report on nothing - CI-01's zero-file rule.
|
||||
empty = tempfile.mkdtemp(prefix="test_bytecode.empty.")
|
||||
tmpdirs.append(empty)
|
||||
none = subprocess.run(
|
||||
["sh", str(harness), empty], cwd=empty, capture_output=True, text=True
|
||||
)
|
||||
check("the harness refuses a tree with no test_*.py", none.returncode != 0)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n5. the guarantee is stated where a mutation gets written")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ THE LIST IS DERIVED FROM THE TREE. A hand-written one is how a new suite
|
||||
# joins this directory carrying no warning at all, which is the exact shape of
|
||||
# failure CI-01's file glob exists to prevent. If you add a test_*.py, put the
|
||||
# pointer in its docstring; that is the whole cost.
|
||||
doc = HERE / "MUTATION.md"
|
||||
check("MUTATION.md exists", doc.is_file())
|
||||
if doc.is_file():
|
||||
text = doc.read_text()
|
||||
check("...and it names the purge as the load-bearing half", "__pycache__" in text and "purge" in text.lower())
|
||||
check("...and warns that -B alone does not read fresh", "-B" in text)
|
||||
check("...and points at the harness", "mutation_run.sh" in text)
|
||||
|
||||
suites = sorted(p.name for p in HERE.glob("test_*.py"))
|
||||
check("there are test suites to survey at all", len(suites) >= 15)
|
||||
missing = [n for n in suites if "MUTATION.md" not in (HERE / n).read_text()]
|
||||
check(f"every one of the {len(suites)} suites points at MUTATION.md", missing == [])
|
||||
if missing:
|
||||
print(" missing the pointer:", ", ".join(missing))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n6. CI cannot carry a stale artefact between jobs")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ THE FIRST TWO PROPERTIES ARE THE ONES THAT ACTUALLY DO THE WORK, AND THEY
|
||||
# PREDATE THIS CARD. Measured for TEST-15, not assumed: with the checkout bind
|
||||
# mounted READ-ONLY the interpreter writes no __pycache__ into the tree at all,
|
||||
# and it does so SILENTLY - no error, no warning, exit status 0. The workflow
|
||||
# also `rm -rf`s its workspace and clones afresh, so nothing survives from a
|
||||
# previous job even on the same runner. PYTHONDONTWRITEBYTECODE=1 is belt and
|
||||
# braces on top: it is what keeps this true if somebody ever needs to drop the
|
||||
# `:ro`, and dropping the `:ro` is a one-character edit that nothing else here
|
||||
# would notice.
|
||||
wf = HERE.parent / ".gitea" / "workflows" / "checks.yml"
|
||||
check("the workflow file is where this expects it", wf.is_file())
|
||||
if wf.is_file():
|
||||
y = wf.read_text()
|
||||
# ⚠️ GRADED ON THE COMMAND LINE, NOT ON THE FILE. A `in y` search over the
|
||||
# whole workflow passes on the PROSE around the step - this file's first
|
||||
# draft did exactly that, and stayed green after the `-e` flag and the `:ro`
|
||||
# were deleted, because the comment explaining them still named both.
|
||||
runs = [l for l in y.splitlines() if "docker run" in l and ":/w" in l]
|
||||
check("exactly one docker run mounts the checkout", len(runs) == 1)
|
||||
line = runs[0] if runs else ""
|
||||
check("the checkout is mounted read-only", ':/w:ro"' in line)
|
||||
check("PYTHONDONTWRITEBYTECODE=1 is set on that run", "-e PYTHONDONTWRITEBYTECODE=1" in line)
|
||||
check("the workspace is removed before each checkout", 'rm -rf "$WS"' in y)
|
||||
check("no sleep was introduced to dodge the mtime resolution", "sleep " not in y)
|
||||
|
||||
for d in tmpdirs:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Runnable check for CI-02 - what a green from `checks / unit` is a claim about.
|
||||
`python3 test_ci_gate.py`
|
||||
|
||||
test_bytecode.py grades the thing that grades the add-on. This file grades the
|
||||
thing that decides whether anything was graded at all.
|
||||
|
||||
THE DEFECT, and the correction the measurement forced. CI-02 was filed saying
|
||||
that a CONFLICTED pull request loses its merge ref, so that
|
||||
|
||||
git fetch -q --depth 1 origin "$GITHUB_REF" \
|
||||
|| git fetch -q --depth 1 origin "$GITHUB_SHA"
|
||||
|
||||
falls through to the second half and quietly grades the bare branch head. PR #14
|
||||
did report `checks / unit -> success` while `mergeable: false`, so the symptom
|
||||
was real. The mechanism was not. Measured on a deliberately conflicted throwaway
|
||||
pull request (#29, runs 558 and 559 - one question per STEP, because this
|
||||
Gitea's Actions log endpoints 404 and a step's conclusion is the only channel
|
||||
out of a job):
|
||||
|
||||
GITHUB_REF == refs/pull/<n>/head P01 passed
|
||||
GITHUB_REF != refs/pull/<n>/merge P02 failed
|
||||
GITHUB_SHA == the pull request head sha P03 passed
|
||||
fetching GITHUB_REF succeeds P04 passed <- even conflicted
|
||||
...and it lands on the bare head P05 passed
|
||||
refs/pull/<n>/merge does not exist P06 failed
|
||||
|
||||
`git ls-remote origin 'refs/pull/*'` on this server advertises only `/head`, for
|
||||
open and closed pull requests alike. THERE ARE NO MERGE REFS HERE. So the first
|
||||
half of the `||` always succeeded, the fallback never fired once, and both halves
|
||||
named the same commit anyway. The gate has never 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 branch that has moved underneath it, is not the
|
||||
content that lands. `block_on_outdated_branch` is off on release/1.0, so the
|
||||
base usually has moved.
|
||||
|
||||
WHAT THIS FILE PINS.
|
||||
|
||||
Part 1 The context string branch protection requires is assembled from two
|
||||
tokens in checks.yml. Renaming either makes every open and future
|
||||
pull request unmergeable forever, silently, because Gitea waits on a
|
||||
context it will now never receive. This part is the tripwire on that.
|
||||
Part 2 The three absences in checks.yml that are load-bearing, each graded
|
||||
on the command lines rather than on the prose around them.
|
||||
Part 3 THE DEFECT, REPRODUCED. The old one-line checkout is run verbatim
|
||||
against a real conflicted pull request built here, and is shown to
|
||||
exit 0 having checked out content that cannot land.
|
||||
Part 4 THE FIX, EXECUTED. The checkout and merge steps are EXTRACTED FROM
|
||||
checks.yml AND RUN - the shipped commands, not a paraphrase - against
|
||||
a pull request that merges. The tree they leave behind is the merge,
|
||||
and is shown to differ from the head the old line would have graded.
|
||||
Part 5 The same two steps against a pull request that does NOT merge: the
|
||||
merge step exits non-zero, says nothing was tested, and never prints
|
||||
a GRADED line.
|
||||
Part 6 The gate announces which content it graded, on both paths, so that a
|
||||
reader can tell merged content from a bare ref (CI-02 AC2).
|
||||
|
||||
⚠️ THIS FILE RUNS THE WORKFLOW'S OWN SHELL. That is the point: a check that
|
||||
read those commands as a string and matched them against an expectation would
|
||||
be exactly the failure this repository keeps finding - a reading that never
|
||||
observed its subject. The extractor below is deliberately brittle about finding
|
||||
the step it wants: if checks.yml is restructured so that the step cannot be
|
||||
found, this file goes RED rather than quietly grading nothing.
|
||||
|
||||
⚠️ NEEDS `git`, which is why .gitea/ci/Dockerfile installs it. No network: the
|
||||
fixtures are local repositories built in a temp directory, and the workflow's
|
||||
`git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:...@#")"`
|
||||
is a no-op substitution on a path with no `://` in it, which is what lets the
|
||||
shipped line be used verbatim.
|
||||
|
||||
⚠️ This file writes only under a temporary directory. It must stay that way:
|
||||
CI mounts the checkout READ-ONLY.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or use
|
||||
`tools/mutation_run.sh`, which does. See MUTATION.md. (There is no module here
|
||||
to mutate; the subject is checks.yml. Break a line of it and re-run this file
|
||||
expecting red - if it stays green, it was never watching.)
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
WF = HERE.parent / ".gitea" / "workflows" / "checks.yml"
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
if cond:
|
||||
print(f" ok {name}")
|
||||
else:
|
||||
print(f" FAIL {name}")
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reading checks.yml. There is no yaml module in the image the checks run in
|
||||
# (alpine py3-aiohttp py3-paho-mqtt, and adding a parser to grade five lines
|
||||
# would be a worse trade than a small parser that fails loudly), so the two
|
||||
# things this file needs are pulled out by hand:
|
||||
#
|
||||
# * the workflow name and the job keys, for part 1
|
||||
# * the `run:` block of a named step, verbatim, for parts 3 to 6
|
||||
#
|
||||
# COMMAND LINES ONLY WHERE IT MATTERS. test_bytecode.py's part 6 learned this
|
||||
# the hard way: an `in y` search over a whole workflow passes on the PROSE that
|
||||
# explains a flag, and stayed green after the flag itself was deleted. Every
|
||||
# absence checked in part 2 is checked against the file with its comment lines
|
||||
# stripped.
|
||||
# ---------------------------------------------------------------------------
|
||||
if not WF.is_file():
|
||||
print(f"cannot find the workflow at {WF} - nothing to grade")
|
||||
raise SystemExit(1)
|
||||
YAML = WF.read_text()
|
||||
CODE = "\n".join(l for l in YAML.splitlines() if not l.lstrip().startswith("#"))
|
||||
|
||||
|
||||
def workflow_name():
|
||||
for line in YAML.splitlines():
|
||||
if line.startswith("name:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
def job_keys():
|
||||
out, seen = [], False
|
||||
for line in YAML.splitlines():
|
||||
if line.startswith("jobs:"):
|
||||
seen = True
|
||||
continue
|
||||
if seen:
|
||||
if line and not line[0].isspace():
|
||||
break
|
||||
if line.startswith(" ") and not line.startswith(" ") and line.rstrip().endswith(":"):
|
||||
out.append(line.strip()[:-1])
|
||||
return out
|
||||
|
||||
|
||||
def step_script(name_prefix):
|
||||
"""Return the `run: |` body of the step whose name starts with name_prefix."""
|
||||
lines = YAML.splitlines()
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if lines[i].strip().startswith("- name:") and name_prefix in lines[i]:
|
||||
break
|
||||
i += 1
|
||||
else:
|
||||
return None
|
||||
if i >= len(lines):
|
||||
return None
|
||||
while i < len(lines) and lines[i].strip() != "run: |":
|
||||
# stop at the next step so a missing block cannot borrow the next one's
|
||||
if i > 0 and lines[i].strip().startswith("- name:") and name_prefix not in lines[i]:
|
||||
return None
|
||||
i += 1
|
||||
if i >= len(lines):
|
||||
return None
|
||||
body, i = [], i + 1
|
||||
while i < len(lines):
|
||||
l = lines[i]
|
||||
if l.strip() == "":
|
||||
body.append("")
|
||||
elif l.startswith(" " * 10):
|
||||
body.append(l[10:])
|
||||
else:
|
||||
break
|
||||
i += 1
|
||||
return "\n".join(body).strip("\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The fixture: a real bare repository with a base branch and a refs/pull/N/head,
|
||||
# built to order. `conflict=True` makes the pull request touch the same lines
|
||||
# the base moved, which is how PR #29 was made unmergeable on the server.
|
||||
#
|
||||
# The shapes matter. In BOTH cases the pull request adds `from_pr.txt` and the
|
||||
# base adds `from_base.txt` AFTER the branch point, so the branch head is never
|
||||
# the content that lands even when it merges perfectly - that difference is what
|
||||
# part 4 measures, and it is the ordinary case, not the pathological one.
|
||||
# ---------------------------------------------------------------------------
|
||||
GIT = ["git", "-c", "user.email=fixture@localhost", "-c", "user.name=fixture",
|
||||
"-c", "init.defaultBranch=release/1.0", "-c", "commit.gpgsign=false"]
|
||||
PRNUM = "7"
|
||||
BASEREF = "release/1.0"
|
||||
|
||||
|
||||
def git(cwd, *args, check_rc=True):
|
||||
r = subprocess.run(GIT + list(args), cwd=cwd, capture_output=True, text=True)
|
||||
if check_rc and r.returncode != 0:
|
||||
raise RuntimeError(f"git {' '.join(args)} failed in {cwd}:\n{r.stderr}")
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def build_origin(stack, conflict):
|
||||
"""A bare repo carrying refs/heads/release/1.0 and refs/pull/7/head."""
|
||||
root = Path(tempfile.mkdtemp(prefix="test_ci_gate."))
|
||||
stack.append(str(root))
|
||||
work, bare = root / "work", root / "origin.git"
|
||||
work.mkdir()
|
||||
git(work, "init", "-q", ".")
|
||||
(work / "shared.txt").write_text("one\ntwo\nthree\n")
|
||||
git(work, "add", "-A")
|
||||
git(work, "commit", "-qm", "branch point")
|
||||
fork = git(work, "rev-parse", "HEAD")
|
||||
|
||||
# the base moves on
|
||||
(work / "from_base.txt").write_text("only the base has this\n")
|
||||
if conflict:
|
||||
(work / "shared.txt").write_text("one\nTWO FROM BASE\nthree\n")
|
||||
git(work, "add", "-A")
|
||||
git(work, "commit", "-qm", "the base branch moves on")
|
||||
base_sha = git(work, "rev-parse", "HEAD")
|
||||
|
||||
# the pull request branches from the fork point
|
||||
git(work, "checkout", "-q", "-b", "pr", fork)
|
||||
(work / "from_pr.txt").write_text("only the pull request has this\n")
|
||||
if conflict:
|
||||
(work / "shared.txt").write_text("one\nTWO FROM THE PULL REQUEST\nthree\n")
|
||||
git(work, "add", "-A")
|
||||
git(work, "commit", "-qm", "the pull request")
|
||||
pr_sha = git(work, "rev-parse", "HEAD")
|
||||
|
||||
git(root, "init", "-q", "--bare", str(bare))
|
||||
git(work, "remote", "add", "origin", str(bare))
|
||||
git(work, "push", "-q", "origin", f"{base_sha}:refs/heads/{BASEREF}")
|
||||
git(work, "push", "-q", "origin", f"{pr_sha}:refs/pull/{PRNUM}/head")
|
||||
return str(bare), base_sha, pr_sha, str(root)
|
||||
|
||||
|
||||
def run_script(script, ws, origin, pr_sha, extra=None):
|
||||
env = dict(os.environ)
|
||||
env.pop("PYTHONDONTWRITEBYTECODE", None)
|
||||
env.update(
|
||||
WS=ws, PRNUM=PRNUM, BASEREF=BASEREF, URL=origin, TOKEN="",
|
||||
GITHUB_REF=f"refs/pull/{PRNUM}/head", GITHUB_SHA=pr_sha,
|
||||
GIT_TERMINAL_PROMPT="0",
|
||||
)
|
||||
if extra:
|
||||
env.update(extra)
|
||||
return subprocess.run(["sh", "-c", script], env=env, capture_output=True, text=True)
|
||||
|
||||
|
||||
tmpdirs = []
|
||||
print("the gate's own honesty (CI-02)")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n1. the required context string, from the two tokens that build it")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ READ BEFORE RENAMING ANYTHING IN checks.yml. Branch protection on
|
||||
# release/1.0 has `enable_status_check: true` and
|
||||
# `status_check_contexts: ['checks / unit (pull_request)']`. Gitea assembles
|
||||
# that string from `name:` at the top of the workflow and the job KEY under
|
||||
# `jobs:`. Change either and Gitea starts posting a context nobody is waiting
|
||||
# for, while the one branch protection requires never arrives - so it stays
|
||||
# `pending` forever, on every open pull request and every future one, and
|
||||
# admin override is blocked. There is no error message anywhere in that
|
||||
# sequence. This check is the only thing between a tidy-up and that.
|
||||
REQUIRED = "checks / unit (pull_request)"
|
||||
name, jobs = workflow_name(), job_keys()
|
||||
check("the workflow still has a name", name is not None)
|
||||
check("the workflow declares exactly one job", len(jobs) == 1)
|
||||
built = f"{name} / {jobs[0]} (pull_request)" if name and len(jobs) == 1 else "<unparseable>"
|
||||
check(f"the context it builds is exactly {REQUIRED!r}", built == REQUIRED)
|
||||
if built != REQUIRED:
|
||||
print(f" it now builds {built!r} - branch protection will wait forever for {REQUIRED!r}")
|
||||
check("the workflow still triggers on pull_request", "pull_request:" in CODE)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n2. the three absences that are load-bearing")
|
||||
# ---------------------------------------------------------------------------
|
||||
# `paths:` is CI-01's: a workflow that learns to sit a pull request out leaves
|
||||
# the required context pending forever, which is unmergeable forever.
|
||||
check("no paths: filter", not any(l.strip().startswith("paths:") or l.strip().startswith("paths-ignore:")
|
||||
for l in CODE.splitlines()))
|
||||
# `concurrency:` is CI-02's, and it was MEASURED rather than reasoned about, on
|
||||
# throwaway PR #29 with one run holding the single runner slot and two more
|
||||
# pushes behind it:
|
||||
# no block at all -> runs 561 and 562 both queued, both executed
|
||||
# group + cancel-in-progress: false
|
||||
# -> run 564 sat `Blocked by required conditions`
|
||||
# and was cancelled at 0s the moment 565 existed
|
||||
# A shared group cancels a run that has not started regardless of that flag, and
|
||||
# a cancelled run posts `failure` on the required context ("Canceled after 0s"),
|
||||
# which branch protection cannot tell from a real test failure. Runs 531 and 533
|
||||
# on PR #14 died exactly this way, each at the same second the next run was
|
||||
# created. Having no group is the only setting measured to leave a queued run
|
||||
# alive.
|
||||
check("no concurrency: group", not any(l.startswith("concurrency:") for l in CODE.splitlines()))
|
||||
check("no cancel-in-progress anywhere in the commands", "cancel-in-progress" not in CODE)
|
||||
# The fallback itself. Graded on the command lines: the prose above the step
|
||||
# quotes the old line on purpose, and an `in YAML` search would match that.
|
||||
check("the || $GITHUB_SHA fallback is gone from the commands",
|
||||
not any("||" in l and "GITHUB_SHA" in l for l in CODE.splitlines()))
|
||||
check("the checkout is no longer shallow (a merge needs the merge base)",
|
||||
"--depth 1" not in CODE)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n3. the defect, reproduced: the old line grades a conflicted head, happily")
|
||||
# ---------------------------------------------------------------------------
|
||||
# NON-VACUITY FOR EVERYTHING BELOW. If this part were already red - if the old
|
||||
# line refused a conflicted pull request on its own - then parts 4 and 5 would
|
||||
# be measuring nothing, and the ticket would have no subject.
|
||||
origin, base_sha, pr_sha, _root = build_origin(tmpdirs, conflict=True)
|
||||
ws = tempfile.mkdtemp(prefix="test_ci_gate.ws.")
|
||||
tmpdirs.append(ws)
|
||||
OLD = '''
|
||||
set -eu
|
||||
rm -rf "$WS" && mkdir -p "$WS/goodwe-addon" && cd "$WS/goodwe-addon"
|
||||
git init -q .
|
||||
git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:${TOKEN}@#")"
|
||||
git fetch -q --depth 1 origin "$GITHUB_REF" || git fetch -q --depth 1 origin "$GITHUB_SHA"
|
||||
git checkout -qf FETCH_HEAD
|
||||
echo "checked out $(git log --oneline -1)"
|
||||
'''
|
||||
old = run_script(OLD, ws, origin, pr_sha)
|
||||
check("the pull request genuinely does not merge (the fixture is not lying)",
|
||||
subprocess.run(GIT + ["merge-tree", "--write-tree", base_sha, pr_sha],
|
||||
cwd=origin, capture_output=True, text=True).returncode != 0)
|
||||
check("...and the old checkout exits 0 on it anyway", old.returncode == 0)
|
||||
tree = Path(ws) / "goodwe-addon"
|
||||
check("...having checked out the bare head", (tree / "from_pr.txt").is_file())
|
||||
check("...which is missing what the base branch added", not (tree / "from_base.txt").is_file())
|
||||
check("...and it says nothing about which of the two refs it used",
|
||||
"GRADED" not in old.stdout and "REFUSED" not in old.stdout)
|
||||
if old.returncode != 0:
|
||||
print(old.stdout[-800:], old.stderr[-800:])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n4. the shipped steps, executed, on a pull request that MERGES")
|
||||
# ---------------------------------------------------------------------------
|
||||
CO = step_script("check out the base branch and the pull request head")
|
||||
MERGE = step_script("grade the merge, not the branch")
|
||||
check("the checkout step can be found in checks.yml", CO is not None)
|
||||
check("the merge/refuse step can be found in checks.yml", MERGE is not None)
|
||||
if CO is None or MERGE is None:
|
||||
print(" checks.yml has been restructured; this file grades nothing until it is updated")
|
||||
print()
|
||||
print(f"{len(fails)} FAILED")
|
||||
raise SystemExit(1)
|
||||
|
||||
origin2, base2, pr2, _r2 = build_origin(tmpdirs, conflict=False)
|
||||
ws2 = tempfile.mkdtemp(prefix="test_ci_gate.ws2.")
|
||||
tmpdirs.append(ws2)
|
||||
co = run_script(CO, ws2, origin2, pr2)
|
||||
mg = run_script(MERGE, ws2, origin2, pr2)
|
||||
check("the checkout step succeeds", co.returncode == 0)
|
||||
check("the merge step succeeds on a mergeable pull request", mg.returncode == 0)
|
||||
if co.returncode or mg.returncode:
|
||||
print(co.stdout[-600:], co.stderr[-600:], mg.stdout[-600:], mg.stderr[-600:])
|
||||
t2 = Path(ws2) / "goodwe-addon"
|
||||
check("what is left on disk carries the pull request's file", (t2 / "from_pr.txt").is_file())
|
||||
check("...AND the file the base branch added after the branch point", (t2 / "from_base.txt").is_file())
|
||||
# That second one is the whole difference. The old line graded a tree WITHOUT
|
||||
# from_base.txt - green about content that is not what lands - and nothing in
|
||||
# the run said so. This is the ordinary case: no conflict anywhere in it.
|
||||
check("the graded tree is exactly the tree of a real merge",
|
||||
git(t2, "rev-parse", "HEAD^{tree}") ==
|
||||
subprocess.run(GIT + ["merge-tree", "--write-tree", base2, pr2],
|
||||
cwd=origin2, capture_output=True, text=True).stdout.strip())
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n5. the same two steps on a pull request that does NOT merge")
|
||||
# ---------------------------------------------------------------------------
|
||||
ws3 = tempfile.mkdtemp(prefix="test_ci_gate.ws3.")
|
||||
tmpdirs.append(ws3)
|
||||
co3 = run_script(CO, ws3, origin, pr_sha)
|
||||
mg3 = run_script(MERGE, ws3, origin, pr_sha)
|
||||
check("the checkout step still succeeds - a conflict does not stop a fetch",
|
||||
co3.returncode == 0)
|
||||
# Stated as its own check because it is why the defect was invisible: nothing
|
||||
# about a conflicted pull request makes any git fetch fail on this server.
|
||||
check("the merge step REFUSES (non-zero)", mg3.returncode != 0)
|
||||
check("...and says nothing was tested, not that a test failed",
|
||||
"REFUSED" in mg3.stdout and "NOTHING HAS BEEN TESTED" in mg3.stdout)
|
||||
check("...and never claims to have graded anything", "GRADED:" not in mg3.stdout)
|
||||
check("...and leaves no half-merged tree behind for a later step to grade",
|
||||
not (Path(ws3) / "goodwe-addon" / "from_pr.txt").is_file()
|
||||
or git(Path(ws3) / "goodwe-addon", "rev-parse", "HEAD") == base_sha)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n6. the run says which content it graded (CI-02 AC2)")
|
||||
# ---------------------------------------------------------------------------
|
||||
check("the mergeable run announces the merge it graded", "GRADED:" in mg.stdout)
|
||||
check("...naming the base branch it merged into", BASEREF in mg.stdout)
|
||||
check("...and the tree, which is the only unambiguous name for content",
|
||||
"tree" in mg.stdout and git(t2, "rev-parse", "HEAD^{tree}")[:12] in mg.stdout)
|
||||
# The no-pull-request path (workflow_dispatch) keeps a fallback, because a
|
||||
# branch really can vanish between the dispatch and the fetch. It has to say so.
|
||||
disp = run_script(CO, tempfile.mkdtemp(prefix="test_ci_gate.ws4."), origin2, pr2,
|
||||
extra={"PRNUM": "", "GITHUB_REF": f"refs/heads/{BASEREF}"})
|
||||
check("the no-pull-request path still checks something out", disp.returncode == 0)
|
||||
check("...and announces that it is NOT merged content",
|
||||
"GRADED:" in disp.stdout and "NOT merged content" in disp.stdout)
|
||||
if disp.returncode != 0:
|
||||
print(disp.stdout[-600:], disp.stderr[-600:])
|
||||
|
||||
for d in tmpdirs:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
No framework, asserts only, same house style as test_freeze.py.
|
||||
|
||||
Five parts, and parts 3 and 4 are the ones that carry the acceptance criteria:
|
||||
Seven parts, and parts 3 and 4 are the ones that carry SAFETY-03's own
|
||||
acceptance criteria; part 6 is TEST-11's and part 7 is SAFETY-28's:
|
||||
|
||||
1. THE CLAMP ARITHMETIC, on the shipped `clamp_to_rating()` - boundaries,
|
||||
symmetry, the device second stage, and the non-finite cases. Table driven.
|
||||
@@ -23,6 +24,20 @@ Five parts, and parts 3 and 4 are the ones that carry the acceptance criteria:
|
||||
5. NO PATH BYPASSES IT. write_setpoint() is the only route to the wire, so it
|
||||
is called directly with an out-of-range value the way the heartbeat and the
|
||||
operator's buttons call it.
|
||||
6. TEST-11. THE GUARD READS THE WRITE THAT JUST HAPPENED. `self.clamp_last`
|
||||
is assigned by `write_setpoint()` and by nothing else, so a
|
||||
`check_output_clamp()` moved above the write keeps its loop-side evidence
|
||||
and silently loses its write-side half. Graded as a CONSEQUENCE - what the
|
||||
watchdog could see when it was consulted - so that a rename or a helper
|
||||
does not trip it, and with the write-path half exercised on a stimulus of
|
||||
its own, because parts 3-5 never produce one through cycle().
|
||||
7. SAFETY-28. THE WRITE PATH IS GATED ON THE ALLOCATION RECORD - a value the
|
||||
single allocator did not place on THIS tick does not reach the wire, a
|
||||
record from a previous tick cannot authorise one, and 0 W is authorised
|
||||
unconditionally so the failsafe can never deadlock behind the gate. This is
|
||||
the check that replaced a config-shape proxy: `ACTUATOR_WRITE_KEYS` graded
|
||||
how many option KEYS exist, which stays 1 while a per-inverter config adds
|
||||
a second real writer.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHY PART 4 ALSO ASSERTS ON `Decision`'s BOOLEANS AND NOT ON `reason`
|
||||
@@ -47,9 +62,17 @@ WHAT THIS CHECK DOES NOT COVER
|
||||
asserted here over every value that reached `set_number()`, which is the last
|
||||
thing this process controls; proving it at the inverter needs the witness log
|
||||
and belongs with DRIVER-07.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import math
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -58,7 +81,10 @@ from app.clamp import (
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, ClampWatchdog, clamp_to_rating,
|
||||
is_commandable,
|
||||
)
|
||||
from app.allocator import Allocation, UnitPlan, plan
|
||||
from app.arbiter import P_LOOP, Claim, UnitBounds, allocate, resolve
|
||||
from app.control import Tuning, compute
|
||||
from app.inverter import Inverter
|
||||
from app.main import Controller
|
||||
|
||||
fails = []
|
||||
@@ -516,16 +542,48 @@ print()
|
||||
print("5. no path reaches the wire around the clamp")
|
||||
|
||||
|
||||
def arm(c, value):
|
||||
"""Give `c` a real allocation record authorising `value` on the CURRENT tick.
|
||||
|
||||
⚠️ SAFETY-28 MADE THIS NECESSARY, AND THE FACT THAT IT IS NECESSARY IS THE
|
||||
TICKET. `write_setpoint()` now refuses any value the single allocator did
|
||||
not place on this tick, so the probes below - which call it directly, the
|
||||
way the heartbeat and the operator's buttons do - have to say where their
|
||||
number came from. Before this they did not, and that was precisely the hole:
|
||||
a second writer's number reached the wire on nothing but the fact that
|
||||
somebody called the method.
|
||||
|
||||
⚠️ AND IT IS BUILT THROUGH THE SHIPPED `resolve()`, NOT BY HAND. A helper
|
||||
that assembled an `Allocation` itself would be a second allocator living in
|
||||
the test file, and every check below would then be grading the fixture's
|
||||
idea of a record rather than the one main.py produces. The tick is bumped
|
||||
the same way `cycle()` bumps it, for the same reason.
|
||||
"""
|
||||
c.alloc_tick += 1
|
||||
res = resolve([Claim.set("loop", P_LOOP, value, "test stimulus")],
|
||||
units=c.units, live=c.units, tick=c.alloc_tick)
|
||||
c.allocation = res.allocation
|
||||
return res.allocation
|
||||
|
||||
|
||||
async def _direct():
|
||||
hass = _Hass(0.0)
|
||||
c = Controller({"setpoint_entity": "number.sp", "max_w": 2000,
|
||||
"meter_source": "off"},
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
# The shape the heartbeat, shutdown() and handle_action() all use.
|
||||
await c.write_setpoint(9999.0, force=True)
|
||||
await c.write_setpoint(-9999.0, force=True)
|
||||
# The shape the heartbeat, shutdown() and handle_action() all use - each one
|
||||
# authorised by this tick's allocation, which is what those callers have
|
||||
# too: the heartbeat re-affirms `self.target`, and the buttons write 0 W,
|
||||
# which needs no allocator at all (see part 7).
|
||||
for wanted in (9999.0, -9999.0):
|
||||
arm(c, wanted)
|
||||
await c.write_setpoint(wanted, force=True)
|
||||
# A nan is loss of input, not an unauthorised write: rule 1 turns it into
|
||||
# 0 W ABOVE the gate, and 0 W is always authorised. No arming, on purpose -
|
||||
# if the gate ever moved above the coercion this line would go red.
|
||||
await c.write_setpoint(float("nan"), force=True)
|
||||
arm(c, 1500.0)
|
||||
await c.write_setpoint(1500.0, force=True)
|
||||
return hass, c
|
||||
|
||||
@@ -543,6 +601,618 @@ check("an in-range value is untouched", hass_d.writes[3] == 1500.0)
|
||||
check("every emitted frame satisfies |emitted| <= the rating",
|
||||
all(abs(w) <= 2000.0 for w in hass_d.writes))
|
||||
|
||||
# --- 5b. ALLOC-01: AND THE ALLOCATOR DID NOT ADD ONE ------------------------
|
||||
# ⚠️ "NO PATH REACHES THE WIRE AROUND THE CLAMP" IS A CLAIM ABOUT THE WHOLE
|
||||
# ADD-ON, so it has to be re-made every time a module learns to produce a number
|
||||
# that looks like a setpoint. ALLOC-01 added `app/allocator.py`, whose output is
|
||||
# a dict of per-inverter watts - numbers that are exactly the shape of a thing
|
||||
# somebody would write - and the temptation it creates is real: a second writer
|
||||
# for a second inverter is DEV-02's card, and the wrong way to build it is a
|
||||
# `set_number` call next to the allocation.
|
||||
#
|
||||
# Two checks, and neither is about the allocator's arithmetic:
|
||||
# * it CANNOT write, and that is established from its imports rather than
|
||||
# from its behaviour, because a behavioural check only sees the paths a
|
||||
# fixture happens to take;
|
||||
# * at the shipped roster the number that reaches the wire is still the
|
||||
# arbiter's `target_w`, unclamped by the allocator, so SAFETY-03's clamp is
|
||||
# still the binding one and not a second opinion after a first clamp.
|
||||
import app.allocator as _alloc_mod # noqa: E402
|
||||
|
||||
_alloc_src = io.open(_alloc_mod.__file__, encoding="utf-8").read()
|
||||
_alloc_imports = sorted(
|
||||
n.split(".")[0]
|
||||
for line in _alloc_src.splitlines() if line.startswith(("import ", "from "))
|
||||
for n in [line.split()[1]])
|
||||
print(f" app/allocator.py imports: {_alloc_imports}")
|
||||
check("the allocator imports NOTHING that can reach the wire - no `hass`, no "
|
||||
"`aiohttp`, not even `main`. It is a pure function of intervals and a "
|
||||
"number, so 'it cannot write' is a property of the file rather than of "
|
||||
"the paths a fixture happened to take",
|
||||
_alloc_imports == ["dataclasses", "math"])
|
||||
check("...and it never mentions the setpoint entity, `set_number`, or the "
|
||||
"clamp - the three names any bypass would have to spell",
|
||||
not any(n in _alloc_src for n in ("set_number", "setpoint_entity",
|
||||
"clamp_to_rating")))
|
||||
check("...and it must not: it has no measurement either. The allocator reading "
|
||||
"P1 would be a SECOND integrator on the site measurement, which is the "
|
||||
"same two-writers failure one level up",
|
||||
not any(n in _alloc_src for n in ("p1", "P1Ingest", "grid_w")))
|
||||
|
||||
check("at the shipped roster of ONE the allocator is a pass-through, so the "
|
||||
"number handed to write_setpoint() is the arbiter's own target and "
|
||||
"SAFETY-03's clamp is still the FIRST and only clamp it meets. An "
|
||||
"allocator that pre-clamped here would leave this file grading a value "
|
||||
"that was already in range, and the clamp untested on the live path",
|
||||
allocate(9999.0, {"inverter-a": UnitBounds()}) == ({"inverter-a": 9999.0},
|
||||
True))
|
||||
check("...and that unclamped 9999 W is exactly what part 5 above just proved "
|
||||
"the write path stops at the rating. The two halves meet here: the "
|
||||
"allocator does not clamp, the wire does",
|
||||
hass_d.writes[0] == 2000.0)
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("6. TEST-11: the guard's evidence is THIS cycle's write, not the last one")
|
||||
|
||||
# WHY THIS PART EXISTS. `check_output_clamp()` sits one line below
|
||||
# `await self.write_setpoint(self.target)` in cycle(), and moving it above the
|
||||
# write left this file, test_freeze.py and test_loop.py ALL GREEN. That is not a
|
||||
# missing assertion about line numbers - it is a missing assertion about
|
||||
# EVIDENCE. The guard has two sources:
|
||||
#
|
||||
# * `self.loop_clamped`, set from the Decision well ABOVE the write. Still
|
||||
# seen wherever the call is put, which is why the move is nearly invisible:
|
||||
# on the §10.3 replay above, every engagement the alarm reports is this one.
|
||||
# * `self.clamp_last`, which ONLY `write_setpoint()` assigns (main.py) and
|
||||
# which is None until it has run. This half is what loses its evidence.
|
||||
#
|
||||
# So the check below does not pin the call's POSITION IN THE SOURCE, and must
|
||||
# not: the comment at that line warns against tidying SAFETY-03's ordering and
|
||||
# SAFETY-18's into one rule, and a check a rename breaks is worse than no check.
|
||||
# It pins the CONSEQUENCE - that when the guard is consulted, `self.clamp_last`
|
||||
# is the result of the write of THIS cycle - and part 6b runs the identical
|
||||
# assertions through a deliberately refactored controller to prove it.
|
||||
|
||||
|
||||
class _SpyWatchdog(ClampWatchdog):
|
||||
"""The shipped watchdog, plus a record of what the guard could SEE.
|
||||
|
||||
⚠️ IT HOOKS THE CONSUMER, NOT THE GUARD'S NAME. Wrapping
|
||||
`check_output_clamp` by name would make a rename - AC 2's legitimate
|
||||
refactor - look identical to the defect, and this ticket is explicitly not
|
||||
allowed to buy its check with a nuisance. `clamp_wd.note()` is the one call
|
||||
the guard exists to make, so it is hooked instead: it is reached from the
|
||||
guard under any name, from any helper, as long as that helper runs after the
|
||||
write.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.owner = None
|
||||
self.seen = [] # clamp_last.requested_w at each consultation
|
||||
self.seen_write = [] # was the WRITE-path half engaged, per cycle
|
||||
|
||||
def note(self, engaged, reason, now):
|
||||
cl = self.owner.clamp_last
|
||||
self.seen.append(None if cl is None else cl.requested_w)
|
||||
self.seen_write.append(bool(cl and cl.engaged))
|
||||
return super().note(engaged, reason, now)
|
||||
|
||||
|
||||
def probed(cls=Controller, grid=9000.0, cycles=5, **over):
|
||||
"""`wired()`, with the watchdog replaced by the spy and the per-cycle
|
||||
command returned. A RAMP is used on purpose: the slew limiter makes every
|
||||
cycle's command a different number, which is what makes "this cycle's
|
||||
evidence" and "last cycle's evidence" distinguishable at all."""
|
||||
async def go():
|
||||
# `track=True` - the plant follows, so the saturation freeze never
|
||||
# engages and the slew limiter keeps the command MOVING; against a
|
||||
# dead plant the freeze pins it flat after three cycles and the ramp
|
||||
# stops being a ramp.
|
||||
hass = _Hass(grid, track=True)
|
||||
c = cls(dict(
|
||||
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
|
||||
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
|
||||
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15},
|
||||
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
c.clamp_wd = _SpyWatchdog()
|
||||
c.clamp_wd.owner = c
|
||||
asked = []
|
||||
for _ in range(cycles):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
asked.append(c.target)
|
||||
return hass, c, asked
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
def evidence_checks(label, asked, wd):
|
||||
"""The same four claims, run against the shipped controller (6a) and against
|
||||
a refactored one (6b). Sharing them is the point: if a rename or a helper
|
||||
changed the answer, this function would say so in the same words."""
|
||||
seen = wd.seen
|
||||
print(f" {label}: asked "
|
||||
f"{[f'{w:+.0f}' for w in asked]}")
|
||||
print(f" {label}: the guard saw "
|
||||
f"{['None' if s is None else f'{s:+.0f}' for s in seen]}")
|
||||
check(f"{label}: PRECONDITION - the command MOVES on every cycle, so this "
|
||||
f"cycle's evidence and last cycle's are different numbers",
|
||||
len(set(asked)) == len(asked) and len(asked) >= 4)
|
||||
check(f"{label}: the guard was consulted exactly once per cycle",
|
||||
len(seen) == len(asked))
|
||||
check(f"{label}: AC 1 - on EVERY cycle self.clamp_last held the result of "
|
||||
f"the write THAT CYCLE. write_setpoint() is the only line that "
|
||||
f"assigns self.clamp_last, so a check_output_clamp() moved above the "
|
||||
f"write grades the PREVIOUS cycle's write instead",
|
||||
seen == asked)
|
||||
check(f"{label}: ...and on the FIRST cycle self.clamp_last was already set "
|
||||
f"- before the run's first write it is None, i.e. the write-path half "
|
||||
f"of the guard has no evidence at all",
|
||||
bool(seen) and seen[0] is not None)
|
||||
|
||||
|
||||
hass_e, ctl_e, asked_e = probed()
|
||||
evidence_checks("shipped", asked_e, ctl_e.clamp_wd)
|
||||
|
||||
# ⚠️ AND HERE IS WHY AC 3 NEEDS A STIMULUS OF ITS OWN, MEASURED RATHER THAN
|
||||
# ASSERTED. Through the real cycle() the write-path half never engages: the
|
||||
# arbiter files the device range and max_w as P_SAFETY LIMIT claims, so
|
||||
# `resolution.target_w` is already inside both bounds before write_setpoint()
|
||||
# re-applies them (main.py calls that duplication defence in depth, and it is).
|
||||
# Every engagement the alarm reports on this path is therefore the loop-side
|
||||
# one - which is exactly what makes the move nearly invisible, and exactly why
|
||||
# 6c below drives the write path directly instead of hoping cycle() will.
|
||||
check(f"MEASURED: the write-path half engaged 0 times in {len(asked_e)} real "
|
||||
f"cycles ({sum(ctl_e.clamp_wd.seen_write)}), so no cycle()-driven check "
|
||||
f"can be watching it", not any(ctl_e.clamp_wd.seen_write))
|
||||
|
||||
|
||||
class _RefactoredController(Controller):
|
||||
"""AC 2, as a fixture rather than as a promise: the two refactors a P3 tidy
|
||||
would actually make, applied at once.
|
||||
|
||||
* the guard is RENAMED to `_clamp_guard()`, the old name left as the thin
|
||||
delegate such a rename leaves behind;
|
||||
* the write MOVES INTO A HELPER, `_emit_setpoint()`.
|
||||
|
||||
Neither changes when the guard runs relative to the write, so every check in
|
||||
`evidence_checks()` must stay green. If one of them goes red here, the check
|
||||
is grading the text of main.py and has to be thrown away.
|
||||
|
||||
⚠️ THE SIGNATURE GAINED `unit=` IN DEV-02 AND THIS FIXTURE FOLLOWED IT, which
|
||||
is the honest update and not a weakening: `write_setpoint()` now addresses ONE
|
||||
inverter (or, with `unit=None`, commands the whole site to 0 W), because a
|
||||
controller with a roster cannot have one write path that means "the
|
||||
inverter". A delegate that dropped the argument would silently re-broadcast
|
||||
at N>=2, so it is passed through here exactly as a real refactor would have
|
||||
to pass it through.
|
||||
"""
|
||||
|
||||
async def _emit_setpoint(self, value, *, force=False, unit=None):
|
||||
await Controller.write_setpoint(self, value, force=force, unit=unit)
|
||||
|
||||
async def write_setpoint(self, value, *, force=False, unit=None):
|
||||
await self._emit_setpoint(value, force=force, unit=unit)
|
||||
|
||||
async def _clamp_guard(self):
|
||||
await Controller.check_output_clamp(self)
|
||||
|
||||
async def check_output_clamp(self):
|
||||
await self._clamp_guard()
|
||||
|
||||
|
||||
# ⚠️ VERIFIED AGAINST main.py ITSELF, NOT ONLY AGAINST THIS SUBCLASS. Renaming
|
||||
# `check_output_clamp` throughout main.py AND moving both the write and the guard
|
||||
# into a new `_emit_and_guard()` helper called from cycle() leaves every check in
|
||||
# this part green; moving the guard above the write turns the two AC 1 lines
|
||||
# below red. The one thing a repo-wide rename does cost is the direct call in 6c,
|
||||
# which is a mechanical find/replace and fails as an AttributeError naming the
|
||||
# new method - not as an assertion claiming the ordering is wrong, which is the
|
||||
# false signal this part was written to avoid.
|
||||
hass_f, ctl_f, asked_f = probed(cls=_RefactoredController)
|
||||
evidence_checks("renamed + helper", asked_f, ctl_f.clamp_wd)
|
||||
check("AC 2: the refactored controller behaves identically to the shipped one "
|
||||
"- same commands, same evidence, so the check above is not pinning "
|
||||
"either name",
|
||||
asked_f == asked_e and ctl_f.clamp_wd.seen == ctl_e.clamp_wd.seen)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 6c. AC 3 - the WRITE-PATH half, exercised
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The stimulus is SAFETY-20's shape and that is not a coincidence: `max_w` 5000
|
||||
# on an inverter that reports +-2500 is the one configuration where the value
|
||||
# put on the wire DIFFERS from the value the loop asked for while every loop-side
|
||||
# flag stays clean. That difference is the whole content of `clamp_last`, and a
|
||||
# DEVICE-bound engagement is the only kind `loop_clamped` cannot also see.
|
||||
#
|
||||
# It is driven through `write_setpoint()` and the guard directly rather than
|
||||
# through cycle(), because the measurement above says cycle() cannot produce it
|
||||
# today. That is stated as a limitation, not hidden: what is being graded here is
|
||||
# the guard's own contract - given a write that clamped, alarm - and the ordering
|
||||
# in cycle() is what 6a grades.
|
||||
|
||||
|
||||
async def _write_path(moved=False):
|
||||
"""`moved` runs the two calls in the order the ticket asks about."""
|
||||
hass = _Hass(0.0)
|
||||
c = Controller({"setpoint_entity": "number.sp", "meter_source": "off",
|
||||
"max_w": LIVE_MAX_W},
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
# The inverter is SMALLER than the configured ceiling - SAFETY-20's install.
|
||||
c.dev_min, c.dev_max = -2500.0, 2500.0
|
||||
c.target = 3000.0
|
||||
# SAFETY-28: the write path only carries numbers the allocator placed, so
|
||||
# this stimulus is authorised the same way cycle()'s is. It has to be: the
|
||||
# clamp is what this part grades, and a gate refusal would substitute 0 W
|
||||
# and there would be no clamp to see.
|
||||
arm(c, c.target)
|
||||
if moved:
|
||||
await c.check_output_clamp()
|
||||
await c.write_setpoint(c.target)
|
||||
else:
|
||||
await c.write_setpoint(c.target)
|
||||
await c.check_output_clamp()
|
||||
return hass, c
|
||||
|
||||
|
||||
hass_w, ctl_w = asyncio.run(_write_path())
|
||||
notes_w = clamp_notes(hass_w)
|
||||
print(f" max_w {LIVE_MAX_W:.0f} on a +-2500 W device, asked for "
|
||||
f"{ctl_w.target:+.0f} W: the device saw {hass_w.writes[-1]:+.0f} W, "
|
||||
f"bound={ctl_w.clamp_last.bound!r}, loop_clamped={ctl_w.loop_clamped[0]}")
|
||||
check("PRECONDITION: the write really did clamp, on the DEVICE bound",
|
||||
ctl_w.clamp_last is not None and ctl_w.clamp_last.engaged
|
||||
and ctl_w.clamp_last.bound == "device"
|
||||
and hass_w.writes[-1] == 2500.0)
|
||||
check("...and the loop-side half is SILENT here, so self.clamp_last is the "
|
||||
"only evidence that this happened at all",
|
||||
ctl_w.loop_clamped == (False, ""))
|
||||
check("AC 3: the guard alarmed on write-path evidence alone",
|
||||
ctl_w.clamp_wd.engagements == 1 and len(notes_w) == 1)
|
||||
check("...and the operator is told WHICH bound bit, so a device-rating clamp "
|
||||
"is not reported as a max_w one",
|
||||
bool(notes_w) and "device bound" in notes_w[0]["message"])
|
||||
check("...and the action log carries the row, written by write_setpoint()",
|
||||
any(REASON_CODE in e and "device bound" in e for e in ctl_w.events))
|
||||
|
||||
# The same stimulus, the same clamp, the two calls swapped. This is the ticket
|
||||
# in one pair of runs.
|
||||
hass_m, ctl_m = asyncio.run(_write_path(moved=True))
|
||||
print(f" the same stimulus with the guard consulted BEFORE the write: "
|
||||
f"{ctl_m.clamp_wd.engagements} engagement(s), "
|
||||
f"{len(clamp_notes(hass_m))} notification(s), and the clamp still "
|
||||
f"happened (engaged={ctl_m.clamp_last.engaged})")
|
||||
check("THE DEFECT, DEMONSTRATED: consulted before the write the guard is "
|
||||
"SILENT - self.clamp_last is still None at that point - while the wire "
|
||||
"carries a clamped value either way",
|
||||
not clamp_notes(hass_m) and ctl_m.clamp_wd.engagements == 0
|
||||
and ctl_m.clamp_last.engaged and hass_m.writes[-1] == 2500.0)
|
||||
check("...and main.py:856's consumer is the line that goes quietly false",
|
||||
bool(ctl_w.clamp_last and ctl_w.clamp_last.engaged))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("7. SAFETY-28: the write path is gated on the allocation record")
|
||||
|
||||
# WHY THIS PART IS IN THIS FILE. Part 5 already claims "no path reaches the wire
|
||||
# around the clamp", and the clamp is the last thing between a bug and the
|
||||
# hardware. SAFETY-28 adds a second thing on the same path and asks a different
|
||||
# question of it: not "is this number in range" but "WHO CHOSE THIS NUMBER".
|
||||
#
|
||||
# THE DEFECT IT REPLACES. specs §1.5 makes two writers on one actuator a
|
||||
# non-goal, and until this ticket that was enforced by
|
||||
# `len(validate.ACTUATOR_WRITE_KEYS) == 1` - one option key, so a second writer
|
||||
# is not expressible. True, and a statement about the OPTIONS SCHEMA rather than
|
||||
# about the writes: add CFG-04's per-inverter config and there are two real
|
||||
# writers with that length still 1. test_validate.py part 2 grades the config
|
||||
# half; this part grades the write half, which is the one that does not care
|
||||
# what the options look like.
|
||||
|
||||
A_UNIT, B_HYPO = "inverter-a", "inverter-b"
|
||||
|
||||
|
||||
def fresh(**over):
|
||||
"""A Controller with a stub HA, no cycle run, and therefore NO record."""
|
||||
hass = _Hass(0.0)
|
||||
c = Controller(dict({"setpoint_entity": "number.sp", "max_w": 2000,
|
||||
"meter_source": "off"}, **over),
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
return hass, c
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 7a. AC 1 - an unauthorised value is REFUSED AT THE WRITE PATH
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The second writer, modelled as what it would actually look like in code: some
|
||||
# other strategy with arithmetic of its own calling the one method that reaches
|
||||
# the wire. It has a perfectly reasonable number - in range, finite, nothing the
|
||||
# clamp would object to - and the only thing wrong with it is that nothing
|
||||
# allocated it.
|
||||
async def _second_writer():
|
||||
hass, c = fresh()
|
||||
arm(c, 1500.0) # the allocator placed 1500 W this tick
|
||||
await c.write_setpoint(1500.0, force=True) # the loop's own write
|
||||
await c.write_setpoint(1200.0, force=True) # ...and the second writer's
|
||||
return hass, c
|
||||
|
||||
|
||||
hass_2w, ctl_2w = asyncio.run(_second_writer())
|
||||
print(f" allocator placed +1500 W; a second writer asked for +1200 W; "
|
||||
f"the device saw {hass_2w.writes}")
|
||||
check("AC 1: a value the allocation record does not contain never reaches the "
|
||||
"device. 1200 W is in range and finite - the clamp has no opinion about "
|
||||
"it - so the ONLY thing that stopped it is that no allocator placed it",
|
||||
1200.0 not in hass_2w.writes)
|
||||
check("...and it is REFUSED, not merely logged: 0 W goes on the wire in its "
|
||||
"place. Returning instead would leave the inverter latched on the last "
|
||||
"command with the ESP32 heartbeat still being fed - holding an "
|
||||
"unauthorised setpoint quietly, which is the outcome this gate exists "
|
||||
"to prevent",
|
||||
hass_2w.writes == [1500.0, 0.0])
|
||||
check("...and the action log says so in words an operator can act on",
|
||||
any("WRITE REFUSED" in e for e in ctl_2w.events))
|
||||
check("...while the authorised write of the SAME shape went through "
|
||||
"untouched, so the gate is not simply refusing everything",
|
||||
hass_2w.writes[0] == 1500.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 7b. AC 2 - a record from a previous tick cannot authorise a write in this one
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ THIS IS THE CACHED-ENVELOPE FAULT WEARING A DIFFERENT HAT. allocator.py's
|
||||
# rule is that a unit's interval is read per tick and never cached, because a
|
||||
# pack at its floor or a stale link moves it between one cycle and the next.
|
||||
# A record is that envelope written down - so code that keeps one and replays it
|
||||
# is caching the envelope through the record, and the allocator's own purity
|
||||
# cannot stop it. The tick stamp is what stops it.
|
||||
async def _replay():
|
||||
hass, c = fresh()
|
||||
stale = arm(c, 1500.0) # tick N
|
||||
await c.write_setpoint(1500.0, force=True)
|
||||
arm(c, 800.0) # tick N+1: the envelopes have moved on
|
||||
c.allocation = stale # ...and somebody puts the old one back
|
||||
await c.write_setpoint(1500.0, force=True)
|
||||
return hass, c, stale
|
||||
|
||||
|
||||
hass_rp, ctl_rp, stale_rec = asyncio.run(_replay())
|
||||
print(f" a tick-{stale_rec.tick} record replayed on tick "
|
||||
f"{ctl_rp.alloc_tick}: the device saw {hass_rp.writes}")
|
||||
check("AC 2: replaying the previous tick's record does NOT authorise its "
|
||||
"value again - the write is refused and 0 W goes out instead",
|
||||
hass_rp.writes == [1500.0, 0.0])
|
||||
check("...and the refusal names both ticks, so the reason is legible rather "
|
||||
"than 'computer says no'",
|
||||
"tick" in (ctl_rp.write_refusal(1500.0) or "")
|
||||
and str(stale_rec.tick) in (ctl_rp.write_refusal(1500.0) or "")
|
||||
and str(ctl_rp.alloc_tick) in (ctl_rp.write_refusal(1500.0) or ""))
|
||||
check("...and an UNSTAMPED record - one built by a caller that passed no tick "
|
||||
"at all - is refused too, because 'I cannot say when I was made' must "
|
||||
"fail closed and not read as tick zero",
|
||||
ctl_rp.allocation is not None
|
||||
and (lambda c: (setattr(c, "allocation",
|
||||
plan(1500.0, {A_UNIT: UnitBounds()}))
|
||||
or c.write_refusal(1500.0) is not None))(ctl_rp))
|
||||
|
||||
# ⚠️ AND THE TICK HAS TO ADVANCE ON THE LIVE PATH, WHICH IS A DIFFERENT CLAIM
|
||||
# FROM ANY OF THE ABOVE. Everything so far drives `arm()`, which bumps the
|
||||
# counter itself - so all of it would stay green with `self.alloc_tick += 1`
|
||||
# deleted from cycle(), and the replay protection would be gone on the only path
|
||||
# that matters. This drives REAL cycles and asks the same question of them.
|
||||
async def _live_staleness():
|
||||
hass = _Hass(6000.0)
|
||||
c = Controller({"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
|
||||
"slew_w": 1000, "step_w": 10, "stale_input_s": 15},
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
first, first_v = c.allocation, c.target
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
# ...and now the HOLD: one missed poll, which must NOT age the record, or a
|
||||
# single dropped meter reading would drop the site to 0 W.
|
||||
held_before = c.alloc_tick
|
||||
c.grid = None
|
||||
c.inputs_bad_since = datetime.now(timezone.utc)
|
||||
await c.cycle()
|
||||
hass.writes.clear()
|
||||
await c.write_setpoint(c.target, force=True) # the heartbeat's own call
|
||||
return c, first, first_v, held_before, hass
|
||||
|
||||
|
||||
ctl_ls, rec1, val1, held_before, hass_ls = asyncio.run(_live_staleness())
|
||||
print(f" two real cycles: tick {rec1.tick} -> {ctl_ls.allocation.tick}; "
|
||||
f"a held cycle left it at {ctl_ls.alloc_tick}")
|
||||
check("PRECONDITION: two real cycles produced two records with two different "
|
||||
"ticks and a value worth replaying",
|
||||
rec1.tick != ctl_ls.allocation.tick and val1 != 0.0)
|
||||
check("the tick advances on the LIVE path, so cycle 1's record does not "
|
||||
"authorise a write on cycle 2. Everything above this line drives the "
|
||||
"test's own `arm()` and would stay green with the increment deleted from "
|
||||
"cycle() - this is the check that would not",
|
||||
(lambda c: (setattr(c, "allocation", rec1)
|
||||
or c.write_refusal(val1) is not None))(ctl_ls))
|
||||
check("⚠️ AND A CYCLE THAT PRODUCED NO RESOLUTION DOES NOT AGE IT. The "
|
||||
"'inputs missing, not yet stale' hold returns before the allocator runs, "
|
||||
"precisely so one dropped meter reading does not zero the inverter, and "
|
||||
"the heartbeat re-affirms self.target through this same gate while it "
|
||||
"does. A tick incremented per LOOP ITERATION rather than per RESOLUTION "
|
||||
"would refuse that heartbeat and turn the hold into the outage it exists "
|
||||
"to prevent",
|
||||
ctl_ls.alloc_tick == held_before and hass_ls.writes == [ctl_ls.target]
|
||||
and ctl_ls.target != 0.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 7c. THE FAILSAFE CANNOT DEADLOCK BEHIND THE GATE
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ THE HAZARD THIS PART EXISTS FOR. `write_setpoint()` is also how layer 2
|
||||
# commands 0 W: cycle()'s two refusal branches substitute a 0 W failsafe and
|
||||
# write it like any other cycle, and shutdown() and the operator's stop button
|
||||
# do the same. Every one of those runs while the allocator has REFUSED - `ok` is
|
||||
# False and there are no shares - so a gate that asked the allocator's
|
||||
# permission to command zero would block the safety write behind the very
|
||||
# component whose failure the safety write answers. A gate that can do that is
|
||||
# worse than the hole it closes.
|
||||
#
|
||||
# The resolution: 0 W is authorised unconditionally, and it is authorised on the
|
||||
# VALUE rather than on a `failsafe=True` flag the caller asserts. The argument is
|
||||
# at Controller.write_refusal(): the hazard §1.5 names is N-times loop gain from
|
||||
# broadcasting one target to N inverters, and at S = 0 the broadcast and the
|
||||
# correct allocation COINCIDE - a_i = 1 for every unit gives every unit 0 W,
|
||||
# which is exactly its share. There is no over-command to make at zero.
|
||||
_hostile = []
|
||||
_h, _c = fresh()
|
||||
_hostile.append(("no record at all", _c))
|
||||
_h, _c = fresh()
|
||||
_c.alloc_tick = 1
|
||||
_c.allocation = plan(1500.0, {A_UNIT: UnitBounds()}, scope_errors=("mis-routed",),
|
||||
tick=1)
|
||||
_hostile.append(("the allocator REFUSED this tick", _c))
|
||||
_h, _c = fresh()
|
||||
_c.allocation = arm(_c, 1500.0)
|
||||
_c.alloc_tick += 7 # ...and then seven ticks went by
|
||||
_hostile.append(("only a stale record", _c))
|
||||
for _why, _c in _hostile:
|
||||
check(f"0 W is authorised with {_why}", _c.write_refusal(0.0) is None)
|
||||
check(f"...and a NON-zero write is still refused with {_why}, so the zero "
|
||||
f"exit is a floor and not an amnesty",
|
||||
_c.write_refusal(1500.0) is not None)
|
||||
|
||||
|
||||
async def _failsafe_wire():
|
||||
"""The refusal path END TO END, through the real cycle().
|
||||
|
||||
⚠️ THE ROSTER HERE IS A HYPOTHESIS AND NOTHING IS MEASURED FROM IT.
|
||||
INVERTER B DOES NOT EXIST. It is named only so `live_units()` can return
|
||||
nothing and drive the allocator into REFUSE_EMPTY, which is the state
|
||||
main.py's first refusal branch is written for and the one this check needs:
|
||||
the allocator itself has failed, and layer 2 must still be able to command
|
||||
zero.
|
||||
"""
|
||||
hass = _Hass(0.0)
|
||||
|
||||
class _Blind(Controller):
|
||||
def live_units(self):
|
||||
return ()
|
||||
|
||||
c = _Blind({"setpoint_entity": "number.sp", "meter_source": "off",
|
||||
"max_w": LIVE_MAX_W, "soc_entity": "sensor.soc",
|
||||
"batt_entity": "sensor.batt", "meter_entity": "sensor.grid"},
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
# ⚠️ DEV-02: THE ROSTER IS GROWN BY ADDING AN `Inverter`, NOT BY ASSIGNING
|
||||
# `units`. `Controller.units` is now DERIVED from `Controller.inverters` and
|
||||
# has no setter, because a settable roster is a site that claims two
|
||||
# actuators while holding one writer - a half-driven site with nothing to
|
||||
# say so. The object carries the writer, which is what makes this fixture a
|
||||
# roster of two rather than a tuple of two names.
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W # while the roster is one
|
||||
_b = Inverter(B_HYPO, setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc", batt_entity="sensor.batt",
|
||||
max_w=LIVE_MAX_W, store=c.store)
|
||||
_b.dev_min, _b.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
_b.soc, _b.batt = 50.0, 0.0
|
||||
c.inverters.append(_b)
|
||||
c.auto = True
|
||||
for _ in range(3):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
return hass, c
|
||||
|
||||
|
||||
hass_fs, ctl_fs = asyncio.run(_failsafe_wire())
|
||||
print(f" allocator refused every tick "
|
||||
f"({ctl_fs.allocation.refused!r}); the device saw {hass_fs.writes}")
|
||||
check("the allocator having failed, the 0 W failsafe still REACHES THE WIRE - "
|
||||
"this is the deadlock the gate must not have, driven through the real "
|
||||
"cycle() and its real refusal branch rather than asserted",
|
||||
not ctl_fs.allocation.ok and hass_fs.writes == [0.0, 0.0]
|
||||
and ctl_fs.target == 0.0)
|
||||
check("...and it was the ALLOCATOR's refusal that produced it, not the gate's "
|
||||
"- if the gate were the thing zeroing this cycle the action log would "
|
||||
"say WRITE REFUSED and the distinction between the two failures would "
|
||||
"be lost to whoever has to debug it",
|
||||
any("ARBITER: REFUSED" in e for e in ctl_fs.events)
|
||||
and not any("WRITE REFUSED" in e for e in ctl_fs.events))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 7d. `unmet_w` IS NOT `ok`, AND THE GATE MUST NOT FUSE THEM
|
||||
# --------------------------------------------------------------------------- #
|
||||
# allocator.py says it plainly: an allocation that could not place every watt is
|
||||
# still SAFE TO WRITE and strictly better than commanding zero. Fusing the two
|
||||
# would let ONE SATURATED INVERTER PARK THE WHOLE SITE - the site target goes
|
||||
# unmet by 3800 W, every share is legal, and a gate reading `unmet_w` would
|
||||
# answer that by commanding 0 W and meeting even less of it.
|
||||
_tight = plan(4000.0, {A_UNIT: UnitBounds(lo=-100.0, hi=100.0),
|
||||
B_HYPO: UnitBounds(lo=-100.0, hi=100.0)}, tick=1)
|
||||
_h, _c = fresh()
|
||||
_c.alloc_tick, _c.allocation = 1, _tight
|
||||
print(f" saturated site: {_tight.explain()}")
|
||||
check("PRECONDITION: this record is ok AND has watts it could not place",
|
||||
_tight.ok and _tight.unmet_w == 3800 and _tight.shares[A_UNIT] == 100)
|
||||
check("the gate authorises this unit's share anyway. `unmet_w` is not read - "
|
||||
"it is the site target that went unmet, not a broken allocation",
|
||||
_c.write_refusal(100.0) is None)
|
||||
check("...and it still refuses a value that is not the share, so 'do not read "
|
||||
"unmet_w' has not been implemented as 'read nothing'",
|
||||
_c.write_refusal(4000.0) is not None)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 7e. A RECORD THAT CONTRADICTS ITSELF AUTHORISES NOTHING
|
||||
# --------------------------------------------------------------------------- #
|
||||
# `UnitPlan.explain()` renders a share outside its own interval as
|
||||
# "ENVELOPE VIOLATED, DO NOT WRITE". This is the DO NOT WRITE. The shape is the
|
||||
# half-open clamp defect ALLOC-01 fixed: a plan stating the floor and a share
|
||||
# below it in the same breath, and agreeing with itself.
|
||||
#
|
||||
# ⚠️ UNREACHABLE FROM THE ALLOCATOR AS IT STANDS, AND GRADED ANYWAY. At the
|
||||
# shipped roster of one the pass-through's interval is (-inf, +inf) and every
|
||||
# number is inside it. PR #40's reviewer proved what an unreachable ungraded
|
||||
# branch is worth by replacing one with `if False:` and watching the whole suite
|
||||
# stay green, so this one is exercised on a constructed record rather than
|
||||
# trusted.
|
||||
_liar = Allocation(target_w=500.0, sum_w=500, shares={A_UNIT: 500},
|
||||
plans=(UnitPlan(A_UNIT, 500, 2000.0, math.inf, 1.0,
|
||||
"weighted share"),), tick=1)
|
||||
_h, _c = fresh()
|
||||
_c.alloc_tick, _c.allocation = 1, _liar
|
||||
print(f" {_liar.plans[0].explain()}")
|
||||
check("PRECONDITION: the record is `ok`, and its share IS what the gate would "
|
||||
"otherwise match on - so nothing but the envelope question stops it",
|
||||
_liar.ok and _liar.shares[A_UNIT] == 500)
|
||||
check("a share outside its own plan's interval is refused, and the refusal "
|
||||
"quotes the plan's own DO NOT WRITE line",
|
||||
"DO NOT WRITE" in (_c.write_refusal(500.0) or ""))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 7f. AC 5 - AT N=1 NOTHING CHANGES
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The strongest form of this is not a new assertion, it is the 17 other files in
|
||||
# this suite passing unedited on real cycles. What is added here is the
|
||||
# statement that the live path is not being quietly zeroed: over part 3's
|
||||
# sixty-cycle replay - the 10.3 runaway, an inverter that never follows - the
|
||||
# gate refused nothing at all.
|
||||
check("AC 5: across part 3's 60 real cycles the gate refused ZERO writes, so "
|
||||
"the shipped one-inverter path reaches the wire exactly as it did "
|
||||
"before. A gate that silently substituted 0 W here would leave every "
|
||||
"clamp check above green and the install dead",
|
||||
not [e for e in ctl_r.events if "WRITE REFUSED" in e])
|
||||
check("...and the numbers those cycles put on the wire are the arbiter's own "
|
||||
"target, unrounded and unaltered by the record it now has to appear in",
|
||||
hass_r.writes and all(abs(w) <= LIVE_MAX_W for w in hass_r.writes))
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -6,6 +6,13 @@ its absence caused an observed failure on real hardware.
|
||||
|
||||
If you change control.py, run this. If it fails, the inverter would have done
|
||||
something you did not intend.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -71,14 +78,22 @@ def check(name, cond):
|
||||
# Hence: A FIXTURE MUST SIT CLEAR OF EVERY RAIL IT IS NOT TESTING. Where a test
|
||||
# names one mechanism, make that mechanism the binding one (see TCLAMP and TF).
|
||||
#
|
||||
# ⚠️ RUN MUTATIONS WITH `python -B` AND CLEAR app/__pycache__. CPython
|
||||
# invalidates a .pyc on (source mtime in whole seconds, source size), so a
|
||||
# same-second rewrite that also preserves the file size reuses stale bytecode
|
||||
# and the suite reports on code you are no longer running. It under-reported one
|
||||
# mutation here as 2 where the true figure is 6. The error is one-directional -
|
||||
# stale bytecode can only under-report - so every figure above is a lower bound
|
||||
# at worst, and the two zeros ever recorded were both confirmed by fixing them
|
||||
# and watching the count rise, which a caching artefact cannot do.
|
||||
# ⚠️ RUN MUTATIONS THROUGH `tools/mutation_run.sh`, WHICH CLEARS __pycache__
|
||||
# BEFORE IT RUNS ANYTHING. CPython invalidates a .pyc on (source mtime in whole
|
||||
# seconds, source size), so a same-second rewrite that also preserves the file
|
||||
# size reuses stale bytecode and the suite reports on code you are no longer
|
||||
# running. It under-reported one mutation here as 2 where the true figure is 6.
|
||||
#
|
||||
# ⚠️ AND THE CLEAR IS THE HALF THAT WORKS. This note used to lead with
|
||||
# `python -B`; TEST-15 measured what that flag actually does, and it is
|
||||
# write-only - it does not stop an EXISTING stale .pyc being read, and a
|
||||
# mutation run always has one, because the previous run wrote it. -B is still
|
||||
# worth passing, for the NEXT edit rather than this one.
|
||||
#
|
||||
# The error is one-directional - stale bytecode can only under-report - so
|
||||
# every figure above is a lower bound at worst, and the two zeros ever recorded
|
||||
# were both confirmed by fixing them and watching the count rise, which a
|
||||
# caching artefact cannot do.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
print("control law")
|
||||
|
||||
@@ -62,6 +62,13 @@ load the meter's POWER FIGURE never moved - 10130.0 W for 26 consecutive polls -
|
||||
and only the kWh registers kept `unchanged_s` at zero. See the note in
|
||||
freeze.py; it is why the fingerprint's register terms are the negative case
|
||||
rather than a nicety.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -79,7 +86,8 @@ import aiohttp
|
||||
|
||||
from app.control import Tuning, compute
|
||||
from app.freeze import (
|
||||
RAIL_FRAC, FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_w,
|
||||
RAIL_FRAC, FreezeWatchdog, FreezeWatchdogConfig, looks_frozen,
|
||||
rail_ceiling_w, rail_unreachable, rail_w,
|
||||
)
|
||||
from app.main import Controller
|
||||
from app.p1 import HomeWizardLocalSource, P1Ingest
|
||||
@@ -88,11 +96,17 @@ fails = []
|
||||
T0 = datetime(2026, 8, 26, 7, 0, 0, tzinfo=timezone.utc)
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
# The live install's numbers, which are the ones the exposure is written at.
|
||||
LIVE_MAX_W = 5000.0
|
||||
# ⚠️ NOT config.yaml's shipped max_w. This is the LIVE INSTALL's own setting -
|
||||
# the schema maximum, 5000 - which this file uses on purpose wherever the
|
||||
# scenario under test is "a device smaller than the configured max_w" (freeze.py
|
||||
# describes exactly this install). Named LIVE_INSTALL_MAX_W, not the shorter
|
||||
# LIVE_MAX_W it used to be called, so it cannot be misread as "the current
|
||||
# shipped default" - see SHIPPED_MAX_W below for that number, and the pin two
|
||||
# sections down for what stops the two from drifting apart unnoticed (TEST-10).
|
||||
LIVE_INSTALL_MAX_W = 5000.0
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# THE SHIPPED THRESHOLD, READ FROM config.yaml (TEST-06 / AC3)
|
||||
# THE SHIPPED DEFAULTS, READ FROM config.yaml (TEST-06 / TEST-10 / AC3)
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ `meter_freeze_s: 300` used to appear in this file only as the literal 300,
|
||||
# retyped six times, so `300 -> 3600` in config.yaml left every check here
|
||||
@@ -110,9 +124,18 @@ def _cfg_default(key):
|
||||
return float(found[0]) if len(found) == 1 else None
|
||||
|
||||
|
||||
SHIPPED_HOLD_S = _cfg_default("meter_freeze_s")
|
||||
SHIPPED_DEADBAND_W = _cfg_default("deadband_w")
|
||||
SHIPPED_MAX_W = _cfg_default("max_w")
|
||||
# THE KEYS THIS FILE PINS - one place, so the next key added to config.yaml's
|
||||
# options block can be checked against it rather than discovered unread later
|
||||
# (TEST-10 / AC3). Deliberately not every key: max_w, deadband_w and
|
||||
# meter_freeze_s are the ones this file's arithmetic actually reasons about
|
||||
# (the mutual-exclusion argument in section 2b needs all three together).
|
||||
# Everything else in config.yaml's options block is out of scope here - see
|
||||
# test_validate.py for the general "no default silently drifts" pin, and do
|
||||
# NOT add a maintenance_* key here: CFG-03 is moving that default between
|
||||
# config.yaml, main.py and maintenance.py concurrently with this ticket.
|
||||
PINNED_KEYS = ("meter_freeze_s", "deadband_w", "max_w")
|
||||
SHIPPED_HOLD_S, SHIPPED_DEADBAND_W, SHIPPED_MAX_W = (
|
||||
_cfg_default(key) for key in PINNED_KEYS)
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
@@ -121,7 +144,7 @@ def check(name, cond):
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def tuning(max_w=LIVE_MAX_W):
|
||||
def tuning(max_w=LIVE_INSTALL_MAX_W):
|
||||
"""The shipped defaults from config.yaml, at the live install's max_w."""
|
||||
return Tuning(gain=0.6, max_w=max_w, slew_w=1000, deadband_w=15,
|
||||
target_grid_w=-10, step_w=10, saturation_w=500,
|
||||
@@ -155,6 +178,87 @@ check("no transport signal (unchanged_s None) -> silent, never assumed frozen",
|
||||
check("no command yet -> silent", not looks_frozen(3600, None, 5000, cfg))
|
||||
check("rail_w tracks RAIL_FRAC", rail_w(5000) == 5000 * RAIL_FRAC)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1c. SAFETY-20 - the rail is measured against what the WIRE can carry
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ EVERY CHECK IN THIS SECTION WAS RED BEFORE THE FIX AND IS THE FIX'S ONLY
|
||||
# EVIDENCE. The shipped rail was `RAIL_FRAC * max_w` and the emitted command is
|
||||
# clipped to the SETPOINT DEVICE's range as well (clamp.py's second stage, and
|
||||
# the arbiter's `device` P_SAFETY claim), so any rating at or below
|
||||
# `0.95 * max_w` made term 2 unsatisfiable by construction while the status page
|
||||
# went on printing `armed (... over 4750 W)`.
|
||||
#
|
||||
# THIS IS ALSO THE CHECK THAT HAS TO GO RED IF ANYONE REVERTS THE RAIL TO BARE
|
||||
# `max_w` (AC3). Verified by doing it - reinstating
|
||||
# `return abs(float(max_w)) * RAIL_FRAC` in `rail_w()` fails the six checks
|
||||
# below plus three in part 6, and nothing else in the suite.
|
||||
print("SAFETY-20: the rail against the device rating")
|
||||
|
||||
# The ticket's own numbers: a 5 kW ceiling on a 4.7 kW inverter.
|
||||
DEV = dict(dev_min=-4700.0, dev_max=4700.0)
|
||||
|
||||
check("the rail is the DEVICE's rail, not max_w's, when the device is smaller",
|
||||
rail_w(LIVE_INSTALL_MAX_W, **DEV) == 4700.0 * RAIL_FRAC
|
||||
and rail_w(LIVE_INSTALL_MAX_W, **DEV) != LIVE_INSTALL_MAX_W * RAIL_FRAC)
|
||||
check("a command pinned at the device rating with a meter frozen for an hour "
|
||||
"FIRES - this is the ticket, and it returned False before the fix",
|
||||
looks_frozen(3600, 4700, LIVE_INSTALL_MAX_W, cfg, **DEV))
|
||||
check("...and the identical call without the rating is still False, so the "
|
||||
"check above is measuring the rating and not something else",
|
||||
not looks_frozen(3600, 4700, LIVE_INSTALL_MAX_W, cfg))
|
||||
check("the charge rail moves with it too - the runaway can be either sign",
|
||||
looks_frozen(3600, -4700, LIVE_INSTALL_MAX_W, cfg, **DEV))
|
||||
check("just under the DEVICE rail is still silent, so the fix widened term 2 "
|
||||
"by exactly the rating and not into a bare unchanged_s threshold",
|
||||
not looks_frozen(3600, 4700 * RAIL_FRAC - 1, LIVE_INSTALL_MAX_W, cfg, **DEV))
|
||||
check("a device BIGGER than max_w cannot raise the rail - max_w is still a "
|
||||
"ceiling the operator set and this is a min(), not a swap",
|
||||
rail_w(LIVE_INSTALL_MAX_W, dev_min=-9000.0, dev_max=9000.0)
|
||||
== LIVE_INSTALL_MAX_W * RAIL_FRAC)
|
||||
check("an unread rating (ESPHome not up) leaves the old behaviour exactly - "
|
||||
"no device clamp is applied on that path either, so max_w IS the ceiling",
|
||||
rail_w(LIVE_INSTALL_MAX_W, dev_min=None, dev_max=None) == LIVE_INSTALL_MAX_W * RAIL_FRAC)
|
||||
check("one bound alone still lowers it - hass.limits() reads min and max "
|
||||
"independently and either can come back None on its own",
|
||||
rail_w(LIVE_INSTALL_MAX_W, dev_max=4000.0) == 4000.0 * RAIL_FRAC)
|
||||
check("a non-finite rating is IGNORED, never read as a 0 W ceiling",
|
||||
rail_w(LIVE_INSTALL_MAX_W, dev_min=float("nan"), dev_max=float("inf"))
|
||||
== LIVE_INSTALL_MAX_W * RAIL_FRAC)
|
||||
check("the ceiling helper says what it measured",
|
||||
rail_ceiling_w(LIVE_INSTALL_MAX_W, **DEV) == 4700.0)
|
||||
|
||||
# The degenerate end. A ceiling of 0 W does not disarm term 2, it makes it TRUE
|
||||
# OF EVERY COMMAND - including a parked 0 W one - which decays the conjunction
|
||||
# into the bare `unchanged_s > N` threshold TEL-05 refused for false-tripping at
|
||||
# this controller's own operating point.
|
||||
check("a device that can command nothing makes the rail UNREACHABLE, with a "
|
||||
"reason for the status page",
|
||||
bool(rail_unreachable(LIVE_INSTALL_MAX_W, dev_min=0.0, dev_max=0.0)))
|
||||
check("...and a reachable rail says so with an empty string, or the row above "
|
||||
"would be permanent",
|
||||
rail_unreachable(LIVE_INSTALL_MAX_W, **DEV) == ""
|
||||
and rail_unreachable(LIVE_INSTALL_MAX_W) == "")
|
||||
check("...and the predicate is SILENT there rather than degenerately true - "
|
||||
"0 W would otherwise satisfy `abs(command) >= 0`",
|
||||
not looks_frozen(3600, 0.0, LIVE_INSTALL_MAX_W, cfg, dev_min=0.0, dev_max=0.0)
|
||||
and not looks_frozen(3600, 5000, LIVE_INSTALL_MAX_W, cfg, dev_min=0.0,
|
||||
dev_max=0.0))
|
||||
|
||||
# The alarm text is the third surface that carried the untrue number - it
|
||||
# outlives the page, in the log and in the HA notification.
|
||||
_wd20 = FreezeWatchdog(cfg)
|
||||
_wd20.evaluate(3600, 4700, LIVE_INSTALL_MAX_W, T0, **DEV)
|
||||
print(f" at max_w {LIVE_INSTALL_MAX_W:.0f} on a +-4700 W device: "
|
||||
f"{_wd20.reason}")
|
||||
check("the alarm reason quotes the rail it actually used and NOT the max_w one "
|
||||
"the command could never have reached",
|
||||
f"command over {4700 * RAIL_FRAC:.0f} W" in _wd20.reason
|
||||
and f"{LIVE_INSTALL_MAX_W * RAIL_FRAC:.0f} W" not in _wd20.reason)
|
||||
check("...and it says the device is what capped it, so the operator is not "
|
||||
"left wondering why the guard is armed somewhere other than max_w",
|
||||
"rating" in _wd20.reason and "max_w" in _wd20.reason)
|
||||
|
||||
print("the edge")
|
||||
wd = FreezeWatchdog(cfg)
|
||||
edges = [wd.evaluate(u, c, 5000, T0 + timedelta(seconds=i))
|
||||
@@ -188,6 +292,18 @@ check("the code default and the shipped default are the same number - "
|
||||
check("this file's `tuning()` really is config.yaml's deadband, so the "
|
||||
"mutual-exclusion arithmetic below is about the shipped law",
|
||||
tuning().deadband_w == SHIPPED_DEADBAND_W)
|
||||
# TEST-10: max_w had no such pin - `max_w: 2000 -> 4500` in config.yaml left
|
||||
# every check in this file green, the same gap TEST-06 closed for
|
||||
# meter_freeze_s one key over. `Tuning`'s own bare default (control.py) is
|
||||
# 2000.0 because that IS the shipped default; comparing the two means a
|
||||
# config.yaml edit that is not matched in control.py goes red here, the same
|
||||
# way FreezeWatchdogConfig().hold_s catches a drifted meter_freeze_s above.
|
||||
# This is deliberately NOT a check against LIVE_INSTALL_MAX_W (5000) - that
|
||||
# constant is the live install's own override and is supposed to differ from
|
||||
# the shipped default; see its definition for why.
|
||||
check("the code default and the shipped default agree on max_w too - "
|
||||
"Tuning() is a default, config.yaml is the ship",
|
||||
Tuning().max_w == SHIPPED_MAX_W)
|
||||
|
||||
# The upper bound is money: config.yaml's own comment says 300 s is "still
|
||||
# inside the 900 s quarter that bills the capacity tariff". A hold that outlives
|
||||
@@ -246,7 +362,7 @@ def replay(hold_s, max_w, cycle_floor_s=15.0):
|
||||
return wd, peak_cmd, peak_unchanged, fired_at
|
||||
|
||||
|
||||
for mw in (2000.0, LIVE_MAX_W):
|
||||
for mw in (SHIPPED_MAX_W, LIVE_INSTALL_MAX_W):
|
||||
wd_r, peak_cmd, peak_unchanged, fired = replay(SHIPPED_HOLD_S, mw)
|
||||
print(f" max_w {mw:.0f}: worst |command| {peak_cmd:.0f} W (rail "
|
||||
f"{rail_w(mw):.0f} W), worst unchanged {peak_unchanged:.1f} s "
|
||||
@@ -258,7 +374,7 @@ for mw in (2000.0, LIVE_MAX_W):
|
||||
# The margin on the OTHER term, as numbers rather than as trust. Both figures
|
||||
# are quoted in config.yaml and freeze.py, so they are re-measured every run and
|
||||
# a capture swapped underneath them cannot leave the prose saying the old thing.
|
||||
_, _, worst_unchanged, _ = replay(SHIPPED_HOLD_S, LIVE_MAX_W)
|
||||
_, _, worst_unchanged, _ = replay(SHIPPED_HOLD_S, LIVE_INSTALL_MAX_W)
|
||||
gaps, last, prev = [], rows[0]["t"], None
|
||||
for r in rows:
|
||||
if prev is None or r["pwr"] != prev:
|
||||
@@ -292,7 +408,7 @@ check(f"the shipped {SHIPPED_HOLD_S:.0f} s is at least 15x the worst of the two"
|
||||
# that happens not to move.
|
||||
print("TEL-05's low-power case - a converged meter that legitimately repeats")
|
||||
tel05 = FreezeWatchdog(FreezeWatchdogConfig(hold_s=SHIPPED_HOLD_S))
|
||||
t5 = tuning(LIVE_MAX_W)
|
||||
t5 = tuning(LIVE_INSTALL_MAX_W)
|
||||
tgt, iw, sc = 0.0, 0.0, 0
|
||||
bare_would_fire = False
|
||||
tel05_cmd = 0.0
|
||||
@@ -308,7 +424,7 @@ for k in range(TEL05_S):
|
||||
tel05_cmd = max(tel05_cmd, abs(tgt))
|
||||
# The REJECTED design, written out so the case can be shown to be real.
|
||||
bare_would_fire = bare_would_fire or float(k) >= SHIPPED_HOLD_S
|
||||
tel05.evaluate(float(k), tgt, LIVE_MAX_W, T0 + timedelta(seconds=k))
|
||||
tel05.evaluate(float(k), tgt, LIVE_INSTALL_MAX_W, T0 + timedelta(seconds=k))
|
||||
print(f" {TEL05_S / 60:.0f} min at -14.2 W: worst |command| {tel05_cmd:.0f} W, "
|
||||
f"unchanged_s reaches {TEL05_S - 1} s, episodes {tel05.episodes}")
|
||||
check("the bare unchanged_s threshold TEL-05 rejected WOULD fire here",
|
||||
@@ -316,7 +432,7 @@ check("the bare unchanged_s threshold TEL-05 rejected WOULD fire here",
|
||||
check("the conjunction does not - this is what the rail term buys",
|
||||
tel05.episodes == 0)
|
||||
check("...and the command stayed nowhere near the rail",
|
||||
tel05_cmd < rail_w(LIVE_MAX_W))
|
||||
tel05_cmd < rail_w(LIVE_INSTALL_MAX_W))
|
||||
|
||||
# The structural version of the same point, re-derived rather than asserted in
|
||||
# prose: the slowest register a HEALTHY meter can have while still being seen as
|
||||
@@ -332,7 +448,7 @@ print(f" at the shipped {SHIPPED_HOLD_S:.0f} s hold, the slowest healthy "
|
||||
check("a healthy meter slow enough to look unchanged is inside the deadband",
|
||||
slowest_healthy_w < DEADBAND_W)
|
||||
_park = compute(prev_w=0.0, grid_w=-slowest_healthy_w, actual_w=0.0,
|
||||
tuning=tuning(LIVE_MAX_W), sat_count=0, i_w=0.0)
|
||||
tuning=tuning(LIVE_INSTALL_MAX_W), sat_count=0, i_w=0.0)
|
||||
check("...and the shipped law really does park the command there",
|
||||
_park.target_w == 0.0)
|
||||
|
||||
@@ -411,7 +527,7 @@ while True:
|
||||
'''
|
||||
|
||||
|
||||
async def live_run(port, mode, hold_s=10.0, seconds=16.0, max_w=LIVE_MAX_W):
|
||||
async def live_run(port, mode, hold_s=10.0, seconds=16.0, max_w=LIVE_INSTALL_MAX_W):
|
||||
"""Poll a real HTTP meter through the real transport and drive the detector."""
|
||||
ingest = P1Ingest(phases=1, max_age_s=30)
|
||||
wd = FreezeWatchdog(FreezeWatchdogConfig(hold_s=hold_s))
|
||||
@@ -494,7 +610,7 @@ fired2, polls2, unch2, cmd2 = with_server(
|
||||
print(f" moving: {polls2} polls, worst unchanged {unch2:.1f} s, "
|
||||
f"worst |command| {cmd2:.0f} W, alarm at "
|
||||
f"{'never' if fired2 is None else f'{fired2:.1f} s'}")
|
||||
check("the moving meter also drove the command to the rail", cmd2 >= rail_w(LIVE_MAX_W))
|
||||
check("the moving meter also drove the command to the rail", cmd2 >= rail_w(LIVE_INSTALL_MAX_W))
|
||||
check("a REAL railed condition against a live meter stays silent", fired2 is None)
|
||||
check("...because the meter kept moving", unch2 < 10.0)
|
||||
|
||||
@@ -582,7 +698,7 @@ STALE_INPUT_S = 15.0
|
||||
def _opts(**over):
|
||||
o = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
|
||||
"meter_source": "off", "max_w": LIVE_INSTALL_MAX_W, "gain": 0.6,
|
||||
"slew_w": 1000, "deadband_w": SHIPPED_DEADBAND_W,
|
||||
"target_grid_w": -10, "step_w": 10, "heartbeat_s": 10,
|
||||
"stale_input_s": STALE_INPUT_S}
|
||||
@@ -649,19 +765,24 @@ def notes_by_text_of(hass):
|
||||
if "frozen" in str(d.get("title", "")).lower()]
|
||||
|
||||
|
||||
def wired(unchanged_s, grid, cycles=60, **over):
|
||||
def wired(unchanged_s, grid, cycles=60, dev=(-5000.0, 5000.0), **over):
|
||||
"""Returns (hass, controller, trace).
|
||||
|
||||
`trace` is one row per cycle - (command after the cycle, the watchdog's
|
||||
level, notifications raised so far) - which is what AC1 needs: the
|
||||
QUESTION IS NOT WHETHER THE ALARM HAPPENS BUT ON WHICH CYCLE.
|
||||
|
||||
⚠️ `dev` IS THE SETPOINT DEVICE'S OWN REPORTED RANGE, and it is a parameter
|
||||
rather than a constant because SAFETY-20 is entirely about installs where it
|
||||
is NOT the default +-5000. It defaults to the live install's rating, which
|
||||
equals its `max_w` - which is exactly why that install never saw the defect.
|
||||
"""
|
||||
async def go():
|
||||
hass = _Hass(grid)
|
||||
c = Controller(_opts(**over), hass, _Store(), _Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -5000.0, 5000.0
|
||||
c.dev_min, c.dev_max = dev
|
||||
c.p1_source = _Source(unchanged_s)
|
||||
trace = []
|
||||
for _ in range(cycles):
|
||||
@@ -717,7 +838,7 @@ check("the event log names it", any("METER FROZEN" in e for e in ctl_f.events))
|
||||
# ⚠️ NOTIFY, NOT TRIP. If a later cut turns this into a trip it must come here
|
||||
# and change this expectation deliberately, not discover it in the field.
|
||||
check("and it does NOT command 0 W - this cut is an alarm only",
|
||||
abs(ctl_f.target) >= rail_w(LIVE_MAX_W))
|
||||
abs(ctl_f.target) >= rail_w(LIVE_INSTALL_MAX_W))
|
||||
|
||||
# ⚠️ THE THREE SCENARIOS THAT SAFETY-03 FORCED A DECISION ON, decided one at a
|
||||
# time and not reflexively narrowed to green. Measured on the merged tree:
|
||||
@@ -754,7 +875,7 @@ print(f" real 8 kW load, moving meter: surface "
|
||||
f"freeze {len(notes_of(hass_m))}")
|
||||
check("wired up, a real railed load against a moving meter raises nothing FROM "
|
||||
"THIS GUARD (the clamp alarm there is SAFETY-03's and is correct)",
|
||||
not notes_of(hass_m) and abs(ctl_m.target) >= rail_w(LIVE_MAX_W))
|
||||
not notes_of(hass_m) and abs(ctl_m.target) >= rail_w(LIVE_INSTALL_MAX_W))
|
||||
|
||||
hass_u = wired(None, 4000.0)[0]
|
||||
print(f" uncovered transport: surface "
|
||||
@@ -808,7 +929,7 @@ check("an uncovered transport (no unchanged_s) raises nothing FROM THIS GUARD, "
|
||||
print("WHERE the detector is evaluated in cycle() - the ordering AC1 protects")
|
||||
|
||||
_railed = [i for i, (t, _, _) in enumerate(trace_f)
|
||||
if abs(t) >= rail_w(LIVE_MAX_W)]
|
||||
if abs(t) >= rail_w(LIVE_INSTALL_MAX_W)]
|
||||
_alarmed = [i for i, (_, _, n) in enumerate(trace_f) if n >= 1]
|
||||
_first_railed = _railed[0] if _railed else None
|
||||
_first_alarm = _alarmed[0] if _alarmed else None
|
||||
@@ -878,7 +999,7 @@ print(f" poll task killed: alarming at {_before[0]:+.0f} W "
|
||||
for i, (t, a, n) in enumerate(_dead)))
|
||||
check("the episode really was standing before the poll task died - otherwise "
|
||||
"the release below is vacuous",
|
||||
_before[1] and _before[2] == 1 and abs(_before[0]) >= rail_w(LIVE_MAX_W))
|
||||
_before[1] and _before[2] == 1 and abs(_before[0]) >= rail_w(LIVE_INSTALL_MAX_W))
|
||||
check("a dead poll task drives the command to 0 W",
|
||||
bool(_zeroed) and _zeroed[0] == 0)
|
||||
check("...and on THAT SAME cycle the alarm is already clear, because the "
|
||||
@@ -903,7 +1024,7 @@ check("...and it stays clear for as long as the failsafe holds it there",
|
||||
# loop re-rails, and that is a new episode which MUST speak. What must not
|
||||
# happen is a second notification stacking up beside the first.
|
||||
_re_railed = [i for i, (t, _, _) in enumerate(_revived)
|
||||
if abs(t) >= rail_w(LIVE_MAX_W)]
|
||||
if abs(t) >= rail_w(LIVE_INSTALL_MAX_W)]
|
||||
print(f" transport revived: command re-rails on cycle "
|
||||
f"{_re_railed[0] if _re_railed else None}, "
|
||||
f"{_ctl_d.freeze_wd.episodes} episode(s), {len(_freeze_notes)} "
|
||||
@@ -990,10 +1111,131 @@ check("...and it quotes the hold and the rail it is armed at, derived from the "
|
||||
"config the controller was actually built with",
|
||||
bool(_rows_c)
|
||||
and f"{_ctl_c.freeze_wd.cfg.hold_s:.0f} s unchanged" in _rows_c[0]["text"]
|
||||
and f"{rail_w(LIVE_MAX_W):.0f} W" in _rows_c[0]["text"])
|
||||
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" in _rows_c[0]["text"])
|
||||
check("...as an ok row, since a covered armed guard is not a warning",
|
||||
bool(_rows_c) and _rows_c[0]["ok"] and not _rows_c[0]["warn"])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5b. SAFETY-20 - is the device range actually WIRED into the guard? (AC1)
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ THIS SECTION EXISTS BECAUSE THE FIX SURVIVED ITS OWN MUTATION TEST WITHOUT
|
||||
# IT. Deleting `dev_min=`/`dev_max=` from `check_meter_frozen()`'s call - i.e.
|
||||
# shipping the corrected predicate and then not telling it the rating - left
|
||||
# every check in 1c and 6b green, because those grade `freeze.py` and the status
|
||||
# string and neither of them goes through `cycle()`. A guard that is right in the
|
||||
# module and unwired in the caller is the defect with an extra step.
|
||||
#
|
||||
# So this drives the REAL `Controller.cycle()` on a controller configured the way
|
||||
# the ticket describes - `max_w: 5000` on a +-4700 W inverter - and asserts on
|
||||
# the notification that leaves the process.
|
||||
#
|
||||
# ⚠️ WHAT IT STILL DOES NOT COVER, stated rather than implied: the OTHER call
|
||||
# site, `looks_frozen(...)` inside the SAFETY-07 peak-guard block, is behind
|
||||
# `if self.p1_enabled` and needs a fed averager to have any observable
|
||||
# consequence (`blindness()` returns BLIND_NO_BLOCK before it ever reads
|
||||
# `frozen`). That gate is uncovered here for the same reason freeze_rows() says
|
||||
# it is uncovered there, and widening the fixture belongs to whoever covers it.
|
||||
print("SAFETY-20: the device range through the REAL cycle()")
|
||||
|
||||
_h20, _c20, _t20 = wired(3600.0, 4000.0, dev=(-4700.0, 4700.0))
|
||||
_n20 = notes_of(_h20)
|
||||
print(f" max_w 5000 on a +-4700 W device, meter frozen an hour: the command "
|
||||
f"stood at {_c20.target:+.0f} W, {len(_n20)} freeze notification(s), "
|
||||
f"watchdog active={_c20.freeze_wd.active}")
|
||||
check("PRECONDITION: the device rating - not max_w - is what stopped the "
|
||||
"command, so this really is the configuration the ticket is about",
|
||||
abs(_c20.target) == 4700.0)
|
||||
check("...and that command is BELOW the max_w rail, which is why the shipped "
|
||||
"guard could never fire on this install",
|
||||
abs(_c20.target) < rail_w(LIVE_INSTALL_MAX_W))
|
||||
check("...and at or above the device rail, which is why it must",
|
||||
abs(_c20.target) >= rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0))
|
||||
check("THE ALARM FIRES through the real cycle() - the rating reaches the "
|
||||
"predicate from the controller, not just from a unit call",
|
||||
len(_n20) == 1 and _c20.freeze_wd.active)
|
||||
check("...and the notification the operator gets quotes the capped rail, "
|
||||
"not the max_w one",
|
||||
bool(_n20)
|
||||
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
|
||||
in str(_n20[0].get("message", "")))
|
||||
# p1_enabled is what a homewizard_local install has - set by hand here for the
|
||||
# same reason part 4 sets it, see freeze_rows().
|
||||
_c20.p1_enabled = True
|
||||
check("...and the red status row is up too, so the page and the notification "
|
||||
"agree",
|
||||
any("METER MAY BE FROZEN" in r["text"] for r in _c20.checks()))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 6b. SAFETY-20 - what the page says when the DEVICE is the ceiling (AC2)
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ THIS IS THE ACCEPTANCE CRITERION READ OFF THE RENDERED PAGE, NOT OFF THE
|
||||
# SOURCE. The card is explicit that reading the code does not settle it, so
|
||||
# every assertion below goes through the real `Controller.checks()` on a
|
||||
# controller that was CONFIGURED with a device smaller than its `max_w` and
|
||||
# then run - three real cycles, the real arbiter, the real write path.
|
||||
print("what the page says when the device rating is the smaller ceiling")
|
||||
|
||||
# max_w 5000 on a 4700 W inverter - the ticket's own install. Covered transport,
|
||||
# so the only question is what the ARMED row says.
|
||||
_ctl_s, _rows_s = freeze_rows(2.0, -14.2, dev=(-4700.0, 4700.0))
|
||||
# And the degenerate end: a device that can command nothing at all.
|
||||
_ctl_z, _rows_z = freeze_rows(2.0, -14.2, dev=(0.0, 0.0))
|
||||
print(f" max_w 5000 on a +-4700 W device: {_rows_s[0]['text'] if _rows_s else '(no row)'}")
|
||||
print(f" a device with no commandable range: {_rows_z[0]['text'] if _rows_z else '(no row)'}")
|
||||
|
||||
check("a device smaller than max_w still gets exactly one freeze-detector row",
|
||||
len(_rows_s) == 1)
|
||||
check("...and the page does NOT print the max_w rail the command can never "
|
||||
"reach - THE DEFECT, in one assertion",
|
||||
bool(_rows_s)
|
||||
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" not in _rows_s[0]["text"])
|
||||
check("...it prints the rail the predicate actually uses",
|
||||
bool(_rows_s)
|
||||
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
|
||||
in _rows_s[0]["text"])
|
||||
check("...and it names the device range as the reason, since the operator "
|
||||
"configured max_w and is owed the reason the guard is armed elsewhere",
|
||||
bool(_rows_s) and "4700" in _rows_s[0]["text"]
|
||||
and "max_w" in _rows_s[0]["text"])
|
||||
|
||||
# ⚠️ THE NUMBER IS TAKEN OFF THE ROW AND FED BACK INTO THE SHIPPED PREDICATE.
|
||||
# Asserting the page against `rail_w(...)` alone proves the two agree, which a
|
||||
# revert of BOTH to bare max_w also satisfies. What a revert cannot satisfy is
|
||||
# this: the rail the page advertises has to be a magnitude the wire can carry,
|
||||
# and the wire stops at the device rating.
|
||||
_m = re.search(r"command is over (\d+(?:\.\d+)?) W", _rows_s[0]["text"]) if _rows_s else None
|
||||
_page_rail = float(_m.group(1)) if _m else None
|
||||
print(f" the page advertises a rail of {_page_rail} W against a device that "
|
||||
f"stops at {_ctl_s.dev_max:.0f} W")
|
||||
check("...the row states a rail in watts at all, so the parse below means "
|
||||
"something", _page_rail is not None)
|
||||
check("...and THE RAIL THE PAGE ADVERTISES IS ONE THE WIRE CAN ACTUALLY CARRY. "
|
||||
"This is the whole ticket: 4750 W on a device that stops at 4700 W is a "
|
||||
"guard that cannot fire, advertising that it can",
|
||||
_page_rail is not None and _page_rail <= abs(_ctl_s.dev_max))
|
||||
check("...and a command at exactly that advertised rail, with the meter frozen, "
|
||||
"really does trip the shipped predicate the row is describing",
|
||||
_page_rail is not None
|
||||
and looks_frozen(3600, _page_rail, LIVE_INSTALL_MAX_W, cfg,
|
||||
dev_min=_ctl_s.dev_min, dev_max=_ctl_s.dev_max))
|
||||
check("...as an ok row - a guard armed at a lower rail is still armed, not a "
|
||||
"warning", bool(_rows_s) and _rows_s[0]["ok"] and not _rows_s[0]["warn"])
|
||||
|
||||
check("a device with no commandable range gets exactly one row", len(_rows_z) == 1)
|
||||
check("...and it says NOT COVERED - the rail is not merely low there, it is "
|
||||
"0 W, which would be TRUE of every command including a parked one",
|
||||
bool(_rows_z) and "NOT COVERED" in _rows_z[0]["text"])
|
||||
check("...and it NEVER says armed",
|
||||
bool(_rows_z) and "armed" not in _rows_z[0]["text"])
|
||||
check("...flagged as a warning rather than a silent ok, like the uncovered "
|
||||
"transport row it shares",
|
||||
bool(_rows_z) and not _rows_z[0]["ok"] and _rows_z[0]["warn"])
|
||||
check("...and the predicate agrees with the page rather than firing behind it",
|
||||
not looks_frozen(3600, 0.0, LIVE_INSTALL_MAX_W, cfg,
|
||||
dev_min=_ctl_z.dev_min, dev_max=_ctl_z.dev_max))
|
||||
|
||||
|
||||
# AC3's other half, through the built object rather than through a literal: the
|
||||
# opts dict does NOT carry meter_freeze_s, so this is the number an install that
|
||||
# leaves the key at its config.yaml default actually lands on.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,13 @@ HANDOVER.md. An understated runtime reads as a hang.
|
||||
`cycle_floor_s` is read from options with no lower bound in code, so a huge
|
||||
value reproduces the pre-fix behaviour exactly. Every claim below is therefore
|
||||
an A/B on one binary, not a comparison against a remembered old one.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
@@ -3,12 +3,24 @@
|
||||
Walks a whole cycle in simulated time, which is the test the YAML version never
|
||||
had - and it is exactly the phase transitions that a real cycle takes ten hours
|
||||
to exercise once a month.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.maintenance import CHARGE, DRAIN, HOLD, IDLE, MaintConfig, Maintenance
|
||||
from app.maintenance import (
|
||||
CHARGE, DRAIN, HOLD, IDLE, MAINT_OPTION_DEFAULTS, MaintConfig, Maintenance,
|
||||
)
|
||||
|
||||
fails = []
|
||||
|
||||
@@ -104,6 +116,114 @@ print("disabled schedule")
|
||||
m = Maintenance(MaintConfig(enabled=False), FakeStore())
|
||||
check("disabled never starts", m.tick(t0, soc=80).phase == IDLE)
|
||||
|
||||
print("CFG-03: config.yaml is the source, this module is the only mirror")
|
||||
# ⚠️ WHY THIS IS PINNED BY READING THE FILE. Before CFG-03 each maintenance
|
||||
# default was written down three times - config.yaml's `options:` block,
|
||||
# `opts.get(key, <literal>)` in main.py, and MaintConfig's dataclass defaults -
|
||||
# and MAINT-04 proved they had already drifted (2000 shipped, 2500 in code). The
|
||||
# fix is one source and one mirror; the mirror is only worth anything if drift
|
||||
# from it is a CI failure rather than a comment, and a transcription of
|
||||
# config.yaml into this file would just be a fourth place to disagree. So the
|
||||
# shipped values are READ out of the file the Supervisor reads.
|
||||
#
|
||||
# Regex rather than PyYAML on purpose, for test_validate.py's reason: the add-on
|
||||
# image installs py3-aiohttp and py3-paho-mqtt only, and a check that skips on
|
||||
# ImportError is not a check.
|
||||
HERE = Path(__file__).resolve().parent
|
||||
_CFG_TEXT = (HERE / "config.yaml").read_text(encoding="utf-8")
|
||||
_SCHEMA_AT = _CFG_TEXT.index("\nschema:")
|
||||
|
||||
|
||||
def shipped_default(key):
|
||||
"""The value under `options:` - the FIRST match, before `schema:`."""
|
||||
m = re.search(rf"^ {key}: *(.+?) *$", _CFG_TEXT[:_SCHEMA_AT], re.M)
|
||||
return None if m is None else m.group(1)
|
||||
|
||||
|
||||
def schema_range(key):
|
||||
"""`int(lo,hi)` under `schema:` -> (lo, hi); None if the key is absent."""
|
||||
m = re.search(rf"^ {key}: *\w+\((-?[\d.]+),(-?[\d.]+)\) *$",
|
||||
_CFG_TEXT[_SCHEMA_AT:], re.M)
|
||||
return None if m is None else (float(m.group(1)), float(m.group(2)))
|
||||
|
||||
|
||||
def as_number(text):
|
||||
if text in ("true", "false"):
|
||||
return 1.0 if text == "true" else 0.0
|
||||
return float(text)
|
||||
|
||||
|
||||
_ship = {k: shipped_default(k) for k in MAINT_OPTION_DEFAULTS}
|
||||
print(f" config.yaml ships {_ship}")
|
||||
check("every mirrored key is actually shipped in config.yaml's options: block",
|
||||
all(v is not None for v in _ship.values()))
|
||||
check("...and MAINT_OPTION_DEFAULTS reproduces each one exactly, so the mirror "
|
||||
"is a copy of the source and not an opinion about it",
|
||||
all(str(MAINT_OPTION_DEFAULTS[k]).lower() == _ship[k]
|
||||
for k in MAINT_OPTION_DEFAULTS))
|
||||
# ⚠️ THE OTHER DIRECTION MATTERS TOO. Equality alone would still pass if the
|
||||
# mirror quietly stopped covering a key someone added to config.yaml, and the
|
||||
# key nobody mirrors is exactly the key that gets a literal typed for it.
|
||||
_shipped_keys = set(re.findall(r"^ (maintenance_[a-z_]+):", _CFG_TEXT[:_SCHEMA_AT],
|
||||
re.M))
|
||||
check(f"...and it covers EVERY maintenance_* option the file ships, so a ninth "
|
||||
f"key cannot arrive unmirrored {sorted(_shipped_keys)}",
|
||||
_shipped_keys == set(MAINT_OPTION_DEFAULTS))
|
||||
|
||||
# ⚠️ WHERE THE RANGE LIVES, ASSERTED RATHER THAN COMMENTED. The PM proofread on
|
||||
# this card named a FOURTH declaration site of a different kind:
|
||||
# `maintenance_charge_w: int(500,5000)` in the schema block. It is a BOUND, not
|
||||
# a default, and the decision this card records is that bounds live in the
|
||||
# schema block ONLY - the Supervisor enforces them and reads only that file, so
|
||||
# a Python copy would be a place to disagree and nothing else. What that
|
||||
# decision needs from a check is (a) every mirrored key really does have a
|
||||
# schema entry, or the Supervisor drops the option and the add-on silently runs
|
||||
# on the mirror instead of the operator's value, and (b) the shipped default
|
||||
# lies INSIDE its own range, because a default the schema rejects is a config
|
||||
# that cannot be re-entered once it is edited.
|
||||
_ranged = {k: schema_range(k) for k in MAINT_OPTION_DEFAULTS}
|
||||
check("every mirrored key is in the schema: block too - `bool` has no range, so "
|
||||
"it is the one exception and it is named",
|
||||
all(v is not None for k, v in _ranged.items()
|
||||
if k != "maintenance_enabled")
|
||||
and _ranged["maintenance_enabled"] is None)
|
||||
_outside = {k: (as_number(_ship[k]), rng) for k, rng in _ranged.items()
|
||||
if rng is not None and not rng[0] <= as_number(_ship[k]) <= rng[1]}
|
||||
check(f"...and every shipped default sits inside its own schema range, so the "
|
||||
f"Supervisor accepts the file it ships with {_outside or 'all inside'}",
|
||||
not _outside)
|
||||
|
||||
# ⚠️ AND THE CONSUMERS MOVE WITH IT - checked as a RELATION, not against
|
||||
# numbers. AC4 asks for the replacement to be broken and every consumer observed
|
||||
# moving together. A number typed here would pass whether or not the derivation
|
||||
# survived; `dataclasses.fields()` reads what the class actually defaulted to,
|
||||
# so this can only pass while the class is still indexing the mirror.
|
||||
_fields = {f.name: f.default for f in dataclasses.fields(MaintConfig)}
|
||||
check(f"MaintConfig's field names are the option names minus the prefix, so a "
|
||||
f"new field cannot miss the mirror by being spelt differently {sorted(_fields)}",
|
||||
{f"maintenance_{n}" for n in _fields} == set(MAINT_OPTION_DEFAULTS))
|
||||
check("...and every dataclass default IS the mirror's value - no literal "
|
||||
"survives in the class that used to hold the third opinion",
|
||||
all(float(d) == float(MAINT_OPTION_DEFAULTS[f"maintenance_{n}"])
|
||||
for n, d in _fields.items()))
|
||||
# from_options() is the only reader, so it is the only thing that can put a
|
||||
# different number in front of main.py.
|
||||
_from_empty = MaintConfig.from_options({})
|
||||
check("...and from_options({}) - the sim/inject.py and test-suite path, the one "
|
||||
"the Supervisor never takes and the only one the divergence was reachable "
|
||||
"from - equals MaintConfig() exactly",
|
||||
_from_empty == MaintConfig())
|
||||
check("...while a supplied value still wins, or the mirror would be a ceiling "
|
||||
"rather than a default",
|
||||
MaintConfig.from_options({"maintenance_charge_w": 1234}).charge_w == 1234.0)
|
||||
# ⚠️ `key in opts`, not truthiness: `false` and `0` are values an operator can
|
||||
# set, and a truthy read would silently replace both with the default - this
|
||||
# card's own bug in a different costume.
|
||||
check("...and an explicit falsey value is honoured rather than defaulted away",
|
||||
MaintConfig.from_options({"maintenance_start_hour": 0}).start_hour == 0
|
||||
and MaintConfig.from_options({"maintenance_enabled": False}).enabled is False)
|
||||
|
||||
print()
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -8,6 +8,13 @@ Every assert here is a rule whose absence poisons something downstream: an
|
||||
inverted sign inverts the control loop, a fabricated zero hides a dead meter, a
|
||||
naive per-phase sum misbills the capacity tariff, and a plain mean of samples
|
||||
computes the wrong quarter-hour figure whenever the telegram cadence changes.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
+1374
-36
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,13 @@ TWO RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
||||
- the DST checks run on the 23-hour and 25-hour days, not on a normal
|
||||
24-hour day where a fixed-24-slot implementation and a correct one
|
||||
agree exactly.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import ast
|
||||
|
||||
@@ -48,6 +48,13 @@ THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
||||
and a stale one is not a fresh one: the gate blocks on `no_data`, blocks on
|
||||
an hour the series does not cover, and blocks on a provisional hour - three
|
||||
different reasons, none of them an answer.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import ast
|
||||
|
||||
@@ -10,6 +10,11 @@ it:
|
||||
AC2 Fully-fixed pricing DISABLES ARBITRAGE and leaves self-consumption only.
|
||||
AC3 The Belgian scenario suite carries a static-profile regression case.
|
||||
|
||||
Section 9 is TEST-16 and is not one of those criteria. It pins the two
|
||||
`arbitrage()` rungs SAFETY-26's AC4 sweep found pinned from ONE SIDE ONLY -
|
||||
`spread == 0.0` and `len(firm) < 2` - and, more to the point, pins them so
|
||||
they stop MASKING EACH OTHER. Read its header before editing either rung.
|
||||
|
||||
THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
||||
|
||||
* EVERY VERDICT IS DERIVED. No `check("the split is right", True)`. Where a
|
||||
@@ -45,6 +50,13 @@ THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
||||
price, and zero is exactly the value that makes everything look free. So
|
||||
`static_plan` with no profile is checked to produce a read with NO
|
||||
`.points`, and `price_at` on it must RAISE rather than answer 0.0.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
@@ -55,7 +67,7 @@ from pathlib import Path
|
||||
|
||||
from app.price import (
|
||||
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
|
||||
plan_is_provisional,
|
||||
plan_is_provisional, required_spread, uncertainty_of,
|
||||
)
|
||||
from app.static_tariff import (
|
||||
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
|
||||
@@ -853,6 +865,255 @@ check("...and main.py grew no `o.get(\"static_profile\", <a default>)` fallback
|
||||
'get("static_profile"' not in _MAIN and "static_status(" in _MAIN)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("9. TEST-16 - the two arbitrage rungs, pinned from BOTH sides and "
|
||||
"un-masked from each other")
|
||||
# SAFETY-26's AC4 gave the same tolerance mutation to every price and quantity
|
||||
# boundary in arbitrage.py and static_tariff.py. Everything in arbitrage.py went
|
||||
# red. The two survivors are the two rungs graded below, and section 2 above is
|
||||
# why they survived: it probes the flat rung with an EXACTLY FLAT series and
|
||||
# never probes the firm-pair rung at all.
|
||||
#
|
||||
# D `spread == 0.0` weakened to `spread <= 0.001` SURVIVED (+2 B). An
|
||||
# exactly flat series refuses under both spellings, so a tolerance is
|
||||
# invisible to a flat probe. It is NOT cosmetic: a real 0.0005 spread
|
||||
# flips ALLOWED -> REFUSED at min_spread_eur_kwh=0.0, which arbitrage()'s
|
||||
# own docstring documents as supported ("pass 0.0 to mean any positive
|
||||
# spread, explicitly") and which its validator at :861 accepts on purpose.
|
||||
#
|
||||
# F `len(firm) < 2` weakened to `< 1` SURVIVED, at +0 BYTES - the delta
|
||||
# MUTATION.md names as the risky one.
|
||||
#
|
||||
# ⚠️ AND THE FINDING IS THE INTERACTION, NOT EITHER ROW. D AND F MASK EACH
|
||||
# OTHER. A single firm hour has a spread of 0.0, so with F weakened the FLAT
|
||||
# rung catches the stimulus and the verdict is still "refused"; with D weakened
|
||||
# the tiny-spread stimulus is still refused, just for a lie. Each rung's failure
|
||||
# is absorbed by the other, so BOTH can be weakened together and a suite that
|
||||
# grades only `kind` reports nothing. That is invisible to single-mutation
|
||||
# testing, which is how this pair survived four rounds of review on TARIFF-03
|
||||
# and FCST-03.
|
||||
#
|
||||
# TWO THINGS FOLLOW, and they shape every check below.
|
||||
#
|
||||
# 1. THE REASON IS GRADED, NOT JUST THE REFUSAL. The masking case REFUSES
|
||||
# CORRECTLY WHILE REPORTING THE WRONG SENTENCE - an operator reading "the
|
||||
# tariff is flat" about a tariff that is not flat, or reading it about a
|
||||
# series whose real problem is that §8.3 has no firm pair to trade on. A
|
||||
# check that asserts only "refused" passes on a broken tree. So each
|
||||
# stimulus below is graded on WHICH RUNG ANSWERED IT.
|
||||
# 2. EACH RUNG NEEDS A STIMULUS THE OTHER CANNOT ABSORB, or the pinning is
|
||||
# circular. The flat rung gets a series with THREE firm hours, where the
|
||||
# firm-pair rung cannot fire under any weakening down to `< 0`. The
|
||||
# firm-pair rung gets a ONE-FIRM series, which the flat rung CAN answer -
|
||||
# so its attribution is the whole check, and that is why row 2 of the table
|
||||
# is written as a rung name rather than as a verdict.
|
||||
#
|
||||
# ⚠️ SHIPPED BEHAVIOUR IS UNCHANGED BY THIS SECTION. Both rungs are correct
|
||||
# today; nothing in app/ was touched. This is the missing measurement, not a fix.
|
||||
#
|
||||
# THE LEDGER. Eight rounds through `tools/mutation_run.sh` (TEST-15): recursive
|
||||
# `__pycache__` purge before each, `python3 -B`, the tree re-copied FROM DISK
|
||||
# between rounds, the mutated line grepped out of the file before the run, and
|
||||
# the byte delta printed. `test_static_tariff.py` and `test_arbitrage.py` graded
|
||||
# each time. Both suites green on the unmutated tree.
|
||||
#
|
||||
# round delta test_static_tariff.py
|
||||
# --------------------------------------------- ------ ---------------------
|
||||
# baseline, shipped +0 B PASS
|
||||
# D1 `spread == 0.0` -> `<= 0.001` +2 B RED, 3 checks
|
||||
# D2 `spread == 0.0` -> `<= 0.000001` +5 B RED, AC1 alone
|
||||
# D3 `spread == 0.0` -> `< 0.0` (rung gone) +0 B RED (section 2 too)
|
||||
# F1 `len(firm) < 2` -> `< 1` +0 B RED, 3 checks
|
||||
# F2 `len(firm) < 2` -> `< 3` +0 B RED, 6 checks
|
||||
# F3 `len(firm) < 2` -> `< 0` (rung gone) +0 B RED (and ValueError)
|
||||
# D1+F1 BOTH AT ONCE - THE TICKET +2 B RED, 5 checks
|
||||
#
|
||||
# Read the rows in pairs. D1 and F1 are SAFETY-26's two survivors and each is
|
||||
# now red on its own; D2 is the tolerance bisected to the rung's own resolution,
|
||||
# so "pinned at exactly zero" is measured rather than asserted at one epsilon;
|
||||
# F2 and F3 pin the firm-pair count from ABOVE and BELOW, which the ticket's
|
||||
# criteria do not ask for but which is what stops this section being one-sided
|
||||
# in the way it was written to fix. THE LAST ROW IS THE TICKET: with BOTH rungs
|
||||
# weakened together - the arrangement that reported nothing before this section
|
||||
# existed - five checks go red, including AC1 and AC2, because neither of them
|
||||
# is spelled in terms of the other rung's behaviour.
|
||||
#
|
||||
# ⚠️ FOUR OF THOSE SEVEN MUTATIONS ARE +0 BYTES, which MUTATION.md names as the
|
||||
# delta stale bytecode can hide. They are all REDS, the direction the bytecode
|
||||
# defect cannot fabricate - but they were run under the purge anyway, because
|
||||
# "it went red so the cache cannot have lied" is a conclusion, not a procedure.
|
||||
|
||||
_A9 = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset())
|
||||
|
||||
|
||||
def plan9(prices, provisional=None, product=PriceProduct.DAY_AHEAD):
|
||||
"""A bare hourly series, built here rather than through a profile.
|
||||
|
||||
A `static_profile` cannot express a 0.0005 spread or a part-provisional day,
|
||||
and going through one would grade the profile generator as well. These are
|
||||
the smallest series that put exactly one rung in question.
|
||||
"""
|
||||
flags = provisional if provisional is not None else [False] * len(prices)
|
||||
pts = [PricePoint(start=brussels(THU + timedelta(hours=i)),
|
||||
eur_per_kwh=p, provisional=v)
|
||||
for i, (p, v) in enumerate(zip(prices, flags))]
|
||||
return TariffPlan(PriceRead.series(pts, product, "generic_dayahead", THU),
|
||||
None, "provider")
|
||||
|
||||
|
||||
def rung9(verdict):
|
||||
"""WHICH refusal answered, named from the reason the operator would read.
|
||||
|
||||
Deliberately not a code inspection: the sentence in the log IS the artefact
|
||||
that degrades when these two rungs mask each other, so it is the sentence
|
||||
that gets graded. An unrecognised refusal is reported as such rather than
|
||||
quietly matching nothing - "no rung fired" and "a rung I did not list fired"
|
||||
must not grade the same.
|
||||
"""
|
||||
if verdict.kind == "allowed":
|
||||
return "allowed"
|
||||
r = verdict.reason
|
||||
hit = [n for n, needle in (("flat", "the tariff is flat"),
|
||||
("firm-pair", "carry a published price"),
|
||||
("threshold", "needs"),
|
||||
("peak", "peak guard"),
|
||||
("no-series", "No series exists"))
|
||||
if needle in r]
|
||||
return hit[0] if len(hit) == 1 else f"UNATTRIBUTED<{r[:60]}>"
|
||||
|
||||
|
||||
# --- the sweep D survived: a small but REAL spread, at min_spread 0.0 --------
|
||||
# ⚠️ NOT AN EXACTLY FLAT SERIES, which is the whole point - a flat probe cannot
|
||||
# see a tolerance. The threshold is 0.0 so the `spread < need` rung below cannot
|
||||
# fire either (required_spread(0.0, anything) is 0.0, asserted first), leaving
|
||||
# the flat rung as the only thing that can refuse these.
|
||||
check(f"the widened threshold at min_spread_eur_kwh=0.0 is still 0.0, so the "
|
||||
f"probes below are graded by the FLAT rung and not by the threshold rung "
|
||||
f"({required_spread(0.0, uncertainty_of(PriceProduct.DAY_AHEAD)):.6f})",
|
||||
near(required_spread(0.0, uncertainty_of(PriceProduct.DAY_AHEAD)), 0.0))
|
||||
|
||||
# 1e-6 is the floor of the question, not of the check: app/static_tariff.py
|
||||
# rounds the spread to 6 decimals before comparing, so a 5e-7 difference IS
|
||||
# zero to this rung by construction. Graded as its own row below rather than
|
||||
# left as an unstated edge.
|
||||
TINY9 = (0.0005, 0.0001, 0.00001, 0.000001)
|
||||
_tiny9 = [(d, plan9([0.20000, round(0.20000 + d, 6)])) for d in TINY9]
|
||||
check(f"each tiny-spread probe really carries the spread it is named for, and "
|
||||
f"two FIRM hours - so the firm-pair rung cannot be what answers them "
|
||||
f"{[(f'{d:.6f}', f'{p.spread_eur_kwh():.6f}') for d, p in _tiny9]}",
|
||||
all(near(p.spread_eur_kwh(), d, 1e-12)
|
||||
and len([q for q in p.read.points if not q.provisional]) == 2
|
||||
for d, p in _tiny9))
|
||||
_tinyv9 = [(d, rung9(p.arbitrage(**_A9))) for d, p in _tiny9]
|
||||
check(f"TEST-16 AC1: a spread that is SMALL BUT REAL is ALLOWED at "
|
||||
f"min_spread_eur_kwh=0.0 - down to 0.000001 EUR/kWh, so the flat rung is "
|
||||
f"`spread == 0.0` and not `spread <= epsilon` for any epsilon "
|
||||
f"{_tinyv9}",
|
||||
all(r == "allowed" for _d, r in _tinyv9))
|
||||
check("...and the exactly flat series on the OTHER side of that boundary is "
|
||||
"still refused, and refused BY THE FLAT RUNG - without this the sweep "
|
||||
"above would also pass for a rung that had been deleted outright",
|
||||
rung9(plan9([0.20000, 0.20000]).arbitrage(**_A9)) == "flat")
|
||||
_q9 = plan9([0.20000, 0.2000005])
|
||||
check(f"...and a difference below the rung's own 6-decimal rounding is flat BY "
|
||||
f"CONSTRUCTION and says so: the raw difference is "
|
||||
f"{_q9.spread_eur_kwh():.7f} and non-zero, `round(..., 6)` makes it "
|
||||
f"exactly 0.0, and the rung agrees. That is the QUANTISATION the shipped "
|
||||
f"line chooses in the open, not a tolerance smuggled into the comparison "
|
||||
f"- and it is why the sweep above stops at 0.000001 rather than pretending "
|
||||
f"to bisect further",
|
||||
not near(_q9.spread_eur_kwh(), 0.0, 1e-9)
|
||||
and near(round(_q9.spread_eur_kwh(), 6), 0.0, 1e-12)
|
||||
and rung9(_q9.arbitrage(**_A9)) == "flat")
|
||||
|
||||
# --- and what D's survival actually costs: the sentence, not the verdict -----
|
||||
_lie9 = plan9([0.20000, 0.20050]).arbitrage(**_A9)
|
||||
check(f"TEST-16 AC4: the 0.0005 verdict carries the real spread rather than a "
|
||||
f"flatness claim about a tariff that is not flat - the false sentence is "
|
||||
f"what a loosened rung ships to the operator while the outcome at a "
|
||||
f"shipped threshold looks unchanged ({_lie9.kind})",
|
||||
_lie9.kind == "allowed" and near(_lie9.spread_eur_kwh, 0.0005)
|
||||
and near(_lie9.cheapest.eur_per_kwh, 0.20000)
|
||||
and near(_lie9.dearest.eur_per_kwh, 0.20050))
|
||||
|
||||
# --- the rung F survived: ON ITS OWN, not through the flat rung --------------
|
||||
# ⚠️ THIS ROW IS THE ANTI-MASKING ONE. A one-firm series has a spread of 0.0,
|
||||
# so with `< 2` weakened to `< 1` the FLAT rung refuses it and `kind` never
|
||||
# moves. The only thing that moves is the sentence: §8.3's honest "only 1 of 3
|
||||
# hours carry a published price" degrades to "the tariff is flat" about a series
|
||||
# whose firm and provisional prices differ by 0.20 EUR/kWh. Grading the rung
|
||||
# name is what makes `< 1` red without borrowing anything from the flat rung.
|
||||
_one9 = plan9([0.20, 0.40, 0.10], [False, True, True])
|
||||
check(f"the one-firm probe is well formed: exactly ONE firm hour out of three, "
|
||||
f"and its full series is anything but flat (spread "
|
||||
f"{max(p.eur_per_kwh for p in _one9.read.points) - min(p.eur_per_kwh for p in _one9.read.points):.2f} "
|
||||
f"EUR/kWh) - so 'flat' would be a false sentence about it",
|
||||
len([p for p in _one9.read.points if not p.provisional]) == 1
|
||||
and len(_one9.read.points) == 3)
|
||||
check("TEST-16 AC2: one firm hour is refused BY §8.3's FIRM-PAIR RUNG, named "
|
||||
"in the reason - not by the flat rung that would also have refused it. "
|
||||
"This is what takes `len(firm) < 2` -> `< 1` red on its own",
|
||||
rung9(_one9.arbitrage(**_A9)) == "firm-pair")
|
||||
check("...and the sentence is §8.3's in full, so the operator is told the "
|
||||
"series is half-published rather than told it is fixed-price",
|
||||
"§8.3" in _one9.arbitrage(**_A9).reason
|
||||
and "only 1 of 3" in _one9.arbitrage(**_A9).reason
|
||||
and "flat" not in _one9.arbitrage(**_A9).reason)
|
||||
# The rung pinned from ABOVE as well. Without this, `len(firm) < 3` - the same
|
||||
# +0 B edit in the other direction - would pass everything above.
|
||||
_two9 = plan9([0.20, 0.40, 0.10], [False, False, True])
|
||||
check("...and TWO firm hours are ALLOWED on the identical series shape, so the "
|
||||
"rung is pinned from above too and `< 3` is red as well as `< 1`",
|
||||
rung9(_two9.arbitrage(**_A9)) == "allowed"
|
||||
and near(_two9.arbitrage(**_A9).spread_eur_kwh, 0.20))
|
||||
# ...and from below, where the failure is not a wrong sentence but a crash:
|
||||
# `max()` over an empty `firm` raises ValueError, so `< 0` does not degrade the
|
||||
# reason, it takes the verdict away entirely.
|
||||
_zero9 = plan9([0.20, 0.40, 0.10], [True, True, True])
|
||||
check("...and a FULLY provisional series is refused by the same rung and does "
|
||||
"not raise - `max()` over an empty firm set is a ValueError, so this row "
|
||||
"is what makes `len(firm) < 0` red",
|
||||
rung9(derived(lambda: _zero9.arbitrage(**_A9))) == "firm-pair")
|
||||
|
||||
# --- AC3: the two rungs do not mask each other ------------------------------
|
||||
# ⚠️ THE POINT OF THE TICKET, AND IT IS A PROPERTY OF THE TABLE, NOT OF A ROW.
|
||||
# Masking is possible exactly when every stimulus that one rung answers could
|
||||
# also be answered by the other with an acceptable-looking result. The table
|
||||
# below denies that on both sides:
|
||||
#
|
||||
# * the flat rung is asked a question the firm-pair rung CANNOT reach - three
|
||||
# firm hours, where `len(firm) < N` refuses for no N a person would write;
|
||||
# * the firm-pair rung is asked a question the flat rung CAN reach - one firm
|
||||
# hour - and the answer is graded on WHICH rung gave it, so the flat rung
|
||||
# absorbing it is a failure rather than a pass;
|
||||
# * and the tiny-spread row is allowed, which neither rung may refuse.
|
||||
#
|
||||
# Verified by running BOTH mutations at once (`spread <= 0.001` AND
|
||||
# `len(firm) < 1`, purged harness, +2 B and +0 B): three rows move. Under
|
||||
# single-mutation testing this arrangement reports nothing at all.
|
||||
_TABLE9 = (
|
||||
("three firm hours, all 0.2000", plan9([0.2, 0.2, 0.2]), "flat"),
|
||||
("two firm hours, 0.0005 apart", plan9([0.20000, 0.20050]), "allowed"),
|
||||
("one firm hour of three", _one9, "firm-pair"),
|
||||
("two firm hours of three", _two9, "allowed"),
|
||||
("no firm hours at all", _zero9, "firm-pair"),
|
||||
)
|
||||
_got9 = [(name, want, rung9(derived(lambda p=pl: p.arbitrage(**_A9))))
|
||||
for name, pl, want in _TABLE9]
|
||||
check(f"TEST-16 AC3: each stimulus is answered by the rung that OWNS it, and "
|
||||
f"by no other - the flat rung is asked a question the firm-pair rung "
|
||||
f"cannot reach, and the firm-pair rung is asked one the flat rung could "
|
||||
f"reach but must not be allowed to. Weakening EITHER rung moves a row; "
|
||||
f"weakening BOTH TOGETHER still moves three "
|
||||
f"{[(n, w, g) for n, w, g in _got9]}",
|
||||
all(want == got for _n, want, got in _got9))
|
||||
check("...and the table really does exercise both rungs and the allowing path, "
|
||||
"so 'every row matched' is not a statement about one rung repeated",
|
||||
{want for _n, _p, want in _TABLE9} == {"flat", "firm-pair", "allowed"})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -85,6 +85,13 @@ regression fixture that CI cannot see is not a regression fixture.
|
||||
|
||||
The recorder's window rolls: 2026-08-01 leaves it around 2026-09-15, which is
|
||||
why the committed copy is the artefact and not the API call.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -108,10 +115,16 @@ fails = []
|
||||
HERE = Path(__file__).resolve().parent
|
||||
TMP = Path(tempfile.mkdtemp(prefix="goodwe-tariff-"))
|
||||
|
||||
# The live install's numbers: NOTES.md and freeze.py both record max_w 5000
|
||||
# there. `peak_cap_w` ships at 3500 in config.yaml and is the SOFT LIMIT AC 3
|
||||
# grades against - read out of the shipped file in part 10 rather than typed.
|
||||
LIVE_MAX_W = 5000.0
|
||||
# ⚠️ NOT config.yaml's shipped max_w (2000). NOTES.md and freeze.py both record
|
||||
# that the LIVE INSTALL runs max_w 5000, and that is the number this file uses
|
||||
# as the device's power ceiling wherever it needs one - it is named
|
||||
# LIVE_INSTALL_MAX_W, not the shorter LIVE_MAX_W, precisely so it cannot be
|
||||
# misread as "the current shipped default" (TEST-10; test_freeze.py carries the identical
|
||||
# constant, under the identical name, for the identical reason - grep it there
|
||||
# for the pin that stops config.yaml's max_w from drifting under it unnoticed).
|
||||
# `peak_cap_w` ships at 3500 in config.yaml and is the SOFT LIMIT AC 3 grades
|
||||
# against - read out of the shipped file in part 10 rather than typed.
|
||||
LIVE_INSTALL_MAX_W = 5000.0
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
@@ -291,7 +304,7 @@ CTRL_W = ctrl_closed[0].offtake_avg_w
|
||||
|
||||
# (b) restarted at minute 10, checkpoint restored through a real SQLite file.
|
||||
st = fresh_store("restart")
|
||||
tr = PeakTracker(st, cap_w=LIVE_MAX_W)
|
||||
tr = PeakTracker(st, cap_w=LIVE_INSTALL_MAX_W)
|
||||
a1 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
feed(a1, 0, RESTART_AT_S)
|
||||
check("the checkpoint round-trips through SQLite", st.save_partial(a1.snapshot()))
|
||||
@@ -328,7 +341,7 @@ OUTAGE_S = 200
|
||||
a4 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
st4 = fresh_store("outage")
|
||||
st4.save_partial(a1.snapshot())
|
||||
PeakTracker(st4, cap_w=LIVE_MAX_W).restore(a4, B + timedelta(seconds=RESTART_AT_S + OUTAGE_S))
|
||||
PeakTracker(st4, cap_w=LIVE_INSTALL_MAX_W).restore(a4, B + timedelta(seconds=RESTART_AT_S + OUTAGE_S))
|
||||
o_closed = feed(a4, RESTART_AT_S + OUTAGE_S, QUARTER_S)
|
||||
OUTAGE_UNCOVERED_S = OUTAGE_S - MAX_HOLD_S
|
||||
print(f" a {OUTAGE_S} s outage inside the quarter, of which "
|
||||
@@ -408,7 +421,7 @@ check("...so a new month starts from its OWN quarters, not from the previous "
|
||||
# The restart that straddles the boundary: a checkpoint whose open block is in
|
||||
# August, restored when `now` is already September.
|
||||
st7 = fresh_store("straddle")
|
||||
tr7 = PeakTracker(st7, cap_w=LIVE_MAX_W)
|
||||
tr7 = PeakTracker(st7, cap_w=LIVE_INSTALL_MAX_W)
|
||||
straddler = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
for i in range(0, 400, STEP_S):
|
||||
straddler.add(sample(AUG_LATE + timedelta(seconds=i), RUN_W))
|
||||
@@ -436,17 +449,17 @@ print("5. AN UNREADABLE STORE IS NOT A ZERO PEAK (the acceptance argument)")
|
||||
|
||||
# Three stores, three states, and the three must not answer alike.
|
||||
good = fresh_store("states")
|
||||
good_tr = PeakTracker(good, cap_w=LIVE_MAX_W)
|
||||
good_tr = PeakTracker(good, cap_w=LIVE_INSTALL_MAX_W)
|
||||
empty_r = good_tr.peak(B)
|
||||
|
||||
gone = fresh_store("gone")
|
||||
gone_tr = PeakTracker(gone, cap_w=LIVE_MAX_W)
|
||||
gone_tr = PeakTracker(gone, cap_w=LIVE_INSTALL_MAX_W)
|
||||
gone.record_block(_CB(B, 3000.0, None))
|
||||
gone.close() # the database goes away under a live reader
|
||||
gone_r = gone_tr.peak(B)
|
||||
|
||||
never = PeakStore(str(TMP / "no-such-dir" / "x.db"))
|
||||
never_tr = PeakTracker(never, cap_w=LIVE_MAX_W)
|
||||
never_tr = PeakTracker(never, cap_w=LIVE_INSTALL_MAX_W)
|
||||
never_r = never_tr.peak(B)
|
||||
|
||||
# A store that is OPEN and whose query FAILS is a third code path - a closed
|
||||
@@ -456,7 +469,7 @@ broken = fresh_store("broken-table")
|
||||
broken.record_block(_CB(B, 3000.0, None))
|
||||
broken._db.execute("DROP TABLE monthly_peak")
|
||||
broken._db.commit()
|
||||
broken_r = PeakTracker(broken, cap_w=LIVE_MAX_W).peak(B)
|
||||
broken_r = PeakTracker(broken, cap_w=LIVE_INSTALL_MAX_W).peak(B)
|
||||
|
||||
for label, r in (("fresh store, no quarter yet", empty_r),
|
||||
("store that broke under a live reader", gone_r),
|
||||
@@ -506,26 +519,26 @@ check("PeakReading exposes no accessor that defaults a missing peak to a "
|
||||
if a.startswith(("get", "value")) or a.endswith("_or")])
|
||||
|
||||
# The consequence, which is the reason any of this matters.
|
||||
print(f" headroom under a {LIVE_MAX_W:.0f} W limit: "
|
||||
print(f" headroom under a {LIVE_INSTALL_MAX_W:.0f} W limit: "
|
||||
f"no quarter yet -> {good_tr.headroom_w(B)}, "
|
||||
f"unreadable -> {gone_tr.headroom_w(B)}")
|
||||
check("an unreadable store yields NO headroom figure - the disarming default "
|
||||
"(full headroom, guard silently off) is unreachable",
|
||||
gone_tr.headroom_w(B) is None and never_tr.headroom_w(B) is None)
|
||||
check(f"...where a genuinely empty month yields the full {LIVE_MAX_W:.0f} W, "
|
||||
check(f"...where a genuinely empty month yields the full {LIVE_INSTALL_MAX_W:.0f} W, "
|
||||
f"which is correct and is a different answer",
|
||||
near(good_tr.headroom_w(B), LIVE_MAX_W))
|
||||
near(good_tr.headroom_w(B), LIVE_INSTALL_MAX_W))
|
||||
check("the 80 % warning fraction is likewise None when nothing was observed",
|
||||
gone_tr.warning_frac(B) is None and good_tr.warning_frac(B) == 0.0)
|
||||
|
||||
# And a real peak reduces headroom, so "always None" does not pass this part.
|
||||
good.record_block(_CB(B, LIVE_MAX_W * 0.9, None))
|
||||
print(f" after one {LIVE_MAX_W * 0.9:.0f} W quarter: "
|
||||
good.record_block(_CB(B, LIVE_INSTALL_MAX_W * 0.9, None))
|
||||
print(f" after one {LIVE_INSTALL_MAX_W * 0.9:.0f} W quarter: "
|
||||
f"headroom {good_tr.headroom_w(B):.0f} W, "
|
||||
f"warning {good_tr.warning_frac(B) * 100:.0f} % of the limit")
|
||||
check("a real observed peak gives a real headroom, so this part is not passed "
|
||||
"by a store that answers None to everything",
|
||||
near(good_tr.headroom_w(B), LIVE_MAX_W * 0.1, 1e-6))
|
||||
near(good_tr.headroom_w(B), LIVE_INSTALL_MAX_W * 0.1, 1e-6))
|
||||
check(f"...and it is over §9.1's 80 % warning threshold",
|
||||
good_tr.warning_frac(B) > 0.8)
|
||||
|
||||
@@ -534,7 +547,7 @@ check(f"...and it is over §9.1's 80 % warning threshold",
|
||||
corrupt_path = TMP / "corrupt.db"
|
||||
corrupt_path.write_bytes(b"this is not a database" * 64)
|
||||
corrupt = PeakStore(str(corrupt_path))
|
||||
c_r = PeakTracker(corrupt, cap_w=LIVE_MAX_W).peak(B)
|
||||
c_r = PeakTracker(corrupt, cap_w=LIVE_INSTALL_MAX_W).peak(B)
|
||||
print(f" a corrupt database file -> {c_r.state}")
|
||||
check("a corrupt database reads NOT_OBSERVED and does not raise into the "
|
||||
"control loop (§6 fail-open)", c_r.state == NOT_OBSERVED)
|
||||
@@ -668,6 +681,65 @@ check(f"...and on a BALANCED load NET and SUM coincide ({b_vals[POLICY_NET]:.0f}
|
||||
f"so a balanced fixture could not have told them apart",
|
||||
near(b_vals[POLICY_NET], b_vals[POLICY_SUM], 1e-6))
|
||||
|
||||
# --- TEST-13 item 1: the POLICY ARGUMENT'S PLUMBING, not just billed_w() ------
|
||||
# `PeakStore.record_block(block, policy)` really does call `billed_w(block,
|
||||
# policy)` - the product code is correct and is not what this adds. What was
|
||||
# missing is that NO CHECK COULD TELL that call apart from a hardcoded
|
||||
# `billed_w(block, POLICY_NET)`. Mutating it to exactly that left all 17 suites
|
||||
# green, twice measured (TARIFF-01's reviewer, re-run through the purge harness
|
||||
# for TEST-15 and again here).
|
||||
#
|
||||
# The reason is this section's own subject, one level up. `billed_w()`
|
||||
# short-circuits to `block.offtake_avg_w` whenever `per_phase_offtake_avg_w` is
|
||||
# absent, and EVERY block that reached a store anywhere in this file was
|
||||
# single-phase or built with `per_phase_offtake_avg_w=None` - so all three
|
||||
# policies collapsed onto one number and the argument was unobservable at the
|
||||
# call site. The checks above grade the FUNCTION on an unbalanced fixture; they
|
||||
# say nothing about whether its CALLER forwards what it was handed. That is the
|
||||
# same shape as WITNESS-02's span normalisation and TARIFF-01's own DST window:
|
||||
# a stimulus evaluated where the quantity it grades is degenerate.
|
||||
#
|
||||
# So the stimulus here is `blk` - the unbalanced three-phase block from the top
|
||||
# of this section, whose three reductions were asserted three lines up to be
|
||||
# three DIFFERENT numbers. A `record_block()` that ignores its argument stores
|
||||
# one policy's figure under all three names and at least two of these
|
||||
# round-trips go red.
|
||||
#
|
||||
# Which direction the defect runs in, because that is what makes it worth
|
||||
# plumbing-testing at all: NET is the SMALLEST of the three on this fixture (the
|
||||
# exporting phase nets off against the other two). A store that silently billed
|
||||
# NET while its caller asked for MAX would under-record the month's peak, and an
|
||||
# under-recorded peak is maximum headroom for SAFETY-07 - the same
|
||||
# under-guarding direction as item 4's `getattr(..., 0.0)` default.
|
||||
_banked = {}
|
||||
for _pol in (POLICY_NET, POLICY_SUM, POLICY_MAX):
|
||||
_st = fresh_store(f"policy-{_pol}")
|
||||
_st.record_block(blk, _pol)
|
||||
_read = _st.peak(month=month_key(blk.start))
|
||||
_derived = _st.recompute_month(month_key(blk.start))
|
||||
_banked[_pol] = _read.peak_w
|
||||
check(f"record_block(block, {_pol!r}) banks {vals[_pol]:.0f} W - the store "
|
||||
f"applies the reduction it was HANDED, not one of its own",
|
||||
_read.is_observed and near(_read.peak_w, vals[_pol]))
|
||||
check(f"...and re-deriving from the 15-min tier gives the same {_pol!r} "
|
||||
f"figure, so BOTH writes in that one transaction used that reduction",
|
||||
_derived.is_observed and near(_derived.peak_w, vals[_pol]))
|
||||
|
||||
_st_def = fresh_store("policy-default")
|
||||
_st_def.record_block(blk)
|
||||
check(f"...and record_block() with NO policy argument banks the NET figure "
|
||||
f"({vals[POLICY_NET]:.0f} W), matching billed_w()'s own shipped default",
|
||||
near(_st_def.peak(month=month_key(blk.start)).peak_w, vals[POLICY_NET]))
|
||||
# The anti-degeneracy pin, stated over the BANKED numbers rather than over
|
||||
# `vals`, because it is the banked ones this triple is made of. If a future
|
||||
# fixture change ever made two of these coincide, the triple above would start
|
||||
# passing under a hardcoded policy and would be silently back where it started.
|
||||
print(f" banked through the store: "
|
||||
+ ", ".join(f"{k}={v:.0f} W" for k, v in _banked.items()))
|
||||
check("the three BANKED figures are three distinct numbers, so this triple is "
|
||||
"not itself degenerate - it can only pass if the argument is read",
|
||||
len({round(v, 6) for v in _banked.values() if v is not None}) == 3)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
@@ -719,17 +791,29 @@ check("the committed replay fixtures are present - a regression fixture CI "
|
||||
"cannot see is not a regression fixture", len(REPLAY) >= 2)
|
||||
|
||||
# ⚠️ `peak_cap_w:` appears TWICE in config.yaml - once under `options:` with the
|
||||
# default, once under `schema:` with `int(500,15000)`. Taking the last match
|
||||
# would blow up on the schema line, and taking any match that parses would be
|
||||
# luck; the FIRST is the options block, which is the number an install lands on.
|
||||
SHIPPED_CAP_W = None
|
||||
for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines():
|
||||
if line.strip().startswith("peak_cap_w:"):
|
||||
try:
|
||||
SHIPPED_CAP_W = float(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
continue
|
||||
break
|
||||
# default, once under `schema:` with `int(500,15000)`. This used to take the
|
||||
# FIRST match on a STRIPPED line and call that the options block; CFG-04 made
|
||||
# stripped-line-first-match unsafe in this file's neighbours (a `max_w:` nested
|
||||
# six spaces under `inverters:` in the schema block began outranking the
|
||||
# top-level one), so it is anchored the same way they now are: the options half
|
||||
# of the file, at exactly two spaces of indentation. `peak_cap_w` is not a
|
||||
# per-inverter key today and this reader was never wrong - it is the IDIOM that
|
||||
# was, and leaving one copy of it here would be leaving the next per-inverter
|
||||
# key a place to land. test_inverter.py part 12 runs this function against a
|
||||
# two-inverter config.
|
||||
def shipped_option(key):
|
||||
"""The `options:` default for `key`, anchored at two spaces. None if absent."""
|
||||
body = (HERE / "config.yaml").read_text(encoding="utf-8")
|
||||
for line in body[:body.index("\nschema:")].splitlines():
|
||||
if line.startswith(f" {key}:") and not line[2:3].isspace():
|
||||
try:
|
||||
return float(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
SHIPPED_CAP_W = shipped_option("peak_cap_w")
|
||||
check("config.yaml's shipped peak_cap_w was read out of the file rather than "
|
||||
"typed here", SHIPPED_CAP_W is not None)
|
||||
|
||||
@@ -739,7 +823,7 @@ under = 0
|
||||
for path in REPLAY:
|
||||
st9 = fresh_store("replay-" + path.stem)
|
||||
ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S)
|
||||
tr9 = PeakTracker(st9, cap_w=SHIPPED_CAP_W or LIVE_MAX_W)
|
||||
tr9 = PeakTracker(st9, cap_w=SHIPPED_CAP_W or LIVE_INSTALL_MAX_W)
|
||||
first = last = None
|
||||
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
@@ -753,7 +837,7 @@ for path in REPLAY:
|
||||
span_h = (last - first).total_seconds() / 3600.0
|
||||
qs = [w for _, w in st9.blocks_in(sorted(months)[0])] if months else []
|
||||
peak = max(qs) if qs else 0.0
|
||||
ok_q = sum(1 for w in qs if w <= (SHIPPED_CAP_W or LIVE_MAX_W))
|
||||
ok_q = sum(1 for w in qs if w <= (SHIPPED_CAP_W or LIVE_INSTALL_MAX_W))
|
||||
total_q += len(qs)
|
||||
under += ok_q
|
||||
replay_days += span_h / 24.0
|
||||
@@ -808,7 +892,7 @@ class _Pub:
|
||||
|
||||
class _Hass:
|
||||
async def limits(self, entity):
|
||||
return (-LIVE_MAX_W, LIVE_MAX_W, 1.0)
|
||||
return (-LIVE_INSTALL_MAX_W, LIVE_INSTALL_MAX_W, 1.0)
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
return None
|
||||
@@ -824,7 +908,7 @@ def built(db_path, opts=None):
|
||||
return Controller(dict(
|
||||
{"setpoint_entity": "number.sp", "meter_source": "ha_signed",
|
||||
"p1_net_entity": "sensor.net", "meter_phases": 1,
|
||||
"max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 1000, "deadband_w": 15,
|
||||
"max_w": LIVE_INSTALL_MAX_W, "gain": 0.6, "slew_w": 1000, "deadband_w": 15,
|
||||
"target_grid_w": 0, "step_w": 10, "heartbeat_s": 10,
|
||||
"stale_input_s": 15}, **(opts or {})),
|
||||
_Hass(), _Store(), _Pub(), stop=asyncio.Event(),
|
||||
@@ -863,7 +947,12 @@ check("...and syncing the same quarter again banks nothing new, so the count "
|
||||
check("...and the peak is unchanged by the repeat",
|
||||
near(c.peak.peak(B).peak_w, 4998.0, 1e-6))
|
||||
|
||||
rows = [r for r in c.checks() if "capacity-tariff" in r["text"] or "peak" in r["text"]]
|
||||
# ⚠️ `when=B`, AND THAT ARGUMENT IS THE TICKET (TEST-17). Without it `checks()`
|
||||
# reads the peak for `datetime.now()`'s month while this fixture banked its
|
||||
# quarter in August, so every check below passed in August and failed from
|
||||
# 2026-09-01 - on a commit nobody had touched. The suite must grade the month
|
||||
# it created, not the month the machine happens to be running in.
|
||||
rows = [r for r in c.checks(when=B) if "capacity-tariff" in r["text"] or "peak" in r["text"]]
|
||||
peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r["text"]]
|
||||
print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)"))
|
||||
check("the status page carries exactly one capacity-tariff peak row",
|
||||
@@ -879,7 +968,7 @@ check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
|
||||
|
||||
# The half that matters: the same page with a store that cannot answer.
|
||||
c2 = built(TMP / "no-such-dir" / "nope.db")
|
||||
u_rows = [r for r in c2.checks() if "capacity-tariff peak" in r["text"]]
|
||||
u_rows = [r for r in c2.checks(when=B) if "capacity-tariff peak" in r["text"]]
|
||||
print(" status row (unreadable store): "
|
||||
+ (u_rows[0]["text"] if u_rows else "(none)"))
|
||||
check("an unreadable store gets its OWN row, saying UNREADABLE", len(u_rows) == 1)
|
||||
@@ -891,6 +980,58 @@ check("...and the healthy row and the unreadable row are different text",
|
||||
bool(u_rows) and bool(peak_rows)
|
||||
and u_rows[0]["text"] != peak_rows[0]["text"])
|
||||
|
||||
# --- TEST-17: the page must be graded at a stated time, and prove it ----------
|
||||
# Three ways this can regress, so three checks. The first two would both go
|
||||
# green again if `checks()` quietly went back to reading the clock, which is
|
||||
# exactly the mutation this section could not previously detect.
|
||||
|
||||
# ⚠️ SELECTED ON "quarter(s) banked", NOT ON "peak". The NO_RECORD row reads
|
||||
# "2026-10: no completed quarter recorded yet - ..." and contains neither the
|
||||
# word "peak" nor "capacity-tariff" - only the UNREADABLE branch prefixes the
|
||||
# family name. Filtering these rows the way the checks above do would drop the
|
||||
# row and then grade its absence, which is the very mistake this ticket fixes.
|
||||
# (That asymmetry in the row's own text is worth a look on its own; it is what
|
||||
# made the original filter brittle. Not changed here - it is product text with
|
||||
# other suites reading it.)
|
||||
_peak_rows = lambda rows: [r for r in rows if "quarter(s) banked" in r["text"]]
|
||||
|
||||
_later = B + timedelta(days=45) # same fixture, a later billing month
|
||||
_other_peak = _peak_rows(c.checks(when=_later))
|
||||
print(f" the SAME controller read at {_later.date()}: "
|
||||
+ (_other_peak[0]["text"] if _other_peak else "(no peak row)"))
|
||||
check("asked for a LATER month, the same controller reports that month's "
|
||||
"absence of a quarter, not August's 4998 W - the row follows `when`",
|
||||
bool(_other_peak) and "4998 W" not in _other_peak[0]["text"]
|
||||
and "no completed quarter recorded yet" in _other_peak[0]["text"])
|
||||
check("...and that is NO_RECORD, distinguishable from BOTH an observed peak and "
|
||||
"an unreadable store - a month with no quarter is not a broken instrument",
|
||||
bool(_other_peak) and bool(peak_rows)
|
||||
and "UNREADABLE" not in _other_peak[0]["text"]
|
||||
and _other_peak[0]["text"] != peak_rows[0]["text"])
|
||||
check("...and it is not FLAGGED, because an empty month is not an exceedance",
|
||||
bool(_other_peak) and _other_peak[0]["ok"] and not _other_peak[0]["warn"])
|
||||
|
||||
# The mutation guard. `checks()` ignoring `when` is the whole defect, and it is
|
||||
# invisible to every check above whenever the machine's clock happens to sit in
|
||||
# the fixture's own month - which is how this shipped. Grading two DIFFERENT
|
||||
# reference months against each other cannot be satisfied by a clock at all.
|
||||
check("the peak row differs between two reference months, so a `checks()` that "
|
||||
"dropped `when` and read datetime.now() would fail HERE regardless of "
|
||||
"what day the suite is run on",
|
||||
bool(peak_rows) and bool(_other_peak)
|
||||
and peak_rows[0]["text"] != _other_peak[0]["text"])
|
||||
|
||||
# And the production meaning is unchanged: no argument still means today.
|
||||
_now_peak = _peak_rows(c.checks())
|
||||
_today_is_fixture_month = month_key(datetime.now(timezone.utc)) == month_key(B)
|
||||
check("calling checks() with NO argument still reads the month being billed "
|
||||
"today - the live status page's meaning is untouched",
|
||||
bool(_now_peak) and (bool(peak_rows)
|
||||
and _now_peak[0]["text"] == peak_rows[0]["text"]
|
||||
if _today_is_fixture_month
|
||||
else "no completed quarter recorded yet"
|
||||
in _now_peak[0]["text"]))
|
||||
|
||||
# THE MEASUREMENT IS OF DELIVERED POWER (SAFETY-20). Graded on the parsed
|
||||
# SYNTAX, not on the text: the module docstring argues this at length and names
|
||||
# every forbidden word while doing so, so a grep over the source would be
|
||||
@@ -906,24 +1047,106 @@ _names |= {n.name for n in _ast.walk(_tree)
|
||||
_FORBIDDEN = {"setpoint", "write_setpoint", "target", "Decision", "i_clamped",
|
||||
"clamped", "Claim", "resolve", "P_PEAK", "arbiter"}
|
||||
_found = sorted(_names & _FORBIDDEN)
|
||||
_imports = sorted(m.module or "" for m in _ast.walk(_tree)
|
||||
if isinstance(m, _ast.ImportFrom))
|
||||
|
||||
# ⚠️ THE IMPORT HALF WALKS `Import` TOO, AND READS THE IMPORTED NAMES - TEST-13
|
||||
# item 2. It used to be `m.module for m in walk(_tree) if isinstance(m,
|
||||
# ImportFrom)`, matched against a literal list of six spellings, and that is
|
||||
# blind to TWO OF PYTHON'S THREE import forms:
|
||||
#
|
||||
# * `from . import arbiter as _ab` is an `ImportFrom` whose `.module` is
|
||||
# **None** - the dots live in `.level` and the module name lives in
|
||||
# `.names` - so the old grader collected `""` and matched nothing;
|
||||
# * `import app.arbiter` is an `ast.Import` node, a different class the walk
|
||||
# filtered out entirely and therefore never looked at.
|
||||
#
|
||||
# Neither was a live exposure: the `_names` half above catches any USE of either
|
||||
# alias, because reaching anything through it produces an `Attribute` whose
|
||||
# `.attr` is a forbidden name. The defect was in the SENTENCE. A check that
|
||||
# prints "it imports nothing from arbiter, clamp or control" while verifying
|
||||
# that for one spelling out of three is worse than one that claims less, because
|
||||
# the next reader stops looking. So: collect every module any import statement
|
||||
# names, in all three forms, and match per dotted SEGMENT - `app.arbiter` and
|
||||
# `.arbiter` must not be able to hide behind a package prefix either.
|
||||
_FORBIDDEN_MODULES = {"arbiter", "clamp", "control"}
|
||||
|
||||
|
||||
def _imported_modules(tree):
|
||||
"""Every module path an import statement in `tree` names, all three forms."""
|
||||
out = set()
|
||||
for node in _ast.walk(tree):
|
||||
if isinstance(node, _ast.Import):
|
||||
# `import app.arbiter`, `import app.arbiter as x` - the whole
|
||||
# dotted path is the module.
|
||||
out |= {a.name for a in node.names}
|
||||
elif isinstance(node, _ast.ImportFrom):
|
||||
base = node.module or "" # None for `from . import arbiter`
|
||||
if base:
|
||||
out.add(base) # `from .arbiter import resolve`
|
||||
# ...and in the `module is None` form the MODULE is in .names, so
|
||||
# the names have to be joined on whether or not there is a base.
|
||||
out |= {f"{base}.{a.name}" if base else a.name for a in node.names}
|
||||
return out
|
||||
|
||||
|
||||
def _forbidden_imports(mods):
|
||||
# Segment-wise, not substring: `arbitrage` contains no forbidden segment and
|
||||
# must not be flagged, while `app.arbiter` must be.
|
||||
return sorted(m for m in mods
|
||||
if _FORBIDDEN_MODULES & {p for p in m.split(".") if p})
|
||||
|
||||
|
||||
_imports = sorted(_imported_modules(_tree))
|
||||
print(f" tariff.py evaluates {len(_names)} distinct names and imports "
|
||||
f"from {_imports or ['(stdlib only)']}")
|
||||
f"{sorted({m.split('.')[0] for m in _imports}) or ['(nothing)']}")
|
||||
check(f"none of them is a command-side name {sorted(_FORBIDDEN)} - the utility "
|
||||
f"bills what FLOWED, so this reads the meter and nothing else"
|
||||
+ (f"; found {_found}" if _found else ""), not _found)
|
||||
check("...and it imports nothing from arbiter, clamp or control: it files no "
|
||||
"claim, applies no clamp, and commands nothing - SAFETY-07's job",
|
||||
not [m for m in _imports if m in ("arbiter", "clamp", "control",
|
||||
".arbiter", ".clamp", ".control")])
|
||||
# ...and the guard is not vacuous: the same grader run over main.py, which DOES
|
||||
# command, must light up.
|
||||
_main = {n.attr for n in _ast.walk(
|
||||
_ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8")))
|
||||
if isinstance(n, _ast.Attribute)}
|
||||
check(f"the same grader over main.py finds {sorted(_main & _FORBIDDEN)}, so it "
|
||||
f"is capable of failing", bool(_main & _FORBIDDEN))
|
||||
check("...and it imports nothing from arbiter, clamp or control in ANY of the "
|
||||
"three import spellings: it files no claim, applies no clamp, and "
|
||||
"commands nothing - SAFETY-07's job",
|
||||
not _forbidden_imports(_imports))
|
||||
|
||||
# The import grader graded. Every one of these is a real way to reach the
|
||||
# command side from this module, and the first two are the ones the previous
|
||||
# version let through; the last two it already caught and must keep catching.
|
||||
_EVASIONS = ["from .arbiter import resolve",
|
||||
"from . import arbiter as _ab",
|
||||
"import app.arbiter",
|
||||
"from app.control import compute",
|
||||
"import app.clamp as _c"]
|
||||
_missed = [e for e in _EVASIONS
|
||||
if not _forbidden_imports(_imported_modules(_ast.parse(e)))]
|
||||
check(f"...and that import grader is not spelling-blind: all {len(_EVASIONS)} "
|
||||
f"ways of importing the command side are caught"
|
||||
+ (f"; MISSED {_missed}" if _missed else ""), not _missed)
|
||||
# ...and it is not simply flagging everything, which would make the line above
|
||||
# free. The last three are the reason the match is per SEGMENT and not `f in m`:
|
||||
# `clamps`, `arbiters` and `controller` all CONTAIN a forbidden word and none of
|
||||
# them is the command side. `controller` is not hypothetical - this package is
|
||||
# `goodwe_controller`, and a substring grader pointed at a path rather than a
|
||||
# module name would refuse the whole repository.
|
||||
_INNOCENT = ["import json", "from datetime import datetime, timezone",
|
||||
"from . import arbitrage", "from .price import Uncertainty",
|
||||
"from zoneinfo import ZoneInfo",
|
||||
"import clamps", "from .arbiters import x",
|
||||
"from . import controller"]
|
||||
_false = [i for i in _INNOCENT
|
||||
if _forbidden_imports(_imported_modules(_ast.parse(i)))]
|
||||
check("...and it does not fire on innocent imports whose names merely CONTAIN a "
|
||||
"forbidden word (`clamps`, `arbiters`, `controller`) - a substring match "
|
||||
"would have"
|
||||
+ (f"; FALSELY FLAGGED {_false}" if _false else ""), not _false)
|
||||
|
||||
# ...and neither half is vacuous: the same two graders run over main.py, which
|
||||
# DOES command, must both light up. main.py carries all three import forms.
|
||||
_main_tree = _ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8"))
|
||||
_main = {n.attr for n in _ast.walk(_main_tree) if isinstance(n, _ast.Attribute)}
|
||||
_main_imports = _forbidden_imports(_imported_modules(_main_tree))
|
||||
check(f"the same NAME grader over main.py finds {sorted(_main & _FORBIDDEN)}, "
|
||||
f"so it is capable of failing", bool(_main & _FORBIDDEN))
|
||||
check(f"...and the same IMPORT grader over main.py finds {_main_imports}, so "
|
||||
f"it is capable of failing on a real module and not only on a fixture",
|
||||
bool(_main_imports))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
@@ -5,14 +5,22 @@ three refusable configurations are refused. The second asserts that everything
|
||||
on validate.py's "deliberately not refused" list still STARTS - because the way
|
||||
a validator dies is not by missing a fault, it is by refusing something harmless
|
||||
until somebody deletes the call.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig
|
||||
from app.validate import (
|
||||
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
|
||||
validate_startup_config,
|
||||
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused,
|
||||
actuator_writers, refuse, validate_startup_config,
|
||||
)
|
||||
|
||||
fails = []
|
||||
@@ -27,10 +35,16 @@ def check(name, cond):
|
||||
def base(**over):
|
||||
"""A configuration that must start. Everything else is this, spoilt."""
|
||||
# ⚠️ The maintenance rates are named EXPLICITLY, at the values config.yaml
|
||||
# ships (MAINT-04). They are not decoration: with the key absent, rule 6
|
||||
# falls back to main.py's 2500 W, which against this 2000 W ceiling is a
|
||||
# genuine refusal - so a base() without them would be a config that does
|
||||
# not start, and every check below would be measuring the wrong thing.
|
||||
# ships (MAINT-04). Under MAINT-04 that was load-bearing: with the key
|
||||
# absent, rule 6 fell back to a 2500 W literal that against this 2000 W
|
||||
# ceiling is a genuine refusal, so a base() without them was a config that
|
||||
# does not start and every check below would have been measuring the wrong
|
||||
# thing. CFG-03 closed that - the fallback is now config.yaml's own 2000 -
|
||||
# so they are here to say out loud what this fixture asks for rather than to
|
||||
# rescue it. The CFG-03 section at the foot of this file is what asserts
|
||||
# that absent and present are now THE SAME NUMBER; do not delete these two
|
||||
# keys, because a base() that agrees with the default by accident is a
|
||||
# fixture that cannot tell you when the default moves.
|
||||
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off",
|
||||
@@ -76,6 +90,40 @@ check("an unreachable device skips the range check rather than assuming",
|
||||
check("a non-numeric max_w is refused",
|
||||
why(validate_startup_config(base(max_w="lots"), -5000, 5000), "not a number"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1b. SAFETY-20's AC4 decision, PINNED HERE RATHER THAN ONLY WRITTEN DOWN
|
||||
# --------------------------------------------------------------------------- #
|
||||
# AC4 asked whether a device rating that disarms SAFETY-18's freeze rail
|
||||
# additionally warrants a refusal of its own, the way `sign_disagree_w >= max_w`
|
||||
# is refused. THE ANSWER IS NO, AND THE REASON IS RULE 2 ABOVE: the configuration
|
||||
# is `max_w` above the device rating, which rule 2 already refuses on the
|
||||
# identical predicate. A rail-specific rule (`0.95 * max_w > dev_max`) would fire
|
||||
# on a strict SUBSET of what rule 2 refuses, so it could never be the rule that
|
||||
# stopped a start.
|
||||
#
|
||||
# ⚠️ THAT DECISION HAS A PREMISE, AND THIS IS THE PREMISE, EXECUTED. If rule 2 is
|
||||
# ever softened to a warning - which is exactly what main.py used to do before
|
||||
# CFG-01 - the reasoning recorded in validate.py's "deliberately not refused"
|
||||
# list silently stops being true, and SAFETY-18 is left with only its runtime
|
||||
# NOT COVERED row. Then somebody has to come back and re-decide AC4.
|
||||
from app.freeze import RAIL_FRAC # noqa: E402 - imported here, beside its use
|
||||
|
||||
_DEV = 4700.0 # the ticket's inverter
|
||||
_CEIL = 5000.0 # the ticket's configured ceiling
|
||||
check("SAFETY-20's premise: a rating below max_w disarms the freeze rail, so "
|
||||
f"{RAIL_FRAC:g} * max_w is unreachable on the ticket's install",
|
||||
_DEV < RAIL_FRAC * _CEIL)
|
||||
check("...and rule 2 REFUSES that whole configuration at startup, which is why "
|
||||
"SAFETY-20 added no rule of its own (AC4)",
|
||||
len(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV)) == 1)
|
||||
check("...on the rating side specifically, not incidentally through some other "
|
||||
"rule", why(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV),
|
||||
"THE DEVICE WINS"))
|
||||
check("...and the case the refusal CANNOT reach - the rating unread because "
|
||||
"ESPHome was not up - still starts, which is precisely why SAFETY-20's "
|
||||
"answer had to be a runtime status row and not a startup gate",
|
||||
validate_startup_config(base(max_w=_CEIL), None, None) == [])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("2. an actuator to write to")
|
||||
# A controller with no actuator: write_setpoint() returns on the blank entity,
|
||||
@@ -87,9 +135,128 @@ for blank in ("", " ", None):
|
||||
check("...and the message says why it is dangerous rather than untidy",
|
||||
why(validate_startup_config(base(setpoint_entity=""), None, None),
|
||||
"heartbeat"))
|
||||
# specs §1.5: two writers on one actuator is not expressible in this schema.
|
||||
check("there is exactly one way to reach the actuator",
|
||||
ACTUATOR_WRITE_KEYS == ("setpoint_entity",))
|
||||
# --------------------------------------------------------------------------- #
|
||||
# specs §1.5: TWO WRITERS ON ONE ACTUATOR, AND WHAT ACTUALLY GRADES IT
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ WHAT USED TO BE HERE, AND WHY IT WAS NOT ENOUGH. This was one line:
|
||||
#
|
||||
# check("there is exactly one way to reach the actuator",
|
||||
# ACTUATOR_WRITE_KEYS == ("setpoint_entity",))
|
||||
#
|
||||
# and validate.py's comment said the §1.5 non-goal was ENFORCED by construction -
|
||||
# exactly one such key, so a second writer is not expressible in the options
|
||||
# schema at all. Both were true. Both graded THE SHAPE OF THE OPTIONS SCHEMA
|
||||
# rather than the writes, and the difference is not academic: the config below
|
||||
# has TWO WRITERS ON ONE ACTUATOR and leaves that tuple's length at 1. The check
|
||||
# stays green while the property it protects is gone, which is this repository's
|
||||
# named failure mode - a check that observes something other than what it claims
|
||||
# to observe.
|
||||
#
|
||||
# So the first thing this section does is DEMONSTRATE the old check passing on
|
||||
# the hazardous config, rather than assert in prose that it would have.
|
||||
_PER_INVERTER_HAZARD = base(inverters=[
|
||||
{"id": "inverter-a", "setpoint_entity": "number.goodwe_setpoint"},
|
||||
# ⚠️ INVERTER B DOES NOT EXIST. Nothing is measured from it here; it is a
|
||||
# config fixture, and the only thing that matters about it is that its
|
||||
# `setpoint_entity` NAMES THE SAME ENTITY as the scalar key above - which is
|
||||
# the shape of the mistake, and the shape CFG-04 makes reachable.
|
||||
{"id": "inverter-b", "setpoint_entity": "number.goodwe_setpoint"}])
|
||||
|
||||
check("⚠️ THE DEFECT, DEMONSTRATED: on a per-inverter config with two writers "
|
||||
"aimed at ONE entity, the old proxy is still green - it counts option "
|
||||
"KEYS, and there is still exactly one key called setpoint_entity. This "
|
||||
"is the check that could not see the thing it was named after",
|
||||
len(ACTUATOR_WRITE_KEYS) == 1)
|
||||
|
||||
_census = actuator_writers(_PER_INVERTER_HAZARD)
|
||||
print(f" the same config, censused by writer: "
|
||||
f"{ {k: len(v) for k, v in _census.items()} }")
|
||||
check("...and the replacement SEES it, because it counts writers per ACTUATOR "
|
||||
"rather than keys: three declarations, one entity",
|
||||
list(_census) == ["number.goodwe_setpoint"]
|
||||
and len(_census["number.goodwe_setpoint"]) == 3)
|
||||
_hazard_problems = validate_startup_config(_PER_INVERTER_HAZARD, -5000, 5000)
|
||||
check("AC 3: and the config is REFUSED, where today it starts",
|
||||
why(_hazard_problems, "one actuator"))
|
||||
check("...and the refusal names the actuator and every writer aimed at it, so "
|
||||
"an operator can see WHICH declarations to remove rather than being told "
|
||||
"only that something is wrong",
|
||||
why(_hazard_problems, "number.goodwe_setpoint")
|
||||
and why(_hazard_problems, "inverters[0].setpoint_entity")
|
||||
and why(_hazard_problems, "inverters[1].setpoint_entity"))
|
||||
|
||||
# ⚠️ AND THE GATE MUST NOT REFUSE THE CONFIG CFG-04 IS ACTUALLY FOR. A validator
|
||||
# that fires on the legitimate case gets switched off by the first person it
|
||||
# inconveniences, and then none of this is enforced. Two inverters with two
|
||||
# entities are two writers on TWO actuators, which is not the non-goal - it is
|
||||
# the whole point of the per-inverter roster.
|
||||
#
|
||||
# ⚠️ THE ENTRIES BELOW GAINED `name` AND `max_w` WHEN CFG-04 LANDED, AND THE
|
||||
# HAZARD FIXTURE ABOVE DELIBERATELY DID NOT. SAFETY-28 wrote both against a
|
||||
# shape that did not exist yet and guessed `id`; CFG-04 chose `name`, and made
|
||||
# `name`/`setpoint_entity`/`max_w` required of every entry (rule 9). The hazard
|
||||
# fixture is left in the OLD guessed shape on purpose - the claim it makes is
|
||||
# that `actuator_writers()` finds writers STRUCTURALLY, under a container and a
|
||||
# key spelling this file was never told, and rewriting it into today's shape
|
||||
# would quietly turn it into a check that the census knows about `inverters`.
|
||||
# This one is the opposite claim - that a real, valid, two-inverter config is
|
||||
# ACCEPTED - so it has to be a config the shipped validator actually accepts.
|
||||
_PER_INVERTER_OK = {k: v for k, v in base().items() if k != "setpoint_entity"}
|
||||
_PER_INVERTER_OK["inverters"] = [
|
||||
{"name": "inverter-a", "setpoint_entity": "number.goodwe_setpoint",
|
||||
"max_w": 2000},
|
||||
# ⚠️ INVERTER B DOES NOT EXIST. 2000 W here is the SHIPPED default repeated,
|
||||
# not a rating measured off a second device - SPIKE-04 measures that when
|
||||
# there is one to measure. Nothing in this file depends on the number.
|
||||
{"name": "inverter-b", "setpoint_entity": "number.goodwe_setpoint_b",
|
||||
"max_w": 2000}]
|
||||
check("two inverters with their own entities are ACCEPTED - one writer each, "
|
||||
"no shared actuator, and that is CFG-04's whole purpose",
|
||||
validate_startup_config(_PER_INVERTER_OK, -5000, 5000) == [])
|
||||
check("...and today's single-inverter config is untouched: one writer, one "
|
||||
"actuator, and the census finds it wherever it is declared",
|
||||
validate_startup_config(base(), -5000, 5000) == []
|
||||
and actuator_writers(base()) ==
|
||||
{"number.goodwe_setpoint": ["setpoint_entity"]})
|
||||
|
||||
# The census is found STRUCTURALLY, not by looking for a container called
|
||||
# `inverters` - CFG-04 has not chosen that name yet, and a census that guessed
|
||||
# wrong would go green while two writers existed, which is the same defect one
|
||||
# step along.
|
||||
check("a writer nested under a container this file has never heard of is "
|
||||
"still counted, because the walk is over the options TREE and not over a "
|
||||
"list of blessed key names",
|
||||
len(actuator_writers({"setpoint_entity": "number.x",
|
||||
"whatever_cfg04_calls_it": {
|
||||
"unit_b": {"setpoint_entity": "NUMBER.X"}}})
|
||||
["number.x"]) == 2)
|
||||
check("...and entity ids are folded to lower case, so a second writer cannot "
|
||||
"be smuggled past the census on Number.X versus number.x",
|
||||
why(validate_startup_config(base(inverters=[{"setpoint_entity":
|
||||
"NUMBER.GOODWE_SETPOINT"}]),
|
||||
-5000, 5000), "one actuator"))
|
||||
# ⚠️ THIS CHECK CHANGED SHAPE IN CFG-04, AND THE REASON IS A DECISION RATHER
|
||||
# THAN AN ADJUSTMENT. It used to assert that a blank per-inverter entity left
|
||||
# the whole config ACCEPTED - "blank is how an operator leaves a slot
|
||||
# unconfigured". CFG-04 decided that is not true of a ROSTER ENTRY: an entry
|
||||
# with no `setpoint_entity` is a unit that `allocator.plan()` still gives a
|
||||
# share of the site target to and that nothing can command, on a status page
|
||||
# reporting a site at full strength. Rule 9 refuses it by name. The claim about
|
||||
# the CENSUS is untouched and is now asserted directly on the census, where it
|
||||
# always belonged - a blank contributes no writer, so it cannot manufacture a
|
||||
# two-writer refusal out of two unconfigured slots.
|
||||
check("a BLANK per-inverter entity is not a writer - the same rule the scalar "
|
||||
"key gets, since write_setpoint() returns immediately on a blank entity",
|
||||
actuator_writers(base(inverters=[{"setpoint_entity": " "},
|
||||
{"setpoint_entity": ""}]))
|
||||
== {"number.goodwe_setpoint": ["setpoint_entity"]})
|
||||
check("...but the ENTRY is refused all the same, by rule 9 and not by the "
|
||||
"census: a roster slot nothing can write still takes a share of the site "
|
||||
"target every cycle and delivers none of it",
|
||||
why(validate_startup_config(base(inverters=[{"name": "inverter-b",
|
||||
"max_w": 2000,
|
||||
"setpoint_entity": " "}]),
|
||||
-5000, 5000), "has no setpoint_entity"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("3. a meter that can never read")
|
||||
@@ -338,74 +505,6 @@ check("...and a ceiling already past the billing quarter, so no value the "
|
||||
"schema still accepts can alarm inside the quarter that bills it",
|
||||
_hi is not None and _hi > _QUARTER_S)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the KNOWN cross-file default divergence, pinned rather than commented")
|
||||
# ⚠️ WHAT THIS GUARDS, AND WHY IT ASSERTS THE BUG RATHER THAN THE FIX.
|
||||
# config.yaml and validate.py now default maintenance_charge_w to 2000, while
|
||||
# main.py's two `opts.get(..., 2500)` fallbacks and MaintConfig's dataclass
|
||||
# default still say 2500. So an options dict with the key ABSENT is ACCEPTED by
|
||||
# rule 6 at 2000 and then built by main.py at 2500 - which is a contradiction:
|
||||
#
|
||||
# validate_startup_config -> [] (accepted)
|
||||
# MaintConfig().charge_w -> 2500.0
|
||||
# arbiter -> 0.0 W, contradiction=True
|
||||
#
|
||||
# That is the bug rule 6 exists to catch, passing rule 6. It is unreachable on a
|
||||
# Supervisor install (every key is always supplied from config.yaml) but fully
|
||||
# reachable from sim/inject.py and from tests - which is exactly where anyone
|
||||
# debugging maintenance works.
|
||||
#
|
||||
# The fix is ONE SOURCE OF TRUTH for option defaults. That is a bigger change
|
||||
# than this ticket and it lands in main.py, which another ticket is live in - so
|
||||
# the divergence is RECORDED here instead of closed. The check therefore passes
|
||||
# on the mismatch and FAILS when either side moves, in EITHER direction:
|
||||
#
|
||||
# - config.yaml moves -> FAIL: re-decide what the fallback should be
|
||||
# - main.py is fixed to match -> FAIL: the divergence is gone, DELETE this
|
||||
# check and the ⚠️ block in validate.py
|
||||
#
|
||||
# A comment cannot do that, and "a comment where a check belonged" is this
|
||||
# repo's most repeated defect.
|
||||
_APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app")
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(*parts), encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
|
||||
# Every literal fallback for this key in main.py, whatever the call shape.
|
||||
_main_falls = [float(m) for m in re.findall(
|
||||
r'\.get\(\s*"maintenance_charge_w"\s*,\s*(-?\d+(?:\.\d+)?)\s*\)',
|
||||
_read(_APP, "main.py"))]
|
||||
# ...and MaintConfig's own dataclass default, which is the one that bites when
|
||||
# main.py is bypassed entirely (a Maintenance built straight from defaults).
|
||||
_dc_falls = [float(m) for m in re.findall(
|
||||
r"^\s*charge_w:\s*float\s*=\s*(-?\d+(?:\.\d+)?)\s*$",
|
||||
_read(_APP, "maintenance.py"), re.M)]
|
||||
|
||||
check(f"main.py's fallbacks are still found and still 2500 {_main_falls}",
|
||||
len(_main_falls) == 2 and set(_main_falls) == {2500.0})
|
||||
check(f"MaintConfig's default is still found and still 2500 {_dc_falls}",
|
||||
len(_dc_falls) == 1 and set(_dc_falls) == {2500.0})
|
||||
check(f"...and config.yaml still ships 2000, so the divergence is UNCHANGED "
|
||||
f"(shipped {_shipped['maintenance_charge_w']:.0f}, code 2500)",
|
||||
_shipped["maintenance_charge_w"] == 2000.0)
|
||||
|
||||
# ⚠️ And the consequence itself, through the REAL code rather than through the
|
||||
# numbers above - so that if someone "fixes" this by changing a literal without
|
||||
# understanding it, the thing that goes red is the behaviour, not the transcript.
|
||||
from app.maintenance import MaintConfig # noqa: E402
|
||||
|
||||
_absent = {k: v for k, v in base().items() if k != "maintenance_charge_w"}
|
||||
_built = MaintConfig().charge_w
|
||||
check("with the key ABSENT the validator accepts...",
|
||||
validate_startup_config(_absent, -5000, 5000) == [])
|
||||
check(f"...while the code would build {_built:.0f} W against a "
|
||||
f"{_absent['max_w']:.0f} W ceiling...", _built > _absent["max_w"])
|
||||
_r = _charge(_absent["max_w"], _built)
|
||||
check("...which the arbiter resolves to 0 W as a contradiction - the hole, "
|
||||
"asserted so it cannot widen unnoticed",
|
||||
_r.target_w == 0.0 and _r.contradiction)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("max_w must be a positive magnitude")
|
||||
# Schema-blocked on a real install, but validate_startup_config() is also
|
||||
@@ -566,6 +665,94 @@ try:
|
||||
except TypeError:
|
||||
check("a Controller with no stop Event is rejected at construction", True)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("CFG-03: the key ABSENT is the SAME NUMBER everywhere, through real code")
|
||||
# ⚠️ WHAT THIS REPLACES, AND WHY IT IS THE OPPOSITE ASSERTION. MAINT-04 left a
|
||||
# TRIPWIRE here whose PASSING CONDITION WAS THE BUG: it asserted that
|
||||
# config.yaml said 2000 while main.py's two `opts.get(..., 2500)` fallbacks and
|
||||
# MaintConfig's dataclass default said 2500, and it went red if EITHER side
|
||||
# moved - including when somebody closed the divergence. That check has been
|
||||
# DELETED, as it and validate.py's ⚠️ block both instructed, and this is what
|
||||
# stands in its place: the same four consumers, exercised the same way, now
|
||||
# agreeing.
|
||||
#
|
||||
# The four are the four the defect ran through. The validator ACCEPTS a dict
|
||||
# with the key missing; main.py BUILDS a MaintConfig from that same dict; the
|
||||
# dataclass answers on its own with no dict at all; and the arbiter is what
|
||||
# turns their disagreement into a number an operator can see. Any three of them
|
||||
# agreeing was never the property that mattered.
|
||||
_absent = {k: v for k, v in base().items()
|
||||
if not k.startswith("maintenance_")}
|
||||
_ship_charge = _shipped["maintenance_charge_w"]
|
||||
_ship_drain = _shipped["maintenance_discharge_w"]
|
||||
|
||||
check("the validator still ACCEPTS a dict with every maintenance key missing - "
|
||||
"the fixture is the reachable one, not a refusal in disguise",
|
||||
validate_startup_config(_absent, -5000, 5000) == [])
|
||||
|
||||
# 1. the mirror against the shipped file. Read, not transcribed: a transcription
|
||||
# is the thing that drifted.
|
||||
check(f"the ONE in-code mirror equals what config.yaml ships "
|
||||
f"(charge {MAINT_OPTION_DEFAULTS['maintenance_charge_w']}, "
|
||||
f"shipped {_ship_charge:.0f})",
|
||||
float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"]) == _ship_charge
|
||||
and float(MAINT_OPTION_DEFAULTS["maintenance_discharge_w"]) == _ship_drain)
|
||||
|
||||
# 2. MaintConfig on its own - the one that bites when main.py is bypassed
|
||||
# entirely, which is sim/inject.py and most of this suite.
|
||||
check(f"MaintConfig() answers the shipped number, not a third one "
|
||||
f"({MaintConfig().charge_w:.0f} W)",
|
||||
MaintConfig().charge_w == _ship_charge
|
||||
and MaintConfig().discharge_w == _ship_drain)
|
||||
|
||||
# 3. main.py's own path, through a real Controller rather than through a regex
|
||||
# over its source. A source scan can only say the literal is gone; this says
|
||||
# the object an operator's install actually runs on holds the right number.
|
||||
_ctl_absent = Controller(_absent, None, _Store2(), _Pub2(), stop=asyncio.Event())
|
||||
print(f" a Controller built from a dict with NO maintenance key: "
|
||||
f"charge_w={_ctl_absent.maint.cfg.charge_w:.0f} W, "
|
||||
f"discharge_w={_ctl_absent.maint.cfg.discharge_w:.0f} W")
|
||||
check("main.py builds the shipped number from a dict that does not contain it",
|
||||
_ctl_absent.maint.cfg.charge_w == _ship_charge
|
||||
and _ctl_absent.maint.cfg.discharge_w == _ship_drain)
|
||||
# ...and it is the SAME number when the operator supplies it explicitly, which
|
||||
# is what "the default is not a special case" means.
|
||||
_ctl_named = Controller(base(), None, _Store2(), _Pub2(), stop=asyncio.Event())
|
||||
check("...and an explicit key builds the identical config, so absent and "
|
||||
"present are not two code paths",
|
||||
_ctl_named.maint.cfg == _ctl_absent.maint.cfg)
|
||||
|
||||
# 4. the arbiter - the consequence itself. Under MAINT-04 this pair was
|
||||
# (2000, 2500) and resolved to 0 W with contradiction=True; that was the
|
||||
# ACCEPTED-then-contradicted hole. The rates come out of the built config,
|
||||
# not out of a literal, so if the single source moves this moves with it.
|
||||
_r_ok = _charge(float(_absent["max_w"]), _ctl_absent.maint.cfg.charge_w)
|
||||
print(f" arbiter with that config: {_r_ok.explain()}")
|
||||
check("the arbiter CHARGES instead of contradicting itself - the hole MAINT-04 "
|
||||
"pinned open is closed at the only place it was ever reachable",
|
||||
_r_ok.target_w == -_ship_charge and not _r_ok.contradiction)
|
||||
_r_drain = _drain(float(_absent["max_w"]), _ctl_absent.maint.cfg.discharge_w)
|
||||
check("...and the drain SET is not clamped either, so the twin key the card "
|
||||
"did not name is closed too",
|
||||
_r_drain.target_w == _ship_drain and not _r_drain.contradiction)
|
||||
|
||||
# 5. nothing typed a number behind the mirror's back. Rule 6's fallback and
|
||||
# main.py's are the two sites the divergence lived at, so they are the two
|
||||
# sites a scan is worth running over.
|
||||
_srcs = {name: open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"app", name), encoding="utf-8").read()
|
||||
for name in ("main.py", "validate.py", "maintenance.py")}
|
||||
_literals = {name: re.findall(
|
||||
r'"(maintenance_[a-z_]+)"\s*,\s*(-?\d+(?:\.\d+)?)\s*[,)]', src)
|
||||
for name, src in _srcs.items()}
|
||||
check(f"no numeric fallback for any maintenance_* key survives in main.py, "
|
||||
f"validate.py or maintenance.py {_literals}",
|
||||
not any(_literals.values()))
|
||||
check("...and MaintConfig's dataclass defaults carry no numeric literal either "
|
||||
"- they are indexed out of the mirror",
|
||||
not re.search(r"^\s*(charge_w|discharge_w):\s*float\s*=\s*-?\d",
|
||||
_srcs["maintenance.py"], re.M))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -8,6 +8,13 @@ person switches it off.
|
||||
The headline validation is not here - it is `sim/replay_sign.py` in the infra
|
||||
repo, which drives this module over the two captured bus days. This file is the
|
||||
table-driven half: boundaries, timers, the latch, and the arming gate.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -204,13 +211,15 @@ TABLE = [
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, reason="charge-only"),
|
||||
MAXW], 900, False),
|
||||
# ⚠️ THE CASE `winner == \"loop\"` ALONE WOULD HAVE GOT WRONG. 1500 W, not
|
||||
# the default 2500 W: at max_w 2000 a -2500 hi against a -2000 lo is an
|
||||
# arbiter contradiction, not a charge - see main.py.
|
||||
# 2500 W: at max_w 2000 a -2500 hi against a -2000 lo is an arbiter
|
||||
# contradiction, not a charge. That pair was the SHIPPED default until
|
||||
# MAINT-04 refused it and CFG-03 gave the default one source of truth; it is
|
||||
# kept below as a case rather than as a configuration.
|
||||
("cheap-window charge REDIRECTS the loop while it still 'wins'",
|
||||
[Claim.set("loop", P_LOOP, 500),
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-1500, reason="cheap-window"),
|
||||
MAXW], 500, False),
|
||||
("...and the contradiction at the shipped defaults is not following either",
|
||||
("...and the contradiction when charge_w exceeds max_w is not following either",
|
||||
[Claim.set("loop", P_LOOP, 500),
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-2500, reason="cheap-window"),
|
||||
MAXW], 500, False),
|
||||
@@ -269,6 +278,18 @@ print("end to end: the trip is COMMAND ZERO, not alarm-only")
|
||||
import asyncio # noqa: E402
|
||||
|
||||
from app.main import Controller # noqa: E402
|
||||
# ⚠️ DEV-02. THE KEY SPELLING IS IMPORTED, NEVER TRANSCRIBED. `sign_tripped` and
|
||||
# `sign_reason` are now NAMESPACED PER INVERTER - flat, a trip caused by B
|
||||
# latched A across every restart and one operator press cleared both. A check
|
||||
# that spelled `"unit/inverter-a/sign_tripped"` by hand here would go on passing
|
||||
# against a main.py that had started writing somewhere else, which is this
|
||||
# repository's own named failure mode: the copy that drifts is always the
|
||||
# grading one. `unit_key()` is the one definition and `Inverter.key()` calls it.
|
||||
from app.inverter import UNIT_A # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
TRIPPED_KEY = unit_key(UNIT_A, "sign_tripped")
|
||||
REASON_KEY = unit_key(UNIT_A, "sign_reason")
|
||||
|
||||
|
||||
class _Hass:
|
||||
@@ -421,9 +442,24 @@ async def _restart():
|
||||
|
||||
pre, post, store, hass2 = asyncio.run(_restart())
|
||||
check("it tripped before the restart", pre.sign_wd.tripped)
|
||||
check("the trip reached the store", store.data["sign_tripped"] is True)
|
||||
check("the trip reached the store, UNDER THE TRIPPED INVERTER'S OWN KEY",
|
||||
store.data[TRIPPED_KEY] is True)
|
||||
check("...with the reason beside it, so the operator is told why",
|
||||
"SIGN_DISAGREEMENT" in store.data["sign_reason"])
|
||||
"SIGN_DISAGREEMENT" in store.data[REASON_KEY])
|
||||
check("⚠️ ...AND THE FLAT PRE-DEV-02 KEY IS MIRRORED, AS THE ROLLBACK VIEW. "
|
||||
"This assertion used to read the other way - that the flat key was NOT "
|
||||
"written - on the argument that writing it would latch every other "
|
||||
"inverter on the site. That argument was right about the READ and wrong "
|
||||
"about the WRITE: nothing at runtime reads the flat key, and the one "
|
||||
"thing that does (`_grade_store_migration()`, on every start) ignores it "
|
||||
"whenever ANY namespaced latch is set, which a sibling's trip always "
|
||||
"leaves. What the absence actually cost was the ROLLBACK: store.py's "
|
||||
"rule 2 keeps this key so a downgrade finds the latch, and a trip taken "
|
||||
"after the migration wrote nothing here - so an operator who downgraded "
|
||||
"came up DRIVING. `test_inverter.py` part 3 is where 'B's trip does not "
|
||||
"latch A across a restart' is graded, on a real roster of two",
|
||||
store.data.get("sign_tripped") is True
|
||||
and "SIGN_DISAGREEMENT" in str(store.data.get("sign_reason", "")))
|
||||
check("the new Controller comes back TRIPPED", post.sign_wd.tripped)
|
||||
check("...and says why, restored rather than re-detected",
|
||||
"SIGN_DISAGREEMENT" in post.sign_wd.reason)
|
||||
@@ -438,11 +474,14 @@ check("the banner still leads with the failsafe",
|
||||
# a one-press clear would be reachable by an operator who never read the banner.
|
||||
asyncio.run(post.handle_action("auto_toggle")) # -> stopped
|
||||
check("pressing stop does not clear the failsafe",
|
||||
post.sign_wd.tripped and store.data["sign_tripped"] is True)
|
||||
post.sign_wd.tripped and store.data[TRIPPED_KEY] is True)
|
||||
asyncio.run(post.handle_action("auto_toggle")) # -> started
|
||||
check("pressing start DOES clear it, and clears the store with it",
|
||||
not post.sign_wd.tripped and store.data["sign_tripped"] is False
|
||||
and store.data["sign_reason"] == "")
|
||||
check("pressing start DOES clear it, and clears the store with it - BOTH "
|
||||
"spellings, which is what stops the every-start latch guard re-arming a "
|
||||
"failsafe the operator has already dealt with",
|
||||
not post.sign_wd.tripped and store.data[TRIPPED_KEY] is False
|
||||
and store.data[REASON_KEY] == ""
|
||||
and store.data.get("sign_tripped") is False)
|
||||
# One more restart from the cleared store, to prove the clear persisted too.
|
||||
fresh = Controller({"setpoint_entity": "number.sp", "meter_source": "off"},
|
||||
_Hass(), store, _P(), stop=asyncio.Event())
|
||||
@@ -464,7 +503,7 @@ check("...so the next restart comes back armed and untripped, as it should",
|
||||
|
||||
class _CrashOnFlag(_MemStore):
|
||||
def set(self, k, v):
|
||||
if k == "sign_tripped" and v:
|
||||
if k == TRIPPED_KEY and v:
|
||||
raise RuntimeError("power cut, mid-trip")
|
||||
super().set(k, v)
|
||||
|
||||
@@ -494,9 +533,577 @@ async def _crash_mid_write():
|
||||
crashed_store, died = asyncio.run(_crash_mid_write())
|
||||
check("the second write is the one that sets the flag", died)
|
||||
check("a crash between the two writes leaves the REASON on disk",
|
||||
"SIGN_DISAGREEMENT" in crashed_store.data.get("sign_reason", ""))
|
||||
"SIGN_DISAGREEMENT" in crashed_store.data.get(REASON_KEY, ""))
|
||||
check("...and never a latch with nothing to explain it",
|
||||
not crashed_store.data.get("sign_tripped"))
|
||||
not crashed_store.data.get(TRIPPED_KEY))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
print()
|
||||
print("=" * 72)
|
||||
print("SAFETY-31: circulating power, which nets to zero at the meter")
|
||||
print("=" * 72)
|
||||
# ⚠️ READ THIS BEFORE ADDING A CHECK HERE. Half of this card was already built
|
||||
# and it is NOT the half below. ALLOC-01 shipped the SIGN-COHERENCE GATE
|
||||
# (`allocator.REFUSE_SIGN`, graded by test_alloc.py part 6): an allocation whose
|
||||
# shares carry two signs is REFUSED, both units named, no shares returned. So
|
||||
# the allocator can no longer COMMAND circulating power, and nothing here
|
||||
# re-tests that.
|
||||
#
|
||||
# What is graded here is the MEASUREMENT half: circulating power the allocator
|
||||
# NEVER COMMANDED. The two are complements, not duplicates - the gate refuses an
|
||||
# allocation before it is written and cannot see one millisecond past that; this
|
||||
# reads the hardware and does not care what was written.
|
||||
|
||||
from app.inverter import LINK_STALE_S # noqa: E402
|
||||
from app.circulation import ( # noqa: E402
|
||||
CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog,
|
||||
circulating_w, opposing,
|
||||
)
|
||||
|
||||
print()
|
||||
print("1. the sensor: sum(|Bi|) - |sum(Bi)| over MEASURED per-unit power")
|
||||
check("both units discharging: the triangle equality holds and it is ZERO. "
|
||||
"This is the ordinary case and the sensor must be silent in it",
|
||||
circulating_w([1000.0, 2000.0]) == 0.0)
|
||||
check("...both charging, likewise", circulating_w([-1000.0, -2000.0]) == 0.0)
|
||||
check("...and a unit resting at exactly zero beside a working one is not "
|
||||
"opposition", circulating_w([0.0, 2500.0]) == 0.0)
|
||||
# ⚠️ ASSERTED AGAINST A LITERAL, NOT AGAINST `2 * min(abs(a), abs(b))`.
|
||||
# MUTATION.md's rule 2: an assertion written in terms of the expression under
|
||||
# test cannot tell "the right formula" from "the same wrong formula twice".
|
||||
check("A charging 3000 W while B discharges 3000 W reads 6000 W - the card's "
|
||||
"own scenario, and the figure is 2*min(|A|,|B|), the power going round "
|
||||
"the loop through BOTH conversion stages",
|
||||
circulating_w([-3000.0, 3000.0]) == 6000.0)
|
||||
check("...unequal opposition is bounded by the smaller unit: -3000 against "
|
||||
"+1000 is 2000 W, not 4000",
|
||||
circulating_w([-3000.0, 1000.0]) == 2000.0)
|
||||
print(f" [-3000, +3000] -> {circulating_w([-3000.0, 3000.0]):.0f} W "
|
||||
f"[-3000, +1000] -> {circulating_w([-3000.0, 1000.0]):.0f} W "
|
||||
f"[+1000, +2000] -> {circulating_w([1000.0, 2000.0]):.0f} W")
|
||||
check("three units, one out of step: it is defined over the whole roster, not "
|
||||
"over a pair", circulating_w([-2000.0, 1000.0, 1000.0]) == 4000.0)
|
||||
|
||||
print()
|
||||
print(" AC 5: at N=1 it is IDENTICALLY zero, for every reading")
|
||||
# ⚠️ SWEPT, NOT SAMPLED. "Identically zero" is a claim about EVERY value, and
|
||||
# one call with one number cannot distinguish it from "zero for this number".
|
||||
_n1 = [circulating_w([v]) for v in
|
||||
(0.0, 1.0, -1.0, 2500.0, -2500.0, 5000.0, -5000.0, 1e9, -1e9)]
|
||||
check("nine readings from -1e9 to +1e9 W, every one of them 0.0",
|
||||
set(_n1) == {0.0})
|
||||
check("...and an EMPTY roster is zero too, not a crash - `Controller.publish()` "
|
||||
"calls this before anything has been read",
|
||||
circulating_w([]) == 0.0)
|
||||
check("⚠️ AND A MISSING READING AT N=1 IS STILL ZERO, WHICH IS THE OPPOSITE OF "
|
||||
"THE N>=2 RULE BELOW AND IS DELIBERATE. With one term the algebra has no "
|
||||
"room for an unknown: |B| - |B| is 0 for every B, INCLUDING one nobody "
|
||||
"read. Returning None here would park the shipped one-inverter install in "
|
||||
"a permanent 'cannot tell' on a quantity that cannot be anything else",
|
||||
circulating_w([None]) == 0.0)
|
||||
|
||||
print()
|
||||
print(" a missing reading at N>=2 is UNKNOWN, and never zero")
|
||||
check("⚠️ None, NOT 0.0. Zero would be this sensor reporting HEALTH FROM AN "
|
||||
"ABSENCE - which is the 2026-09-02 failure itself, where a subsystem "
|
||||
"stopped answering and its silence read as calm for 6 h 25 m",
|
||||
circulating_w([None, 3000.0]) is None
|
||||
and circulating_w([-3000.0, None]) is None
|
||||
and circulating_w([None, None]) is None)
|
||||
|
||||
print()
|
||||
print(" it is NON-NEGATIVE, including where floating point would say otherwise")
|
||||
_fp = [circulating_w([0.1, 0.2, 0.3]), circulating_w([1e-9, 2e-9]),
|
||||
circulating_w([-0.1, -0.2, -0.30000000000000004])]
|
||||
print(f" {_fp}")
|
||||
check("a same-sign roster never publishes a negative number. ⚠️ THIS GRADES "
|
||||
"THE PROPERTY, NOT THE `max(0.0, ...)` GUARD THAT DEFENDS IT, and the "
|
||||
"difference is stated so a mutation sweep does not report a gap that is "
|
||||
"not one: for a same-sign roster the two sums accumulate the same "
|
||||
"magnitudes in the same order and are bit-identical, so the guard is an "
|
||||
"EQUIVALENT MUTANT - searched over 700 000 random rosters without a "
|
||||
"single negative. circulation.py says so at the line",
|
||||
all(v >= 0.0 for v in _fp))
|
||||
|
||||
print()
|
||||
print("2. CFG-03: config.yaml is the source of truth and there is ONE copy")
|
||||
# ⚠️ READ OFF THE FILE, NOT TRANSCRIBED. The Dockerfile does not put config.yaml
|
||||
# in the image, so the running add-on cannot read it and `CIRC_OPTION_DEFAULTS`
|
||||
# has to exist - which makes it a place to drift. This is the same pin
|
||||
# test_maintenance.py puts on MAINT_OPTION_DEFAULTS, for the same reason.
|
||||
import re as _re # noqa: E402
|
||||
from pathlib import Path as _Path # noqa: E402
|
||||
|
||||
_cfg_txt = (_Path(__file__).resolve().parent / "config.yaml").read_text(encoding="utf-8")
|
||||
# ⚠️ SPLIT ON THE TOP-LEVEL KEY, NOT ON THE WORD. "schema:" appears inside the
|
||||
# CFG-03 comment above these very options ("options:, schema:, ..."), and
|
||||
# splitting on the bare word cut the file in the middle of a comment - the
|
||||
# options block then contained no options, the regex found none, and this pin
|
||||
# compared an EMPTY dict against an empty expectation. That is MUTATION.md's
|
||||
# third failure mode exactly: the assertion ran, the sentence was true, and the
|
||||
# data could not reach the failure it names. Caught by the printed line.
|
||||
_cfg_opts, _cfg_schema = _cfg_txt.split("\nschema:", 1)
|
||||
_from_yaml = {k: int(v) for k, v in
|
||||
_re.findall(r"^ (circulation_\w+):\s*(\d+)\s*$", _cfg_opts, _re.M)}
|
||||
print(f" config.yaml options: {_from_yaml}")
|
||||
print(f" CIRC_OPTION_DEFAULTS: {CIRC_OPTION_DEFAULTS}")
|
||||
check("both options are declared in config.yaml's options: block and the "
|
||||
"in-code mirror agrees with it key for key and value for value",
|
||||
_from_yaml == CIRC_OPTION_DEFAULTS and len(_from_yaml) == 2)
|
||||
_bounds = dict(_re.findall(r"^ (circulation_\w+):\s*int\((\d+),(\d+)\)\s*$",
|
||||
_cfg_schema, _re.M)
|
||||
and [(m[0], (int(m[1]), int(m[2]))) for m in
|
||||
_re.findall(r"^ (circulation_\w+):\s*int\((\d+),(\d+)\)\s*$",
|
||||
_cfg_schema, _re.M)])
|
||||
print(f" schema bounds: {_bounds}")
|
||||
check("...and BOTH have a schema range, so the Supervisor refuses an "
|
||||
"out-of-range value rather than the add-on discovering it at runtime",
|
||||
set(_bounds) == set(CIRC_OPTION_DEFAULTS))
|
||||
check("⚠️ AND THE THRESHOLD'S FLOOR IS THE MEASURED KNEE, NOT A ROUND NUMBER. "
|
||||
"On the TEST-19 rig the worst CONTINUOUS opposition a site where NOTHING "
|
||||
"is faulty can produce is 5.0 s at 400 W, 10.0 s at 300 W, 40.0 s at "
|
||||
"250 W and 505.0 s at 100 W. Below the knee, ordinary operation holds "
|
||||
"this sensor over the threshold for MINUTES and no window shorter than "
|
||||
"the 900 s billing quarter could tell that from a fault - so a "
|
||||
"configurable value under 400 W would be a false-alarm generator, and "
|
||||
"the schema does not offer one",
|
||||
_bounds["circulation_w"][0] == 400)
|
||||
check("...and the window's floor is the firmware's own veto window, "
|
||||
"`inverter.LINK_STALE_S`, because below it a unit still executing a "
|
||||
"stale share is a LINK fault the add-on already handles by dropping it "
|
||||
"from the split - and its ceiling is the 900 s quarter that bills the "
|
||||
"capacity tariff, inside which this must be caught and acted on",
|
||||
_bounds["circulation_hold_s"] == (int(LINK_STALE_S), 900))
|
||||
check("the shipped defaults sit inside their own schema ranges",
|
||||
all(_bounds[k][0] <= v <= _bounds[k][1]
|
||||
for k, v in CIRC_OPTION_DEFAULTS.items()))
|
||||
check("⚠️ AND NEITHER IS A ROUND NUMBER, WHICH IS AC 3 READ LITERALLY. 700 W "
|
||||
"is 1.75x the measured knee - the headroom is bought for inverter B's "
|
||||
"ramp and lag being PLACEHOLDERS until SPIKE-04 measures them - and 75 s "
|
||||
"is 15x the worst legitimate episode and 2.5x the firmware veto. 60, 100, "
|
||||
"120, 180, 300, 500 and 1000 are all values a guess would have landed on",
|
||||
CIRC_OPTION_DEFAULTS["circulation_w"] not in (100, 500, 1000, 2000)
|
||||
and CIRC_OPTION_DEFAULTS["circulation_hold_s"] not in (30, 60, 120, 180, 300))
|
||||
|
||||
print()
|
||||
print("3. the trip: sustained opposition fires, the slew transient does not")
|
||||
|
||||
|
||||
def circ_drive(series, cfg=None, step=5.0):
|
||||
"""Feed a list of circulating figures, one per control cycle."""
|
||||
wd = CirculationWatchdog(cfg or CirculationConfig())
|
||||
at = None
|
||||
for i, v in enumerate(series):
|
||||
if wd.evaluate(v, T0 + timedelta(seconds=i * step)) and at is None:
|
||||
at = i * step
|
||||
return wd, at
|
||||
|
||||
|
||||
_CFG = CirculationConfig()
|
||||
_sustained, _at = circ_drive([2000.0] * 40)
|
||||
check("2000 W of measured opposition, held, trips", _sustained.tripped)
|
||||
check(f"...and at {_CFG.hold_s:.0f} s, not before - the window is a window, "
|
||||
"not a delay somebody rounded", _at == _CFG.hold_s)
|
||||
check("the reason gives the operator the figure, the peak and the duration, "
|
||||
"and says in words why nothing else reported it",
|
||||
"2000" in _sustained.reason and "75 s" in _sustained.reason
|
||||
and "nets to zero" in _sustained.reason.lower())
|
||||
print(f" e.g. {_sustained.reason!r}")
|
||||
|
||||
print()
|
||||
print(" ⚠️ AND NOT ON THE TRANSIENT, WHICH IS THE FALSE-ALARM CASE")
|
||||
# ⚠️ THE SHAPES BELOW ARE THE RIG'S, NOT INVENTED. Measured on TEST-19 over 924
|
||||
# closed-loop runs in which every unit executed the share it was given: the
|
||||
# worst continuous opposition above 700 W is 5.0 s - ONE control cycle - at a
|
||||
# peak of 1700 W. That is the serialised write path: on a reversing cycle B's
|
||||
# new number lands up to a whole cycle after A's, both slew through zero at
|
||||
# 800 W/s behind a 10 s lag, and between the two crossings they genuinely
|
||||
# oppose. A guard that trips on that is a false-alarm generator, and this
|
||||
# project's doctrine on crying wolf is explicit (SAFETY-15, SAFETY-19).
|
||||
_one_cycle = [0.0] * 5 + [1700.0] + [0.0] * 30
|
||||
_t1, _ = circ_drive(_one_cycle)
|
||||
check("the worst single transient the rig can produce - 1700 W for ONE control "
|
||||
"cycle - does not trip", not _t1.tripped)
|
||||
_repeated = ([0.0] * 3 + [1700.0]) * 20
|
||||
_t2, _ = circ_drive(_repeated)
|
||||
check("...and neither does it REPEATED every fourth cycle for 400 s, which is "
|
||||
"a load flapping across zero all afternoon. The window clears between "
|
||||
"them, so nothing accumulates", not _t2.tripped)
|
||||
_almost = [1500.0] * 14 + [0.0] + [1500.0] * 14
|
||||
_t3, _ = circ_drive(_almost)
|
||||
check("...and opposition that stands for 70 s, clears for one cycle, then "
|
||||
"stands again for 70 s does NOT trip: 140 s of trouble, but never 75 s "
|
||||
"CONTINUOUSLY, which is what the guard actually claims to measure",
|
||||
not _t3.tripped)
|
||||
_below = [_CFG.min_w] * 60
|
||||
_t4, _ = circ_drive(_below)
|
||||
check(f"...and exactly {_CFG.min_w:.0f} W forever does not trip - the "
|
||||
"comparison is strictly greater, so the threshold is a threshold rather "
|
||||
"than an off-by-one", not _t4.tripped)
|
||||
_just_over, _at5 = circ_drive([_CFG.min_w + 1.0] * 40)
|
||||
check("...while one watt above it does, held long enough. The pair is what "
|
||||
"makes the boundary a measured line rather than a direction",
|
||||
_just_over.tripped and _at5 == _CFG.hold_s)
|
||||
|
||||
print()
|
||||
print(" a MISSING reading clears the window rather than accumulating it")
|
||||
_gap, _ = circ_drive([2000.0] * 10 + [None] + [2000.0] * 10)
|
||||
check("⚠️ SAFETY-02 MAKES THE SAME DECISION FOR A STALE METER, AND FOR THE SAME "
|
||||
"REASON: a frozen figure held against a moving one manufactures a fault "
|
||||
"in about a window's worth of cycles. 50 s over, one unknown, 50 s over "
|
||||
"again is not 100 s of opposition", not _gap.tripped)
|
||||
|
||||
print()
|
||||
print(" the latch: it does not re-evaluate, because the trip removes the evidence")
|
||||
_latched, _ = circ_drive([2000.0] * 40 + [0.0] * 40)
|
||||
check("still tripped after the opposition has gone - and the opposition ALWAYS "
|
||||
"goes, because the trip commands 0 W everywhere and two inverters at zero "
|
||||
"cannot oppose. A non-latching version would de-assert and start the "
|
||||
"whole thing again, three cycles at a time, forever", _latched.tripped)
|
||||
_r = CirculationWatchdog(_CFG, tripped=True, reason="restored from the store")
|
||||
check("...and one restored as tripped stays tripped without being re-driven",
|
||||
_r.evaluate(0.0, T0) and _r.reason == "restored from the store")
|
||||
_r.reset()
|
||||
check("...until it is reset, which is the operator's press and nothing else",
|
||||
not _r.tripped and _r.reason == "" and _r.held_s(T0) == 0.0)
|
||||
|
||||
print()
|
||||
print(" `opposing()` names the two sides, for the sentence the operator reads")
|
||||
check("indices of who is discharging and who is charging",
|
||||
opposing([-3000.0, 3000.0]) == ([1], [0]))
|
||||
check("...and a unit idling below the floor is not named as a participant in "
|
||||
"somebody else's loop", opposing([-3000.0, 3000.0, 3.0], min_w=350.0)
|
||||
== ([1], [0]))
|
||||
|
||||
print()
|
||||
print("4. END TO END, AND THIS IS AC 2 - the sensor is MEASURED, so it sees")
|
||||
print(" circulation the allocator NEVER COMMANDED")
|
||||
# ⚠️ THIS IS THE CHECK MUTATION.md's THIRD FAILURE MODE IS ABOUT, so it is
|
||||
# built the way that section says to build one. The claim is "X differs from Y"
|
||||
# - the MEASURED sensor differs from a sensor computed off the allocation
|
||||
# record - and X and Y are ACTUALLY MADE DIFFERENT here, with the difference
|
||||
# asserted as its own precondition and PRINTED, before anything is concluded
|
||||
# from it. DEV-02 shipped a check comparing a number against itself; ALLOC-01
|
||||
# shipped a generator that found 0 escapes where a wider one found 39 991.
|
||||
|
||||
from app.inverter import Inverter # noqa: E402
|
||||
|
||||
# ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST - it is ~10 days out and SPIKE-04
|
||||
# measures it. Its rating here is a HYPOTHESIS, named as one, exactly as
|
||||
# test_peakguard.py's helper names its own.
|
||||
B_RATING_HYPOTHESIS = 3000.0
|
||||
|
||||
|
||||
class _CircHass(_Hass):
|
||||
"""A house at rest, and two inverters MEASURED to be fighting each other.
|
||||
|
||||
⚠️ THE MEETER READS ZERO AND THAT IS THE WHOLE POINT. grid = load - sum(B),
|
||||
and with A at -3000 W and B at +3000 W the sum is zero, so P1 reads exactly
|
||||
what an idle site reads. Every existing guard in this add-on is a consumer
|
||||
of that number.
|
||||
"""
|
||||
|
||||
def __init__(self, a_w=-3000.0, b_w=3000.0):
|
||||
super().__init__()
|
||||
self.a_w, self.b_w = a_w, b_w
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
return {"sensor.grid": 0.0,
|
||||
"sensor.soc": 50.0, "sensor.soc_b": 50.0,
|
||||
"sensor.batt": self.a_w, "sensor.batt_b": self.b_w}.get(entity)
|
||||
|
||||
|
||||
def _two_inverter(hass, store, *, hold_s=1, min_w=700, **over):
|
||||
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 5000, "gain": 0.6,
|
||||
"circulation_w": min_w, "circulation_hold_s": hold_s,
|
||||
"auto_start": True}
|
||||
opts.update(over)
|
||||
c = Controller(opts, hass, store, _P(), stop=asyncio.Event())
|
||||
b = Inverter("inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
||||
max_w=B_RATING_HYPOTHESIS, store=store)
|
||||
b.dev_min, b.dev_max = -B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS
|
||||
c.inverters.append(b)
|
||||
c.auto = True
|
||||
for inv in c.inverters:
|
||||
inv.dev_min = inv.dev_min if inv.dev_min is not None else -5000.0
|
||||
inv.dev_max = inv.dev_max if inv.dev_max is not None else 5000.0
|
||||
return c
|
||||
|
||||
|
||||
async def _circ_e2e():
|
||||
hass = _CircHass()
|
||||
c = _two_inverter(hass, _MemStore())
|
||||
seen = []
|
||||
for _ in range(14):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
seen.append(dict(circ=c.site_circulating_w, grid=c.grid,
|
||||
target=c.target, alloc=c.allocation,
|
||||
tripped=c.circ_wd.tripped))
|
||||
await asyncio.sleep(0.25)
|
||||
return c, hass, seen
|
||||
|
||||
|
||||
_c, _chass, _seen = asyncio.run(_circ_e2e())
|
||||
_first = _seen[0]
|
||||
_alloc = _first["alloc"]
|
||||
_shares = dict(_alloc.shares)
|
||||
_meas = [inv.batt for inv in _c.inverters]
|
||||
print(f" MEASURED per-inverter power : {dict(zip(_c.units, _meas))}")
|
||||
print(f" ALLOCATION RECORD : ok={_alloc.ok} {_alloc.explain()}")
|
||||
print(f" meter (P1) : {_first['grid']:+.0f} W")
|
||||
print(f" sensor over MEASURED power : {circulating_w(_meas):.0f} W")
|
||||
print(f" sensor over the RECORD : "
|
||||
f"{circulating_w(list(_shares.values())):.0f} W")
|
||||
|
||||
# --- THE PRECONDITION, AS ITS OWN CHECK -------------------------------------
|
||||
# ⚠️ MUTATION.md RULE 1. If a later edit makes the record and the measurement
|
||||
# agree again, THIS goes red rather than the claim below going quietly vacuous.
|
||||
check("PRECONDITION: the allocation record and the measured power are "
|
||||
"ACTUALLY DIFFERENT in this fixture. The record is coherent - it is `ok`, "
|
||||
"its shares carry one sign, and they sum to the site target - while the "
|
||||
"hardware is measured at -3000 W and +3000 W. Without this line the two "
|
||||
"claims below could both be true of a fixture in which nothing differed",
|
||||
_alloc.ok is True
|
||||
and len({(s > 0) - (s < 0) for s in _shares.values() if s}) <= 1
|
||||
and sorted(_meas) == [-3000.0, 3000.0]
|
||||
and set(_shares.values()) != set(_meas))
|
||||
check("ALLOC-01's gate is SATISFIED here, and that is the point rather than an "
|
||||
"accident: the allocator refused nothing, because it commanded nothing "
|
||||
"wrong. `REFUSE_SIGN` cannot fire on a record whose shares agree in sign",
|
||||
_alloc.refused is None)
|
||||
check("⚠️ SO A SENSOR COMPUTED FROM THE ALLOCATION RECORD READS EXACTLY 0 W. "
|
||||
"That is the cheap version of this card, and it is worth nothing: "
|
||||
"ALLOC-01's sign-coherence gate already guarantees the record is "
|
||||
"sign-coherent, so a record-derived sensor is zero BY CONSTRUCTION and "
|
||||
"can only ever confirm that the allocator agrees with itself",
|
||||
circulating_w(list(_shares.values())) == 0.0)
|
||||
check("⚠️ AND THE SHIPPED SENSOR READS 6000 W ON THE SAME CYCLE, because it is "
|
||||
"derived from `Inverter.batt` - each unit's OWN measurement. This is the "
|
||||
"whole card: it detects circulation THE ALLOCATOR NEVER COMMANDED - a "
|
||||
"unit ignoring or lagging its share, a stale share still being executed, "
|
||||
"or HW-01's power stage running its last instruction for 6 h 25 m with "
|
||||
"the comms dead and the record impeccable throughout",
|
||||
_c.site_circulating_w == 6000.0)
|
||||
check("...and the METER READS ZERO through all of it, which is why no existing "
|
||||
"guard fires. SAFETY-02 gates on abs(grid_w) > 300 W and that gate never "
|
||||
"opens; the peak guard and the capacity tariff both bill grid exchange",
|
||||
_first["grid"] == 0.0
|
||||
and not any(i.sign_wd.tripped for i in _c.inverters))
|
||||
|
||||
print()
|
||||
print(" ...and the trip ACTS: a site-wide stop, not a notification")
|
||||
check("the watchdog tripped", _c.circ_wd.tripped)
|
||||
check("the site is commanded 0 W", _c.target == 0.0)
|
||||
check("...and 0 W actually reached BOTH inverters, not just the one somebody "
|
||||
"decided was to blame",
|
||||
_chass.writes and _chass.writes[-1] == 0.0
|
||||
and all(i.target == 0.0 for i in _c.inverters))
|
||||
check("...and it got there through the `circulation` claim, by name, rather "
|
||||
"than by some other guard happening to stop the site",
|
||||
"circulation" in _c.reason)
|
||||
print(f" status line: {_c.reason!r}")
|
||||
check("the status page says so in words, and says the meter cannot see it",
|
||||
any("CIRCULATING POWER" in ck["text"] and "meter cannot see" in ck["text"]
|
||||
for ck in _c.checks()))
|
||||
check("the banner leads with it - above the sign failsafe, because a sign "
|
||||
"disagreement is visible on every dashboard in the house and this is "
|
||||
"visible nowhere at all",
|
||||
"CIRCULATING POWER" in _c.status_payload()["banner"])
|
||||
_pub = {}
|
||||
_c.mqtt = type("P", (), {"enabled": True,
|
||||
"publish": lambda _s, v: _pub.update(v),
|
||||
"close": lambda _s: None})()
|
||||
_c.publish()
|
||||
print(f" published: circulation={_pub.get('circulation')} "
|
||||
f"grid={_pub.get('grid')} status={_pub.get('status')}")
|
||||
check("⚠️ AND IT IS PUBLISHED AS ITS OWN SENSOR BESIDE THE GRID FIGURE. "
|
||||
"`grid 0 W` and `circulating 6000 W` on the same dashboard is the whole "
|
||||
"phenomenon in two rows, and NO arithmetic on the grid figure alone can "
|
||||
"ever produce the second one",
|
||||
_pub.get("circulation") == 6000 and _pub.get("grid") == 0.0)
|
||||
check("...and the controller-status sensor reports the failsafe state",
|
||||
_pub.get("status") == "failsafe")
|
||||
# ⚠️ AND THE UNKNOWN CASE, WHICH A SWEEP FOUND UNGRADED. A per-unit reading that
|
||||
# did not arrive makes the site figure None, and `MqttPublisher.publish()` DROPS
|
||||
# None values - so the entity keeps its last figure and the dashboard shows a
|
||||
# stale number rather than a fresh zero. Publishing `round(x or 0.0)` instead
|
||||
# survived the first mutation battery: it turns "I do not know" into "0 W", i.e.
|
||||
# health manufactured from an absence, on the one surface an operator actually
|
||||
# watches. Same defect as the sensor's own None rule, one layer out.
|
||||
_c.inverters[1].batt = None
|
||||
_pub_unknown = {}
|
||||
_c.mqtt = type("P", (), {"enabled": True,
|
||||
"publish": lambda _s, v: _pub_unknown.update(
|
||||
{k: x for k, x in v.items() if x is not None}),
|
||||
"close": lambda _s: None})()
|
||||
_c.publish()
|
||||
print(f" with inverter-b's reading missing, publish() offers: "
|
||||
f"circulation={_pub_unknown.get('circulation', '<omitted>')!r}")
|
||||
check("⚠️ WITH ONE UNIT'S READING MISSING THE SENSOR IS NOT PUBLISHED AT ALL, "
|
||||
"so the entity holds its last figure instead of being handed a 0 that "
|
||||
"would read as health. `publish()` drops None, and this is what makes "
|
||||
"that drop deliberate rather than incidental",
|
||||
"circulation" not in _pub_unknown)
|
||||
_c.inverters[1].batt = 3000.0
|
||||
|
||||
print()
|
||||
print("5. AC 5: at ONE inverter the sensor is identically zero and NOTHING changes")
|
||||
|
||||
|
||||
async def _n1():
|
||||
hass = _CircHass(a_w=-3000.0, b_w=0.0)
|
||||
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 5000, "gain": 0.6,
|
||||
"circulation_w": 700, "circulation_hold_s": 1, "auto_start": True}
|
||||
c = Controller(opts, hass, _MemStore(), _P(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -5000.0, 5000.0
|
||||
for _ in range(14):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await asyncio.sleep(0.2)
|
||||
return c
|
||||
|
||||
|
||||
_c1 = asyncio.run(_n1())
|
||||
print(f" roster={_c1.units} measured batt={_c1.inverters[0].batt:+.0f} W "
|
||||
f"sensor={_c1.site_circulating_w}")
|
||||
check("the shipped roster of one, with the single inverter measured at "
|
||||
"-3000 W - a magnitude that would trip instantly at N=2 - reads exactly "
|
||||
"0.0 W", _c1.site_circulating_w == 0.0)
|
||||
check("...so the watchdog never trips, over 14 cycles at a 1 s window",
|
||||
not _c1.circ_wd.tripped)
|
||||
check("...no `circulation` claim is ever filed, so the arbitration is "
|
||||
"byte-identical to what shipped", "circulation" not in _c1.reason)
|
||||
check("...and the status page says n/a rather than claiming to watch something "
|
||||
"that cannot happen - an 'armed' row for an identically-zero quantity is "
|
||||
"a guard advertising work it is not doing",
|
||||
any("circulating power: n/a at one inverter" in ck["text"]
|
||||
for ck in _c1.checks()))
|
||||
_pub1 = {}
|
||||
_c1.mqtt = type("P", (), {"enabled": True,
|
||||
"publish": lambda _s, v: _pub1.update(v),
|
||||
"close": lambda _s: None})()
|
||||
_c1.publish()
|
||||
check("...and it publishes 0, not None: the value is KNOWN at N=1, and a "
|
||||
"dashboard row reading 0 W is the honest report of it",
|
||||
_pub1.get("circulation") == 0)
|
||||
|
||||
print()
|
||||
print("6. SAFETY-34: the alarm survives a Home Assistant restart")
|
||||
# ⚠️ THE LATCH IS NOT THE ALARM, AND THE 2026-09-02 OUTAGE IS WHY THAT SENTENCE
|
||||
# IS HERE. `persistent_notification` is IN MEMORY: Home Assistant restarts and
|
||||
# the card is gone while the fault runs on. That outage went unseen for 6 h 25 m
|
||||
# with the detector correct throughout. So the durable half (the store) and the
|
||||
# visible half (the notification) have to be joined, and this grades the join.
|
||||
|
||||
|
||||
async def _circ_restart():
|
||||
shared = _MemStore()
|
||||
h1 = _CircHass()
|
||||
a = _two_inverter(h1, shared)
|
||||
for _ in range(14):
|
||||
await a.read_inputs()
|
||||
await a.cycle()
|
||||
await asyncio.sleep(0.25)
|
||||
# THE RESTART. Same store, brand new Controller and Hass - which is exactly
|
||||
# what a Home Assistant restart does to this add-on. The house has stopped
|
||||
# circulating in the meantime, so nothing in the new process's own
|
||||
# measurements could re-derive the trip.
|
||||
h2 = _CircHass(a_w=0.0, b_w=0.0)
|
||||
b = _two_inverter(h2, shared)
|
||||
for _ in range(3):
|
||||
await b.read_inputs()
|
||||
await b.cycle()
|
||||
await asyncio.sleep(0.05)
|
||||
return a, b, shared, h1, h2
|
||||
|
||||
|
||||
_a, _b, _shared, _h1, _h2 = asyncio.run(_circ_restart())
|
||||
print(f" store after the trip: circ_tripped="
|
||||
f"{_shared.data.get('circ_tripped')!r} "
|
||||
f"circ_reason={str(_shared.data.get('circ_reason'))[:48]!r}...")
|
||||
check("the trip is written to /data - the add-on's only durable volume - as a "
|
||||
"flag AND a reason",
|
||||
_shared.data.get("circ_tripped") is True
|
||||
and "CIRCULATING_POWER" in str(_shared.data.get("circ_reason")))
|
||||
check("⚠️ THE REBUILT CONTROLLER COMES UP TRIPPED, and its own measurements say "
|
||||
"the site is quiet - 0 W on both units - so this can ONLY have come from "
|
||||
"the store. A latch that lives in memory comes back armed, into the "
|
||||
"fault, with no operator involved",
|
||||
_b.circ_wd.tripped and _b.site_circulating_w == 0.0)
|
||||
check("...carrying the reason with it, so the operator who has to decide "
|
||||
"whether to clear it can read WHY",
|
||||
"CIRCULATING_POWER" in _b.circ_wd.reason)
|
||||
check("...and it is still commanding 0 W on the far side of the restart",
|
||||
_b.target == 0.0 and "circulation" in _b.reason)
|
||||
|
||||
_notes1 = [c for c in _h1.calls if c[0] == "persistent_notification"]
|
||||
_notes2 = [c for c in _h2.calls if c[0] == "persistent_notification"]
|
||||
print(f" notifications raised: {len(_notes1)} before the restart, "
|
||||
f"{len(_notes2)} after it")
|
||||
check("the alarm was raised in the first process", len(_notes1) >= 1)
|
||||
check("⚠️ AND RE-RAISED IN THE SECOND ONE. THIS IS THE CARD'S THIRD "
|
||||
"DELIVERABLE. `persistent_notification` does not survive a Home Assistant "
|
||||
"restart, so a latch alone would leave a stopped site with NOTHING in "
|
||||
"front of the operator - the detector correct, the state file correct, "
|
||||
"and the screen blank. That is how 2026-09-02 went unseen for 6 h 25 m",
|
||||
len(_notes2) >= 1)
|
||||
check("...under a fixed notification_id of its own, so a second episode "
|
||||
"REPLACES the first rather than stacking, and it is not confused with "
|
||||
"the frozen meter, the clamp or the saturated peak guard",
|
||||
all(c[2]["notification_id"] == "goodwe_circulating_power"
|
||||
for c in _notes1 + _notes2)
|
||||
and len({c[2]["notification_id"] for c in _notes1 + _notes2}) == 1)
|
||||
_msg = _notes2[0][2]["message"]
|
||||
check("...and the message tells the operator the three things only this alarm "
|
||||
"can: that the meter cannot see it, that control has been stopped, and "
|
||||
"that if the grid reading does not move afterwards one inverter is no "
|
||||
"longer listening and a human at the isolator is the last actuator",
|
||||
"METER CANNOT SEE" in _msg and "CONTROL HAS BEEN STOPPED" in _msg
|
||||
and "isolator" in _msg)
|
||||
check("...and it does not raise the same card on every cycle - a per-PROCESS "
|
||||
"flag, deliberately not persisted, is what makes 'has a human been shown "
|
||||
"this' a different question from 'is this site tripped'",
|
||||
len(_notes2) == 1)
|
||||
|
||||
print()
|
||||
print(" the operator's press is the only way out, and it clears BOTH halves")
|
||||
|
||||
|
||||
async def _circ_clear():
|
||||
shared = _MemStore()
|
||||
c = _two_inverter(_CircHass(), shared)
|
||||
for _ in range(14):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await asyncio.sleep(0.25)
|
||||
was = c.circ_wd.tripped
|
||||
await c.handle_action("auto_toggle") # stop
|
||||
await c.handle_action("auto_toggle") # start - the deliberate press
|
||||
return c, shared, was
|
||||
|
||||
|
||||
_cc, _cstore, _was = asyncio.run(_circ_clear())
|
||||
check("it really was tripped before the press", _was)
|
||||
check("a deliberate START clears the latch in memory and on disk together - "
|
||||
"the condition ALWAYS goes away on its own, because the trip commands "
|
||||
"0 W everywhere and two inverters at zero cannot oppose, so clearing on "
|
||||
"the condition would clear on nothing",
|
||||
not _cc.circ_wd.tripped
|
||||
and _cstore.data.get("circ_tripped") is False
|
||||
and _cstore.data.get("circ_reason") == "")
|
||||
check("⚠️ AND THE 'HAS A HUMAN SEEN THIS' FLAG IS CLEARED WITH IT. Left "
|
||||
"standing through a clear, the NEXT trip in this process would latch, "
|
||||
"stop the site, and raise no notification at all",
|
||||
_cc._circ_announced is False)
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ALLOC-01's acceptance evidence, on admin/goodwe's OWN two-master rig.
|
||||
|
||||
python3 tools/alloc_sim.py --table # broadcast vs allocator, side by side
|
||||
python3 tools/alloc_sim.py --splits # AC 2: split-invariance at the meter
|
||||
python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split
|
||||
python3 tools/alloc_sim.py --all
|
||||
|
||||
# DEV-02: the split-invariance finding ALLOC-01's review handed on, and the
|
||||
# sensing change that closed it. The two rows are the evidence.
|
||||
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback unit # 250 W apart
|
||||
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback site # identical
|
||||
|
||||
WHY THIS IS A SCRIPT AND NOT A `test_*.py`
|
||||
==========================================
|
||||
It needs admin/goodwe checked out beside this repository, and the add-on's CI
|
||||
job has no credentials for that repository - it is private and the job's token
|
||||
is minted for this one. A `test_*.py` here is globbed and RUN by
|
||||
.gitea/workflows/checks.yml, so a file that needs the sibling repo would either
|
||||
fail the gate or - worse, and this is what test_freeze.py does today - pass
|
||||
SILENTLY having checked nothing. So the rig evidence lives here, is quoted in
|
||||
the pull request, and test_alloc.py grades the same properties in CI against a
|
||||
plant it models itself and says it models.
|
||||
|
||||
WHAT IS REAL IN HERE
|
||||
====================
|
||||
Everything except the allocator's inputs. `sitesim.run_site()` is used
|
||||
UNMODIFIED: the add-on's own `compute()` closes the loop, the firmware's own
|
||||
encoder puts each command on each unit's wire, `plantsim.Plant` is the battery
|
||||
model, and `watchdog.SignWatchdog` is the shipped detector. The ONLY thing this
|
||||
file changes is `sitesim.allocate`, which it wraps so that a new mode name
|
||||
routes to `app.arbiter.allocate()` - the shipped allocator, imported, never
|
||||
transcribed - and delegates every other mode to the original. That wrapper is
|
||||
the whole diff between the RED run and the GREEN run, which is what makes the
|
||||
comparison worth anything.
|
||||
|
||||
⚠️ NOTHING IN admin/goodwe IS MODIFIED. `sim/` triggers a workflow that takes
|
||||
the physical rig's lock; this file reads that repository and writes nothing to
|
||||
it.
|
||||
|
||||
⚠️ INVERTER B'S PARAMETERS ARE PLACEHOLDERS. `plantsim.INVERTER_B` carries
|
||||
`measured=False` - it is A's numbers copied, because inverter B does not exist
|
||||
yet (SPIKE-04 measures it). Every verdict below that involves B is a verdict
|
||||
about the ARCHITECTURE, not about that inverter, and the runs say so.
|
||||
"""
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ADDON = HERE.parent.parent # the goodwe-addon checkout root
|
||||
|
||||
|
||||
def find_sim():
|
||||
"""admin/goodwe's `sim/` directory, or None - and the caller says which.
|
||||
|
||||
$GOODWE_SIM first, the same override test_freeze.py takes, then the sibling
|
||||
checkouts that actually occur. A missing sibling is not a failure of the
|
||||
allocator and must never be reported as one.
|
||||
"""
|
||||
cands = []
|
||||
if os.environ.get("GOODWE_SIM"):
|
||||
cands.append(pathlib.Path(os.environ["GOODWE_SIM"]))
|
||||
for up in (ADDON.parent, ADDON.parent.parent):
|
||||
cands += [up / "goodwe" / "sim", up / "goodwe-session" / "goodwe" / "sim"]
|
||||
for c in cands:
|
||||
if (c / "sitesim.py").exists():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def load(sim):
|
||||
"""Import the rig, pointed at THIS checkout, and graft our allocator in."""
|
||||
sys.path[:0] = [str(sim)]
|
||||
# inject.py resolves the add-on repo, and refuses to guess between two
|
||||
# checkouts. Naming it removes the guess: the tree under test is the tree
|
||||
# this file ships in, whatever else is lying around the disk.
|
||||
os.environ.setdefault("GOODWE_ADDON_REPO", str(ADDON))
|
||||
import inject, sitesim # noqa: E402
|
||||
arbiter = sitesim.load_addon("app.arbiter", "alloc_sim")
|
||||
allocator = sitesim.load_addon("app.allocator", "alloc_sim")
|
||||
|
||||
original = sitesim.allocate
|
||||
|
||||
def patched(target_w, n, mode):
|
||||
if not mode.startswith("alloc01"):
|
||||
return original(target_w, n, mode)
|
||||
# "alloc01" or "alloc01:90/10" - the weights, which AC 2 says must not
|
||||
# change the aggregate trace at the meter.
|
||||
names = [f"inverter-{c}" for c in "ab"[:n]]
|
||||
parts = mode.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
ws = [float(x) for x in parts[1].split("/")]
|
||||
else:
|
||||
ws = [1.0] * n
|
||||
bounds = {u: arbiter.UnitBounds(lo=-r, hi=r)
|
||||
for u, r in zip(names, patched.ratings)}
|
||||
shares, ok = arbiter.allocate(
|
||||
target_w, bounds, weights=dict(zip(names, ws)))
|
||||
if not ok:
|
||||
# A refusal on this rig would be a bug in the experiment, not a
|
||||
# finding: nothing here is stale, mis-routed or pinned. Fail loud.
|
||||
raise SystemExit(f"alloc_sim: the allocator REFUSED at {target_w} W "
|
||||
"- no scenario below should reach a refusal")
|
||||
return [float(shares[u]) for u in names]
|
||||
|
||||
# The envelope handed to the allocator each tick: the rating each MODELLED
|
||||
# inverter actually carries, read off plantsim rather than typed here, so a
|
||||
# change to INVERTER_A - or a --b-rating override - moves the envelope with
|
||||
# it instead of leaving this file quietly wrong. ⚠️ INVERTER_B's IS A
|
||||
# PLACEHOLDER; nothing here has measured it.
|
||||
from plantsim import INVERTER_A, INVERTER_B
|
||||
patched.ratings = [INVERTER_A["rating_w"], INVERTER_B["rating_w"]]
|
||||
sitesim.allocate = patched
|
||||
return inject, sitesim, arbiter, allocator
|
||||
|
||||
|
||||
def run(sitesim, control, watchdog, mode, gain, **kw):
|
||||
return sitesim.run_site(control, watchdog, n=2, gain=gain, mode=mode,
|
||||
label=f"N=2 gain {gain} {mode}", **kw)
|
||||
|
||||
|
||||
def run_site_feedback(sitesim, control, mode, gain, load_w=2000.0, cycles=40,
|
||||
cycle_s=None, dt=0.05):
|
||||
"""`sitesim.run_site()` WITH EXACTLY ONE LINE CHANGED, and it is named here.
|
||||
|
||||
⚠️ THIS IS A COPY AND IT IS A COPY ON PURPOSE. `run_site` lives in
|
||||
admin/goodwe, which this file reads and never writes, and it feeds
|
||||
`compute()` ONE inverter's power as `actual_w` because that is what the
|
||||
add-on gave it: a single `batt_entity`, so an operator who wired up a second
|
||||
inverter had a loop that saw unit A only. A rig that fed the sum would have
|
||||
been KINDER than the deployment.
|
||||
|
||||
So this differs from `run_site` in one expression - `site.battery`, the
|
||||
site's total inverter power, instead of `inverters[0].plant.actual` - and in
|
||||
nothing else.
|
||||
|
||||
⚠️ AND SINCE DEV-02 THAT ONE EXPRESSION IS THE SHIPPED ONE, WHICH TURNS THIS
|
||||
FUNCTION FROM A COUNTERFACTUAL INTO THE MODEL. `Controller.site_batt_w` is
|
||||
the SUM of the per-instance battery readings and is what main.py hands
|
||||
`compute()`; `--feedback site` is therefore the add-on as it now stands, and
|
||||
`--feedback unit` is the PRE-DEV-02 sensing, kept because the comparison is
|
||||
the evidence. Measured here, 2 kW load, 40 cycles: fed one unit's power the
|
||||
aggregate trace at the meter is split-dependent by 510 W at gain 0.2, 250 W
|
||||
at 0.4 and 50 W at 0.6; fed the site total every split is bit-identical at
|
||||
every gain. `run_site` still models the old sensing and must not be
|
||||
"corrected" to the new one - the row it produces is what the fix is measured
|
||||
against.
|
||||
"""
|
||||
from plantsim import INVERTER_A, INVERTER_B, Inverter, Site
|
||||
cycle_s = sitesim.CYCLE_S if cycle_s is None else cycle_s
|
||||
specs = [("A", INVERTER_A), ("B", INVERTER_B)]
|
||||
inverters = [Inverter.from_spec(n, dict(sp, **sitesim.INSTANT))
|
||||
for n, sp in specs]
|
||||
site = Site(inverters, load=load_w)
|
||||
loop = sitesim.Loop(control, gain)
|
||||
samples = []
|
||||
for c in range(cycles):
|
||||
grid = site.grid
|
||||
d = loop.cycle(grid, site.battery) # <- THE ONE CHANGED LINE
|
||||
for inv, share in zip(inverters,
|
||||
sitesim.allocate(d.target_w, len(inverters), mode)):
|
||||
inv.command(share)
|
||||
site.settle(cycle_s, dt=dt)
|
||||
samples.append(dict(cycle=c, grid_before=grid, target=d.target_w,
|
||||
grid=site.grid, batt=site.battery, reason=d.reason,
|
||||
sat=d.sat_count, frozen=d.frozen, clamped=d.clamped,
|
||||
i_clamped=d.i_clamped,
|
||||
shares=[inv.plant.cmd for inv in inverters]))
|
||||
return sitesim.Run(f"site-feedback gain {gain} {mode}", samples, inverters,
|
||||
mode, gain, None)
|
||||
|
||||
|
||||
SPLITS = ["alloc01:50/50", "alloc01:90/10", "alloc01:10/90",
|
||||
"alloc01:99/1", "alloc01:1/0"]
|
||||
|
||||
|
||||
def table(sitesim, control, watchdog, cycles, load_w):
|
||||
print("=== AC 3 / the ARCH-01 experiment: broadcast vs allocation ===")
|
||||
print(f"{'run':28} {'tail p2p':>10} {'worst export':>13} commands")
|
||||
rows = []
|
||||
for gain in (0.6, 1.0):
|
||||
for mode in ["broadcast", "even_split"] + SPLITS:
|
||||
r = run(sitesim, control, watchdog, mode, gain,
|
||||
load_w=load_w, cycles=cycles)
|
||||
rows.append((gain, mode, r))
|
||||
print(f"gain {gain} {mode:20} {r.p2p():10.0f} "
|
||||
f"{r.worst_export(1):13.0f} "
|
||||
f"{[int(x) for x in r.targets(4)]}")
|
||||
return rows
|
||||
|
||||
|
||||
def splits(sitesim, control, watchdog, cycles, load_w, gain=1.0,
|
||||
feedback="unit"):
|
||||
"""AC 2. A 90/10 and a 50/50 split, and the AGGREGATE trace at the meter."""
|
||||
print(f"=== AC 2 / split-invariance at gain {gain}, loop feedback "
|
||||
f"= {feedback} ===")
|
||||
base = None
|
||||
ok = True
|
||||
for mode in SPLITS:
|
||||
r = (run(sitesim, control, watchdog, mode, gain,
|
||||
load_w=load_w, cycles=cycles) if feedback == "unit"
|
||||
else run_site_feedback(sitesim, control, mode, gain,
|
||||
load_w=load_w, cycles=cycles))
|
||||
trace = [round(g, 6) for g in r.grids()]
|
||||
cmds = [round(t, 6) for t in r.targets()]
|
||||
if base is None:
|
||||
base, base_mode = trace, mode
|
||||
print(f" {mode:16} reference: grid {[int(g) for g in trace[:6]]} ...")
|
||||
else:
|
||||
same = trace == base
|
||||
ok &= same
|
||||
print(f" {mode:16} {'IDENTICAL' if same else 'DIFFERS'} to "
|
||||
f"{base_mode} worst |delta| "
|
||||
f"{max(abs(a - b) for a, b in zip(trace, base)):.6f} W")
|
||||
# And the shares are NOT the same - otherwise the invariance is vacuous.
|
||||
s = r.samples[-1]["shares"]
|
||||
print(f" last shares {[int(x) for x in s]}, "
|
||||
f"sum {sum(s):.0f} vs site command {cmds[-1]:.0f}")
|
||||
print(f" -> split-invariance {'HOLDS' if ok else 'FAILS'}")
|
||||
return ok
|
||||
|
||||
|
||||
def sweep(sitesim, control, watchdog, cycles, load_w):
|
||||
"""AC 3. Gain swept to config.yaml's documented maximum, at every split."""
|
||||
print("=== AC 3 / gain sweep to 1.0 at every split ===")
|
||||
gains = [0.05, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0]
|
||||
print(f"{'mode':20}" + "".join(f"{g:>9}" for g in gains))
|
||||
worst = {}
|
||||
for mode in ["broadcast"] + SPLITS:
|
||||
line = f"{mode:20}"
|
||||
for g in gains:
|
||||
r = run(sitesim, control, watchdog, mode, g,
|
||||
load_w=load_w, cycles=cycles)
|
||||
p = r.p2p()
|
||||
worst[mode] = max(worst.get(mode, 0.0), p)
|
||||
line += f"{p:9.0f}"
|
||||
print(line + f" worst {worst[mode]:.0f} W p2p")
|
||||
print(" (tail peak-to-peak grid swing, W. A limit cycle is a tail that "
|
||||
"does not go to zero.)")
|
||||
return worst
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--table", action="store_true")
|
||||
ap.add_argument("--splits", action="store_true")
|
||||
ap.add_argument("--sweep", action="store_true")
|
||||
ap.add_argument("--all", action="store_true")
|
||||
ap.add_argument("--cycles", type=int, default=40)
|
||||
ap.add_argument("--load", type=float, default=2000.0)
|
||||
ap.add_argument("--gain", type=float, default=1.0)
|
||||
ap.add_argument("--feedback", choices=("unit", "site"),
|
||||
default="site",
|
||||
help="what the loop is fed as actual_w: the site total "
|
||||
"(the shipped add-on since DEV-02, the default here) "
|
||||
"or one inverter's power (the PRE-DEV-02 sensing, "
|
||||
"kept because the comparison is the evidence)")
|
||||
a = ap.parse_args()
|
||||
if not (a.table or a.splits or a.sweep):
|
||||
a.all = True
|
||||
|
||||
sim = find_sim()
|
||||
if sim is None:
|
||||
print("alloc_sim: admin/goodwe is not checked out beside this "
|
||||
"repository, so the two-master rig is unavailable. NOTHING has "
|
||||
"been measured. Set $GOODWE_SIM to its sim/ directory.")
|
||||
return 2
|
||||
print(f"rig: {sim}")
|
||||
inject, sitesim, arbiter, allocator = load(sim)
|
||||
control = sitesim.load_addon("app.control", "alloc_sim")
|
||||
watchdog = sitesim.load_addon("app.watchdog", "alloc_sim")
|
||||
print(f"add-on under test: {os.environ['GOODWE_ADDON_REPO']}")
|
||||
print("⚠️ inverter B's rating/ramp/lag are PLACEHOLDERS (plantsim."
|
||||
"INVERTER_B, measured=False) - SPIKE-04 measures them\n")
|
||||
|
||||
if a.table or a.all:
|
||||
table(sitesim, control, watchdog, a.cycles, a.load)
|
||||
print()
|
||||
if a.splits or a.all:
|
||||
splits(sitesim, control, watchdog, a.cycles, a.load, a.gain,
|
||||
a.feedback)
|
||||
print()
|
||||
if a.sweep or a.all:
|
||||
sweep(sitesim, control, watchdog, a.cycles, a.load)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/bin/sh
|
||||
# The house mutation harness (TEST-15). `tools/mutation_run.sh [TREE] [test_*.py ...]`
|
||||
#
|
||||
# WHY THIS EXISTS. Nearly every safety claim in this repository was verified by
|
||||
# mutation: change the shipped module, run the suite, watch it go red, revert,
|
||||
# watch it go green. FCST-03's developer found that the middle two steps can be
|
||||
# a lie. CPython decides a `.pyc` is still valid from (source size, source mtime
|
||||
# TRUNCATED TO WHOLE SECONDS). Revert `self.provisional` to `here.provisional`
|
||||
# and neither term moves - same length, same second - so the interpreter loads
|
||||
# the bytecode it already has. Confirmed by disassembly: the source read `here`,
|
||||
# the running code loaded `self`. The mutation "survived" because it never ran.
|
||||
#
|
||||
# ⚠️ `python3 -B` IS NOT THE FIX, AND NEITHER IS PYTHONDONTWRITEBYTECODE=1.
|
||||
# Both stop bytecode being WRITTEN. Neither stops a stale `.pyc` that is already
|
||||
# on disk from being READ - measured, and graded by test_bytecode.py part 2.
|
||||
# The load-bearing half is the PURGE. `-B` is here so the run leaves no cache
|
||||
# behind for the NEXT mutation to inherit.
|
||||
#
|
||||
# ⚠️ NOT A SLEEP. One-second mtime resolution is the mechanism, not the enemy;
|
||||
# the answer is to stop trusting mtime, not to make every edit wait a second.
|
||||
# A sleep also only covers the edit you remembered to slow down.
|
||||
#
|
||||
# It prints a fingerprint of the tree it is about to grade, because the other
|
||||
# half of a mutation report is WHICH SOURCE produced it: a report that cannot
|
||||
# say what was on disk is the same unverifiable claim in a different dress.
|
||||
# Diff two fingerprints and the mutated file is the line that changed.
|
||||
set -eu
|
||||
|
||||
TREE=""
|
||||
case "${1-}" in
|
||||
"" ) : ;;
|
||||
-h|--help ) sed -n '2,30p' "$0"; exit 0 ;;
|
||||
* ) if [ -d "$1" ]; then TREE=$1; shift; fi ;;
|
||||
esac
|
||||
if [ -z "$TREE" ]; then
|
||||
# Default: the add-on directory this script ships in, so `tools/mutation_run.sh`
|
||||
# works from anywhere. An explicit TREE is what lets the infra repo's replay
|
||||
# harnesses purge $GOODWE_ADDON_REPO before they grade it - see MUTATION.md.
|
||||
TREE=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
fi
|
||||
PY=${PY:-python3}
|
||||
|
||||
cd "$TREE"
|
||||
|
||||
# CI-01's rule, for the same reason: a loop over an empty glob exits 0, and a
|
||||
# green run that examined nothing is worse than a red one.
|
||||
if [ $# -eq 0 ]; then set -- test_*.py; fi
|
||||
if [ ! -f "$1" ]; then
|
||||
echo "mutation_run: no test_*.py under $TREE - NOTHING has been graded." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== purge"
|
||||
find . -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name '*.pyc' -type f -exec rm -f {} + 2>/dev/null || true
|
||||
left=$(find . -name '__pycache__' -type d -o -name '*.pyc' -type f | head -5)
|
||||
if [ -n "$left" ]; then
|
||||
# A read-only tree cannot hold a cache written during this run either, so this
|
||||
# is only reachable when the purge itself failed on a tree that HAS one - the
|
||||
# one state where continuing would grade unknown bytecode.
|
||||
echo "mutation_run: could not purge, and a cache remains:" >&2
|
||||
echo "$left" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " no bytecode cache under $TREE"
|
||||
|
||||
echo "== tree graded (sha256, bytes)"
|
||||
find app -name '*.py' 2>/dev/null | sort | while read -r f; do
|
||||
printf ' %s %8d %s\n' "$(sha256sum "$f" | cut -c1-16)" "$(wc -c < "$f")" "$f"
|
||||
done
|
||||
|
||||
echo "== run ($PY -B)"
|
||||
rc=0
|
||||
for f in "$@"; do
|
||||
start=$(date +%s)
|
||||
if "$PY" -B "$f" > /tmp/mutation_run.out 2>&1; then
|
||||
echo " PASS $f ($(( $(date +%s) - start ))s)"
|
||||
else
|
||||
echo " FAIL $f ($(( $(date +%s) - start ))s)"
|
||||
tail -40 /tmp/mutation_run.out
|
||||
rc=1
|
||||
fi
|
||||
done
|
||||
|
||||
# -B was asked for; check it was honoured rather than assuming. A cache here
|
||||
# would be inherited by the next mutation in the batch, which is exactly the
|
||||
# state this script exists to prevent.
|
||||
after=$(find . -name '__pycache__' -type d -o -name '*.pyc' -type f | head -5)
|
||||
if [ -n "$after" ]; then
|
||||
echo "mutation_run: a bytecode cache appeared DURING the run - the next" >&2
|
||||
echo "mutation in this batch would inherit it:" >&2
|
||||
echo "$after" >&2
|
||||
rc=1
|
||||
fi
|
||||
exit $rc
|
||||
@@ -0,0 +1,210 @@
|
||||
"""CFG-04: does Supervisor accept a list-of-objects option? MEASURED, not assumed.
|
||||
|
||||
⚠️ WHY THIS SCRIPT EXISTS AND WHY IT IS COMMITTED. config.yaml had no
|
||||
list-of-objects option anywhere before CFG-04, and `static_profile` carries a
|
||||
structured value as a JSON STRING in a `str?` precisely because nobody knew
|
||||
whether the Supervisor's options validator supported a nested one. That is a
|
||||
question about a specific version of a specific piece of somebody else's
|
||||
software, and the honest way to answer it is to run that software.
|
||||
|
||||
WHAT IT RUNS. Supervisor's OWN `AppOptions` validator and its OWN
|
||||
`SCHEMA_APP_CONFIG`, out of the release image, against THIS repository's
|
||||
config.yaml. Nothing here is a re-implementation of the validation rules; if
|
||||
Supervisor changes them, this script changes its answer.
|
||||
|
||||
HOW TO RUN IT (the version is the one `ha info` reports on the target install -
|
||||
2026.08.0 is what 192.168.2.158 ran when CFG-04 landed):
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" -v /tmp/out:/out \
|
||||
--entrypoint python3 \
|
||||
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
||||
/probe/supervisor_shape_probe.py
|
||||
|
||||
⚠️ IT TOUCHES NOTHING. It reads config.yaml and runs a validator in a throwaway
|
||||
container. It does not talk to the live Supervisor, does not POST options and
|
||||
does not install anything - the schema conflict in docs/deploy-rollback.md §4 is
|
||||
exactly what you do NOT want to discover on a running site.
|
||||
|
||||
WHAT IT ANSWERED, 2026-09-04, on 2026.08.0 - the full output is in
|
||||
DECISIONS/CFG-04-inverter-roster.md:
|
||||
|
||||
A. The manifest, list-of-objects schema and all, is ACCEPTED.
|
||||
B. The shipped `options:` block validates as it stands, and the SAME block
|
||||
with ONLY `inverters` removed is refused BY NAME - `Missing option
|
||||
'inverters' in root`. That is the one measurement the `inverters: []`
|
||||
default rests on, and it is an ISOLATED counterfactual: exactly one key
|
||||
varies between the two runs, so the refusal cannot be about anything else.
|
||||
B2. A reconstructed pre-0.6.0 options blob, merged with those defaults,
|
||||
validates. Its RAW form is refused - but for whichever required key the
|
||||
reconstruction happens to lack FIRST, which is an artefact of the
|
||||
reconstruction and NOT evidence about `inverters`. B is that evidence.
|
||||
C. A two-inverter blob validates, per-entry types enforced.
|
||||
D. An entry missing `name`, `setpoint_entity` or `max_w` is refused BY NAME,
|
||||
and a JSON string where the list belongs is refused outright.
|
||||
"""
|
||||
import sys, json, copy
|
||||
sys.path.insert(0, "/usr/src/supervisor")
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
from deepmerge import Merger
|
||||
from supervisor.apps.validate import SCHEMA_APP_CONFIG
|
||||
from supervisor.apps.options import AppOptions
|
||||
from pathlib import Path
|
||||
from supervisor.utils.yaml import read_yaml_file
|
||||
|
||||
MERGER = Merger(type_strategies=[(dict, ["merge"])],
|
||||
fallback_strategies=["override"], type_conflict_strategies=["override"])
|
||||
|
||||
raw = read_yaml_file(Path("/w/config.yaml"))
|
||||
print("== A. the whole config.yaml through SCHEMA_APP_CONFIG ==")
|
||||
try:
|
||||
cfg = SCHEMA_APP_CONFIG(raw)
|
||||
print(" PASS Supervisor accepts the add-on manifest, version", cfg["version"])
|
||||
except vol.Invalid as e:
|
||||
print(" FAIL ", humanize_error(raw, e)); sys.exit(1)
|
||||
|
||||
schema, defaults = cfg["schema"], cfg["options"]
|
||||
print(" schema declares", len(schema), "keys; inverters ->", json.dumps(schema["inverters"]))
|
||||
# .get, not [], deliberately: if someone deletes the `inverters: []` default
|
||||
# this line must not crash the probe before section B gets to REFUSE it.
|
||||
print(" options ship inverters ->", json.dumps(defaults.get("inverters")))
|
||||
|
||||
def validate(options):
|
||||
v = vol.Schema(vol.All(dict, AppOptions(None, schema, cfg["name"], "local_goodwe_controller")))
|
||||
try:
|
||||
return True, v(copy.deepcopy(options))
|
||||
except vol.Invalid as e:
|
||||
return False, str(e)
|
||||
|
||||
FAILED = []
|
||||
|
||||
def show(t, ok, d=""):
|
||||
print(f" {'PASS' if ok else 'FAIL'} {t}")
|
||||
if d: print(" " + str(d)[:300])
|
||||
if not ok: FAILED.append(t)
|
||||
|
||||
# The live install's persisted options, reconstructed from HANDOVER.md §1 and
|
||||
# docs/deploy-rollback.md §0/§B.5. No `inverters` key: it did not exist.
|
||||
LIVE = {
|
||||
"meter_entity": "sensor.p1_meter_active_power", "meter_invert": False,
|
||||
"soc_entity": "sensor.goodwe_battery_soc", "batt_entity": "sensor.goodwe_battery_power",
|
||||
"batt_invert": False,
|
||||
"setpoint_entity": "number.kelder_goodwe_master_stage1_goodwe_setpoint_w",
|
||||
"meter_source": "homewizard_local", "meter_phases": 1, "meter_max_age_s": 30,
|
||||
"meter_poll_s": 5, "p1_host": "192.168.2.250",
|
||||
"max_w": 5000, "gain": 0.6, "slew_w": 1000, "deadband_w": 15, "target_grid_w": -10,
|
||||
"step_w": 10, "saturation_w": 500, "saturation_cycles": 3, "integrator_max_w": 0,
|
||||
"heartbeat_s": 10, "stale_input_s": 15, "cycle_floor_s": 15, "auto_start": False,
|
||||
"maintenance_enabled": False, "estop_fitted": True, "log_level": "info",
|
||||
}
|
||||
|
||||
print("\n== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==")
|
||||
# ⚠️ THIS IS AN ISOLATED COUNTERFACTUAL AND IT HAS TO STAY ISOLATED. The claim
|
||||
# the `inverters: []` default rests on is about ONE key, so exactly ONE key
|
||||
# varies between these two runs: the full shipped `options:` block, and the same
|
||||
# block with `inverters` removed and nothing else touched. Feeding the validator
|
||||
# some other, INCOMPLETE blob would also produce a refusal - but about whichever
|
||||
# required key that blob lacks first, which is a different measurement wearing
|
||||
# this one's label. §B2 below is that other blob, and it is labelled as such.
|
||||
ok, out = validate(dict(defaults))
|
||||
show("the shipped `options:` block validates AS IT STANDS - so anything refused "
|
||||
"on the next line is refused for the one key that was removed", ok,
|
||||
"" if ok else out)
|
||||
_no_inv = {k: v for k, v in defaults.items() if k != "inverters"}
|
||||
ok_ni, out_ni = validate(_no_inv)
|
||||
# ⚠️ THE ASSERTION IS ON THE KEY NAME, NOT ON `not ok`, AND THAT IS THE POINT.
|
||||
# A refusal is only evidence for THIS key if it NAMES this key. Asserted as a
|
||||
# bare `not ok`, this line would go on printing PASS after someone deleted
|
||||
# `inverters: []` from config.yaml - blind to the exact regression it is the
|
||||
# committed evidence for. Asserted on the string, it goes red: with the default
|
||||
# gone the line above fails too, because then nothing distinguishes the two runs.
|
||||
_names_it = (not ok_ni) and "Missing option 'inverters' in root" in str(out_ni)
|
||||
show("...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - "
|
||||
"which is every options blob written before 0.6.0, since the key did not "
|
||||
"exist when they were written", _names_it, out_ni)
|
||||
|
||||
print("\n== B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version ==")
|
||||
# ⚠️ WHAT THIS SECTION IS AND IS NOT. LIVE is an INCOMPLETE reconstruction - it
|
||||
# carries the keys HANDOVER.md and deploy-rollback.md record and no others, so
|
||||
# it is missing many required keys that have nothing to do with this card. Its
|
||||
# raw refusal is therefore about whichever of THOSE the validator reaches first
|
||||
# (it has printed `meter_freeze_s` and `p1_phase_export_entities` on different
|
||||
# runs), NOT about `inverters`. It is kept because the MERGED half is a real
|
||||
# demonstration that `deepmerge(options:, stored)` is what App.options is; the
|
||||
# raw half is printed only so the merge has something to be contrasted with.
|
||||
# The `inverters` measurement is §B, where one key and only one key varies.
|
||||
ok, out = validate(LIVE)
|
||||
show("the reconstructed raw blob is REFUSED - for an unrelated missing key, "
|
||||
"because the reconstruction is incomplete; see the refusal printed below "
|
||||
"and §B for the `inverters` measurement", not ok, out)
|
||||
merged = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(LIVE))
|
||||
ok, out = validate(merged)
|
||||
show("the same blob MERGED with these defaults - what App.options actually is", ok,
|
||||
f"inverters={out.get('inverters')!r} setpoint_entity={out.get('setpoint_entity')!r} "
|
||||
f"max_w={out.get('max_w')!r}" if ok else out)
|
||||
|
||||
print("\n== C. AC2: a two-inverter blob an operator would POST ==")
|
||||
TWO = dict(LIVE, setpoint_entity="", inverters=[
|
||||
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
|
||||
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
|
||||
"max_w": 5000},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
|
||||
"soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power",
|
||||
"max_w": 3000}])
|
||||
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
|
||||
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
|
||||
# The validated blob, for anyone who wants to POST it. Optional: the probe's
|
||||
# whole job is the verdicts above, and a missing /out must not fail it.
|
||||
try:
|
||||
json.dump(out if ok else {}, open("/out/two_inverter_options.json", "w"), indent=1)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
print("\n== D. what the schema itself refuses, per entry ==")
|
||||
for title, entry in (
|
||||
("no name", {"setpoint_entity": "number.b", "max_w": 3000}),
|
||||
("no setpoint_entity", {"name": "inverter-b", "max_w": 3000}),
|
||||
("no max_w", {"name": "inverter-b", "setpoint_entity": "number.b"}),
|
||||
("max_w over the range", {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 9999})):
|
||||
ok, out = validate(dict(TWO, inverters=[entry]))
|
||||
show(f"an entry with {title} is refused", not ok, out)
|
||||
ok, out = validate(dict(TWO, inverters="[]"))
|
||||
show("a JSON STRING where the list belongs is refused", not ok, out)
|
||||
|
||||
|
||||
print("\n== E. the ROLLBACK direction, and the operator's UI ==")
|
||||
# The old (flat) schema, i.e. what an operator would be running after a
|
||||
# downgrade, meeting options that carry an `inverters` list.
|
||||
_flat_schema = {k: v for k, v in schema.items() if k != "inverters"}
|
||||
_v = vol.Schema(vol.All(dict, AppOptions(None, _flat_schema, cfg["name"],
|
||||
"local_goodwe_controller")))
|
||||
try:
|
||||
_rolled = _v(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
|
||||
show("a schema WITHOUT `inverters` drops the key with a warning rather "
|
||||
"than refusing - so a downgrade is not the §B trap in reverse",
|
||||
"inverters" not in _rolled, sorted(_rolled)[:6])
|
||||
except vol.Invalid as e:
|
||||
show("a schema WITHOUT `inverters` drops the key", False, str(e))
|
||||
|
||||
from supervisor.apps.options import UiOptions # noqa: E402
|
||||
try:
|
||||
_ui = [e for e in UiOptions(None)(schema) if e.get("name") == "inverters"]
|
||||
show("Supervisor's UI renderer turns it into a repeatable section rather "
|
||||
"than choking - an operator can add the second inverter in the "
|
||||
"Configuration tab, which the JSON-string alternative could not offer",
|
||||
bool(_ui) and _ui[0].get("multiple") is True, json.dumps(_ui))
|
||||
except Exception as e: # noqa: BLE001
|
||||
show("Supervisor's UI renderer survives the shape", False, f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
|
||||
# and still succeeds, and a transcript is only evidence if a wrong answer is
|
||||
# distinguishable from a right one by something other than reading it.
|
||||
print()
|
||||
if FAILED:
|
||||
print(f"== {len(FAILED)} CASE(S) FAILED ==")
|
||||
for t in FAILED:
|
||||
print(" - " + t)
|
||||
sys.exit(1)
|
||||
print("== all cases passed ==")
|
||||
Reference in New Issue
Block a user