Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae21b71824 | ||
|
|
18f052a071 | ||
|
|
d5c1577212 | ||
|
|
22c016fadd | ||
|
|
f000547518 | ||
|
|
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 | ||
|
|
04292c4a08 | ||
|
|
86c43eba24 |
+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,474 @@
|
||||
# SAFETY-30 — what the sign watchdog compares once there is more than one inverter, and the numbers it compares with
|
||||
|
||||
**Status: DECIDED 2026-09-04.** The one comparison becomes **two**, asked of the
|
||||
evidence that can answer each: **each unit's standing command against that
|
||||
unit's own measured power** (register 0x0518, per-instance since [[DEV-02]]),
|
||||
and **the sum of the standing commands against the meter**. At a roster of one
|
||||
nothing changes at all — the two questions are the same sentence there, and the
|
||||
detector that ships is the only one armed.
|
||||
|
||||
This document records the argument for that split, the campaign the four numbers
|
||||
were read off, **a number that was carried and a number that refused to be**, and
|
||||
the mutation ledger. Filed with SAFETY-30, card `6a9a1828e844f22fabf7fe9c`.
|
||||
|
||||
---
|
||||
|
||||
## 0. The half that was already built, and the half this card is
|
||||
|
||||
[[DEV-02]] shipped the **per-instance** half and nothing here re-tests it: each
|
||||
`Inverter` owns its own `SignWatchdog`, the latch is namespaced under
|
||||
`unit/<name>/sign_tripped`, the flat pre-DEV-02 pair is maintained as a rollback
|
||||
view, and a trip on B no longer latches A. `test_inverter.py` part 3 grades that.
|
||||
|
||||
What was left is **the comparison itself**, and it was one line:
|
||||
|
||||
```python
|
||||
inv.sign_wd.evaluate(stood, self.grid, self.following, now) # every unit
|
||||
```
|
||||
|
||||
`stood` is the command **this controller put on THIS actuator**. `self.grid` is
|
||||
what the **whole site** is exchanging with the network. At one inverter those are
|
||||
two views of one thing — §6.2's identity — which is what makes the check sound
|
||||
and what earned its record. At two they are not, and it fails in **both**
|
||||
directions:
|
||||
|
||||
| | what happens | why no threshold fixes it |
|
||||
|---|---|---|
|
||||
| **false trip** | A commanded −1500 W while the meter reads +1500 W, because B is discharging 3000 W exactly as told | the meter is answering for a unit A's command says nothing about |
|
||||
| **missed fault** | B commanded +1500 W and **measured** at −1500 W, beside A at +1500 W — the meter sits where a healthy site leaves it | the summation **destroys** the evidence |
|
||||
|
||||
NOTES.md calls SAFETY-02 the highest-value check in the safety table: 93 %
|
||||
agreement while healthy against 4 % during runaway, four observed episodes, zero
|
||||
false positives. **That record was earned at N=1 on the site comparison.** The
|
||||
first row above spends it; the second row is the guard being absent when it
|
||||
matters.
|
||||
|
||||
## 1. The two questions, and the two remedies
|
||||
|
||||
> **PER UNIT** — this unit's standing command against **its own** measured power.
|
||||
> Conclusion: *the actuator I am driving is not doing what I told it.* Evidence
|
||||
> about one device, so the remedy is that device: the `failsafe` SET, which
|
||||
> `arbiter.CLAIM_SCOPE` has classified `SCOPE_UNIT` since it was written.
|
||||
>
|
||||
> **SITE** — the **sum** of the standing commands against the meter. Conclusion:
|
||||
> *the site as a whole is driving the wrong way* — an inverted gain, an
|
||||
> integrator wound past the rail, a strategy on the wrong side of the meter.
|
||||
> That is the 2026-08-20 fault day, it is a property of the **connection**, and
|
||||
> the remedy is a new SITE SET, `sign_site`, that stops every inverter.
|
||||
|
||||
### Why `failsafe` stays per-unit and `sign_site` is new
|
||||
|
||||
`CLAIM_SCOPE`'s entry for `failsafe` used to read *"the evidence is site-wide and
|
||||
the remedy is not"* — a site measurement attributed to one actuator on the
|
||||
strength of the N=1 identity. After this card the first half of that sentence is
|
||||
simply true: the evidence **is** per-unit. The site-wide half did not disappear;
|
||||
it moved to a claim that is site-scoped for the reason `circulation` is, one
|
||||
entry above it: **the offending number is the site target, which no single
|
||||
inverter owns.** Parking one unit on it would leave the rest of the roster
|
||||
pushing the same way with the guard satisfied.
|
||||
|
||||
### At N=1, exactly one detector is armed
|
||||
|
||||
`Controller._arm_sign_watchdogs()` is the only place the roster decides anything
|
||||
here, and it is a pure function of `len(self.inverters)`:
|
||||
|
||||
* **one inverter** — the unit detector is armed against **the meter**, at the
|
||||
shipped `sign_disagree_w` / `sign_disagree_s`. The site detector is
|
||||
**constructed and never evaluated**. Same comparison, same thresholds, same
|
||||
reason string byte for byte, same namespaced keys and flat mirror, same
|
||||
`failsafe` claim, same banner, same single status row. `test_watchdog.py`
|
||||
part 7 asserts each of those, including that the site detector's window was
|
||||
never so much as opened.
|
||||
* **two or more** — each unit against its own 0x0518 power under the per-unit
|
||||
pair, and the site detector against the meter under the site pair.
|
||||
|
||||
Arming both at N=1 would be two latches and two banners for one fault, and a
|
||||
false-positive record measured on **one** comparison spent twice.
|
||||
|
||||
## 2. What this is NOT — the two neighbours, named
|
||||
|
||||
Two guards that overlap without saying so is how a false-alarm generator gets
|
||||
built, so both are written down.
|
||||
|
||||
### It is not [[SAFETY-31]]
|
||||
|
||||
| | **SAFETY-31 — `circulation.py`** | **SAFETY-30 — the per-unit half** |
|
||||
|---|---|---|
|
||||
| compares | the units **against each other**: `Σ\|Bᵢ\| − \|ΣBᵢ\|` over measured power | each unit against **its own command** |
|
||||
| catches | two inverters pushing **through** each other; nets to zero at the meter | a unit **not doing what it was told**; that unit is faulty or wedged |
|
||||
| attributes | nothing — the quantity is defined over the roster and is identically zero for one unit | **the unit**, by name, and parks it |
|
||||
| scope | SITE claim, flat latch | UNIT claim, namespaced latch |
|
||||
|
||||
They genuinely overlap in **one** scenario — a unit reversing while its sibling
|
||||
pulls the other way — and `test_watchdog.py` part 4 asserts exactly that, on the
|
||||
same fixture, with both readings printed: circulating power is over its threshold
|
||||
**and** SAFETY-30 names B. Neither is the other with a different threshold. Take
|
||||
two units faithfully executing an allocation with two signs: circulating power is
|
||||
the whole loop and every per-unit sign check reads perfect agreement. Take one
|
||||
unit doing the opposite of its share while its sibling covers: this reads the
|
||||
whole disagreement and circulating power can be zero.
|
||||
|
||||
### It is not the saturation freeze
|
||||
|
||||
`compute()`'s anti-windup test is `|prev_w − actual_w| > saturation_w` (500 W). It
|
||||
concludes *the inverter cannot follow*, it fires on a merely **saturated** unit as
|
||||
readily as on a reversed one, it **stops the integrator winding** — an ordinary
|
||||
control action taken every day — and it neither latches nor parks anything.
|
||||
|
||||
This asks for **opposition**: a sign, on both sides of a threshold, held
|
||||
continuously. A saturated-but-obedient unit cannot produce it. And the two meet
|
||||
at a number rather than overlapping in a band nobody drew — see §3.
|
||||
|
||||
## 3. The numbers, and where they come from
|
||||
|
||||
Read off `admin/goodwe`'s `sim/` two-master rig (TEST-19), used **read-only** and
|
||||
run container-free. Real and imported, never transcribed: the control law
|
||||
(`control.compute`, `Tuning`), the allocator (`arbiter.allocate_record` →
|
||||
`allocator.plan`), the sign predicate (`watchdog.disagrees`), the wire
|
||||
(`frame_check.setpoint_atomic`, the firmware's own encoder), the plant
|
||||
(`plantsim.Plant`), and the per-unit reading, taken from **holding register
|
||||
0x0518** — the register the live `batt_entity` binds.
|
||||
|
||||
**1890 closed-loop runs in which NOTHING is faulty** — every unit executes the
|
||||
share the allocator gave it — over **6 gains × 5 slews**, the whole range
|
||||
`config.yaml` accepts for `gain` `float(0.05,1.0)` and `slew_w` `int(50,5000)`,
|
||||
across both families of stimulus that can produce legitimate opposition:
|
||||
|
||||
* **1050 load reversals** — 7 sizes (±300 W … ±4000 W) × 5 write staggers, the
|
||||
serialised write path landing unit B's number up to four cycles after A's;
|
||||
* **840 flapping loads** — 7 periods (10 s … 600 s) × 4 amplitudes.
|
||||
|
||||
Every run is executed **twice**: once at a roster of two and once at a roster of
|
||||
one on the same stimulus. **The N=1 arm is the control**, and it is the reason
|
||||
this document can separate *"a property of the check that shipped"* from *"a
|
||||
property of the second inverter"* instead of asserting one and hoping.
|
||||
|
||||
Supervised caps are the shipped `max_w` (2000 W per unit), so the site rail is
|
||||
2000 W at N=1 and 4000 W at N=2 — `Controller.site_max_w`, the roster sum.
|
||||
|
||||
### Reproducing it
|
||||
|
||||
The harness is **not committed here**, for the reason SAFETY-31's was not: it
|
||||
imports `plantsim` and `frame_check`, which live in the other repository and are
|
||||
not present in CI. What it does is small enough to restate: build two
|
||||
`plantsim.Inverter`s from `INVERTER_A` / `INVERTER_B`, close the loop with the
|
||||
add-on's own `compute()` and `arbiter.allocate_record()`, put each share on its
|
||||
own wire through `Inverter.command()`, advance `Site.settle()` one control cycle,
|
||||
and record `(standing command, inv.reg(0x0518))` per unit and
|
||||
`(Σ standing, site.grid)` for the site. Then, for each candidate threshold, the
|
||||
**longest run of consecutive cycles** on which the shipped `disagrees()` is true —
|
||||
not a count of true samples, because the watchdog's window is cleared by any
|
||||
disagreeing sample. Point it at a checkout with `GOODWE_ADDON_REPO`.
|
||||
⚠️ MUTATION.md's note applies: those harnesses write `__pycache__` into **both**
|
||||
trees.
|
||||
|
||||
### A. PER-UNIT — a unit's standing command vs its own 0x0518 power, N=2
|
||||
|
||||
| threshold | worst continuous, reversals | worst continuous, flapping | runs crossing (of 1890) |
|
||||
|---:|---:|---:|---:|
|
||||
| 50 W | 25.0 s | 20.0 s | 962 |
|
||||
| 100 W | 20.0 s | 15.0 s | 511 |
|
||||
| 150 W | 15.0 s | 15.0 s | 379 |
|
||||
| 200 W | 10.0 s | 15.0 s | 258 |
|
||||
| **250 W** | **10.0 s** | **10.0 s** | **194** |
|
||||
| 300 W | 10.0 s | 10.0 s | 124 |
|
||||
| 500 W | 5.0 s | 5.0 s | 37 |
|
||||
| 700 W | 5.0 s | 5.0 s | 16 |
|
||||
| 1000 W | 0.0 s | 0.0 s | 0 |
|
||||
|
||||
Largest opposed magnitude anywhere in the 1890 runs: **875 W**.
|
||||
|
||||
**The curve is flat, and that is the finding that decided the shape of this
|
||||
pair.** Unlike the site comparison — where the meter is driven by the LOAD and a
|
||||
flapping load can hold it opposed for minutes — a unit's own power **chases its
|
||||
own command** through a bounded ramp and a first-order lag, so the opposition can
|
||||
only last as long as the crossing does. Dropping the threshold from 300 W to
|
||||
50 W buys 15 s of extra transient and nothing else. **So the work is done by the
|
||||
window, and the threshold is put where the control law already draws its line.**
|
||||
|
||||
#### `unit_sign_disagree_w: 250` — schema `int(50,5000)`
|
||||
|
||||
`saturation_w / 2`. The check gates on **both** sides being over `min_w`, and the
|
||||
two are **opposed**, so the smallest command-versus-power gap it can ever see is
|
||||
`2 × min_w`. At 250 W that is exactly the 500 W at which `compute()`'s own
|
||||
anti-windup test already concludes the inverter is not following. **This watchdog
|
||||
therefore cannot fire on a divergence the shipped control law itself calls
|
||||
ordinary tracking**, and the two guards meet at a number.
|
||||
|
||||
Rejected: a threshold above the observed 875 W maximum, which would gate nothing
|
||||
legitimate in — and would also be blind to a unit reversing a 900 W share.
|
||||
|
||||
#### `unit_sign_disagree_s: 85` — schema `int(30,1800)`
|
||||
|
||||
Three bounds, and the value clears all three.
|
||||
|
||||
* **lower, from the rig — 10.0 s.** Two control cycles, the worst continuous
|
||||
legitimate opposition above 250 W across all 1890 runs. It is a reversing site
|
||||
whose second write lands a cycle after the first, both units then slewing
|
||||
through zero behind a 10 s lag. Nothing is wrong.
|
||||
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
|
||||
executing its share for less than the firmware's own veto window is a *link*
|
||||
fault the add-on already answers by dropping the unit from the split. Firing
|
||||
inside that window would raise a second, louder alarm about a state already
|
||||
being handled — the SAFETY-15/SAFETY-19 failure exactly.
|
||||
* **upper, from SAFETY-02 itself — 180 s, the site hold.** A unit that is not
|
||||
doing what it was told must not be parked *later* than the site would be
|
||||
parked for the same watts, and this conclusion needs nothing to net out: it is
|
||||
complete on the first cycle it appears.
|
||||
|
||||
**85 s** is seventeen control cycles: **8.5× the worst legitimate episode**,
|
||||
**2.8× the firmware veto**, and **under half** the site hold. Deliberately not
|
||||
60, 75, 90, 120 or 180.
|
||||
|
||||
### B / C. SITE — Σ standing commands vs the meter, at BOTH rosters
|
||||
|
||||
**Load-reversal family** (1050 runs each), worst continuous legitimate opposition:
|
||||
|
||||
| threshold | **N=2** | **N=1 (control)** | ratio |
|
||||
|---:|---:|---:|---:|
|
||||
| 100 W | 335.0 s | 175.0 s | 1.91× |
|
||||
| 200 W | 260.0 s | 120.0 s | 2.17× |
|
||||
| **300 W** | **215.0 s** | **110.0 s** | **1.95×** |
|
||||
| 500 W | 155.0 s | 90.0 s | 1.72× |
|
||||
| 700 W | 130.0 s | 70.0 s | 1.86× |
|
||||
| 1000 W | 85.0 s | 35.0 s | 2.43× |
|
||||
|
||||
**The second inverter roughly DOUBLES the worst legitimate crossing, and at the
|
||||
shipped 300 W threshold it takes it from 110 s — comfortably inside the 180 s
|
||||
hold — to 215 s, which is outside it.**
|
||||
|
||||
The mechanism is not mysterious and is worth stating because it predicts the
|
||||
ratio: the transient the 180 s hold exists to reject is the loop crossing zero
|
||||
after a load reversal, and the command has to travel the whole **site rail** at
|
||||
`slew_w` per cycle. At N≥2 that rail is `Controller.site_max_w`, the roster sum,
|
||||
while `slew_w` is unchanged. The worst case is the corner of the config space
|
||||
where that takes longest — `gain: 0.05`, `slew_w: 50`, both **schema floors** —
|
||||
against a ±2200 W load reversal, which is an oven and a heat pump.
|
||||
|
||||
#### `site_sign_disagree_w: 300` — CARRIED, and here is the justification
|
||||
|
||||
The argument behind 300 W is about **noise**: *"near zero the sign is noise, and
|
||||
near zero is where a healthy loop LIVES"* — the deadband holds the command still
|
||||
while the meter wanders either side of `target_grid_w`. `deadband_w` is 15 W and
|
||||
there is one P1 whatever is behind it. **Nothing in that argument depends on the
|
||||
roster**, and the campaign agrees: the fraction of samples gated in at 300 W is
|
||||
within a few percent at both rosters. Carried, with a measurement behind the
|
||||
carry rather than an assumption.
|
||||
|
||||
#### `site_sign_disagree_s: 325` — NOT carried, and that is the point of the card
|
||||
|
||||
180 s is **inside** the 215 s the two-inverter campaign measures, at a
|
||||
configuration the schema accepts. Carrying it would have shipped a false trip on
|
||||
the check with the best record in the safety table — which is the exact failure
|
||||
this card was opened to prevent, arriving from the other direction.
|
||||
|
||||
**325 s** is 1.51× the measured worst. It is well inside the **900 s** quarter
|
||||
that bills the capacity tariff, and it is inside **every** episode the fault day
|
||||
contains: 2026-08-20's four clusters run 16, 30, 77 and 77 minutes, and its
|
||||
opposed runs reach 1750 s. What it costs is stated rather than hidden — about two
|
||||
and a half extra minutes of wrong-direction **site** power before the stop —
|
||||
and it is bought to remove a false trip a legal configuration produces.
|
||||
Deliberately not 300 or 360.
|
||||
|
||||
**Rejected alternative:** keep 180 s and raise the site threshold to 1000 W,
|
||||
where the worst is 85 s. It gives a better margin and a faster stop, and it is
|
||||
wrong: it would be raising a **noise** gate to reject a **transient**, which is
|
||||
what the window is for, and it would leave the site check blind to a 900 W
|
||||
whole-site reversal at a 4 kW site.
|
||||
|
||||
### A limitation this campaign found, which SAFETY-30 does not introduce and does not fix
|
||||
|
||||
The **flapping-load** family behaves completely differently for the SITE
|
||||
comparison, and the control arm is what makes it interpretable:
|
||||
|
||||
| threshold | **N=2** | **N=1 (control)** |
|
||||
|---:|---:|---:|
|
||||
| 300 W | 980.0 s | 1000.0 s |
|
||||
| 700 W | 975.0 s | 1000.0 s |
|
||||
| 1000 W | 950.0 s | 1000.0 s |
|
||||
|
||||
A square wave whose half period is about one control cycle opposes the command on
|
||||
**every** cycle: the command chases the previous half-cycle's meter while the
|
||||
meter has already reversed. **No window inside the billing quarter survives it and
|
||||
no threshold up to 1000 W does either — at EITHER roster.** The N=1 column is
|
||||
this document's whole reason for existing: this is a property of comparing a
|
||||
lagged command against a meter, and it has nothing to do with the second
|
||||
inverter.
|
||||
|
||||
It is not acted on here, in order of weight: it is **identical at the roster this
|
||||
add-on ships**, so acting on it would be changing a live safety path on evidence
|
||||
that has nothing to do with this card; a ~6 kW square wave at 0.1 Hz sustained
|
||||
for a quarter of an hour is not a house (the captured healthy day's longest
|
||||
opposed run is **30 s**); and the fix is not a number — it wants the detector to
|
||||
notice that the disagreement is **alternating**, which is a different check.
|
||||
**It is a card, and this section is its evidence.**
|
||||
|
||||
### D. The fault arm — because a survey of healthy data is not a derivation
|
||||
|
||||
210 runs with inverter B executing the **negation** of its share throughout,
|
||||
sibling faithful, meter left to net:
|
||||
|
||||
| threshold | longest run the per-unit check detects | runs the SITE check also sees |
|
||||
|---:|---:|---:|
|
||||
| 250 W | 890.0 s | 210 / 210 |
|
||||
| 300 W | 890.0 s | 180 / 210 |
|
||||
| 700 W | 885.0 s | 112 / 210 |
|
||||
| 1000 W | 885.0 s | 60 / 210 |
|
||||
| 1500 W | 880.0 s | 45 / 210 |
|
||||
| 2000 W | 0.0 s | 39 / 210 |
|
||||
|
||||
At the shipped 250 W the fault is detected continuously for the whole run in
|
||||
every one of the 210 — an 85 s hold has ten times the evidence it needs. The
|
||||
right-hand column is the honest half: on a *reversing* stimulus the site
|
||||
comparison often does see something too, because the reversal is large enough to
|
||||
move the meter. **The case it cannot see is the steady one**, and that is the
|
||||
fixture `test_watchdog.py` part 4 runs: the shipped comparison is false on
|
||||
**0 of 14** cycles while the per-unit check convicts B on every one.
|
||||
|
||||
## 4. Inverter B does not exist
|
||||
|
||||
Every two-inverter figure in §3 was produced with **inverter A's ramp and lag
|
||||
copied onto B** (`plantsim.INVERTER_B`, `measured=False`); its 3000 W rating,
|
||||
where this repository's fixtures use one, is a **hypothesis marked as one**.
|
||||
[[SPIKE-04]] measures the real ones and is the revisit trigger for all four
|
||||
numbers — not a reason to move any of them now.
|
||||
|
||||
Which figures depend on it, stated so nobody has to work it out:
|
||||
|
||||
* **`unit_sign_disagree_s: 85`** — most. Its lower bound is the 10.0 s crossing,
|
||||
which is a direct function of B's ramp and lag. A slower second unit lengthens
|
||||
it; the 8.5× is what buys headroom for that.
|
||||
* **`site_sign_disagree_s: 325`** — yes, but less directly. Its 215 s is
|
||||
dominated by the **rail** and by `slew_w`, which are configuration, not
|
||||
hardware; B's dynamics move it at the margin.
|
||||
* **`unit_sign_disagree_w: 250`** — **no.** It is `saturation_w / 2`, read off
|
||||
the add-on's own control law. The rig only confirms that nothing legitimate
|
||||
sits over it for long.
|
||||
* **`site_sign_disagree_w: 300`** — **no.** It is the meter's noise floor and the
|
||||
deadband, neither of which is a property of any inverter.
|
||||
|
||||
## 5. Mutation ledger
|
||||
|
||||
Every run on a **freshly copied tree of its own, no `__pycache__`, mounted
|
||||
`:ro`** into the CI image — the structural immunity MUTATION.md records. ⚠️ The
|
||||
first battery used ONE shared directory rebuilt between mutations and reported a
|
||||
**RED baseline**; a container mounted it mid-copy and read a missing
|
||||
`config.yaml` as a failing check. That whole run was discarded: MUTATION.md's
|
||||
rule is that a run which could have been racing is not a result, and a battery
|
||||
whose baseline is not green establishes nothing. The harness now copies a
|
||||
directory per mutation and **refuses to report anything if the baseline is not
|
||||
green**.
|
||||
|
||||
Byte deltas measured, not estimated; the `+0 B` ones are marked, since a
|
||||
size-preserving edit is what TEST-15 turned into a false survivor once already.
|
||||
|
||||
| # | file | mutation | Δ | result |
|
||||
|---|---|---|---|---|
|
||||
| M1 | `main.py` | the per-unit comparison put back to `self.grid` at every roster | −30 B | **RED** — `test_watchdog.py`. This is the card |
|
||||
| M2 | `main.py` | the per-unit comparison forced to `inv.batt` at N=1 too | −31 B | **RED** — AC 8 |
|
||||
| M3 | `main.py` | the site half never evaluated | −6 B | **RED** — AC 3 |
|
||||
| **M4** | `main.py` | **the site half armed at N=1 as well** | **+0 B** | **SURVIVOR on the first battery — GAP CLOSED**, then RED. See below |
|
||||
| M5 | `main.py` | the `sign_site` claim never filed | −6 B | **RED** — `test_watchdog.py`. ⚠️ `test_peakguard.py` stayed **GREEN**, correctly: 10f's AST scan pins which sources CAN be filed and the append is still written; whether it is REACHED is a dynamic question no fixture there gets to |
|
||||
| M6 | `main.py` | the site latch not persisted to the store | −58 B | **RED** — the flat-keys and restart checks |
|
||||
| M7 | `main.py` | the operator clear leaves the site latch set | −42 B | **RED** — the clear check |
|
||||
| M8 | `watchdog.py` | `arm()` clears `disagree_since` | +35 B | **RED** — "re-armed on every one of 38 cycles still trips" |
|
||||
| M9 | `watchdog.py` | the reason hard-codes "a meter reading" again | +1 B | **RED** — `test_watchdog.py`. `test_inverter.py` stayed **GREEN**; its part-3 row reads the noun off `sign_wd.against` rather than out of the reason string, so the two checks bite in different places |
|
||||
| M10 | `watchdog.py` | `unit_sign_disagree_w` 250 → 300 | **+0 B** | **RED** — the `saturation_w / 2` pin and the CFG-03 pin |
|
||||
| M11 | `watchdog.py` | `site_sign_disagree_s` 325 → 180, i.e. **the number carried** | **+0 B** | **RED** — the measured-215 s check and the CFG-03 pin |
|
||||
| M12 | `arbiter.py` | `sign_site` scoped `SCOPE_UNIT` | **+0 B** | **RED** — `test_arbiter.py` **and** `test_peakguard.py` |
|
||||
| M13 | `validate.py` | the `unit_sign_disagree_w` rule removed | −126 B | **RED** — `test_validate.py` |
|
||||
| **M14** | `main.py` | **`arm()` handed `self.sign_cfg` at EVERY roster** — the WIRING, not the values | −31 B | **SURVIVOR at review — GAP CLOSED**, then RED. See below |
|
||||
| M15 | `main.py` | per-unit `arm()` given the right `min_w` and the SHIPPED hold — a HOLD-ONLY mis-wire | +78 B | **RED** — the same wiring pin |
|
||||
| M16 | `main.py` | per-unit `arm()` given the right `min_w` and the SITE hold — hold-only again, and behaviour-preserving | +83 B | **RED** — the wiring pin, and the ONLY red in `test_watchdog.py` |
|
||||
|
||||
### M4 is recorded as a survivor because it was one
|
||||
|
||||
The check it should have gone red on read:
|
||||
|
||||
```python
|
||||
not _c1s.site_sign_wd.tripped and _c1s.site_sign_wd.disagree_since is None
|
||||
```
|
||||
|
||||
Arming the site detector at a roster of one left that **green**, and the reason
|
||||
is worth reading because the check looked airtight. In the N=1 fixture the unit
|
||||
detector trips first at its 1 s hold and parks the inverter; the site's standing
|
||||
command is then **0 W**, so the site predicate is false, its window is never
|
||||
opened, and `disagree_since is None` is true *whether or not the detector was
|
||||
evaluated at all*. **The assertion ran, the sentence was true, and the data
|
||||
could not reach the failure it named** — MUTATION.md's third failure mode,
|
||||
inside the check written to prevent it.
|
||||
|
||||
Replaced with a **call counter installed on the object** before the run: twelve
|
||||
cycles, zero calls, printed. A counter can only be zero for one reason. The
|
||||
mutation was re-run against it and is red; the run that found it was discarded
|
||||
along with its tree, so this is a result and not an artefact.
|
||||
|
||||
### M14: the VALUES were pinned and the WIRING was not
|
||||
|
||||
M10 and M11 mutate the numbers in `watchdog.SIGN_OPTION_DEFAULTS` and both are
|
||||
red through the config pins. Neither of them touches the line that DELIVERS
|
||||
those numbers to a detector:
|
||||
|
||||
```python
|
||||
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg,
|
||||
AGAINST_METER if one else AGAINST_OWN_POWER)
|
||||
```
|
||||
|
||||
Drop the conditional — `arm(self.sign_cfg, ...)` — and every unit is armed with
|
||||
SAFETY-02's meter-facing pair instead of the re-derived per-unit one. At review
|
||||
that left **all 19 files green**. `grep -rn "sign_wd.cfg" test_*.py` found
|
||||
exactly one threshold assertion, and it is §7's N=1 arm — `(300.0, 1.0)` —
|
||||
which the mutation PRESERVES. Nothing anywhere read `inv.sign_wd.cfg` at a
|
||||
roster of two.
|
||||
|
||||
The behavioural checks in part 3 cannot close it and it is worth saying why: B
|
||||
is reversed at ~2000 W and every hold in that fixture is short, so B trips under
|
||||
either pair. **A fault big enough and long enough to trip under both thresholds
|
||||
is evidence about neither.**
|
||||
|
||||
What the survivor costs in the field is the whole product of §3: at
|
||||
`config.yaml`'s own defaults the per-unit check would run at `sign_disagree_s`
|
||||
**180 s instead of 85 s** — 2.1× slower to park a reversed inverter — and its
|
||||
gate would move from 250 W to 300 W, so a unit reversing a share between the two
|
||||
becomes invisible, while `SIGN_OPTION_DEFAULTS`, `config.yaml` and §3 all go on
|
||||
describing a pair the running code does not use. With CI green.
|
||||
|
||||
**Closed by a wiring pin in `test_watchdog.py` part 3**, on the real
|
||||
two-inverter Controller: every `inv.sign_wd.cfg` equals the per-unit pair,
|
||||
`site_sign_wd.cfg` equals the site pair, and each detector's `against` is its
|
||||
own instrument — all graded **against literals**, since `== self.unit_sign_cfg`
|
||||
cannot tell *the per-unit pair* from *whatever this Controller was handed*.
|
||||
|
||||
⚠️ **And the fixture had to be changed for that pin to mean anything.**
|
||||
`_sign_site` set `unit_sign_disagree_s: 1` **and** `site_sign_disagree_s: 1`
|
||||
**and** `sign_disagree_s: 1`, with two of the three magnitude gates at 300 W. A
|
||||
bare equality there discriminates on `min_w` alone: a mis-wiring that delivered
|
||||
the right magnitude with the wrong hold would compare EQUAL to the pair it was
|
||||
supposed to have. That is this document's own §5 lesson and MUTATION.md's third
|
||||
failure mode — a check comparing two things that happen to be equal cannot see
|
||||
the difference it names — reappearing inside the fix for it. The three pairs are
|
||||
now pairwise distinct **in both coordinates** (250 W / 1 s, 320 W / 2 s,
|
||||
300 W / 3 s), a PRECONDITION check asserts that distinctness so a later edit
|
||||
collapsing them goes red rather than quietly vacuous, and both pairs are
|
||||
**printed**. M15 and M16 exist to show the hold half actually bites: right
|
||||
magnitude, wrong hold, red both times — and M16, whose wrong hold still trips
|
||||
inside the drive, is red on the wiring pin and on **nothing else** in that file.
|
||||
|
||||
## 6. What this card does NOT close
|
||||
|
||||
* **The flapping-load blind spot in the SITE comparison.** §3, at length. Present
|
||||
identically at the shipped roster of one; wants an alternation test, not a
|
||||
number; a card of its own.
|
||||
* **A unit misbehaving while it is NOT loop-driven.** Gate 1 (`following`) is
|
||||
kept for the per-unit comparison, deliberately: the whole no-false-positive
|
||||
argument is built on it, and the saturation freeze already grades that window.
|
||||
So between the start of a maintenance cycle and its end, a unit reversing
|
||||
against its own command is the freeze's to notice. Widening it is a card with a
|
||||
rig campaign behind it, not an edit.
|
||||
* **A sign inversion downstream of this add-on** — in the firmware, on the wire,
|
||||
or in a future driver. Unchanged: our view stays self-consistent and both
|
||||
comparisons stay silent. That is the witness's problem and it always was.
|
||||
* **A false-alarm interaction with SAFETY-31 that nobody has to think about.**
|
||||
They overlap in one scenario and both fire in it. That is correct — they
|
||||
conclude different things — but an operator will see two alarms for one event.
|
||||
Whether the banner should say so in one sentence is a UI card.
|
||||
@@ -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.
|
||||
+283
-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 |
|
||||
@@ -287,6 +363,10 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
| `auto_start` | false | Start controlling on boot (only after commissioning) |
|
||||
| `sign_disagree_w` | 300 | Both the command and the meter must exceed this before a sign difference counts. Must stay **below `max_w`** or the watchdog can never fire, and the add-on refuses to start |
|
||||
| `sign_disagree_s` | 180 | How long they must stay opposed, continuously, before the failsafe trips |
|
||||
| `unit_sign_disagree_w` | 250 | **Two or more inverters only.** Both a unit's own share and its own measured power must exceed this before their sign difference counts. It is `saturation_w / 2`, so the smallest divergence it can see is exactly the gap at which the loop already calls the inverter "not following". Must stay **below `max_w`** or it can never fire, and the add-on refuses to start |
|
||||
| `unit_sign_disagree_s` | 85 | **Two or more inverters only.** How long one unit's command and its own power must stay opposed before that unit is parked |
|
||||
| `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise |
|
||||
| `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two |
|
||||
|
||||
#### The sign-disagreement failsafe
|
||||
|
||||
@@ -298,6 +378,43 @@ there. The banner reads `FAILSAFE`, the commissioning list says which two
|
||||
numbers disagreed and for how long, and the `Controller status` entity reports
|
||||
`failsafe`.
|
||||
|
||||
#### ⚠️ At two or more inverters this is TWO checks, not one (SAFETY-30)
|
||||
|
||||
The paragraph above describes what a **one-inverter** install does, and it is
|
||||
unchanged: there is one meter, one actuator, and comparing the command against
|
||||
the meter is comparing a thing with a view of itself.
|
||||
|
||||
With a second inverter that stops being true. The meter answers for **both**
|
||||
units, so a command put on one of them can be opposed to it with nothing wrong
|
||||
(inverter A charging while inverter B discharges more), and - the worse
|
||||
direction - a unit genuinely driving the wrong way can be **hidden**, because its
|
||||
sibling's watts arrive at the meter and the sum looks healthy. So the check
|
||||
splits in two, and they are different faults:
|
||||
|
||||
- **Per inverter** — that unit's own share against **its own measured power**
|
||||
(the `batt_entity` reading, register 0x0518). This catches *"this inverter is
|
||||
not doing what it was told"*. Only that inverter is parked; the site keeps
|
||||
running on the one that works. Thresholds: `unit_sign_disagree_w` /
|
||||
`unit_sign_disagree_s`.
|
||||
- **Site-wide** — the **sum** of what every inverter has standing, against the
|
||||
meter. This catches *"the site as a whole is pushing the wrong way"*, which no
|
||||
per-inverter check can see because every unit may be faithfully executing its
|
||||
share of a number that is itself wrong. **Every** inverter is parked and the
|
||||
banner names the site rather than an inverter. Thresholds:
|
||||
`site_sign_disagree_w` / `site_sign_disagree_s`, latched under flat
|
||||
`site_sign_tripped` in `/data/state.json`.
|
||||
|
||||
Both latch, both are cleared by the same *stop, then start*, and both survive a
|
||||
restart. **On a one-inverter install neither pair does anything at all** - the
|
||||
two questions are the same question there, and only the check described above is
|
||||
armed.
|
||||
|
||||
This is **not** the circulating-power alarm further down: that one compares the
|
||||
inverters *against each other* and catches them pushing power through each other,
|
||||
which nets to zero at the meter and which no command is evidence about. It is
|
||||
also not the saturation freeze, which is a *magnitude* test that shapes the loop
|
||||
rather than a *direction* test that parks an inverter.
|
||||
|
||||
**It does not clear itself, and restarting the add-on does not clear it either.**
|
||||
Pressing *Start / stop control* twice — stop, then start — is the only thing that
|
||||
does. That is deliberate on both counts:
|
||||
@@ -306,7 +423,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 +553,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 +675,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 +700,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 +821,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 +858,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 +878,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 +1178,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,183 @@ 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,
|
||||
# main.py: `Claim.set("sign_site", P_SAFETY, 0.0, "site sign disagreement")`.
|
||||
# SAFETY-30, and it is the twin of `failsafe` below rather than a duplicate
|
||||
# of it - the two exist BECAUSE the same words describe two different
|
||||
# measurements once there is more than one inverter.
|
||||
#
|
||||
# `failsafe` grades ONE unit's standing command against THAT UNIT'S OWN
|
||||
# measured power (register 0x0518). Its conclusion is "the actuator I am
|
||||
# driving is not doing what I told it", which is a fact about one device,
|
||||
# so it parks one device.
|
||||
#
|
||||
# This one grades the SUM of the standing commands against P1. Its
|
||||
# conclusion is "the site as a whole is driving the wrong way" - an inverted
|
||||
# gain, an integrator wound past the rail, a strategy on the wrong side of
|
||||
# the meter - and there is nothing per-unit about it: the quantity is a sum
|
||||
# over the roster and the offending number is the site target, which no
|
||||
# single inverter owns. Parking one unit on that evidence would leave the
|
||||
# rest of the site pushing the same way with the guard satisfied.
|
||||
#
|
||||
# ⚠️ AT N=1 IT IS NEVER FILED, and that is the identity rather than a
|
||||
# special case: with one inverter the sum of the commands IS that unit's
|
||||
# command and `failsafe` already carries exactly this trip, under the keys,
|
||||
# the banner and the claim an install has had since SAFETY-02 shipped.
|
||||
# `Controller.cycle()` does not even arm the site detector there.
|
||||
"sign_site": 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 PER-UNIT AND SO IS THE REMEDY - AND SINCE SAFETY-30 THE
|
||||
# FIRST HALF OF THAT SENTENCE IS TRUE. It used to read "the evidence is
|
||||
# site-wide and the remedy is not", because the watchdog compared THIS
|
||||
# unit's standing command against P1: a site measurement, attributed to one
|
||||
# actuator on the strength of §6.2's N=1 identity. That identity does not
|
||||
# survive a second inverter - at N>=2 the meter answers for a sibling this
|
||||
# unit's command says nothing about, in both directions (a false trip when
|
||||
# the sibling covers the meter, and a missed one when the sibling MASKS a
|
||||
# unit that really is reversed).
|
||||
#
|
||||
# So at N>=2 the comparison behind this claim is THIS unit's command against
|
||||
# THIS unit's own measured power - register 0x0518, per-instance since
|
||||
# DEV-02 - and the scope that was already right for the wrong reason is now
|
||||
# right for the right one. The site-wide half moved to `sign_site` above.
|
||||
# At N=1 the two are the same comparison and this claim is the one filed,
|
||||
# exactly as it shipped.
|
||||
"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 +278,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 +357,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 +393,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 +518,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 +528,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
|
||||
+2108
-192
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,56 @@ _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.
|
||||
#
|
||||
# ⚠️ AND SAFETY-30's PAIR IS FLAT FOR THE SAME REASON SAFETY-31's IS, WHICH IS
|
||||
# NOT THE SAME REASON THE ONES ABOVE ARE NAMESPACED. `site_sign_tripped` /
|
||||
# `site_sign_reason` hold the SITE sign-disagreement latch: the SUM of every
|
||||
# standing command opposed the meter, continuously. That is a statement about
|
||||
# the grid connection - the offending number is the site target, which no single
|
||||
# inverter owns - so there is no unit to hang it on, and hanging it on one would
|
||||
# let an operator clear "inverter B's half" of a fault that has no halves.
|
||||
# `unit/<name>/sign_tripped` above stays what it always was and now means what
|
||||
# it says: THAT unit's own command against THAT unit's own measured power.
|
||||
#
|
||||
# ⚠️ AND THEY ARE NOT IN `LEGACY_UNIT_KEYS`, MUST NEVER BE, AND ARE NOT WRITTEN
|
||||
# AT A ROSTER OF ONE AT ALL. At N=1 the site comparison and the unit comparison
|
||||
# are the same sentence and `Controller.cycle()` arms only the per-unit
|
||||
# detector, so a one-inverter install's state file is byte-identical to what it
|
||||
# was before this card - these two keys sit at their defaults and nothing reads
|
||||
# them. A rollback therefore finds exactly the flat `sign_tripped` mirror it
|
||||
# always found.
|
||||
DEFAULTS = {
|
||||
"phase": "idle",
|
||||
"phase_started": None,
|
||||
@@ -46,8 +96,194 @@ DEFAULTS = {
|
||||
"last_start_attempt": None,
|
||||
"sign_tripped": False,
|
||||
"sign_reason": "",
|
||||
"circ_tripped": False,
|
||||
"circ_reason": "",
|
||||
"site_sign_tripped": False,
|
||||
"site_sign_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"):
|
||||
|
||||
@@ -74,7 +74,8 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
was overruled. That firmware file's own comment says a lower bound on this
|
||||
"belongs in CFG-01, not here" - so here it is.
|
||||
|
||||
5. `sign_disagree_w` at or above `max_w`. [(a)]
|
||||
5. `sign_disagree_w` - or SAFETY-30's `unit_sign_disagree_w` - at or above
|
||||
`max_w`. [(a)]
|
||||
The arbiter's `supervised` limit bounds every command to +-max_w, and
|
||||
SAFETY-02's predicate needs `abs(command) > sign_disagree_w`. Set the
|
||||
threshold at or above the ceiling and the command side of the comparison
|
||||
@@ -150,6 +151,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 +197,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,10 +229,13 @@ 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
|
||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||
from .watchdog import SIGN_OPTION_DEFAULTS
|
||||
|
||||
_LOG = logging.getLogger("goodwe.config")
|
||||
|
||||
@@ -205,17 +258,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 +584,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 +593,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,20 +710,71 @@ 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.")
|
||||
|
||||
# ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A
|
||||
# SEPARATE COMPARISON AND THEREFORE A SEPARATE WAY TO DISARM. It grades a
|
||||
# unit's own SHARE against that unit's own measured power, and a share is
|
||||
# bounded by the same `supervised` claim at +-max_w - so a threshold at or
|
||||
# above the ceiling makes the command side unsatisfiable by construction,
|
||||
# exactly as above. Written as its own branch rather than folded into the
|
||||
# one above because the two produce different sentences and an operator
|
||||
# needs to be told WHICH watchdog they have disarmed.
|
||||
#
|
||||
# ⚠️ REFUSED EVEN AT A ROSTER OF ONE, WHERE THIS CHECK IS NOT ARMED. A
|
||||
# config that cannot work is refused when it is WRITTEN, not on the day a
|
||||
# second inverter is added - which is the day nobody is watching the startup
|
||||
# log. This is `meter_max_age_s`'s rule read the same way: the failure it
|
||||
# prevents is silent.
|
||||
unit_sign_w = _number(opts, "unit_sign_disagree_w",
|
||||
float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]))
|
||||
if unit_sign_w is None:
|
||||
problems.append(f"unit_sign_disagree_w "
|
||||
f"{opts.get('unit_sign_disagree_w')!r} is not a number")
|
||||
elif max_w is not None and max_w > 0 and unit_sign_w >= max_w:
|
||||
problems.append(
|
||||
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w is "
|
||||
f"{max_w:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
|
||||
"watchdog can never fire: no inverter is ever commanded past max_w, "
|
||||
f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two "
|
||||
"inverters that is the guard which catches a unit doing the "
|
||||
"OPPOSITE of what it was told while its sibling covers for it at "
|
||||
"the meter - the case the site-level watchdog is structurally "
|
||||
f"blind to. Lower unit_sign_disagree_w below {max_w:.0f} W "
|
||||
f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W is the "
|
||||
"measured default), or raise max_w.")
|
||||
|
||||
# --- 6. a maintenance charge the ceiling can actually accommodate --------
|
||||
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
|
||||
# `Claim.limit(hi=-charge_w)` against `supervised`'s `lo=-max_w`, so
|
||||
@@ -356,40 +786,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 +842,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 +889,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.
|
||||
@@ -162,11 +164,106 @@ saturation freeze's question (|command - the battery's actual power| > 500 W,
|
||||
NOTES.md §4.7 row 4), and the two checks are complementary rather than
|
||||
overlapping. Neither substitutes for the other.
|
||||
|
||||
⚠️ THAT PARAGRAPH IS ABOUT THE COMMAND-VERSUS-METER COMPARISON, AND SINCE
|
||||
SAFETY-30 THAT IS NOT THE ONLY ONE THIS MODULE DRIVES. At a roster of two or
|
||||
more each unit is ALSO graded against its own measured power, which is exactly
|
||||
the comparison the paragraph says nothing here makes - see the SAFETY-30 section
|
||||
below, which says what that does and does not take away from the freeze. At the
|
||||
shipped roster of one the paragraph stands unchanged, word for word: the site
|
||||
comparison is the only detector armed and the freeze is still the whole answer.
|
||||
|
||||
Nor does it see a sign inversion introduced DOWNSTREAM of this add-on - in the
|
||||
firmware, on the wire, or in a future driver. Our view stays self-consistent and
|
||||
this stays silent. That is a wire-versus-intent problem and it belongs to the
|
||||
witness, which is the only vantage point that sees both.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
SAFETY-30 - AT TWO INVERTERS THIS IS TWO QUESTIONS, NOT ONE
|
||||
|
||||
Everything above was written at a roster of ONE, and at N=1 it is right for a
|
||||
reason that stops being true at N=2. `commanded_w` is what THIS controller put
|
||||
on THIS actuator. `grid_w` is what the WHOLE SITE is exchanging with the
|
||||
network. At one inverter those are two views of one thing - §6.2's identity -
|
||||
which is what makes comparing them sound. At two they are not:
|
||||
|
||||
* A FALSE TRIP THE COMPARISON CANNOT AVOID. "Inverter A commanded -1500 W
|
||||
while the meter reads +1500 W" is evidence of nothing at N=2: inverter B may
|
||||
be discharging 3000 W and doing exactly what it was told. No fault anywhere,
|
||||
and the check NOTES.md calls the highest-value entry in the safety table -
|
||||
93 % agreement while healthy against 4 % during runaway, four episodes, zero
|
||||
false positives - fires.
|
||||
* THE CONVERSE, WHICH IS WORSE. A unit genuinely driving the wrong way is
|
||||
MASKED, because the site figure NETS. Commanded +1500 W, measured -1500 W,
|
||||
beside a sibling measured at +1500 W: the meter sits exactly where a healthy
|
||||
site leaves it. This is not a threshold that is set too high - the
|
||||
information is destroyed by the summation, and no tuning recovers it.
|
||||
|
||||
So the one comparison becomes two, each asked of the evidence that can answer
|
||||
it, and they are DIFFERENT FAULTS rather than one fault at two scales:
|
||||
|
||||
PER UNIT this unit's standing command against ITS OWN measured power -
|
||||
`Inverter.batt`, which on the live system is holding register
|
||||
0x0518, this inverter's own AC power, per-instance since DEV-02.
|
||||
Conclusion: "THE ACTUATOR I AM DRIVING IS NOT DOING WHAT I TOLD
|
||||
IT". The evidence is about one unit, so the remedy is that unit:
|
||||
the `failsafe` SET, which arbiter.CLAIM_SCOPE has called a UNIT
|
||||
claim from the day it was written, for this exact reason.
|
||||
SITE the SUM of the standing commands against the meter. Conclusion:
|
||||
"THE SITE AS A WHOLE IS DRIVING THE WRONG WAY" - an inverted gain,
|
||||
an integrator wound past the rail, a strategy pushing the wrong
|
||||
side of the meter. That is the fault the 2026-08-20 capture
|
||||
contains and it is a property of the CONNECTION, so the remedy is
|
||||
the connection: a SITE SET that stops every inverter.
|
||||
|
||||
⚠️ AND AT N=1 THERE IS EXACTLY ONE ARMED DETECTOR, WHICH IS THE ONE THAT
|
||||
SHIPPED. The two questions above are the SAME SENTENCE at a roster of one: the
|
||||
sum of one command is that command, and "the site" and "the unit" name one
|
||||
actuator. Arming both would be arming a duplicate - two latches, two banners and
|
||||
two chances to spend a false-positive record that was earned by ONE comparison
|
||||
on two captured bus days. `Controller.cycle()` therefore hands the single unit's
|
||||
watchdog THE METER, under the shipped `sign_disagree_w` / `sign_disagree_s`,
|
||||
exactly as before, and never arms the site one. `arm()` below is where that
|
||||
choice is written down. Nothing about a one-inverter install changes - not the
|
||||
comparison, not the thresholds, not the store keys, not the claim, not the
|
||||
banner.
|
||||
|
||||
⚠️ WHAT THE PER-UNIT COMPARISON IS NOT. Two guards that overlap without saying
|
||||
so is how a false-alarm generator gets built, so both neighbours are named:
|
||||
|
||||
* IT IS NOT SAFETY-31. `circulation.py` compares the units AGAINST EACH OTHER
|
||||
- `sum(|B_i|) - |sum(B_i)|` over MEASURED power - and catches two inverters
|
||||
pushing THROUGH each other, which nets to zero at the meter and about which
|
||||
no single command is evidence at all. This compares each unit against ITS
|
||||
OWN COMMAND. Take two units faithfully executing an allocation that ALLOC-01
|
||||
would refuse: circulating power is the whole loop, and every per-unit sign
|
||||
check here reads perfect agreement. Take one unit doing the exact opposite
|
||||
of its share while its sibling covers for it: this reads the whole
|
||||
disagreement, and circulating power can be zero, because the two measured
|
||||
powers may well agree in sign. Neither is the other with a different
|
||||
threshold.
|
||||
* IT IS NOT THE SATURATION FREEZE, AND THE OVERLAP IS REAL AND BOUNDED. The
|
||||
freeze asks `|command - measured| > saturation_w` (500 W) and concludes "the
|
||||
inverter cannot follow"; it then stops the integrator winding, which is a
|
||||
control action taken every day, on a unit that is merely SATURATED as
|
||||
readily as on one that is reversed, and it neither latches nor parks
|
||||
anything. This asks for OPPOSITION - a sign, on both sides of a threshold,
|
||||
held continuously - which a saturated-but-obedient unit cannot produce at
|
||||
all, and it LATCHES and commands that unit to zero. The freeze is a
|
||||
magnitude test that shapes the loop; this is a direction test that stops an
|
||||
actuator.
|
||||
|
||||
⚠️ GATE 1 STILL APPLIES TO BOTH, AND THAT IS A DELIBERATE CHOICE WITH A COST.
|
||||
The per-unit comparison could have been armed whenever ANY non-zero command is
|
||||
standing - a unit should do what it is told whoever told it - and that would
|
||||
catch a wedged inverter during a maintenance charge. It is not, because the
|
||||
whole no-false-positive argument above is built on `following`, because the
|
||||
freeze already grades that window, and because this card's own warning is that
|
||||
SAFETY-02's record was earned at N=1 and must not be spent on a check that fires
|
||||
when nothing is wrong. What it costs is written here rather than discovered:
|
||||
between the start of a maintenance cycle and its end, a unit reversing against
|
||||
its own command is the freeze's to notice, not this module's. Widening it is a
|
||||
card, with a rig campaign behind it, not an edit.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
STATE, TRIP AND LATCH
|
||||
|
||||
@@ -196,6 +293,178 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# SAFETY-30. CFG-03: THE ONE IN-CODE COPY OF THE FOUR MULTI-INVERTER DEFAULTS.
|
||||
# `config.yaml`'s `options:` block is the source of truth an operator edits and
|
||||
# the Supervisor reads, and `schema:` holds the bounds. This dict exists for the
|
||||
# reason `circulation.CIRC_OPTION_DEFAULTS` and `maintenance.MAINT_OPTION_DEFAULTS`
|
||||
# do - 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.
|
||||
#
|
||||
# ⚠️ EVERY KEY HERE IS DEAD AT A ROSTER OF ONE, and that is the point rather
|
||||
# than a caveat. `Controller._arm_sign_watchdogs()` arms the single unit's
|
||||
# detector against the meter with the SHIPPED `sign_disagree_w` /
|
||||
# `sign_disagree_s`, which are read in main.py exactly where they always were
|
||||
# and are not in this dict. Nothing below is consulted until a second inverter
|
||||
# exists.
|
||||
SIGN_OPTION_DEFAULTS = {
|
||||
# --- THE MAGNITUDE GATE, AND IT IS NOT SAFETY-02's 300 W BY ACCIDENT ---
|
||||
# ⚠️ DERIVED FROM `control.Tuning.saturation_w`, WHICH IS 500 W, AND THE
|
||||
# DERIVATION IS THE POINT. This check gates on BOTH sides being over
|
||||
# `min_w`, so the smallest divergence it can ever see between a command and
|
||||
# the power that command produced is `2 * min_w` - the two are OPPOSED, so
|
||||
# the gap is the sum of the magnitudes. At 250 W that is exactly 500 W: the
|
||||
# gap at which `compute()`'s own anti-windup test already concludes THE
|
||||
# INVERTER IS NOT FOLLOWING and freezes the integrator. So this watchdog
|
||||
# cannot fire on a divergence the shipped control law itself calls ordinary
|
||||
# tracking, and the two guards meet at a number rather than overlapping in a
|
||||
# band nobody has drawn.
|
||||
#
|
||||
# MEASURED, on 1890 closed-loop two-inverter runs in which NOTHING is faulty
|
||||
# (every unit executes the share the allocator gave it), swept over the
|
||||
# WHOLE range config.yaml accepts for `gain` float(0.05,1.0) and `slew_w`
|
||||
# int(50,5000), across both families of stimulus that can produce legitimate
|
||||
# opposition. Worst CONTINUOUS opposition between a unit's standing command
|
||||
# and its own 0x0518 power, as a function of this threshold:
|
||||
#
|
||||
# 50 W -> 25.0 s 250 W -> 10.0 s 700 W -> 5.0 s
|
||||
# 150 W -> 15.0 s 300 W -> 10.0 s 1000 W -> 0.0 s
|
||||
# 200 W -> 15.0 s 500 W -> 5.0 s
|
||||
#
|
||||
# ⚠️ AND THE CURVE IS FLAT, WHICH IS THE FINDING THAT DECIDED THE SHAPE OF
|
||||
# THIS PAIR. Unlike the site comparison - where the meter is driven by the
|
||||
# LOAD and a flapping load holds it opposed for minutes - a unit's own power
|
||||
# CHASES ITS OWN COMMAND through a bounded ramp and a first-order lag, so the
|
||||
# opposition can only last as long as the crossing does. Dropping the
|
||||
# threshold from 300 W to 50 W buys 15 s of extra transient and nothing else.
|
||||
# The work is therefore done by the WINDOW below, and this number is set
|
||||
# where the control law already draws its line rather than being tuned.
|
||||
#
|
||||
# The largest opposed magnitude seen anywhere in those 1890 runs is 875 W;
|
||||
# a threshold above that would gate NOTHING legitimate in at all, and is
|
||||
# rejected because it would also be blind to a unit reversing a 900 W share.
|
||||
"unit_sign_disagree_w": 250,
|
||||
|
||||
# --- THE WINDOW -------------------------------------------------------
|
||||
# Three bounds, and the value clears all three.
|
||||
#
|
||||
# LOWER, from the rig: 10.0 s - two control cycles, the worst CONTINUOUS
|
||||
# legitimate opposition above 250 W across all 1890 runs. It is a
|
||||
# reversing site whose second write lands a cycle after the first, both
|
||||
# units then slewing through zero behind a 10 s lag; nothing is wrong.
|
||||
#
|
||||
# LOWER, from the firmware: `inverter.LINK_STALE_S`, 30 s. A unit not
|
||||
# executing its 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, which is the SAFETY-15/SAFETY-19 failure exactly.
|
||||
#
|
||||
# UPPER, from SAFETY-02 itself: 180 s, the SITE hold. A unit that is not
|
||||
# doing what it was told must not be parked LATER than the site would be
|
||||
# parked for the same watts, and unlike the site comparison this one needs
|
||||
# nothing to net out before it can conclude - the evidence is complete on
|
||||
# the first cycle it appears.
|
||||
#
|
||||
# 85 s: seventeen control cycles - 8.5x the worst legitimate episode this
|
||||
# rig can produce, 2.8x the firmware's own veto window, and under HALF the
|
||||
# site hold. ⚠️ IT IS DELIBERATELY NOT 60, 75, 90, 120 OR 180. 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.
|
||||
#
|
||||
# ⚠️ AND 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 second unit lengthens every crossing above, which
|
||||
# is what the 8.5x buys headroom for. SPIKE-04 is the revisit trigger for
|
||||
# both numbers here, not a reason to move them now.
|
||||
"unit_sign_disagree_s": 85,
|
||||
|
||||
# --- THE SITE COMPARISON AT N>=2, AND IT IS NOT THE SHIPPED PAIR -------
|
||||
# ⚠️ THE THRESHOLD IS CARRIED AND THE WINDOW IS NOT, WHICH IS A FINDING
|
||||
# RATHER THAN A COMPROMISE. Only one of the two arguments behind 300 W /
|
||||
# 180 s depends on how many inverters the site has:
|
||||
#
|
||||
# THE THRESHOLD is about NOISE. "Near zero the sign is noise, and near
|
||||
# zero is where a healthy loop LIVES" - the deadband holds the command
|
||||
# still while the meter wanders either side of `target_grid_w`. The
|
||||
# deadband is 15 W and P1 is one meter whatever the roster, so nothing
|
||||
# about that argument moves. 300 W, carried - and the two-inverter
|
||||
# campaign says the carry is safe rather than merely convenient: in the
|
||||
# load-reversal family the site figure ever goes opposed AND over 300 W in
|
||||
# 994 of 1050 runs at two inverters and 955 of 1050 at one. The gate opens
|
||||
# about as often either way; what changed is how LONG it stays open, which
|
||||
# is the window's problem and not the threshold's.
|
||||
#
|
||||
# THE WINDOW is about a TRANSIENT, and the transient scales with the site.
|
||||
# The measured worst legitimate case is a load reversal that the loop
|
||||
# crosses zero slowly: the command has to travel the whole rail at
|
||||
# `slew_w` per cycle, and at N>=2 the rail is `Controller.site_max_w` - the
|
||||
# ROSTER SUM - while `slew_w` is unchanged. So the crossing takes about
|
||||
# twice as long at two inverters as at one, and that is exactly what the
|
||||
# rig measures.
|
||||
#
|
||||
# 1890 closed-loop runs in which nothing is faulty, the SAME campaign the
|
||||
# per-unit pair above is read off, run at BOTH rosters as its own control.
|
||||
# Worst CONTINUOUS opposition between the standing command(s) and the meter,
|
||||
# LOAD-REVERSAL family, at a 300 W threshold:
|
||||
#
|
||||
# N=1 (the shipped comparison) 110.0 s - inside 180 s, 1.6x
|
||||
# N=2 (the sum, same stimuli) 215.0 s - OVER 180 s
|
||||
#
|
||||
# ⚠️ SO CARRYING 180 s WOULD HAVE SHIPPED A FALSE TRIP, and it is a config a
|
||||
# schema-legal install can reach: `gain` 0.05 and `slew_w` 50 are both the
|
||||
# floor of what config.yaml accepts, and a +-2200 W load reversal is an oven
|
||||
# and a heat pump. That is the number this card exists to not assume.
|
||||
#
|
||||
# 325 s: 1.51x the measured worst, well inside the 900 s quarter that bills
|
||||
# the capacity tariff, and still inside every episode the fault day
|
||||
# contains - 2026-08-20's four clusters run 16, 30, 77 and 77 minutes and
|
||||
# its opposed runs reach 1750 s, so a 325 s window fires in all of them.
|
||||
# What it costs is stated rather than hidden: about two and a half extra
|
||||
# minutes of wrong-direction SITE power before the stop, bought to remove a
|
||||
# false trip that a legal configuration produces. ⚠️ DELIBERATELY NOT 300 OR
|
||||
# 360; the measurement is what it answers to.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT THE PER-UNIT WINDOW EITHER, IN THE OTHER DIRECTION. The
|
||||
# per-unit comparison is 85 s because a unit's own power CHASES ITS OWN
|
||||
# COMMAND and the transient is two cycles; the site comparison is 325 s
|
||||
# because the meter is driven by the LOAD and the transient is the loop
|
||||
# crossing zero. Same predicate, two instruments, two windows, both measured.
|
||||
"site_sign_disagree_w": 300,
|
||||
"site_sign_disagree_s": 325,
|
||||
}
|
||||
|
||||
# ⚠️ A LIMIT OF THE SITE COMPARISON THAT SAFETY-30 DID NOT INTRODUCE AND DOES
|
||||
# NOT FIX, RECORDED HERE BECAUSE THE CAMPAIGN FOUND IT AND SILENCE WOULD BE
|
||||
# WORSE. The rig's other stimulus family is a FLAPPING LOAD, and a square wave
|
||||
# whose half period is about one control cycle opposes the command on EVERY
|
||||
# cycle: the command chases the previous half-cycle's meter while the meter has
|
||||
# already reversed. Across the 840 flapping runs the worst continuous opposition
|
||||
# at a 300 W threshold is 980 s at two inverters - and 1000 s at ONE, which is
|
||||
# the whole point. The N=1 arm is the control, and it says this is a property of
|
||||
# comparing a lagged command against a meter, not a property of the second
|
||||
# inverter. No window inside the 900 s billing quarter survives it, and no
|
||||
# threshold up to 1000 W does either.
|
||||
#
|
||||
# It is not acted on here for three reasons, in order of weight: it is identical
|
||||
# at the roster this add-on actually ships, so acting on it would be changing a
|
||||
# live safety path on evidence that has nothing to do with this card; a
|
||||
# ~6 kW square wave at 0.1 Hz sustained for a quarter of an hour is not a house
|
||||
# (the captured healthy day's longest opposed run is 30 s); and the fix is not a
|
||||
# number - it wants the detector to notice that the disagreement is ALTERNATING,
|
||||
# which is a different check. It is a card, and this comment is its evidence.
|
||||
|
||||
# SAFETY-30. THE TWO NOUNS THE TRIP SENTENCE CAN NAME, WRITTEN ONCE. They are
|
||||
# constants rather than literals at the call sites because `main.py` arms the
|
||||
# detectors and `test_watchdog.py` grades what they say, and a string typed in
|
||||
# two places is a string that drifts in one of them - the same argument
|
||||
# `store.unit_key()` makes about a key name. `AGAINST_METER` is the wording that
|
||||
# has always shipped and must not change: it is what a one-inverter install's
|
||||
# `reason` reads, in the store, in the action log and on the status page.
|
||||
AGAINST_METER = "a meter reading"
|
||||
AGAINST_OWN_POWER = "its own measured power"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SignWatchdogConfig:
|
||||
"""Defaults, not constants - config.yaml carries both (SAFETY-02 AC 5)."""
|
||||
@@ -204,7 +473,7 @@ class SignWatchdogConfig:
|
||||
hold_s: float = 180.0
|
||||
|
||||
|
||||
def disagrees(commanded_w, grid_w, following: bool,
|
||||
def disagrees(commanded_w, against_w, following: bool,
|
||||
cfg: SignWatchdogConfig) -> bool:
|
||||
"""The predicate, and the ONLY place it is written down.
|
||||
|
||||
@@ -213,12 +482,23 @@ def disagrees(commanded_w, grid_w, following: bool,
|
||||
inline. Two copies of a safety predicate is one copy plus a future
|
||||
disagreement about what the check means, and the copy that drifts is always
|
||||
the one in the harness that grades it.
|
||||
|
||||
⚠️ `against_w` WAS CALLED `grid_w`, AND THE RENAME IS THE WHOLE OF SAFETY-30
|
||||
IN ONE PARAMETER. The arithmetic is unchanged - opposed signs, neither side
|
||||
near zero, and the loop actually driving - but WHAT the command is opposed
|
||||
TO is now a property of the caller rather than a fact about this function.
|
||||
The site comparison hands it the meter; each unit's own comparison hands it
|
||||
that unit's measured power (register 0x0518). One predicate, two callers,
|
||||
for `loop_is_following()`'s reason: a safety predicate written down twice is
|
||||
one copy plus a future disagreement about what the check means. The
|
||||
parameter is positional at every call site in both repositories, so the
|
||||
rename moves no caller.
|
||||
"""
|
||||
return (following
|
||||
and commanded_w is not None and grid_w is not None
|
||||
and commanded_w * grid_w < 0
|
||||
and commanded_w is not None and against_w is not None
|
||||
and commanded_w * against_w < 0
|
||||
and abs(commanded_w) > cfg.min_w
|
||||
and abs(grid_w) > cfg.min_w)
|
||||
and abs(against_w) > cfg.min_w)
|
||||
|
||||
|
||||
def loop_is_following(resolution, loop_asked) -> bool:
|
||||
@@ -270,7 +550,17 @@ def loop_is_following(resolution, loop_asked) -> bool:
|
||||
|
||||
|
||||
class SignWatchdog:
|
||||
"""One instance per actuator, evaluated once per control cycle.
|
||||
"""One instance per COMPARISON, evaluated once per control cycle.
|
||||
|
||||
⚠️ SINCE SAFETY-30 THAT IS NOT THE SAME SENTENCE AS "one per actuator",
|
||||
which is what this docstring used to say. There is one instance per
|
||||
inverter - `Inverter.sign_wd`, latched under that unit's own store keys -
|
||||
AND one for the site, `Controller.site_sign_wd`, latched under flat ones.
|
||||
The two ask different questions of different evidence and reach different
|
||||
remedies; `arm()` below is where an instance is told which of them it is.
|
||||
At a roster of one they would be the same question, so only the per-unit
|
||||
instance is ever armed there - see the SAFETY-30 section of the module
|
||||
docstring.
|
||||
|
||||
⚠️ NOT SHARED ACROSS CADENCES. `disagree_since` is a wall-clock start time,
|
||||
so driving one instance from two loops at two rates would have each one
|
||||
@@ -280,8 +570,14 @@ class SignWatchdog:
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: SignWatchdogConfig | None = None, *,
|
||||
tripped: bool = False, reason: str = ""):
|
||||
tripped: bool = False, reason: str = "",
|
||||
against: str = AGAINST_METER):
|
||||
self.cfg = cfg or SignWatchdogConfig()
|
||||
# SAFETY-30. WHAT THE COMMAND IS BEING COMPARED AGAINST, as the operator
|
||||
# reads it in the trip sentence. The default is the meter, which is what
|
||||
# every trip this add-on has ever taken was compared against and is what
|
||||
# keeps a one-inverter install's `reason` string byte-identical.
|
||||
self.against = str(against)
|
||||
self.disagree_since: datetime | None = None
|
||||
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED. A latch that lives only in
|
||||
# memory is not a latch: a container restart clears it, and the add-on
|
||||
@@ -290,8 +586,31 @@ class SignWatchdog:
|
||||
self.tripped = bool(tripped)
|
||||
self.reason = reason if self.tripped else ""
|
||||
|
||||
# -- which of SAFETY-30's two questions this instance is asking ----------
|
||||
def arm(self, cfg: SignWatchdogConfig, against: str) -> None:
|
||||
"""Set the thresholds and the subject of the comparison. Nothing else.
|
||||
|
||||
⚠️ IT IS CONFIGURATION, NOT STATE, AND IT DELIBERATELY DOES NOT TOUCH
|
||||
`disagree_since`, `tripped` OR `reason`. A detector that cleared its own
|
||||
window when it was told what it was comparing against would lose the
|
||||
episode it is in the middle of, every cycle, and would never accumulate
|
||||
a hold at all - the check would be permanently green and nothing would
|
||||
say so.
|
||||
|
||||
⚠️ AND IT IS RE-STATED EVERY CYCLE RATHER THAN FROZEN AT CONSTRUCTION.
|
||||
The answer is a property of the ROSTER and of nothing else, and
|
||||
`Controller.cycle()` already re-reads the roster every cycle for
|
||||
liveness and for the allocation. Freezing it in `__init__` would also
|
||||
make it wrong for any caller that appends an inverter afterwards, which
|
||||
is exactly how a two-inverter fixture is built in this repository - and
|
||||
a fixture that silently got the one-inverter comparison would grade
|
||||
nothing while looking green.
|
||||
"""
|
||||
self.cfg = cfg
|
||||
self.against = str(against)
|
||||
|
||||
# -- the whole check, and it is a pure function of its arguments ----------
|
||||
def evaluate(self, commanded_w, grid_w, following: bool,
|
||||
def evaluate(self, commanded_w, against_w, following: bool,
|
||||
now: datetime) -> bool:
|
||||
"""Advance the detector one cycle. Returns True while tripped.
|
||||
|
||||
@@ -299,15 +618,23 @@ class SignWatchdog:
|
||||
after deadband, slew, quantisation and the arbiter. Not
|
||||
the optimizer's raw wish: the wire carries the emitted
|
||||
value, so anything else can disagree with the meter for a
|
||||
reason that is not a fault.
|
||||
`grid_w` net P1, + = importing. None when the meter is stale or
|
||||
missing, which CLEARS the timer rather than accumulating
|
||||
- a frozen figure held against a moving command would
|
||||
otherwise manufacture a disagreement in about three
|
||||
minutes, on top of SAFETY-01 having already commanded
|
||||
zero for the same cause.
|
||||
reason that is not a fault. For a per-unit instance it is
|
||||
THAT UNIT'S share of it; for the site instance it is the
|
||||
roster sum.
|
||||
`against_w` what the command is graded against, and SAFETY-30 made
|
||||
this a decision rather than a fact: net P1 for the SITE
|
||||
comparison (+ = importing), and this unit's own measured
|
||||
power for the PER-UNIT one (+ = discharging, register
|
||||
0x0518). Both carry the same sign convention as the
|
||||
command, which is why one predicate serves both. None
|
||||
when the reading is stale or missing, which CLEARS the
|
||||
timer rather than accumulating - a frozen figure held
|
||||
against a moving command would otherwise manufacture a
|
||||
disagreement in about three minutes, on top of SAFETY-01
|
||||
having already commanded zero for the same cause.
|
||||
`following` did the grid-following loop actually win this cycle? See
|
||||
gate 1 in the module docstring.
|
||||
gate 1 in the module docstring, and the SAFETY-30 section
|
||||
on why the per-unit comparison keeps it.
|
||||
`now` the caller's clock, so this stays testable and replayable.
|
||||
"""
|
||||
if self.tripped:
|
||||
@@ -315,7 +642,7 @@ class SignWatchdog:
|
||||
return True
|
||||
|
||||
cfg = self.cfg
|
||||
if not disagrees(commanded_w, grid_w, following, cfg):
|
||||
if not disagrees(commanded_w, against_w, following, cfg):
|
||||
self.disagree_since = None
|
||||
return False
|
||||
|
||||
@@ -328,9 +655,17 @@ class SignWatchdog:
|
||||
return False
|
||||
|
||||
self.tripped = True
|
||||
# ⚠️ `self.against` IS THE ONLY THING SAFETY-30 CHANGED IN THIS STRING,
|
||||
# and it defaults to the wording that shipped - "a meter reading" - so a
|
||||
# one-inverter install's `reason` is byte-identical to what an operator
|
||||
# (and test_watchdog.py) has always read. A per-unit instance names what
|
||||
# it actually compared instead, because "commanded +1500 W against a
|
||||
# meter reading -1500 W" would be a false statement about which
|
||||
# instrument produced the evidence, on the one line somebody woken by
|
||||
# this alarm reads first.
|
||||
self.reason = (
|
||||
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against a meter "
|
||||
f"reading {grid_w:+.0f} W for {held:.0f} s continuously "
|
||||
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against "
|
||||
f"{self.against} {against_w:+.0f} W for {held:.0f} s continuously "
|
||||
f"(limits: both over {cfg.min_w:.0f} W, held over {cfg.hold_s:.0f} s)"
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -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
|
||||
@@ -114,6 +167,62 @@ options:
|
||||
sign_disagree_w: 300
|
||||
sign_disagree_s: 180
|
||||
|
||||
# --- SAFETY-30 per-inverter sign-disagreement watchdog ---------------------
|
||||
# ⚠️ A DIFFERENT COMPARISON, NOT A SECOND COPY OF THE ONE ABOVE, AND IT IS
|
||||
# ARMED ONLY WHEN THIS SITE HAS MORE THAN ONE INVERTER. The pair above grades
|
||||
# the SITE's total command against P1 - one meter, one grid connection. This
|
||||
# pair grades EACH inverter's own share against THAT inverter's own measured
|
||||
# power (holding register 0x0518), and it catches the fault the meter cannot
|
||||
# answer for once there are two units: a unit doing the opposite of what it
|
||||
# was told, while its sibling covers for it and P1 reads exactly what a
|
||||
# healthy site reads.
|
||||
#
|
||||
# At ONE inverter these two keys do nothing at all. §6.2's identity makes the
|
||||
# site's total command that inverter's command, so the pair above is already
|
||||
# asking this question of the same actuator, and arming a second detector
|
||||
# would be two latches and two banners for one fault.
|
||||
#
|
||||
# Both defaults are measured on admin/goodwe's two-master rig, over 1890
|
||||
# closed-loop runs in which nothing is faulty, swept across the whole range
|
||||
# this file accepts for `gain` and `slew_w`. 250 W is `saturation_w / 2`, so
|
||||
# the smallest divergence this can see is exactly the gap at which the control
|
||||
# law itself already says the inverter is not following; 85 s is 8.5x the
|
||||
# worst legitimate opposition those runs produce (10.0 s), 2.8x the firmware's
|
||||
# own 30 s link veto, and under half the 180 s the site check takes. See
|
||||
# app/watchdog.py's SIGN_OPTION_DEFAULTS and
|
||||
# DECISIONS/SAFETY-30-per-unit-sign.md.
|
||||
#
|
||||
# ⚠️ unit_sign_disagree_w must stay BELOW max_w, for sign_disagree_w's reason:
|
||||
# a share is bounded to +-max_w, so a threshold at or above it can never fire.
|
||||
# validate.py refuses that config.
|
||||
unit_sign_disagree_w: 250
|
||||
unit_sign_disagree_s: 85
|
||||
|
||||
# --- SAFETY-30 SITE sign-disagreement watchdog -----------------------------
|
||||
# ⚠️ THE SAME COMPARISON `sign_disagree_*` MAKES, AT THE ROSTER WHERE IT STOPS
|
||||
# BEING A STATEMENT ABOUT ONE INVERTER. At two or more units the SUM of the
|
||||
# standing commands is graded against P1, and the trip stops the whole site -
|
||||
# what it catches is the site being asked for the wrong thing, which every
|
||||
# per-inverter check is blind to because each unit is faithfully executing its
|
||||
# share. At ONE inverter these two keys do nothing: `sign_disagree_*` above is
|
||||
# already asking this of the same actuator.
|
||||
#
|
||||
# The threshold is the shipped 300 W, CARRIED, and the reason is that the
|
||||
# argument behind it does not depend on the roster: near zero the sign is
|
||||
# noise, the deadband is 15 W, and there is one P1 whatever is behind it.
|
||||
#
|
||||
# ⚠️ THE WINDOW IS NOT CARRIED, AND THAT IS A MEASUREMENT. The transient 180 s
|
||||
# exists to reject is the loop crossing zero after a load reversal, and the
|
||||
# command has to travel the whole SITE rail at `slew_w` per cycle - a rail
|
||||
# that is the roster sum while `slew_w` is not. On 1890 two-inverter runs in
|
||||
# which nothing is faulty, the worst continuous legitimate opposition at 300 W
|
||||
# is 110 s at one inverter and 215 s at two: carrying 180 s across would have
|
||||
# shipped a false trip reachable at `gain: 0.05`, `slew_w: 50` and an ordinary
|
||||
# 2.2 kW load reversal. 325 s is 1.51x that, inside the 900 s billing quarter,
|
||||
# and still inside every episode of the 2026-08-20 fault day.
|
||||
site_sign_disagree_w: 300
|
||||
site_sign_disagree_s: 325
|
||||
|
||||
# --- SAFETY-18 frozen-but-fresh meter detector -----------------------------
|
||||
# Seconds the meter may keep answering with an UNCHANGED measurement while the
|
||||
# command stands at the rail before the add-on raises an alarm. Both halves
|
||||
@@ -134,7 +243,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 +329,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 +478,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
|
||||
@@ -354,6 +543,33 @@ schema:
|
||||
sign_disagree_w: int(50,5000)
|
||||
sign_disagree_s: int(30,1800)
|
||||
|
||||
# SAFETY-30. No "off" value, for SAFETY-02's reason.
|
||||
#
|
||||
# ⚠️ THE THRESHOLD'S RANGE IS THE SAME AS THE SITE CHECK'S ON PURPOSE, AND THE
|
||||
# WINDOW'S FLOOR IS NOT. On the two-inverter rig the per-unit comparison's
|
||||
# worst continuous legitimate opposition is nearly FLAT in the threshold -
|
||||
# 25.0 s at 50 W, 15.0 s at 150 W, 10.0 s at 250 W, 5.0 s at 500 W - because a
|
||||
# unit's own power chases its own command rather than the load. So no value in
|
||||
# this range puts the guard outside the regime it can be answered for, and the
|
||||
# floor does not have to be a knee the way `circulation_w`'s does.
|
||||
unit_sign_disagree_w: int(50,5000)
|
||||
# The floor of 30 is `inverter.LINK_STALE_S`, the firmware's own veto window:
|
||||
# below it, a unit not executing its share is a LINK fault the add-on already
|
||||
# answers by dropping the unit from the split, and firing inside that window
|
||||
# would be a second, louder alarm about a state that is already being handled.
|
||||
unit_sign_disagree_s: int(30,1800)
|
||||
|
||||
# SAFETY-30's SITE pair. Same ranges as `sign_disagree_*`, because it is the
|
||||
# same comparison asked of the site total - and no "off" value, for the same
|
||||
# reason. ⚠️ THERE IS DELIBERATELY NO validate.py RULE PAIRING
|
||||
# site_sign_disagree_w WITH max_w, unlike the other two thresholds: the bound
|
||||
# on the SITE command is `Controller.site_max_w`, the ROSTER SUM, and
|
||||
# `validate_startup_config()` is handed options rather than a roster. A rule
|
||||
# written against max_w would refuse a perfectly reachable two-inverter
|
||||
# configuration the day CFG-04 lands. The schema range is the bound here.
|
||||
site_sign_disagree_w: int(50,5000)
|
||||
site_sign_disagree_s: int(30,1800)
|
||||
|
||||
# No "off" value, for SAFETY-02's reason: disarming a safety alarm is not a
|
||||
# supported configuration. ⚠️ THE FLOOR OF 60 IS NOT A RECOMMENDATION. At 300 s
|
||||
# the slowest a HEALTHY meter's 1 Wh register can tick and still look unchanged
|
||||
@@ -363,6 +579,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)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# 30 days of P1 net power — 2026-08-01 … 2026-08-30
|
||||
|
||||
`sensor.p1_meter_active_power` out of Home Assistant's recorder, one gzipped
|
||||
JSONL file per **local** day (Europe/Brussels), same schema as the two files one
|
||||
directory up:
|
||||
|
||||
{"t": "2026-08-22T22:00:00+00:00", "w": -25.0}
|
||||
|
||||
**485,841 samples, 30/30 days read, 0 days failed, 4.13 MB gzipped.**
|
||||
Produced by `tools/export_p1_history.py`; `manifest.json` here carries the
|
||||
per-day counts, sha256 of every file, and every gap.
|
||||
|
||||
python3 tools/export_p1_history.py --days 30 --end-date 2026-08-30 \
|
||||
--out-dir scenarios/p1-net-30d-2026-08
|
||||
|
||||
## Why this exists
|
||||
|
||||
[[TARIFF-01]] AC 3 and [[SAFETY-07]] AC 3 both ask for *"monthly peak held under
|
||||
the soft limit in >= 95 % of intervals over a 30-day simulation replay"*, and
|
||||
both were merged with the criterion honestly unmet because only two days of
|
||||
capture existed. `test_tariff.py`'s docstring still recorded the reason as
|
||||
"nothing longer was ever captured" — which was true about the past and was
|
||||
quietly read as a statement about the future. It was not one. Probed on
|
||||
2026-08-31, HA's recorder answered a 20-minute window with 230 samples at 30
|
||||
days back and 0 at 45, so the whole month was still there and was exported the
|
||||
same day. **The window rolls: 2026-08-01 leaves the recorder around 2026-09-15.**
|
||||
|
||||
## The size decision — COMMITTED, deliberately, at 4.13 MB
|
||||
|
||||
Three options were on the table. This one was taken and the cost is stated
|
||||
rather than absorbed, and it is measured rather than estimated - a
|
||||
`git bundle` of the whole history before and after this commit:
|
||||
|
||||
release/1.0 @ 86c43eb 1.58 MB
|
||||
DATA-01 5.28 MB +3.70 MB, x3.3
|
||||
|
||||
A full clone of this repository therefore more than triples, and a Home
|
||||
Assistant supervisor clones it on every add-on install and update. That is the
|
||||
price; the paragraphs below are why it was judged worth paying rather than
|
||||
avoided by making the data worse or making it disappear.
|
||||
|
||||
* **Downsampling is not available.** It is not a matter of taste: the shipped
|
||||
`meter_max_age_s` is **30 s**, which is the age past which the controller's
|
||||
own ingest stops counting a held reading as coverage. Any resample coarser
|
||||
than 30 s therefore converts real coverage into modelled meter-gap and changes
|
||||
the very number AC 3 measures. Resampling at or under 30 s saves almost
|
||||
nothing against a 5 s cadence.
|
||||
* **"Outside the repo with a pointer" is what already happened, and it is why
|
||||
this card exists.** The two committed days came from the infra repo's private
|
||||
`sim/scenarios/`, the one-off export script was thrown away, and three cards
|
||||
then deferred an acceptance criterion on the belief that the data could not be
|
||||
produced again. `test_tariff.py` states the general rule verbatim: *a
|
||||
regression fixture CI cannot see is not a regression fixture.* CI's token is
|
||||
minted for this repository only.
|
||||
* **So: committed** — but in a SUBDIRECTORY, which is load-bearing. Both
|
||||
`test_tariff.py` part 9 and `test_peakguard.py` part 6 do
|
||||
`(HERE / "scenarios").glob("p1-net-*.jsonl.gz")`, which does not recurse. Drop
|
||||
these thirty files beside the two and CI grows sixty full-day
|
||||
replays - about 50 s of pure compute - on every PR forever, `test_tariff.py`'s
|
||||
`check(... replay_days < 30)` starts failing, and both files' honesty notes
|
||||
become false. In `p1-net-30d-2026-08/` they are visible to CI, invisible to
|
||||
the globs, and the two committed days keep their job as the small fixtures the
|
||||
suites grade against.
|
||||
|
||||
They are also written once and never edited, so git stores one already-deflated
|
||||
blob per day with no delta churn.
|
||||
|
||||
## Gaps are visible, never interpolated
|
||||
|
||||
Nothing is filled, carried forward or resampled. A hole in the recorder is a
|
||||
hole here — consecutive `t` values further apart than the cadence — which is
|
||||
exactly what the controller's freshness rule already reads as *no coverage*, so
|
||||
a missing hour replays as a missing hour rather than as an hour of whatever the
|
||||
last sample said.
|
||||
|
||||
**13 gaps over 30 s, 1291 s in total: 99.950 % of the month is covered.**
|
||||
All of them are listed in `manifest.json` and by
|
||||
`python3 tools/replay_ac3.py <dir> --report-gaps`. The longest is 263 s on
|
||||
2026-08-11.
|
||||
|
||||
Three samples in the month carried the state `unavailable` rather than a number
|
||||
(2026-08-18 02:03:54Z and 04:10:41Z, 2026-08-25 01:52:02Z). There is no float to
|
||||
write and the committed schema has no slot for a non-number, so they are absent
|
||||
from the series and recorded individually in `manifest.json` under
|
||||
`non_numeric_states`. Each one sits inside a gap that is already listed, which
|
||||
is the honest rendering: the meter was not reporting a power then.
|
||||
|
||||
**A failed read is never an empty hour.** If a day's read fails, the exporter
|
||||
writes NO FILE, records `"read": "FAILED"` with the error, and exits non-zero —
|
||||
so a missing day is unreadable-and-known rather than silently zero. The entity
|
||||
is also proved to exist through `/api/states` before any day is trusted, because
|
||||
a typo would otherwise answer `200 []` thirty times and produce a beautifully
|
||||
consistent dataset of nothing. On this run: 30 read, 0 failed, 0 empty.
|
||||
|
||||
## AC 3, measured
|
||||
|
||||
cd goodwe_controller
|
||||
python3 tools/replay_ac3.py scenarios/p1-net-30d-2026-08
|
||||
|
||||
485841 samples, 2879 closed quarters (30.0 days' worth)
|
||||
month 2026-08: peak 5029.8 W uncontrolled -> 4107.3 W guarded
|
||||
|
||||
TARIFF-01 AC 3 (UNCONTROLLED): 2847/2879 = 98.89 % of quarters at or under 3500 W
|
||||
SAFETY-07 AC 3 (GUARDED): 2850/2879 = 98.99 %
|
||||
guard cycles 143810, engaged on the measurement 1542, blind 195,
|
||||
saturated 0, modelled discharge 8060 Wh
|
||||
|
||||
2879 rather than 2880 quarters: the last quarter of 2026-08-30 closes only when
|
||||
a telegram past its boundary arrives, and that telegram is 2026-08-31's.
|
||||
|
||||
### What the numbers actually say — three findings, not a pass
|
||||
|
||||
1. **AC 3 is met on the ">= 95 % of intervals" reading, and the criterion turns
|
||||
out to be nearly insensitive.** The UNCONTROLLED month — no controller, no
|
||||
peak shaving, nothing running — already scores 98.89 %. A criterion that a
|
||||
house passes by doing nothing does not discriminate between a working guard
|
||||
and an absent one. That is a finding about the acceptance criterion, and it
|
||||
is reported rather than filed as a win.
|
||||
2. **On the other reading of the same sentence, AC 3 is NOT met.** "Monthly peak
|
||||
held under the soft limit" taken as *the peak itself* fails: the guard pulls
|
||||
August's peak from **5029.8 W to 4107.3 W** (-922.5 W, -18.3 %), which is
|
||||
real money on a Belgian capacity tariff, but 4107.3 W is still over the
|
||||
3500 W soft limit. Both readings are printed by the tool; neither is hidden.
|
||||
3. **The guard stops defending the soft limit once the month's peak is set, and
|
||||
that is by design.** `quarter_ceiling_w` uses `CEILING_MONTH_PEAK`: the
|
||||
billed quantity is the month's maximum, so a quarter under an
|
||||
already-recorded peak costs nothing extra and the guard does not spend the
|
||||
battery on it. The month's 32 over-cap quarters fall on four nights
|
||||
(2026-08-17, -18, -20, -21); **31 of them are pulled down** — 7494 W of
|
||||
quarter-average removed in total, 8060 Wh of modelled discharge — but only
|
||||
**3 land under 3500 W**, because by then the ceiling being defended is the
|
||||
running month peak (~4107 W), not the soft limit. The single untouched
|
||||
quarter, 2026-08-18T08:15Z at 4039.7 W, is the same rule seen from the other
|
||||
side: the running peak was already 4833 W, so 4039.7 W added nothing to the
|
||||
bill and the guard correctly stayed out. AC 3's wording asks the guard for
|
||||
something its own tariff logic deliberately does not optimise.
|
||||
|
||||
Also worth an eye: **195 blind engagements** over 143,810 cycles — the guard
|
||||
acting on a projection it could not fully cover. On the two committed days that
|
||||
count was 5. It scales with the gaps above.
|
||||
|
||||
## Verification of the exporter
|
||||
|
||||
`tools/export_p1_history.py --verify` re-fetches a committed day by the exact
|
||||
route used here and diffs it against the file:
|
||||
|
||||
python3 tools/export_p1_history.py \
|
||||
--verify scenarios/p1-net-2026-08-20.jsonl.gz \
|
||||
scenarios/p1-net-2026-08-23.jsonl.gz
|
||||
|
||||
Both days come back **byte-identical after decompression** — 865,024 B /
|
||||
15,600 samples and 894,387 B / 16,351 samples — and the copies of those two days
|
||||
inside this directory are byte-identical to the committed ones too. The gzip
|
||||
CONTAINERS differ by design: a gzip header carries an mtime and a stored
|
||||
filename, and this exporter writes mtime 0 so its own output is reproducible,
|
||||
where the 2026-08-25 one-off wrote a wall-clock mtime. Content equality is the
|
||||
claim; container equality would only assert that two files were written at the
|
||||
same second.
|
||||
|
||||
`tools/replay_ac3.py` is checked the same way rather than trusted: pointed at
|
||||
`scenarios/` it must reproduce `test_peakguard.py` part 6's published totals,
|
||||
and `--expect` makes it a hard check.
|
||||
|
||||
python3 tools/replay_ac3.py scenarios --expect 189/190
|
||||
|
||||
reproduces 189/190 quarters, the 5029.8 W billed quarter, the 3485.3 W guarded
|
||||
result and the 878 Wh of modelled discharge that part 6 prints.
|
||||
@@ -0,0 +1,767 @@
|
||||
{
|
||||
"generated": "2026-08-31T08:42:40.614548+00:00",
|
||||
"generator": "tools/export_p1_history.py",
|
||||
"source": "http://192.168.2.158:8123/api/history/period",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"timezone": "Europe/Brussels",
|
||||
"gap_threshold_s": 30.0,
|
||||
"format": "{\"t\": <ISO-8601 UTC>, \"w\": <float watts>} per line, gzip",
|
||||
"days": [
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
"window": [
|
||||
"2026-08-01T00:00:00+02:00",
|
||||
"2026-08-02T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1128060,
|
||||
"attempts": 1,
|
||||
"samples": 16511,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-07-31T22:00:00+00:00",
|
||||
"last": "2026-08-01T21:59:56.268499+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86396.3,
|
||||
"mean_cadence_s": 5.233,
|
||||
"file": "p1-net-2026-08-01.jsonl.gz",
|
||||
"bytes_gz": 140531,
|
||||
"bytes_raw": 913203,
|
||||
"sha256_raw": "120de2a0b7eb552281dac4bc1f8fd76d947ba72d1f5b426d9dcdd85ec103bd06",
|
||||
"sha256_gz": "e09bdee349dd3668a073b7fd77974e639c269ec37e73e81a9706cadde083f064"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-02",
|
||||
"window": [
|
||||
"2026-08-02T00:00:00+02:00",
|
||||
"2026-08-03T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1115387,
|
||||
"attempts": 1,
|
||||
"samples": 16303,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-01T22:00:00+00:00",
|
||||
"last": "2026-08-02T21:59:55.191360+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86395.2,
|
||||
"mean_cadence_s": 5.3,
|
||||
"file": "p1-net-2026-08-02.jsonl.gz",
|
||||
"bytes_gz": 141404,
|
||||
"bytes_raw": 903234,
|
||||
"sha256_raw": "9ab4891c0abf9d621fa528848fc3a874687843804768e8aeab6a88f7bb04b679",
|
||||
"sha256_gz": "94774139b3e19d04bcda3ac5ea988dd4d645e99004f90b917c6b0bf3fbac21cb"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-03",
|
||||
"window": [
|
||||
"2026-08-03T00:00:00+02:00",
|
||||
"2026-08-04T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1123668,
|
||||
"attempts": 1,
|
||||
"samples": 16424,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-02T22:00:00+00:00",
|
||||
"last": "2026-08-03T21:59:55.255808+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86395.3,
|
||||
"mean_cadence_s": 5.261,
|
||||
"file": "p1-net-2026-08-03.jsonl.gz",
|
||||
"bytes_gz": 144202,
|
||||
"bytes_raw": 909942,
|
||||
"sha256_raw": "13f8dd904a31b5b67fcfde04f6afc49c34c3a9504617278d79d87516fcd6441f",
|
||||
"sha256_gz": "e1bc0cb0e271cbc8d896aee97aa0f5c18e49e78905d1aaf137904a768047c7bc"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-04",
|
||||
"window": [
|
||||
"2026-08-04T00:00:00+02:00",
|
||||
"2026-08-05T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1108582,
|
||||
"attempts": 1,
|
||||
"samples": 16286,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-03T22:00:00+00:00",
|
||||
"last": "2026-08-04T21:59:58.729138+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.7,
|
||||
"mean_cadence_s": 5.305,
|
||||
"file": "p1-net-2026-08-04.jsonl.gz",
|
||||
"bytes_gz": 136505,
|
||||
"bytes_raw": 896650,
|
||||
"sha256_raw": "a65cd9d1ceda61e5a40043edb401c2154feea2b36bd3221710df808d778eabce",
|
||||
"sha256_gz": "c19b19ac890f4caad4140cb27d2fe6f3e972545f664127e5d9f457e711ad8c88"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-05",
|
||||
"window": [
|
||||
"2026-08-05T00:00:00+02:00",
|
||||
"2026-08-06T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1136556,
|
||||
"attempts": 1,
|
||||
"samples": 16655,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-04T22:00:00+00:00",
|
||||
"last": "2026-08-05T21:59:56.189152+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86396.2,
|
||||
"mean_cadence_s": 5.188,
|
||||
"file": "p1-net-2026-08-05.jsonl.gz",
|
||||
"bytes_gz": 142561,
|
||||
"bytes_raw": 919827,
|
||||
"sha256_raw": "dd327685c6f322bfaa49bc5bf228301e790e5a96fd7c23c487b845e44a962db4",
|
||||
"sha256_gz": "5ff7a1336c49ba38b8058820181dd8ed9c2b8db41b5a3a52412a5a3d8c515332"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-06",
|
||||
"window": [
|
||||
"2026-08-06T00:00:00+02:00",
|
||||
"2026-08-07T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1124419,
|
||||
"attempts": 1,
|
||||
"samples": 16412,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-05T22:00:00+00:00",
|
||||
"last": "2026-08-06T21:59:57.192711+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86397.2,
|
||||
"mean_cadence_s": 5.265,
|
||||
"file": "p1-net-2026-08-06.jsonl.gz",
|
||||
"bytes_gz": 142313,
|
||||
"bytes_raw": 910849,
|
||||
"sha256_raw": "7cd1fe8315e24dc6391693c49d6306b0c0cf9b6c3a478e457da921001cea930c",
|
||||
"sha256_gz": "9108f169d645b9da07d4ae310666a1365ea07275ff311cd60adad1b702edbf11"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-07",
|
||||
"window": [
|
||||
"2026-08-07T00:00:00+02:00",
|
||||
"2026-08-08T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1087531,
|
||||
"attempts": 1,
|
||||
"samples": 15952,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-06T22:00:00+00:00",
|
||||
"last": "2026-08-07T21:59:58.195053+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.2,
|
||||
"mean_cadence_s": 5.416,
|
||||
"file": "p1-net-2026-08-07.jsonl.gz",
|
||||
"bytes_gz": 135979,
|
||||
"bytes_raw": 879941,
|
||||
"sha256_raw": "e0f67a9214980e2fd4893208cb402165065dadce2105ce1581c6b1d49952f39a",
|
||||
"sha256_gz": "b70b84f3743d1d84055c911bcd5589cb8f0371e8916bde741308c5bb50702c29"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-08",
|
||||
"window": [
|
||||
"2026-08-08T00:00:00+02:00",
|
||||
"2026-08-09T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1058569,
|
||||
"attempts": 1,
|
||||
"samples": 15468,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-07T22:00:00+00:00",
|
||||
"last": "2026-08-08T21:59:52.805887+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86392.8,
|
||||
"mean_cadence_s": 5.586,
|
||||
"file": "p1-net-2026-08-08.jsonl.gz",
|
||||
"bytes_gz": 134025,
|
||||
"bytes_raw": 857271,
|
||||
"sha256_raw": "0be14ca7593b4c7d68570115cc26e24f6a26ab87fe3c168a8b942b167e8d4f68",
|
||||
"sha256_gz": "d93dcc8ca3eae03af13b9ae36acd5ab1eaa1e7ee93c237d386e8ded1c61f5906"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
"window": [
|
||||
"2026-08-09T00:00:00+02:00",
|
||||
"2026-08-10T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1059349,
|
||||
"attempts": 1,
|
||||
"samples": 15508,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-08T22:00:00+00:00",
|
||||
"last": "2026-08-09T21:59:58.178998+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.2,
|
||||
"mean_cadence_s": 5.572,
|
||||
"file": "p1-net-2026-08-09.jsonl.gz",
|
||||
"bytes_gz": 135697,
|
||||
"bytes_raw": 857531,
|
||||
"sha256_raw": "f4793799173c502fcee5f78e2d9b0dcc984b5d2d8bd2fc0db0cd0f8d403733b3",
|
||||
"sha256_gz": "2fea6c5c8b1452d72ad5aa3b0ae9755bb2ea08071514c08b1a3b1155ef457cf1"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"window": [
|
||||
"2026-08-10T00:00:00+02:00",
|
||||
"2026-08-11T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1066721,
|
||||
"attempts": 1,
|
||||
"samples": 15594,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-09T22:00:00+00:00",
|
||||
"last": "2026-08-10T21:59:59.170056+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86399.2,
|
||||
"mean_cadence_s": 5.541,
|
||||
"file": "p1-net-2026-08-10.jsonl.gz",
|
||||
"bytes_gz": 136682,
|
||||
"bytes_raw": 863785,
|
||||
"sha256_raw": "ff5c3fa419505127a51acc56dfc13e8437e4ec2c13a11c8d8b2d6c3e20e2410e",
|
||||
"sha256_gz": "4fd30e495f4e6d1c8501d05c23f9891ac34364d86a9c55ac825d52b2ebb31023"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-11",
|
||||
"window": [
|
||||
"2026-08-11T00:00:00+02:00",
|
||||
"2026-08-12T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1056281,
|
||||
"attempts": 1,
|
||||
"samples": 15371,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-10T22:00:00+00:00",
|
||||
"last": "2026-08-11T21:59:58.608150+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-11T08:00:14.121653+00:00",
|
||||
"to": "2026-08-11T08:02:54.427007+00:00",
|
||||
"seconds": 160.305
|
||||
},
|
||||
{
|
||||
"from": "2026-08-11T17:04:18.517592+00:00",
|
||||
"to": "2026-08-11T17:08:41.759777+00:00",
|
||||
"seconds": 263.242
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86398.6,
|
||||
"mean_cadence_s": 5.621,
|
||||
"file": "p1-net-2026-08-11.jsonl.gz",
|
||||
"bytes_gz": 133711,
|
||||
"bytes_raw": 856244,
|
||||
"sha256_raw": "67c2a740019f8908405b96b3436cd953af27baead2957acc7c1dcf10b37ac94e",
|
||||
"sha256_gz": "4f8c553edc9051644bd7baa659f6f92a8b5f5ce12a0410c8b5235d1e6c3084c9"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-12",
|
||||
"window": [
|
||||
"2026-08-12T00:00:00+02:00",
|
||||
"2026-08-13T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1124945,
|
||||
"attempts": 1,
|
||||
"samples": 16432,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-11T22:00:00+00:00",
|
||||
"last": "2026-08-12T21:59:58.648966+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.6,
|
||||
"mean_cadence_s": 5.258,
|
||||
"file": "p1-net-2026-08-12.jsonl.gz",
|
||||
"bytes_gz": 136903,
|
||||
"bytes_raw": 911115,
|
||||
"sha256_raw": "73c8a1626640226ddffb32d564557d1cbe57a15367ea1ba3632934e15a1413dc",
|
||||
"sha256_gz": "acec87065b7de2c605db00504ec5a9b3c99584620dfb3b67194e2cf9464d6a5f"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"window": [
|
||||
"2026-08-13T00:00:00+02:00",
|
||||
"2026-08-14T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1126742,
|
||||
"attempts": 1,
|
||||
"samples": 16473,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-12T22:00:00+00:00",
|
||||
"last": "2026-08-13T21:59:57.562043+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86397.6,
|
||||
"mean_cadence_s": 5.245,
|
||||
"file": "p1-net-2026-08-13.jsonl.gz",
|
||||
"bytes_gz": 136221,
|
||||
"bytes_raw": 912379,
|
||||
"sha256_raw": "1d9a16c529174bd8c74625a742c46d98de4acf7a045f83df8bdbac22fa86e8e7",
|
||||
"sha256_gz": "adfef36f629decf54c625f2dbfe6b4dab9441218c42557c90ac7f6d331943a59"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"window": [
|
||||
"2026-08-14T00:00:00+02:00",
|
||||
"2026-08-15T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1124413,
|
||||
"attempts": 1,
|
||||
"samples": 16427,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-13T22:00:00+00:00",
|
||||
"last": "2026-08-14T21:59:55.611487+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86395.6,
|
||||
"mean_cadence_s": 5.26,
|
||||
"file": "p1-net-2026-08-14.jsonl.gz",
|
||||
"bytes_gz": 135570,
|
||||
"bytes_raw": 910648,
|
||||
"sha256_raw": "dc1417881e54fe215e9fcd4ed643d2482a277d61f8f27f25e2ff1e2cba29f7a8",
|
||||
"sha256_gz": "d4eff781cd16a83fd1be9e643fee5bb5d5897e7d45bb9865972a7304d92c84a4"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-15",
|
||||
"window": [
|
||||
"2026-08-15T00:00:00+02:00",
|
||||
"2026-08-16T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1124461,
|
||||
"attempts": 1,
|
||||
"samples": 16516,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-14T22:00:00+00:00",
|
||||
"last": "2026-08-15T21:59:56.581991+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86396.6,
|
||||
"mean_cadence_s": 5.231,
|
||||
"file": "p1-net-2026-08-15.jsonl.gz",
|
||||
"bytes_gz": 137670,
|
||||
"bytes_raw": 909539,
|
||||
"sha256_raw": "f07a242faed68313512e0363d914846522ff839315ad80b55689072ac74e8a69",
|
||||
"sha256_gz": "082314c6003c2208752acc4614b9809f06735d01e216b935ea94faf578c5598e"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-16",
|
||||
"window": [
|
||||
"2026-08-16T00:00:00+02:00",
|
||||
"2026-08-17T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1127871,
|
||||
"attempts": 1,
|
||||
"samples": 16552,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-15T22:00:00+00:00",
|
||||
"last": "2026-08-16T21:59:59.527710+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86399.5,
|
||||
"mean_cadence_s": 5.22,
|
||||
"file": "p1-net-2026-08-16.jsonl.gz",
|
||||
"bytes_gz": 138347,
|
||||
"bytes_raw": 912481,
|
||||
"sha256_raw": "a44e5d961600a539ddf824d5d211ef319833666b7598e5939a6cdf2cb1e3581f",
|
||||
"sha256_gz": "8f6511e52f5be9187f422021abc93a5fa8b698b4c785877d69d15dd6de106fd8"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-17",
|
||||
"window": [
|
||||
"2026-08-17T00:00:00+02:00",
|
||||
"2026-08-18T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1133721,
|
||||
"attempts": 1,
|
||||
"samples": 16627,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-16T22:00:00+00:00",
|
||||
"last": "2026-08-17T21:59:59.685699+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-17T17:27:00.599126+00:00",
|
||||
"to": "2026-08-17T17:28:20.389628+00:00",
|
||||
"seconds": 79.791
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86399.7,
|
||||
"mean_cadence_s": 5.197,
|
||||
"file": "p1-net-2026-08-17.jsonl.gz",
|
||||
"bytes_gz": 140214,
|
||||
"bytes_raw": 917356,
|
||||
"sha256_raw": "70cce79b3732f292f7f58f0b2655bdb4a00a20a6530f1f50dac62ef5f039788b",
|
||||
"sha256_gz": "e2f9b461c4db7902e1a3d30b439ebc11ebcd9618a72d12f10e5131f38fb437bf"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-18",
|
||||
"window": [
|
||||
"2026-08-18T00:00:00+02:00",
|
||||
"2026-08-19T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1074019,
|
||||
"attempts": 1,
|
||||
"samples": 15699,
|
||||
"non_numeric_states": [
|
||||
{
|
||||
"t": "2026-08-18T02:03:54.417106+00:00",
|
||||
"state": "unavailable"
|
||||
},
|
||||
{
|
||||
"t": "2026-08-18T04:10:41.786299+00:00",
|
||||
"state": "unavailable"
|
||||
}
|
||||
],
|
||||
"first": "2026-08-17T22:00:00+00:00",
|
||||
"last": "2026-08-18T21:59:54.501766+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-18T02:02:59.633845+00:00",
|
||||
"to": "2026-08-18T02:04:38.694366+00:00",
|
||||
"seconds": 99.061
|
||||
},
|
||||
{
|
||||
"from": "2026-08-18T04:09:54.292049+00:00",
|
||||
"to": "2026-08-18T04:11:38.500238+00:00",
|
||||
"seconds": 104.208
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86394.5,
|
||||
"mean_cadence_s": 5.504,
|
||||
"file": "p1-net-2026-08-18.jsonl.gz",
|
||||
"bytes_gz": 135894,
|
||||
"bytes_raw": 869570,
|
||||
"sha256_raw": "68cd44ac56099ee38f0bba4c32d7d17d33b92d28a99d27419f53c22374491ca6",
|
||||
"sha256_gz": "d6c97508f805aff040d2b4ed4e06c0e7373db2ea6227c7d79ef24c8c79074115"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-19",
|
||||
"window": [
|
||||
"2026-08-19T00:00:00+02:00",
|
||||
"2026-08-20T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 989720,
|
||||
"attempts": 1,
|
||||
"samples": 14549,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-18T22:00:00+00:00",
|
||||
"last": "2026-08-19T21:59:56.556610+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-19T01:33:29.599224+00:00",
|
||||
"to": "2026-08-19T01:34:01.653383+00:00",
|
||||
"seconds": 32.054
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86396.6,
|
||||
"mean_cadence_s": 5.939,
|
||||
"file": "p1-net-2026-08-19.jsonl.gz",
|
||||
"bytes_gz": 126345,
|
||||
"bytes_raw": 800369,
|
||||
"sha256_raw": "0f11ce0cee43cbd7bdb08dc48b202e58c442157ac08b2cda5b64bbab9bd56300",
|
||||
"sha256_gz": "13d61b7da903877c8ae7813b833cb5836ec04a1cf1efd0e1fa87e916bef95fd5"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"window": [
|
||||
"2026-08-20T00:00:00+02:00",
|
||||
"2026-08-21T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1068038,
|
||||
"attempts": 1,
|
||||
"samples": 15600,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-19T22:00:00+00:00",
|
||||
"last": "2026-08-20T21:59:56.742604+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-20T02:11:56.607373+00:00",
|
||||
"to": "2026-08-20T02:12:38.840458+00:00",
|
||||
"seconds": 42.233
|
||||
},
|
||||
{
|
||||
"from": "2026-08-20T20:42:04.623102+00:00",
|
||||
"to": "2026-08-20T20:43:41.666543+00:00",
|
||||
"seconds": 97.043
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86396.7,
|
||||
"mean_cadence_s": 5.539,
|
||||
"file": "p1-net-2026-08-20.jsonl.gz",
|
||||
"bytes_gz": 139693,
|
||||
"bytes_raw": 865024,
|
||||
"sha256_raw": "2612153963951dd13fe310ad7fbb5bec2e195db56fd99fd963134c8badae25ba",
|
||||
"sha256_gz": "362f2ee3d9d7f23f9051ba78993c5af7443dabb3b5f16339efc673444720c892"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-21",
|
||||
"window": [
|
||||
"2026-08-21T00:00:00+02:00",
|
||||
"2026-08-22T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1118002,
|
||||
"attempts": 1,
|
||||
"samples": 16383,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-20T22:00:00+00:00",
|
||||
"last": "2026-08-21T21:59:57.771588+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86397.8,
|
||||
"mean_cadence_s": 5.274,
|
||||
"file": "p1-net-2026-08-21.jsonl.gz",
|
||||
"bytes_gz": 139105,
|
||||
"bytes_raw": 904809,
|
||||
"sha256_raw": "933d9a6899f59c128d0d9b84d6944cea63b1ad58dc57ac52e3e476f9c616f07b",
|
||||
"sha256_gz": "a563581a9a3338af173abb99a200db32881ddaf893d2458f5d8de59b5ec6041f"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-22",
|
||||
"window": [
|
||||
"2026-08-22T00:00:00+02:00",
|
||||
"2026-08-23T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1129094,
|
||||
"attempts": 1,
|
||||
"samples": 16520,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-21T22:00:00+00:00",
|
||||
"last": "2026-08-22T21:59:56.913786+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-21T23:05:20.035692+00:00",
|
||||
"to": "2026-08-21T23:07:51.147719+00:00",
|
||||
"seconds": 151.112
|
||||
},
|
||||
{
|
||||
"from": "2026-08-21T23:07:56.966604+00:00",
|
||||
"to": "2026-08-21T23:08:27.695101+00:00",
|
||||
"seconds": 30.728
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86396.9,
|
||||
"mean_cadence_s": 5.23,
|
||||
"file": "p1-net-2026-08-22.jsonl.gz",
|
||||
"bytes_gz": 141564,
|
||||
"bytes_raw": 914120,
|
||||
"sha256_raw": "39ccdf67c806344490f791648822cd2cd14d73ea7cc0b6e8572a1e15e8f74641",
|
||||
"sha256_gz": "5c46f1879a723f9986d521124200cab839af2a4722b76bc97c6f6f59d0df65a2"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"window": [
|
||||
"2026-08-23T00:00:00+02:00",
|
||||
"2026-08-24T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1107164,
|
||||
"attempts": 1,
|
||||
"samples": 16351,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-22T22:00:00+00:00",
|
||||
"last": "2026-08-23T21:59:56.921115+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86396.9,
|
||||
"mean_cadence_s": 5.284,
|
||||
"file": "p1-net-2026-08-23.jsonl.gz",
|
||||
"bytes_gz": 136277,
|
||||
"bytes_raw": 894387,
|
||||
"sha256_raw": "ffb15367bf3c98eff8a5600e4858ac99778b597c03d0a4f6154e6971b8f98a44",
|
||||
"sha256_gz": "3fa76981758a468ba0aa68b50fe68d5bdb1ef38cd7faec2b6ffdb61c9ddb2db2"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"window": [
|
||||
"2026-08-24T00:00:00+02:00",
|
||||
"2026-08-25T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1122175,
|
||||
"attempts": 1,
|
||||
"samples": 16584,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-23T22:00:00+00:00",
|
||||
"last": "2026-08-24T21:59:57.940474+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86397.9,
|
||||
"mean_cadence_s": 5.21,
|
||||
"file": "p1-net-2026-08-24.jsonl.gz",
|
||||
"bytes_gz": 136834,
|
||||
"bytes_raw": 906369,
|
||||
"sha256_raw": "30465d5ff51220201b4a79341e5da7bfc3d88290465b1c9d886ba94eee8dd234",
|
||||
"sha256_gz": "5103088e047bfcbd9b3f55521f431b01cd27de10c2642e00281a13faa762bdd9"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-25",
|
||||
"window": [
|
||||
"2026-08-25T00:00:00+02:00",
|
||||
"2026-08-26T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1110567,
|
||||
"attempts": 1,
|
||||
"samples": 16451,
|
||||
"non_numeric_states": [
|
||||
{
|
||||
"t": "2026-08-25T01:52:02.104225+00:00",
|
||||
"state": "unavailable"
|
||||
}
|
||||
],
|
||||
"first": "2026-08-24T22:00:00+00:00",
|
||||
"last": "2026-08-25T21:59:56.883744+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-25T01:51:25.942719+00:00",
|
||||
"to": "2026-08-25T01:52:53.203963+00:00",
|
||||
"seconds": 87.261
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86396.9,
|
||||
"mean_cadence_s": 5.252,
|
||||
"file": "p1-net-2026-08-25.jsonl.gz",
|
||||
"bytes_gz": 135890,
|
||||
"bytes_raw": 896416,
|
||||
"sha256_raw": "0e7c86b2b74f2108bc7f5f5d2de2627caaafe473ca4ae18515534dff10526e26",
|
||||
"sha256_gz": "2a5411c10d8d3b271f133c62c04bd722279b48b08d7376084fbad6cc6dd66d76"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-26",
|
||||
"window": [
|
||||
"2026-08-26T00:00:00+02:00",
|
||||
"2026-08-27T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1123610,
|
||||
"attempts": 1,
|
||||
"samples": 16542,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-25T22:00:00+00:00",
|
||||
"last": "2026-08-26T21:59:58.915457+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.9,
|
||||
"mean_cadence_s": 5.223,
|
||||
"file": "p1-net-2026-08-26.jsonl.gz",
|
||||
"bytes_gz": 139301,
|
||||
"bytes_raw": 908350,
|
||||
"sha256_raw": "88d72c1b9f51707d62e744f40402de4260a04617875b85c65d2df6079c6c84ca",
|
||||
"sha256_gz": "0661588fbc0c6c42825b67d483c8818c070405c1002424b3f1d61d8fdadb6370"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-27",
|
||||
"window": [
|
||||
"2026-08-27T00:00:00+02:00",
|
||||
"2026-08-28T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1122433,
|
||||
"attempts": 1,
|
||||
"samples": 16573,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-26T22:00:00+00:00",
|
||||
"last": "2026-08-27T21:59:58.538446+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-27T18:50:29.030586+00:00",
|
||||
"to": "2026-08-27T18:52:22.938470+00:00",
|
||||
"seconds": 113.908
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86398.5,
|
||||
"mean_cadence_s": 5.214,
|
||||
"file": "p1-net-2026-08-27.jsonl.gz",
|
||||
"bytes_gz": 138009,
|
||||
"bytes_raw": 906770,
|
||||
"sha256_raw": "528ab8ee10a46275d2e91d56ef2ed847e5932fae76a1752e6f2e9828dbfa9865",
|
||||
"sha256_gz": "5cacc8c5788f43d6e6e2f569445fa82d9edc5a63b1f082f9a1d6a3bb0b404260"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-28",
|
||||
"window": [
|
||||
"2026-08-28T00:00:00+02:00",
|
||||
"2026-08-29T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1114796,
|
||||
"attempts": 1,
|
||||
"samples": 16456,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-27T22:00:00+00:00",
|
||||
"last": "2026-08-28T21:59:58.515555+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.5,
|
||||
"mean_cadence_s": 5.251,
|
||||
"file": "p1-net-2026-08-28.jsonl.gz",
|
||||
"bytes_gz": 135917,
|
||||
"bytes_raw": 900654,
|
||||
"sha256_raw": "f89838bc9f61af074c17817e4a6eab037318f38fa3c65dad3edfa2106abbc9fb",
|
||||
"sha256_gz": "97090b605f67dcc1f84dea2c51d4832dfe1c2af497a4f4cfe50ecb51024d9b4c"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-29",
|
||||
"window": [
|
||||
"2026-08-29T00:00:00+02:00",
|
||||
"2026-08-30T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1092983,
|
||||
"attempts": 1,
|
||||
"samples": 16132,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-28T22:00:00+00:00",
|
||||
"last": "2026-08-29T21:59:57.574524+00:00",
|
||||
"gaps": [
|
||||
{
|
||||
"from": "2026-08-29T00:03:20.539844+00:00",
|
||||
"to": "2026-08-29T00:03:50.569581+00:00",
|
||||
"seconds": 30.03
|
||||
}
|
||||
],
|
||||
"covered_span_s": 86397.6,
|
||||
"mean_cadence_s": 5.356,
|
||||
"file": "p1-net-2026-08-29.jsonl.gz",
|
||||
"bytes_gz": 134210,
|
||||
"bytes_raw": 883053,
|
||||
"sha256_raw": "896d4c9d660be4faabc5a088323e246e257877d5ff6bd0ec084b785480d76e12",
|
||||
"sha256_gz": "b6f264cd3afba63209123a27490e7e1c3ae70a86731d7ea05ae67f88917f136f"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-30",
|
||||
"window": [
|
||||
"2026-08-30T00:00:00+02:00",
|
||||
"2026-08-31T00:00:00+02:00"
|
||||
],
|
||||
"read": "OK",
|
||||
"http_bytes": 1118895,
|
||||
"attempts": 1,
|
||||
"samples": 16490,
|
||||
"non_numeric_states": [],
|
||||
"first": "2026-08-29T22:00:00+00:00",
|
||||
"last": "2026-08-30T21:59:58.533323+00:00",
|
||||
"gaps": [],
|
||||
"covered_span_s": 86398.5,
|
||||
"mean_cadence_s": 5.24,
|
||||
"file": "p1-net-2026-08-30.jsonl.gz",
|
||||
"bytes_gz": 138429,
|
||||
"bytes_raw": 904311,
|
||||
"sha256_raw": "e704818efb58bb6c505791bc9eab2e9476bf48ff8911d0e79b401fd35b529fa0",
|
||||
"sha256_gz": "9052fead2418bff3083bec3ae8ce64d6b9f6224b668f4bb510c79775f66c5b79"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"days_requested": 30,
|
||||
"days_read": 30,
|
||||
"days_failed": 0,
|
||||
"samples": 485841,
|
||||
"bytes_gz": 4126003,
|
||||
"empty_days": []
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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,399 @@ 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",
|
||||
# SAFETY-30, and it is the entry most easily confused with `failsafe`
|
||||
# below, because the same three words describe both.
|
||||
"sign_site": "the SUM of every standing command against the meter. The "
|
||||
"offending number is the SITE target, which no single "
|
||||
"inverter owns - parking one of them would leave the rest "
|
||||
"pushing the same way with the guard satisfied",
|
||||
}
|
||||
UNIT_SOURCES = {
|
||||
"device": "one inverter's own reported min/max rating",
|
||||
"supervised": "max_w caps what may be WRITTEN to one setpoint entity",
|
||||
"failsafe": "per-unit evidence, per-unit remedy: since SAFETY-30 it "
|
||||
"grades THIS unit's command against THIS unit's OWN "
|
||||
"measured power (0x0518), so what it learns is about one "
|
||||
"device. The site-wide half moved to `sign_site`",
|
||||
"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 TEN claim sources are classified, and only those ten",
|
||||
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
|
||||
check("the six 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 six "
|
||||
"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
|
||||
|
||||
+1406
-39
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:
|
||||
|
||||
@@ -35,16 +35,39 @@ NOT allowed to be tested at:
|
||||
10. THE WIRING, through the real `Controller` and its real status page.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
⚠️ AC 3 IS NOT MET, AND PART 9 DOES NOT PRETEND OTHERWISE.
|
||||
⚠️ PART 9 IS A TWO-DAY MEASUREMENT AND SAYS SO. THE 30-DAY ANSWER IS ELSEWHERE
|
||||
(DATA-01), AND IT DOES NOT LAND WHERE ANYONE EXPECTED.
|
||||
|
||||
AC 3 asks for "the monthly peak held under the soft limit in >= 95 % of
|
||||
intervals over a 30-day simulation replay". THERE IS NO 30-DAY DATASET. The
|
||||
infra repo's `sim/scenarios/` holds exactly two days of this house's real meter
|
||||
- 2026-08-20 (the Illusmart failure day) and 2026-08-23 (the healthy reference)
|
||||
- exported on 2026-08-25 because they were about to age out of HA's recorder,
|
||||
and nothing longer was ever captured. `sim/scenarios/README.md` lists the whole
|
||||
inventory; the rest is RS485 bus frames, OTA witness logs and a 578-row rollback
|
||||
poll, none of which carries grid power over calendar time.
|
||||
intervals over a 30-day simulation replay". When this file was written there
|
||||
were two days of this house's real meter - 2026-08-20 (the Illusmart failure
|
||||
day) and 2026-08-23 (the healthy reference) - exported on 2026-08-25 because
|
||||
they were about to age out of HA's recorder.
|
||||
|
||||
⚠️ AND THE SENTENCE THAT USED TO STAND HERE, "nothing longer was ever
|
||||
captured", WAS TRUE ABOUT THE PAST AND WAS READ AS A CLAIM ABOUT THE FUTURE.
|
||||
It was not one. HA's recorder still held the whole month; DATA-01 probed it on
|
||||
2026-08-31 (230 samples in a 20-minute window at 30 days back, 0 at 45) and
|
||||
exported thirty days the same day, into `scenarios/p1-net-30d-2026-08/` with
|
||||
`tools/export_p1_history.py` - which is committed now precisely because the
|
||||
2026-08-25 one-off was not, and its absence is what let the belief survive.
|
||||
|
||||
Over those thirty days the criterion measures 98.89 % UNCONTROLLED and 98.99 %
|
||||
guarded, so it is met - and that is a finding about the CRITERION as much as
|
||||
about the guard, because a month with no controller at all passes it. Read the
|
||||
other way - the monthly PEAK itself under the soft limit - it is not met: the
|
||||
guard takes August's peak from 5029.8 W to 4107.3 W, still over 3500 W. Both
|
||||
numbers, and why they part company, are in
|
||||
`scenarios/p1-net-30d-2026-08/README.md`.
|
||||
|
||||
PART 9 IS DELIBERATELY LEFT GRADING THE TWO COMMITTED DAYS. It is a per-PR
|
||||
regression check and the thirty days cost about fifty seconds of replay; they
|
||||
also live one directory down precisely so this file's glob does not pick them
|
||||
up. `tools/replay_ac3.py` is the thing that runs the month, and it is checked
|
||||
against part 6 of test_peakguard.py rather than trusted:
|
||||
|
||||
python3 tools/replay_ac3.py scenarios --expect 189/190
|
||||
python3 tools/replay_ac3.py scenarios/p1-net-30d-2026-08
|
||||
|
||||
So part 9 replays the two days that exist, reports the percentage over them, and
|
||||
states the sample size in days next to it. It is a MEASUREMENT, not a verdict:
|
||||
@@ -60,8 +83,15 @@ export) for the reason `scenarios/p1-age-2026-08-26.jsonl.gz` already is: the
|
||||
infra repo is private, CI's token is minted for this repository only, and a
|
||||
regression fixture that CI cannot see is not a regression fixture.
|
||||
|
||||
Closing AC 3 needs 30 days of capture. That is TEST-01's replay harness, and it
|
||||
cannot start before somebody starts recording.
|
||||
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
|
||||
@@ -85,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):
|
||||
@@ -268,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()))
|
||||
@@ -305,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 "
|
||||
@@ -385,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))
|
||||
@@ -413,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
|
||||
@@ -433,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),
|
||||
@@ -483,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)
|
||||
|
||||
@@ -511,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)
|
||||
@@ -645,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()
|
||||
@@ -696,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)
|
||||
|
||||
@@ -716,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:
|
||||
@@ -730,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
|
||||
@@ -748,9 +855,11 @@ print()
|
||||
print(f" AC 3 MEASURED: {under}/{total_q} quarters = {PCT:.1f} % under the "
|
||||
f"{SHIPPED_CAP_W:.0f} W soft limit, over {replay_days:.1f} DAYS of "
|
||||
f"replay, UNCONTROLLED (no peak shaving was running on either day).")
|
||||
print(f" AC 3 ASKS FOR 30 DAYS. {replay_days:.1f} days exist. "
|
||||
f"AC 3 IS NOT MET and this run does not claim it is - see the module "
|
||||
f"docstring. The figure above is the BASELINE SAFETY-07 has to improve.")
|
||||
print(f" AC 3 ASKS FOR 30 DAYS and this run is {replay_days:.1f}, so it "
|
||||
f"does not settle it either way. The 30 days DO now exist - "
|
||||
f"scenarios/p1-net-30d-2026-08/, DATA-01 - and "
|
||||
f"tools/replay_ac3.py is what runs them; the figure above is the "
|
||||
f"BASELINE SAFETY-07 has to improve, on the two days CI replays.")
|
||||
check("the replay produced a measurable number over a stated sample size, "
|
||||
"rather than a criterion quietly redefined to fit the data",
|
||||
total_q > 100 and replay_days < 30)
|
||||
@@ -783,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
|
||||
@@ -799,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(),
|
||||
@@ -838,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",
|
||||
@@ -854,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)
|
||||
@@ -866,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
|
||||
@@ -881,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
|
||||
@@ -424,9 +523,26 @@ check("...and the message explains it would read as a trip, not a mistake",
|
||||
# check below.
|
||||
check("the smallest sane max_w still starts",
|
||||
validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
|
||||
unit_sign_disagree_w=0.5,
|
||||
maintenance_charge_w=1), -5000, 5000) == [])
|
||||
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
|
||||
why(validate_startup_config(base(max_w=1), -5000, 5000), "never fire"))
|
||||
# ⚠️ SAFETY-30 ADDED A SECOND WAY TO DISARM A SIGN WATCHDOG BY CONFIGURATION,
|
||||
# and the fixture above now has to name BOTH thresholds - which is the check
|
||||
# doing its job rather than a bookkeeping edit. `unit_sign_disagree_w` bounds a
|
||||
# unit's own SHARE, which the same `supervised` claim holds at +-max_w, so at or
|
||||
# above the ceiling it is unsatisfiable by construction exactly as
|
||||
# `sign_disagree_w` is. Refused even at the shipped roster of one, where this
|
||||
# watchdog is not armed: a config that cannot work is refused when it is
|
||||
# written, not on the day somebody adds a second inverter.
|
||||
check("...and the per-inverter threshold has the same rule, named separately "
|
||||
"so an operator is told WHICH watchdog they disarmed",
|
||||
why(validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
|
||||
maintenance_charge_w=1), -5000, 5000),
|
||||
"unit_sign_disagree_w"))
|
||||
check("...and one watt under that ceiling is reachable, so accepted",
|
||||
validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999),
|
||||
-5000, 5000) == [])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("what must NOT be refused - a validator that fires on trivia gets deleted")
|
||||
@@ -566,6 +682,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:
|
||||
|
||||
+1325
-14
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
@@ -0,0 +1,455 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export a P1 power signal out of Home Assistant's recorder into the committed
|
||||
scenario format - one gzipped JSON object per line, one file per LOCAL day.
|
||||
|
||||
WHY THIS FILE EXISTS AT ALL
|
||||
===========================
|
||||
`scenarios/p1-net-2026-08-20.jsonl.gz` and `p1-net-2026-08-23.jsonl.gz` were
|
||||
produced once, by hand, and the script was thrown away. The consequence was not
|
||||
theoretical: TARIFF-01's and SAFETY-07's AC 3 ("monthly peak held under the soft
|
||||
limit in >= 95 % of intervals over a 30-day simulation replay") were both merged
|
||||
UNMET on the belief that thirty days could not be produced retroactively - a
|
||||
belief that was simply wrong, and that survived because nobody could re-run the
|
||||
export to find out. So the export is a committed, runnable artefact now, and the
|
||||
next person does not have to re-derive it under time pressure while a retention
|
||||
window rolls.
|
||||
|
||||
python3 tools/export_p1_history.py --days 30 --out-dir /some/where
|
||||
|
||||
THE TWO RULES THIS FILE IS BUILT AROUND
|
||||
=======================================
|
||||
1. A FAILED READ IS NOT AN OBSERVATION. An HTTP error, a timeout, a truncated
|
||||
body, a non-JSON body - none of those are "the meter reported nothing that
|
||||
hour". They are "we do not know what the meter reported that hour", and the
|
||||
two must never end up looking the same in the output. So:
|
||||
|
||||
* every request is retried with backoff;
|
||||
* a day whose read ultimately fails writes NO FILE and is recorded in the
|
||||
manifest as `"read": "FAILED"` with the error text, and the process exits
|
||||
non-zero;
|
||||
* a day that reads back successfully but EMPTY writes an empty file and is
|
||||
recorded as `"read": "OK", "samples": 0`. Empty-and-known and
|
||||
unknown are therefore distinguishable both in the manifest and by the
|
||||
presence or absence of the file;
|
||||
* before any day is fetched, the entity is proved to exist through
|
||||
/api/states/<entity>. Without that check a typo in --entity would return
|
||||
200 + `[]` for all thirty days and produce a beautifully consistent
|
||||
dataset of nothing.
|
||||
|
||||
2. GAPS ARE VISIBLE, NEVER INTERPOLATED. Nothing here carries a value forward,
|
||||
fills, resamples or averages. A recorder outage or an HA restart shows up as
|
||||
what it is: consecutive `t` values further apart than the meter's cadence.
|
||||
The controller's own ingest already reads that correctly - `meter_max_age_s`
|
||||
(30 s shipped) is exactly the age past which a held reading stops counting as
|
||||
coverage - so an hour with no telegrams replays as an hour of NO COVERAGE
|
||||
rather than as an hour of whatever the last sample said. The manifest lists
|
||||
every gap over --gap-s so a reader does not have to diff timestamps to find
|
||||
them.
|
||||
|
||||
DAY AT A TIME, AND WHY NOT SMALLER
|
||||
==================================
|
||||
One request per local day. That is gentle on a live HA (~1 MB of JSON per day)
|
||||
and it is also the only chunking that reproduces the committed files: the
|
||||
history API prepends the state as it stood AT the period start
|
||||
(`include_start_time_state`), stamped with the start instant. Splitting a day
|
||||
into six-hour chunks would therefore inject three synthetic boundary samples per
|
||||
day that no meter ever sent. Days are the natural boundary because the committed
|
||||
files are local days.
|
||||
|
||||
VERIFYING THE EXPORTER
|
||||
======================
|
||||
python3 tools/export_p1_history.py --verify scenarios/p1-net-2026-08-20.jsonl.gz
|
||||
|
||||
re-fetches that day by this exact route and compares the DECOMPRESSED bytes with
|
||||
the committed file. Byte equality of the .gz containers is not expected and not
|
||||
required: a gzip header carries an mtime, and this file writes mtime 0 so its
|
||||
own output is reproducible. Content equality is the claim that matters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try: # py3.9+; the add-on image has it
|
||||
from zoneinfo import ZoneInfo
|
||||
except ImportError: # pragma: no cover
|
||||
ZoneInfo = None # type: ignore
|
||||
|
||||
DEFAULT_URL = "http://192.168.2.158:8123"
|
||||
DEFAULT_ENTITY = "sensor.p1_meter_active_power"
|
||||
DEFAULT_TZ = "Europe/Brussels"
|
||||
# The shipped meter_max_age_s. A hole longer than this is a hole the controller
|
||||
# itself would refuse to see through, which makes it the honest gap threshold.
|
||||
DEFAULT_GAP_S = 30.0
|
||||
|
||||
|
||||
class ReadFailed(RuntimeError):
|
||||
"""The read did not happen. NOT the same thing as an empty result."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# transport
|
||||
|
||||
|
||||
def _get(url: str, token: str, timeout: float):
|
||||
req = urllib.request.Request(url, headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
if resp.status != 200:
|
||||
raise ReadFailed(f"HTTP {resp.status}")
|
||||
return resp.read()
|
||||
|
||||
|
||||
def fetch_json(url: str, token: str, *, timeout: float, retries: int,
|
||||
backoff: float, log=print):
|
||||
"""GET and parse, or raise ReadFailed. Never returns a partial answer."""
|
||||
last = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
raw = _get(url, token, timeout)
|
||||
except urllib.error.HTTPError as exc:
|
||||
last = f"HTTP {exc.code} {exc.reason}"
|
||||
except Exception as exc: # URLError, timeout, ...
|
||||
last = f"{type(exc).__name__}: {exc}"
|
||||
else:
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8")), len(raw), attempt
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
# A truncated body looks exactly like this. It is a failure.
|
||||
last = f"unparseable body ({len(raw)} bytes): {exc}"
|
||||
if attempt < retries:
|
||||
wait = backoff * attempt
|
||||
log(f" read failed ({last}); retry {attempt + 1}/{retries} "
|
||||
f"in {wait:.0f} s")
|
||||
time.sleep(wait)
|
||||
raise ReadFailed(last or "unknown")
|
||||
|
||||
|
||||
def assert_entity_exists(base: str, entity: str, token: str, *, timeout: float,
|
||||
retries: int, backoff: float, log=print):
|
||||
"""Prove the entity is real BEFORE trusting any empty day.
|
||||
|
||||
Without this, `--entity sensor.typo` answers 200 + [] for every day and the
|
||||
exporter cheerfully writes thirty empty files.
|
||||
"""
|
||||
url = f"{base}/api/states/{urllib.parse.quote(entity)}"
|
||||
doc, _, _ = fetch_json(url, token, timeout=timeout, retries=retries,
|
||||
backoff=backoff, log=log)
|
||||
if not isinstance(doc, dict) or doc.get("entity_id") != entity:
|
||||
raise ReadFailed(f"{entity} is not a known entity")
|
||||
return doc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# one day
|
||||
|
||||
|
||||
def day_bounds(day: datetime, tz):
|
||||
start = datetime(day.year, day.month, day.day, tzinfo=tz)
|
||||
return start, start + timedelta(days=1)
|
||||
|
||||
|
||||
def history_url(base: str, entity: str, start: datetime, end: datetime) -> str:
|
||||
q = urllib.parse.urlencode({
|
||||
"filter_entity_id": entity,
|
||||
"end_time": end.isoformat(),
|
||||
"minimal_response": "",
|
||||
})
|
||||
return (f"{base}/api/history/period/"
|
||||
f"{urllib.parse.quote(start.isoformat())}?{q}")
|
||||
|
||||
|
||||
def rows_from(doc, entity: str):
|
||||
"""HA's shape -> [(iso_t, state_str)], plus the states that are not numbers.
|
||||
|
||||
`minimal_response` returns the first record in full and the rest as
|
||||
{state, last_changed}. An entity that went unavailable reports the string
|
||||
"unavailable" (or "unknown") where a number belongs. Those are dropped from
|
||||
the numeric series - there is no float to write and the committed schema has
|
||||
no slot for a non-number - but every one is returned here so it lands in the
|
||||
manifest. Dropping them is what MAKES the outage visible: the series simply
|
||||
has no samples across it, which is the same thing the meter falling silent
|
||||
looks like, and is what the controller's freshness rule already handles.
|
||||
"""
|
||||
if not isinstance(doc, list):
|
||||
raise ReadFailed(f"expected a list, got {type(doc).__name__}")
|
||||
if not doc:
|
||||
return [], []
|
||||
series = doc[0]
|
||||
if not isinstance(series, list):
|
||||
raise ReadFailed("history series is not a list")
|
||||
good, bad = [], []
|
||||
for rec in series:
|
||||
eid = rec.get("entity_id")
|
||||
if eid is not None and eid != entity:
|
||||
raise ReadFailed(f"unexpected entity {eid!r} in the response")
|
||||
t = rec.get("last_changed") or rec.get("last_updated")
|
||||
s = rec.get("state")
|
||||
if t is None:
|
||||
raise ReadFailed("a record carries no timestamp")
|
||||
try:
|
||||
w = float(s)
|
||||
except (TypeError, ValueError):
|
||||
bad.append({"t": t, "state": s})
|
||||
continue
|
||||
good.append((t, w))
|
||||
return good, bad
|
||||
|
||||
|
||||
def render(rows, *, newline: str) -> bytes:
|
||||
"""The committed format, exactly: `{"t": <iso>, "w": <float>}` per line.
|
||||
|
||||
json.dumps' default separators (", " / ": ") and repr of a float are what
|
||||
the two committed files contain; so is the CRLF, an artefact of the original
|
||||
one-off having been run on Windows in text mode. It is reproduced rather
|
||||
than tidied because reproducing the committed bytes is this exporter's
|
||||
verification, and gzip.open(..., 'rt') strips it either way.
|
||||
"""
|
||||
out = bytearray()
|
||||
for t, w in rows:
|
||||
out += json.dumps({"t": t, "w": w}).encode("utf-8")
|
||||
out += newline.encode("ascii")
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def gz(payload: bytes) -> bytes:
|
||||
"""gzip level 9, mtime 0 - so the same input always gives the same bytes."""
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
with gzip.GzipFile(fileobj=buf, mode="wb", compresslevel=9, mtime=0) as f:
|
||||
f.write(payload)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def gaps_in(rows, gap_s: float):
|
||||
out = []
|
||||
prev = None
|
||||
for t, _ in rows:
|
||||
cur = datetime.fromisoformat(t)
|
||||
if prev is not None:
|
||||
d = (cur - prev).total_seconds()
|
||||
if d > gap_s:
|
||||
out.append({"from": prev.isoformat(), "to": cur.isoformat(),
|
||||
"seconds": round(d, 3)})
|
||||
prev = cur
|
||||
return out
|
||||
|
||||
|
||||
def export_day(base, entity, token, day, tz, *, timeout, retries, backoff,
|
||||
gap_s, newline, log=print):
|
||||
"""Read one local day. Raises ReadFailed rather than returning a guess."""
|
||||
start, end = day_bounds(day, tz)
|
||||
doc, nbytes, attempts = fetch_json(
|
||||
history_url(base, entity, start, end), token,
|
||||
timeout=timeout, retries=retries, backoff=backoff, log=log)
|
||||
rows, bad = rows_from(doc, entity)
|
||||
entry = {
|
||||
"date": day.date().isoformat(),
|
||||
"window": [start.isoformat(), end.isoformat()],
|
||||
"read": "OK",
|
||||
"http_bytes": nbytes,
|
||||
"attempts": attempts,
|
||||
"samples": len(rows),
|
||||
"non_numeric_states": bad,
|
||||
"first": rows[0][0] if rows else None,
|
||||
"last": rows[-1][0] if rows else None,
|
||||
"gaps": gaps_in(rows, gap_s),
|
||||
}
|
||||
if rows:
|
||||
span = (datetime.fromisoformat(rows[-1][0])
|
||||
- datetime.fromisoformat(rows[0][0])).total_seconds()
|
||||
entry["covered_span_s"] = round(span, 1)
|
||||
entry["mean_cadence_s"] = round(span / max(1, len(rows) - 1), 3)
|
||||
return rows, entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# modes
|
||||
|
||||
|
||||
def do_export(args, token, tz, log=print):
|
||||
base = args.url.rstrip("/")
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
newline = "\r\n" if args.newline == "crlf" else "\n"
|
||||
|
||||
log(f" proving {args.entity} exists before trusting any empty day")
|
||||
st = assert_entity_exists(base, args.entity, token, timeout=args.timeout,
|
||||
retries=args.retries, backoff=args.backoff,
|
||||
log=log)
|
||||
log(f" {args.entity} = {st.get('state')} "
|
||||
f"{st.get('attributes', {}).get('unit_of_measurement', '')}")
|
||||
|
||||
if args.end_date:
|
||||
last = datetime.fromisoformat(args.end_date).replace(tzinfo=tz)
|
||||
else: # yesterday: today is still being logged
|
||||
last = datetime.now(tz).replace(hour=0, minute=0, second=0,
|
||||
microsecond=0) - timedelta(days=1)
|
||||
days = [last - timedelta(days=i) for i in range(args.days - 1, -1, -1)]
|
||||
|
||||
manifest = {
|
||||
"generated": datetime.now(timezone.utc).isoformat(),
|
||||
"generator": "tools/export_p1_history.py",
|
||||
"source": f"{base}/api/history/period",
|
||||
"entity": args.entity,
|
||||
"timezone": args.tz,
|
||||
"gap_threshold_s": args.gap_s,
|
||||
"format": '{"t": <ISO-8601 UTC>, "w": <float watts>} per line, gzip',
|
||||
"days": [],
|
||||
}
|
||||
failures = []
|
||||
for day in days:
|
||||
name = f"{args.prefix}{day.date().isoformat()}.jsonl.gz"
|
||||
log(f" {name}")
|
||||
try:
|
||||
rows, entry = export_day(
|
||||
base, args.entity, token, day, tz, timeout=args.timeout,
|
||||
retries=args.retries, backoff=args.backoff, gap_s=args.gap_s,
|
||||
newline=newline, log=log)
|
||||
except ReadFailed as exc:
|
||||
# NO FILE. A missing file is the honest record of a read that did
|
||||
# not happen; an empty one would claim the meter was silent.
|
||||
entry = {"date": day.date().isoformat(), "read": "FAILED",
|
||||
"error": str(exc), "file": None}
|
||||
manifest["days"].append(entry)
|
||||
failures.append(entry)
|
||||
log(f" READ FAILED: {exc} - no file written")
|
||||
continue
|
||||
payload = render(rows, newline=newline)
|
||||
blob = gz(payload)
|
||||
(out_dir / name).write_bytes(blob)
|
||||
entry["file"] = name
|
||||
entry["bytes_gz"] = len(blob)
|
||||
entry["bytes_raw"] = len(payload)
|
||||
entry["sha256_raw"] = hashlib.sha256(payload).hexdigest()
|
||||
entry["sha256_gz"] = hashlib.sha256(blob).hexdigest()
|
||||
manifest["days"].append(entry)
|
||||
ng = len(entry.get("gaps", []))
|
||||
log(f" {entry['samples']} samples, {len(blob)} B gz, "
|
||||
f"{ng} gap(s) over {args.gap_s:g} s"
|
||||
+ (f", {len(entry['non_numeric_states'])} non-numeric state(s)"
|
||||
if entry["non_numeric_states"] else ""))
|
||||
if day is not days[-1]:
|
||||
time.sleep(args.delay)
|
||||
|
||||
ok = [d for d in manifest["days"] if d["read"] == "OK"]
|
||||
manifest["summary"] = {
|
||||
"days_requested": len(days),
|
||||
"days_read": len(ok),
|
||||
"days_failed": len(failures),
|
||||
"samples": sum(d["samples"] for d in ok),
|
||||
"bytes_gz": sum(d["bytes_gz"] for d in ok),
|
||||
"empty_days": [d["date"] for d in ok if d["samples"] == 0],
|
||||
}
|
||||
# newline="\n" explicitly: write_text() on Windows emits CRLF, and
|
||||
# the manifest would then differ from a Linux run of the same export
|
||||
# for no reason visible in a diff.
|
||||
with open(args.manifest or (out_dir / "manifest.json"), "w",
|
||||
encoding="utf-8", newline="\n") as fh:
|
||||
fh.write(json.dumps(manifest, indent=2) + "\n")
|
||||
log(f" {manifest['summary']['days_read']}/{len(days)} days, "
|
||||
f"{manifest['summary']['samples']} samples, "
|
||||
f"{manifest['summary']['bytes_gz'] / 1e6:.2f} MB gz")
|
||||
if failures:
|
||||
log(f" {len(failures)} DAY(S) NOT READ - the dataset is incomplete "
|
||||
f"and says so: " + ", ".join(d["date"] for d in failures))
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def do_verify(args, token, tz, log=print):
|
||||
"""Re-export a committed day by this route and diff it against the file."""
|
||||
base = args.url.rstrip("/")
|
||||
newline = "\r\n" if args.newline == "crlf" else "\n"
|
||||
rc = 0
|
||||
for target in args.verify:
|
||||
path = Path(target)
|
||||
stamp = path.name.split(".")[0].rsplit("-", 3)[-3:]
|
||||
day = datetime(int(stamp[0]), int(stamp[1]), int(stamp[2]), tzinfo=tz)
|
||||
log(f" {path.name} (local day {day.date().isoformat()})")
|
||||
committed = gzip.decompress(path.read_bytes())
|
||||
rows, entry = export_day(base, args.entity, token, day, tz,
|
||||
timeout=args.timeout, retries=args.retries,
|
||||
backoff=args.backoff, gap_s=args.gap_s,
|
||||
newline=newline, log=log)
|
||||
fresh = render(rows, newline=newline)
|
||||
if fresh == committed:
|
||||
log(f" IDENTICAL: {len(fresh)} bytes, {entry['samples']} "
|
||||
f"samples, sha256 {hashlib.sha256(fresh).hexdigest()[:16]}")
|
||||
continue
|
||||
rc = 1
|
||||
cl = committed.split(newline.encode())
|
||||
fl = fresh.split(newline.encode())
|
||||
log(f" DIFFERS: committed {len(committed)} B / {len(cl)} lines, "
|
||||
f"re-export {len(fresh)} B / {len(fl)} lines")
|
||||
shown = 0
|
||||
for i in range(max(len(cl), len(fl))):
|
||||
a = cl[i] if i < len(cl) else b"<absent>"
|
||||
b = fl[i] if i < len(fl) else b"<absent>"
|
||||
if a != b:
|
||||
log(f" line {i}: committed {a!r}")
|
||||
log(f" re-export {b!r}")
|
||||
shown += 1
|
||||
if shown >= args.max_diff:
|
||||
log(f" ... stopping after {shown} differing lines")
|
||||
break
|
||||
return rc
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
p.add_argument("--url", default=DEFAULT_URL)
|
||||
p.add_argument("--entity", default=DEFAULT_ENTITY)
|
||||
p.add_argument("--token", help="bearer token; else $HASS_TOKEN")
|
||||
p.add_argument("--tz", default=DEFAULT_TZ,
|
||||
help="local timezone that defines a day boundary")
|
||||
p.add_argument("--days", type=int, default=30)
|
||||
p.add_argument("--end-date", help="last local day to export (YYYY-MM-DD); "
|
||||
"default yesterday")
|
||||
p.add_argument("--out-dir", default=".")
|
||||
p.add_argument("--prefix", default="p1-net-")
|
||||
p.add_argument("--manifest", help="path for the manifest JSON")
|
||||
p.add_argument("--gap-s", type=float, default=DEFAULT_GAP_S)
|
||||
p.add_argument("--newline", choices=("crlf", "lf"), default="crlf",
|
||||
help="line terminator; crlf reproduces the committed files")
|
||||
p.add_argument("--timeout", type=float, default=120.0)
|
||||
p.add_argument("--retries", type=int, default=4)
|
||||
p.add_argument("--backoff", type=float, default=5.0)
|
||||
p.add_argument("--delay", type=float, default=2.0,
|
||||
help="pause between days - this is a live HA")
|
||||
p.add_argument("--verify", nargs="+", metavar="FILE",
|
||||
help="re-export these committed days and diff them")
|
||||
p.add_argument("--max-diff", type=int, default=20)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
token = args.token or __import__("os").environ.get("HASS_TOKEN")
|
||||
if not token:
|
||||
print("no token: pass --token or set HASS_TOKEN", file=sys.stderr)
|
||||
return 2
|
||||
if ZoneInfo is None:
|
||||
print("zoneinfo unavailable", file=sys.stderr)
|
||||
return 2
|
||||
tz = ZoneInfo(args.tz)
|
||||
try:
|
||||
if args.verify:
|
||||
return do_verify(args, token, tz)
|
||||
return do_export(args, token, tz)
|
||||
except ReadFailed as exc:
|
||||
print(f"read failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(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,303 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run TARIFF-01's and SAFETY-07's AC 3 over a directory of `p1-net-*.jsonl.gz`.
|
||||
|
||||
AC 3, both cards, same words: *"monthly peak held under the soft limit in
|
||||
>= 95 % of intervals over a 30-day simulation replay."* Both cards merged with
|
||||
it UNMET because two days of capture existed. DATA-01 exported thirty. This is
|
||||
the thing that turns those thirty days into the number.
|
||||
|
||||
cd goodwe_controller
|
||||
python3 tools/replay_ac3.py scenarios/p1-net-30d-2026-08
|
||||
|
||||
WHAT IT MEASURES
|
||||
================
|
||||
Two runs over the same telegrams, one file at a time in date order into a
|
||||
SHARED peak store, so the 15-minute blocks accumulate into a real MONTH rather
|
||||
than into 30 disconnected days:
|
||||
|
||||
UNCONTROLLED the recorded meter as it happened. Nothing was shaving peaks on
|
||||
this house during August 2026, so this is not a simulation of
|
||||
anything - it is what the month did. It is TARIFF-01's AC 3.
|
||||
GUARDED the same month with `peakguard.decide()` in the loop and the
|
||||
battery it asks for modelled, closed-loop: the meter the guard
|
||||
sees is the recorded net MINUS the discharge it obtained. That
|
||||
is SAFETY-07's AC 3.
|
||||
|
||||
Both numbers are `quarters <= peak_cap_w` over `quarters closed`, with the cap
|
||||
read out of config.yaml rather than typed. Nothing here exits non-zero on 95 %:
|
||||
a measurement below the criterion is a finding about the house and the guard,
|
||||
and this tool's job is to report it, not to be graded by it.
|
||||
|
||||
FAITHFULNESS TO THE SUITE
|
||||
=========================
|
||||
The replay loop is `test_peakguard.py` part 6's, with `pack=None` (SoC pinned
|
||||
above the floor, so SAFETY-24's energy floor is not the thing deciding the
|
||||
answer). It is not imported from there because that file runs its whole suite on
|
||||
import. The duplication is checked instead of asserted: pointed at `scenarios/`
|
||||
- the two committed days - this tool must reproduce part 6's published totals,
|
||||
and `--expect` makes that a hard check.
|
||||
|
||||
python3 tools/replay_ac3.py scenarios --expect 189/190
|
||||
|
||||
GAPS
|
||||
====
|
||||
Nothing is interpolated. A hole in the export is a hole here: the shipped
|
||||
`meter_max_age_s` is what decides whether a held reading still counts as
|
||||
coverage, so an hour with no telegrams closes no quarters and contributes to
|
||||
neither numerator nor denominator. `--report-gaps` lists them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from app.p1 import P1Ingest, make_sample, split_signed # noqa: E402
|
||||
from app.peakguard import decide # noqa: E402
|
||||
from app.tariff import PeakStore, PeakTracker, month_key # noqa: E402
|
||||
|
||||
|
||||
def read_option(key: str):
|
||||
"""The shipped default for `key`, out of config.yaml.
|
||||
|
||||
⚠️ `peak_cap_w:` appears TWICE in config.yaml - once under `options:` with
|
||||
the default, once under `schema:` as `int(500,15000)`. The FIRST match is
|
||||
the options block, which is the number an install actually lands on; taking
|
||||
the last would try to float() the schema line. Same rule as the suites.
|
||||
"""
|
||||
for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines():
|
||||
if line.strip().startswith(f"{key}:"):
|
||||
try:
|
||||
return float(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def read_schema_max(key: str):
|
||||
for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith(f"{key}:") and "(" in s and ")" in s:
|
||||
try:
|
||||
return float(s[s.index("(") + 1:s.index(")")].split(",")[-1])
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def sample(t: datetime, net_w: float):
|
||||
"""One telegram through the SHIPPED sign derivation - the suites' helper."""
|
||||
imp, exp = split_signed(net_w)
|
||||
return make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=t, ingest_mono=t.timestamp())
|
||||
|
||||
|
||||
def rows(path):
|
||||
"""Every sample in one file, in order, with the gaps left as gaps."""
|
||||
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
row = json.loads(line)
|
||||
yield datetime.fromisoformat(row["t"]), float(row["w"])
|
||||
|
||||
|
||||
def replay(paths, *, guarded: bool, cap_w, max_w, max_hold_s, cycle_s,
|
||||
soc, soc_floor, tmp: Path, gap_s: float):
|
||||
"""test_peakguard.py part 6's loop, over an arbitrary number of days.
|
||||
|
||||
⚠️ ONE STORE, AND AN INGEST THAT IS RESET ONLY AT A REAL DISCONTINUITY.
|
||||
Neither half of that is a detail.
|
||||
|
||||
The STORE is shared across the files because "monthly peak" is what AC 3 is
|
||||
about and a per-file store cannot express it.
|
||||
|
||||
The INGEST is carried across a file boundary when the next file's first
|
||||
telegram follows the previous file's last one within `gap_s`, and rebuilt
|
||||
when it does not. Both failure modes are real and were measured here:
|
||||
|
||||
* carried across a genuine hole, one averager manufactures quarters out of
|
||||
nothing - streamed straight through the three-day gap between the two
|
||||
committed days this reported 285/286 where the suite reports 189/190;
|
||||
* rebuilt at every file, each day's LAST quarter (21:45-22:00 UTC) never
|
||||
closes, because it closes only when a telegram past the boundary
|
||||
arrives and that telegram is in the next file. That is why the suite's
|
||||
two days give 95 quarters each rather than 96, and it silently discards
|
||||
one quarter per day - 30 of them over a month.
|
||||
|
||||
So contiguous days are joined and non-contiguous ones are not, which gives
|
||||
189/190 on the committed pair and a full 96 quarters a day on a run of
|
||||
consecutive days.
|
||||
"""
|
||||
store = PeakStore(str(tmp / f"ac3-{'g' if guarded else 'u'}.db"))
|
||||
tracker = PeakTracker(store, cap_w=cap_w)
|
||||
engaged = blind = saturated = cycles = n = joins = 0
|
||||
wh = 0.0
|
||||
gaps = []
|
||||
months = set()
|
||||
ing = None
|
||||
batt = 0.0 # delivered discharge, W (positive)
|
||||
next_cycle = None
|
||||
prev_t = None
|
||||
for path in paths:
|
||||
for i, (t, w) in enumerate(rows(path)):
|
||||
if i == 0:
|
||||
contiguous = (prev_t is not None
|
||||
and 0 <= (t - prev_t).total_seconds() <= gap_s)
|
||||
if contiguous:
|
||||
joins += 1
|
||||
else:
|
||||
if ing is not None:
|
||||
months |= {month_key(b.start) for b in ing.blocks}
|
||||
ing = P1Ingest(phases=1, max_age_s=max_hold_s)
|
||||
batt = 0.0
|
||||
next_cycle = None
|
||||
prev_t = None
|
||||
if prev_t is not None:
|
||||
dt = (t - prev_t).total_seconds()
|
||||
if dt > gap_s:
|
||||
gaps.append((prev_t, t, dt))
|
||||
wh += batt * dt / 3600.0
|
||||
prev_t = t
|
||||
n += 1
|
||||
ing.submit(sample(t, w - batt))
|
||||
tracker.sync(ing)
|
||||
if next_cycle is not None and t < next_cycle:
|
||||
continue
|
||||
next_cycle = t + timedelta(seconds=cycle_s)
|
||||
cycles += 1
|
||||
d = decide(PeakTracker.project(ing.averager, t), batt_w=batt,
|
||||
cap_w=cap_w, reading=tracker.peak(t),
|
||||
achievable_w=max_w, max_hold_s=max_hold_s, soc=soc,
|
||||
soc_floor=soc_floor, frozen=False)
|
||||
if d.engaged:
|
||||
blind += 1 if d.blind else 0
|
||||
engaged += 0 if d.blind else 1
|
||||
saturated += 1 if d.saturated else 0
|
||||
if not guarded:
|
||||
continue
|
||||
engaged_floor = d.engaged and d.floor_w is not None
|
||||
batt = max(0.0, d.floor_w) if engaged_floor else 0.0
|
||||
if ing is not None:
|
||||
months |= {month_key(b.start) for b in ing.blocks}
|
||||
out = {m: dict(store.blocks_in(m)) for m in sorted(months)}
|
||||
return {"months": out, "samples": n, "cycles": cycles, "joins": joins,
|
||||
"engaged": engaged, "blind": blind, "saturated": saturated,
|
||||
"wh": wh, "gaps": gaps}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
p.add_argument("directory", help="a directory of p1-net-*.jsonl.gz")
|
||||
p.add_argument("--glob", default="p1-net-*.jsonl.gz")
|
||||
p.add_argument("--expect", help="UNDER/TOTAL the uncontrolled run must "
|
||||
"reproduce, e.g. 189/190")
|
||||
p.add_argument("--report-gaps", action="store_true")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
paths = sorted(Path(args.directory).glob(args.glob))
|
||||
if not paths:
|
||||
print(f"no {args.glob} under {args.directory}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
CAP_W = read_option("peak_cap_w")
|
||||
MAX_HOLD_S = read_option("meter_max_age_s")
|
||||
CYCLE_S = read_option("cycle_floor_s")
|
||||
SOC_FLOOR = read_option("peak_soc_floor")
|
||||
LIVE_MAX_W = read_schema_max("max_w")
|
||||
if None in (CAP_W, MAX_HOLD_S, CYCLE_S, SOC_FLOOR, LIVE_MAX_W):
|
||||
print("could not read the shipped options out of config.yaml",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
# The suites' fixture SoC: comfortably above the floor, so SAFETY-24's
|
||||
# energy floor is not what decides an AC 3 that is not about it.
|
||||
SOC_OK = SOC_FLOOR + 30.0
|
||||
|
||||
days = len(paths)
|
||||
span_first = span_last = None
|
||||
print(f"AC 3 - {days} file(s) from {args.directory}")
|
||||
print(f" shipped: peak_cap_w {CAP_W:.0f} W, meter_max_age_s "
|
||||
f"{MAX_HOLD_S:.0f} s, cycle_floor_s {CYCLE_S:.0f} s, "
|
||||
f"max_w schema ceiling {LIVE_MAX_W:.0f} W")
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="goodwe-ac3-"))
|
||||
kw = dict(cap_w=CAP_W, max_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
cycle_s=CYCLE_S, soc=SOC_OK, soc_floor=SOC_FLOOR, tmp=tmp,
|
||||
gap_s=MAX_HOLD_S)
|
||||
u = replay(paths, guarded=False, **kw)
|
||||
g = replay(paths, guarded=True, **kw)
|
||||
|
||||
def flat(run):
|
||||
return [w for m in run["months"].values() for w in m.values()]
|
||||
|
||||
uq, gq = flat(u), flat(g)
|
||||
u_under = sum(1 for w in uq if w <= CAP_W)
|
||||
g_under = sum(1 for w in gq if w <= CAP_W)
|
||||
u_pct = 100.0 * u_under / max(len(uq), 1)
|
||||
g_pct = 100.0 * g_under / max(len(gq), 1)
|
||||
|
||||
print(f" {u['samples']} samples, {len(uq)} closed quarters "
|
||||
f"({len(uq) / 96.0:.1f} days' worth), {days - u['joins']} contiguous "
|
||||
f"run(s) of days")
|
||||
for m in sorted(u["months"]):
|
||||
mu = u["months"][m]
|
||||
mg = g["months"].get(m, {})
|
||||
print(f" month {m}: {len(mu)} quarters, peak "
|
||||
f"{max(mu.values()):.1f} W uncontrolled -> "
|
||||
f"{max(mg.values()) if mg else 0.0:.1f} W guarded")
|
||||
print()
|
||||
print(f" TARIFF-01 AC 3 (UNCONTROLLED, what the month did): "
|
||||
f"{u_under}/{len(uq)} = {u_pct:.2f} % of quarters at or under "
|
||||
f"{CAP_W:.0f} W")
|
||||
print(f" SAFETY-07 AC 3 (GUARDED, peakguard.decide in the loop): "
|
||||
f"{g_under}/{len(gq)} = {g_pct:.2f} %")
|
||||
print(f" guard cycles {g['cycles']}, engaged on the measurement "
|
||||
f"{g['engaged']}, blind {g['blind']}, saturated {g['saturated']}, "
|
||||
f"modelled discharge {g['wh']:.0f} Wh")
|
||||
# AC 3'S WORDS SUPPORT TWO READINGS AND BOTH ARE REPORTED, because they
|
||||
# disagree on this data and picking the flattering one is the failure mode
|
||||
# this whole card exists to correct.
|
||||
u_peak = max(uq) if uq else 0.0
|
||||
g_peak = max(gq) if gq else 0.0
|
||||
print(f" reading A - PERCENTAGE OF INTERVALS under the cap (the reading "
|
||||
f"test_tariff.py part 9 already uses): uncontrolled {u_pct:.2f} %, "
|
||||
f"guarded {g_pct:.2f} %")
|
||||
print(f" reading B - THE MONTHLY PEAK ITSELF under the cap: "
|
||||
f"{u_peak:.1f} W uncontrolled -> {g_peak:.1f} W guarded, against a "
|
||||
f"{CAP_W:.0f} W soft limit "
|
||||
f"({'under' if g_peak <= CAP_W else 'STILL OVER'})")
|
||||
print(f" verdict at the 95 % criterion: uncontrolled "
|
||||
f"{'MEETS' if u_pct >= 95.0 else 'DOES NOT MEET'}, guarded "
|
||||
f"{'MEETS' if g_pct >= 95.0 else 'DOES NOT MEET'} "
|
||||
f"({len(uq) / 96.0:.1f} days of replay)")
|
||||
|
||||
if u["gaps"]:
|
||||
tot = sum(d for _, _, d in u["gaps"])
|
||||
print(f" gaps over {MAX_HOLD_S:.0f} s: {len(u['gaps'])}, "
|
||||
f"{tot:.0f} s total - NOT interpolated; the quarters they fall in "
|
||||
f"simply carry less coverage")
|
||||
if args.report_gaps:
|
||||
for a, b, d in u["gaps"]:
|
||||
print(f" {a.isoformat()} -> {b.isoformat()} {d:.0f} s")
|
||||
|
||||
if args.expect:
|
||||
want_u, want_t = (int(x) for x in args.expect.split("/"))
|
||||
ok = (u_under, len(uq)) == (want_u, want_t)
|
||||
print(f" reproduces test_peakguard.py part 6's published "
|
||||
f"{want_u}/{want_t}: {'yes' if ok else 'NO'} "
|
||||
f"(got {u_under}/{len(uq)})")
|
||||
return 0 if ok else 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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