Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22c016fadd | ||
|
|
6b85acb0f1 | ||
|
|
b30797e1f9 | ||
|
|
7ef3d23245 | ||
|
|
b6f41f736c | ||
|
|
753f700539 | ||
|
|
f594543988 | ||
|
|
d48c6b971d | ||
|
|
e941bf7f62 | ||
|
|
e306c74cb8 | ||
|
|
591c35ed01 | ||
|
|
3030f72b9e | ||
|
|
3fe3c7acb0 | ||
|
|
186e13ceca | ||
|
|
df88b323f5 | ||
|
|
38f6680639 | ||
|
|
06f95e0a64 | ||
|
|
0c7e786ed5 | ||
|
|
ab6fbe2f9e | ||
|
|
1c59cfeaf5 | ||
|
|
acbdf41248 | ||
|
|
950472662e | ||
|
|
6ea84a46f6 | ||
|
|
48cd5f31ca | ||
|
|
20f9f4969d | ||
|
|
b68f288fef | ||
|
|
e8fb596b99 | ||
|
|
d0b053b150 | ||
|
|
f630ed949b | ||
|
|
d5b1ef19b3 | ||
|
|
b7267eb51a | ||
|
|
e498dd4c1c | ||
|
|
3cb9d3b7d9 | ||
|
|
99321892cb | ||
|
|
182102eeaf | ||
|
|
1e4bad8aa7 | ||
|
|
6ea79f1043 | ||
|
|
29fa39eda7 | ||
|
|
d64790b3e2 | ||
|
|
83911ed637 | ||
|
|
7b4877d56d | ||
|
|
358fbb0172 | ||
|
|
504e8daac3 | ||
|
|
f6ecd5ff03 | ||
|
|
04292c4a08 | ||
|
|
86c43eba24 | ||
|
|
d57122ec74 | ||
|
|
4601682815 | ||
|
|
9234016419 | ||
|
|
b7272169b1 | ||
|
|
2b8b8cb97f | ||
|
|
8e589fee3f | ||
|
|
bdd7f18a54 | ||
|
|
a1376e44e4 | ||
|
|
e709554857 | ||
|
|
a30bde1ab1 | ||
|
|
71cd7849e4 | ||
|
|
e7c95bfd8a | ||
|
|
dd40c56ab4 | ||
|
|
c2ed509565 | ||
|
|
2d570d84ce | ||
|
|
cf5094c608 | ||
|
|
9f8b721a4d | ||
|
|
303525cfc5 | ||
|
|
616b62d3e9 | ||
|
|
91ea676af4 | ||
|
|
862f0fa574 | ||
|
|
e102cde841 | ||
|
|
0dbd485b5a | ||
|
|
f4914a174a | ||
|
|
852fc7ced6 | ||
|
|
9aadf469bc | ||
|
|
4b521b2df0 | ||
|
|
0942228d65 | ||
|
|
6aabb832fc | ||
|
|
d31a12b7c6 | ||
|
|
2983d7c3b0 | ||
|
|
611d3dbcdf | ||
|
|
46383c5ae5 | ||
|
|
5e966ca59f | ||
|
|
4e66583a52 | ||
|
|
c34b5a2922 | ||
|
|
9c1a23328a | ||
|
|
c040aac4b8 | ||
|
|
0cdaa3bd3e | ||
|
|
7cde5d862a | ||
|
|
b0a17474a3 | ||
|
|
ffb8ec6373 | ||
|
|
0ba3a200fe | ||
|
|
7aa1bd052e | ||
|
|
878a6ce452 | ||
|
|
bb64f8fdd7 | ||
|
|
e40eb0768d | ||
|
|
cc4e88490b | ||
|
|
2513ba1f12 | ||
|
|
9aed43990b | ||
|
|
35856a73ae | ||
|
|
aae74d7571 | ||
|
|
8cc9438aec | ||
|
|
96a808a057 | ||
|
|
88cd97cc7a | ||
|
|
e2e331ea54 | ||
|
|
547d796c5d | ||
|
|
fc9bd89dca | ||
|
|
0422127cab | ||
|
|
80ebb96e7d | ||
|
|
c70239cac2 | ||
|
|
08f17f65dd | ||
|
|
4d41b0a79e | ||
|
|
98109a9b91 | ||
|
|
ad9c5772a4 | ||
|
|
1b343da8e3 | ||
|
|
e663e10245 | ||
|
|
632be44f6c | ||
|
|
8b51a51e20 | ||
|
|
c24bc0a011 | ||
|
|
80402c978f | ||
|
|
6c980e87b0 | ||
|
|
505a847d85 | ||
|
|
f498d5fa54 | ||
|
|
4bd659c499 | ||
|
|
f47f1f0129 |
@@ -0,0 +1,18 @@
|
||||
# This add-on is deployed to a Linux container. core.autocrlf=true on the
|
||||
# authoring box gave the checkout CRLF, so a plain copy shipped CRLF files -
|
||||
# run.sh with CRLF is a "bad interpreter" failure, and any hash-based drift
|
||||
# check between repo and deployment fails for a reason that has nothing to do
|
||||
# with the code. Deploy with:
|
||||
# git -c core.autocrlf=false archive release/1.0 goodwe_controller | tar -x
|
||||
# which is how 0.3.0 went out, byte-identical to the blobs.
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.gz binary
|
||||
|
||||
# `.gitea/workflows/*.yml` steps embed shell heredocs; a `\r` on the terminator
|
||||
# line means the heredoc never closes and the job fails on a syntax error that is
|
||||
# invisible in the diff. A Windows checkout with core.autocrlf=true would
|
||||
# reintroduce it on the next edit. CI-01.
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
@@ -0,0 +1,34 @@
|
||||
# The image the add-on's tests run in (CI-01). This repo had NO CI at all before
|
||||
# that ticket - eight runnable test files, none of them ever run by a machine.
|
||||
#
|
||||
# ⚠️ THE DEPENDENCIES ARE THE SHIPPED ONES, FROM APK, AND THAT IS THE POINT.
|
||||
# goodwe_controller/Dockerfile installs `py3-aiohttp py3-paho-mqtt` from Alpine
|
||||
# packages rather than pip, because Alpine ships musl and there are no musl
|
||||
# wheels for aiohttp - pip would compile it from source on a client's Raspberry
|
||||
# Pi. Testing against pip-installed glibc wheels would be testing a different
|
||||
# add-on from the one that ships. Same base, same package manager, same names.
|
||||
#
|
||||
# ⚠️ NOT `FROM ghcr.io/home-assistant/...-base`. The real Dockerfile takes its
|
||||
# base from the Supervisor's `BUILD_FROM` build-arg, which is per-architecture
|
||||
# and not resolvable outside a Supervisor build. `alpine:3` is what those images
|
||||
# are, minus the s6 supervision the tests do not use - and it is already cached
|
||||
# on this runner's host, so the gate does not depend on a registry pull.
|
||||
#
|
||||
# ⚠️ The runner container itself CANNOT run these tests: it is Alpine with a bare
|
||||
# python3, no aiohttp, no pip (`No module named pip`; checked on the box, not
|
||||
# assumed). An image is not gold-plating here, it is the only option that does
|
||||
# not mutate a shared long-lived runner.
|
||||
# ⚠️ `git` IS A TEST DEPENDENCY, NOT A CONVENIENCE (CI-02). test_ci_gate.py
|
||||
# grades the gate's own checkout by EXTRACTING the shell out of
|
||||
# .gitea/workflows/checks.yml and RUNNING it - against two throwaway local
|
||||
# repositories it builds itself, one whose pull request merges and one whose
|
||||
# does not. Reading those commands as a string would be the thing this
|
||||
# repository keeps finding: a check that never observed its subject. Running
|
||||
# them needs git in the image the checks run in.
|
||||
#
|
||||
# It costs one apk package and no network at test time: the fixtures are local
|
||||
# paths, and the token substitution in the workflow's `git remote add` is a
|
||||
# no-op on a path with no `://` in it, which is exactly why the test can use
|
||||
# the shipped line verbatim instead of a paraphrase of it.
|
||||
FROM alpine:3
|
||||
RUN apk add --no-cache python3 py3-aiohttp py3-paho-mqtt git
|
||||
@@ -0,0 +1,301 @@
|
||||
# CI-01: this repository's FIRST automated check. Before this file, admin/goodwe-addon
|
||||
# had no `.gitea/workflows/` directory at all - eight runnable test files, ~100 s
|
||||
# of real coverage including a wall-clock cadence test, and not one machine ever
|
||||
# ran them. Every merge here was a human reading a diff.
|
||||
#
|
||||
# ⚠️ NO `paths:` FILTER, AND ITS ABSENCE IS LOAD-BEARING. Branch protection on
|
||||
# release/1.0 requires the context `checks / unit (pull_request)`. Gitea leaves a
|
||||
# required context it never receives PENDING forever, so the moment this workflow
|
||||
# learns to sit a PR out, that PR becomes unmergeable. Condition a STEP if you
|
||||
# must; never the workflow.
|
||||
#
|
||||
# ⚠️ THE RUNNER IS SHARED WITH THE OTHER REPO'S RIG. `goodwe-runner` on
|
||||
# 192.168.2.147 carries the labels `self-hosted` and `goodwe-rig` and is
|
||||
# registered `:host`, and it is the only runner there is - so this job wears the
|
||||
# rig label without wanting a rig. Nothing below starts, stops, or reads the
|
||||
# `goodwe-sim` compose project or takes `sim/riglock.py`; it runs one short
|
||||
# container and exits. It does occupy the runner's single slot (`capacity: 1`),
|
||||
# so it QUEUES behind a rig campaign - a delayed verdict, never a destroyed one.
|
||||
#
|
||||
# NO JS ACTIONS: the runner image has no node, so `actions/checkout` cannot run.
|
||||
# Checkout is plain git below - and since CI-02 it is a checkout of the MERGE of
|
||||
# the pull request into its base, not of the branch tip. See the long note on
|
||||
# that step; a green from this workflow is a claim about content that can land.
|
||||
name: checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [release/1.0, master]
|
||||
workflow_dispatch:
|
||||
|
||||
# ⚠️ NO `concurrency:` BLOCK, AND ITS ABSENCE IS AS LOAD-BEARING AS THE MISSING
|
||||
# `paths:` FILTER ABOVE. CI-01 put one here - `group: addon-checks-<pr number>`,
|
||||
# `cancel-in-progress: true` - to stop a second pull request superseding a
|
||||
# first one's run. It did stop that. It also did this, twice, on PR #14:
|
||||
#
|
||||
# run 531 created 17:08:19 `pending / Waiting to run`
|
||||
# cancelled 17:10:44 <- the exact second run 532 was created
|
||||
# run 533 created 17:45:11 `pending / Waiting to run`
|
||||
# cancelled 18:11:22 <- the exact second run 534 was created
|
||||
#
|
||||
# Both cancelled runs have `started_at` at the Unix epoch: they never executed.
|
||||
# A cancelled run posts `failure` on the required context, described
|
||||
# `Canceled after 0s`. Branch protection compares STATE, not description, so
|
||||
# "nothing ran" and "your tests are broken" arrive at the gate as the same red,
|
||||
# and the correct operator response to each is the opposite of the other.
|
||||
#
|
||||
# CI-02 MEASURED THE THREE CANDIDATE SETTINGS ON A THROWAWAY PR (#29) RATHER
|
||||
# THAN READING THE DOCS, because the docs and this Gitea disagree about which
|
||||
# knob does it. In each trial one run was made to hold the single slot and two
|
||||
# further pushes followed:
|
||||
#
|
||||
# trial 1, NO concurrency block - runs 561 and 562 both sat queued together
|
||||
# and both executed. Nothing was cancelled.
|
||||
# trial 2, group kept, cancel-in-progress: FALSE
|
||||
# - run 564 sat `pending / Blocked by required
|
||||
# conditions` and was cancelled at 0s the
|
||||
# second run 565 was created.
|
||||
#
|
||||
# So `cancel-in-progress` IS NOT THE KNOB. A shared group cancels a run that has
|
||||
# not started no matter what that flag says - GitHub documents this ("any
|
||||
# previously pending run in the group will be cancelled") and Gitea 1.27
|
||||
# implements it. The only configuration that leaves a queued run alive is having
|
||||
# no group at all, which is what is written here.
|
||||
#
|
||||
# ⚠️ DO NOT REPLACE THIS WITH A SHA-KEYED GROUP as a compromise. It looks
|
||||
# tempting - two runs on different commits could never share a group, so nothing
|
||||
# would be cancelled - but the only cancellations it still permits are the
|
||||
# same-sha ones, and those are precisely the dangerous half: a red for a run
|
||||
# that never started, landing on a commit that IS still the head. It
|
||||
# concentrates the failure instead of removing it.
|
||||
#
|
||||
# WHAT THIS COSTS, stated honestly: the runner is `capacity: 1` and shared with
|
||||
# the other repo's rig, so a developer who pushes four times in a minute now
|
||||
# occupies it four times instead of once. That is CI-01's own trade written the
|
||||
# other way round - it accepted "a delayed verdict, never a destroyed one" for
|
||||
# the rig, and this accepts the same for pushes. A destroyed verdict here is
|
||||
# worse than a slow one, because it is indistinguishable from a real failure.
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
runs-on: [self-hosted, goodwe-rig]
|
||||
timeout-minutes: 70 # ~4 to run; the rest is queueing for the single slot
|
||||
env:
|
||||
# ⚠️ UNDER /home/homelab, NOT $GITHUB_WORKSPACE (ENV-01, and re-measured
|
||||
# during CI-01). act's workspace is `/root/.cache/act/<id>/hostexecutor`, a
|
||||
# path that exists only INSIDE the runner container. Hand it to the host's
|
||||
# snap-confined dockerd and the build fails with "failed to read
|
||||
# dockerfile", or - worse, and this is the documented case - a bind mount
|
||||
# resolves to an EMPTY DIRECTORY with no error at all. /home/homelab is
|
||||
# mirrored 1:1 into the runner and is under $HOME, where snap confinement
|
||||
# accepts it. The same probe under /tmp failed and under $HOME succeeded.
|
||||
WS: /home/homelab/ci/addon-checks-${{ github.run_number }}
|
||||
steps:
|
||||
# ⚠️ THE CHECKOUT IS TWO LEVELS BELOW THE MOUNT ROOT, DELIBERATELY.
|
||||
# test_freeze.py's find_hwsim() reads `HERE.parents[1] / "goodwe" / "sim"`,
|
||||
# looking for the infra repo as a SIBLING of this one. Check out flat -
|
||||
# goodwe_controller directly at the mount root - and `parents[1]` does not
|
||||
# exist, so the test dies on `IndexError: 1` before its first assertion.
|
||||
# Measured during CI-01; the flat layout was tried first and did exactly
|
||||
# that. $WS/goodwe-addon/goodwe_controller gives parents[1] == $WS.
|
||||
#
|
||||
# ⚠️ WHAT THIS CHECKS OUT IS THE MERGE, NOT THE BRANCH, AND THAT IS THE
|
||||
# WHOLE OF CI-02. What was here before was one line:
|
||||
#
|
||||
# git fetch -q --depth 1 origin "$GITHUB_REF" \
|
||||
# || git fetch -q --depth 1 origin "$GITHUB_SHA"
|
||||
#
|
||||
# CI-02 measured what those two names actually are on this Gitea, on a
|
||||
# deliberately conflicted throwaway PR (#29, runs 558 and 559), by asking
|
||||
# one question per STEP - the Actions log endpoints 404 here, so a step's
|
||||
# own conclusion is the only channel out of a job. The answers:
|
||||
#
|
||||
# GITHUB_REF == refs/pull/<n>/head (step P01 passed)
|
||||
# GITHUB_REF != refs/pull/<n>/merge (step P02 failed)
|
||||
# GITHUB_SHA == the pull request head sha (step P03 passed)
|
||||
# fetching GITHUB_REF succeeds (step P04 passed)
|
||||
# ...and lands on the bare head (step P05 passed)
|
||||
# refs/pull/<n>/merge does not exist (step P06 failed)
|
||||
#
|
||||
# ⚠️ SO THE FALLBACK NEVER FIRED, AND THE DEFECT IS WORSE THAN THE ONE
|
||||
# REPORTED. The ticket says a conflicted PR loses its merge ref and the
|
||||
# `||` quietly grades the bare head instead. It does not: this Gitea has
|
||||
# no merge refs at all - `git ls-remote origin 'refs/pull/*'` advertises
|
||||
# only `/head`, for open and closed pull requests alike - so the FIRST
|
||||
# half always succeeds, and both halves name the same commit anyway
|
||||
# (GITHUB_REF resolves to GITHUB_SHA). The gate has never once graded
|
||||
# merged content, on any pull request in this repository's history. Green
|
||||
# has meant "this branch passed on its own", which on a base that has
|
||||
# moved - and `block_on_outdated_branch` is off here, so it usually has -
|
||||
# is not the content that lands. PR #14 reporting success while
|
||||
# `mergeable: false` was not the fallback misfiring; it was the gate
|
||||
# working exactly as written.
|
||||
#
|
||||
# A fallback that cannot fire cannot be "kept for the case it was for", so
|
||||
# it is gone from this path and kept, ANNOUNCED, on the one path that has
|
||||
# no pull request to merge (workflow_dispatch, below).
|
||||
#
|
||||
# ⚠️ NOT `--depth 1` ANY MORE. A merge needs the merge base, and a shallow
|
||||
# pair does not have one. Measured before changing it: a full fetch of
|
||||
# this repository is 6.8 MB and 1.1 s against this server, over 95
|
||||
# commits. The depth flag was buying nothing worth a wrong verdict.
|
||||
#
|
||||
# ⚠️ THE WORKFLOW AND THE CONTENT NOW COME FROM DIFFERENT TREES. Gitea
|
||||
# picks the workflow file from refs/pull/<n>/head, so THIS file is always
|
||||
# the branch's; the tree graded below - including .gitea/ci/Dockerfile -
|
||||
# is the merge. That is the right way round (a pull request must not be
|
||||
# able to grade itself with a gate the base has not seen) but it means a
|
||||
# change to this file is tested by the branch it is on, which is how you
|
||||
# are reading this.
|
||||
- name: check out the base branch and the pull request head (git; no node for actions/checkout)
|
||||
env:
|
||||
URL: ${{ github.server_url }}/${{ github.repository }}.git
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PRNUM: ${{ github.event.pull_request.number }}
|
||||
BASEREF: ${{ github.base_ref }}
|
||||
run: |
|
||||
set -eu
|
||||
rm -rf "$WS" && mkdir -p "$WS/goodwe-addon" && cd "$WS/goodwe-addon"
|
||||
git init -q .
|
||||
git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:${TOKEN}@#")"
|
||||
if [ -n "$PRNUM" ]; then
|
||||
# Two fetches, not one, so that a failure to reach either ref fails
|
||||
# HERE with the ref in the message, rather than inside the merge
|
||||
# step where it would read as a conflict. `github.base_ref` being
|
||||
# populated at all was checked on the probe PR too (step Q01).
|
||||
git fetch -q origin "+refs/heads/$BASEREF:refs/remotes/origin/base"
|
||||
git fetch -q origin "+refs/pull/$PRNUM/head:refs/remotes/origin/pr"
|
||||
git checkout -qf refs/remotes/origin/base
|
||||
echo "base $BASEREF $(git rev-parse refs/remotes/origin/base)"
|
||||
echo "head refs/pull/$PRNUM/head $(git rev-parse refs/remotes/origin/pr)"
|
||||
else
|
||||
# NO PULL REQUEST (workflow_dispatch). There is nothing to merge
|
||||
# into, so this path grades a bare ref and SAYS SO - which is all
|
||||
# the old `||` ever needed to do. It is kept here because a branch
|
||||
# can be deleted or renamed between the dispatch and the fetch, and
|
||||
# unlike the pull_request path that is a case nobody has ruled out.
|
||||
if git fetch -q origin "$GITHUB_REF"; then
|
||||
echo "GRADED: the bare ref $GITHUB_REF - NOT merged content (no pull request in this event)."
|
||||
else
|
||||
git fetch -q origin "$GITHUB_SHA"
|
||||
echo "GRADED: the bare commit $GITHUB_SHA - NOT merged content, and NOT $GITHUB_REF either, which would not fetch."
|
||||
fi
|
||||
git checkout -qf FETCH_HEAD
|
||||
fi
|
||||
echo "checked out $(git log --oneline -1)"
|
||||
|
||||
# ⚠️ THIS STEP IS THE ONE THAT MAKES A GREEN MEAN SOMETHING. It exists as
|
||||
# its OWN step rather than as three lines of the checkout because the
|
||||
# Actions log endpoints 404 on this Gitea and
|
||||
# /api/v1/repos/.../actions/runs/<id>/jobs - which carries a per-step
|
||||
# conclusion - is the only machine-readable account of what a run did. A
|
||||
# reader who sees this step red knows the pull request does not apply and
|
||||
# that NO TEST RAN; a reader who sees the next two red knows the tests ran
|
||||
# and spoke. Collapse them back together and that distinction is gone
|
||||
# again, which is the shape of defect this whole ticket is about.
|
||||
#
|
||||
# The refusal is a FAILURE rather than a skip on purpose. "Reports
|
||||
# nothing" would leave the required context pending forever, which this
|
||||
# file's own header explains is unmergeable-forever; and a conflicted pull
|
||||
# request cannot be merged by Gitea anyway, so the red blocks nothing that
|
||||
# was not already blocked. What it does is stop a green being posted about
|
||||
# content that cannot land.
|
||||
- name: grade the merge, not the branch - refuse a pull request that does not apply
|
||||
env:
|
||||
PRNUM: ${{ github.event.pull_request.number }}
|
||||
BASEREF: ${{ github.base_ref }}
|
||||
run: |
|
||||
set -eu
|
||||
cd "$WS/goodwe-addon"
|
||||
if [ -z "$PRNUM" ]; then
|
||||
echo "no pull request in this event - nothing to merge, and the previous step said what it graded."
|
||||
exit 0
|
||||
fi
|
||||
if git -c user.email=checks@localhost -c user.name=checks \
|
||||
merge --no-edit refs/remotes/origin/pr; then
|
||||
echo "GRADED: refs/pull/$PRNUM/head merged into $BASEREF"
|
||||
echo " result $(git rev-parse HEAD)"
|
||||
echo " tree $(git rev-parse 'HEAD^{tree}')"
|
||||
else
|
||||
git merge --abort 2>/dev/null || true
|
||||
echo "REFUSED: refs/pull/$PRNUM/head does not merge into $BASEREF."
|
||||
echo " NOTHING HAS BEEN TESTED. This is not a test failure - no test ran."
|
||||
echo " Rebase or merge $BASEREF into the branch and push again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile)
|
||||
run: |
|
||||
set -eu
|
||||
docker build -q -t goodwe-addon-checks:1 "$WS/goodwe-addon/.gitea/ci" >/dev/null
|
||||
docker run --rm goodwe-addon-checks:1 python3 -c \
|
||||
'import aiohttp, paho.mqtt.client as m, sys; print("python", sys.version.split()[0], "aiohttp", aiohttp.__version__)'
|
||||
|
||||
# ⚠️ THE FILE LIST IS DERIVED FROM THE TREE, NOT TYPED HERE. A hand-written
|
||||
# list is how a new test file joins the repo and is never run - the exact
|
||||
# shape of failure this ticket exists to close. The step globs `test_*.py`,
|
||||
# prints the count, and REFUSES A COUNT OF ZERO: a loop over an empty glob
|
||||
# exits 0 and would report a green gate that examined nothing.
|
||||
#
|
||||
# ⚠️ test_loop.py TAKES ~93 SECONDS OF WALL CLOCK ON PURPOSE (its own
|
||||
# docstring says so, and says why: it drives the real `run_control()`
|
||||
# against a real wall-clock floor, and mocking the clock would leave the
|
||||
# assertion testing the mock). It is not hung. Do not "fix" it with a
|
||||
# shorter timeout - a 60 s limit was tried during CI-01 and reported
|
||||
# exactly the false hang the docstring predicts.
|
||||
#
|
||||
# ⚠️ WHAT THIS RUN DOES NOT COVER, stated because a pass here would
|
||||
# otherwise over-claim. test_freeze.py's third section wants the infra
|
||||
# repo's `sim/hwsim.py` to drive a real HTTP meter through the real
|
||||
# transport. admin/goodwe is PRIVATE (checked via the API) and this
|
||||
# workflow's GITHUB_TOKEN is minted for THIS repository only, so hwsim is
|
||||
# absent here and `find_hwsim()` returns None. The rest of the file runs
|
||||
# and passes. It does so SILENTLY - the file prints nothing to say the
|
||||
# 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 -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
|
||||
echo "NO test_*.py FOUND - the mount is empty or the layout moved. NOTHING has been checked."
|
||||
exit 1
|
||||
fi
|
||||
echo "$# test file(s) to run"
|
||||
rc=0
|
||||
for f in "$@"; do
|
||||
start=$(date +%s)
|
||||
if python3 "$f" > /tmp/out 2>&1; then
|
||||
echo "PASS $f ($(( $(date +%s) - start ))s)"
|
||||
else
|
||||
echo "FAIL $f ($(( $(date +%s) - start ))s)"
|
||||
tail -40 /tmp/out
|
||||
rc=1
|
||||
fi
|
||||
done
|
||||
exit $rc
|
||||
'
|
||||
|
||||
- name: clean up
|
||||
if: always()
|
||||
run: |
|
||||
rm -rf "$WS" || true
|
||||
# Root-owned by this runner inside a directory the `homelab` user owns,
|
||||
# and sudo on that box is interactive-only, so the user cannot clean
|
||||
# this up and the job that made it has to.
|
||||
ls -1dt /home/homelab/ci/addon-checks-*/ 2>/dev/null | tail -n +6 | xargs -r rm -rf || true
|
||||
+63
-2
@@ -12,7 +12,7 @@ a GoodWe GW5000S-BP.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| running | `local_goodwe_controller` **0.2.0**, `boot: auto`, holding grid at zero |
|
||||
| running | `local_goodwe_controller` **0.4.0**, `boot: auto`, holding grid at zero |
|
||||
| installed as | a **Local** add-on: `/addons/goodwe_controller` on HA OS at 192.168.2.158 |
|
||||
| repo | Gitea `admin/goodwe-addon` (ssh://git@192.168.2.147:2222) — **private** |
|
||||
| tuning | gain 0.6, `max_w` 5000, slew 1000, deadband 15, step 10, saturation 500/3, heartbeat 10 s |
|
||||
@@ -52,9 +52,65 @@ Run the checks before deploying. No framework needed:
|
||||
|
||||
```bash
|
||||
cd goodwe_controller
|
||||
python3 test_control.py && python3 test_maintenance.py && python3 test_arbiter.py
|
||||
python3 test_control.py && python3 test_maintenance.py && python3 test_arbiter.py \
|
||||
&& python3 test_validate.py && python3 test_p1.py && python3 test_watchdog.py \
|
||||
&& python3 test_loop.py
|
||||
```
|
||||
|
||||
⚠️ **Mutating a module to prove a check bites? Read `goodwe_controller/
|
||||
MUTATION.md` first, and run `goodwe_controller/tools/mutation_run.sh`.** A
|
||||
same-size edit reverted inside one second changes neither the file size nor the
|
||||
whole-second mtime, so CPython reuses the bytecode it already compiled and the
|
||||
suite grades code that is not there - once already, reported as a survivor and
|
||||
disproved by disassembly (TEST-15). `python3 -B` does **not** save you: it stops
|
||||
bytecode being written, not a stale `.pyc` being read. The purge is the fix.
|
||||
|
||||
`test_watchdog.py` covers SAFETY-02's logic, its arming gate (derived from the
|
||||
real arbiter, not passed in), its trip path, and the fact that the latch survives
|
||||
an add-on restart. Its headline validation is not here at all: `sim/replay_sign.py`
|
||||
in the infra repo drives the same module over the two captured RS485 days and must
|
||||
fire on all four of the 2026-08-20 episodes and stay silent on 2026-08-23. That
|
||||
needs both checkouts:
|
||||
|
||||
```bash
|
||||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 sim/replay_sign.py
|
||||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 sim/replay_sign.py --stub
|
||||
```
|
||||
|
||||
The second run checks the grader still *discriminates* rather than merely still
|
||||
passing — a watchdog that never trips and one that always trips must both be
|
||||
rejected.
|
||||
|
||||
⚠️ **If you are testing the failsafe by hand, note that stopping and starting the
|
||||
add-on will not clear a trip.** That is the point of it; use the *Start / stop
|
||||
control* button twice, or delete the trip from `/data/state.json`.
|
||||
|
||||
⚠️ **DEV-02 renamed that key. There is one `SignWatchdog` per inverter, so the
|
||||
LIVE latch lives under `unit/<inverter>/sign_tripped`** —
|
||||
`unit/inverter-a/sign_tripped` on a single-inverter site. **That is the one to
|
||||
delete if you are clearing a trip by hand**; deleting the flat one alone clears
|
||||
nothing.
|
||||
|
||||
⚠️ **The flat `sign_tripped` is still maintained, though — it is the ROLLBACK
|
||||
VIEW, not a fossil.** It is written alongside the namespaced key on a trip and
|
||||
cleared alongside it when you press *Start / stop control*, so an add-on you
|
||||
**downgrade** past this version still finds the latch it should. It is also read
|
||||
on **every** start, and the one disagreement it can produce is acted on: *the
|
||||
flat key says tripped and no inverter came up latched* means the latch was
|
||||
written by an older add-on while this install was rolled back — or the migration
|
||||
lost it — and the controller **re-arms the latch and shouts in the log** rather
|
||||
than coming up disarmed with a green banner. So if you edit `/data/state.json`
|
||||
by hand, clear **both** spellings or the next start will put the trip back.
|
||||
|
||||
⚠️ `test_loop.py` takes about **95 seconds** (measured 93 s) and that is not a bug. It
|
||||
drives the real control loop, which sleeps 1 s a tick, eight times over, to prove a
|
||||
wall-clock property about it (SAFETY-12); mocking the clock there would leave the
|
||||
assertion testing the mock. The other six suites together are ~440 checks and finish in
|
||||
seconds — `test_watchdog.py` spends ~3 s of that driving a real `Controller` through a
|
||||
trip. If you shorten `test_loop.py`'s windows, re-measure and correct this figure —
|
||||
an understated runtime is what makes a slow test look like a hung one, and a slow test
|
||||
that looks hung gets deleted.
|
||||
|
||||
---
|
||||
|
||||
## 3. Known broken / unfinished
|
||||
@@ -134,6 +190,11 @@ Each of these was found the expensive way. They are in the code comments too.
|
||||
`timestamp` attribute.
|
||||
- **Alpine ships paho-mqtt 1.x**, which has no `CallbackAPIVersion`, and musl has
|
||||
no aiohttp wheel — install from apk, not pip.
|
||||
- **A mutation run can grade code that is not there.** CPython validates a
|
||||
`.pyc` on (source size, source mtime in whole seconds), so a same-size edit
|
||||
reverted within one second is invisible to it and the old bytecode runs. It
|
||||
manufactures *survivors*, never reds. Purge `__pycache__` -
|
||||
`tools/mutation_run.sh` does - and see `goodwe_controller/MUTATION.md`.
|
||||
- **The inverter holds its last command forever.** It has no meter-timeout. Every
|
||||
failsafe here exists because of that one fact, and anything in doubt commands
|
||||
**0 W** rather than holding.
|
||||
|
||||
@@ -19,6 +19,8 @@ goodwe_controller/ the add-on
|
||||
test_control.py runnable checks — no framework needed
|
||||
test_maintenance.py runnable checks — walks a full cycle in fake time
|
||||
DOCS.md the add-on's documentation tab
|
||||
MUTATION.md how to run a mutation so the result means something
|
||||
tools/mutation_run.sh the mutation harness - purges bytecode, then grades
|
||||
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
|
||||
estop/rs485_log.py optional RS485 e-stop / bus witness
|
||||
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
|
||||
@@ -82,7 +84,12 @@ python3 test_control.py
|
||||
python3 test_maintenance.py
|
||||
```
|
||||
|
||||
Both must pass before shipping any change. They are not unit-test theatre —
|
||||
Both must pass before shipping any change. To prove a check *bites* - the way
|
||||
almost every claim in this repository was established - mutate the module and
|
||||
run `tools/mutation_run.sh`, which clears `__pycache__` first. It is not
|
||||
optional: a same-size edit reverted inside one second leaves CPython reusing the
|
||||
old bytecode, and the run grades code that is not there. `goodwe_controller/MUTATION.md`
|
||||
has the mechanism, the measurement, and what the flags do not do. They are not unit-test theatre —
|
||||
each assertion corresponds to a rule whose absence produced an observed failure
|
||||
on real hardware, and the comments in `control.py` say which.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
# CFG-04 — how a second inverter is declared, and why that shape was measured
|
||||
|
||||
**Status: DECIDED 2026-09-04 — a list of objects, `inverters:`, with `[]` as
|
||||
the shipped default.** The alternative on the table was the `static_profile`
|
||||
precedent: a structured value carried as a JSON **string** in a `str?` option.
|
||||
Both work. The list wins on three counts that were measured rather than argued,
|
||||
and the empty default is not cosmetic — without it the upgrade breaks.
|
||||
|
||||
Filed with CFG-04. Card 6a99fdf9d381935eddce4467.
|
||||
|
||||
---
|
||||
|
||||
## 1. The question, and why it could not be answered from the documentation
|
||||
|
||||
`config.yaml` used `list(off|ha_dsmr|…)` for enums and `str?` for free text, and
|
||||
**there was no list-of-objects anywhere in the file**. `static_profile`
|
||||
(`options:` `""`, `schema:` `str?`) carries a whole tariff structure as one JSON
|
||||
line, and its own comment says why: *"a JSON string rather than a nested schema,
|
||||
deliberately … it survives config.yaml's flat options block"*. That comment is
|
||||
evidence that a nested schema was **doubted**, not that it was tried.
|
||||
|
||||
So the question was not "is this expressible in YAML" but **"does the Supervisor
|
||||
this site actually runs accept it"** — and the Supervisor pins the options schema
|
||||
to the *installed* version (`docs/pickup.md`), so guessing wrong is not a
|
||||
compile error, it is an operator whose add-on will not start after an update
|
||||
(`docs/deploy-rollback.md` §4, in the forward direction).
|
||||
|
||||
## 2. How it was answered
|
||||
|
||||
`tools/supervisor_shape_probe.py`, committed beside this record, runs
|
||||
**Supervisor's own `AppOptions` validator and its own `SCHEMA_APP_CONFIG`** out
|
||||
of the release image — `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`
|
||||
— against **this repository's `config.yaml`**. 2026.08.0 is the version the live
|
||||
install reports (`supervisor_version` in its own backup metadata, 2026-09-03,
|
||||
alongside Home Assistant 2026.8.3).
|
||||
|
||||
Nothing was re-implemented: if the Supervisor changes its rules, the script
|
||||
changes its answer. Nothing was touched, either — it reads the file and runs a
|
||||
validator in a throwaway container. It does **not** talk to the live Supervisor
|
||||
and does not POST options, because the schema conflict is the one thing you do
|
||||
not want to discover on a running site.
|
||||
|
||||
### What it printed, 2026-09-04
|
||||
|
||||
⚠️ **This transcript was RE-RUN after review, against the same image tag, and
|
||||
it is not the one this record first carried.** The first §B validated the
|
||||
reconstructed live blob — a blob missing many required keys that have nothing
|
||||
to do with this card — and quoted its refusal as evidence about `inverters`.
|
||||
It never was: that refusal names whichever unrelated key the validator reaches
|
||||
first, and it has printed `meter_freeze_s`, `p1_phase_export_entities` and
|
||||
`sign_disagree_w` on three different runs. §B is now the isolated
|
||||
counterfactual — the shipped `options:` block, and the same block with
|
||||
`inverters` and nothing else removed — and it asserts on the KEY NAME in the
|
||||
refusal. The mis-cited half is kept, relabelled, as §B2. See §4.
|
||||
|
||||
```
|
||||
App 'GoodWe RS485 Controller' uses deprecated 'arch' values: ['armv7']
|
||||
Option 'inverters' does not exist in the schema for GoodWe RS485 Controller (local_goodwe_controller)
|
||||
== A. the whole config.yaml through SCHEMA_APP_CONFIG ==
|
||||
PASS Supervisor accepts the add-on manifest, version 0.6.0
|
||||
schema declares 66 keys; inverters -> [{"name": "str", "setpoint_entity": "str", "max_w": "int(100,5000)", "soc_entity": "str?", "batt_entity": "str?", "batt_invert": "bool?"}]
|
||||
options ship inverters -> []
|
||||
|
||||
== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
|
||||
PASS the shipped `options:` block validates AS IT STANDS - so anything refused on the next line is refused for the one key that was removed
|
||||
PASS ...and the SAME block with ONLY `inverters` removed is REFUSED, BY NAME - which is every options blob written before 0.6.0, since the key did not exist when they were written
|
||||
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
|
||||
== B2. AC3: a reconstructed pre-0.6.0 blob upgrades into this version ==
|
||||
PASS the reconstructed raw blob is REFUSED - for an unrelated missing key, because the reconstruction is incomplete; see the refusal printed below and §B for the `inverters` measurement
|
||||
Missing option 'sign_disagree_w' in root in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS the same blob MERGED with these defaults - what App.options actually is
|
||||
inverters=[] setpoint_entity='number.kelder_goodwe_master_stage1_goodwe_setpoint_w' max_w=5000
|
||||
|
||||
== C. AC2: a two-inverter blob an operator would POST ==
|
||||
PASS two inverters
|
||||
[{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w", "soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power", "max_w": 5000}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_soc", "batt_entity": "s
|
||||
|
||||
== D. what the schema itself refuses, per entry ==
|
||||
PASS an entry with no name is refused
|
||||
Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with no setpoint_entity is refused
|
||||
Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with no max_w is refused
|
||||
Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with max_w over the range is refused
|
||||
value must be at most 5000.0
|
||||
PASS a JSON STRING where the list belongs is refused
|
||||
Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
|
||||
== E. the ROLLBACK direction, and the operator's UI ==
|
||||
PASS a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse
|
||||
['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert']
|
||||
PASS Supervisor's UI renderer turns it into a repeatable section rather than choking - an operator can add the second inverter in the Configuration tab, which the JSON-string alternative could not offer
|
||||
[{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer
|
||||
|
||||
== all cases passed ==
|
||||
```
|
||||
|
||||
*(`arch: armv7` deprecation is pre-existing and unrelated.)*
|
||||
|
||||
## 3. What that establishes, item by item
|
||||
|
||||
| Claim | Evidence |
|
||||
|---|---|
|
||||
| A list of objects is a legal `schema:` element | §A — the whole manifest passes `SCHEMA_APP_CONFIG`, whose `SCHEMA_ELEMENT` explicitly permits `[{str: Self}]` |
|
||||
| Per-entry types are enforced | §D — `max_w: 9999` refused with *"value must be at most 5000.0"* |
|
||||
| A missing required key is refused **by name** | §D — *"Missing option 'name' in inverters"* |
|
||||
| A two-inverter blob validates | §C |
|
||||
| The operator can edit it in the UI | §E — `UiOptions` renders `{"type": "schema", "multiple": true}` |
|
||||
| The `inverters: []` default is what makes a pre-0.6.0 blob validate | §B — the shipped defaults **minus only that key** are refused with *"Missing option 'inverters' in root"*; unmodified they pass |
|
||||
| A downgrade drops the key rather than refusing | §E — a schema without `inverters` **drops the key with a warning**; it is not the §B trap in reverse |
|
||||
|
||||
## 4. ⚠️ The one that decided the default: `inverters: []`
|
||||
|
||||
§B is the load-bearing measurement, and it is an **isolated counterfactual**:
|
||||
exactly one key differs between its two runs. `supervisor/apps/options.py`'s
|
||||
`_check_missing_options()` treats a nested list as **required** unless its
|
||||
element is an optional scalar — a list whose element is a dict is never
|
||||
optional. So the shipped `options:` block validates as it stands, and the same
|
||||
block with **only** `inverters` removed is answered with:
|
||||
|
||||
```
|
||||
Missing option 'inverters' in root in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
```
|
||||
|
||||
The live install's stored options have no such key, because it did not exist
|
||||
when they were written. If `config.yaml` declared `inverters:` in `schema:` and
|
||||
**not** in `options:`, then on update `App.test_update_schema()` would log *"new
|
||||
schema is not compatible"* and the operator would be left with an add-on that
|
||||
will not start — and the schema they would need to fix it with is pinned to the
|
||||
version they cannot install.
|
||||
|
||||
What makes it safe is that `App.options` is
|
||||
`deepmerge(config.yaml's options:, the operator's persisted options)`, so a
|
||||
shipped `[]` fills the gap before validation ever runs. §B shows the halves that
|
||||
carry the claim — the defaults **minus this one key refused, by this key's
|
||||
name**, and the unmodified defaults **accepted** — and §B2 shows the merge doing
|
||||
its job on a reconstructed pre-0.6.0 blob.
|
||||
|
||||
⚠️ **What §B2 does NOT show, and what this record used to claim it did.** §B2's
|
||||
`LIVE` dict is an incomplete reconstruction: it carries the keys `HANDOVER.md`
|
||||
and `docs/deploy-rollback.md` record and no others, so it is missing many
|
||||
required keys unrelated to this card. Its RAW half is therefore refused for
|
||||
whichever of *those* the validator reaches first — three separate runs have
|
||||
printed `meter_freeze_s`, `p1_phase_export_entities` and `sign_disagree_w` —
|
||||
and the earlier version of this section quoted that refusal as if it named
|
||||
`inverters`. It never did. The string above was produced by §B, where the
|
||||
`inverters` key is the only thing that varies.
|
||||
|
||||
The probe's assertion on that line is on the **key name in the refusal**, not on
|
||||
"a refusal happened". With the weaker assertion §B would have gone on printing
|
||||
PASS after someone deleted `inverters: []` from `config.yaml` — blind to the one
|
||||
regression it is the committed evidence for. It is now checked both ways: with
|
||||
the default deleted the probe prints `2 CASE(S) FAILED` and exits 1; restored,
|
||||
it prints `all cases passed` and exits 0.
|
||||
|
||||
The merge strategy was checked too, because it is the other way this could have
|
||||
gone wrong: `_OPTIONS_MERGER` is `type_strategies=[(dict, ["merge"])]` with an
|
||||
`override` fallback, so **lists override rather than concatenate**. A shipped
|
||||
one-element default plus an operator's two-element list would otherwise have
|
||||
produced three entries — with inverter A declared twice, aimed at one actuator,
|
||||
which is exactly the hazard SAFETY-28 refuses. `[]` makes that unreachable and
|
||||
the override rule means even a non-empty default could not have grown a roster.
|
||||
|
||||
## 5. Why the list, and not the `str?` + JSON precedent
|
||||
|
||||
The JSON string works — §C of the earlier draft of this probe validated the same
|
||||
roster as a `str?` — and if the list had failed it is what would have shipped.
|
||||
It loses on three things:
|
||||
|
||||
1. **SAFETY-28's writer census stops working.** `actuator_writers()` walks the
|
||||
options **tree** looking for `setpoint_entity` at any depth, and its own
|
||||
docstring anticipates this card: *"Add CFG-04's per-inverter config — a list
|
||||
of inverters, each with its own `setpoint_entity` — and there are two real
|
||||
writers"*. Inside a JSON **string** those entities are invisible to it, so
|
||||
the §1.5 two-writers guarantee would have had to be re-implemented against a
|
||||
parsed copy — a second place for the census to be wrong.
|
||||
2. **No per-field validation.** With `str?` the Supervisor sees one opaque
|
||||
string: a typo'd rating, a missing name and a malformed entity id all arrive
|
||||
at the add-on identically, and every error message has to be written here.
|
||||
§D shows the Supervisor doing that work, in its own words, before the add-on
|
||||
starts.
|
||||
3. **No UI.** §E renders a repeatable section; a `str?` renders a text box into
|
||||
which an operator hand-types JSON. `static_profile` accepts that cost because
|
||||
§14 gives it exactly one key and its structure is a tariff document; a roster
|
||||
is a list of devices an operator adds one at a time.
|
||||
|
||||
## 6. What is deliberately NOT in the shape
|
||||
|
||||
- **No per-entry defaults.** `name`, `setpoint_entity` and `max_w` are
|
||||
**required**; the sensors are optional and their absence means *this unit has
|
||||
no such sensor*, never *use the other one's*. CFG-03 keeps defaults in exactly
|
||||
one place, and a per-entry default would be a second one — worse, it would be
|
||||
a per-entry default whose value is another inverter's measurement, which is
|
||||
the sibling-grading defect DEV-02 spent a card removing.
|
||||
- **No `gain`, `slew_w`, `integrator_max_w` per entry.** §6.2 and ALLOC-01 are
|
||||
ONE integrator on P1 above the split. `app/inverter.py`'s module docstring
|
||||
carries that argument; the config must not make a second one expressible.
|
||||
- **No `inverters` entry for the flat keys.** They ARE the single-inverter
|
||||
shape. Declaring both is refused (rule 9e) rather than merged, because a
|
||||
config that silently becomes a different config is worse than one that
|
||||
refuses to load.
|
||||
|
||||
## 7. Revisit triggers
|
||||
|
||||
- **A Supervisor upgrade that changes `AppOptions`.** Re-run the probe with the
|
||||
new version tag. The whole point of committing it is that this costs a minute.
|
||||
- **SPIKE-04 measuring inverter B.** It supplies a real `max_w` for the second
|
||||
entry. It does not change the shape.
|
||||
- **A second `Maintenance` FSM.** `validate.py` rule 6 grades
|
||||
`maintenance_charge_w` against the first entry of `unit_caps_indexed()`
|
||||
because `Controller.maint_unit` is `inverters[0]`. When that stops being true, that line moves with it.
|
||||
@@ -0,0 +1,417 @@
|
||||
# SAFETY-25 — does the grid-following loop get a reserve floor?
|
||||
|
||||
**Status: DECIDED 2026-09-02 — Option A.** The product owner has answered §1:
|
||||
**the BMS cut-out stays the only backstop for demand-driven discharge on the
|
||||
grid-following loop.** **Option B — a loop-side reserve floor — is DEFERRED**,
|
||||
not rejected, with an explicit revisit trigger recorded in §0c: **`ALLOC-01`**.
|
||||
|
||||
Option A **is the shipped behaviour**, so recording this decision changed no
|
||||
code. The honesty half of SAFETY-25 — `peak_soc_floor`'s scope made true in the
|
||||
config, in `DOCS.md` and on the status page — had already shipped and was
|
||||
required under every option. What was missing was the answer, and the answer is
|
||||
now here rather than in a Trello card that scrolls away.
|
||||
|
||||
**Everything from §1 down is the memo exactly as it was written before the
|
||||
decision, and is deliberately left intact.** It is the record of *why*, and a
|
||||
future reader arriving at the revisit trigger needs the options analysis, the
|
||||
costs, the measured replay and the trap in Option B's obvious shape — not a
|
||||
one-line verdict. §5 still labels Option B *"(recommended)"* and §6 still says
|
||||
"Option B is what I would build"; those were the engineering recommendation put
|
||||
to the owner, they are preserved as written, and **§0 is what was decided.**
|
||||
|
||||
Filed with SAFETY-25. Card 6a94b2b98b73123d520e69f2. The other half of that
|
||||
ticket — making `peak_soc_floor`'s scope honest in the config, the docs and on
|
||||
the status page — **shipped in SAFETY-25's own PR** and did not wait on this,
|
||||
because it was required whichever way the answer went.
|
||||
|
||||
*This directory exists so a recorded decision has somewhere to live that is not a
|
||||
comment in a source file nobody reads and not a Trello card that scrolls away.
|
||||
`SAFETY-27` asks for a second one (the naming of the floors); it belongs here
|
||||
too, and §0d says what N=2 does to it.*
|
||||
|
||||
---
|
||||
|
||||
## 0. The decision
|
||||
|
||||
### 0a. What was decided, and the owner's reasoning
|
||||
|
||||
**Option A. The BMS remains the only backstop for demand-driven discharge on the
|
||||
grid-following loop. Option B is deferred until `ALLOC-01` exists.**
|
||||
|
||||
The reasoning is not "Option B is wrong" — §5 and §6 still stand as engineering.
|
||||
It is that **deferring is the safe direction, and building now is not.**
|
||||
|
||||
Option B's safety proof is the ordering constraint in §5:
|
||||
|
||||
bms_soc_floor < loop_soc_floor <= peak_soc_floor
|
||||
|
||||
That proof has an unstated premise: **the reserve predicate and
|
||||
`discharge_permitted()` must read the SAME state of charge.** At N=1 they do,
|
||||
which is why the proof reads as airtight, and why part 10d can run it through
|
||||
the real arbiter and watch the contradiction disappear.
|
||||
|
||||
**With two packs they read different numbers.** The reserve binds on whichever
|
||||
pack is emptier and files `hi = 0`; the peak guard, reading the other pack or an
|
||||
average of the two, is *not* floor-limited and files `lo = excess > 0`. Then
|
||||
`lo > hi`, arbiter clause 3 fires, and the result is **0 W — the battery parked,
|
||||
in the billed quarter.** That is precisely the failure §5 marks *"⚠️ fatal if
|
||||
missed"*, arriving by a route the ordering rule cannot close, because
|
||||
`loop_soc_floor <= peak_soc_floor` is still perfectly satisfied while it happens.
|
||||
|
||||
**And no config-ordering validator can catch it.** The rule §5 proposes — refuse
|
||||
the bad ordering at startup, in CFG-01's manner — grades *configuration*. This is
|
||||
a **runtime divergence between two readings**, not a config ordering. The startup
|
||||
check would pass, every time, on the exact tree that parks the battery.
|
||||
|
||||
So the shape of the risk decides the direction: **Option A's cost is energy
|
||||
(§5, "every watt below the reserve becomes billed import" — inverted: the pack
|
||||
may sit on its own cut-out). Option B's cost, built too early, is a parked
|
||||
battery in the one quarter of the month that sets the bill.** One of those is a
|
||||
tariff line; the other is the failure this memo already calls fatal. Take the
|
||||
one that cannot park the battery, and build Option B when its proof holds again.
|
||||
|
||||
### 0b. What this decision does NOT say
|
||||
|
||||
- It does **not** say a loop-side reserve is a bad idea. §5's protection
|
||||
argument — keeping the pack off its own cut-out, which is the `MAINT-01` drift
|
||||
condition — is unchanged and still wanted.
|
||||
- It does **not** close §4's fifth-key question. `SAFETY-27` still owns the
|
||||
naming of the floors, and any future `loop_soc_floor` still lands after it.
|
||||
- It does **not** weaken §6 item 3: **the tripwire in `test_peakguard.py`
|
||||
part 10 stays.** A deferred option needs its tripwire more than an open one
|
||||
does, because "deferred" is exactly the state in which someone builds it by
|
||||
accident. Part 10f still takes red on the line a ninth claim source is
|
||||
written. Do not remove it.
|
||||
|
||||
### 0c. The revisit trigger: `ALLOC-01`
|
||||
|
||||
**`ALLOC-01` is the trigger, and it is a trigger because it changes the SHAPE of
|
||||
the two claims, not because it changes anyone's mind.**
|
||||
|
||||
`ARCH-01` decided that the site grid target is **allocated host-side, above the
|
||||
driver seam**: one integrator on P1, a sum-invariant split into per-inverter
|
||||
shares, and each inverter told a function of **its own share** only. It also
|
||||
decided that **per-inverter constraints are consumed by the allocator as
|
||||
INTERVALS, never filed as site-level claims** — which is the structural fix for
|
||||
the cross-inverter `lo > hi -> 0 W` contradiction.
|
||||
|
||||
Under the allocator, the two claims that collide in §0a stop sharing a claim
|
||||
list at all:
|
||||
|
||||
| | today (N=1, no allocator) | under `ALLOC-01` |
|
||||
|---|---|---|
|
||||
| the loop reserve | a site `hi` at `P_LOOP` | a **per-inverter `hi`**, consumed at **stage A** as that unit's interval |
|
||||
| the peak floor | a site `lo` at `P_PEAK` | still a **site `lo`**, filed at **stage B** against the site target |
|
||||
|
||||
**Different claim lists, evaluated at different stages, structurally unable to
|
||||
cross.** The reserve bounds one inverter's share; the peak floor bounds the site
|
||||
sum. There is no arbiter resolution in which one is the other's `lo` and `hi`,
|
||||
so §0a's failure cannot be constructed — not "is guarded against", *cannot be
|
||||
constructed*. That is the same class of argument as §5's ordering rule but a
|
||||
stronger one: §5 makes the crossing harmless, `ALLOC-01` makes it unreachable.
|
||||
|
||||
**That is the shape in which Option B becomes safe to build.** When `ALLOC-01`
|
||||
exists, reopen this memo at §5 Option B, restate the reserve as a stage-A
|
||||
per-inverter interval, and the ordering rule becomes an implementation
|
||||
convenience rather than the load-bearing proof.
|
||||
|
||||
### 0d. What has to be RE-EARNED at N=2, stated plainly
|
||||
|
||||
Two things this decision leans on do not survive a second pack, and saying so is
|
||||
part of the decision rather than a footnote to it.
|
||||
|
||||
**1. `SAFETY-27` now names FIVE floors across TWO packs.** §4's table already
|
||||
carries four keys plus "the loop"; two of them ship the same default of 20 from
|
||||
different cards. At N=2 each of those is a floor *per pack*, and "the SoC" stops
|
||||
being a scalar the config can bound — which is the same divergence that makes
|
||||
§0a's contradiction possible. `SAFETY-27` inherits a harder question than the
|
||||
one it was filed with.
|
||||
|
||||
**2. `test_peakguard.py` part 10f — the guarantee that made SAFETY-25 mergeable —
|
||||
CANNOT EXPRESS TWO PACKS, and will not cover the two-pack case.** Say it flatly,
|
||||
because it is the load-bearing pin (§6 item 3: PR #31 shipped this section
|
||||
claiming a tripwire it did not have, and the reviewer disproved the claim by
|
||||
building Option B under a green suite):
|
||||
|
||||
- 10f's dynamic half is a **scalar 0–100 % SoC sweep** — one number swept over
|
||||
one range, in the idle and floor-limited states — against a **single-series
|
||||
capture** (§7: the fixtures carry signed net power at the meter and nothing
|
||||
else; the pack is modelled, and one pack is modelled).
|
||||
- A two-pack exposure lives in the **difference** between two SoC readings. A
|
||||
sweep over one scalar visits no point of that space, at any resolution. It
|
||||
cannot fail on the divergence because it cannot represent it.
|
||||
- 10f's **static** half — the AST scan pinning the eight claim sources in
|
||||
`Controller.cycle()` — survives, and is the half that keeps working: a ninth
|
||||
source still goes red on the line it is written, at N=2 as at N=1. It is the
|
||||
*dynamic* half, the one that proves the named sources actually reach the
|
||||
arbiter across the whole state space, that goes silent.
|
||||
|
||||
So: **10f remains a valid tripwire against building Option B today, and is not a
|
||||
guarantee about N=2.** Whoever reaches the `ALLOC-01` revisit inherits the job of
|
||||
re-earning it — a two-pack fixture with two SoC series, or a claim-shape argument
|
||||
at stage A that does not need one. Merging a two-pack reserve on 10f's current
|
||||
green is merging on a check that cannot see the failure.
|
||||
|
||||
---
|
||||
|
||||
## 1. The question
|
||||
|
||||
> Does the grid-following (self-consumption) loop get an SoC floor of its own, or
|
||||
> is the BMS cut-out **deliberately** the only backstop under demand-driven
|
||||
> discharge?
|
||||
|
||||
Either answer is defensible. What is not defensible is the state we have been in
|
||||
for four tickets: **silence, plus a config key called `peak_soc_floor` that reads
|
||||
like the answer is "yes".**
|
||||
|
||||
## 2. What is actually exposed — replayed, not argued
|
||||
|
||||
The exposure was written down three times (SAFETY-18's finding, SAFETY-24's
|
||||
module note and PR body, and SAFETY-24's honestly-uncaught 13th mutation) and
|
||||
never measured. SAFETY-25 measured it. The replay is `test_peakguard.py` part 10;
|
||||
it drives the **shipped** `control.compute()`, `peakguard.decide()`,
|
||||
`peakguard.claim()` and `arbiter.resolve()` over the committed capture
|
||||
`scenarios/p1-net-2026-08-20.jsonl.gz`, with the battery and the state of charge
|
||||
modelled (the captures carry meter power only — see §7).
|
||||
|
||||
### 2a. A sustained-load evening takes the pack to the BMS cut-out
|
||||
|
||||
19:00–22:00 UTC on 2026-08-20 — three hours of house load after the sun has gone,
|
||||
**830 W mean, minimum 701 W, maximum 1223 W, not one watt of export in the whole
|
||||
window.** The pack is the 13.8 kWh reference pack (300 Ah × 51.2 V × DoD 90),
|
||||
started at **21 %** — one point above the shipped `peak_soc_floor`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| pack at the start of the evening | **21.0 %** |
|
||||
| pack three hours later | **10.0 %** — `bms_soc_floor` |
|
||||
| energy the loop delivered | **1525 Wh** |
|
||||
| control cycles with the pack below `peak_soc_floor` | **549 of 587** |
|
||||
| …of those, cycles still delivering discharge | **343** |
|
||||
| cycles on which the peak guard filed *any* floor | **0** |
|
||||
|
||||
### 2b. It is a behaviour, not a config value
|
||||
|
||||
The same replay, same pack, same evening, with `peak_soc_floor` set to **5, 11,
|
||||
20 and 60** — the shipped value and both ends of the schema range:
|
||||
|
||||
peak_soc_floor = 5 -> ends at 9.9715 %, 1524.58 Wh out
|
||||
peak_soc_floor = 11 -> ends at 9.9715 %, 1524.58 Wh out
|
||||
peak_soc_floor = 20 -> ends at 9.9715 %, 1524.58 Wh out
|
||||
peak_soc_floor = 60 -> ends at 9.9715 %, 1524.58 Wh out
|
||||
|
||||
Identical to four decimal places. **Raising the option does nothing, because the
|
||||
guard it bounds is not the thing doing the discharging.**
|
||||
|
||||
### 2c. The two states coexist in the same cycle
|
||||
|
||||
On the same day's high-load block (15:00–17:00 UTC), with the pack started one
|
||||
point *below* the floor, the guard reports `PEAK GUARD FLOOR-LIMITED` —
|
||||
*"commanding no forced discharge"* — on **49 cycles**, and on **all 49** the
|
||||
arbiter simultaneously grants the loop a discharge of up to **2000 W**. The
|
||||
status page tells the operator the truth about the guard while the pack empties.
|
||||
|
||||
### 2d. Stated fairly, because overstating it is how a real finding gets ignored
|
||||
|
||||
This is **not** a runaway. Self-consumption discharge is demand-driven and
|
||||
self-limiting: it stops when the house stops drawing, and every watt of it is
|
||||
serving load that would otherwise have been imported. It is not SAFETY-18's
|
||||
frozen-meter rail, and nothing here is spending the pack for a reason of its own.
|
||||
What it means is narrower and still real:
|
||||
|
||||
- the **depth** the pack reaches on a heavy evening is set by the house, not by
|
||||
anything an operator can configure; and
|
||||
- `peak_soc_floor` **reads like a system-wide guarantee it is not.** A floor that
|
||||
binds one consumer and not the other is worse than no floor if its name does
|
||||
not say so — which is precisely what SAFETY-25's other half fixed.
|
||||
|
||||
## 3. Why SAFETY-24's floor cannot be extended to cover this
|
||||
|
||||
The peak guard files a `lo` limit — a **lower** bound on the setpoint. That
|
||||
asymmetry is what makes the guard trustworthy: a reviewer proved across 19,800
|
||||
combinations that it can never lower a target, never cap a discharge, never
|
||||
contradict the arbiter, and never file a negative floor. `peakguard.claim()`
|
||||
leaves `hi` at `+inf` as a fact about the object rather than a rule someone has
|
||||
to remember.
|
||||
|
||||
**A `lo` limit is structurally incapable of stopping a discharge the loop itself
|
||||
wants.** So the same property that makes the guard safe makes it useless here.
|
||||
Raising `peak_soc_floor` cannot become the answer; §2b is that statement
|
||||
measured.
|
||||
|
||||
## 4. The floors we already have — and this would be the fifth key
|
||||
|
||||
SAFETY-24 recorded a decision to keep `maintenance_soc_floor` separate with an
|
||||
explicit revisit trigger: *"if a third floor appears."* **The trigger has already
|
||||
fired, unremarked**, when OPT-04 shipped `arbitrage_soc_floor`.
|
||||
|
||||
| option | default | bounds | priority of the consumer |
|
||||
|---|---|---|---|
|
||||
| `maintenance_soc_floor` | 11 | the monthly recalibration's **drain target** | `P_MAINTENANCE` 60 |
|
||||
| `peak_soc_floor` | 20 | the peak guard's forced discharge | `P_PEAK` 50 (a `lo` limit) |
|
||||
| `bms_soc_floor` | 10 | *nothing* — it describes the pack, so rule 8 can refuse a floor that cannot bind | — |
|
||||
| `arbitrage_soc_floor` | 20 | arbitrage discharge | `P_ARBITRAGE` 30 |
|
||||
| **the loop** | — | **nothing** | `P_LOOP` 10 |
|
||||
|
||||
Two of them ship the same default (20) from different cards for different
|
||||
reasons, which is how a value that looks deliberate becomes a coincidence nobody
|
||||
dares change. **`SAFETY-27` owns the naming question and this memo does not
|
||||
pre-empt it** — but any answer to §1 that adds a fifth key should land *after*
|
||||
SAFETY-27's decision, or it adds a fifth accreted name to four.
|
||||
|
||||
## 5. The options
|
||||
|
||||
### Option A — record the BMS as the deliberate backstop; add nothing
|
||||
|
||||
Say plainly, in `DOCS.md` and in `freeze.py`'s finding, that demand-driven
|
||||
discharge is bounded by the pack's own cut-out on purpose, and close the
|
||||
question.
|
||||
|
||||
- **Protects:** the whole point of the product. Every watt the loop discharges is
|
||||
self-consumption the house would otherwise have imported; a reserve is
|
||||
literally energy bought at the import tariff and held back.
|
||||
- **Costs:** on a heavy evening the pack ends at the BMS cut-out with no EMS-side
|
||||
record of why. That is the SoC-drift condition **MAINT-01** exists to correct,
|
||||
and the first symptom — a battery that is never available when it is wanted —
|
||||
is indistinguishable from a coulomb counter that has drifted. It also means the
|
||||
morning's peak-guard capacity is whatever last night left, which the peak guard
|
||||
cannot influence.
|
||||
- **Cost of building it:** zero. It is already the shipped behaviour, and after
|
||||
SAFETY-25's honesty half it is already documented.
|
||||
|
||||
### Option B — a `hi` limit at `P_LOOP`, with an enforced ordering *(recommended)*
|
||||
|
||||
A new option `loop_soc_floor`. While `soc <= loop_soc_floor`, `main.py` files
|
||||
`Claim.limit("reserve", P_LOOP, hi=0.0)`. Zero forbids **discharge** and permits
|
||||
charging, which is the mirror image of the guard's `lo = 0` and one-directional
|
||||
in the safe direction.
|
||||
|
||||
**⚠️ This shape has one trap and it is fatal if missed.** `arbiter.resolve()`
|
||||
applies **every** limit at or above the winning `set` claim's priority and takes
|
||||
the most restrictive, *with no notion of one limit outranking another* — that is
|
||||
deliberate and is what makes the safety bounds unconditional. So when the loop
|
||||
wins at `P_LOOP`, a reserve `hi = 0` and the peak guard's `lo = excess > 0` are
|
||||
**both** applied, `lo > hi`, and the arbiter resolves a **CONTRADICTION to 0 W**:
|
||||
the battery parked, in the one quarter of the month that sets the bill. This is
|
||||
the same crossing-limits hazard `shaping_suspended()` already exists to avoid for
|
||||
the maintenance charge-only clamp.
|
||||
|
||||
**The fix is an ordering constraint, and it is provable:** require
|
||||
|
||||
bms_soc_floor < loop_soc_floor <= peak_soc_floor
|
||||
|
||||
Then whenever the reserve binds (`soc <= loop_soc_floor <= peak_soc_floor`) the
|
||||
guard is *already* floor-limited and files `lo = 0.0`, so `lo == hi == 0` and the
|
||||
result is 0 W with no contradiction. Refuse any other ordering at startup, in
|
||||
CFG-01's manner and with rule 8's message shape. **A `loop_soc_floor` above
|
||||
`peak_soc_floor` must not be merely discouraged; it is the parked-battery bug.**
|
||||
|
||||
- **Protects:** the pack from routinely sitting on its own cut-out, which is the
|
||||
MAINT-01 drift condition; and it gives the operator one number that means what
|
||||
its name says.
|
||||
- **Costs:** a fifth SoC key (see §4), a validator rule, and — the real cost —
|
||||
**every watt below the reserve becomes billed import.** Set too high it turns a
|
||||
working battery into a smaller one.
|
||||
- **What it does *not* touch, correctly:** a maintenance DRAIN sets at
|
||||
`P_MAINTENANCE` 60 and arbitrage sets at `P_ARBITRAGE` 30, both above `P_LOOP`
|
||||
10, so a limit filed at `P_LOOP` is not applied to either. The monthly
|
||||
recalibration still drains to `maintenance_soc_floor`; arbitrage is still
|
||||
bounded by its own key. That is the right answer and it falls out of the
|
||||
priorities rather than needing a special case.
|
||||
- **Recommended starting value if this option is taken:** a *low* one — three
|
||||
points above `bms_soc_floor`, i.e. **13 %**, well under `peak_soc_floor`'s 20.
|
||||
The purpose is to keep the pack off its own cut-out, **not** to reserve energy
|
||||
for anything, and an over-tight reserve is the version of this that fights
|
||||
MAINT-01 from the other side: it shrinks the band the pack actually cycles in,
|
||||
which is the condition a coulomb counter drifts in. It does not matter that 13
|
||||
sits above `maintenance_soc_floor`'s 11 — the maintenance drain sets at
|
||||
`P_MAINTENANCE` 60 and a `P_LOOP` limit is not applied to it, so the two never
|
||||
interact. **The number itself is part of the owner's answer**; what this memo
|
||||
offers is the derivation and the ordering constraint that makes any number in
|
||||
that range safe.
|
||||
|
||||
### Option C — a `set` claim above `P_PEAK`
|
||||
|
||||
File `Claim.set("reserve", 55, 0.0)` when the pack is under the reserve. Because
|
||||
clause 2 applies only limits at or **above** the winner's priority, the guard's
|
||||
`lo` at 50 would then be dropped and there is no contradiction to engineer
|
||||
around.
|
||||
|
||||
- **Protects:** the same as B, without the ordering rule.
|
||||
- **Costs:** it is a **stop**, not a bound. It parks the battery outright rather
|
||||
than merely forbidding discharge, so it also blocks the charging that would end
|
||||
the condition, and it silently outranks peak shaving in a way that is invisible
|
||||
on the priority table. This project's rule is that a floor about **energy** is
|
||||
not a stop of last resort (`peakguard.py`, point 1). **Not recommended.**
|
||||
|
||||
### Option D — clamp inside `control.compute()`
|
||||
|
||||
- **Costs:** it puts precedence back inside a strategy, which is the exact thing
|
||||
`arbiter.py` was built to end, and it would be invisible to `explain()`. **Not
|
||||
recommended.**
|
||||
|
||||
## 6. Recommendation
|
||||
|
||||
⚠️ **This section is the recommendation that was PUT to the owner, kept as
|
||||
written. It was answered on 2026-09-02 — see §0. The owner took Option A and
|
||||
deferred Option B to `ALLOC-01`; item 3 below (keep the part 10 tripwire)
|
||||
was taken as written and still stands.**
|
||||
|
||||
**Option A is honest and cheap; Option B is what I would build.** Concretely:
|
||||
|
||||
1. **Now (shipped with SAFETY-25):** the honesty half. `peak_soc_floor`, its
|
||||
documentation and the status page say plainly that it bounds the peak guard
|
||||
only. This is required under every option and it is the actual dishonesty on
|
||||
the live system today.
|
||||
2. **Owner's call:** A or B. If B, take it **after `SAFETY-27`'s naming decision**
|
||||
so the fifth key lands into a scheme rather than onto a pile, and **after
|
||||
`MAINT-01`** has run one real cycle — until then nobody has observed what this
|
||||
pack's SoC reading does over a full cycle, and picking a reserve against a
|
||||
reading that has never been recalibrated is picking a number against an
|
||||
instrument we have not checked.
|
||||
3. **Whichever way:** the tripwire in `test_peakguard.py` part 10 stays until the
|
||||
question is answered, so the day someone builds a loop-side floor it goes red
|
||||
and has to be updated deliberately, rather than the exposure quietly changing
|
||||
under a green suite. It has two halves and they cover the two places the floor
|
||||
can land:
|
||||
|
||||
- **10f pins the claim-source set.** Every `claims.append(...)` in
|
||||
`Controller.cycle()` is read off its AST and must be one of eight named
|
||||
sources, and real cycles — spied at main.py's own `resolve`, swept over the
|
||||
whole 0–100 % SoC range in both the idle and the floor-limited state — must
|
||||
file exactly those. **Option B is a ninth source**, and so is **Option C**'s
|
||||
`set` claim, so filing either takes part 10 red on the line it is written.
|
||||
**Option A** files no claim at all — it is the honesty half already
|
||||
shipped, and part 10 is the record it leaves behind.
|
||||
- **10a–10c replay the shipped code**, so **Option D** — a clamp inside
|
||||
`control.compute()` — moves the replayed numbers instead.
|
||||
|
||||
⚠️ **10f is load-bearing and was added late.** PR #31 shipped this section
|
||||
claiming the tripwire without it, and the reviewer disproved the claim by
|
||||
building Option B in `main.py`: the entire suite, part 10 included, stayed
|
||||
green, and mutation 13 went on printing *"nothing prevents it, on purpose"* on
|
||||
a tree where something did. Do not remove 10f while this question is open.
|
||||
|
||||
## 7. What the replay is and is not
|
||||
|
||||
The committed captures carry **one** series — signed net power at the meter — so
|
||||
the pack is modelled, and the model is stated rather than hidden:
|
||||
|
||||
- the captured `w` is the load presented to the meter; the modelled battery's
|
||||
delivered power is subtracted from it, one telegram late (an actuation delay);
|
||||
- the loop's command is the **shipped** `control.compute()` at the shipped
|
||||
tuning, resolved through the **shipped** `arbiter.resolve()` against the
|
||||
**shipped** `peakguard.claim()`, at a `cycle_floor_s` tick;
|
||||
- SoC falls by the delivered discharge integrated over the real telegram
|
||||
timestamps, on a pack of 300 Ah × 51.2 V × DoD 90;
|
||||
- the BMS is modelled as a hard cut: at or below `bms_soc_floor` the modelled
|
||||
battery delivers 0 W whatever is asked of it. Without that the run would be
|
||||
unphysical — the BMS really does stop the pack, and the whole finding is that
|
||||
it is then the **only** thing that does;
|
||||
- nothing recharges the pack inside the window. One evening, one direction.
|
||||
|
||||
**The fixtures cannot express a low pack on their own** — there is no battery or
|
||||
SoC telemetry in them — so the starting state of charge is a fixture parameter,
|
||||
not a measurement. It is chosen as *one point above `peak_soc_floor`*, which is
|
||||
derived from the shipped option rather than picked. Everything downstream of that
|
||||
one number is the real capture and the real code.
|
||||
@@ -0,0 +1,316 @@
|
||||
# SAFETY-31 — what "acted on" means for circulating power, and what it cannot mean
|
||||
|
||||
**Status: DECIDED 2026-09-04.** The trip **stops the site**: a `SET 0 W` claim
|
||||
at `P_SAFETY` on the **site** actuator, latched, persisted, and re-announced
|
||||
after a restart. This document records why that is an action rather than
|
||||
theatre, given that [[HW-01]] established a wedged inverter cannot be commanded
|
||||
at all — and it records the numbers the threshold and the window were read off,
|
||||
because "establish the window from the rig, do not pick a round number" is an
|
||||
acceptance criterion and a number without its measurement is a guess.
|
||||
|
||||
Filed with SAFETY-31. Card `6a99e0022b6a6c8ca15dbe3e`.
|
||||
|
||||
---
|
||||
|
||||
## 0. The two halves, and which one this card built
|
||||
|
||||
Half of this card was already shipped, and it is not the half below.
|
||||
|
||||
| | **ALLOC-01, AC 7 — the COMMAND half** | **SAFETY-31 — the MEASUREMENT half** |
|
||||
|---|---|---|
|
||||
| what it is | `allocator.REFUSE_SIGN` | `circulation.circulating_w()` + `CirculationWatchdog` |
|
||||
| what it sees | the shares the allocator is about to hand out | each inverter's **own measured power** (`Inverter.batt`, register 0x0518) |
|
||||
| what it does | **refuses** the allocation, names both units, returns no shares | **stops the site**, latches, alarms durably |
|
||||
| what it covers | circulating power the allocator would have **commanded** | circulating power **nobody commanded** |
|
||||
| graded by | `test_alloc.py` part 6 | `test_watchdog.py`, the SAFETY-31 section |
|
||||
|
||||
**They are complements, not duplicates.** The gate refuses an allocation before
|
||||
it is written and cannot see one millisecond past that. This card reads the
|
||||
hardware and does not care what was written. Neither replaces the other, and
|
||||
**a sensor built on the allocation record would collapse the second into the
|
||||
first and detect nothing** — see §2.
|
||||
|
||||
## 1. What "acted on" means
|
||||
|
||||
> **The trip files `Claim.set("circulation", P_SAFETY, 0.0)` on the SITE
|
||||
> actuator. Every inverter is commanded 0 W and held there until an operator
|
||||
> starts control again.**
|
||||
|
||||
### The objection, which is correct
|
||||
|
||||
[[HW-01]] is on the record: on 2026-09-02 the comms subsystem failed and the
|
||||
power stage went on executing its last instruction for **6 h 25 m**, through
|
||||
every command this add-on sent it. If the cause of the circulation is a unit
|
||||
that has stopped listening, **a command is not a remedy**, and shipping one as
|
||||
though it were is theatre.
|
||||
|
||||
### The answer
|
||||
|
||||
**A circulating loop needs two participants.** In every cause on this card's
|
||||
list — a unit ignoring its share, a unit lagging it, a stale share still being
|
||||
executed, a maintenance or [[SAFETY-25]] pin holding one unit while the loop
|
||||
drives the other, or HW-01's wedged power stage — **exactly one participant is
|
||||
the unit that stopped listening, and the other one is this controller**,
|
||||
faithfully commanding the sibling to a share computed to sum with a number the
|
||||
wedged unit is not actually producing.
|
||||
|
||||
Stopping the commanding half does two things a notification cannot:
|
||||
|
||||
1. **It halves the loop.** The unit that *is* still listening stops being driven
|
||||
into the one that is not. That is real energy and real cycles off a real
|
||||
pack, and it needs no cooperation from the wedged unit to take effect.
|
||||
2. **It makes the residue visible — and this is the more important half.**
|
||||
Circulating power hides *because it nets to zero at the meter*. With this
|
||||
controller's contribution removed, whatever the wedged unit is still doing
|
||||
stops being cancelled and appears at P1 as a plain net flow, where
|
||||
SAFETY-02's sign watchdog, the peak guard and the capacity tariff can all see
|
||||
it. **The fault stops being invisible.**
|
||||
|
||||
Measured on the [[TEST-19]] rig, and this is the number that makes the case:
|
||||
in the card's own scenario — A at −3000 W, B at +3000 W, house load 0 W — the
|
||||
meter reads **+0 W** and the shipped `SignWatchdog`'s predicate is **false on
|
||||
every one of 30 cycles**, because it gates on `abs(grid_w) > 300` and the gate
|
||||
never opens.
|
||||
|
||||
### What it does not do, said plainly
|
||||
|
||||
**It does not stop the wedged inverter. Nothing this add-on can send will.** If
|
||||
the loop persists at the meter after this trip, the remaining actuator is a
|
||||
human at the isolator, and the notification says so in those words.
|
||||
|
||||
### Why the claim is SITE-scoped
|
||||
|
||||
`arbiter.CLAIM_SCOPE` classifies `circulation` as `SCOPE_SITE`, beside `loop`,
|
||||
`peak`, `arbitrage` and `safety`. The neighbouring `failsafe` entry looks like
|
||||
it and is not: SAFETY-02 has site-wide *evidence* and a per-unit *conclusion*,
|
||||
because it compares the meter against the command **this controller put on one
|
||||
actuator**.
|
||||
|
||||
Circulating power has no such conclusion available. It is
|
||||
`Σ|Bᵢ| − |ΣBᵢ|` — defined over the whole roster, **identically zero for any one
|
||||
unit** — and there is no fact of the matter about which of two opposing
|
||||
inverters is the offender: *a unit ignoring its share* and *a sibling faithfully
|
||||
executing a share computed to sum with it* produce **identical readings**.
|
||||
Filing this per-unit would require inventing an attribution the measurement
|
||||
cannot support, and would park one inverter while the other went on feeding the
|
||||
loop — quite possibly parking the only one still listening.
|
||||
|
||||
The same argument is why the latch is **flat** in the store (`circ_tripped`,
|
||||
`circ_reason`) and not namespaced like SAFETY-02's. One site, one latch.
|
||||
|
||||
## 2. Why the sensor is MEASURED, and why the cheap version is worthless
|
||||
|
||||
The obvious cheap version computes the same formula over `Allocation.shares`.
|
||||
It would be simpler, would never be `None`, and would be **worth nothing**, for
|
||||
two independent reasons:
|
||||
|
||||
1. **It cannot fire.** ALLOC-01's sign-coherence gate already refuses any
|
||||
allocation whose shares carry two signs. A record-derived sensor is therefore
|
||||
a sensor over a set that is **guaranteed sign-coherent**. It reads zero *by
|
||||
construction*, forever.
|
||||
2. **It is [[MUTATION.md]]'s third failure mode.** It could only ever confirm
|
||||
that the allocator agrees with itself — a check whose data cannot reach the
|
||||
failure it names.
|
||||
|
||||
Verified rather than argued. On the rig, with inverter B wedged on a stale
|
||||
discharge while PV drives the site to charge:
|
||||
|
||||
```
|
||||
S = -2500 W shares a = -1250 W b = -1250 W <- record: ok, ONE sign, sums to S
|
||||
MEASURED A = -1250 W B = +1000 W <- hardware
|
||||
sensor over the RECORD : 0 W
|
||||
sensor over MEASURED power : 2000 W -> trips at 190 s
|
||||
```
|
||||
|
||||
And the card's own headline scenario, driven on the same rig — A at −3000 W,
|
||||
B at +3000 W, house load 0 W:
|
||||
|
||||
```
|
||||
METER (P1) : +0 W <- the site looks IDLE
|
||||
sensor over MEASURED power : 6000 W -> trips at 100 s
|
||||
SAFETY-02 SignWatchdog : predicate false on 30 of 30 cycles
|
||||
```
|
||||
|
||||
The same pair is asserted in `test_watchdog.py` **with the precondition written
|
||||
as its own check and printed**, per MUTATION.md's rule, and mutation **M10**
|
||||
(the property rewritten to read `self.allocation.shares`) takes it **red**.
|
||||
|
||||
## 3. The numbers, and where they come from
|
||||
|
||||
Both were read off a curve produced on `admin/goodwe`'s `sim/` two-master rig,
|
||||
used read-only: the add-on's own `compute()`, its own `allocator.plan()` and its
|
||||
own `circulating_w()`, driving `plantsim.Plant` through the firmware's own
|
||||
encoder, with the sensor input taken from each unit's holding register 0x0518.
|
||||
|
||||
**924 closed-loop runs in which nothing is faulty** — every unit executes the
|
||||
share it was given — over both families of stimulus that can produce legitimate
|
||||
opposition (7 reversals × 5 write staggers, and 7 flap periods × 4 amplitudes),
|
||||
each swept across the **whole range `config.yaml` accepts** for the two settings
|
||||
that decide how fast the site can turn around: `gain` `float(0.05,1.0)` and
|
||||
`slew_w` `int(50,5000)`.
|
||||
|
||||
### Reproducing it
|
||||
|
||||
The rig lives in `admin/goodwe` (`sim/`) and was used **read-only**; the harness
|
||||
is not committed here because it imports that repo's `plantsim`/`frame_check`,
|
||||
which are not present in this one or in CI. What it does is small enough to
|
||||
restate: build `Site([Inverter.from_spec("A", INVERTER_A),
|
||||
Inverter.from_spec("B", INVERTER_B)])`, close the loop with the add-on's own
|
||||
`compute()` and `allocator.plan()`, put each share on its unit's wire through
|
||||
`Inverter.command()` (which goes out through the firmware's real encoder), and
|
||||
read the sensor from `inv.reg(0x0518)` — **the same register the live add-on's
|
||||
`batt_entity` binds**, so the input is the one the shipped code gets. Point it
|
||||
at a checkout with `GOODWE_ADDON_REPO`. ⚠️ MUTATION.md's note applies: those
|
||||
harnesses write `__pycache__` into **both** trees, so purge both by hand before
|
||||
grading a mutation through one.
|
||||
|
||||
Every figure below was re-run against the final shipped `CirculationConfig`
|
||||
(`min_w=700`, `hold_s=75`) rather than against the draft values they were first
|
||||
measured at.
|
||||
|
||||
### Worst CONTINUOUS legitimate opposition, by threshold
|
||||
|
||||
| threshold | worst continuous | runs that ever cross it |
|
||||
|---:|---:|---:|
|
||||
| 100 W | **505.0 s** | 281 / 924 |
|
||||
| 200 W | 445.0 s | 174 / 924 |
|
||||
| 250 W | 40.0 s | 132 / 924 |
|
||||
| 300 W | 10.0 s | 115 / 924 |
|
||||
| **400 W** | **5.0 s** | 74 / 924 |
|
||||
| 700 W | 5.0 s | 33 / 924 |
|
||||
| 1000 W | 5.0 s | 16 / 924 |
|
||||
| 1700 W | 0.0 s | 0 / 924 |
|
||||
|
||||
****With the shipped `min_w=700 W` and `hold_s=75 s`, 0 of those 924 runs trip.**
|
||||
|
||||
The knee is at 400 W and it is sharp.** Below ~250 W a flapping load holds the
|
||||
sensor above the threshold for *minutes* at a time, and no window shorter than
|
||||
the 900 s billing quarter could tell that from a fault. At 400 W and above the
|
||||
worst legitimate episode collapses to **5.0 s — one control cycle — and stays
|
||||
there**. The worst peak on any legitimate run is **1700 W**.
|
||||
|
||||
That 5.0 s is the serialised write path doing exactly what it should: on a
|
||||
reversing cycle inverter B's new number lands up to a whole control cycle after
|
||||
A's, both then slew through zero at 800 W/s behind a 10 s lag, and **between the
|
||||
two zero crossings they genuinely oppose**. Nothing is wrong. A guard that
|
||||
tripped on it would be the fourth false-alarm generator of the week, and this
|
||||
project's doctrine on crying wolf is explicit ([[SAFETY-15]], [[SAFETY-19]]).
|
||||
|
||||
### `circulation_w: 700` — schema floor `400`
|
||||
|
||||
`700` is **1.75× the measured knee**. The headroom is bought for one named
|
||||
reason: **inverter B does not physically exist.** Its ramp and lag in the rig
|
||||
are inverter A's, *copied*, carrying `measured=False`; [[SPIKE-04]] measures the
|
||||
real ones. A slower or faster second unit moves the transient's shape, and the
|
||||
margin above the knee is what absorbs that without re-opening this card.
|
||||
**Revisit when SPIKE-04 lands, not before.**
|
||||
|
||||
The **schema floor is the knee itself**, 400 W, so a legally configured install
|
||||
cannot land in the 445–505 s regime. What the threshold costs: a circulating
|
||||
loop smaller than 350 W per unit is not reported, which is deliberate — at that
|
||||
scale the round-trip loss is a few watts.
|
||||
|
||||
### `circulation_hold_s: 75` — schema range `30..900`
|
||||
|
||||
Three bounds, and the value clears all three:
|
||||
|
||||
* **lower, from the rig — 5.0 s.** The worst continuous legitimate opposition
|
||||
above 700 W across all 924 runs.
|
||||
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit still
|
||||
executing a stale share for less than the firmware's own veto window is a
|
||||
*link* fault, and the add-on already answers it: liveness drops the unit and
|
||||
the survivors carry its share, with the record saying so. Firing inside that
|
||||
window would raise a second, louder alarm about a state already being handled.
|
||||
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
|
||||
|
||||
**75 s** is fifteen control cycles: **15× the worst legitimate episode**, **2.5×
|
||||
the firmware veto**, and **12× inside the quarter**. It is deliberately not 60
|
||||
or 120 — a round number here would be a guess wearing a measurement's clothes,
|
||||
and a check in `test_watchdog.py` asserts it is none of the values a guess
|
||||
lands on.
|
||||
|
||||
## 4. Why the window cannot be defeated the way SAFETY-02's was
|
||||
|
||||
`allocator.py` records the measurement that makes SAFETY-02 blind to the
|
||||
broadcast fight: the sign disagreement **flips on alternate ticks**, so
|
||||
`disagree_since` is cleared between every pair of them and the 180 s hold never
|
||||
accumulates.
|
||||
|
||||
Circulating power cannot do that. It is a **magnitude**, non-negative by
|
||||
construction, and a sustained loop holds it high on *every* cycle rather than
|
||||
every other one. An oscillation that genuinely alternated between opposing and
|
||||
agreeing is not a circulating loop and should not be reported as one — which is
|
||||
exactly the flapping-load case in §3, and it is why the window clears.
|
||||
|
||||
## 5. SAFETY-34: the latch is not the alarm
|
||||
|
||||
`persistent_notification` is **in memory**. Home Assistant restarts and the card
|
||||
is gone while the fault runs on — which is precisely how the 2026-09-02 outage
|
||||
went unseen for **6 h 25 m with the detector correct throughout**.
|
||||
|
||||
So the durable half and the visible half are joined, and the join is
|
||||
`Controller._circ_announced`: a **per-process** flag, deliberately *not*
|
||||
persisted. The latch in `/data/state.json` says *"this site is tripped"*; the
|
||||
flag says *"this process has put that in front of a human"*. A restart clears
|
||||
the flag and keeps the latch, so **the notification is re-raised on the first
|
||||
cycle after every start** — which is the first cycle after a Home Assistant
|
||||
restart, because the add-on restarts with it.
|
||||
|
||||
**One code path serves both** the fresh trip and the restart, on purpose: two
|
||||
paths would be two chances for the restart one to rot unnoticed, and the restart
|
||||
one is the case nobody exercises by hand. Mutations **M13** (flag initialised
|
||||
from the latch) and **M18** (the call removed) both take it red.
|
||||
|
||||
## 6. Mutation ledger
|
||||
|
||||
Every run on a **freshly copied tree with no `__pycache__`, mounted `:ro`** into
|
||||
the CI image — the structural immunity MUTATION.md records for CI and for
|
||||
SAFETY-25 — so no stale `.pyc` could exist to be read. Byte deltas recorded;
|
||||
**three are `+0 B`, the risky delta, and are marked**.
|
||||
|
||||
| # | file | mutation | Δ | result |
|
||||
|---|---|---|---|---|
|
||||
| M1 | `circulation.py` | `sum(abs(v))` → `sum(v)` in the sensor | −5 B | **RED** — the 6000 W, 2000 W and three-unit checks |
|
||||
| M2 | `circulation.py` | N≤1 returns `None` instead of `0.0` | +1 B | **RED** — AC 5's nine-reading sweep |
|
||||
| M3 | `circulation.py` | a missing reading at N≥2 returns `0.0` | −1 B | **RED** — "health from an absence" |
|
||||
| M4 | `circulation.py` | drop `max(0.0, ...)` | — | **EQUIVALENT, not run as a claim.** For a same-sign roster the two sums accumulate the same magnitudes in the same order and are bit-identical; 700 000 random rosters produced no negative. Argued at the line and in the check's own text. |
|
||||
| M5 | `circulation.py` | threshold `<=` → `<` | −1 B | **RED** — "exactly 700 W forever does not trip" |
|
||||
| M6 | `circulation.py` | window `<` → `<=` | +1 B | **RED** — "trips at 75 s, not before" |
|
||||
| M7 | `circulation.py` | the window removed entirely | −85 B | **RED** — both slew-transient checks |
|
||||
| M8 | `circulation.py` | `None` no longer clears the window | +34 B | **RED** — the stale-reading gap check |
|
||||
| M9 | `circulation.py` | the latch removed | −123 B | **RED** — "restored as tripped stays tripped" |
|
||||
| **M10** | `main.py` | **the sensor derived from `self.allocation.shares`** | +60 B | **RED** — AC 2's headline check. This is the card. |
|
||||
| M11 | `main.py` | the `circulation` claim never filed | −235 B | **RED** — `test_watchdog.py` **and** `test_peakguard.py` 10f/10g |
|
||||
| M12 | `main.py` | the latch not persisted to the store | −112 B | **RED** — all three durability checks |
|
||||
| M13 | `main.py` | `_circ_announced` initialised from the latch | +21 B | **RED** — SAFETY-34's re-raise |
|
||||
| M14 | `main.py` | the operator clear leaves `_circ_announced` set | −45 B | **RED** — "the next trip would raise nothing" |
|
||||
| M15 | `arbiter.py` | `circulation` scoped `SCOPE_UNIT` | **+0 B** | **RED** — `test_arbiter.py` ×3 and `test_peakguard.py` ×3 |
|
||||
| M16 | `config.yaml` | schema floor `400` → `100` | **+0 B** | **RED** — the measured-knee check |
|
||||
| M17 | `circulation.py` | default `700` → `500` | **+0 B** | **RED** — the CFG-03 pin and the not-a-round-number check |
|
||||
| M18 | `main.py` | `await self.check_circulating()` removed | −39 B | **RED** — all four notification checks |
|
||||
| M19 | `main.py` | publish `round(x or 0.0)` instead of `None` | −68 B | **survivor on the first battery — GAP CLOSED**, then RED |
|
||||
|
||||
**M19 is recorded as a survivor because it was one.** The first battery found no
|
||||
check grading what `publish()` does when a per-unit reading is missing, so
|
||||
turning *"I do not know"* into *"0 W"* on the one surface an operator actually
|
||||
watches went unnoticed — the sensor's own `None` rule, defeated one layer out.
|
||||
A check was added and the mutation re-run; it is red now. The run that found it
|
||||
was purged, so it is a result and not an artefact.
|
||||
|
||||
## 7. What this card does NOT close
|
||||
|
||||
* **Inverter B's ramp and lag are placeholders.** Every transient number in §3
|
||||
was produced with inverter A's parameters copied onto B (`measured=False`).
|
||||
SPIKE-04 measures the real ones and is the revisit trigger for the 1.75×
|
||||
margin. **Do not present any figure here as a measurement of inverter B.**
|
||||
* **A wedged inverter still cannot be stopped by this add-on.** §1 says so; the
|
||||
notification says so; there is no software fix and this card does not pretend
|
||||
to one.
|
||||
* **A loop below ~350 W per unit is not reported.** Deliberate, §3.
|
||||
* **The saturation freeze fires under a wedged unit and leaves a standing grid
|
||||
error.** Observed on the rig while producing §2's numbers: with B wedged, the
|
||||
loop's anti-windup test compares a site target against a measured site power
|
||||
that cannot track it, freezes the integrator and leaves the meter parked at
|
||||
−3750 W. Nothing alarms on that today — a freeze is an ordinary control action
|
||||
by design. It is a real finding, it is **not** in scope here, and it wants a
|
||||
card of its own.
|
||||
+1003
-21
File diff suppressed because it is too large
Load Diff
@@ -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,22 +25,216 @@ 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
|
||||
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
|
||||
P_PEAK = 50 # capacity-tariff peak shaving - costs real money
|
||||
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
|
||||
# OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A
|
||||
# preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor
|
||||
# clamp an arbitrage charge, which is §9.1's "give peak shaving priority over
|
||||
# arbitrage whenever the two conflict" expressed as arithmetic rather than as a
|
||||
# rule somebody has to remember. P_MAINT_SHAPE (40) is above it so a speculative
|
||||
# spread cannot override the pack's balancing cycle. P_LOOP (10) is below it
|
||||
# because trading against the meter is the entire point - the grid-following
|
||||
# loop would otherwise cancel every charge the moment it created import.
|
||||
P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose
|
||||
P_LOOP = 10 # ordinary grid-following
|
||||
|
||||
SET = "set"
|
||||
LIMIT = "limit"
|
||||
|
||||
# --- actuators, and the two scopes a claim can have -------------------------
|
||||
# ⚠️ §6.2: THE GRID CONNECTION IS AN ACTUATOR TOO. It is the one every inverter
|
||||
# at the site shares, and two inverters regulating against one meter are two
|
||||
# writers on it - which is the failure this module exists to prevent, one level
|
||||
# up from the one it was built for. So it gets a name, and claims about it are
|
||||
# filed on that name rather than on nothing in particular.
|
||||
ACTUATOR_SITE = "site"
|
||||
|
||||
SCOPE_SITE = "site" # about the grid connection: one of these per site
|
||||
SCOPE_UNIT = "unit" # about one pack, one inverter, one device
|
||||
|
||||
# ⚠️ THE CLASSIFICATION, AND IT IS THE REAL OUTPUT OF ARB-03. Each entry is read
|
||||
# off what the claim is ABOUT at its construction site in main.py, not off which
|
||||
# module builds it. Getting an entry wrong is not cosmetic: a per-inverter
|
||||
# reserve filed as a site `hi` against the peak guard's site `lo` is `lo > hi`,
|
||||
# clause 3, 0 W - the battery parked in the billed quarter that SAFETY-25's memo
|
||||
# calls fatal. The reasoning is recorded here, at the line, because this table is
|
||||
# where a ninth source will be classified and the reasoning is what a future
|
||||
# reader needs in order to classify it correctly.
|
||||
CLAIM_SCOPE = {
|
||||
# --- SITE: about the grid connection ---------------------------------
|
||||
# main.py: `Claim.set("loop", P_LOOP, decision.target_w, ...)`. compute()
|
||||
# regulates `grid_w` - the P1 measurement - to zero. Its subject is the
|
||||
# meter, and §6.2's whole architecture is ONE integrator on P1 whose output
|
||||
# is then split. A site claim, and the one the split exists for.
|
||||
"loop": SCOPE_SITE,
|
||||
# main.py: `peakguard.claim(...)`. The quantity is the 15-minute average
|
||||
# OFFTAKE AT THE GRID CONNECTION, which is what the Belgian capacity tariff
|
||||
# bills. There is one quarter-hour peak per site, not one per pack.
|
||||
"peak": SCOPE_SITE,
|
||||
# main.py: `arbitrage.claim(...)`. It buys and sells at the grid against a
|
||||
# day-ahead price. The price is a property of the connection, and the energy
|
||||
# it trades crosses the meter.
|
||||
"arbitrage": SCOPE_SITE,
|
||||
# main.py: `Claim.set("safety", P_SAFETY, 0.0, "stopped" | "inputs missing")`.
|
||||
# ⚠️ READ BOTH CONSTRUCTION SITES BEFORE MOVING THIS ONE. Neither is about a
|
||||
# pack. "stopped" is the operator switching control off for the SITE, and
|
||||
# "inputs missing" is the SITE's own telemetry gone - the meter this whole
|
||||
# controller regulates against. Both must stop every inverter, and a site
|
||||
# SET at P_SAFETY is how that is said once rather than N times.
|
||||
"safety": SCOPE_SITE,
|
||||
# main.py: `Claim.set("circulation", P_SAFETY, 0.0, "circulating power")`.
|
||||
# SAFETY-31, and this one is a SITE claim for a reason worth writing down,
|
||||
# because the neighbouring `failsafe` entry looks like it and is not.
|
||||
#
|
||||
# `failsafe` is SAFETY-02: site-wide evidence, per-unit conclusion. It
|
||||
# compares the meter against THE COMMAND THIS CONTROLLER PUT ON ONE
|
||||
# ACTUATOR, so what it learns is about that actuator and the remedy is that
|
||||
# actuator.
|
||||
#
|
||||
# Circulating power is not a property any single inverter has. It is
|
||||
# `sum(|B_i|) - |sum(B_i)|`, defined over the whole roster and identically
|
||||
# zero for any one unit, and there is NO FACT OF THE MATTER about which of
|
||||
# two opposing inverters is the offender - a unit ignoring its share and a
|
||||
# sibling faithfully executing a share computed to sum with it produce the
|
||||
# same reading. Filing this per-unit would require inventing an attribution
|
||||
# the measurement cannot support, and would park one inverter while the
|
||||
# other went on feeding the loop. The evidence is site-level and so is the
|
||||
# remedy: stop commanding, everywhere, and say why.
|
||||
"circulation": SCOPE_SITE,
|
||||
|
||||
# --- UNIT: about one pack, one inverter, one device -------------------
|
||||
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
|
||||
# "rating")`. The numbers are read off ONE inverter's own reported min/max.
|
||||
# Another inverter has another rating; there is no site rating.
|
||||
"device": SCOPE_UNIT,
|
||||
# main.py: `Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w")`.
|
||||
# `max_w` is the supervised cap on what this controller may WRITE to a
|
||||
# setpoint entity - §11.3's "the actuator is capped below the inverter
|
||||
# rating". It bounds a write to one device, like `device` does and for the
|
||||
# same reason, so it is scoped like `device`.
|
||||
"supervised": SCOPE_UNIT,
|
||||
# main.py: `Claim.set("failsafe", P_SAFETY, 0.0, "sign disagreement")`.
|
||||
# ⚠️ THE EVIDENCE IS SITE-WIDE AND THE REMEDY IS NOT, and that is why this
|
||||
# entry needs its reasoning written down. SAFETY-02 compares P1 - a site
|
||||
# measurement - against `standing`, THE COMMAND THIS CONTROLLER PUT ON THIS
|
||||
# ACTUATOR. What it concludes is "the unit I am driving is pushing power the
|
||||
# wrong way", and what it does is park that unit. At N>=2 the same evidence
|
||||
# cannot be attributed to a sibling nobody here commands, so a site-wide
|
||||
# remedy would be this watchdog stopping an inverter it never graded.
|
||||
"failsafe": SCOPE_UNIT,
|
||||
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
|
||||
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
|
||||
# it only shapes. §12's monthly recalibration is a PACK procedure - drain
|
||||
# this pack to its floor, charge this pack to full, recalibrate this pack's
|
||||
# coulomb counter. Two packs recalibrate on their own schedules.
|
||||
"maintenance": SCOPE_UNIT,
|
||||
}
|
||||
|
||||
|
||||
def actuator_for(source: str, unit: str, units=()) -> str:
|
||||
"""Which actuator a claim from `source` belongs on, given the site's roster.
|
||||
|
||||
⚠️ ROUTE THROUGH THIS, DO NOT PASS `actuator=` BY HAND. An unclassified
|
||||
source raises `KeyError` here rather than defaulting to the site, which is
|
||||
the one direction that is not safe to default in: a per-inverter constraint
|
||||
that lands in the site claim list is exactly the `lo > hi` this card exists
|
||||
to make unreachable. A ninth claim source has to be classified in
|
||||
CLAIM_SCOPE before it can be filed, and test_peakguard.py part 10f is the
|
||||
other half of that - it goes red on the line the ninth append is written.
|
||||
|
||||
⚠️ AND AT N=1 EVERY SOURCE ROUTES TO THE SITE. That is not the classification
|
||||
being ignored, it is §6.2's identity: with one inverter the fabricated grid
|
||||
reading and the battery setpoint are the same number, so the grid connection
|
||||
and the only unit ARE the same actuator and there is nothing to allocate.
|
||||
Splitting the list at N=1 would be a behaviour change on a live safety path -
|
||||
the `failsafe` and `maintenance` SET claims would stop competing with the
|
||||
site's for the winner, `explain()` would name a different source, and
|
||||
`loop_is_following()` reads that name. The identity holds only at N=1, so
|
||||
this function is the ONE place that stops relying on it, the day the roster
|
||||
grows.
|
||||
"""
|
||||
if CLAIM_SCOPE[source] == SCOPE_SITE:
|
||||
return ACTUATOR_SITE
|
||||
return ACTUATOR_SITE if len(units) <= 1 else unit
|
||||
|
||||
|
||||
def scope_violations(claims) -> list:
|
||||
"""Claims filed on an actuator their source's scope does not permit.
|
||||
|
||||
A list of one-line complaints, empty when the routing is right. This is the
|
||||
check `actuator_for()` cannot make on its own, because a caller can always
|
||||
write `actuator=` by hand: it grades the claim list that actually reached the
|
||||
arbiter, whatever built it.
|
||||
"""
|
||||
out = []
|
||||
# ⚠️ THE SECOND TEST IS CONDITIONAL ON THE SITE HAVING NAMED UNITS AT ALL,
|
||||
# and that is not laxity. At N=1 §6.2's identity holds and a per-inverter
|
||||
# source filed on the site is CORRECT - it is what `actuator_for()` returns.
|
||||
# The direction that produces `lo > hi` only exists once there is a second
|
||||
# actuator for the constraint to have belonged to.
|
||||
split = any(c.actuator != ACTUATOR_SITE for c in claims)
|
||||
for c in claims:
|
||||
scope = CLAIM_SCOPE.get(c.source)
|
||||
if scope is None:
|
||||
out.append(f"{c.source}: not classified in CLAIM_SCOPE")
|
||||
elif scope == SCOPE_SITE and c.actuator != ACTUATOR_SITE:
|
||||
out.append(f"{c.source}: a SITE claim filed on unit {c.actuator!r} - "
|
||||
f"it would bound one inverter's share instead of the grid")
|
||||
elif scope == SCOPE_UNIT and c.actuator == ACTUATOR_SITE and split:
|
||||
out.append(f"{c.source}: a UNIT claim filed on the site while the "
|
||||
f"site has named units - it can cross a site limit")
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Claim:
|
||||
@@ -51,15 +245,76 @@ class Claim:
|
||||
lo: float = -inf # for LIMIT
|
||||
hi: float = inf # for LIMIT
|
||||
reason: str = ""
|
||||
# ⚠️ specs.md §6.1 DECLARES THIS FIELD FIRST AND IT IS LAST HERE, ON PURPOSE.
|
||||
# The spec's point is that a claim NAMES its actuator; the tuple position is
|
||||
# not the point, and it is load-bearing for two things that are. Every
|
||||
# construction in this repository passes `source` first, and
|
||||
# test_peakguard.py part 10f reads `Claim.set(...)`/`Claim.limit(...)`'s
|
||||
# FIRST POSITIONAL ARGUMENT off main.py's AST to pin the claim-source set -
|
||||
# the tripwire that guards SAFETY-25's deferred option. Moving `source` off
|
||||
# position 0 would silently blind that scan, which is this repository's named
|
||||
# failure mode. So the field is restored where it costs nothing.
|
||||
#
|
||||
# ⚠️ AND ITS DEFAULT IS THE SITE, EXPLICITLY. Not None, not "". A claim that
|
||||
# does not name an actuator is a claim about the grid connection and says so;
|
||||
# "unspecified" is what the shipped code already had, and it is what made
|
||||
# every claim implicitly about the one and only actuator.
|
||||
actuator: str = ACTUATOR_SITE
|
||||
|
||||
@staticmethod
|
||||
def set(source: str, priority: int, value: float, reason: str = "") -> "Claim":
|
||||
return Claim(source, priority, SET, value=value, reason=reason)
|
||||
def set(source: str, priority: int, value: float, reason: str = "", *,
|
||||
actuator: str = ACTUATOR_SITE) -> "Claim":
|
||||
return Claim(source, priority, SET, value=value, reason=reason,
|
||||
actuator=actuator)
|
||||
|
||||
@staticmethod
|
||||
def limit(source: str, priority: int, lo: float = -inf, hi: float = inf,
|
||||
reason: str = "") -> "Claim":
|
||||
return Claim(source, priority, LIMIT, lo=lo, hi=hi, reason=reason)
|
||||
reason: str = "", *, actuator: str = ACTUATOR_SITE) -> "Claim":
|
||||
return Claim(source, priority, LIMIT, lo=lo, hi=hi, reason=reason,
|
||||
actuator=actuator)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnitBounds:
|
||||
"""What one inverter's OWN claims say about that inverter's share.
|
||||
|
||||
STAGE 1's output, one per unit, and the thing ALLOC-01 consumes. It is an
|
||||
INTERVAL plus the two facts an allocator cannot reconstruct from an interval:
|
||||
|
||||
* `pinned_w` - the value that unit's own winning SET asked for. A SET is not
|
||||
a bound: `resolve()` CLAMPS a set value into the limits rather than
|
||||
intersecting with it, and folding a 2500 W drain into `[2500, 2500]` would
|
||||
turn "clamped to the rating" into "contradicts the rating". So it travels
|
||||
beside the interval, not inside it.
|
||||
* `gate` - the priority of that winning SET, or None. Clause 2 applies a
|
||||
limit only at or above the winner's priority, and at N>=2 the winner may be
|
||||
a SITE set chosen at stage 2, i.e. AFTER this. Stage 1 states the
|
||||
constraint and records what it is gated on; deciding whether it applies is
|
||||
stage 3's business. Guessing here is how a maintenance charge-only `hi`
|
||||
would come to bind a peak discharge it does not outrank.
|
||||
|
||||
`contradiction` is a unit contradicting ITSELF - its own `lo` above its own
|
||||
`hi`. It parks that unit and says so; it does not park the site, which is the
|
||||
entire difference this card is about.
|
||||
"""
|
||||
lo: float = -inf
|
||||
hi: float = inf
|
||||
lo_src: str | None = None
|
||||
hi_src: str | None = None
|
||||
gate: int | None = None
|
||||
pinned_w: float | None = None
|
||||
pinned_by: str | None = None
|
||||
contradiction: bool = False
|
||||
|
||||
def explain(self) -> str:
|
||||
if self.contradiction:
|
||||
return (f"CONTRADICTION within the unit ({self.lo_src} lo="
|
||||
f"{self.lo:.0f} > {self.hi_src} hi={self.hi:.0f})")
|
||||
span = (f"[{'-inf' if self.lo == -inf else format(self.lo, '.0f')}, "
|
||||
f"{'+inf' if self.hi == inf else format(self.hi, '.0f')}]")
|
||||
if self.pinned_by is not None:
|
||||
return f"{span} around {self.pinned_by} -> {self.pinned_w:.0f} W"
|
||||
return span
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -69,6 +324,32 @@ class Resolution:
|
||||
bound_by: str | None = None # which limit actually changed it, if any
|
||||
contradiction: bool = False
|
||||
considered: list = field(default_factory=list)
|
||||
# --- the three-stage fields. ⚠️ APPENDED, AND DEFAULTED, ON PURPOSE: every
|
||||
# existing construction of this type is positional in its first five fields
|
||||
# and `target_w` still means exactly what it meant - the SITE grid target.
|
||||
# main.py assigns it to `self.target` unchanged.
|
||||
unit_bounds: dict = field(default_factory=dict) # stage 1, per actuator
|
||||
shares: dict = field(default_factory=dict) # stage 3, per actuator
|
||||
allocated: bool = True # False when stage 3 declined to split
|
||||
scope_errors: list = field(default_factory=list) # mis-routed claims
|
||||
# ALLOC-01, appended for the reason the four above were: `target_w` still
|
||||
# means the SITE grid target and every existing construction is untouched.
|
||||
#
|
||||
# `allocation` is stage 3's full record - what each unit was told and why,
|
||||
# what could not be placed, what was dropped. `shares` and `allocated` are
|
||||
# two fields OF that record, kept at the top level because main.py and four
|
||||
# test files already read them there. ⚠️ IT IS THE RECORD, NOT THE PAIR,
|
||||
# THAT SAFETY-28 GATES THE WRITE PATH ON: `allocated` says the allocator
|
||||
# stands behind the split, and `unmet_w` says how much of the site target it
|
||||
# could not place, and fusing those two would make one saturated inverter
|
||||
# park the whole site.
|
||||
allocation: object | None = None
|
||||
# The priority of the SITE claim that chose `target_w`. Stage 3 weighs a
|
||||
# unit's OWN winning SET against it - see `allocator.plan()`'s PINS note -
|
||||
# and getting it from here rather than recomputing it is what stops the two
|
||||
# rules drifting apart: this is set by the same line that picks the winner
|
||||
# `explain()` names.
|
||||
winner_priority: int | None = None
|
||||
|
||||
def explain(self) -> str:
|
||||
"""One line an operator can act on - this is the point of the module."""
|
||||
@@ -79,7 +360,107 @@ class Resolution:
|
||||
return f"{self.winner} -> {self.target_w:.0f} W"
|
||||
|
||||
|
||||
def resolve(claims: list) -> Resolution:
|
||||
def pre_resolve(unit_claims: list) -> UnitBounds:
|
||||
"""STAGE 1. One inverter's own claims -> that inverter's interval.
|
||||
|
||||
Clauses 1 and 2 of the rule, transposed: the unit's highest-priority SET
|
||||
names what the unit wants and gates which of the unit's limits apply, and the
|
||||
most restrictive of those limits gives the interval. Read UnitBounds on why
|
||||
the SET value is not folded into the interval.
|
||||
|
||||
⚠️ THE INPUT IS ONE UNIT'S CLAIMS AND NOTHING ELSE. A site claim reaching
|
||||
this function would become a bound on one inverter's share - the mirror image
|
||||
of the fault this card removes, and the reason `resolve()` partitions rather
|
||||
than filters twice.
|
||||
"""
|
||||
sets = sorted((c for c in unit_claims if c.kind == SET),
|
||||
key=lambda c: c.priority, reverse=True)
|
||||
gate = pinned_w = pinned_by = None
|
||||
if sets:
|
||||
gate, pinned_w, pinned_by = sets[0].priority, sets[0].value, sets[0].source
|
||||
|
||||
lo, hi = -inf, inf
|
||||
lo_src = hi_src = None
|
||||
for c in unit_claims:
|
||||
if c.kind != LIMIT or (gate is not None and c.priority < gate):
|
||||
continue
|
||||
if c.lo > lo:
|
||||
lo, lo_src = c.lo, c.source
|
||||
if c.hi < hi:
|
||||
hi, hi_src = c.hi, c.source
|
||||
return UnitBounds(lo, hi, lo_src, hi_src, gate, pinned_w, pinned_by,
|
||||
contradiction=lo > hi)
|
||||
|
||||
|
||||
def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
scope_errors=(), site_priority=None) -> tuple:
|
||||
"""STAGE 3. THE SEAM, AND SINCE ALLOC-01 IT IS OCCUPIED. `(shares, allocated)`.
|
||||
|
||||
⚠️ THE ALLOCATOR ITSELF IS `app/allocator.py` AND THE REASONING LIVES THERE.
|
||||
This is the adapter: it keeps the two-value shape every caller in this
|
||||
repository already reads, and it exists as a separate name so that the
|
||||
arbiter's three stages are still three functions a test can enter at any
|
||||
one of them. `allocator.plan()` returns the full record - use that when you
|
||||
need `unmet_w`, `dropped` or the per-unit reasons; `resolve()` stashes it on
|
||||
`Resolution.allocation` for exactly that.
|
||||
|
||||
What §6.2 fixed and what this delivers:
|
||||
|
||||
* SUM-INVARIANT. `sum(shares) + unmet_w == round(target_w)`, in integer
|
||||
arithmetic, so it cannot drift. Rounding is largest-remainder because a
|
||||
per-unit `round()` biases the sum by up to N/2 W every tick and at 1-2 Hz
|
||||
that is a PERMANENT standing error the site integrator chases forever.
|
||||
* EACH UNIT IS TOLD A FUNCTION OF ITS OWN SHARE, never of the shared
|
||||
measurement. That makes the gain at the meter a convex combination of the
|
||||
per-unit gains for ANY split, which is why the weights are a comfort
|
||||
choice and a bad split cannot destabilise the site.
|
||||
* WITH AT MOST ONE UNIT IT IS STILL A PASS-THROUGH, byte-identical to what
|
||||
ARB-03 shipped: the share IS the site target, as a float, unrounded and
|
||||
unclamped. §6.2's N=1 identity, and the compatibility promise every
|
||||
existing replay in this repository depends on.
|
||||
|
||||
⚠️ AND IT STILL DECLINES, WHICH IS NOT THE SAME LIST OF CASES AS BEFORE.
|
||||
ARB-03 declined at N>=2 because it had no allocator; that trigger is gone
|
||||
and the branch is NOT. It now fires when no safe split exists at all: a
|
||||
mis-routed claim, no unit left in the envelope set, or a split that could
|
||||
only be made by commanding one pack to charge while another discharges.
|
||||
Read `allocator.plan()` for each. main.py's refusal path and
|
||||
test_peakguard.py part 10h were re-aimed onto those rather than deleted -
|
||||
PR #40's reviewer proved that guard was unreachable and untested once, and
|
||||
removing its trigger without giving it a new one would rebuild the hole.
|
||||
"""
|
||||
return allocate_record(target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=scope_errors,
|
||||
site_priority=site_priority)[:2]
|
||||
|
||||
|
||||
def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
scope_errors=(), site_priority=None, tick=None) -> tuple:
|
||||
"""`allocate()` plus the record - `(shares, allocated, allocation)`.
|
||||
|
||||
`tick` is SAFETY-28's freshness token: it identifies the tick the record is
|
||||
FOR and is handed straight through to `allocator.plan()`. Left None the
|
||||
record is UNSTAMPED, which the write gate refuses - so a caller that intends
|
||||
the record to authorise a write must pass one. Every existing caller in this
|
||||
repository wants the arithmetic and not the authority, and gets None.
|
||||
"""
|
||||
rec = allocator.plan(target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=scope_errors,
|
||||
site_priority=site_priority, site_key=ACTUATOR_SITE,
|
||||
tick=tick)
|
||||
return rec.shares, rec.ok, rec
|
||||
|
||||
|
||||
def site_resolve(claims: list) -> Resolution:
|
||||
"""STAGE 2. The SITE claims -> one grid target.
|
||||
|
||||
⚠️ THIS FUNCTION IS THE SHIPPED ARBITER, UNCHANGED. It is a separate name and
|
||||
the same code, deliberately: at N=1 nothing is filed on a unit, so `resolve()`
|
||||
hands it the whole claim list and it answers what it has always answered.
|
||||
Every check in test_arbiter.py, test_watchdog.py, test_validate.py and
|
||||
test_peakguard.py replays through here unedited, which is the proof that
|
||||
ARB-03 changed no behaviour on the live path.
|
||||
"""
|
||||
sets = sorted((c for c in claims if c.kind == SET),
|
||||
key=lambda c: c.priority, reverse=True)
|
||||
|
||||
@@ -104,7 +485,8 @@ def resolve(claims: list) -> Resolution:
|
||||
|
||||
if lo > hi:
|
||||
return Resolution(0.0, winner.source, bound_by=None,
|
||||
contradiction=True, considered=considered)
|
||||
contradiction=True, considered=considered,
|
||||
winner_priority=winner.priority)
|
||||
|
||||
target = max(lo, min(hi, winner.value))
|
||||
bound_by = None
|
||||
@@ -113,4 +495,80 @@ def resolve(claims: list) -> Resolution:
|
||||
if binder is not None:
|
||||
bound_by = f"{binder.source}({binder.reason})" if binder.reason else binder.source
|
||||
|
||||
return Resolution(float(target), winner.source, bound_by, False, considered)
|
||||
return Resolution(float(target), winner.source, bound_by, False, considered,
|
||||
winner_priority=winner.priority)
|
||||
|
||||
|
||||
def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
tick=None) -> Resolution:
|
||||
"""The three stages, in order, over one cycle's claims.
|
||||
|
||||
`units` is the site's roster of inverters. Left None it is derived from the
|
||||
claims themselves, which is right for a caller that files a claim for every
|
||||
unit every cycle (main.py does - `supervised` is unconditional) and wrong for
|
||||
one that does not, so main.py passes the real roster rather than let a silent
|
||||
unit look like a smaller site.
|
||||
|
||||
`weights` and `live` are ALLOC-01's two per-tick inputs to stage 3, and both
|
||||
are per-TICK on purpose. `weights` is the comfort choice - SoC, thermals,
|
||||
wear - and cannot destabilise the site whatever it says. `live` is the set
|
||||
of units whose master has reported recently enough to be commanded; a unit
|
||||
left out of it is DROPPED from the envelope set and the record says so,
|
||||
because the survivor carrying a dead unit's share silently is the failure
|
||||
SAFETY-30 is about. ⚠️ NEITHER MAY BE CACHED BY A CALLER. A unit's range
|
||||
moves for reasons unrelated to the allocator - a stale link, a pack at its
|
||||
floor, a device rating that failed to read - so an envelope read once at
|
||||
construction over-allocates into a unit that cannot take it.
|
||||
|
||||
⚠️ AND THE ARGUMENT HAS TO BE HONOURED, WHICH IS NOT THE SAME AS EXISTING.
|
||||
Ignore it - derive always - and a unit that filed nothing this cycle ceases
|
||||
to exist: a site of two reads as a site of one and stage 3 passes the whole
|
||||
grid target through to the inverter that spoke, which is ARCH-01's fault
|
||||
with its own evidence removed. That mutation survived the whole suite until
|
||||
test_arbiter.py part 7 graded it on a SILENT unit.
|
||||
|
||||
⚠️ `tick` IS SAFETY-28's FRESHNESS TOKEN AND IT IS NOT OPTIONAL ON THE LIVE
|
||||
PATH. It identifies the tick this resolution is FOR, and it ends up on
|
||||
`Resolution.allocation.tick`, where the write gate compares it against the
|
||||
tick it is writing on. A record with no tick cannot authorise a write, which
|
||||
is why main.py passes `self.alloc_tick` and why a test that only wants the
|
||||
arbiter's arithmetic can leave it out and be sure it has not accidentally
|
||||
minted a licence to write.
|
||||
|
||||
⚠️ THE PARTITION IS BY `actuator`, AND IT IS A PARTITION, NOT A FILTER. Every
|
||||
claim lands in exactly one stage. That is the whole structural argument: a
|
||||
unit's `hi` and the site's `lo` are never both terms of the same `lo > hi`
|
||||
test, so SAFETY-25 §0a's cross-inverter contradiction - 0 W inside the billed
|
||||
quarter - is not something to guard against, it is something that cannot be
|
||||
written down.
|
||||
"""
|
||||
site_claims = [c for c in claims if c.actuator == ACTUATOR_SITE]
|
||||
if units is None:
|
||||
units = {c.actuator for c in claims if c.actuator != ACTUATOR_SITE}
|
||||
units = tuple(sorted(units))
|
||||
|
||||
# STAGE 1 - each unit's own claims become that unit's interval.
|
||||
bounds = {u: pre_resolve([c for c in claims if c.actuator == u])
|
||||
for u in units}
|
||||
|
||||
# STAGE 2 - the site claims become the grid target. At N=1 `site_claims` IS
|
||||
# `claims`, so this is the shipped arbiter on the shipped list.
|
||||
out = site_resolve(site_claims)
|
||||
|
||||
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
|
||||
#
|
||||
# ⚠️ THE ORDER MATTERS: `scope_violations()` IS COMPUTED BEFORE THE SPLIT,
|
||||
# NOT AFTER, because stage 3 consumes it. A claim filed on an actuator its
|
||||
# source's scope does not permit means the number about to be divided may
|
||||
# itself be wrong, and dividing a number that may be wrong is worse than
|
||||
# not dividing it - `allocator.plan()` carries the full argument, including
|
||||
# why the mis-routed claim is NOT promoted into somebody's envelope. This
|
||||
# was the open question ARB-03 left, and it is answered here rather than
|
||||
# left as a log line nothing grades.
|
||||
out.scope_errors = scope_violations(claims)
|
||||
out.unit_bounds = bounds
|
||||
out.shares, out.allocated, out.allocation = allocate_record(
|
||||
out.target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=out.scope_errors, site_priority=out.winner_priority,
|
||||
tick=tick)
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
"""OPT-04 - §10.1 priority 3: charge cheap, discharge expensive, and only when
|
||||
the round trip is worth more than it costs.
|
||||
|
||||
WHERE THIS LIVES, AND WHY IT IS NOT WHERE THE CARD SAYS. The functional notes
|
||||
on the Trello card reference specs.md §2.2's `ems/optimizer/...` layout. That
|
||||
module layout does not exist and is not being built; the product owner's
|
||||
decision of 2026-08-30 is that everything ships in `goodwe_controller/app/`,
|
||||
beside price.py, static_tariff.py, peakguard.py and arbiter.py. The BEHAVIOUR
|
||||
§10.1 / §9.1 / §8.3 / §17 describes is unchanged and still authoritative.
|
||||
|
||||
|
||||
WHAT THIS MODULE IS, AND - MORE IMPORTANTLY - WHAT IT IS NOT
|
||||
============================================================
|
||||
Four cards landed before this one and this one CONSUMES all four. Almost every
|
||||
question an arbitrage strategy has to answer is already answered somewhere
|
||||
else, by an object built for it, and the single biggest way to get this ticket
|
||||
wrong is to answer any of them again here:
|
||||
|
||||
price.py (TARIFF-02) what a price IS, all-in, and what a product is.
|
||||
`Uncertainty`, `WIDEN_FACTOR`, `required_spread`.
|
||||
static_tariff.py (TARIFF-03) `TariffPlan.arbitrage()` - THE VERDICT. Whether
|
||||
a trade is permitted at all, on which two hours,
|
||||
against which widened threshold. Five refusal
|
||||
reasons live there, not here.
|
||||
price_forecast.py (FCST-03) provisional carrying, and the rule that no
|
||||
irreversible action rests on a provisional
|
||||
price (§8.3).
|
||||
peakguard.py (SAFETY-07) the capacity-tariff floor, filed as a `lo`
|
||||
LIMIT at P_PEAK on the arbiter.
|
||||
|
||||
So what is left for this file is genuinely small, and that is the point:
|
||||
|
||||
* WHICH HOUR IS IT NOW - is this instant the buy hour, the sell hour, or
|
||||
neither.
|
||||
* MAY THE BATTERY DO IT - state of charge, and §17's negative-price rule.
|
||||
* HOW HARD - one number, filed as one arbiter claim.
|
||||
|
||||
Everything else is delegated, and delegated by CONSTRUCTION rather than by
|
||||
convention: `TariffPlan.arbitrage()` will not hand over a spread at all unless
|
||||
it is told whether the peak guard is holding, what a cycle costs, and what is
|
||||
uncertain. There is no code path here that can reach a tradeable number without
|
||||
having answered §10.1's priority ordering first.
|
||||
|
||||
|
||||
AC3 - PEAK SHAVING WINS, AND IT WINS TWICE
|
||||
==========================================
|
||||
§9.1: "Give peak shaving priority over arbitrage whenever the two conflict."
|
||||
That is honoured through the two mechanisms that already exist, and NEITHER of
|
||||
them is re-implemented here:
|
||||
|
||||
1. THE INTERLOCK. `plan.arbitrage(peak_guard_active=...)` is keyword-only and
|
||||
REQUIRED. When the guard is engaged the verdict is a refusal carrying the
|
||||
§10.1 wording, so `decide()` below never sees a spread at all. It is not
|
||||
possible to write a caller here that skips the question - the argument has
|
||||
no default and the spread is unreachable without it.
|
||||
|
||||
2. THE ARBITER. Even if (1) were somehow bypassed, `claim()` files a SET at
|
||||
P_ARBITRAGE (30) and peakguard files a LIMIT at P_PEAK (50). arbiter.py
|
||||
clause 2 applies every limit whose priority is >= the winning set's, most
|
||||
restrictive wins - so the peak floor CLAMPS an arbitrage charge whatever
|
||||
this module thinks. 50 >= 30 is what makes that true, and it is why the
|
||||
priority below sits under P_PEAK rather than beside it.
|
||||
|
||||
Two independent mechanisms for one rule is deliberate. The first is the one
|
||||
that reports honestly; the second is the one that holds when the first is
|
||||
wrong.
|
||||
|
||||
⚠️ AND THE GUARD MUST BE ABLE TO SEE. On `meter_source: off` there is no
|
||||
telegram stream, so peakguard.decide() is never run and `peak_guard_active` is
|
||||
not FALSE - it is UNKNOWN. Charging a battery at max_w creates offtake, offtake
|
||||
is the billed quantity (§9.1), and doing it deliberately while nothing measures
|
||||
the quarter is the exact shape of failure §9.1 exists to prevent. So the third
|
||||
state is carried explicitly as `None` and it REFUSES. A failed read is not an
|
||||
observation, and "the guard did not run" is not "the guard is clear".
|
||||
|
||||
|
||||
§17's NEGATIVE-PRICE CASE, STATED ONCE
|
||||
======================================
|
||||
"Negative-price hours - arbitrage must not export." A negative price means the
|
||||
market pays the site to consume. Discharging the battery into such an hour
|
||||
gives away stored energy AND forgoes being paid to take more in; if the house
|
||||
load is covered it becomes export, which at a negative price is paid-for
|
||||
disposal. The buy side is untouched - a negative hour is the best possible hour
|
||||
to charge in, and this file's guard is deliberately asymmetric for that reason.
|
||||
|
||||
⚠️ THE GUARD IS ON THE HOUR BEING SOLD INTO, NOT ON THE SPREAD. A series whose
|
||||
prices are -0.30 and -0.05 has a 0.25 EUR/kWh spread that clears any shipped
|
||||
threshold, and its DEAREST hour is still negative. A guard written as
|
||||
"spread > 0" or "the prices are not all negative" passes that case and exports.
|
||||
|
||||
|
||||
WHAT `cycle_cost_threshold_eur` IS, AND WHAT THIS FILE REFUSES TO DO WITH IT
|
||||
===========================================================================
|
||||
§10.1 gates arbitrage on "expected round-trip benefit exceeds
|
||||
`cycle_cost_threshold_eur`". It ships at 0.10 EUR/kWh. On realistic Belgian
|
||||
day/night numbers the static spread is about 0.08 EUR/kWh, and FCST-03's
|
||||
widening factors (2.5 real-time, 1.5 no-weather) are honest policy defaults
|
||||
with nothing to derive them from.
|
||||
|
||||
⚠️ SO A STATIC-TARIFF SITE DOES NOT TRADE AT SHIPPED DEFAULTS, AND THAT IS
|
||||
REPORTED RATHER THAN TUNED AWAY. The threshold is a parameter here, the
|
||||
behaviour is correct at any value of it, and no number in this file was chosen
|
||||
to make a trade happen. Whether 0.10 is the right shipped default - and whether
|
||||
2.5/1.5 are - is one product decision and it belongs to the owner, not to this
|
||||
card. test_arbitrage.py section 7 reports what the shipped default does over a
|
||||
replayed year rather than adjusting anything so that it looks better.
|
||||
|
||||
|
||||
THE ACCOUNTING BASIS, AND THE TERM THAT IS DELIBERATELY ABSENT
|
||||
=============================================================
|
||||
The card's technical notes ask for `benefit = value_out(d) - cost_in(c)/η`, and
|
||||
then say of the round-trip efficiency: "η is NOT a §14 key - it needs one, or
|
||||
must be derived from measured charge/discharge energy in the state store. DO
|
||||
NOT HARDCODE IT." Both halves are honoured by not having the term at all:
|
||||
|
||||
* THE SELL SIDE IS PRICED AS DISPLACED IMPORT, NOT AS EXPORT. The two hours
|
||||
compared are both all-in CONSUMPTION prices, which is the basis the notes
|
||||
require ("credit displacement at all-in import prices, not at export price,
|
||||
or the number flatters itself"). `export_price_eur_kwh` is therefore not
|
||||
read here at all - §9.3's near-zero Belgian injection price is precisely
|
||||
why the economically sound trade on this market is charge-cheap-to-
|
||||
displace-expensive-import, and §17's guard below is the same fact stated as
|
||||
a rule.
|
||||
|
||||
* AND THERE IS NO η, BECAUSE THERE IS NOTHING HONEST TO PUT IN IT. No config
|
||||
key exists, the state store carries no measured charge/discharge energy
|
||||
pair to derive one from, and a hardcoded 0.85 is exactly what the notes
|
||||
forbid - it would also DOUBLE-COUNT, because `cycle_cost_threshold_eur` is
|
||||
already defined as what a round trip costs per kWh and conversion loss is
|
||||
part of that cost. When a measured η exists it belongs beside the threshold
|
||||
as a second term, and this file's arithmetic is one subtraction, in one
|
||||
place, ready for it.
|
||||
|
||||
|
||||
HYSTERESIS (§10.2)
|
||||
==================
|
||||
The switching decision is "which hour is it", and an hour is 60 minutes wide,
|
||||
so the action cannot chatter within one. The other two terms are monotone
|
||||
inside an hour in the only direction that matters: a charge raises SoC towards
|
||||
the ceiling and a discharge lowers it towards the floor, so each can stop once
|
||||
and cannot restart in the same hour. No timer is needed and none is invented.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .arbiter import ACTUATOR_SITE, P_ARBITRAGE, Claim
|
||||
from .price import Absence, price_option
|
||||
|
||||
_LOG = logging.getLogger("goodwe.arbitrage")
|
||||
|
||||
SOURCE = "arbitrage"
|
||||
|
||||
# §10.2 reason codes. Written out because the action log is a user-facing
|
||||
# artefact and a code assembled from an f-string cannot be grepped for.
|
||||
CHARGE = "ARBITRAGE_CHARGE"
|
||||
DISCHARGE = "ARBITRAGE_DISCHARGE"
|
||||
|
||||
# Why nothing was done. These are the reasons this MODULE owns; the five
|
||||
# refusals that belong to the verdict itself are carried through verbatim from
|
||||
# static_tariff.py rather than restated, so a refusal's wording has exactly one
|
||||
# author.
|
||||
IDLE_NO_PLAN = "no tariff plan has been resolved yet"
|
||||
IDLE_NOT_COVERED = (
|
||||
"NOT COVERED: meter_source is off, so the capacity-tariff peak guard never "
|
||||
"runs and nothing measures the quarter this charge would land in. §9.1 puts "
|
||||
"peak shaving above arbitrage, and an unmeasured peak cannot be shaved - "
|
||||
"so the honest answer is that arbitrage is not covered on this install, not "
|
||||
"that the guard is clear")
|
||||
IDLE_NO_SOC = (
|
||||
"state of charge is not being read, and a battery whose fill nobody knows "
|
||||
"cannot be told to empty or to fill")
|
||||
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
|
||||
NEGATIVE_PRICE = (
|
||||
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
|
||||
"energy away and pay for the privilege. Arbitrage must not export at a "
|
||||
"negative price")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArbitrageDecision:
|
||||
"""What the strategy concluded this cycle, with what it concluded it from.
|
||||
|
||||
⚠️ NO FIELD HAS A DEFAULT, and that is TEST-14's rule rather than a style
|
||||
choice: an outcome type's required field quietly gaining a default is a
|
||||
mutation that survived review in three files in this repository. The
|
||||
signature is scanned in test_price_forecast.py alongside `Gate`'s and
|
||||
`Arbitrage`'s constructors, so a default added here later goes red without
|
||||
anybody remembering to write a case for it.
|
||||
|
||||
`action` is None whenever nothing is to be done, and `power_w` is then None
|
||||
too - not 0.0. Zero watts is a COMMAND (it parks the battery); "no claim"
|
||||
is the absence of one, and the arbiter must be free to let the loop win.
|
||||
"""
|
||||
|
||||
action: str | None # CHARGE | DISCHARGE | None
|
||||
power_w: float | None # signed: negative charges, positive discharges
|
||||
reason: str
|
||||
spread_eur_kwh: float | None
|
||||
required_eur_kwh: float | None
|
||||
price_now_eur_kwh: float | None
|
||||
|
||||
@property
|
||||
def trading(self) -> bool:
|
||||
return self.action is not None
|
||||
|
||||
def explain(self) -> str:
|
||||
if not self.trading:
|
||||
return f"arbitrage idle - {self.reason}"
|
||||
return self.reason
|
||||
|
||||
|
||||
def _idle(reason, *, spread=None, required=None, price=None) -> ArbitrageDecision:
|
||||
if not reason or not isinstance(reason, str):
|
||||
raise ValueError(
|
||||
"an idle arbitrage decision needs a reason a human can act on - "
|
||||
"§10.2 requires every plan change to carry one, and 'arbitrage did "
|
||||
"nothing' with no reason is indistinguishable from a bug.")
|
||||
return ArbitrageDecision(action=None, power_w=None, reason=reason,
|
||||
spread_eur_kwh=spread, required_eur_kwh=required,
|
||||
price_now_eur_kwh=price)
|
||||
|
||||
|
||||
def enabled(opts: dict) -> bool:
|
||||
"""Is the strategy switched on at all? Off in the shipped configuration.
|
||||
|
||||
⚠️ OFF BY DEFAULT ON PURPOSE. Every other card in this repository ships its
|
||||
machinery inert and lets an operator arm it; this one MOVES ENERGY against
|
||||
the meter on a real battery, so it is the last one that should be an
|
||||
exception to that.
|
||||
"""
|
||||
v = price_option(opts, "arbitrage_enabled")
|
||||
return v is True or str(v).strip().lower() in ("true", "yes", "on", "1")
|
||||
|
||||
|
||||
def threshold_eur_kwh(opts: dict) -> float:
|
||||
"""§10.1's `cycle_cost_threshold_eur`, from the one place option defaults live."""
|
||||
v = float(price_option(opts, "cycle_cost_threshold_eur"))
|
||||
if not math.isfinite(v) or v < 0:
|
||||
raise ValueError(
|
||||
f"cycle_cost_threshold_eur is {v!r}. It is what a round trip COSTS, "
|
||||
f"in EUR/kWh, so it is non-negative and finite - a negative "
|
||||
f"threshold is one every spread clears, which is arbitrage with the "
|
||||
f"brake removed.")
|
||||
return v
|
||||
|
||||
|
||||
def soc_bounds(opts: dict) -> tuple:
|
||||
"""(floor, ceiling) in percent. The reserve arbitrage may not spend."""
|
||||
return (float(price_option(opts, "arbitrage_soc_floor")),
|
||||
float(price_option(opts, "arbitrage_soc_ceiling")))
|
||||
|
||||
|
||||
def guard_state(peak_decision):
|
||||
"""The peak guard's answer as the three-state `decide()` takes: T / F / None.
|
||||
|
||||
THE ONE PLACE THIS CONJUNCTION IS WRITTEN, and it is public for the reason
|
||||
`peakguard.shaping_suspended()`, `watchdog.loop_is_following()` and
|
||||
`freeze.looks_frozen()` are: the check that grades it has to assert on THE
|
||||
SHIPPED EXPRESSION. A predicate transcribed into its own test is one copy
|
||||
plus a future disagreement about what the check means, and the copy that
|
||||
drifts is always the grading one - this repository has three separate
|
||||
records of exactly that, one of which left every suite green while a
|
||||
default install parked at 0 W in the quarter that sets the month's bill.
|
||||
|
||||
⚠️ `None` IS NOT `False` AND COLLAPSING THE TWO IS THE WHOLE HAZARD. On
|
||||
`meter_source: off` main.py never runs peakguard.decide() - there is no
|
||||
telegram stream, so there is no open quarter to project - and the decision
|
||||
it hands here is None. That is "the guard never ran", not "the guard is
|
||||
clear", and answering False would arm a strategy whose charging creates the
|
||||
billed quantity (§9.1) while nothing measures the quarter it lands in.
|
||||
"""
|
||||
if peak_decision is None:
|
||||
return None
|
||||
return bool(peak_decision.engaged)
|
||||
|
||||
|
||||
def _point_at(plan, when: datetime):
|
||||
"""The price point covering `when`, or None when the series does not cover it.
|
||||
|
||||
⚠️ None IS NOT A PRICE AND IS NOT ZERO. `TariffPlan` raises `Absence` here
|
||||
rather than answering 0.0, for price.py's reason - "no data" becoming "free"
|
||||
is the defect the whole outcome-type family exists to remove - and this
|
||||
turns that into "do nothing", never into "it is cheap".
|
||||
"""
|
||||
try:
|
||||
return plan.read.match(series=lambda r: r.at(when),
|
||||
no_data=lambda _r: None)
|
||||
except Absence:
|
||||
return None
|
||||
|
||||
|
||||
def decide(plan, now: datetime, *, peak_guard_active, soc,
|
||||
min_spread_eur_kwh, uncertainty, charge_w, discharge_w,
|
||||
soc_floor_pct, soc_ceiling_pct) -> ArbitrageDecision:
|
||||
"""Should the battery trade this cycle, and how hard.
|
||||
|
||||
⚠️ `peak_guard_active` IS A THREE-STATE ARGUMENT AND ALL THREE ARE REAL.
|
||||
True (the guard is holding), False (it ran and is clear), and None (it never
|
||||
ran, because nothing measures this install's quarter). See the module note:
|
||||
None refuses, because the alternative is a strategy that creates the billed
|
||||
quantity while the thing that would have limited it is switched off.
|
||||
|
||||
⚠️ EVERY OTHER ARGUMENT IS KEYWORD-ONLY AND HAS NO DEFAULT, for
|
||||
`TariffPlan.arbitrage()`'s reason. A defaulted `soc_floor_pct` is a reserve
|
||||
floor this module asserted rather than one the operator configured, and the
|
||||
first time it is wrong the battery is at 0 %.
|
||||
"""
|
||||
if peak_guard_active is None:
|
||||
return _idle(IDLE_NOT_COVERED)
|
||||
if plan is None:
|
||||
return _idle(IDLE_NO_PLAN)
|
||||
|
||||
verdict = plan.arbitrage(peak_guard_active=peak_guard_active,
|
||||
min_spread_eur_kwh=min_spread_eur_kwh,
|
||||
uncertainty=uncertainty)
|
||||
return verdict.match(
|
||||
refused=lambda v: _idle(v.reason),
|
||||
allowed=lambda v: _act(plan, now, v, soc, charge_w, discharge_w,
|
||||
soc_floor_pct, soc_ceiling_pct))
|
||||
|
||||
|
||||
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||
"""The permitted case: which hour is this, and may the pack do it."""
|
||||
here = _point_at(plan, now)
|
||||
if here is None:
|
||||
return _idle(
|
||||
f"the series carries a tradeable spread but no price for this "
|
||||
f"instant ({now.isoformat()}), which is §11.4 for this hour rather "
|
||||
f"than a reason to reach for a neighbouring one",
|
||||
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh)
|
||||
|
||||
price = here.eur_per_kwh
|
||||
buying = here.start == v.cheapest.start
|
||||
selling = here.start == v.dearest.start
|
||||
if not (buying or selling):
|
||||
return _idle(IDLE_OFF_PEAK_HOUR, spread=v.spread_eur_kwh,
|
||||
required=v.required_spread_eur_kwh, price=price)
|
||||
|
||||
# ⚠️ BELT AND BRACES ON §8.3, AND IT IS NOT DEAD CODE. `_firm_or_die` makes
|
||||
# the two points on the verdict firm at construction, but the point matched
|
||||
# HERE is whatever `at()` returned for this instant, and the identity of the
|
||||
# two rests on the series being the one the verdict was built from. An hour
|
||||
# that is provisional is one nobody published a price for, and no
|
||||
# irreversible action may rest on it.
|
||||
if here.provisional:
|
||||
return _idle(
|
||||
f"§8.3: the price for this hour ({here.start.isoformat()}) is "
|
||||
f"CARRIED FORWARD, not published, and no irreversible action may be "
|
||||
f"taken on a provisional price",
|
||||
spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh,
|
||||
price=price)
|
||||
|
||||
if soc is None:
|
||||
return _idle(IDLE_NO_SOC, spread=v.spread_eur_kwh,
|
||||
required=v.required_spread_eur_kwh, price=price)
|
||||
|
||||
common = dict(spread=v.spread_eur_kwh, required=v.required_spread_eur_kwh,
|
||||
price=price)
|
||||
|
||||
if selling:
|
||||
# §17. THE NAMED REGRESSION CASE. Checked BEFORE the SoC question, so a
|
||||
# negative price reports the reason that actually forbids the trade
|
||||
# rather than one that happens to fire first on some other site.
|
||||
if price < 0:
|
||||
return _idle(f"{NEGATIVE_PRICE} - this hour is "
|
||||
f"{price:+.4f} EUR/kWh", **common)
|
||||
if soc <= floor_pct:
|
||||
return _idle(
|
||||
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
|
||||
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
|
||||
f"arbitrage may not spend (§10.1 priority 4)", **common)
|
||||
return ArbitrageDecision(
|
||||
action=DISCHARGE, power_w=abs(float(discharge_w)),
|
||||
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
|
||||
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} above the "
|
||||
f"cheapest and clear of the {v.required_spread_eur_kwh:.4f} "
|
||||
f"this cycle had to cover; discharging "
|
||||
f"{abs(float(discharge_w)):.0f} W from {soc:g} %"),
|
||||
spread_eur_kwh=v.spread_eur_kwh,
|
||||
required_eur_kwh=v.required_spread_eur_kwh,
|
||||
price_now_eur_kwh=price)
|
||||
|
||||
if soc >= ceiling_pct:
|
||||
return _idle(
|
||||
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
|
||||
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
|
||||
f"nowhere to put the cheap energy", **common)
|
||||
return ArbitrageDecision(
|
||||
action=CHARGE, power_w=-abs(float(charge_w)),
|
||||
reason=(f"{CHARGE}: this is the cheapest firm hour at "
|
||||
f"{price:.4f} EUR/kWh, {v.spread_eur_kwh:.4f} below the dearest "
|
||||
f"and clear of the {v.required_spread_eur_kwh:.4f} this cycle "
|
||||
f"had to cover; charging {abs(float(charge_w)):.0f} W from "
|
||||
f"{soc:g} %"),
|
||||
spread_eur_kwh=v.spread_eur_kwh,
|
||||
required_eur_kwh=v.required_spread_eur_kwh,
|
||||
price_now_eur_kwh=price)
|
||||
|
||||
|
||||
def claim(decision: ArbitrageDecision) -> Claim | None:
|
||||
"""The decision as one arbiter claim, or None when nothing is to be traded.
|
||||
|
||||
A SET at P_ARBITRAGE, and the priority is the whole §10.1 ordering:
|
||||
|
||||
P_PEAK 50 > this, so peakguard's `lo` floor CLAMPS an arbitrage
|
||||
charge (arbiter clause 2 applies every limit at or
|
||||
above the winner's priority). AC3, mechanically.
|
||||
P_MAINT_SHAPE 40 > this, so a maintenance charge window's `hi` shaping is
|
||||
not overridden by a price opportunity. §12.4's "money
|
||||
outranks the schedule" is about the PEAK guard, which
|
||||
is measured and billed; a speculative spread is not
|
||||
that, and letting it out-rank the pack's balancing
|
||||
cycle would be this card quietly rewriting §12.
|
||||
P_LOOP 10 < this, because trading against the meter is the point:
|
||||
the grid-following loop would otherwise cancel every
|
||||
charge the moment it created import.
|
||||
|
||||
⚠️ NONE MEANS NO CLAIM, NOT A 0 W CLAIM. Filing `Claim.set(..., 0.0)` here
|
||||
would out-rank the loop with a park order every cycle the strategy had
|
||||
nothing to say, i.e. it would silently disable self-consumption on any site
|
||||
that armed arbitrage.
|
||||
"""
|
||||
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, 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
|
||||
@@ -0,0 +1,294 @@
|
||||
"""SAFETY-03 - the output clamp, and the alarm that makes it audible.
|
||||
|
||||
WHAT IT CATCHES, in one sentence: a control law that computes a setpoint the
|
||||
actuator cannot deliver, which every layer below it will happily clip to
|
||||
something legal-looking while the thing that produced it goes on winding.
|
||||
|
||||
THE CLAMP IS NOT THE DELIVERABLE. specs.md 10.3 is explicit that this project
|
||||
already clipped, and that the clipping is what hid the defect:
|
||||
|
||||
with the inverter switched off entirely, an unclamped integrator climbed
|
||||
~130 W per 4 s to over 10 kW and, pinned at the 5 kW rail, reported
|
||||
14 768 W
|
||||
|
||||
A clamp alone turns that into a silent, perfectly flat 5000 W hold. Every
|
||||
dashboard reads "commanded 5000 W", which is a legal number, and nothing
|
||||
anywhere says the loop asked for three times it. 11.2's watchdog row says
|
||||
"Clamp AND ALARM - the loop is winding"; the second verb is this module.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHAT "THE RATING" IS HERE, AND WHY IT IS ONE NUMBER AND NOT A MIN()
|
||||
|
||||
The card names two bounds and warns against choosing between them:
|
||||
|
||||
- `DriverCapabilities.setpoint_limit_w` (3.2), the inverter's own magnitude
|
||||
rating. In this add-on that arrives as the setpoint entity's `min`/`max`
|
||||
from `hass.limits()` - `Controller.dev_min` / `dev_max`.
|
||||
- `optimizer.loop.clamp_w` (14), the configured loop clamp. In this add-on
|
||||
that is the `max_w` option, which `Tuning.max_w` and the arbiter's
|
||||
`supervised` limit both already use.
|
||||
|
||||
14 says these two must AGREE, validated at startup with a refusal on
|
||||
mismatch - and that gate already ships: `validate_startup_config()` refuses
|
||||
`max_w > dev_max` and `-max_w < dev_min` (validate.py, problem 1), and
|
||||
run_control() re-runs it once ESPHome is up and the real rating is known.
|
||||
|
||||
So this guard clamps to `max_w`, THE VALIDATED VALUE, and does not `min()` it
|
||||
with the device rating at runtime. That is the card's instruction and it is also
|
||||
the only reading that keeps `max_w` meaningful: a silent runtime `min()` is
|
||||
precisely the "helper bounded to +-500 W under a loop clamped to 1500 W" failure
|
||||
the 14 warning box is about.
|
||||
|
||||
WARNING - THE DEVICE BOUND IS STILL APPLIED, SECOND, AND IT IS NOT REDUNDANT.
|
||||
After a successful validation `+-max_w` lies inside `[dev_min, dev_max]`, so the
|
||||
second stage is arithmetically a no-op and can never engage. If it DOES engage,
|
||||
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.
|
||||
|
||||
⚠️ 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
|
||||
|
||||
`control.compute()` bounds the integrator to `integrator_max_w or max_w` and
|
||||
THEN derives the command from it, so on a default install (`integrator_max_w`
|
||||
unset, i.e. following `max_w`) the command can never exceed `max_w` and the
|
||||
actuator-side clamp below IS UNREACHABLE. control.py says so in as many words:
|
||||
|
||||
SAFETY-03 ... must watch for THIS, not for "clamped" below.
|
||||
|
||||
An alarm wired only to this module's `ClampResult.engaged` would therefore be
|
||||
silent on the exact configuration the whole ticket is about. The winding is
|
||||
visible one layer up, at the integrator bound, and that is why `Decision` now
|
||||
carries `i_clamped` / `clamped` BOOLEANS and the caller ORs them in here.
|
||||
|
||||
AND WHY BOOLEANS RATHER THAN `Decision.reason`. `reason` is a single string that
|
||||
later stages overwrite: a cycle that clamps the integrator and is then slew
|
||||
limited reports "slew-limited", and one that clamps while saturated reports
|
||||
"saturated-freeze". Both are exactly the winding-into-a-rail case, and on the
|
||||
string the clamp signal is destroyed before anything can alarm on it. The two
|
||||
flags are set where the clamp happens and are never overwritten. A check in
|
||||
test_clamp.py drives that precise combination.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
DISTINCT FROM SATURATION - THIS IS ACCEPTANCE CRITERION 2, NOT A NICETY
|
||||
|
||||
clamp the COMPUTED value exceeded the rating. A software bug signal:
|
||||
something upstream is winding. Alarm, every engagement.
|
||||
saturation |command - inverter AC power| > `saturation_w` for
|
||||
`saturation_cycles`. The HARDWARE cannot follow a perfectly legal
|
||||
command. Action is "freeze the integrator" (11.2), not an alarm.
|
||||
|
||||
A loop can be legally saturated all day - a cold pack, a curtailed inverter, an
|
||||
export limit - so a saturation-shaped alarm is one an operator learns to ignore.
|
||||
Routing the clamp through it puts the bug signal inside the noise, which is the
|
||||
same silence 10.3 is complaining about. They share no reason code, no
|
||||
notification id, no status row and no counter.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
ANTI-FLOOD, WITHOUT GOING SILENT
|
||||
|
||||
A genuinely winding loop clamps on EVERY cycle for as long as it winds. One HA
|
||||
notification per cycle is how an operator learns to dismiss notifications
|
||||
unread, and this add-on already made that argument once (see the fixed
|
||||
`notification_id` note in main.check_meter_frozen).
|
||||
|
||||
So the two surfaces are split, deliberately:
|
||||
|
||||
- EVERY engagement is recorded, never deduplicated, and that record is what
|
||||
says how hard the loop is pushing. 15's action log is this add-on's
|
||||
`Controller.events`, and it gets a row from `write_setpoint()` on every
|
||||
engaged WRITE; `engagements` here counts every engaged CYCLE. The two layers
|
||||
are counted separately on purpose - the heartbeat re-writes an
|
||||
already-clamped value between cycles, so a per-write count would inflate and
|
||||
a per-cycle log would miss an out-of-cycle write.
|
||||
- THE NOTIFICATION fires on the rising edge and then RE-ASSERTS every
|
||||
`REASSERT_S`, so a loop pinned on the rail for an hour does not go quiet.
|
||||
- It DE-ASSERTS only after `CLEAR_CYCLES` consecutive clean cycles, which is
|
||||
the 10.2 hysteresis: a command sitting exactly on the rail dithers in and
|
||||
out of engagement by one quantisation step, and without this the alarm
|
||||
chatters.
|
||||
|
||||
`REASSERT_S` and `CLEAR_CYCLES` are module constants, not options, for the same
|
||||
reason `freeze.RAIL_FRAC` is: they describe the event bus, not the plant, and
|
||||
every knob 10.3 asked to keep exposed is a LOOP TUNING knob and is untouched by
|
||||
this module. No new config key is added anywhere - CFG-03 already has three
|
||||
places a default has to be kept in step and this ticket does not earn a fourth.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
NOT A TRIP
|
||||
|
||||
Clamping does not stop the loop and does not command 0 W. The clamped value is
|
||||
written and control continues, which is what 11.2 asks for ("Clamp and alarm")
|
||||
and what distinguishes this from SAFETY-01/02, whose action is 0 W. It follows
|
||||
that this must NOT drive the failsafe status - `publish()` keeps reporting
|
||||
"running", because the controller is.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
# The reason code, distinct from any saturation-freeze code by construction:
|
||||
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
|
||||
REASON_CODE = "OUTPUT_CLAMP"
|
||||
|
||||
# Re-assert the notification this often while the clamp stays engaged. 30 s is
|
||||
# the planning cadence (2.3), i.e. slow enough not to flood and fast enough
|
||||
# that a pinned loop is never quiet for a whole billing quarter.
|
||||
REASSERT_S = 30.0
|
||||
|
||||
# Consecutive clean cycles before the alarm de-asserts (10.2 hysteresis).
|
||||
CLEAR_CYCLES = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
"""What the guard did, with the RAW value preserved.
|
||||
|
||||
AC 2 turns on "the raw computed value differed from the clamped one", so the
|
||||
raw value has to survive the call - a guard that returns only the applied
|
||||
number cannot be asked the question the acceptance criterion asks.
|
||||
"""
|
||||
|
||||
requested_w: float
|
||||
applied_w: float
|
||||
limit_w: float
|
||||
engaged: bool
|
||||
bound: str = "" # "" | "max_w" | "device" - which stage bit
|
||||
|
||||
def describe(self) -> str:
|
||||
return (f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W "
|
||||
f"was clamped to {self.applied_w:+.0f} W by the {self.bound} "
|
||||
f"bound (rating {self.limit_w:.0f} W) - something upstream is "
|
||||
f"winding")
|
||||
|
||||
|
||||
def is_commandable(value) -> bool:
|
||||
"""False for None, NaN and the infinities.
|
||||
|
||||
A non-finite setpoint is LOSS OF INPUT, not an out-of-range value, and
|
||||
11.1 rule 1 says loss of input means command zero and keep commanding. It
|
||||
must not reach the clamp arithmetic: `min(limit, nan)` returns nan on
|
||||
CPython, so a nan would sail straight through a naive clamp and then report
|
||||
`engaged` True on the `applied != requested` test - because nan != nan - i.e.
|
||||
a transport bug filed in the winding-loop counter, with a nan on the wire.
|
||||
"""
|
||||
if value is None:
|
||||
return False
|
||||
try:
|
||||
return math.isfinite(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def clamp_to_rating(requested_w, limit_w, dev_min=None, dev_max=None) -> ClampResult:
|
||||
"""Bound a computed setpoint to the validated rating. The ONLY clamp.
|
||||
|
||||
`limit_w` the validated magnitude bound - `max_w`. Symmetric, because
|
||||
3.2 calls `setpoint_limit_w` a MAGNITUDE clamp: it binds import
|
||||
(+) and export (-) identically.
|
||||
`dev_min` / `dev_max` the device's own asymmetric range, applied second and
|
||||
expected never to bite. See the module note.
|
||||
|
||||
Public, and the predicate lives here rather than in the test, for the reason
|
||||
`freeze.looks_frozen()` gives: a safety expression transcribed into its own
|
||||
check is one copy plus a future disagreement about what the check means.
|
||||
|
||||
EXACTLY +-`limit_w` IS NOT AN ENGAGEMENT. The bound is inclusive; a loop
|
||||
resting on the rail is doing what it was told, and alarming there would fire
|
||||
on every healthy full-power hour.
|
||||
"""
|
||||
requested = float(requested_w)
|
||||
limit = abs(float(limit_w))
|
||||
|
||||
# ⚠️ BOTH STAGES ALWAYS RUN. THIS USED TO `return` FROM THE FIRST ONE, AND
|
||||
# THAT WAS A REGRESSION AGAINST THE CODE IT REPLACED. `write_setpoint()`
|
||||
# previously clamped to the device range unconditionally, under the comment
|
||||
# "never send a value the device will reject outright"; short-circuiting here
|
||||
# meant the device bound was applied only when the rating bound had NOT
|
||||
# fired, i.e. THE BIGGER THE OVERSHOOT THE LESS THE GUARD DID. Measured on
|
||||
# the shipped function at max_w 5000 against a device of +-4000:
|
||||
#
|
||||
# +9999 -> +5000 bound='max_w' <- outside the device range
|
||||
# +4500 -> +4000 bound='device'
|
||||
#
|
||||
# It also undercut this module's own argument for clamping to `max_w` rather
|
||||
# than a runtime `min()`, which rests entirely on this stage catching the
|
||||
# case where the startup validation did not run.
|
||||
applied = max(-limit, min(limit, requested))
|
||||
bound = "max_w" if applied != requested else ""
|
||||
|
||||
# Second stage. Arithmetically dead after a successful startup validation -
|
||||
# and therefore worth alarming on precisely BECAUSE it fired.
|
||||
if dev_min is not None and dev_max is not None:
|
||||
bounded = max(float(dev_min), min(float(dev_max), applied))
|
||||
if bounded != applied:
|
||||
bound = "device" if not bound else "max_w+device"
|
||||
applied = bounded
|
||||
|
||||
return ClampResult(requested, applied, limit, applied != requested, bound)
|
||||
|
||||
|
||||
class ClampWatchdog:
|
||||
"""One instance per controller. Counts every engagement, alarms sparsely.
|
||||
|
||||
`note()` is called once per write with whether the clamp bit; the caller ORs
|
||||
in the loop's own `i_clamped` / `clamped` flags, which is where the winding
|
||||
is actually visible on a default install (see the module note).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.active = False
|
||||
self.engagements = 0 # every engagement, never deduplicated
|
||||
self.episodes = 0 # rising edges
|
||||
self.since: datetime | None = None
|
||||
self.last_alarm: datetime | None = None
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
|
||||
"""Advance one cycle. True when the caller should raise the alarm.
|
||||
|
||||
`now` is the caller's clock, the same discipline as
|
||||
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`.
|
||||
"""
|
||||
if engaged:
|
||||
self.engagements += 1
|
||||
self.clean_cycles = 0
|
||||
self.reason = reason
|
||||
if not self.active:
|
||||
self.active = True
|
||||
self.since = now
|
||||
self.episodes += 1
|
||||
self.last_alarm = now
|
||||
return True
|
||||
# Still engaged: re-assert on the slow cadence, not every cycle.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
return True
|
||||
return False
|
||||
|
||||
if not self.active:
|
||||
return False
|
||||
self.clean_cycles += 1
|
||||
if self.clean_cycles >= CLEAR_CYCLES:
|
||||
self.active = False
|
||||
self.since = None
|
||||
self.last_alarm = None
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
return False
|
||||
@@ -64,6 +64,23 @@ class Decision:
|
||||
# and quantisation. Carry it back in as `i_w` next cycle; that is what keeps
|
||||
# it a separate quantity from the command.
|
||||
i_w: float = 0.0
|
||||
# SAFETY-03. Did a clamp actually engage this cycle - i.e. did the RAW
|
||||
# computed value differ from the bounded one?
|
||||
#
|
||||
# ⚠️ THESE EXIST BECAUSE `reason` CANNOT ANSWER THAT QUESTION. `reason` is
|
||||
# one string and every later stage overwrites it: a cycle that clamps the
|
||||
# integrator and is then slew limited reports "slew-limited", and one that
|
||||
# clamps while saturated reports "saturated-freeze". Both ARE the winding
|
||||
# case, and on the string the clamp signal is gone before anything can alarm
|
||||
# on it. These two are written where the clamp happens and are never
|
||||
# overwritten, so they mean exactly what they say and nothing else.
|
||||
#
|
||||
# `i_clamped` is the one that fires on a default install: the integrator
|
||||
# bound is reached first, so `clamped` is unreachable unless
|
||||
# `integrator_max_w` is deliberately set above `max_w`. Alarm on BOTH - see
|
||||
# the comment at the bound below, and clamp.py.
|
||||
i_clamped: bool = False
|
||||
clamped: bool = False
|
||||
|
||||
|
||||
def compute(
|
||||
@@ -95,19 +112,23 @@ def compute(
|
||||
# lets slew be larger than saturation_w.
|
||||
#
|
||||
# The spec states this window twice and differently: "> 10 s" (§11.2) and
|
||||
# "3 samples" (§10.3). This counts CYCLES, and a cycle is not a unit of
|
||||
# time: run_control() calls cycle() only when the meter value CHANGES
|
||||
# (`if self.grid != last_grid`), so three cycles is three distinct meter
|
||||
# readings and nothing more. At the reference P1's ~5 s update rate that is
|
||||
# usually ~15 s, but there is no upper bound on it - a meter that repeats a
|
||||
# value stalls the counter.
|
||||
# "3 samples" (§10.3). This counts CYCLES, and a cycle is still not a unit
|
||||
# of time - but it is now a BOUNDED one. run_control() runs a cycle on a
|
||||
# changed meter value OR on `cycle_floor_s` elapsing, whichever comes first
|
||||
# (SAFETY-12), so three cycles is at most 3 x cycle_floor_s = 45 s at the
|
||||
# defaults, and usually ~15 s at the reference P1's ~5 s update rate.
|
||||
#
|
||||
# That is a detection-latency limit, not a windup hazard: the same
|
||||
# condition that stalls the counter stalls the whole loop, so nothing
|
||||
# accumulates in the meantime either. If a wall-clock window is ever
|
||||
# required, it belongs in Controller (which has a clock) and not here.
|
||||
# ponytail: this function is worth keeping clockless; the ceiling is that
|
||||
# saturation_cycles cannot express a guaranteed number of seconds.
|
||||
# ⚠️ THIS COMMENT USED TO CLAIM there was no upper bound and that it did not
|
||||
# matter, "because the same condition that stalls the counter stalls the
|
||||
# whole loop, so nothing accumulates in the meantime". The first clause was
|
||||
# true and the second was the bug: a stalled loop does not stop commanding,
|
||||
# it goes on emitting its last command through the heartbeat. Windup was
|
||||
# never the hazard; staleness was.
|
||||
#
|
||||
# The wall-clock window lives in Controller, which has a clock, exactly as
|
||||
# this comment always said it should. ponytail: this function stays
|
||||
# clockless; the remaining ceiling is that saturation_cycles expresses a
|
||||
# bound in seconds rather than an exact number of them.
|
||||
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
|
||||
sat_count = min(sat_count + 1, 10) if saturated_now else 0
|
||||
frozen = sat_count >= tuning.saturation_cycles
|
||||
@@ -184,7 +205,8 @@ def compute(
|
||||
# bound is not. It is what makes the worst-case unwind time finite and
|
||||
# knowable instead of a function of how long the error happened to stand.
|
||||
bounded = max(-limit, min(limit, i_w))
|
||||
if bounded != i_w:
|
||||
i_clamped = bounded != i_w
|
||||
if i_clamped:
|
||||
# ⚠️ SAFETY-03 (alarm whenever the loop winds into a rail) must watch
|
||||
# for THIS, not for "clamped" below. At the default limit == max_w the
|
||||
# integrator bound is reached first and the command derived from it can
|
||||
@@ -205,7 +227,8 @@ def compute(
|
||||
# --- ORDER MATTERS: clamp -> slew -> freeze ----------------------------
|
||||
# An early draft applied a floor after the clamp and let demand escape it.
|
||||
target = max(-tuning.max_w, min(tuning.max_w, want))
|
||||
if target != want:
|
||||
clamped = target != want
|
||||
if clamped:
|
||||
reason = "clamped"
|
||||
|
||||
slewed = max(prev_w - tuning.slew_w, min(prev_w + tuning.slew_w, target))
|
||||
@@ -231,7 +254,8 @@ def compute(
|
||||
step = max(1, int(tuning.step_w))
|
||||
target = round(target / step) * step
|
||||
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w))
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||
i_clamped, clamped)
|
||||
|
||||
|
||||
def maintenance_charge_floor(
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
"""SAFETY-18 - the frozen-but-fresh meter detector.
|
||||
|
||||
WHAT IT CATCHES, in one sentence: a meter that keeps answering 200 OK with a
|
||||
document that never changes, which every freshness mechanism in this stack reads
|
||||
as perfectly healthy while the loop winds to the rail and stays there.
|
||||
|
||||
THE FAILURE NEEDS NO FAULT ANYWHERE ELSE. A frozen-but-talking meter is
|
||||
*arriving* on every poll, so:
|
||||
|
||||
- `meter_max_age_s` is structurally blind. Every response resets the arrival
|
||||
stamp (p1.py, `HomeWizardLocalSource`), so the age never climbs. Age catches
|
||||
a meter that stops TALKING; this one stops CHANGING.
|
||||
- firmware layer 1 is fed. Its watchdog keys on setpoint staleness, which the
|
||||
heartbeat satisfies, and on `sensor.goodwe_p1_sample_age`, which a talking
|
||||
meter satisfies.
|
||||
- the arbiter contributes only the device rating and +-`max_w` - it bounds the
|
||||
magnitude, which is the number this fault drives us to, not away from.
|
||||
- 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:
|
||||
|
||||
meter frozen at to rail, max_w 2000 to rail, max_w 5000
|
||||
350 W 150 s 360 s
|
||||
1000 W 60 s 135 s
|
||||
>= 3000 W 30 s 75 s
|
||||
|
||||
⚠️ THE LIVE INSTALL RUNS `max_w: 5000`, which is both the schema maximum and the
|
||||
device rating, so the right-hand column is the one that describes this house.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THIS HAS A EURO FIGURE, AND IT IS THE REASON THE TICKET IS P1
|
||||
|
||||
`maintenance_charge_floor()` caps MAINTENANCE charging against `peak_cap_w`. The
|
||||
grid-following loop is not capped by anything but `max_w`. A meter frozen
|
||||
reading a large export drives a 5 kW unattended grid IMPORT, and >= 15 minutes
|
||||
of that sets the month's billed capaciteitstarief peak: specs.md:583 records a
|
||||
measured case where a single 4998 W quarter moved a monthly peak from 4832 W to
|
||||
5013 W and billed the whole month at it.
|
||||
|
||||
⚠️ THIS CUT DOES NOT CAP THAT. It notifies. See DOCS.md, "Capacity-tariff
|
||||
exposure of the grid-following loop", and SAFETY-07.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHY IT IS A CONJUNCTION AND NOT AN `unchanged_s` THRESHOLD
|
||||
|
||||
`unchanged_s` (p1.py) already measures exactly the right thing: seconds since
|
||||
the meter served a DIFFERENT measurement, fingerprinted over the power figure
|
||||
and both energy registers. It is computed, it is on the status page, and TEL-05
|
||||
deliberately refused to threshold it - correctly:
|
||||
|
||||
this controller regulates grid power toward ~0 W, and at a converged -10 W
|
||||
the export register takes about six minutes to advance by its 1 Wh
|
||||
resolution while the power figure legitimately repeats.
|
||||
|
||||
So a bare `unchanged_s > N` false-trips at the exact operating point we aim for,
|
||||
and `p1.py` says as much in a note that ends "whoever wires a freeze detector
|
||||
must handle the low-power case first". This is that handling, and it is not a
|
||||
smaller number:
|
||||
|
||||
unchanged_s >= hold_s AND |commanded_w| is at the rail
|
||||
|
||||
The second term is what TEL-05's case cannot satisfy. At the converged point the
|
||||
command is nowhere near +-`max_w`; on the 40 minutes of live converged behaviour
|
||||
in `sim/scenarios/p1-age-2026-08-26.jsonl.gz` the meter never once leaves
|
||||
+-500 W, so the loop never even approaches a 5000 W rail. And the reverse case -
|
||||
a command genuinely pinned at the rail by a real 8 kW load - cannot satisfy the
|
||||
FIRST term, because a real railed condition means kilowatts still crossing the
|
||||
meter, and a kilowatt advances a 1 Wh register every 3.6 s.
|
||||
|
||||
The two terms fail for independent reasons. That is the whole design.
|
||||
|
||||
⚠️ AND THE SECOND HALF OF THAT WAS MEASURED ON THE RIG, WITH A RESULT WORTH
|
||||
READING CAREFULLY. Driving the real transport against the rig's own hwsim under
|
||||
+8000 W of house load - a genuinely railed command, meter perfectly healthy - the
|
||||
POWER FIGURE DID NOT MOVE AT ALL: `active_power_w` sat at exactly 10130.0 W for
|
||||
26 consecutive polls. What kept `unchanged_s` pinned at 0.0 s was the kWh
|
||||
registers advancing, and nothing else.
|
||||
|
||||
So the register terms in `measurement_fingerprint()` are not belt-and-braces
|
||||
here, they are the entire negative case. A freeze detector fingerprinted on the
|
||||
power figure alone would have raised a false alarm on that rig run - a healthy
|
||||
meter, a real load, a correctly railed command - which is the exact failure mode
|
||||
that gets a safety guard switched off. Anyone tempted to simplify the fingerprint
|
||||
should read that run first.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHERE `hold_s` COMES FROM
|
||||
|
||||
Measured on `sim/scenarios/p1-age-2026-08-26.jsonl.gz` - 2399 s (40 min) of the
|
||||
live house at the converged operating point, mean grid -14.2 W, 1194 polls:
|
||||
|
||||
polls that repeated the previous reading 735 of 1193 (61.6 %)
|
||||
distinct-reading transitions 458
|
||||
median gap between distinct readings 6.0 s
|
||||
p95 gap 6.0 s
|
||||
LARGEST `unchanged_s` any sample observes 8.0 s
|
||||
longest interval serving no distinct reading 15.4 s
|
||||
samples outside +-500 W 0
|
||||
|
||||
The default 300 s is 37x the largest `unchanged_s` this house has been recorded
|
||||
producing while converged, and 19.5x the longest interval in which it served no
|
||||
distinct reading at all. The two figures differ because the capture has an 11.4 s
|
||||
hole in its OWN polling; the second is quoted because it is the pessimistic one.
|
||||
300 s is not fitted to either margin - it is picked as "clearly past any
|
||||
plausible register-tick interval, and clearly inside the 900 s quarter that bills
|
||||
the capacity tariff". test_freeze.py re-measures both every run, so this
|
||||
paragraph cannot quietly stop being true.
|
||||
|
||||
⚠️ BE PRECISE ABOUT WHAT THAT CAPTURE PROVES. It is POWER-ONLY - the shipped
|
||||
fingerprint also covers both kWh registers, so a real `unchanged_s` on that data
|
||||
can only be shorter, never longer, which makes it a conservative measurement of
|
||||
the converged case. But it does NOT on its own refute a bare `unchanged_s`
|
||||
threshold, and saying it did would be exactly the overstatement this project
|
||||
keeps having to walk back: at 8 s worst, a bare 300 s threshold is silent on it
|
||||
too. The case that refutes the bare threshold is the one below, which no capture
|
||||
of THIS meter contains because this meter's power figure jitters.
|
||||
|
||||
The floor on the other side: at the rail, the smallest residual grid flow that
|
||||
still ticks a 1 Wh register inside 300 s is 12 W. Any condition that genuinely
|
||||
pins this loop at +-5000 W has three orders of magnitude more than that crossing
|
||||
the meter.
|
||||
|
||||
⚠️ AND THOSE TWO NUMBERS DO NOT MERELY HAVE MARGIN - THEY CANNOT BOTH BE TRUE.
|
||||
The worry the conjunction has to answer is "a HEALTHY meter whose register is
|
||||
slower than `hold_s`". At 300 s that is |grid| < 12 W. The shipped `deadband_w`
|
||||
is 15 W, so anywhere the register is that slow the control law is already inside
|
||||
its deadband and the command is parked - the rail term is not merely unlikely to
|
||||
be true there, it is 0 W. The two terms are mutually exclusive by construction
|
||||
at the defaults, not just far apart, and the check re-derives that every run.
|
||||
|
||||
That relationship is the one to protect. `hold_s` may be lowered only while
|
||||
3600/hold_s stays under `deadband_w`; at the schema floor of 60 s that is 60 W
|
||||
against a 15 W deadband, and the mutual exclusion is gone. A 60 s `meter_freeze_s`
|
||||
is a supported configuration that gives up this argument and keeps only the
|
||||
margin - do not set one without a capture that says the margin is enough.
|
||||
|
||||
⚠️ WHAT `hold_s` DOES NOT BUY IS DETECTION SPEED. The alert lands at
|
||||
max(hold_s, time-to-rail) after the freeze, so at the shipped default and
|
||||
`max_w: 5000` that is 300-360 s. The rail is REACHED first, from 75 s. A
|
||||
notification is not a brake; see the DOCS.md note.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE RAIL TERM, PRECISELY
|
||||
|
||||
abs(commanded_w) >= RAIL_FRAC * max_w
|
||||
|
||||
`commanded_w` is the EMITTED setpoint - what the arbiter resolved and
|
||||
`write_setpoint()` put on the wire - not the loop's raw wish, for the same
|
||||
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), 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
|
||||
excludes it, and it excludes it on physics rather than on bookkeeping. Gating on
|
||||
`following` as well would additionally blind the detector to a maintenance phase
|
||||
running against a meter that really has frozen, which is the same fault with a
|
||||
worse actuator behind it.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
NOTIFY, DO NOT TRIP
|
||||
|
||||
First cut is an alarm and nothing else: an HA persistent notification, an ERROR
|
||||
in the log, an event on the status page, and a failed check row. It does NOT
|
||||
claim the actuator, does NOT file a P_SAFETY claim and does NOT latch.
|
||||
|
||||
That is a deliberate ordering of costs, not timidity. A false trip here parks
|
||||
the battery - on a house that is running this loop precisely to avoid buying
|
||||
power at the wrong moment - and the operator's response to a parked battery is
|
||||
to switch the guard off. A false notification costs one alert. The trip is a
|
||||
separate decision that has to be earned with observed data from a real install,
|
||||
and it needs an answer to a question this cut does not have one for: what should
|
||||
the loop do instead, given that 0 W is itself a grid import of whatever the
|
||||
house is drawing.
|
||||
|
||||
⚠️ TRANSPORT-CONDITIONAL. `unchanged_s` exists ONLY on `homewizard_local`,
|
||||
because it is the only transport that sees the meter's own document. Under
|
||||
`ha_dsmr`, `mqtt_p1`, `ha_signed` and `meter_source: off` this detector is
|
||||
silent and the exposure above is uncovered. The caller passes None and gets
|
||||
False; there is no half-measure available on those paths, and pretending
|
||||
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
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FreezeWatchdogConfig:
|
||||
"""Default, not a constant - config.yaml carries `meter_freeze_s`."""
|
||||
|
||||
hold_s: float = 300.0
|
||||
|
||||
|
||||
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, *, 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
|
||||
it must assert on THE SHIPPED EXPRESSION. A safety predicate transcribed
|
||||
into its own test is one copy plus a future disagreement about what the
|
||||
check means, and the copy that drifts is always the one doing the grading.
|
||||
|
||||
`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.
|
||||
`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, dev_min=dev_min, dev_max=dev_max))
|
||||
|
||||
|
||||
class FreezeWatchdog:
|
||||
"""One instance per controller, evaluated once per control cycle.
|
||||
|
||||
Level-triggered state with an EDGE for the alarm: `evaluate()` returns True
|
||||
on the cycle the conjunction first becomes true and False on every cycle
|
||||
after, so the caller raises one notification per episode rather than one
|
||||
every second. `active` is the level, for the status page.
|
||||
|
||||
No latch, deliberately - see "NOTIFY, DO NOT TRIP". A latch exists so a trip
|
||||
cannot erase its own evidence by working; nothing here acts, so there is
|
||||
nothing to erase. When the meter starts moving again the alert clears
|
||||
itself, which is the honest report of the state.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: FreezeWatchdogConfig | None = None):
|
||||
self.cfg = cfg or FreezeWatchdogConfig()
|
||||
self.active = False
|
||||
self.since: datetime | None = None
|
||||
self.reason = ""
|
||||
self.episodes = 0
|
||||
|
||||
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()`. `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,
|
||||
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 {ceiling:.0f} W rail{capped} "
|
||||
f"(limits: unchanged over {self.cfg.hold_s:.0f} s, command over "
|
||||
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W)")
|
||||
if self.active:
|
||||
return False
|
||||
self.active = True
|
||||
self.since = now
|
||||
self.episodes += 1
|
||||
return 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
|
||||
+2901
-111
File diff suppressed because it is too large
Load Diff
@@ -28,26 +28,122 @@ _LOG = logging.getLogger("goodwe.maint")
|
||||
|
||||
IDLE, DRAIN, CHARGE, HOLD = "idle", "drain", "charge", "hold"
|
||||
|
||||
# A drain of a 13.5 kWh usable pack at 2500 W is ~5 h even with no house load
|
||||
# helping. 12 h means the battery is not following commands - a fault, not a
|
||||
# slow day. The charge phase deliberately waits for sun and for cheap hours, so
|
||||
# A drain of a 13.8 kWh usable pack (300 Ah x 51.2 V, DoD 90) at the shipped
|
||||
# 2000 W is ~6.9 h even with no house load helping - and less in practice, since
|
||||
# house load drains alongside the export and soc_floor stops short of empty.
|
||||
# 12 h means the battery is not following commands - a fault, not a slow day. The charge phase deliberately waits for sun and for cheap hours, so
|
||||
# it is expected to span a night; 36 h still bounds it.
|
||||
DRAIN_TIMEOUT_MIN = 720
|
||||
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,8 +43,48 @@ 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
|
||||
# object_id spells. An earlier version of this comment said the id "must be
|
||||
# exactly sensor.p1_sample_age_s" - that name has never existed on any HA,
|
||||
# and believing it is how a safety layer gets disarmed by someone tidying up.
|
||||
#
|
||||
# On HA 2026.8 `object_id` in a discovery payload does NOT determine the
|
||||
# entity_id. HA composes it from the device name plus the entity name:
|
||||
# "GoodWe RS485 Controller" + "P1 sample age" -> sensor.goodwe_p1_sample_age.
|
||||
# Verified against the live system 2026-08-26; the firmware subscribes to
|
||||
# that composed name (goodwe-master-stage4-failsafe.yaml, homeassistant
|
||||
# sensor p1_age_s), and its own comment records that sensor.p1_sample_age_s
|
||||
# never existed.
|
||||
#
|
||||
# So: do NOT "fix" the firmware to match the object_id below, and do not
|
||||
# rename the device or this entity's friendly name - either changes the
|
||||
# composed id and silently disarms the ingest guard, because has_state()
|
||||
# simply stays false forever and nothing reports it. The firmware calls this
|
||||
# out as the failure that "FAILS SILENTLY AND LOOKS CORRECT".
|
||||
#
|
||||
# The value: seconds since the newest accepted P1 telegram, republished
|
||||
# every second so a meter frozen at a constant value still shows a climbing
|
||||
# age - the false-trip this entity exists to remove.
|
||||
#
|
||||
# ⚠️ Separately, and not fixed here: the firmware's ingest guard tests this
|
||||
# sensor's VALUE rather than its arrival, so a dead publisher freezes the
|
||||
# number and reads permanently fresh. Filed as TEL-06.
|
||||
("p1_age", "p1_sample_age_s", "P1 sample age", "s", "duration", "measurement", None),
|
||||
]
|
||||
|
||||
BASE = "goodwe_ctl"
|
||||
@@ -52,7 +92,12 @@ AVAILABILITY = f"{BASE}/availability"
|
||||
|
||||
|
||||
class MqttPublisher:
|
||||
def __init__(self, host, port, username=None, password=None):
|
||||
def __init__(self, host, port, username=None, password=None, omit=()):
|
||||
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
|
||||
# sensor that nothing will ever publish to is not harmless here:
|
||||
# p1_sample_age_s is a watchdog input, and an entity that exists but is
|
||||
# never fed is a worse signal than one that does not exist at all.
|
||||
self.omit = set(omit)
|
||||
self.enabled = mqtt is not None and bool(host)
|
||||
self.client = None
|
||||
if not self.enabled:
|
||||
@@ -86,6 +131,8 @@ class MqttPublisher:
|
||||
|
||||
def _announce(self) -> None:
|
||||
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
|
||||
if key in self.omit:
|
||||
continue
|
||||
cfg = {
|
||||
"name": name,
|
||||
"object_id": object_id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,757 @@
|
||||
"""SAFETY-07 - the quarter-peak projection guard, and the only thing in this
|
||||
add-on that is allowed to spend the loop's freedom to save money.
|
||||
|
||||
WHAT IT IS. TARIFF-01 built the INSTRUMENT: `tariff.py` measures the number the
|
||||
Belgian capaciteitstarief bills on - the highest 15-minute average offtake of the
|
||||
calendar month - and banks it where a restart cannot reach. It files no claim and
|
||||
commands nothing, and says so in its own first paragraph. This module is the
|
||||
CONSUMER it named. It turns that measurement into one arbiter claim and nothing
|
||||
else: no writes, no I/O, no clock, no state between calls. Given a projection and
|
||||
two measured numbers it returns a decision; `main.py` files it.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE HAZARD, WITH THE EURO FIGURE
|
||||
|
||||
specs.md:583 records a measured case: ONE 4998 W quarter moved a monthly peak
|
||||
from 4832 W to 5013 W and billed the whole month at it. The capacity tariff has
|
||||
no averaging and no forgiveness - the month's single worst quarter is the bill.
|
||||
|
||||
SAFETY-18 shipped the ALARM for the way this system produces such a quarter by
|
||||
itself: a meter that answers 200 OK forever with a document that never changes,
|
||||
which every freshness mechanism in this stack reads as healthy while the loop
|
||||
integrates a constant error to the rail and holds it there. freeze.py's own
|
||||
table: at the live install's `max_w: 5000`, a meter frozen at >= 3000 W rails the
|
||||
command in 75 s. SAFETY-18 notifies at `meter_freeze_s` (300 s default), against
|
||||
a 900 s billing quarter. That is about nine minutes of human reaction time and
|
||||
nothing else - freeze.py says so in the words "THIS CUT DOES NOT CAP THAT."
|
||||
|
||||
This is the cap. It is deliberately NOT a second freeze detector: it consumes
|
||||
freeze.py's shipped predicate rather than transcribing it.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE MECHANISM: ONE `lo` LIMIT AT `P_PEAK`, AND WHY THAT SHAPE
|
||||
|
||||
specs.md 10.1 row 2 asks for exactly two behaviours - "pre-emptive discharge as
|
||||
the projected quarter average approaches the soft limit; hard clamp on charging
|
||||
above it" - and 9.1 constrains the lever: "Discharge and export are never capped
|
||||
by the peak guard; only charging and import are."
|
||||
|
||||
A LOWER BOUND on the setpoint is both of those in one object, and it is the only
|
||||
shape that is:
|
||||
|
||||
* incapable of capping discharge - a floor never stops the loop discharging
|
||||
HARDER, so 9.1's asymmetry is structural here rather than remembered;
|
||||
* incapable of capping export, for the same reason;
|
||||
* a hard clamp on charging whenever the floor is above the standing charge;
|
||||
* pre-emptive discharge whenever the floor is above zero.
|
||||
|
||||
It is a LIMIT, not a SET, and that matters at both ends of the priority table.
|
||||
`P_PEAK` (50) is above `P_LOOP` (10), so it binds the grid-following loop. It is
|
||||
BELOW `P_MAINTENANCE` (60) and `P_SAFETY` (100), so a maintenance phase that owns
|
||||
the actuator and every layer-1 stop still outrank it: arbiter.py's clause 2 only
|
||||
applies limits whose priority is >= the WINNING SET's, so a P_SAFETY 0 W stop is
|
||||
not dragged off zero by this floor. That is the correct order - a controller
|
||||
pushing power the wrong way is wrong however much money it is trying to save.
|
||||
|
||||
⚠️ AND A LIMIT DOES NOT OUT-VOTE ANOTHER LIMIT. This was assumed, and it is
|
||||
false: arbiter.py clause 2 applies EVERY limit at or above the winner's priority
|
||||
and takes the MOST RESTRICTIVE, with no notion of priority between them - which
|
||||
is exactly what makes the safety bounds unconditional and must not be changed.
|
||||
So this floor (`lo >= 0`) and the maintenance charge-only shaping (`hi <= 0`) do
|
||||
not resolve in P_PEAK's favour when both are filed. They resolve to `lo > hi`, a
|
||||
CONTRADICTION, which arbiter.py answers with 0 W - the battery parked in the one
|
||||
quarter of the month it should be shaving hardest. The rule for two limits that
|
||||
can cross is therefore SUPPRESSION, not priority: main.py drops the maintenance
|
||||
shaping for as long as this guard is engaged, which is the same thing that branch
|
||||
already did for `peak_forecast_entity` and is why 9.1's "money outranks the
|
||||
maintenance schedule" reads as a suppression there rather than as a priority
|
||||
here. The floor's clamp to `achievable_w` closes the only other pair that could
|
||||
cross - the P_SAFETY rating bounds.
|
||||
|
||||
AND IT IS WHY OPT-04 NEEDS NO CHANGE HERE. 9.1 requires peak shaving to outrank
|
||||
arbitrage. Arbitrage does not exist yet, but it will arrive as a SET below
|
||||
P_PEAK, and arbiter.py clause 2 will bind it with this floor on the day it lands,
|
||||
with no edit to this file. Expressing the priority as a claim rather than as an
|
||||
if/else in main.py is the whole reason that holds.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE FLOOR IS DERIVED, AND EVERY TERM OF IT IS MEASURED
|
||||
|
||||
Let `ceiling_w` be the quarter average that must not be exceeded and `remaining`
|
||||
the seconds left in the open block. `project()` gives
|
||||
|
||||
projected = (acc + held * remaining) / 900
|
||||
|
||||
so the held level that would land the quarter exactly on the ceiling is
|
||||
`held + 900 * (ceiling - projected) / remaining`, and the watts that must come
|
||||
off the meter are the difference between the two:
|
||||
|
||||
excess_w = 900 * (projected - ceiling) / remaining
|
||||
|
||||
That single expression carries the whole control law, and two properties of it
|
||||
are the argument for this design:
|
||||
|
||||
1. THE CLAIM IS FILED WHILE THE FLOOR IS POSITIVE, NOT WHILE THE EXCESS IS.
|
||||
`excess` is what must come off the METER, so it falls to zero the moment the
|
||||
battery is already taking it - and a guard that disengaged there would hand
|
||||
the actuator back, watch the offtake spring straight to the raw load, and
|
||||
engage again next cycle. That is not a hypothetical: it is what the first
|
||||
cut of this file did to 2026-08-20's billed quarter on replay - seventeen
|
||||
engage/release cycles in 900 s, and the quarter still finished 49 W over the
|
||||
limit it had spent a quarter of an hour holding. 10.2 names the cost
|
||||
exactly: "an EMS that oscillates is worse than no EMS, both for hardware
|
||||
wear and - per 9.1 - for the peak it is trying to avoid."
|
||||
|
||||
`batt_w + excess` IS the quantity to threshold: the offtake there would be
|
||||
with the battery at zero, less the level this quarter can still afford. Read
|
||||
as a LEVEL rather than as a switch, the same arithmetic gives a claim that
|
||||
is filed for as long as a positive floor is needed and decays continuously
|
||||
to nothing as the load falls. So there is still no hysteresis constant in
|
||||
this file - not because oscillation was ruled out by argument, but because
|
||||
the thresholded quantity is the one that does not oscillate.
|
||||
|
||||
2. THE GAIN IS 900/remaining, WHICH IS 1.0 AT THE START OF THE QUARTER AND
|
||||
GROWS. This is the answer to the degeneracy at the top of a block, where the
|
||||
projection is an extrapolation from almost no evidence: the guard is at its
|
||||
GENTLEST exactly when its evidence is weakest, and becomes aggressive only
|
||||
once most of the quarter is behind it and the projection is nearly a
|
||||
measurement. The formula supplies that; no minimum-evidence threshold was
|
||||
invented, because inventing one would delay the guard in the case it exists
|
||||
for - a sustained rail from second zero.
|
||||
|
||||
The floor itself is then
|
||||
|
||||
floor_w = batt_w + excess_w
|
||||
|
||||
`batt_w` IS THE MEASURED BATTERY POWER, NOT THE STANDING COMMAND. Both were
|
||||
available. The measured one is right for two independent reasons. First, 9.1's
|
||||
settled constraint is that the utility bills what FLOWED - the arithmetic
|
||||
`grid = load - batt` is true of delivered power and false of intent. Second, and
|
||||
concretely: when the inverter cannot follow (the saturation case control.py
|
||||
already freezes the integrator for), a floor built on the command asks for
|
||||
`command + excess` every cycle while the command never arrives, and winds itself
|
||||
to the rail; a floor built on the measurement asks for `delivered + excess`,
|
||||
which stops rising the moment delivery stops rising. The delivered form cannot
|
||||
wind up. The intent form can.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE CEILING IS `peak_cap_w`, RAISED ONLY BY AN OBSERVATION
|
||||
|
||||
11.2's row is "Quarter peak | Projected quarter average approaches THE MONTHLY
|
||||
PEAK | Clamp the command", and 9.1's soft limit is `peak_cap_w`. Both matter,
|
||||
because the money is spent on `max(month's peak so far, this quarter)`:
|
||||
|
||||
ceiling_w = max(peak_cap_w, the month's banked peak)
|
||||
|
||||
Once a month's peak already stands at 5013 W, holding every later quarter to
|
||||
3500 W buys nothing - the bill for that month is already 5013 W - while the
|
||||
holding itself costs real charging the site wanted. Raising the ceiling to a peak
|
||||
that has already been paid for is what stops one bad quarter from parking the
|
||||
battery's charging for three weeks.
|
||||
|
||||
AND THAT RELAXATION IS GRANTED ONLY BY AN `OBSERVED` READING. `NO_RECORD` and
|
||||
`NOT_OBSERVED` both leave the ceiling at `peak_cap_w`, the tighter of the two, so
|
||||
a peak store that cannot be read makes this guard STRICTER and never looser. That
|
||||
direction is the whole point: tariff.py's `PeakReading` exists because a database
|
||||
that did not answer must not become a number, and the number it would have become
|
||||
here - a zero peak - is exactly the one that reads as "no peak recorded, full
|
||||
freedom". It is also why this module does NOT consume `headroom_w()`, whose
|
||||
disarming failure it would otherwise inherit: a peak read as 0 W returns FULL
|
||||
headroom, and full headroom is a guard that is not there.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
BLINDNESS COMES FIRST, AND IT IS A DIFFERENT ACTION
|
||||
|
||||
A projection is evidence only if the quantity under it was actually measured.
|
||||
Three ways it is not, all of them checked BEFORE the projection is graded,
|
||||
because grading a stimulus where the quantity it grades is degenerate is how a
|
||||
guard passes its own tests while being switched off:
|
||||
|
||||
* NO OPEN BLOCK. `project()` answers a fresh averager with
|
||||
`Projection(0.0, 0.0, 0.0, 0.0, False, None, 0.0)` - a ZERO projection, which
|
||||
compares as comfortably under any ceiling. That is not "the quarter is fine",
|
||||
it is "there is no quarter". Graded as a number it silently disarms the guard
|
||||
on exactly the startup path where nothing else is watching.
|
||||
|
||||
* COVERAGE. A meter gap longer than one `max_hold_s` window leaves seconds that
|
||||
`QuarterAverager` deliberately does not accumulate (p1.py: holding a dead
|
||||
meter's last value forward is how a fabricated peak gets onto a permanent
|
||||
record). Those seconds make the projection read LOW, which is the
|
||||
under-guarding direction. The threshold is `max_hold_s` itself - the point
|
||||
p1.py already stops trusting a held sample - not a new number.
|
||||
|
||||
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
|
||||
rather than re-written here. A frozen meter is the worst case of all: the
|
||||
samples keep ARRIVING, so coverage stays perfect and the projection is
|
||||
confident - and if the stuck value is an export, the projection is a
|
||||
confident ZERO while the real meter runs at the rail. This is the case
|
||||
SAFETY-07 exists to backstop, and it is the case a projection cannot see.
|
||||
|
||||
THE BLIND ACTION IS `floor_w = 0.0`: the battery may not IMPORT while the billed
|
||||
quantity is unobservable. It is the minimal action that closes the hazard, and it
|
||||
is one-directional - forbidding charging can only ever lower delivered offtake,
|
||||
never raise it - so a false blindness costs a charge the site wanted and cannot
|
||||
cost energy the pack did not want to give. Contrast the graded branch, which can
|
||||
force discharge; blindness deliberately does not earn that, because there is by
|
||||
definition no measurement to size it from.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHY THIS ONE TRIPS WHERE SAFETY-18 ONLY NOTIFIES
|
||||
|
||||
SAFETY-18 shipped notification-first, and config.yaml says why in one line: "A
|
||||
false trip parks the battery; a false alarm costs an alert." That reasoning is
|
||||
right for a DETECTOR whose negative case had to be established on a rig run. It
|
||||
does not carry to this guard, and the two committed captures say so:
|
||||
|
||||
* MEASURED FALSE-ENGAGEMENT RATE ON THE HEALTHY DAY: zero. 2026-08-23's worst
|
||||
quarter is a few hundred watts against a 3500 W ceiling and nothing in its 95
|
||||
quarters comes near engaging. test_peakguard.py re-measures that every run
|
||||
from the committed capture rather than quoting it here.
|
||||
* THE ACTION IS LEVEL-TRIGGERED AND UNLATCHED. It is re-derived from a fresh
|
||||
projection every cycle and releases the instant the projection drops back
|
||||
under the ceiling. There is no state to clear and no operator to wake. A
|
||||
false engagement costs one cycle of discharge, not a parked battery - which
|
||||
is precisely the cost SAFETY-18 could not bound and this one can.
|
||||
* THE ACTION IS A LIMIT, NOT A SET. Every safety stop in 11.2's table outranks
|
||||
it, so the worst thing a bug in this file can do is fail to relax a floor -
|
||||
and a floor is bounded above by `achievable_w`, which main.py takes from the
|
||||
same `max_w` and device rating the arbiter already enforces.
|
||||
|
||||
So it trips. It is also the only guard here whose trigger IS the billed quantity
|
||||
itself, measured from parsed P1 telegrams, rather than a proxy for it.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
SATURATION IS REPORTED, NOT ALARMED AS A BUG
|
||||
|
||||
10.3's doctrine - "treat an out-of-range computed setpoint as a bug signal, clamp
|
||||
it AND alarm" - is SAFETY-03's, and it is right there: a loop computing 14 768 W
|
||||
is a winding integrator, i.e. a defect. A peak floor above the inverter's rating
|
||||
is NOT a defect. It is the ordinary physics of a 5900 W house load on a 5000 W
|
||||
inverter, which is literally what 2026-08-20 15:45 was. So the floor is clamped
|
||||
to `achievable_w` and the decision carries `saturated`, and main.py raises ONE
|
||||
notification on that - not because the controller is broken, but because it is
|
||||
the one case where the money WILL be spent and the controller has already done
|
||||
everything it can. An engagement that is not saturated is an ordinary control
|
||||
action and goes to the action log, like the clamp's engagements do.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
NO NEW OPTION. CFG-03 STILL TRACKS THREE PLACES.
|
||||
|
||||
`peak_cap_w` already ships, is already validated `int(500,15000)`, and is already
|
||||
read by `control.peak_at_risk()`. `max_hold_s` comes off the averager the caller
|
||||
already owns; `achievable_w` off `max_w` and the device rating the arbiter
|
||||
already enforces; the freeze predicate off `meter_freeze_s`. config.yaml and
|
||||
validate.py are untouched by this ticket, exactly as TARIFF-01 left them, and
|
||||
14's `capacity_peak_limit_kw` stays declined - a kW key beside a W key is the
|
||||
mismatch trap 14's own warning box describes.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
SAFETY-24: THE ENERGY BACKSTOP THIS GUARD SHIPPED WITHOUT
|
||||
|
||||
SAFETY-07 left this file saying, in its own words, that the graded branch can
|
||||
FORCE DISCHARGE and that nothing below it consults the state of charge - "the
|
||||
BMS is the sole energy backstop" (freeze.py). On the 2026-08-20 replay the guard
|
||||
asks for 878 Wh in a day, and it keeps asking for as long as the meter says the
|
||||
quarter is over the ceiling. That is the behaviour working as designed, and it
|
||||
is unbounded from below by anything in the EMS.
|
||||
|
||||
⚠️ STATE THE RISK FAIRLY, because overstating it is how a real one gets ignored.
|
||||
Relying on the BMS is not absurd: it is a genuine floor and it works. But it is
|
||||
THE PACK's protection, not the OPERATOR's. A month of aggressive quarters can
|
||||
leave the battery repeatedly parked at that floor with no EMS-side record of
|
||||
why, and the first symptom - a pack that is never available when it is wanted -
|
||||
is indistinguishable from the SoC drift MAINT-01 exists to correct.
|
||||
|
||||
So `decide()` now takes `soc` and `soc_floor`, and `discharge_permitted()` is
|
||||
the one place the question is answered. Four things about the shape of it:
|
||||
|
||||
1. IT IS NOT A TRIP, AND IT MUST NOT BECOME ONE. SAFETY-07's claim is a LIMIT
|
||||
and every stop in 11.2's table still outranks it. This floor is about
|
||||
ENERGY - how far down the guard may spend the pack for money - not about
|
||||
last-resort safety. The action when it binds is therefore to stop ASKING
|
||||
for more discharge, not to command anything.
|
||||
|
||||
2. THE ACTION IS `floor_w = 0.0`, WHICH IS THE BLIND BRANCH'S ACTION FOR THE
|
||||
BLIND BRANCH'S REASON. Zero is one-directional: it forbids CHARGING, which
|
||||
can only ever lower delivered offtake, and it forbids nothing else. It
|
||||
cannot pull the loop off a discharge it is already doing, because a `lo`
|
||||
limit structurally cannot cap discharge - the property this module's whole
|
||||
shape rests on, and one a NEGATIVE floor would destroy. Nothing here can
|
||||
produce a negative floor: the only two values `floor_w` ever takes are 0.0
|
||||
and `min(wanted, achievable)` with `wanted > 0` already established.
|
||||
|
||||
⚠️ AND IT DOES NOT BOUND THE LOOP. Self-consumption may still drain the
|
||||
pack past this floor, exactly as SAFETY-18 recorded. That is not fixed
|
||||
here and must not be claimed: this card bounds THE PEAK GUARD's OWN forced
|
||||
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
|
||||
governs nothing, and would report "floor-limited" on a quiet afternoon
|
||||
where the guard was never going to discharge anyway.
|
||||
|
||||
4. AN UNREADABLE SoC IS NOT A FULL BATTERY. `soc=None` is floor-limited, not
|
||||
permitted. This is `PeakReading`'s lesson in the one place it bites
|
||||
hardest: a full battery is exactly the value that would disarm this floor,
|
||||
so a failed read defaulting to one would silently restore the unbounded
|
||||
discharge this card exists to remove. Both new arguments are REQUIRED
|
||||
keyword-only and neither has a default, which is TEST-14 read as a rule
|
||||
rather than as a story: a required field that gains a default is how three
|
||||
separate files in this repo shipped a quiet fallback.
|
||||
|
||||
AND FAILING TO SHAVE MUST NOT BE SILENT. The whole point of the guard is the
|
||||
euro figure, so a floor-limited cycle carries `floor_limited=True` and main.py
|
||||
raises ONE notification on the edge, saying the quarter will be exceeded - the
|
||||
same shape, and the same argument, as `saturated`. The two are mutually
|
||||
exclusive by construction: a floor-limited decision asks for nothing, so it
|
||||
cannot have run out of inverter.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
CFG-03: TWO NEW OPTIONS, MIRRORED ONCE, AND WHY BOTH ARE NEEDED
|
||||
|
||||
SAFETY-07 added none and said so. This card cannot: an EMS-side floor is a
|
||||
number only the site knows, and refusing one set below the pack's own cut-out
|
||||
means knowing that cut-out too.
|
||||
|
||||
* `peak_soc_floor` - the EMS floor. ⚠️ NOT `maintenance_soc_floor`. That is
|
||||
the maintenance cycle's DRAIN TARGET: the depth a monthly recalibration
|
||||
deliberately goes to, once, under supervision. This is the depth a
|
||||
money-saving discharge may never reach, on any day of the month. Sharing
|
||||
one key would couple two features whose right values differ and would widen
|
||||
CFG-03 rather than narrow it.
|
||||
* `bms_soc_floor` - the pack's own cut-out (register 0x0560 on the reference
|
||||
unit, 10 %). It is here so validate.py rule 8 can REFUSE a `peak_soc_floor`
|
||||
at or below it. That configuration is CFG-01 rule (a): the operator states
|
||||
a floor, the BMS enforces a different one, and the status page reports an
|
||||
energy backstop that can never bind.
|
||||
|
||||
Both defaults are mirrored ONCE, in `PEAK_OPTION_DEFAULTS` below, which is
|
||||
TARIFF-02's `PRICE_OPTION_DEFAULTS` pattern - imported by validate.py and by
|
||||
main.py, pinned to config.yaml by test_peakguard.py rather than transcribed.
|
||||
This module still reads no option itself: `decide()` takes both as ARGUMENTS,
|
||||
makes no `.get()` call, and the mirror is a default table, not a read.
|
||||
|
||||
⚠️ A DECISION RECORDED, NOT A RENAME DONE. Now that a second SoC floor exists,
|
||||
`maintenance_soc_floor` is the ambiguous name of the two - it reads like "the
|
||||
SoC floor" and is not. Renaming it is an option migration on a live install for
|
||||
a clarity gain, so the decision recorded here is: KEEP IT, and carry the
|
||||
distinction in DOCS.md and in this comment. Revisit if a THIRD floor appears -
|
||||
a loop-side guard would be the third - at which point the migration buys enough
|
||||
to be worth its cost.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
|
||||
|
||||
The sum/max/net reduction is an open product question with the owner. TARIFF-01
|
||||
put it behind `billed_w()` and defaulted to NET. Everything this module consumes
|
||||
- `PeakReading.peak_w`, `Projection.projected_w` - is already reduced by that one
|
||||
function, so whichever way the owner answers, this file does not change and does
|
||||
not need re-testing. Nothing here indexes a phase.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
|
||||
from .control import peak_at_risk
|
||||
from .tariff import QUARTER_S
|
||||
|
||||
# Why the projection may not be graded. Reasons, not booleans: the status page
|
||||
# and the action log both have to say WHICH, and "blind" alone is unactionable.
|
||||
BLIND_NO_BLOCK = "no open quarter - the meter has produced no sample yet"
|
||||
BLIND_COVERAGE = "meter gap"
|
||||
BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
|
||||
|
||||
# Where the ceiling came from. Only the second is a relaxation, and only an
|
||||
# OBSERVED reading may produce it.
|
||||
CEILING_CAP = "peak_cap_w"
|
||||
CEILING_MONTH_PEAK = "the month's banked peak"
|
||||
|
||||
# SAFETY-24. Why the forced discharge may not be sized. Reasons, not booleans,
|
||||
# for BLIND_*'s reason: the status page, the action log and the operator
|
||||
# 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"
|
||||
|
||||
# ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR THIS CARD'S OPTIONS, and
|
||||
# it is TARIFF-02's `PRICE_OPTION_DEFAULTS` pattern for TARIFF-02's reason: the
|
||||
# add-on already carries option defaults in three places (config.yaml's
|
||||
# `options:`, config.yaml's `schema:`, and main.py's literal `.get()`
|
||||
# fallbacks), and a fourth is the open ticket. validate.py and main.py both
|
||||
# import THIS dict rather than typing a literal, and test_peakguard.py pins it
|
||||
# to config.yaml by READING the file - so a drift goes red instead of shipping.
|
||||
#
|
||||
# ⚠️ NOT A READ. `decide()` takes both values as arguments and this module makes
|
||||
# no `.get()` call at all; the dict is a default table for its two callers.
|
||||
PEAK_OPTION_DEFAULTS = {
|
||||
# The EMS-side floor on SAFETY-07's forced discharge, in percent.
|
||||
#
|
||||
# 20 is DERIVED from the two numbers this repository already has, not
|
||||
# chosen. The reference pack is 300 Ah at 51.2 V with DoD 90 - 13.8 kWh
|
||||
# usable, the figure validate.py's rule 6 note already quotes - and the
|
||||
# 2026-08-20 replay models 878 Wh of guard discharge in a day, which is
|
||||
# 6.4 % of that. A floor 10 points above the pack's own 10 % cut-out
|
||||
# therefore leaves the guard roughly 1.6 of its worst observed days of room
|
||||
# before it stops, which is the smallest margin that lets the guard do its
|
||||
# job on a bad day and still never reach the BMS.
|
||||
"peak_soc_floor": 20,
|
||||
# The pack's OWN cut-out, in percent: what the BMS enforces regardless of
|
||||
# anything here. On the reference unit this is register 0x0560, read as 10,
|
||||
# and it is consistent with DoD 90 on a full pack. It exists so validate.py
|
||||
# rule 8 can refuse a `peak_soc_floor` at or below it - see that rule.
|
||||
"bms_soc_floor": 10,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PeakDecision:
|
||||
"""What the guard concluded this cycle, with everything it concluded it from.
|
||||
|
||||
Every field is derived from a measurement taken in the same cycle. There is
|
||||
no `floor_w_or(default)` and there must not be one, for `PeakReading`'s
|
||||
reason: an accessor that turns "not measured" into a number re-creates the
|
||||
defect the type exists to remove.
|
||||
"""
|
||||
|
||||
engaged: bool
|
||||
floor_w: float | None # the `lo` the claim carries; None = no claim
|
||||
ceiling_w: float
|
||||
ceiling_from: str
|
||||
blind: str | None = None
|
||||
projected_w: float | None = None
|
||||
excess_w: float = 0.0
|
||||
wanted_w: float | None = None # the floor before `achievable_w` clamped it
|
||||
saturated: bool = False
|
||||
# SAFETY-24. True when a forced discharge WAS wanted and the EMS-side SoC
|
||||
# floor refused to size it. Mutually exclusive with `saturated` by
|
||||
# construction: this branch asks for nothing, so it cannot have run out of
|
||||
# inverter. `soc_floor_reason` says WHICH of the two ways it bound.
|
||||
floor_limited: bool = False
|
||||
soc: float | None = None
|
||||
soc_floor: float | None = None
|
||||
soc_floor_reason: str | None = None
|
||||
reason: str = ""
|
||||
|
||||
def explain(self) -> str:
|
||||
if self.floor_limited:
|
||||
return self.reason
|
||||
if not self.engaged:
|
||||
if self.projected_w is None:
|
||||
return f"peak guard idle - {self.reason}"
|
||||
return (f"peak guard idle - quarter projects "
|
||||
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W "
|
||||
f"ceiling ({self.ceiling_from})")
|
||||
return self.reason
|
||||
|
||||
|
||||
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
|
||||
"""The average this quarter may not exceed, and where the number came from.
|
||||
|
||||
ONLY an OBSERVED reading may raise it. `NO_RECORD` (the store answered, this
|
||||
month has nothing yet) and `NOT_OBSERVED` (the store did not answer) both
|
||||
return the cap, which is the tighter answer. A failed read therefore makes
|
||||
the guard stricter, never looser - see the module note.
|
||||
"""
|
||||
cap = float(cap_w)
|
||||
if (reading is not None and getattr(reading, "is_observed", False)
|
||||
and reading.peak_w is not None and float(reading.peak_w) > cap):
|
||||
return float(reading.peak_w), CEILING_MONTH_PEAK
|
||||
return cap, CEILING_CAP
|
||||
|
||||
|
||||
def excess_w(projection, ceiling_w) -> float:
|
||||
"""Watts that must come off the meter NOW to land the quarter on the ceiling.
|
||||
|
||||
The whole control law, and the only place it is written down:
|
||||
|
||||
excess = 900 * (projected - ceiling) / remaining
|
||||
|
||||
Positive means the quarter is projected over; zero or negative means it is
|
||||
not. `remaining <= 0` is not a large number, it is an undefined one - a
|
||||
quarter with no seconds left cannot be influenced - so it answers 0.0 rather
|
||||
than dividing.
|
||||
"""
|
||||
remaining = QUARTER_S - float(projection.elapsed_s)
|
||||
if remaining <= 0.0:
|
||||
return 0.0
|
||||
return QUARTER_S * (float(projection.projected_w) - float(ceiling_w)) / remaining
|
||||
|
||||
|
||||
def uncovered_s(projection) -> float:
|
||||
"""Seconds of the open block the meter did not actually cover.
|
||||
|
||||
Derived from what `Projection` already carries rather than re-measured:
|
||||
`covered_frac` is `elapsed / elapsed_wall`, and `elapsed_s` IS that wall.
|
||||
"""
|
||||
return max(0.0, float(projection.elapsed_s)
|
||||
* (1.0 - float(projection.covered_frac)))
|
||||
|
||||
|
||||
def blindness(projection, max_hold_s, frozen: bool) -> str | None:
|
||||
"""Why this projection may not be graded, or None if it may be.
|
||||
|
||||
Evaluated BEFORE the projection is compared to anything. Each of the three is
|
||||
a way for the projected number to be confident and wrong, and the first - a
|
||||
fresh averager's all-zero `Projection` - is confident, wrong AND under every
|
||||
ceiling there is.
|
||||
"""
|
||||
if frozen:
|
||||
return BLIND_FROZEN
|
||||
if projection.block_start is None:
|
||||
return BLIND_NO_BLOCK
|
||||
gap = uncovered_s(projection)
|
||||
if max_hold_s is not None and gap > float(max_hold_s):
|
||||
return (f"{BLIND_COVERAGE}: {gap:.0f} s of this quarter uncovered, past "
|
||||
f"the {float(max_hold_s):.0f} s the averager will hold a sample")
|
||||
return None
|
||||
|
||||
|
||||
def discharge_permitted(soc, soc_floor) -> str | None:
|
||||
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
|
||||
|
||||
Returns None when it may, and otherwise the REASON it may not - which the
|
||||
decision, the status page, the action log and the operator notification all
|
||||
carry, because "floor-limited" on its own tells nobody what to do.
|
||||
|
||||
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
|
||||
`freeze.looks_frozen()`, `watchdog.loop_is_following()` and
|
||||
`shaping_suspended()` are: a safety predicate transcribed into its own check
|
||||
is one copy plus a future disagreement about what the check means, and the
|
||||
copy that drifts is always the grading one.
|
||||
|
||||
⚠️ `soc is None` IS NOT PERMISSION. A failed read is not an observation, and
|
||||
a FULL BATTERY is exactly the value that would disarm this floor - so a
|
||||
`float(soc or 100)` here would restore the unbounded discharge this card
|
||||
removes, silently, on the one install whose SoC sensor is misnamed. There is
|
||||
deliberately no default argument on either parameter.
|
||||
|
||||
⚠️ AND `soc_floor is None` IS NOT PERMISSION EITHER, for the same reason one
|
||||
step further out: a caller that has no floor configured has not thereby been
|
||||
granted an unbounded one.
|
||||
|
||||
Boundary: `<=`, not `<`. A pack sitting exactly ON the floor has already
|
||||
spent everything this guard was allowed to spend; permitting one more cycle
|
||||
there would make the floor mean "one cycle below the floor".
|
||||
"""
|
||||
if soc is None:
|
||||
return FLOOR_SOC_UNREADABLE
|
||||
if soc_floor is None:
|
||||
return ("no peak guard SoC floor is configured, so there is nothing to "
|
||||
"bound a forced discharge with")
|
||||
if float(soc) <= float(soc_floor):
|
||||
return FLOOR_AT_SOC_FLOOR
|
||||
return None
|
||||
|
||||
|
||||
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
||||
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
|
||||
|
||||
`batt_w` MEASURED battery power, positive = discharge. None when the
|
||||
sensor for it is missing - and then there is nothing to size a
|
||||
floor from, so no floor is invented.
|
||||
`achievable_w` the largest discharge the arbiter could actually grant
|
||||
(`min(max_w, device max)`). The floor is clamped to it so this
|
||||
claim can NEVER produce `lo > hi` in the resolver - a
|
||||
contradiction there resolves to 0 W, which would park the
|
||||
battery in the exact minute it should be discharging hardest.
|
||||
`soc` SAFETY-24. MEASURED state of charge, percent. None when the
|
||||
sensor for it did not answer - which is floor-limited, not
|
||||
permission.
|
||||
`soc_floor` SAFETY-24. The percent below which this guard may not spend
|
||||
the pack for money. REQUIRED, and deliberately without a
|
||||
default: a default here would be CFG-03's fourth defaults
|
||||
site and TEST-14's quiet fallback in one line.
|
||||
"""
|
||||
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
|
||||
ceil_top = abs(float(achievable_w))
|
||||
|
||||
blind = blindness(projection, max_hold_s, frozen)
|
||||
if blind is not None:
|
||||
# 0.0, not a sized floor. There is no trustworthy measurement to size one
|
||||
# from; the claim that remains is "do not add to the offtake you cannot
|
||||
# see", which is one-directional and cannot cost pack energy.
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
|
||||
blind=blind, projected_w=None,
|
||||
reason=(f"PEAK GUARD BLIND ({blind}) - the battery may not import "
|
||||
f"while the billed quarter cannot be measured; commanding no "
|
||||
f"charging (floor 0 W)"))
|
||||
|
||||
projected = float(projection.projected_w)
|
||||
if batt_w is None:
|
||||
# main.py's P_SAFETY stop already owns a cycle with a missing input, at
|
||||
# 0 W. Filing nothing is the honest answer: a floor sized from a
|
||||
# measurement that is not there is a made-up number.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected,
|
||||
reason="no battery measurement to size a floor from")
|
||||
if QUARTER_S - float(projection.elapsed_s) <= 0.0:
|
||||
# A block with no seconds left cannot be influenced. This is degenerate
|
||||
# rather than urgent - `excess_w` answers 0.0 there and a floor equal to
|
||||
# the battery's own current output would be a claim that says nothing.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected,
|
||||
reason="the quarter has no seconds left to influence")
|
||||
|
||||
ex = excess_w(projection, ceiling)
|
||||
wanted = float(batt_w) + ex
|
||||
if wanted <= 0.0:
|
||||
# ⚠️ THE TEST IS ON THE FLOOR, NOT ON `excess > 0`, AND THE REPLAY IS WHY.
|
||||
# `excess` is the watts that must come off the METER, so it goes to zero
|
||||
# as soon as the battery is already removing them - and a guard that
|
||||
# disengaged there handed the actuator straight back, the offtake sprang
|
||||
# back to the raw load, and the next cycle engaged again. On 2026-08-20's
|
||||
# billed quarter that bang-bang cycled 17 times in 900 s and left the
|
||||
# quarter 49 W over the limit it had just spent a quarter of an hour
|
||||
# holding. §10.2: "an EMS that oscillates is worse than no EMS, both for
|
||||
# hardware wear and - per §9.1 - for the peak it is trying to avoid."
|
||||
#
|
||||
# `batt_w + excess` is the offtake that would exist with the battery at
|
||||
# zero, minus the level the quarter can afford. Testing THAT is the same
|
||||
# arithmetic read as a level rather than as a switch: the claim is filed
|
||||
# for as long as a positive floor is needed and decays continuously to
|
||||
# nothing as the load falls, so there is still no hysteresis constant -
|
||||
# the quantity being thresholded is simply the right one.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected, excess_w=ex,
|
||||
wanted_w=wanted,
|
||||
reason="the load is under the level this quarter can afford")
|
||||
|
||||
# SAFETY-24 - AND ONLY HERE. `wanted > 0` is now established, so a forced
|
||||
# discharge really is being asked for and the SoC is the quantity that
|
||||
# decides whether it may be sized. Asked one line earlier it would grade a
|
||||
# number that governs nothing on every quiet cycle of the day, and would
|
||||
# report "floor-limited" over a guard that was never going to discharge.
|
||||
denied = discharge_permitted(soc, soc_floor)
|
||||
if denied is not None:
|
||||
# 0.0, not `wanted`, and not a negative number. Zero forbids CHARGING -
|
||||
# one-directional, cannot cost pack energy - and forbids nothing else;
|
||||
# a `lo` limit structurally cannot cap the discharge the loop is already
|
||||
# doing. See the module note: this bounds THE GUARD's forced discharge,
|
||||
# not the grid-following loop, which still has no SoC guard.
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected, excess_w=ex,
|
||||
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
|
||||
soc_floor_reason=denied,
|
||||
reason=(
|
||||
f"PEAK GUARD FLOOR-LIMITED: the quarter projects "
|
||||
f"{projected:.0f} W against a {ceiling:.0f} W ceiling "
|
||||
f"({from_where}) and {wanted:+.0f} W of discharge would hold "
|
||||
f"it, but {denied}"
|
||||
+ ("" if soc is None else f" ({float(soc):.0f} %")
|
||||
+ ("" if soc is None or soc_floor is None
|
||||
else f" vs {float(soc_floor):.0f} %")
|
||||
+ ("" if soc is None else ")")
|
||||
+ " - commanding no forced discharge, so THIS QUARTER WILL GO "
|
||||
"OVER THE CEILING. Charging stays blocked (floor 0 W)"))
|
||||
|
||||
floor = min(wanted, ceil_top)
|
||||
saturated = floor < wanted
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
|
||||
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
|
||||
saturated=saturated, soc=soc, soc_floor=soc_floor,
|
||||
reason=(
|
||||
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
|
||||
f"{ceiling:.0f} W ceiling ({from_where}) with "
|
||||
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
|
||||
f"come off the meter; battery measured {float(batt_w):+.0f} W, "
|
||||
f"floor {floor:+.0f} W"
|
||||
+ (f" - SATURATED, {wanted:+.0f} W was needed and the inverter can "
|
||||
f"only give {ceil_top:.0f} W" if saturated else "")))
|
||||
|
||||
|
||||
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
|
||||
"""Must the maintenance charge-only shaping stand down this cycle?
|
||||
|
||||
THE ONE PLACE THIS CONJUNCTION IS WRITTEN, and it is public for the reason
|
||||
`watchdog.loop_is_following()` and `freeze.looks_frozen()` are: the check
|
||||
that grades it has to assert on THE SHIPPED EXPRESSION. A safety predicate
|
||||
transcribed into its own test is one copy plus a future disagreement about
|
||||
what the check means, and the copy that drifts is always the grading one.
|
||||
It was inline in `main.py` and nothing could reach it; a reviewer mutated it
|
||||
back to its historical broken form and all twelve suites stayed green.
|
||||
|
||||
⚠️ WHY SUPPRESSION AND NOT PRIORITY. The shaping is a LIMIT (`hi <= 0`) and
|
||||
the peak guard's claim is a LIMIT (`lo >= 0`). arbiter.py clause 2 applies
|
||||
EVERY limit at or above the winner's priority and takes the MOST RESTRICTIVE,
|
||||
with no notion of one limit outranking another - which is precisely what
|
||||
makes the safety bounds unconditional and must not be changed. Two crossing
|
||||
limits are therefore a CONTRADICTION, which resolves to 0 W: the battery
|
||||
PARKED, in the one quarter of the month that sets the bill. P_PEAK 50 >
|
||||
P_MAINT_SHAPE 40 does not save it and never could.
|
||||
|
||||
⚠️ AND THE FIRST TERM IS NOT ENOUGH ON ITS OWN. `peak_forecast_entity` is
|
||||
OPTIONAL and is empty on a default install, so `peak_forecast_w` is None
|
||||
there and `peak_at_risk()` answers False forever. Before this card the
|
||||
suspension therefore could not fire on the very site that needed it. The
|
||||
second term is the MEASURED guard, which is always present wherever P1 is.
|
||||
Deleting either term - or re-gating the whole thing on the forecast - parks
|
||||
a default install at 0 W, silently, in the billed quarter. test_peakguard.py
|
||||
part 7 drives the real cycle with each of those mutations in place.
|
||||
"""
|
||||
if peak_at_risk(peak_forecast_w, float(cap_w)):
|
||||
return True
|
||||
return decision is not None and decision.engaged
|
||||
|
||||
|
||||
def claim(decision: PeakDecision) -> Claim | None:
|
||||
"""The decision as one arbiter claim, or None when the guard is idle.
|
||||
|
||||
A LIMIT with only `lo` set. `hi` stays `+inf`, which is 9.1's "discharge and
|
||||
export are never capped by the peak guard" expressed as a fact about the
|
||||
object rather than as a rule somebody has to remember.
|
||||
"""
|
||||
if not decision.engaged or decision.floor_w is None:
|
||||
return None
|
||||
# Three reasons, not two. A `lo = 0 W` claim reaches the arbiter from THREE
|
||||
# different states now - blind, floor-limited, and a genuine floor that
|
||||
# happens to be zero - and the arbiter's explain() is what an operator reads
|
||||
# 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"),
|
||||
actuator=ACTUATOR_SITE)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,465 @@
|
||||
"""FCST-03 - the hours the feed has not published yet, and what may not rest on them.
|
||||
|
||||
WHERE THIS LIVES. specs.md §2.2 puts price ingest under `ems/ingest/prices/`.
|
||||
That layout does not exist and is not being built; the product owner's decision
|
||||
of 2026-08-30 is that everything ships in `goodwe_controller/app/`. The
|
||||
BEHAVIOUR §8.3 describes is unchanged and still authoritative - only the path
|
||||
moved. Read `ems/...` as `app/...`.
|
||||
|
||||
§8.3, in full, because every line below is one clause of it:
|
||||
|
||||
"Day-ahead prices arrive once daily. Until they do, unknown hours use
|
||||
yesterday's profile and are flagged `provisional: true` - and NO
|
||||
IRREVERSIBLE ACTION MAY BE TAKEN ON A PROVISIONAL PRICE. Real-time /
|
||||
imbalance products are treated as high-volatility: arbitrage thresholds
|
||||
widen."
|
||||
|
||||
Three separate things, and this module owns the first two. The third - the
|
||||
widening arithmetic - lives in price.py beside the product enum it is derived
|
||||
from (`Uncertainty`, `widen_factor`, `required_spread`), and is spent by
|
||||
`static_tariff.TariffPlan.arbitrage()`, because that is where the verdict this
|
||||
repo already refuses to hand out casually is produced. FCST-03 added a refusal
|
||||
to that existing ladder rather than a second mechanism beside it.
|
||||
|
||||
|
||||
THE DEFECT THIS MODULE IS SHAPED BY: A COPY THAT KEEPS THE WRONG FLAG
|
||||
=====================================================================
|
||||
A point filled from yesterday and a point published this morning are the SAME
|
||||
SHAPE. Three floats and a bool; only the bool tells them apart. And the natural
|
||||
way to write a carry-forward fill is:
|
||||
|
||||
PricePoint(start=t, eur_per_kwh=y.eur_per_kwh, provisional=y.provisional)
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
which copies yesterday's `False` onto today's guess and produces exactly the
|
||||
thing the ticket exists to prevent: STALE DATA PRESENTED AS CURRENT. It reads
|
||||
fine, it type-checks, it round-trips through §9.3's JSON, and every price on
|
||||
the status page looks like a price. This is the meter-side SAFETY-18 defect one
|
||||
layer up: the reading arrives on time, looks fresh, and is wrong - and a
|
||||
freshness check that only asks "did something arrive" cannot see it.
|
||||
|
||||
So the flag is not passed through anywhere in this file:
|
||||
|
||||
* A CARRIED POINT IS BUILT BY `_carried()`, WHICH HAS NO PARAMETER FOR THE
|
||||
FLAG. `provisional=True` is a literal in one place. There is no argument a
|
||||
caller can pass, no keyword to forget, and no variable that could hold
|
||||
yesterday's value at the moment the point is constructed.
|
||||
* A PUBLISHED POINT IS NEVER COPIED AT ALL. It is passed through BY IDENTITY -
|
||||
the object the provider produced is the object in the output series - so
|
||||
there is no copy that could lose or gain a flag. `filled.points[i] is
|
||||
fetched.points[j]` is a checkable property, and test_price_forecast.py
|
||||
checks it.
|
||||
* `_audit()` RE-DERIVES the expected flag for every output point from the set
|
||||
of hours the feed actually published, and RAISES if any point disagrees.
|
||||
It is a post-condition, not a filter: it never fixes a flag, because a
|
||||
module that quietly corrects its own provenance is one whose provenance
|
||||
cannot be trusted.
|
||||
* and test_price_forecast.py walks THIS FILE'S AST and fails if any
|
||||
`PricePoint(...)` in it passes anything but a literal for `provisional`, so
|
||||
the pass-through idiom cannot come back in a later edit and pass review by
|
||||
looking ordinary.
|
||||
|
||||
⚠️ "PUBLISHED" DOES NOT MEAN "FIRM". `eneco_eddy` is an imbalance product whose
|
||||
current-quarter print is genuinely provisional and says so per point (§9.2,
|
||||
TARIFF-02). Such a point is published AND provisional, and the audit's rule for
|
||||
a published point is "the flag is whatever the feed said", never "False". A
|
||||
provenance check that forced published points to False would be a bug that
|
||||
looks like a guarantee.
|
||||
|
||||
|
||||
THE FILL, AND WHY IT KEYS ON THE WALL CLOCK
|
||||
===========================================
|
||||
For every whole hour in the requested horizon that the feed has not published,
|
||||
take yesterday's value for THE SAME BRUSSELS WALL-CLOCK HOUR - not `t - 86400
|
||||
s`. On the two days a year that matter those are different instants, and the
|
||||
difference is not academic: a 23-hour day has no 02:00 at all and a 25-hour day
|
||||
has two, so a fill that subtracts a fixed 24 h shifts every carried hour by one
|
||||
across a transition and misprices the whole day in a way nothing reports.
|
||||
|
||||
On the 25-hour day the two 02:00 hours - `02:00+02:00` and `02:00+01:00` - both
|
||||
key to local hour 2 and are BOTH filled from yesterday's single 02:00 value.
|
||||
That is deliberate: they are both guesses, they are both flagged, and nothing
|
||||
irreversible may rest on either. If yesterday itself was a 25-hour day, the
|
||||
first of its two 02:00 points is the one used, deterministically.
|
||||
|
||||
An hour with no published price AND no yesterday value is LEFT OUT of the
|
||||
series. It is not filled with a neighbour, a mean, or a zero. §11.4 applies to
|
||||
that hour - `_Series.at()` raises `Absence` for it - and the caller that wanted
|
||||
a tariff for the whole horizon falls back to the static profile (TARIFF-03),
|
||||
which is the row the spec actually writes for "no price data". If NOTHING can
|
||||
be produced, the result is `PriceRead.no_data(reason)` and not an empty series,
|
||||
because an empty series is not constructible on purpose (price.py trap 3).
|
||||
|
||||
|
||||
AC2: THE IRREVERSIBLE-ACTION GATE
|
||||
=================================
|
||||
"The distinction should be an explicit property of the action, not a judgement
|
||||
call at each call site" - so an irreversible action is a value, `Irreversible`,
|
||||
carrying the number of hours it COMMITS. `price_permits()` looks at exactly
|
||||
those hours and answers with a `Gate` variant that carries the evidence on the
|
||||
permitting arm and a reason on the blocking one, the `_Outcome` shape price.py
|
||||
borrowed from `sim/riglock.py`.
|
||||
|
||||
The shipped example is the maintenance drain (§12): starting one commits hours
|
||||
of forced discharge that cannot be undone if the price turns out to have been
|
||||
yesterday's. Its commitment window is DERIVED from `maintenance.DRAIN_TIMEOUT_MIN`
|
||||
rather than typed here, so the gate and the phase machine cannot drift apart.
|
||||
|
||||
⚠️ WHAT DOES NOT GO THROUGH THIS GATE, AND WHY THAT MATTERS MORE THAN WHAT
|
||||
DOES. The maintenance cycle is a CALENDAR obligation (§12.2: a cycle that
|
||||
silently never fires is indistinguishable from one that is not due yet), and a
|
||||
site with `price_provider: off` - the shipped default - has no price series at
|
||||
all. Routing every maintenance start through a gate that blocks on `no_data`
|
||||
would stop the monthly cycle on every default install, permanently and
|
||||
silently: the exact failure §12 opens by naming. So `begin_phase()` is the
|
||||
entry point for a start that is being JUSTIFIED BY A PRICE, main.py's existing
|
||||
calendar path is untouched by this ticket, and OPT-04/MAINT-05 own the decision
|
||||
of which one a site runs. Nothing here disables anything that runs today.
|
||||
|
||||
Reversible actions - ordinary self-consumption modulation, the loop that
|
||||
actually holds the grid at zero - are not gated at all and cannot be: §6 says
|
||||
absent machinery must not disable the loop.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
|
||||
from .maintenance import CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN
|
||||
from .price import PricePoint, PriceRead, _Outcome, brussels
|
||||
|
||||
_LOG = logging.getLogger("goodwe.price.forecast")
|
||||
|
||||
|
||||
class Provenance(Enum):
|
||||
"""Where a point in a filled series came from. INTERNAL - §9.3's wire format
|
||||
carries the boolean and nothing else, so this never leaves the module.
|
||||
|
||||
It exists so `_audit()` has a vocabulary to state its post-condition in, and
|
||||
so the two cases have names in the log rather than being "the ones with the
|
||||
flag" and "the others".
|
||||
"""
|
||||
PUBLISHED = "published" # the provider printed this hour. Flag: the
|
||||
# feed's own - an imbalance print may be
|
||||
# published AND provisional (§9.2).
|
||||
CARRIED = "carried" # yesterday's same-wall-clock hour (§8.3).
|
||||
# Flag: ALWAYS True, by construction.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The fill
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _utc_hour(instant: datetime) -> datetime:
|
||||
u = instant.astimezone(timezone.utc)
|
||||
return u.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
|
||||
"""A point filled from yesterday's profile. §8.3: ALWAYS provisional.
|
||||
|
||||
⚠️ THERE IS NO `provisional` PARAMETER, AND THAT IS THE MECHANISM. This is
|
||||
the only constructor of a carried point, the flag is a literal, and no
|
||||
caller - including a future one editing in a hurry - has anything to pass,
|
||||
default, or copy from the point being carried.
|
||||
"""
|
||||
return PricePoint(start=brussels(start_utc),
|
||||
eur_per_kwh=round(float(eur_per_kwh), 6),
|
||||
provisional=True)
|
||||
|
||||
|
||||
def _audit(points, published) -> None:
|
||||
"""Post-condition: every flag matches the point's TRUE provenance (AC1).
|
||||
|
||||
`published` maps a UTC hour to the point the provider produced for it.
|
||||
|
||||
⚠️ RAISES, NEVER REPAIRS. A module that silently corrects its own
|
||||
provenance is one whose provenance means nothing; the only safe response to
|
||||
"this point's flag disagrees with where it came from" is to refuse to emit
|
||||
the series at all.
|
||||
"""
|
||||
seen = set()
|
||||
for p in points:
|
||||
u = _utc_hour(p.start)
|
||||
if u in seen:
|
||||
raise ValueError(f"the filled series repeats {u.isoformat()}; a "
|
||||
f"duplicated hour is how a DST day gets mispriced")
|
||||
seen.add(u)
|
||||
src = published.get(u)
|
||||
prov = Provenance.CARRIED if src is None else Provenance.PUBLISHED
|
||||
if src is None:
|
||||
if p.provisional is not True:
|
||||
raise ValueError(
|
||||
f"{u.isoformat()} is {prov.value} and is flagged "
|
||||
f"provisional={p.provisional!r}. §8.3: an hour filled from "
|
||||
f"yesterday's profile is provisional whether or not the "
|
||||
f"number turns out to be right - the flag is a claim about "
|
||||
f"PROVENANCE, not about accuracy.")
|
||||
else:
|
||||
if p is not src:
|
||||
raise ValueError(
|
||||
f"{u.isoformat()} is {prov.value} but the series carries a "
|
||||
f"COPY of that point rather than the point itself. A copy "
|
||||
f"is where the provisional flag gets lost; published points "
|
||||
f"pass through by identity.")
|
||||
|
||||
|
||||
def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
since: datetime, until: datetime,
|
||||
stamp: datetime) -> PriceRead:
|
||||
"""§8.3: fill the hours the feed has not published with yesterday's profile.
|
||||
|
||||
Every argument is required and keyword-only past the first. In particular
|
||||
`yesterday` has NO DEFAULT: a fill with no yesterday to fill from is a
|
||||
different situation from a fill that found nothing useful in one, and a
|
||||
default `None` would make the two arrive looking identical.
|
||||
|
||||
Returns a `PriceRead`:
|
||||
|
||||
* a series, whose points are the provider's own objects for the hours it
|
||||
published and `_carried()` points for the rest, ordered by instant;
|
||||
* `no_data` when neither source can produce a single hour - never an
|
||||
empty series, which is not constructible (price.py trap 3).
|
||||
|
||||
⚠️ IDEMPOTENT AND RE-RUNNABLE, WHICH IS THE §5.4 LATE-PUBLICATION CONTRACT.
|
||||
Day-ahead publication at 14-16 h CET can be late or fail, so the fetcher
|
||||
retries and this runs again against the fresher read. Published hours always
|
||||
win over carried ones, so a re-run REPLACES a carried point with the real
|
||||
print and the hour becomes firm - the flag and the number move together
|
||||
because the whole series is rebuilt rather than patched in place.
|
||||
"""
|
||||
for name, value in (("since", since), ("until", until), ("stamp", stamp)):
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError(f"{name} must be tz-aware; a naive instant is "
|
||||
f"ambiguous for one hour every October")
|
||||
|
||||
published = read.match(
|
||||
series=lambda r: {_utc_hour(p.start): p for p in r.points},
|
||||
no_data=lambda _r: {})
|
||||
|
||||
# Yesterday's profile, keyed by BRUSSELS WALL-CLOCK HOUR. First point wins
|
||||
# when yesterday itself was a 25-hour day and printed local hour 2 twice.
|
||||
by_local_hour = {}
|
||||
yesterday.match(
|
||||
series=lambda r: [by_local_hour.setdefault(brussels(p.start).hour, p)
|
||||
for p in r.points],
|
||||
no_data=lambda _r: [])
|
||||
|
||||
out = []
|
||||
u = _utc_hour(since)
|
||||
end = until.astimezone(timezone.utc)
|
||||
while u < end:
|
||||
if u in published:
|
||||
out.append(published[u]) # BY IDENTITY. No copy.
|
||||
else:
|
||||
src = by_local_hour.get(brussels(u).hour)
|
||||
if src is not None:
|
||||
out.append(_carried(u, src.eur_per_kwh))
|
||||
u += timedelta(hours=1)
|
||||
|
||||
if not out:
|
||||
reason = read.match(
|
||||
series=lambda r: (f"the {len(r.points)} published hours all fall "
|
||||
f"outside {since.isoformat()}..{until.isoformat()}"),
|
||||
no_data=lambda r: r.reason)
|
||||
return PriceRead.no_data(
|
||||
f"§8.3 carry-forward produced no hours: {reason}, and yesterday's "
|
||||
f"profile has nothing to fill them with either. §11.4 applies: "
|
||||
f"static profile, plan provisional, no arbitrage")
|
||||
|
||||
_audit(out, published)
|
||||
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
|
||||
if carried:
|
||||
_LOG.info("§8.3: %d of %d hours carried forward from yesterday's "
|
||||
"profile and flagged provisional; %d published",
|
||||
carried, len(out), len(out) - carried)
|
||||
|
||||
product = read.match(series=lambda r: r.product,
|
||||
no_data=lambda _r: yesterday.product)
|
||||
provider = read.match(series=lambda r: r.provider,
|
||||
no_data=lambda r: yesterday.provider)
|
||||
return PriceRead.series(out, product, provider, stamp)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# AC2: irreversibility as a property of the ACTION, and the gate that reads it
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Irreversible:
|
||||
"""An action that cannot be undone once started, and how far it commits.
|
||||
|
||||
`commits_h` is the horizon the action ties up - the hours whose prices must
|
||||
therefore be firm before it may be justified by a price at all.
|
||||
"""
|
||||
|
||||
name: str # the §10.2 reason code
|
||||
commits_h: float
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.name or not isinstance(self.name, str):
|
||||
raise ValueError("an irreversible action needs a reason code (§10.2)")
|
||||
if isinstance(self.commits_h, bool) or \
|
||||
not isinstance(self.commits_h, (int, float)) or \
|
||||
not self.commits_h > 0:
|
||||
raise ValueError(
|
||||
f"commits_h must be a positive number of hours, not "
|
||||
f"{self.commits_h!r}. An action that commits nothing is a "
|
||||
f"reversible one and does not belong here.")
|
||||
|
||||
|
||||
# ⚠️ DERIVED FROM THE PHASE MACHINE'S OWN BOUNDS, NOT TYPED HERE. maintenance.py
|
||||
# already states how long each phase may run before it is a fault; a second copy
|
||||
# of those numbers here would be a place for them to drift apart, and the drift
|
||||
# would show up as a gate that checked the wrong hours.
|
||||
MAINTENANCE_DRAIN = Irreversible("MAINTENANCE_DRAIN", DRAIN_TIMEOUT_MIN / 60.0)
|
||||
MAINTENANCE_CHARGE = Irreversible("MAINTENANCE_CHARGE", CHARGE_TIMEOUT_MIN / 60.0)
|
||||
|
||||
|
||||
class Gate(_Outcome):
|
||||
"""May an irreversible action proceed on the price evidence available?
|
||||
|
||||
* `permitted` - carries `.action` and `.hours`, the firm points that were
|
||||
actually checked. The evidence exists only on this arm.
|
||||
* `blocked` - carries `.reason`. Nothing else; `.hours` raises `Absence`.
|
||||
|
||||
No `__bool__`, both `match()` arms required and keyword-only, for the reason
|
||||
price.py's `_Outcome` gives: `if gate:` must not look like a guard while
|
||||
being none.
|
||||
"""
|
||||
|
||||
def match(self, *, permitted, blocked):
|
||||
return {"permitted": permitted, "blocked": blocked}[self.kind](self)
|
||||
|
||||
@staticmethod
|
||||
def permit(action, hours):
|
||||
return _Permitted(action, hours)
|
||||
|
||||
@staticmethod
|
||||
def block(reason):
|
||||
if not reason or not isinstance(reason, str):
|
||||
raise ValueError(
|
||||
"Gate.block() takes a reason a human can act on. §6 requires "
|
||||
"every decision to explain itself, and 'the maintenance cycle "
|
||||
"did not start' with no reason is indistinguishable from a bug.")
|
||||
return _Blocked(reason)
|
||||
|
||||
|
||||
class _Permitted(Gate):
|
||||
kind = "permitted"
|
||||
|
||||
def __init__(self, action, hours):
|
||||
if not isinstance(action, Irreversible):
|
||||
raise TypeError(f"action must be an Irreversible, not {action!r}")
|
||||
pts = tuple(hours)
|
||||
if not pts:
|
||||
raise ValueError(
|
||||
"a permission granted over ZERO checked hours is a permission "
|
||||
"granted on no evidence - the shape this whole file exists to "
|
||||
"refuse. Block instead.")
|
||||
if any(p.provisional for p in pts):
|
||||
raise ValueError(
|
||||
"a permitted gate cannot carry a provisional hour: §8.3 is the "
|
||||
"one rule this type has.")
|
||||
self.action = action
|
||||
self.hours = pts
|
||||
self.words = (f"{action.name} permitted on {len(pts)} firm hours "
|
||||
f"({pts[0].start.isoformat()} ..)")
|
||||
|
||||
|
||||
class _Blocked(Gate):
|
||||
kind = "blocked"
|
||||
|
||||
def __init__(self, reason):
|
||||
self.reason = reason
|
||||
self.words = f"blocked - {reason}"
|
||||
|
||||
|
||||
def committed_hours(now: datetime, action: Irreversible) -> tuple:
|
||||
"""The whole hours `action` started at `now` would run through.
|
||||
|
||||
Split out because the gate and its caller must agree on what "the hours it
|
||||
commits" means, and because a window that quietly excluded the last hour
|
||||
would be a gate that permits an action on an hour it never looked at.
|
||||
"""
|
||||
if not isinstance(action, Irreversible):
|
||||
raise TypeError(f"action must be an Irreversible, not {action!r}")
|
||||
if now.tzinfo is None or now.utcoffset() is None:
|
||||
raise ValueError("now must be tz-aware")
|
||||
out = []
|
||||
u = _utc_hour(now)
|
||||
end = now.astimezone(timezone.utc) + timedelta(hours=action.commits_h)
|
||||
while u < end:
|
||||
out.append(u)
|
||||
u += timedelta(hours=1)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def price_permits(read: PriceRead, now: datetime,
|
||||
action: Irreversible) -> Gate:
|
||||
"""§8.3's rule, as a value: may this irreversible action rest on this price?
|
||||
|
||||
Blocks when the read observed nothing (§11.4 - an action justified by a
|
||||
price nobody has is an action justified by nothing), when any hour it
|
||||
commits has no price at all, and when any of those hours is provisional.
|
||||
Names the offending hour, so an operator reading the log knows which one.
|
||||
"""
|
||||
hours = committed_hours(now, action)
|
||||
return read.match(
|
||||
series=lambda r: _walk(r, hours, action),
|
||||
no_data=lambda r: Gate.block(
|
||||
f"{action.name} is gated on a price and there is no price data: "
|
||||
f"{r.reason}. §11.4 - and a missing price is not a cheap one"))
|
||||
|
||||
|
||||
def _walk(series, hours, action) -> Gate:
|
||||
pts = []
|
||||
for u in hours:
|
||||
if not series.covers(u):
|
||||
return Gate.block(
|
||||
f"{action.name} commits {action.commits_h:g} h from "
|
||||
f"{hours[0].isoformat()} and no price covers "
|
||||
f"{u.isoformat()}. An hour outside the series is an hour with "
|
||||
f"no price (§11.4 for that hour), not a free one")
|
||||
p = series.at(u)
|
||||
if p.provisional:
|
||||
return Gate.block(
|
||||
f"{action.name} commits {action.commits_h:g} h from "
|
||||
f"{hours[0].isoformat()} and {u.isoformat()} is PROVISIONAL - "
|
||||
f"carried from yesterday's profile or standing in for a dead "
|
||||
f"feed. §8.3: no irreversible action may be taken on a "
|
||||
f"provisional price. It becomes available when the day-ahead "
|
||||
f"print lands (~14-16 h CET, §5.4)")
|
||||
pts.append(p)
|
||||
return Gate.permit(action, pts)
|
||||
|
||||
|
||||
def begin_phase(maint, now: datetime, read: PriceRead,
|
||||
action: Irreversible) -> Gate:
|
||||
"""Start a maintenance phase BECAUSE OF A PRICE, or refuse and say why.
|
||||
|
||||
The AC2 example made real: `maint.force_start(now)` - the call that moves
|
||||
the phase machine out of `idle` and commits the pack to hours of forced
|
||||
discharge - happens on the permitting arm and cannot happen on the other
|
||||
one, because the blocking arm has no code path to it.
|
||||
|
||||
⚠️ READ THE MODULE DOCSTRING'S LAST WARNING BEFORE WIRING THIS IN. This is
|
||||
the price-justified entry point, not the calendar one; a site with no price
|
||||
provider must keep starting its monthly cycle the way it does today, or
|
||||
§12's opening sentence comes true - a cycle that silently never fires.
|
||||
"""
|
||||
gate = price_permits(read, now, action)
|
||||
return gate.match(permitted=lambda g: _go(maint, now, g),
|
||||
blocked=lambda g: _no(g))
|
||||
|
||||
|
||||
def _go(maint, now, gate) -> Gate:
|
||||
maint.force_start(now)
|
||||
_LOG.info("%s: %s", gate.action.name, gate.words)
|
||||
return gate
|
||||
|
||||
|
||||
def _no(gate) -> Gate:
|
||||
_LOG.warning("irreversible action refused - %s", gate.reason)
|
||||
return gate
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,13 +22,248 @@ _LOG = logging.getLogger("goodwe.store")
|
||||
# `auto_start` option only when the key is absent - if a default supplied False,
|
||||
# the fallback could never fire and auto_start would silently do nothing on
|
||||
# every fresh install.
|
||||
#
|
||||
# ⚠️ `sign_tripped` IS in here, and the difference from `auto` is the whole
|
||||
# reason both decisions are written down. `auto` has an option to fall back to
|
||||
# (`auto_start`), so a stored default would mask a fresh install. A latched
|
||||
# failsafe has no such fallback: absent means "never tripped", which is exactly
|
||||
# what a default of False says.
|
||||
#
|
||||
# It is persisted for the reason this module's docstring gives for maintenance
|
||||
# state, applied to something with a much shorter fuse. SAFETY-02's latch is the
|
||||
# only thing standing between a controller that has been caught driving the wrong
|
||||
# way and its own restart, and "restart the add-on" is the FIRST thing an operator
|
||||
# looking at a FAILSAFE banner will try. Held only in memory it came back armed,
|
||||
# into the fault, unattended - measured: after reconstructing Controller against
|
||||
# the same store, `tripped` was False, the banner read NOT READY, and it resumed
|
||||
# commanding -1000, -2000 W before re-tripping 180 s later. Under a supervisor
|
||||
# that restarts a crashed add-on that is a loop, three minutes of wrong-direction
|
||||
# power at a time, forever.
|
||||
#
|
||||
# ⚠️ AND SINCE DEV-02 THE TWO SAFETY-02 KEYS ARE THE *LEGACY* SPELLING. They are
|
||||
# FLAT - one `sign_tripped` for a controller that now holds a LIST of inverters -
|
||||
# so a trip caused by inverter B would latch inverter A across restarts, and one
|
||||
# operator action would clear both. The LIVE latch is per inverter, under
|
||||
# `unit/<name>/...`, and that is the one the watchdog is restored from.
|
||||
#
|
||||
# ⚠️ THE FLAT PAIR IS NOT DEAD, THOUGH: it is the ROLLBACK VIEW. It is written
|
||||
# alongside the namespaced keys on a trip and cleared alongside them on the
|
||||
# operator's clear, so an add-on that is DOWNGRADED past this schema still finds
|
||||
# the latch it should - and so that a trip taken while the install was rolled
|
||||
# back is still visible to the version that comes back up. Rule 2 of
|
||||
# `migrate_unit_keys()` and `Controller._grade_store_migration()` carry the
|
||||
# argument; the short version is that a latch is only worth persisting if it
|
||||
# survives the version change an operator makes while staring at the banner.
|
||||
#
|
||||
# ⚠️ AND SAFETY-31's PAIR IS FLAT ON PURPOSE, WHICH IS THE OPPOSITE DECISION TO
|
||||
# THE ONE ABOVE AND NEEDS ITS OWN SENTENCE. `circ_tripped` / `circ_reason` are
|
||||
# NOT in `LEGACY_UNIT_KEYS` and must never be moved into a unit namespace.
|
||||
# Circulating power is `sum(|B_i|) - |sum(B_i)|` - a quantity DEFINED over the
|
||||
# whole roster, which is identically zero for any single unit. There is no fact
|
||||
# of the matter about which of two opposing inverters is the offender: a unit
|
||||
# ignoring its share and a sibling faithfully executing a share computed to sum
|
||||
# with it read identically. Namespacing this latch would mean inventing an
|
||||
# attribution the measurement cannot support, and would then let an operator
|
||||
# clear "inverter B's" half of a site-wide loop. One site, one latch.
|
||||
#
|
||||
# It is persisted for SAFETY-34's reason, which this add-on has already paid
|
||||
# for once: `persistent_notification` is IN MEMORY. Home Assistant restarts and
|
||||
# the card is gone while the fault runs on - exactly how the 2026-09-02 outage
|
||||
# went unseen for 6 h 25 m with the detector correct throughout. main.py
|
||||
# re-raises the notification from this key on every start.
|
||||
DEFAULTS = {
|
||||
"phase": "idle",
|
||||
"phase_started": None,
|
||||
"last_completed": None,
|
||||
"last_start_attempt": None,
|
||||
"sign_tripped": False,
|
||||
"sign_reason": "",
|
||||
"circ_tripped": False,
|
||||
"circ_reason": "",
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DEV-02: PER-INVERTER KEYS, AND THE MIGRATION IS THE RISKY HALF
|
||||
#
|
||||
# ⚠️ A LATCH THAT SILENTLY FAILS TO MIGRATE IS A DISARMED SAFETY GUARD ON AN
|
||||
# INSTALL THAT BELIEVES IT IS PROTECTED. SAFETY-02's whole reason for being
|
||||
# persisted is that "restart the add-on" is the FIRST thing an operator staring
|
||||
# at a FAILSAFE banner tries, and a latch held only in memory comes back armed,
|
||||
# into the fault, unattended. A latch that is written down under a key nothing
|
||||
# reads any more is exactly the same failure with a longer paper trail: the file
|
||||
# still says `sign_tripped: true`, the banner is green, and the controller
|
||||
# resumes driving the wrong way.
|
||||
#
|
||||
# So this is a MIGRATION WITH A POSTCONDITION, not a copy. `migrate_unit_keys()`
|
||||
# reports what it did AND reads the result back; `Controller.__init__` grades
|
||||
# that report and refuses to come up quietly if the latch did not survive. The
|
||||
# test that grades it BREAKS the migration and watches the guard fire.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# 0 = the flat, one-inverter layout that shipped before DEV-02.
|
||||
# 1 = per-inverter keys under `unit/<name>/...`.
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_KEY = "schema_version"
|
||||
|
||||
# Which flat keys belong to ONE inverter, and therefore move into its namespace.
|
||||
# ⚠️ THE MAINTENANCE KEYS ARE NOT IN HERE AND THAT IS DELIBERATE. `phase`,
|
||||
# `phase_started`, `last_completed` and `last_start_attempt` belong to the
|
||||
# maintenance state machine, of which this add-on still holds exactly ONE - a
|
||||
# monthly recalibration is a pack procedure and per-pack SCHEDULING is a card of
|
||||
# its own, not something to infer from a key rename. Moving them here would
|
||||
# quietly re-home an install's whole maintenance history under a unit name; they
|
||||
# stay where they are, untouched, which is the other half of "migrates without
|
||||
# losing its latch OR ITS HISTORY".
|
||||
LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")
|
||||
|
||||
|
||||
def unit_key(unit: str, key: str) -> str:
|
||||
"""The namespaced Store key for `key` on inverter `unit`.
|
||||
|
||||
⚠️ ONE SPELLING, IN ONE PLACE. `Inverter.key()` calls this and so does the
|
||||
migration, so a check can assert that what was migrated is what is read.
|
||||
Two transcriptions of a key name is one copy plus a future disagreement
|
||||
about which one holds the latch - and the copy that drifts is always the one
|
||||
that reads.
|
||||
"""
|
||||
return f"unit/{unit}/{key}"
|
||||
|
||||
|
||||
class MigrationReport:
|
||||
"""What `migrate_unit_keys()` did, and whether it can be believed.
|
||||
|
||||
⚠️ IT CARRIES `verified`, WHICH IS READ BACK FROM THE STORE RATHER THAN
|
||||
ASSUMED FROM THE ASSIGNMENT. That is the difference between this and a
|
||||
two-line copy: the caller is able to ask "is the latch that was in the file
|
||||
before now under the key the watchdog reads", and a `False` there is a
|
||||
disarmed guard, which is an ERROR and not a log line nobody sees.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.ran = False # did anything need moving
|
||||
self.from_version = 0
|
||||
self.moved = {} # new key -> value written
|
||||
self.kept = [] # keys already namespaced, left alone
|
||||
self.verified = True # every move read back equal
|
||||
self.problems = [] # human-readable, one per failed move
|
||||
# ⚠️ THE SUBSET THAT MATTERS, AND THE DISTINCTION IS NOT PEDANTRY. A
|
||||
# store that drops a write of `sign_tripped=False` has lost the
|
||||
# DEFAULT - the same statement "absent" already makes, so nothing is
|
||||
# gone. One that drops a write of `True` has lost A LATCH. Only the
|
||||
# second is a disarmed safety guard, and only the second may be allowed
|
||||
# to shout: an alarm that fires on the harmless case is an alarm an
|
||||
# operator learns to dismiss before the real one arrives.
|
||||
self.lost = []
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""Nothing that CARRIED STATE failed to land."""
|
||||
return not self.lost
|
||||
|
||||
def explain(self) -> str:
|
||||
if not self.ran:
|
||||
return (f"store already at schema {self.from_version}; no "
|
||||
f"per-inverter migration needed")
|
||||
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
|
||||
f"namespaces: " + ", ".join(sorted(self.moved)))
|
||||
if self.kept:
|
||||
out += " (left alone, already namespaced: " + ", ".join(sorted(self.kept)) + ")"
|
||||
if self.problems:
|
||||
out += " - PROBLEMS: " + "; ".join(self.problems)
|
||||
if self.lost:
|
||||
out += " - STATE LOST: " + "; ".join(self.lost)
|
||||
return out
|
||||
|
||||
|
||||
def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
"""Move the pre-DEV-02 flat per-inverter keys into `unit`'s namespace.
|
||||
|
||||
`unit` is the FIRST inverter in the roster, which on every install that can
|
||||
possibly have a flat key is the only one it ever had. A store written by a
|
||||
version that knew about more than one inverter is already at
|
||||
SCHEMA_VERSION and is not touched.
|
||||
|
||||
THE THREE RULES, each one a case that would otherwise be a wrong answer:
|
||||
|
||||
1. IT NEVER OVERWRITES AN EXISTING NAMESPACED VALUE. A store that has both
|
||||
spellings has been written by this version, and the namespaced one is
|
||||
where the WATCHDOG is restored from. Preferring the flat one HERE would
|
||||
resurrect a trip the operator has already cleared, because a migration
|
||||
runs before anything can tell the two apart. Deciding between them is
|
||||
main.py's job and it has the one rule that can: the flat key carries new
|
||||
information only when NOTHING namespaced is latched.
|
||||
2. THE FLAT KEYS ARE LEFT IN THE FILE, AND THEY ARE KEPT LIVE. Deleting
|
||||
them buys tidiness and costs a rollback: an operator who downgrades the
|
||||
add-on after a migration would find no latch at all, which is the
|
||||
disarmed guard again in the other direction. Left in place, a downgrade
|
||||
finds the trip it had.
|
||||
|
||||
⚠️ AND "LEFT IN PLACE" IS NOT ENOUGH ON ITS OWN, WHICH IS WHAT THIS RULE
|
||||
USED TO SAY. The sentence here read "they go stale from this moment -
|
||||
nothing writes them again - and the direction that staleness fails in is
|
||||
a SPURIOUS latch, never a missing one". That was true of the latch this
|
||||
migration MOVES and false of every trip that happens AFTERWARDS: nothing
|
||||
flat was written for those, so a rollback found `sign_tripped: false` and
|
||||
came up DRIVING. So main.py mirrors both keys on a trip and clears both
|
||||
on the operator's clear. The flat pair is the ROLLBACK VIEW of the latch,
|
||||
maintained, not a fossil - which is also what lets main.py's
|
||||
`_grade_store_migration()` run on every start and catch a trip that
|
||||
happened while the install was rolled back (see its docstring: the flat
|
||||
key saying tripped while no inverter came up tripped is the one
|
||||
disagreement that can occur, and it is a DOWNGRADE-WINDOW LATCH).
|
||||
3. THE VERSION MARKER IS WRITTEN LAST. A crash between the copies and the
|
||||
marker leaves the migration to run again, and rule 1 makes running it
|
||||
again a no-op. A marker written first would leave a half-migrated store
|
||||
looking finished.
|
||||
"""
|
||||
rep = MigrationReport()
|
||||
data = getattr(store, "data", {})
|
||||
rep.from_version = int(data.get(SCHEMA_KEY, 0) or 0)
|
||||
if rep.from_version >= SCHEMA_VERSION:
|
||||
return rep
|
||||
|
||||
rep.ran = True
|
||||
for legacy in LEGACY_UNIT_KEYS:
|
||||
target = unit_key(unit, legacy)
|
||||
if target in data:
|
||||
rep.kept.append(target)
|
||||
continue
|
||||
# ⚠️ `DEFAULTS[legacy]` RATHER THAN A LITERAL. An install that never
|
||||
# tripped has no `sign_tripped` in its file at all, and the value it
|
||||
# then lands on has to be the one `Store.load()` would have supplied -
|
||||
# otherwise "absent" and "never tripped" stop being the same statement,
|
||||
# which is the distinction store.py's own header is about.
|
||||
value = data.get(legacy, DEFAULTS.get(legacy))
|
||||
store.set(target, value)
|
||||
rep.moved[target] = value
|
||||
|
||||
# THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION. This
|
||||
# is the difference between this function and a two-line copy: the caller
|
||||
# can ask "is the latch that was in the file before now under the key the
|
||||
# watchdog reads", through the same `unit_key()` the watchdog's constructor
|
||||
# reads through, and a `False` there is a guard that is off on an install
|
||||
# whose state file says it is on.
|
||||
for target, value in rep.moved.items():
|
||||
got = getattr(store, "data", {}).get(target, "<<missing>>")
|
||||
if got == value:
|
||||
continue
|
||||
rep.verified = False
|
||||
note = f"{target} was written as {value!r} and reads back as {got!r}"
|
||||
rep.problems.append(note)
|
||||
legacy = target.rsplit("/", 1)[-1]
|
||||
if value != DEFAULTS.get(legacy):
|
||||
rep.lost.append(note)
|
||||
|
||||
if rep.ok:
|
||||
store.set(SCHEMA_KEY, SCHEMA_VERSION)
|
||||
else:
|
||||
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
|
||||
# next start rather than be recorded as finished - the one thing worse
|
||||
# than a failed migration is a failed migration a later version skips.
|
||||
_LOG.error("per-inverter store migration LOST STATE and has not been "
|
||||
"marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: str = "/data/state.json"):
|
||||
@@ -42,8 +277,10 @@ class Store:
|
||||
loaded = json.load(fh)
|
||||
if isinstance(loaded, dict):
|
||||
self.data = {**DEFAULTS, **loaded}
|
||||
_LOG.info("state restored: phase=%s last_completed=%s",
|
||||
self.data.get("phase"), self.data.get("last_completed"))
|
||||
_LOG.info("state restored: phase=%s last_completed=%s "
|
||||
"sign_tripped=%s",
|
||||
self.data.get("phase"), self.data.get("last_completed"),
|
||||
self.data.get("sign_tripped"))
|
||||
except FileNotFoundError:
|
||||
_LOG.info("no saved state, starting fresh")
|
||||
except (json.JSONDecodeError, OSError) as err:
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
"""TARIFF-01 - the monthly capacity-tariff peak, and the record that outlives it.
|
||||
|
||||
WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT. This module is an INSTRUMENT. It
|
||||
measures the number the Belgian capaciteitstarief bills on - the highest
|
||||
15-minute average offtake of the calendar month (Fluvius / Resa / Ores) - and it
|
||||
writes that number down somewhere a restart cannot reach. It makes no claim,
|
||||
applies no clamp, and commands nothing. The guard that will read it is SAFETY-07,
|
||||
and it belongs in `arbiter.py` as a `P_PEAK` claim when it is built. Keeping the
|
||||
measurement and the actuation apart is the whole reason this file is small.
|
||||
|
||||
WHAT WAS ALREADY BUILT, AND WHY THIS CARD IS NOT 8 SP OF NEW ARITHMETIC.
|
||||
`p1.QuarterAverager` already computes AC 1 in full: clock-aligned 900 s blocks,
|
||||
time-weighted over irregular telegram spacing, offtake-only, per-phase, with a
|
||||
`max_hold_s` gap rule. It was unwired and unpersisted - `P1Ingest.blocks` is a
|
||||
96-entry list in memory that a restart erases. So this file is the persistence
|
||||
and the monthly reduction, not a second accumulator. Adding one would be adding
|
||||
a second source of truth for the exact quantity the money depends on.
|
||||
|
||||
BLOCKS, NOT A SLIDING WINDOW. The Description says "rolling"; AC 1 says
|
||||
"15-min block average". They are different numbers and the utility bills the
|
||||
second one. §9.1 calls the billed unit the "highest 15-minute average offtake",
|
||||
and §9.1's own worked case - "one 4998 W quarter moved a monthly peak from
|
||||
4832 -> 5013 W" - is stated per QUARTER, an object a sliding window does not
|
||||
have. "Rolling" here means CONTINUOUSLY UPDATED (the open block's partial
|
||||
average is readable at every sample), not a trailing-15-minutes window. A
|
||||
trailing window would report a peak the meter never bills, and it reports it
|
||||
HIGHER than the real one: a load burst straddling :15 fills a trailing window
|
||||
completely while contributing to two block averages at partial weight. Guarding
|
||||
that would spend battery defending an interval nobody charges for.
|
||||
|
||||
DELIVERED POWER, NOT INTENDED POWER. SAFETY-20 has an open question about
|
||||
whether guards should grade `Decision.target` (pre-clamp intent) or `standing`
|
||||
(what actually reached the wire). For a capacity tariff there is no question to
|
||||
ask: the utility's meter integrates the current that flowed. This module never
|
||||
sees a setpoint at all - it consumes `P1Sample`, which is the meter's own
|
||||
reading - and that is on purpose. A peak computed from intent would be wrong in
|
||||
the expensive direction (a clamped 9 kW request that delivered 5 kW would
|
||||
record 9 kW, and every consumer would shave against a peak that never existed).
|
||||
|
||||
AN UNREADABLE STORE IS NOT A ZERO PEAK. This is the defect this repo spent a
|
||||
day removing from `sim/riglock.py`, where `holder()` was rewritten to answer
|
||||
`observed / not_observed / gone` instead of a partial dict every caller
|
||||
`.get()`s a default out of. The same trap is here and it is worse, because the
|
||||
degenerate value is not merely wrong, it is DISARMING: a peak read as 0 W gives
|
||||
SAFETY-07 the maximum possible headroom and silently switches the guard off at
|
||||
the moment its own storage is broken. So `peak()` returns a `PeakReading` whose
|
||||
`state` is one of OBSERVED / NO_RECORD / NOT_OBSERVED, `peak_w` is `None`
|
||||
unless the state is OBSERVED, and there is no `.get(..., 0)` shaped accessor
|
||||
anywhere on it. NO_RECORD ("this month has no completed block yet", a normal
|
||||
state on the 1st) and NOT_OBSERVED ("the database did not answer") are
|
||||
different types of thing and are never spelled the same way.
|
||||
|
||||
CONFIG: NONE ADDED, ON PURPOSE. CFG-03 is open because the option defaults
|
||||
already live in three places held apart by a tripwire in `test_validate.py`. The
|
||||
soft limit this measurement is graded against already exists as `peak_cap_w`
|
||||
(config.yaml, default 3500), already validated, already read by
|
||||
`control.peak_at_risk()`. §14 names a `tariffs.capacity_peak_limit_kw: 2.5` - in
|
||||
KILOwatts, against watts everywhere else - and adding it would have made a fourth
|
||||
defaults site AND imported the unit mismatch §14's own warning box says must be
|
||||
caught at startup. So this module takes its limit as a plain constructor
|
||||
argument in WATTS and config.yaml is untouched by this ticket.
|
||||
|
||||
THE DATABASE. SQLite in /data, the add-on's only durable volume, alongside
|
||||
`store.py`'s state.json. Two tiers from §15, and their retention is the point:
|
||||
|
||||
quarter_blocks one row per completed 900 s block INDEFINITE
|
||||
monthly_peak one row per calendar month PERMANENT
|
||||
|
||||
Neither is ever pruned here, and no sweep in this file can prune them - there is
|
||||
no DELETE statement against either table anywhere in this module. §15's asymmetry
|
||||
is why: raw samples vanish after 7 days, so a 15-minute aggregate that was not
|
||||
written AS IT HAPPENED can never be recomputed. `monthly_peak` is maintained as a
|
||||
derived record - upserted on every block close, and `recompute_month()` re-derives
|
||||
it from `quarter_blocks` so the two can be PROVEN not to have diverged rather
|
||||
than assumed not to have.
|
||||
|
||||
RESTART MID-BLOCK. The open block's accumulator is memory. A restart at minute 12
|
||||
that dropped it would under-read the rest of the quarter, and SAFETY-07's clamp
|
||||
would release exactly when it was most needed. So the accumulator is checkpointed
|
||||
(`QuarterAverager.snapshot()`) into a single row, at most `CHECKPOINT_S` stale,
|
||||
and restored on startup. Three cases, all of them exercised in `test_tariff.py`:
|
||||
|
||||
* the checkpoint's block is the block containing `now` -> restore it verbatim,
|
||||
including the held sample's ORIGINAL timestamp, so the first sample after the
|
||||
restart integrates the gap under the same `max_hold_s` rule a running process
|
||||
would have applied. A restart longer than `max_hold_s` therefore leaves
|
||||
uncovered seconds, and a closed block is always divided by the full 900 s, so
|
||||
the gap DRAGS THE BILLED AVERAGE DOWN instead of being averaged away;
|
||||
* the checkpoint's block ended while we were down -> that block really happened
|
||||
and its measured part is real, so it is closed at `acc / 900` and recorded,
|
||||
then a fresh block is opened. Discarding it would lose measured energy;
|
||||
* no checkpoint, or one that cannot be read -> start clean and SAY SO. The
|
||||
return value of `restore()` names which of the three happened; it is never
|
||||
silently the first one.
|
||||
|
||||
Double counting is answered structurally rather than by care: `quarter_start` is
|
||||
the PRIMARY KEY, and the recorded average may only ever be RAISED by a later
|
||||
write (`ON CONFLICT ... WHERE excluded.offtake_avg_w > ...`). Replaying the same
|
||||
block twice is therefore a no-op, and a restart that reprocesses a block with
|
||||
LESS coverage than the first pass cannot lower a figure already banked.
|
||||
|
||||
MONTH BOUNDARY. Calendar months are LOCAL (Europe/Brussels); blocks are stored in
|
||||
UTC. In August those differ by two hours, so a block starting 2026-08-31 22:15
|
||||
UTC belongs to SEPTEMBER and putting it in August would move a month's bill.
|
||||
Block alignment itself needs no timezone - every Belgian offset is a whole number
|
||||
of hours, so the 900 s UTC grid lands on :00/:15/:30/:45 local on both sides of a
|
||||
DST transition, which is the argument `QuarterAverager` already carries.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone, tzinfo
|
||||
|
||||
_LOG = logging.getLogger("goodwe.tariff")
|
||||
|
||||
QUARTER_S = 900
|
||||
|
||||
# How stale the partial-block checkpoint may be. This is the ONLY thing a crash
|
||||
# can lose from an open block, and the loss is bounded and one-directional: the
|
||||
# lost seconds were already integrated into `_acc`, so dropping them makes the
|
||||
# restored block read LOW, never high. At 15 s against a 900 s block the
|
||||
# worst-case under-read of one quarter's average is 15/900 = 1.7 % of that
|
||||
# quarter's own offtake - see test_tariff.py, which computes that bound from
|
||||
# these two constants rather than quoting it.
|
||||
CHECKPOINT_S = 15.0
|
||||
|
||||
# -- reading states. Three, and never fewer. -------------------------------- #
|
||||
OBSERVED = "observed" # the store answered and there is a peak
|
||||
NO_RECORD = "no_record" # the store answered and this month has none yet
|
||||
NOT_OBSERVED = "not_observed" # the store did not answer. NOT a zero peak.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# local time, without a hard dependency on the tz database
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _EUTimezone(tzinfo):
|
||||
"""Europe/Brussels by the EU harmonised rule, for hosts with no tzdata.
|
||||
|
||||
⚠️ THIS IS A FALLBACK AND IT IS NOT SILENT. `zoneinfo` is tried first and is
|
||||
what runs on the add-on's container. The fallback exists because the
|
||||
alternative - quietly using UTC when tzdata is missing - misfiles every block
|
||||
in the last two hours of a month into the wrong month's bill, and does it
|
||||
without a symptom. Encoding the rule (CET, CEST from 01:00 UTC on the last
|
||||
Sunday of March to 01:00 UTC on the last Sunday of October) is 20 lines and
|
||||
is checked against zoneinfo, date by date, wherever zoneinfo is available.
|
||||
"""
|
||||
|
||||
# The rule, in both frames. `fromutc` is overridden rather than inherited
|
||||
# because the base implementation calls back into `utcoffset` with a
|
||||
# datetime whose tzinfo is self - and the first version of this class
|
||||
# answered that by calling `astimezone`, which calls `utcoffset`, which
|
||||
# recursed until the stack ran out. A tzinfo must answer from the value it
|
||||
# is handed, never by converting it.
|
||||
@staticmethod
|
||||
def _last_sunday(year: int, month: int, hour: int) -> datetime:
|
||||
d = datetime(year, month + 1, 1) - timedelta(days=1)
|
||||
return (d - timedelta(days=(d.weekday() + 1) % 7)).replace(hour=hour)
|
||||
|
||||
def _dst_local(self, naive: datetime) -> bool:
|
||||
"""From a LOCAL wall clock: 02:00 CET last Sunday March -> 03:00 CEST
|
||||
last Sunday October. The one ambiguous hour each autumn resolves to
|
||||
standard time, which cannot move a month boundary."""
|
||||
y = naive.year
|
||||
return self._last_sunday(y, 3, 2) <= naive < self._last_sunday(y, 10, 3)
|
||||
|
||||
def _dst_utc(self, naive_utc: datetime) -> bool:
|
||||
"""From UTC: both EU transitions happen at 01:00 UTC, everywhere."""
|
||||
y = naive_utc.year
|
||||
return self._last_sunday(y, 3, 1) <= naive_utc < self._last_sunday(y, 10, 1)
|
||||
|
||||
def fromutc(self, dt: datetime) -> datetime:
|
||||
return dt + timedelta(hours=2 if self._dst_utc(dt.replace(tzinfo=None)) else 1)
|
||||
|
||||
def utcoffset(self, dt):
|
||||
if dt is None:
|
||||
return timedelta(hours=1)
|
||||
return timedelta(hours=2 if self._dst_local(dt.replace(tzinfo=None)) else 1)
|
||||
|
||||
def dst(self, dt):
|
||||
if dt is None:
|
||||
return timedelta(0)
|
||||
return timedelta(hours=1 if self._dst_local(dt.replace(tzinfo=None)) else 0)
|
||||
|
||||
def tzname(self, dt):
|
||||
return "CEST" if dt is not None and self._dst_local(dt.replace(tzinfo=None)) else "CET"
|
||||
|
||||
|
||||
def _billing_tz():
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
tz = ZoneInfo("Europe/Brussels")
|
||||
# Prove it resolves before trusting it: a ZoneInfo built from an empty
|
||||
# tzdata answers, and answering wrongly is the failure being avoided.
|
||||
if datetime(2026, 7, 1, tzinfo=timezone.utc).astimezone(tz).hour == 2:
|
||||
return tz
|
||||
_LOG.warning("zoneinfo Europe/Brussels resolved but gave the wrong "
|
||||
"summer offset - falling back to the encoded EU rule")
|
||||
except Exception as err: # noqa: BLE001 - any failure
|
||||
_LOG.info("no usable tz database (%s) - using the encoded EU rule", err)
|
||||
return _EUTimezone()
|
||||
|
||||
|
||||
BILLING_TZ = _billing_tz()
|
||||
|
||||
|
||||
def month_key(when: datetime) -> str:
|
||||
"""The calendar month a UTC instant is billed in, as 'YYYY-MM' LOCAL time."""
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=timezone.utc)
|
||||
local = when.astimezone(BILLING_TZ)
|
||||
return f"{local.year:04d}-{local.month:02d}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the per-phase reduction policy - one function, deliberately
|
||||
# --------------------------------------------------------------------------- #
|
||||
# §9.1 requires the average be computed per-phase-aware, but does NOT say whether
|
||||
# the BILLED figure on a three-phase connection is the sum of the phases or the
|
||||
# worst single phase. §5.2 requires the per-phase data; nothing states the
|
||||
# reduction. So it is one function with one argument, and correcting it later is
|
||||
# a one-line change plus a test parameter rather than a change to the
|
||||
# accumulator. §17's "Three-phase unbalanced load" scenario is the case that
|
||||
# tells the three apart - on a balanced load all three agree, which is exactly
|
||||
# why a balanced fixture must not be what this is tested on.
|
||||
POLICY_NET = "net" # default: the connection's net offtake, what the meter bills
|
||||
POLICY_SUM = "sum" # sum of the per-phase offtakes (differs when a phase exports)
|
||||
POLICY_MAX = "max" # the worst single phase
|
||||
|
||||
|
||||
def billed_w(block, policy: str = POLICY_NET) -> float:
|
||||
"""Reduce one closed block to the single figure the month is billed on."""
|
||||
pp = getattr(block, "per_phase_offtake_avg_w", None)
|
||||
if policy == POLICY_NET or not pp:
|
||||
return float(block.offtake_avg_w)
|
||||
if policy == POLICY_SUM:
|
||||
return float(sum(pp))
|
||||
if policy == POLICY_MAX:
|
||||
return float(max(pp))
|
||||
raise ValueError(f"unknown per-phase reduction policy: {policy!r}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# what a read of the store answers
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class PeakReading:
|
||||
"""An answer about a month's peak, or an honest refusal to give one.
|
||||
|
||||
⚠️ There is no `peak_w_or(default)` and there must never be one. Every
|
||||
accessor that lets a caller turn "the database did not answer" into a number
|
||||
re-creates the defect this type exists to remove. Callers ask `is_observed`
|
||||
first, and a caller that forgets gets `None` and a TypeError, which is loud.
|
||||
"""
|
||||
|
||||
state: str
|
||||
month: str
|
||||
peak_w: float | None = None
|
||||
quarter_start: datetime | None = None
|
||||
per_phase_w: tuple | None = None
|
||||
detail: str = ""
|
||||
|
||||
@property
|
||||
def is_observed(self) -> bool:
|
||||
return self.state == OBSERVED
|
||||
|
||||
@property
|
||||
def is_unreadable(self) -> bool:
|
||||
return self.state == NOT_OBSERVED
|
||||
|
||||
def explain(self) -> str:
|
||||
if self.state == OBSERVED:
|
||||
at = self.quarter_start.isoformat() if self.quarter_start else "?"
|
||||
return f"{self.month}: peak {self.peak_w:.0f} W, set by the quarter at {at}"
|
||||
if self.state == NO_RECORD:
|
||||
return f"{self.month}: no completed quarter recorded yet"
|
||||
return (f"{self.month}: PEAK UNREADABLE ({self.detail}) - this is NOT a "
|
||||
f"zero peak and must not be used as headroom")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the store
|
||||
# --------------------------------------------------------------------------- #
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS quarter_blocks (
|
||||
quarter_start TEXT PRIMARY KEY, -- ISO-8601 UTC, aligned to 900 s
|
||||
month TEXT NOT NULL, -- 'YYYY-MM', LOCAL calendar month
|
||||
offtake_avg_w REAL NOT NULL,
|
||||
per_phase_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_quarter_month ON quarter_blocks(month);
|
||||
CREATE TABLE IF NOT EXISTS monthly_peak (
|
||||
month TEXT PRIMARY KEY,
|
||||
peak_w REAL NOT NULL,
|
||||
quarter_start TEXT NOT NULL,
|
||||
per_phase_json TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS partial_block (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
state_json TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class PeakStore:
|
||||
"""SQLite behind /data. Every read answers with a state, never with a default."""
|
||||
|
||||
def __init__(self, path: str = "/data/tariff.db"):
|
||||
self.path = path
|
||||
self.ok = False
|
||||
self.last_error: str | None = None
|
||||
self._db: sqlite3.Connection | None = None
|
||||
self._open()
|
||||
|
||||
def _open(self) -> None:
|
||||
parent = os.path.dirname(self.path)
|
||||
# ⚠️ THE DIRECTORY IS NOT CREATED. /data is mounted by the Supervisor and
|
||||
# always exists on a real install, so `makedirs` here could only ever
|
||||
# succeed where the durable volume is MISSING - manufacturing a database
|
||||
# on the container's ephemeral layer that looks durable, reads fine, and
|
||||
# is thrown away on the next restart. That is a peak store that silently
|
||||
# forgets, which is worse than one that says it cannot answer.
|
||||
if parent and not os.path.isdir(parent):
|
||||
self.last_error = f"no durable volume at {parent}"
|
||||
# INFO, not ERROR: on a dev host or in a test container there is no
|
||||
# /data and nothing is wrong. A store that OPENED and then broke is
|
||||
# the error case, and it is logged as one below.
|
||||
_LOG.info("no peak store: %s - the monthly peak will read "
|
||||
"NOT_OBSERVED", self.last_error)
|
||||
return
|
||||
try:
|
||||
self._db = sqlite3.connect(self.path, timeout=5.0,
|
||||
check_same_thread=False)
|
||||
# WAL: a power cut mid-write must not leave a torn database that
|
||||
# reads as "no peak this month" - the exact shape of the defect.
|
||||
self._db.execute("PRAGMA journal_mode=WAL")
|
||||
self._db.executescript(_SCHEMA)
|
||||
self._db.commit()
|
||||
self.ok = True
|
||||
self.last_error = None
|
||||
except sqlite3.Error as err:
|
||||
self._db = None
|
||||
self.ok = False
|
||||
self.last_error = str(err)
|
||||
# ⚠️ Deliberately NOT raised. §6's fail-open rule: an aggregation
|
||||
# fault must not stop a controller that is regulating a battery. The
|
||||
# cost of continuing is a peak that reads NOT_OBSERVED, and every
|
||||
# reader is built to treat that as "do not use me", not as 0 W.
|
||||
_LOG.error("peak store unusable at %s (%s) - the monthly peak will "
|
||||
"read NOT_OBSERVED, which is not a zero peak", self.path, err)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._db is not None:
|
||||
try:
|
||||
self._db.close()
|
||||
finally:
|
||||
self._db = None
|
||||
self.ok = False
|
||||
|
||||
# -- writing ------------------------------------------------------------
|
||||
def record_block(self, block, policy: str = POLICY_NET) -> bool:
|
||||
"""Persist one completed block and fold it into its month's peak.
|
||||
|
||||
Both tables move in ONE transaction. A crash between them would leave a
|
||||
banked quarter that the monthly peak does not know about, and the peak is
|
||||
the record the money is read off.
|
||||
"""
|
||||
if self._db is None:
|
||||
return False
|
||||
w = billed_w(block, policy)
|
||||
start = block.start.astimezone(timezone.utc).isoformat()
|
||||
month = month_key(block.start)
|
||||
pp = getattr(block, "per_phase_offtake_avg_w", None)
|
||||
pp_json = json.dumps(list(pp)) if pp else None
|
||||
try:
|
||||
with self._db:
|
||||
# A block may only ever be RAISED. A restart that reprocesses a
|
||||
# quarter it had already banked will have LESS coverage than the
|
||||
# first pass (its first seconds are gone), so an unconditional
|
||||
# REPLACE would silently lower a recorded figure.
|
||||
self._db.execute(
|
||||
"INSERT INTO quarter_blocks(quarter_start, month, offtake_avg_w, per_phase_json)"
|
||||
" VALUES(?,?,?,?)"
|
||||
" ON CONFLICT(quarter_start) DO UPDATE SET"
|
||||
" offtake_avg_w=excluded.offtake_avg_w,"
|
||||
" per_phase_json=excluded.per_phase_json"
|
||||
" WHERE excluded.offtake_avg_w > quarter_blocks.offtake_avg_w",
|
||||
(start, month, w, pp_json))
|
||||
self._db.execute(
|
||||
"INSERT INTO monthly_peak(month, peak_w, quarter_start, per_phase_json)"
|
||||
" VALUES(?,?,?,?)"
|
||||
" ON CONFLICT(month) DO UPDATE SET"
|
||||
" peak_w=excluded.peak_w,"
|
||||
" quarter_start=excluded.quarter_start,"
|
||||
" per_phase_json=excluded.per_phase_json"
|
||||
" WHERE excluded.peak_w > monthly_peak.peak_w",
|
||||
(month, w, start, pp_json))
|
||||
return True
|
||||
except sqlite3.Error as err:
|
||||
self.last_error = str(err)
|
||||
_LOG.error("could not record quarter %s (%s)", start, err)
|
||||
return False
|
||||
|
||||
# -- reading ------------------------------------------------------------
|
||||
def peak(self, when: datetime | None = None, month: str | None = None) -> PeakReading:
|
||||
"""The billed peak for a month. Three possible answers, never a default."""
|
||||
if month is None:
|
||||
month = month_key(when or datetime.now(timezone.utc))
|
||||
if self._db is None:
|
||||
return PeakReading(NOT_OBSERVED, month,
|
||||
detail=self.last_error or "store never opened")
|
||||
try:
|
||||
row = self._db.execute(
|
||||
"SELECT peak_w, quarter_start, per_phase_json FROM monthly_peak"
|
||||
" WHERE month=?", (month,)).fetchone()
|
||||
except sqlite3.Error as err:
|
||||
self.last_error = str(err)
|
||||
return PeakReading(NOT_OBSERVED, month, detail=str(err))
|
||||
if row is None:
|
||||
return PeakReading(NO_RECORD, month)
|
||||
pp = tuple(json.loads(row[2])) if row[2] else None
|
||||
return PeakReading(OBSERVED, month, peak_w=float(row[0]),
|
||||
quarter_start=datetime.fromisoformat(row[1]),
|
||||
per_phase_w=pp)
|
||||
|
||||
def recompute_month(self, month: str) -> PeakReading:
|
||||
"""Re-derive the peak from `quarter_blocks` - the divergence check.
|
||||
|
||||
`monthly_peak` is a derived record, so it can be PROVEN equal to the
|
||||
tier it derives from instead of trusted to be. Read-only.
|
||||
"""
|
||||
if self._db is None:
|
||||
return PeakReading(NOT_OBSERVED, month,
|
||||
detail=self.last_error or "store never opened")
|
||||
try:
|
||||
row = self._db.execute(
|
||||
"SELECT offtake_avg_w, quarter_start, per_phase_json FROM quarter_blocks"
|
||||
" WHERE month=? ORDER BY offtake_avg_w DESC, quarter_start ASC LIMIT 1",
|
||||
(month,)).fetchone()
|
||||
except sqlite3.Error as err:
|
||||
self.last_error = str(err)
|
||||
return PeakReading(NOT_OBSERVED, month, detail=str(err))
|
||||
if row is None:
|
||||
return PeakReading(NO_RECORD, month)
|
||||
pp = tuple(json.loads(row[2])) if row[2] else None
|
||||
return PeakReading(OBSERVED, month, peak_w=float(row[0]),
|
||||
quarter_start=datetime.fromisoformat(row[1]),
|
||||
per_phase_w=pp)
|
||||
|
||||
def blocks_in(self, month: str) -> list:
|
||||
"""(quarter_start, offtake_avg_w) for a month, oldest first. [] on error
|
||||
is safe here ONLY because no caller reads emptiness as a peak - AC 3's
|
||||
counter reports the sample size it actually got."""
|
||||
if self._db is None:
|
||||
return []
|
||||
try:
|
||||
return [(datetime.fromisoformat(a), float(b)) for a, b in self._db.execute(
|
||||
"SELECT quarter_start, offtake_avg_w FROM quarter_blocks"
|
||||
" WHERE month=? ORDER BY quarter_start", (month,))]
|
||||
except sqlite3.Error as err:
|
||||
self.last_error = str(err)
|
||||
return []
|
||||
|
||||
# -- the open block's checkpoint ----------------------------------------
|
||||
def save_partial(self, state: dict) -> bool:
|
||||
if self._db is None:
|
||||
return False
|
||||
try:
|
||||
with self._db:
|
||||
self._db.execute(
|
||||
"INSERT INTO partial_block(id, state_json) VALUES(1,?)"
|
||||
" ON CONFLICT(id) DO UPDATE SET state_json=excluded.state_json",
|
||||
(json.dumps(state),))
|
||||
return True
|
||||
except (sqlite3.Error, TypeError, ValueError) as err:
|
||||
self.last_error = str(err)
|
||||
return False
|
||||
|
||||
def load_partial(self) -> dict | None:
|
||||
if self._db is None:
|
||||
return None
|
||||
try:
|
||||
row = self._db.execute(
|
||||
"SELECT state_json FROM partial_block WHERE id=1").fetchone()
|
||||
return json.loads(row[0]) if row else None
|
||||
except (sqlite3.Error, ValueError) as err:
|
||||
self.last_error = str(err)
|
||||
return None
|
||||
|
||||
def clear_partial(self) -> None:
|
||||
# The one DELETE in this module, and it is against the checkpoint - a
|
||||
# scratch row, not a retention tier. Neither quarter_blocks nor
|
||||
# monthly_peak is deleted from anywhere in this file.
|
||||
if self._db is None:
|
||||
return
|
||||
try:
|
||||
with self._db:
|
||||
self._db.execute("DELETE FROM partial_block WHERE id=1")
|
||||
except sqlite3.Error as err:
|
||||
self.last_error = str(err)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the tracker: what main.py holds
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class Projection:
|
||||
"""The open block's end-of-quarter estimate, with the reasons to distrust it.
|
||||
|
||||
`elapsed_s` travels WITH the number rather than beside it, because early in a
|
||||
quarter a projection is a 10-second extrapolation over 900 s and a consumer
|
||||
that cannot see that will react to noise. `covered_frac` is separately useful:
|
||||
it is below 1.0 when the meter had a gap, which is the "unreliable quarter"
|
||||
the functional note asks be marked rather than reported low.
|
||||
"""
|
||||
|
||||
projected_w: float
|
||||
elapsed_w: float
|
||||
elapsed_s: float
|
||||
covered_frac: float
|
||||
complete: bool
|
||||
block_start: datetime | None
|
||||
held_w: float
|
||||
|
||||
def explain(self) -> str:
|
||||
return (f"quarter projects {self.projected_w:.0f} W "
|
||||
f"({self.elapsed_s:.0f} s elapsed, {self.covered_frac * 100:.0f} % covered"
|
||||
+ ("" if self.complete else ", INCOMPLETE") + ")")
|
||||
|
||||
|
||||
class PeakTracker:
|
||||
"""Drives the store from `P1Ingest`. Measures; never commands."""
|
||||
|
||||
def __init__(self, store: PeakStore, cap_w: float = 3500.0,
|
||||
policy: str = POLICY_NET, checkpoint_s: float = CHECKPOINT_S):
|
||||
self.store = store
|
||||
self.cap_w = float(cap_w)
|
||||
self.policy = policy
|
||||
self.checkpoint_s = float(checkpoint_s)
|
||||
self.recorded = 0
|
||||
self.restore_state = "not attempted"
|
||||
self._seen: set = set()
|
||||
self._last_ckpt = 0.0
|
||||
|
||||
# -- startup ------------------------------------------------------------
|
||||
def restore(self, averager, now: datetime | None = None) -> str:
|
||||
"""Put the open block back. Returns which of the three cases happened."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
snap = self.store.load_partial()
|
||||
if not snap:
|
||||
self.restore_state = ("fresh: no checkpoint" if self.store.ok
|
||||
else "fresh: store unreadable")
|
||||
return self.restore_state
|
||||
block = snap.get("block")
|
||||
if block is None:
|
||||
self.restore_state = "fresh: checkpoint had no open block"
|
||||
return self.restore_state
|
||||
|
||||
current = int(now.timestamp() // QUARTER_S) * QUARTER_S
|
||||
if block == current:
|
||||
if not averager.restore(snap):
|
||||
# A reshaped accumulator (meter_phases changed) is a different
|
||||
# measurement, not a resumable one. Say so; do not half-resume.
|
||||
self.store.clear_partial()
|
||||
self.restore_state = "fresh: checkpoint does not fit this averager"
|
||||
return self.restore_state
|
||||
self.restore_state = (
|
||||
f"resumed the open block at {snap.get('elapsed', 0.0):.0f} s "
|
||||
f"of {QUARTER_S} s ({snap.get('acc', 0.0) / QUARTER_S:.0f} W banked)")
|
||||
return self.restore_state
|
||||
|
||||
# The block ended while the add-on was down. Its measured part really
|
||||
# happened, so bank it at acc/900 rather than discard measured energy.
|
||||
# It reads LOW because the seconds after the shutdown were never covered,
|
||||
# and that is the honest figure: coverage a closed block did not get.
|
||||
closed = _ClosedBlock(
|
||||
start=datetime.fromtimestamp(block, timezone.utc),
|
||||
offtake_avg_w=float(snap.get("acc", 0.0)) / QUARTER_S,
|
||||
per_phase_offtake_avg_w=(tuple(v / QUARTER_S for v in snap["pp_acc"])
|
||||
if snap.get("pp_acc") and snap.get("last_pp") else None))
|
||||
banked = self.store.record_block(closed, self.policy)
|
||||
self.store.clear_partial()
|
||||
self.restore_state = (
|
||||
f"closed a stale block from {closed.start.isoformat()} at "
|
||||
f"{closed.offtake_avg_w:.0f} W (partial coverage), "
|
||||
+ ("banked" if banked else "NOT banked - store unwritable"))
|
||||
return self.restore_state
|
||||
|
||||
# -- steady state -------------------------------------------------------
|
||||
def sync(self, ingest, now_mono: float | None = None) -> int:
|
||||
"""Bank any blocks that closed, and refresh the checkpoint. Returns the
|
||||
number of blocks newly recorded on this call."""
|
||||
import time as _t
|
||||
n = 0
|
||||
for block in getattr(ingest, "blocks", ()):
|
||||
key = block.start.timestamp()
|
||||
if key in self._seen:
|
||||
continue
|
||||
if self.store.record_block(block, self.policy):
|
||||
self._seen.add(key)
|
||||
self.recorded += 1
|
||||
n += 1
|
||||
if n:
|
||||
# A block just closed, so the checkpoint's block is gone: clearing it
|
||||
# in the same breath is what stops a crash here from replaying it.
|
||||
self.store.clear_partial()
|
||||
self._last_ckpt = 0.0
|
||||
self.checkpoint(ingest, now_mono or _t.monotonic())
|
||||
return n
|
||||
|
||||
def checkpoint(self, ingest, now_mono: float, force: bool = False) -> bool:
|
||||
if not force and (now_mono - self._last_ckpt) < self.checkpoint_s:
|
||||
return False
|
||||
avg = getattr(ingest, "averager", None)
|
||||
if avg is None:
|
||||
return False
|
||||
self._last_ckpt = now_mono
|
||||
return self.store.save_partial(avg.snapshot())
|
||||
|
||||
# -- reading ------------------------------------------------------------
|
||||
def peak(self, when: datetime | None = None) -> PeakReading:
|
||||
return self.store.peak(when)
|
||||
|
||||
def headroom_w(self, when: datetime | None = None) -> float | None:
|
||||
"""Watts left under the soft limit, or None when it cannot be known.
|
||||
|
||||
⚠️ `None`, not `self.cap_w`. An unreadable peak returning full headroom
|
||||
is the disarming default this whole module is shaped against.
|
||||
"""
|
||||
r = self.peak(when)
|
||||
if r.state == NO_RECORD:
|
||||
return self.cap_w
|
||||
if not r.is_observed:
|
||||
return None
|
||||
return max(0.0, self.cap_w - r.peak_w)
|
||||
|
||||
def warning_frac(self, when: datetime | None = None) -> float | None:
|
||||
"""Peak as a fraction of the soft limit - §9.1's 80 % warning entity."""
|
||||
r = self.peak(when)
|
||||
if r.state == NO_RECORD:
|
||||
return 0.0
|
||||
if not r.is_observed or self.cap_w <= 0:
|
||||
return None
|
||||
return r.peak_w / self.cap_w
|
||||
|
||||
@staticmethod
|
||||
def project(averager, now: datetime) -> Projection:
|
||||
"""End-of-quarter average if the last sample held for the rest of it.
|
||||
|
||||
⚠️ The two divisors are DIFFERENT and only coincide at the end of the
|
||||
block. `elapsed_w` divides by the seconds actually covered (what the
|
||||
meter has shown so far); `projected_w` divides by the full 900 s and
|
||||
credits the held value over what remains. At `elapsed_s == 900` and full
|
||||
coverage they are equal - which is precisely why nothing may be tested
|
||||
only at that point.
|
||||
"""
|
||||
start = averager.block_start
|
||||
if start is None:
|
||||
# ⚠️ A ZERO PROJECTION, AND IT IS NOT "THE QUARTER IS FINE". It is
|
||||
# "there is no quarter". `block_start is None` is the flag that says
|
||||
# which, and SAFETY-07 grades that flag BEFORE it grades the number -
|
||||
# see peakguard.blindness(). A consumer that reads only
|
||||
# `projected_w` here reads 0 W, which is under every ceiling there
|
||||
# is, on exactly the startup path where nothing else is watching.
|
||||
return Projection(0.0, 0.0, 0.0, 0.0, False, None, 0.0)
|
||||
elapsed_wall = max(0.0, (now - start).total_seconds())
|
||||
elapsed = averager.elapsed_s
|
||||
acc = averager.partial_ws
|
||||
remaining = max(0.0, QUARTER_S - elapsed_wall)
|
||||
# ⚠️ NO `getattr` DEFAULT, AND THAT IS THE FIX, NOT A TIDY-UP. This read
|
||||
# was `getattr(averager, "_last_net", 0.0)`: a private name with a
|
||||
# fallback, so renaming the attribute would have kept every projection
|
||||
# green while crediting 0 W held - a projection that reads LOW, which is
|
||||
# the UNDER-GUARDING direction for SAFETY-07's floor. TEST-13 item 4.
|
||||
# `held_net_w` is now a documented public property on QuarterAverager and
|
||||
# is read without a default, so the same rename raises AttributeError on
|
||||
# the first cycle instead of quietly disarming the money guard.
|
||||
held = max(averager.held_net_w, 0.0)
|
||||
return Projection(
|
||||
projected_w=(acc + held * remaining) / QUARTER_S,
|
||||
elapsed_w=(acc / elapsed) if elapsed > 0 else 0.0,
|
||||
elapsed_s=elapsed_wall,
|
||||
covered_frac=(elapsed / elapsed_wall) if elapsed_wall > 0 else 0.0,
|
||||
complete=elapsed_wall <= 0 or elapsed >= elapsed_wall - 1e-9,
|
||||
block_start=start,
|
||||
# Carried so a consumer can see WHAT was extrapolated, not only the
|
||||
# extrapolation. It is the signed held value; `projected_w` uses the
|
||||
# offtake-only form above, and the two differ whenever the site is
|
||||
# exporting - which is the healthy operating point here.
|
||||
held_w=averager.held_net_w)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ClosedBlock:
|
||||
"""A block reconstructed from a checkpoint. Shaped like `p1.QuarterBlock` so
|
||||
`record_block` has one input type; kept private so nothing else makes one."""
|
||||
|
||||
start: datetime
|
||||
offtake_avg_w: float
|
||||
per_phase_offtake_avg_w: tuple | None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,356 @@
|
||||
"""SAFETY-02 - the sign-disagreement watchdog.
|
||||
|
||||
WHAT IT CATCHES, in one sentence: a grid-following loop that is pushing power in
|
||||
the wrong direction, which every other guard in this system is blind to.
|
||||
|
||||
The stale-input watchdog (SAFETY-01) asks "is the meter still talking?". The
|
||||
saturation freeze asks "can the inverter follow?". The output clamp asks "is the
|
||||
number too big?". A loop can pass all three and still be charging the battery
|
||||
out of the grid while the house imports - the meter is fresh, the inverter is
|
||||
tracking beautifully, and the magnitude is inside the rating. The only thing
|
||||
that separates that from healthy operation is the SIGN.
|
||||
|
||||
⚠️ THIS IS NOT A HYPOTHETICAL. On 2026-08-20 the vendor controller (Illusmart)
|
||||
did exactly this four times in one day (NOTES.md §4.7). Both captures of that
|
||||
bus are committed - `sim/scenarios/bus-2026-08-20.log.gz` and the healthy
|
||||
`bus-2026-08-23.log.gz` in the infra repo - and `sim/replay_sign.py` drives this
|
||||
module over both of them. Every number in this file was measured on those two
|
||||
days, not chosen:
|
||||
|
||||
day samples gated in sign agreement
|
||||
2026-08-23 4 212 / 17 280 93.4 % (healthy)
|
||||
2026-08-20 7 182 / 17 280 56.4 % (the whole fault day)
|
||||
cluster A 15:26-16:43 0.3 %
|
||||
cluster B 17:11-18:28 0.9 %
|
||||
cluster C 20:01-20:31 10.5 %
|
||||
cluster D 20:55-21:11 0.0 %
|
||||
|
||||
⚠️ THOSE AGREEMENT FIGURES ARE THE WEAKEST EVIDENCE ON THIS PAGE, and an earlier
|
||||
version of this comment leaned on them as though they were the strongest. The
|
||||
clusters are found by looking for sustained opposed samples, so measuring "how
|
||||
opposed is this cluster" over them is close to a tautology - and "the healthy day
|
||||
contains no such cluster" is the same sentence as "the healthy day produces no
|
||||
trip", not independent corroboration of it.
|
||||
|
||||
What actually carries the argument is two things neither the detector nor the
|
||||
clustering assumes:
|
||||
|
||||
1. The RUN-LENGTH DISTRIBUTION. The longest unbroken opposed run on the healthy
|
||||
day is 30 s. On the fault day there are 18 runs over 180 s and the longest is
|
||||
1750 s. Nothing about that gap was chosen - it is what the two days measure.
|
||||
2. The clusters land where a human independently wrote down onsets. NOTES.md
|
||||
§4.7 records 15:01, 16:56, 19:57 and 20:23 from watching it happen, before
|
||||
any of this existed, and cluster D coincides with the separately documented
|
||||
inverter-off runaway of §4.7 item 2.
|
||||
|
||||
Be precise about (2), because the convenient overstatement is available and wrong:
|
||||
the clustering does NOT reproduce four episodes. It yields THREE clusters over the
|
||||
four documented onsets - C spans both 19:57 and 20:23 - plus a fourth for §4.7
|
||||
item 2, which is not one of the four. Every trip does fall inside a documented
|
||||
window, which is the claim worth making.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHY IT LIVES HERE AND NOT IN THE FIRMWARE OR THE WITNESS
|
||||
|
||||
Three vantage points see three different things. The firmware knows what it
|
||||
WROTE, the witness Pi knows what was ON THE WIRE and whether the inverter ACKed
|
||||
it, and the add-on knows what it INTENDED and holds the authoritative meter.
|
||||
|
||||
This check is not command-versus-wire; it is command-versus-physics. It needs
|
||||
the emitted setpoint and the P1 net figure in the same place, and it needs the
|
||||
authority to command zero when they fight:
|
||||
|
||||
- the witness is read-only by charter and cannot command anything, so it could
|
||||
raise the alarm but never take the trip action the AC requires;
|
||||
- the firmware has the wire but not the meter. §5.1 makes P1 the only
|
||||
authoritative grid figure and the board only ever receives its AGE
|
||||
(`sensor.goodwe_p1_sample_age`), never its value. Putting the check there
|
||||
would mean shipping the meter to the ESP32 for no other reason, and it would
|
||||
be C++ per install rather than one generic guard;
|
||||
- the add-on has both halves already, plus an arbiter that can outrank every
|
||||
strategy with a P_SAFETY claim.
|
||||
|
||||
The cost of standing here is real and worth stating: this watchdog compares what
|
||||
we MEANT to put on the wire against the meter. If the firmware or the driver
|
||||
inverts the sign on the way out, the add-on's own view still agrees with itself
|
||||
and this check stays silent. That gap is a wire-versus-intent problem and it
|
||||
belongs to the witness, not here.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHAT "DISAGREEMENT" MEANS, PRECISELY
|
||||
|
||||
disagree = following
|
||||
and commanded_w * grid_w < 0
|
||||
and abs(commanded_w) > min_w
|
||||
and abs(grid_w) > min_w
|
||||
|
||||
Four gates, and each one exists to exclude a specific thing that is NOT a fault:
|
||||
|
||||
1. `following`. ⚠️ THE GATE THAT IS EASIEST TO FORGET AND MOST EXPENSIVE TO
|
||||
OMIT. Sign agreement is a property of a GRID-FOLLOWING loop. It is not
|
||||
expected of a strategy that deliberately commands against the meter -
|
||||
maintenance is exactly that. A maintenance grid charge of 1500 W into a house
|
||||
drawing 500 W puts +2000 W on the meter against a -1500 W command: opposed,
|
||||
both far over any sane threshold, and sustained for HOURS. Armed
|
||||
unconditionally this watchdog would trip on every single maintenance cycle,
|
||||
which is not a tuning problem, it is the wrong question being asked. So the
|
||||
caller says whether the grid-following loop actually won the arbitration this
|
||||
cycle, and the timer only accumulates when it did.
|
||||
|
||||
⚠️ 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.
|
||||
|
||||
3. The 300 W magnitude gate ON BOTH SIDES. 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`. On the healthy day
|
||||
only 4 212 of 17 280 samples (24 %) are even gated in; the other 76 % are the
|
||||
converged band where sign carries no information. This is also why the
|
||||
deadband cannot produce a false trip: inside it the meter is by definition
|
||||
under 300 W.
|
||||
|
||||
4. The 180 s continuity requirement. This is what excludes the legitimate
|
||||
transient. A load step reverses the meter's sign instantly while the command
|
||||
is still where the last cycle left it; slew limiting then needs a few cycles
|
||||
to cross over. Measured on the healthy day, the LONGEST such crossing is 30 s
|
||||
- and that is with a vendor controller far cruder than ours. 180 s is six
|
||||
times that. It is not a fitted threshold: at 60 s the healthy day still
|
||||
produces zero trips, and at 300 s the fault day still produces 13.
|
||||
|
||||
⚠️ A BATTERY THAT REFUSES A COMMAND CANNOT TRIP THIS, and that is structural
|
||||
rather than lucky. At SoC 10 % in Standby, a 1350 W discharge command into a
|
||||
1350 W import is DECLINED: the battery does nothing, so the meter stays at
|
||||
+1350 W and the command stays at +1350 W. Same sign. Agreement. A refusal leaves
|
||||
the meter where it was; only the inverter ACTING moves it, and acting in the
|
||||
commanded direction moves it toward zero, not through it. The failure mode this
|
||||
check keys on is the opposite of a refusal - it is the inverter obediently doing
|
||||
a wrong thing.
|
||||
|
||||
⚠️ A FROZEN METER cannot trip it either, for the same reason read the other way:
|
||||
a stuck reading of +800 W makes the loop command discharge and keep commanding
|
||||
discharge. Command and meter converge on AGREEING. Freezes are SAFETY-12's and
|
||||
SAFETY-01's problem, and this watchdog neither helps nor hinders there.
|
||||
|
||||
⚠️ ALL OF THAT DEPENDS ON ONE PROPERTY OF THE CONTROL LAW, so it is an invariant
|
||||
and not a happy accident: the saturation freeze in control.py must keep letting
|
||||
the command FALL, CROSS ZERO AND REVERSE - "may not wind further in the direction
|
||||
it is already pushing", not "only corrections that shrink |i_w|". control.py
|
||||
records the shrink-only encoding as tried and rejected because it pins the command
|
||||
at its last value indefinitely; a pinned command against a meter that has moved on
|
||||
is precisely a sustained sign disagreement, so under that encoding this watchdog
|
||||
WOULD false-trip on a plain load step. If anyone ever revisits that freeze, this
|
||||
module's no-false-positive argument is downstream of the answer.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHAT IT DOES NOT CATCH
|
||||
|
||||
An inverter that ACTS opposite to what it was told. Tell it to discharge 2000 W
|
||||
and have it charge instead, and the meter moves FURTHER the way it was already
|
||||
going - so the command and the meter agree, all the way into the rail. This
|
||||
watchdog compares the loop's output direction against the input state it was
|
||||
regulating; it is blind to the plant lying about which way it went. That is the
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
STATE, TRIP AND LATCH
|
||||
|
||||
One timestamp, not a sample counter. The cycle rate is not fixed - a meter
|
||||
arrival, or `cycle_floor_s` elapsing, whichever comes first - so N samples is a
|
||||
different wall-clock window on every path, and the AC is written in minutes.
|
||||
|
||||
The latch is not optional and not a nicety. Commanding 0 W removes the command
|
||||
side of the comparison (|0| is not > 300), so the disagreement disappears the
|
||||
instant the trip works. Un-latched, this watchdog would trip, clear itself, and
|
||||
hand the actuator straight back to the loop that was driving the wrong way -
|
||||
three minutes later, forever. `tripped` therefore stays true until an operator
|
||||
`reset()`s it by starting control again, which is a deliberate human act after
|
||||
looking at why.
|
||||
|
||||
⚠️ AND THE LATCH HAS TO OUTLIVE THE PROCESS. Held only in memory it is not a
|
||||
latch at all - a container restart clears it and the add-on comes back armed,
|
||||
into the same fault, unattended. Worse than merely losing state: "restart the
|
||||
add-on" is the first thing an operator confronted with a FAILSAFE banner and a
|
||||
0 W inverter will try, and under a supervisor that restarts a crashed add-on it
|
||||
is a loop of three-minute wrong-direction bursts. `tripped` and `reason` are
|
||||
therefore constructor arguments, restored from `Store` in `Controller.__init__`
|
||||
and written back the moment the trip happens. See store.py.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SignWatchdogConfig:
|
||||
"""Defaults, not constants - config.yaml carries both (SAFETY-02 AC 5)."""
|
||||
|
||||
min_w: float = 300.0
|
||||
hold_s: float = 180.0
|
||||
|
||||
|
||||
def disagrees(commanded_w, grid_w, following: bool,
|
||||
cfg: SignWatchdogConfig) -> bool:
|
||||
"""The predicate, and the ONLY place it is written down.
|
||||
|
||||
⚠️ Public because `sim/replay_sign.py` needs it too - to describe episodes, to
|
||||
measure agreement, to sweep the clock-skew guard - and it had reimplemented it
|
||||
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.
|
||||
"""
|
||||
return (following
|
||||
and commanded_w is not None and grid_w is not None
|
||||
and commanded_w * grid_w < 0
|
||||
and abs(commanded_w) > cfg.min_w
|
||||
and abs(grid_w) > cfg.min_w)
|
||||
|
||||
|
||||
def loop_is_following(resolution, loop_asked) -> bool:
|
||||
"""Did the grid-following loop actually choose the number going on the wire?
|
||||
|
||||
Gate 1 of the predicate above, and it lives here rather than inline in
|
||||
`cycle()` for the reason `disagrees()` does: the test suite has to be able to
|
||||
assert on THE SHIPPED EXPRESSION, not on a transcription of it that drifts
|
||||
apart from it in silence.
|
||||
|
||||
⚠️ IT WAS A TRANSCRIPTION, AND THAT WAS MEASURED, NOT FEARED. The P_PEAK case
|
||||
in test_watchdog.py exists to make whoever lands peak shaving come here and
|
||||
change an expectation. With the expression copied into the test it did not do
|
||||
that: editing the shipped gate alone to `winner in ("loop", "peak")` left every
|
||||
suite green while the watchdog went blind for the whole of every peak event.
|
||||
One definition, two callers, and that test now bites.
|
||||
|
||||
`resolution` the arbiter's Resolution. Duck-typed on `.winner` and
|
||||
`.target_w` so this module still imports nothing from arbiter.
|
||||
`loop_asked` what the grid-following loop claimed this cycle, or None if it
|
||||
did not claim at all.
|
||||
|
||||
"The loop won" is NOT the question, and the difference is a whole class of
|
||||
false trip. A LIMIT claim can survive the loop winning and still replace its
|
||||
number: maintenance's cheap-window shaping is `hi = -maintenance_charge_w`, so
|
||||
at 1500 W the loop can ask for +500 W of discharge, win the arbitration, and
|
||||
have the emitted value come out at -1500 W of grid charge. Winner says "loop";
|
||||
the wire carries maintenance's intent.
|
||||
|
||||
So the test is on the NUMBER: the loop is following only while the emitted
|
||||
value points the way the loop asked AND is no larger than it asked - limits
|
||||
reduced it, they did not redirect it. The magnitude half is load-bearing on
|
||||
its own, because a `lo` limit can raise magnitude without flipping sign, and
|
||||
that is not the loop following either. Zero on either side falls out as
|
||||
not-following, which costs nothing: a 0 W command cannot be a
|
||||
wrong-direction command.
|
||||
|
||||
⚠️ FORWARD HAZARD, and the claim block in cycle() already anticipates it.
|
||||
`winner == "loop"` disarms the watchdog under ANY higher-priority SET claim.
|
||||
Today only maintenance and the safety stops emit one and neither follows the
|
||||
meter, so the gate costs nothing. A peak-shaving strategy claiming SET at
|
||||
P_PEAK **is** grid-following in sign, and the moment it lands this goes blind
|
||||
for the duration of every peak event. Widening it means asking "does the
|
||||
winning strategy follow the meter", not adding another name to a comparison.
|
||||
"""
|
||||
return (resolution.winner == "loop" and loop_asked is not None
|
||||
and resolution.target_w * loop_asked > 0
|
||||
and abs(resolution.target_w) <= abs(loop_asked))
|
||||
|
||||
|
||||
class SignWatchdog:
|
||||
"""One instance per actuator, evaluated once per control cycle.
|
||||
|
||||
⚠️ 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
|
||||
resetting the other's window. If a fast on-device path ever wants this
|
||||
check, it gets its own instance - the same exactly-one-writer discipline the
|
||||
arbiter applies to the actuator, applied to the detector's own state.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: SignWatchdogConfig | None = None, *,
|
||||
tripped: bool = False, reason: str = ""):
|
||||
self.cfg = cfg or SignWatchdogConfig()
|
||||
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
|
||||
# comes back armed, into the same fault, with no operator involved. See
|
||||
# store.py's note on `sign_tripped` for the measured behaviour.
|
||||
self.tripped = bool(tripped)
|
||||
self.reason = reason if self.tripped else ""
|
||||
|
||||
# -- the whole check, and it is a pure function of its arguments ----------
|
||||
def evaluate(self, commanded_w, grid_w, following: bool,
|
||||
now: datetime) -> bool:
|
||||
"""Advance the detector one cycle. Returns True while tripped.
|
||||
|
||||
`commanded_w` the EMITTED setpoint - what is standing on the wire now,
|
||||
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.
|
||||
`following` did the grid-following loop actually win this cycle? See
|
||||
gate 1 in the module docstring.
|
||||
`now` the caller's clock, so this stays testable and replayable.
|
||||
"""
|
||||
if self.tripped:
|
||||
# Latched. Do not re-evaluate: the trip removed the evidence.
|
||||
return True
|
||||
|
||||
cfg = self.cfg
|
||||
if not disagrees(commanded_w, grid_w, following, cfg):
|
||||
self.disagree_since = None
|
||||
return False
|
||||
|
||||
if self.disagree_since is None:
|
||||
self.disagree_since = now
|
||||
return False
|
||||
|
||||
held = (now - self.disagree_since).total_seconds()
|
||||
if held < cfg.hold_s:
|
||||
return False
|
||||
|
||||
self.tripped = True
|
||||
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"(limits: both over {cfg.min_w:.0f} W, held over {cfg.hold_s:.0f} s)"
|
||||
)
|
||||
return True
|
||||
|
||||
def held_s(self, now: datetime) -> float:
|
||||
"""How long the current disagreement has been standing, for the UI.
|
||||
|
||||
⚠️ Takes the clock rather than reading one. It was a property calling
|
||||
`datetime.now()`, which quietly broke the property this module's header
|
||||
advertises - every other observable here is a pure function of what it
|
||||
was handed - and an unclocked function is one nothing can test, which is
|
||||
exactly what happened to it.
|
||||
"""
|
||||
if self.disagree_since is None:
|
||||
return 0.0
|
||||
return (now - self.disagree_since).total_seconds()
|
||||
|
||||
def reset(self) -> None:
|
||||
self.disagree_since = None
|
||||
self.tripped = False
|
||||
self.reason = ""
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.2.1"
|
||||
version: "0.6.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -39,6 +39,98 @@ 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
|
||||
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below;
|
||||
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
|
||||
# HomeWizard P1 publishes and what ha_dsmr cannot consume.
|
||||
# homewizard_local skips Home Assistant entirely and polls the meter's own
|
||||
# local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when
|
||||
# the meter REPORTED rather than when the value last CHANGED - HA emits
|
||||
# nothing at all for a repeated reading - so it is the only mode a firmware
|
||||
# watchdog may threshold. See DOCS.md.
|
||||
meter_source: "off"
|
||||
meter_phases: 1
|
||||
meter_max_age_s: 30
|
||||
meter_mqtt_topic: ""
|
||||
# homewizard_local only. The meter's own address, `host` or `host:port`.
|
||||
p1_host: ""
|
||||
# homewizard_local only. Seconds between polls. Must be well under
|
||||
# meter_max_age_s - the age is never fresher than this interval - and there is
|
||||
# nothing to gain below the meter's own ~5.0 s update rate (NOTES.md:640).
|
||||
meter_poll_s: 5
|
||||
# The two UNSIGNED Belgian registers. The EMS derives net power from them
|
||||
# (import - export); do NOT point these at a signed template sensor.
|
||||
p1_import_entity: ""
|
||||
p1_export_entity: ""
|
||||
# Optional, in L1..L3 order. Required for the capacity-tariff peak on a
|
||||
# three-phase connection; the list length must equal meter_phases.
|
||||
p1_phase_import_entities: []
|
||||
p1_phase_export_entities: []
|
||||
# ha_signed only. ONE signed net-power sensor: positive = import from the
|
||||
# grid, negative = export to it. Do NOT split it into two template sensors -
|
||||
# the split is done in the add-on (p1.split_signed) precisely so the sign
|
||||
# convention is tested rather than living in unreviewed YAML.
|
||||
p1_net_entity: ""
|
||||
# Optional, in L1..L3 order, each one signed the same way. Same role as
|
||||
# p1_phase_import_entities: the capacity-tariff peak on a three-phase
|
||||
# connection. The list length must equal meter_phases.
|
||||
p1_phase_net_entities: []
|
||||
|
||||
# --- control ---------------------------------------------------------------
|
||||
max_w: 2000
|
||||
gain: 0.6
|
||||
@@ -51,14 +143,105 @@ options:
|
||||
integrator_max_w: 0
|
||||
heartbeat_s: 10
|
||||
stale_input_s: 15
|
||||
cycle_floor_s: 15
|
||||
auto_start: false
|
||||
|
||||
# --- SAFETY-02 sign-disagreement watchdog ----------------------------------
|
||||
# Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways,
|
||||
# both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and
|
||||
# a failsafe that latches until an operator starts control again.
|
||||
#
|
||||
# Both defaults are measured, not chosen. On the captured healthy day
|
||||
# (2026-08-23) the longest legitimate opposed run - a load step the loop has
|
||||
# not caught up with - is 30 s, and sign agreement over the gated samples is
|
||||
# 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 %
|
||||
# agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of
|
||||
# a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the
|
||||
# fault day still trips 13 times. There is no reason to move either number
|
||||
# without a capture that says so. They are options because the AC says
|
||||
# thresholds are defaults rather than constants - not because they are knobs
|
||||
# anyone is expected to turn.
|
||||
#
|
||||
# ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the
|
||||
# arbiter bounds the command to +-max_w). validate.py refuses that config.
|
||||
sign_disagree_w: 300
|
||||
sign_disagree_s: 180
|
||||
|
||||
# --- 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
|
||||
# are required: neither one alone is safe, and app/freeze.py carries why.
|
||||
#
|
||||
# ALARM ONLY. It does not command 0 W and does not latch. A false trip parks
|
||||
# the battery; a false notification costs an alert.
|
||||
#
|
||||
# ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the
|
||||
# only transport that reads the meter's own document. On every other source
|
||||
# the status page says NOT COVERED rather than "armed".
|
||||
#
|
||||
# 300 s is measured, not chosen. Over 2399 s of the live house at the
|
||||
# converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean
|
||||
# grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the
|
||||
# LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any
|
||||
# sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and
|
||||
# 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
|
||||
maintenance_discharge_w: 2500
|
||||
maintenance_charge_w: 2500
|
||||
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
|
||||
# than the 2500 they shipped as. The cheap-window charge shaping asks the
|
||||
# arbiter for `hi = -maintenance_charge_w` against the supervised `lo =
|
||||
# -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to
|
||||
# 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a
|
||||
# default install. validate.py refuses that config (rule 6), so these
|
||||
# defaults had to become self-consistent with the max_w above rather than
|
||||
# ship a configuration the validator rejects.
|
||||
#
|
||||
# The discharge side is clamped rather than contradicted - it drains at max_w
|
||||
# and the arbiter says so - so it is only warned about; it is 2000 here for
|
||||
# symmetry and to keep the shipped config warning-free.
|
||||
#
|
||||
# Raising max_w (the live reference install runs 5000) is what unlocks a
|
||||
# faster maintenance cycle. Raise max_w FIRST, then these.
|
||||
maintenance_discharge_w: 2000
|
||||
maintenance_charge_w: 2000
|
||||
maintenance_soc_floor: 11
|
||||
maintenance_soc_target: 99
|
||||
maintenance_hold_min: 120
|
||||
@@ -66,9 +249,167 @@ options:
|
||||
# --- tariff / capacity tariff (all optional) ------------------------------
|
||||
peak_forecast_entity: ""
|
||||
peak_cap_w: 3500
|
||||
|
||||
# --- SAFETY-24: the energy backstop under SAFETY-07's forced discharge -----
|
||||
# The peak guard can COMMAND a discharge to hold a billed quarter under
|
||||
# peak_cap_w. Until this card it was bounded from below by nothing but the
|
||||
# pack's own BMS cut-out - a real floor, but the PACK's protection rather than
|
||||
# the OPERATOR's. On the 2026-08-20 replay the guard asks for 878 Wh in a day.
|
||||
#
|
||||
# peak_soc_floor is the percent below which that discharge is not sized. When
|
||||
# it binds the guard reports FLOOR-LIMITED, keeps charging blocked (a 0 W
|
||||
# floor), and the add-on raises ONE notification saying the quarter will be
|
||||
# exceeded - failing to shave is never silent, because the euro figure is the
|
||||
# whole point of the guard.
|
||||
#
|
||||
# ⚠️ THIS IS NOT maintenance_soc_floor AND MUST NOT BE MERGED WITH IT.
|
||||
# maintenance_soc_floor is the DRAIN TARGET of the monthly recalibration: the
|
||||
# depth the cycle deliberately goes to, once a month, on purpose. This is the
|
||||
# depth a money-saving discharge may never reach on any day. The right values
|
||||
# differ, and sharing one key would couple two features that want to move
|
||||
# independently.
|
||||
#
|
||||
# ⚠️ 20 IS DERIVED, NOT PICKED. The reference pack is 13.8 kWh usable and the
|
||||
# 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
|
||||
# DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below
|
||||
# it: such a floor can never bind, the BMS wins, and the status page would
|
||||
# report an energy backstop that does not exist.
|
||||
bms_soc_floor: 10
|
||||
|
||||
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
|
||||
# add-on delegating the whole price question to Home Assistant: main.py reads
|
||||
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
|
||||
# nothing except the maintenance cycle's cheap-window charge floor. That is
|
||||
# all they have ever done. They are kept, because retiring them would take
|
||||
# the maintenance cycle's only price signal away on a live install before
|
||||
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
|
||||
# are the cards that replace it, and neither has landed.
|
||||
#
|
||||
# ⚠️ They may NOT be combined with price_provider below. Two price sources on
|
||||
# one site, one of which silently wins, is exactly the "double up" the PM
|
||||
# named; validate.py rule 7 refuses that configuration at startup rather than
|
||||
# letting an operator believe the provider they configured is driving
|
||||
# something. See app/validate.py.
|
||||
price_now_entity: ""
|
||||
price_avg_entity: ""
|
||||
|
||||
# --- price providers (TARIFF-02, specs §9.3 / §14 `tariffs:`) --------------
|
||||
# `off` is the shipped default and means the §11.4 no-price-data row applies:
|
||||
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
|
||||
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
|
||||
# validates and publishes prices and changes no energy decision.
|
||||
#
|
||||
# ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's
|
||||
# PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two
|
||||
# agree key for key. Do not add a third copy in main.py - CFG-03 is open
|
||||
# because that has already happened three times.
|
||||
#
|
||||
# markup/taxes are the §14 `tariffs.energy_price.*` figures and are added by
|
||||
# the FRAMEWORK, only for the components the chosen plugin declares its feed
|
||||
# does not already contain. engie_dynamic, totalenergies_dynamic, eneco_eddy
|
||||
# and manual_csv publish a figure that already carries the markup, so setting
|
||||
# it under those is ignored - and logged as ignored, never applied twice.
|
||||
price_provider: "off"
|
||||
price_markup_eur_kwh: 0.03
|
||||
price_taxes_eur_kwh: 0.06
|
||||
price_csv_path: "/share/goodwe_prices.csv"
|
||||
price_feed_url: ""
|
||||
price_horizon_h: 48
|
||||
price_timeout_s: 10
|
||||
|
||||
# --- static / fully-fixed tariffs (TARIFF-03, specs §9.2 / §11.4) ----------
|
||||
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
|
||||
# of the installed base is on [a static profile]" - this is what makes those
|
||||
# sites first-class rather than "price_provider: off, good luck".
|
||||
#
|
||||
# A JSON object, on one line, whose periods must tile every day type exactly
|
||||
# once - no gap and no overlap. A gap is REFUSED at startup rather than
|
||||
# defaulted, because an invented price is one an optimiser would trade on.
|
||||
# A fully-fixed tariff is ONE period covering 00:00-24:00, all days; the
|
||||
# add-on derives "fixed" from there being one distinct price, so there is no
|
||||
# box to tick and none to tick wrongly.
|
||||
#
|
||||
# {"periods": [
|
||||
# {"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
|
||||
# {"name":"night","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
|
||||
# {"name":"night","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
|
||||
#
|
||||
# ⚠️ A window written "22:00-07:00 mon-fri" does NOT cover Saturday morning -
|
||||
# a period applies on the day the CLOCK reads, so the weekend row above is
|
||||
# required, not decorative. See DOCS.md.
|
||||
#
|
||||
# eur_per_kwh is ALL-IN (energy + markup + taxes, §9.3), the same basis the
|
||||
# dynamic providers are assembled onto. price_markup_eur_kwh and
|
||||
# price_taxes_eur_kwh are NOT added to it.
|
||||
#
|
||||
# ⚠️ RECOMMENDED EVEN ON A DYNAMIC SITE. §11.4's degradation row is "no price
|
||||
# data -> fall back to the static profile"; with this empty there is nothing
|
||||
# to fall back to and a dead feed leaves the site with no tariff at all.
|
||||
# validate.py warns about exactly that.
|
||||
static_profile: ""
|
||||
|
||||
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
|
||||
# which by itself strongly favours self-consumption over export. A scalar, not
|
||||
# a second series: no shipped supplier publishes a dynamic injection curve.
|
||||
export_price_eur_kwh: 0.04
|
||||
|
||||
# --- price arbitrage (OPT-04, specs §10.1 priority 3 / §17) ---------------
|
||||
# Charge in the cheapest published hour, discharge in the dearest, and ONLY
|
||||
# when the spread covers what the round trip costs. Off in the shipped
|
||||
# configuration: this is the first strategy in the add-on that moves energy
|
||||
# against the meter on purpose, so it is armed deliberately or not at all.
|
||||
#
|
||||
# ⚠️ IT NEEDS A PRICE SOURCE AND A P1 METER, and validate.py refuses the
|
||||
# combinations that cannot work rather than letting them look armed. With
|
||||
# `price_provider: off` and no `static_profile` there is no series to trade
|
||||
# on; with `meter_source: off` the capacity-tariff peak guard never runs, and
|
||||
# §9.1 puts peak shaving above arbitrage - a charge nothing measures is the
|
||||
# exact failure that guard exists to prevent.
|
||||
#
|
||||
# ⚠️ AT THE SHIPPED THRESHOLD A STATIC DAY/NIGHT SITE DOES NOT TRADE. The
|
||||
# realistic Belgian day/night spread is about 0.08 EUR/kWh and the threshold
|
||||
# below is 0.10, so the verdict is "the firm spread does not cover this
|
||||
# cycle's cost" and nothing happens - correctly. That is a product decision
|
||||
# (this number, and app/price.py's widening factors) and it is open with the
|
||||
# owner; the code is correct at any value and no number was picked to make a
|
||||
# trade appear.
|
||||
arbitrage_enabled: false
|
||||
# §10.1's `cycle_cost_threshold_eur`: what one full round trip costs per kWh
|
||||
# - the wear on the pack plus the conversion losses. A spread below this is
|
||||
# a cycle that loses money and pack life, so it is not taken.
|
||||
cycle_cost_threshold_eur: 0.10
|
||||
# The reserve arbitrage may not spend, and the headroom it may not overfill.
|
||||
# Deliberately NOT the maintenance_soc_* keys: those describe a monthly
|
||||
# balancing cycle, and pointing a trading strategy at them would couple two
|
||||
# unrelated policies through one number.
|
||||
arbitrage_soc_floor: 20
|
||||
arbitrage_soc_ceiling: 95
|
||||
|
||||
# --- site ------------------------------------------------------------------
|
||||
estop_fitted: false
|
||||
log_level: info
|
||||
@@ -81,6 +422,52 @@ 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
|
||||
# mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a
|
||||
# quietly wrong number.
|
||||
meter_phases: int(1,3)
|
||||
meter_max_age_s: int(5,300)
|
||||
meter_poll_s: int(1,60)
|
||||
meter_mqtt_topic: str?
|
||||
p1_host: str?
|
||||
p1_import_entity: str?
|
||||
p1_export_entity: str?
|
||||
p1_phase_import_entities:
|
||||
- str
|
||||
p1_phase_export_entities:
|
||||
- str
|
||||
p1_net_entity: str?
|
||||
p1_phase_net_entities:
|
||||
- str
|
||||
|
||||
max_w: int(100,5000)
|
||||
gain: float(0.05,1.0)
|
||||
slew_w: int(50,5000)
|
||||
@@ -92,8 +479,55 @@ schema:
|
||||
integrator_max_w: int(0,15000)
|
||||
heartbeat_s: int(2,25)
|
||||
stale_input_s: int(5,120)
|
||||
cycle_floor_s: int(5,60)
|
||||
auto_start: bool
|
||||
|
||||
# No "off" value, deliberately: this is a P0 safety check and the schema is
|
||||
# not the place to make disarming it a supported configuration.
|
||||
sign_disagree_w: int(50,5000)
|
||||
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
|
||||
# is 12 W, which is inside the 15 W deadband, so the two terms of the
|
||||
# conjunction are mutually exclusive rather than merely far apart. At 60 s that
|
||||
# figure is 60 W and the argument is gone - only the margin is left. See
|
||||
# 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)
|
||||
@@ -105,8 +539,47 @@ schema:
|
||||
|
||||
peak_forecast_entity: str?
|
||||
peak_cap_w: int(500,15000)
|
||||
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
|
||||
# a supported way to have no energy bound at all. The ranges OVERLAP on
|
||||
# purpose - the schema cannot express "above bms_soc_floor", so validate.py
|
||||
# rule 8 is what refuses the pair, with a message naming both numbers.
|
||||
peak_soc_floor: int(5,60)
|
||||
bms_soc_floor: int(0,50)
|
||||
price_now_entity: str?
|
||||
price_avg_entity: str?
|
||||
|
||||
# ⚠️ The provider list is written out rather than left as `str`, so the
|
||||
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
|
||||
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
|
||||
# checks reach validate_startup_config() with a dict the Supervisor never saw.
|
||||
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
|
||||
# else, which is AC2.
|
||||
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
price_markup_eur_kwh: float(0,1)
|
||||
price_taxes_eur_kwh: float(0,1)
|
||||
price_csv_path: str?
|
||||
price_feed_url: str?
|
||||
price_horizon_h: int(1,72)
|
||||
price_timeout_s: int(2,60)
|
||||
# A JSON string rather than a nested schema, deliberately: it is ONE option
|
||||
# key (§14 has one), it survives config.yaml's flat options block, and
|
||||
# parse_static_profile() accepts an already-parsed dict too, so a future
|
||||
# nested schema is a config.yaml change and no code change at all.
|
||||
static_profile: str?
|
||||
# ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have
|
||||
# gone below zero (you pay to export at midday in summer), and a schema that
|
||||
# bottoms out at 0 would make that site inexpressible.
|
||||
export_price_eur_kwh: float(-1,1)
|
||||
|
||||
# OPT-04. ⚠️ The threshold's lower bound is 0 and not negative: it is a COST,
|
||||
# and a negative one is a threshold every spread clears - arbitrage with the
|
||||
# brake removed. app/arbitrage.py refuses it in code too, because
|
||||
# validate_startup_config() is also sim/inject.py's entry point and can be
|
||||
# handed any dict at all.
|
||||
arbitrage_enabled: bool
|
||||
cycle_cost_threshold_eur: float(0,1)
|
||||
arbitrage_soc_floor: int(5,95)
|
||||
arbitrage_soc_ceiling: int(10,100)
|
||||
|
||||
estop_fitted: bool
|
||||
log_level: list(trace|debug|info|warning|error)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,551 @@
|
||||
#!/usr/bin/env python3
|
||||
"""DATA-02: fetch 12 months of EPEX BE day-ahead prices, in §9.3's shape.
|
||||
|
||||
python3 scenarios/fetch_epex_be.py --start 2025-09-01 --end 2026-09-01 \
|
||||
--out scenarios/epex-be-dayahead-2025-09-01_2026-09-01.json
|
||||
|
||||
Run from `goodwe_controller/`. Rewrites the output file from scratch; the
|
||||
committed dataset is exactly what this produces, and `--verify` re-derives every
|
||||
claim the file makes about itself without going to the network.
|
||||
|
||||
|
||||
WHAT THIS IS NOT
|
||||
================
|
||||
NOT wired into the add-on, and it must never be. `price.py`'s `GenericDayAhead`
|
||||
is the live path and it has its own egress check; this is a one-shot archival
|
||||
fetch that produces a file for REPLAY, run by hand by a developer. Nothing under
|
||||
`app/` imports it.
|
||||
|
||||
|
||||
THE SOURCE, AND WHY THIS ONE
|
||||
============================
|
||||
`https://api.energy-charts.info/price?bzn=BE` - Fraunhofer ISE's Energy-Charts
|
||||
API. No key, no registration.
|
||||
|
||||
⚠️ THE LICENCE WAS CHECKED BEFORE ANY DATA WAS COMMITTED, because for this
|
||||
source it is NOT uniform across bidding zones. The API's own OpenAPI document
|
||||
(`https://api.energy-charts.info/openapi.json`, /price) splits the zones in two:
|
||||
|
||||
"The data for the following bidding zones is licensed as CC BY 4.0 from
|
||||
Bundesnetzagentur | SMARD.de and is published without changes:
|
||||
AT, BE (Belgium), CH, CZ, DE-LU, ..."
|
||||
|
||||
"The data for the other bidding zones is for private and internal use only.
|
||||
The utilization of any data, whether in its raw or derived form, for
|
||||
external or commercial purposes is expressly prohibited. Should you require
|
||||
licensing for market-related data, please direct your inquiries to the
|
||||
original data providers, including but not limited to EPEX SPOT SE."
|
||||
|
||||
BE is in the FIRST list, so redistribution is permitted with attribution and the
|
||||
dataset is committed rather than left as a fetch script and a checksum. Every
|
||||
response also carries the licence per-response in `license_info`, and this
|
||||
script REFUSES to write a file if that field ever stops saying CC BY 4.0 - a
|
||||
licence checked once by a human and assumed forever is a licence that changes
|
||||
silently. Had BE been in the second list, the correct output of this card would
|
||||
have been this script and a checksum and no data.
|
||||
|
||||
Attribution, carried in the dataset's own `provenance` block and reproduced
|
||||
here: data CC BY 4.0, Bundesnetzagentur | SMARD.de, retrieved via Energy-Charts
|
||||
(Fraunhofer ISE), https://energy-charts.info, published without changes.
|
||||
|
||||
Cross-checked against the upstream on 2026-08-31: SMARD's own chart_data series
|
||||
`4996/DE` over 2026-04-10 matched this API's BE series on all 96 quarter-hours
|
||||
with a maximum absolute difference of 0.00 EUR/MWh. "Published without changes"
|
||||
is a claim worth checking once; `--verify` does not repeat it, because it is a
|
||||
claim about the SOURCE and not about the file.
|
||||
|
||||
|
||||
RAW WHOLESALE, AND THE ASSUMPTION THAT IS NOT BAKED IN
|
||||
======================================================
|
||||
⚠️ WHAT COMES BACK IS RAW WHOLESALE, EUR/MWh, DAY-AHEAD AUCTION CLEARING PRICE.
|
||||
It is NOT all-in: no supplier markup, no distribution or transmission tariff, no
|
||||
levies, no VAT. §9.3's canonical point is all-in, and TARIFF-02's whole trap is
|
||||
a figure that already contains a component being handed to something that adds
|
||||
it again.
|
||||
|
||||
So this file does NOT add one. `eur_per_kwh` in the output is the wholesale
|
||||
figure converted from EUR/MWh, `provenance.all_in` is `false` beside it, and the
|
||||
markup/tax assumption a consumer needs lives in `provenance.all_in_assumption`
|
||||
as NUMBERS TO APPLY rather than numbers already applied. test_arbitrage.py
|
||||
section 7 applies them where a reader can watch it happen.
|
||||
|
||||
The assumption's SHAPE is the part that is not arbitrary, and it decides which
|
||||
conclusions depend on the guessed numbers at all:
|
||||
|
||||
all_in(h) = (wholesale(h) + markup + network_and_levies) * (1 + vat)
|
||||
|
||||
* The two ADDITIVE terms cancel out of a spread: max - min is unchanged by
|
||||
them. So how often a day clears `cycle_cost_threshold_eur` does NOT depend
|
||||
on the markup guess - only on the VAT multiplier.
|
||||
* They do NOT cancel out of §17's negative-price guard, which compares ONE
|
||||
hour's all-in price against zero. A wholesale hour at -0.02 EUR/kWh is
|
||||
comfortably positive all-in once ~0.12 of fixed charges is added.
|
||||
|
||||
Both are asserted arithmetically in section 7 rather than believed, and the
|
||||
trade count is reported on the raw spread as well, so a reader can see exactly
|
||||
how much of the answer rests on the guess.
|
||||
|
||||
The numbers themselves (2026 Belgian residential dynamic contract, order of
|
||||
magnitude) are a STATED GUESS, labelled as one everywhere they appear. They are
|
||||
not a measurement and this card had nothing to measure them with.
|
||||
|
||||
|
||||
HOURS, QUARTER-HOURS, AND THE SEAM IN THE MIDDLE OF THE YEAR
|
||||
============================================================
|
||||
⚠️ THE SOURCE CHANGES RESOLUTION PART-WAY THROUGH ANY RECENT 12 MONTHS. EPEX
|
||||
day-ahead moved to a 15-minute market time unit in October 2025 and the feed
|
||||
follows: 2025-09-10 comes back as 24 hourly points, 2025-12-10 as 96
|
||||
quarter-hourly ones. An importer that assumed either one silently loses three
|
||||
quarters of the year, or reads a quarter as an hour.
|
||||
|
||||
The canonical format is hourly, so quarter-hours are aggregated: an hour's price
|
||||
is the unweighted mean of its four quarters. That is a STATED APPROXIMATION of
|
||||
the hourly product, not the hourly product, and it errs in the direction that
|
||||
matters here - averaging discards intra-hour variation, so the observed spread
|
||||
is a LOWER bound on what a 15-minute strategy would see.
|
||||
`provenance.resolution` records how many hours came from each source shape.
|
||||
|
||||
⚠️ AND A PARTIAL HOUR IS DROPPED, NOT AVERAGED OVER WHAT ARRIVED. An hour with
|
||||
three of four quarters is an hour nobody published a complete price for; the
|
||||
mean of three would be an invented number carrying no flag. Dropped hours are
|
||||
listed in `provenance.gaps` with their timestamps, because a missing hour in a
|
||||
feed is not a zero price, and a zero price is exactly the value that would make
|
||||
everything look free.
|
||||
|
||||
|
||||
SIZE: WHY THIS IS PLAIN JSON AND NOT A `.gz`, MEASURED
|
||||
======================================================
|
||||
The directory's existing captures are gzipped, so the default would have been to
|
||||
gzip this too. Measured on the committed file instead of assumed:
|
||||
|
||||
working tree, plain JSON 893 171 bytes
|
||||
what GIT actually stores for it (zlib, loose object) 74 590 bytes
|
||||
what git would store for a committed `.json.gz` 56 504 bytes
|
||||
|
||||
⚠️ GIT ALREADY COMPRESSES BLOBS, so gzipping buys 18 KB - 24 % of an object
|
||||
that is already 75 KB, in a repository whose three existing P1 captures cost
|
||||
27 KB, 136 KB and 140 KB. Eighteen kilobytes is not worth making the provenance
|
||||
block unreadable in a pull request, and the licence claim in that block is
|
||||
precisely the thing a reviewer of this card has to read and check. Plain JSON
|
||||
also greps, diffs line-by-line when a future refetch changes something, and is
|
||||
what `--verify` opens. `load()` still handles `.gz` for a future dataset large
|
||||
enough to want it; this one is not.
|
||||
|
||||
The P1 captures are gzipped for the opposite reason and it is not an
|
||||
inconsistency: they are hundreds of thousands of telegram lines that no human
|
||||
reads, and there is no metadata in them to review.
|
||||
|
||||
|
||||
TIME
|
||||
====
|
||||
Every instant is carried as UTC and rendered with `app.price.brussels()`, the
|
||||
module that already owns the EU DST rule without a tz database. There is no
|
||||
second answer to that question here and there must not be: the point of
|
||||
`brussels()` is that the 02:00-03:00 hour which happens twice in October is two
|
||||
instants, and a 24-slot local-hour importer collapses them.
|
||||
|
||||
So a year is 8760 hours only if it contains no transition, or contains both and
|
||||
they cancel - 25 in October, 23 in March. `--verify` therefore asserts the
|
||||
PER-DAY counts, not the total, because the total is exactly the number a broken
|
||||
importer also produces.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.price import brussels # noqa: E402
|
||||
|
||||
API = "https://api.energy-charts.info/price"
|
||||
BZN = "BE"
|
||||
LICENCE_MUST_CONTAIN = "CC BY 4.0"
|
||||
UTC = timezone.utc
|
||||
|
||||
ATTRIBUTION = ("Data licensed CC BY 4.0 by Bundesnetzagentur | SMARD.de, "
|
||||
"retrieved via the Energy-Charts API (Fraunhofer ISE), "
|
||||
"https://energy-charts.info. Published without changes.")
|
||||
|
||||
# ⚠️ A GUESS, LABELLED AS ONE, AND APPLIED BY THE CONSUMER - NOT HERE.
|
||||
# Order-of-magnitude 2026 Belgian residential dynamic contract. Nothing in this
|
||||
# repository measured any part of it.
|
||||
ALL_IN_ASSUMPTION = {
|
||||
"form": "all_in = (wholesale + markup + network_and_levies) * (1 + vat)",
|
||||
"markup_eur_per_kwh": 0.020,
|
||||
"network_and_levies_eur_per_kwh": 0.100,
|
||||
"vat_fraction": 0.06,
|
||||
"status": "ASSUMPTION, NOT MEASURED. Stated so a consumer applies it "
|
||||
"deliberately; the committed eur_per_kwh has none of it applied.",
|
||||
"note": "The two additive terms cancel out of any spread, so an arbitrage "
|
||||
"trade count depends on vat_fraction only. They do NOT cancel out "
|
||||
"of the negative-price guard (17), which compares one hour's "
|
||||
"all-in price against zero.",
|
||||
}
|
||||
|
||||
|
||||
PACE_S = 3.0 # between chunks, unconditionally
|
||||
RETRIES = 6
|
||||
|
||||
|
||||
def _get(url: str) -> dict:
|
||||
"""One GET, backing off on 429 and 5xx.
|
||||
|
||||
⚠️ THIS IS A FREE PUBLIC SERVICE AND IT RATE-LIMITS. The first run of this
|
||||
script was refused with 429 on its fifth chunk. Backing off is the polite
|
||||
behaviour; it is also the correct one, because the alternative - catching
|
||||
the error and carrying on - would produce a file that is silently missing a
|
||||
month and says nothing about it. A refused request is not an empty month.
|
||||
"""
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": "goodwe-addon/DATA-02 (one-shot archival fetch)"})
|
||||
wait = 5.0
|
||||
for attempt in range(1, RETRIES + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code not in (429, 500, 502, 503, 504) or attempt == RETRIES:
|
||||
raise
|
||||
print(f" HTTP {e.code}; backing off {wait:.0f}s "
|
||||
f"(attempt {attempt}/{RETRIES})", file=sys.stderr)
|
||||
time.sleep(wait)
|
||||
wait *= 2
|
||||
raise SystemExit(f"unreachable: {url}")
|
||||
|
||||
|
||||
def local_midnight_utc(day: str) -> datetime:
|
||||
"""The INSTANT at which Brussels midnight begins on `day` (YYYY-MM-DD).
|
||||
|
||||
⚠️ THE WINDOW IS LOCAL, NOT UTC, AND THE FIRST RUN OF THIS SCRIPT PROVED WHY.
|
||||
Bounding it at 00:00 UTC starts the year at 02:00 CEST, which makes the first
|
||||
Brussels day 22 hours long - and 22 is not one of the two lengths a
|
||||
transition produces, so `report()` refused the file. A dataset whose first
|
||||
day is short is a dataset whose "days per year" arithmetic is quietly wrong
|
||||
everywhere downstream.
|
||||
|
||||
Derived from `brussels()` by trying the only two offsets Belgium has and
|
||||
keeping the one that round-trips, rather than by restating the EU rule here.
|
||||
"""
|
||||
for hours in (1, 2):
|
||||
guess = datetime.fromisoformat(f"{day}T00:00:00").replace(
|
||||
tzinfo=timezone(timedelta(hours=hours))).astimezone(UTC)
|
||||
if brussels(guess).isoformat() == f"{day}T00:00:00+0{hours}:00":
|
||||
return guess
|
||||
raise SystemExit(f"no Brussels midnight for {day!r} - brussels() disagrees "
|
||||
f"with itself, which is a bug in price.py, not here.")
|
||||
|
||||
|
||||
def _months(start: datetime, end: datetime):
|
||||
"""[start, end) in month-sized chunks, so a bad chunk names its own window."""
|
||||
a = start
|
||||
while a < end:
|
||||
b = min(end, (a.replace(day=1) + timedelta(days=32)).replace(day=1))
|
||||
yield a, b
|
||||
a = b
|
||||
|
||||
|
||||
def fetch(start: datetime, end: datetime):
|
||||
"""Raw {unix_second: EUR/MWh} over [start, end), deduplicated.
|
||||
|
||||
⚠️ A `null` price is DROPPED, never coerced. The API returns null for an
|
||||
interval it holds no result for, and `or 0.0` is the version of this line
|
||||
that ships a year of free electricity.
|
||||
"""
|
||||
seen, licences, nulls = {}, Counter(), 0
|
||||
for a, b in _months(start, end):
|
||||
# ⚠️ THE API'S `end` IS AN INCLUSIVE LOCAL DAY, AND THE WINDOW'S IS AN
|
||||
# EXCLUSIVE INSTANT. Ask for the day containing the LAST hour of the
|
||||
# chunk, derived from the instant rather than by subtracting a calendar
|
||||
# day from the bound: `b - 1 day` drops the final local day whenever the
|
||||
# window ends at local midnight, which is exactly how this window ends.
|
||||
# The first run lost 2026-08-31 that way, silently - 8736 points instead
|
||||
# of 8760 - and the points-plus-gaps accounting in report() is what
|
||||
# noticed. A day nobody asked for is not a day with no prices.
|
||||
first = brussels(a).date().isoformat()
|
||||
last = brussels(b - timedelta(hours=1)).date().isoformat()
|
||||
url = f"{API}?bzn={BZN}&start={first}&end={last}"
|
||||
d = _get(url)
|
||||
lic = str(d.get("license_info", ""))
|
||||
licences[lic] += 1
|
||||
if LICENCE_MUST_CONTAIN not in lic:
|
||||
raise SystemExit(
|
||||
f"REFUSING TO WRITE. {url} came back under licence {lic!r}, "
|
||||
f"which does not say {LICENCE_MUST_CONTAIN!r}. The BE zone was "
|
||||
f"redistributable when this card ran; if that has changed, the "
|
||||
f"right output is this script and a checksum, not the data.")
|
||||
unit = str(d.get("unit", ""))
|
||||
if "MWh" not in unit:
|
||||
raise SystemExit(
|
||||
f"REFUSING TO WRITE. {url} reports unit {unit!r}, not EUR/MWh. "
|
||||
f"A silent unit change is a factor of 1000 on every price.")
|
||||
for t, p in zip(d["unix_seconds"], d["price"]):
|
||||
if p is None:
|
||||
nulls += 1
|
||||
continue
|
||||
if start.timestamp() <= t < end.timestamp():
|
||||
seen[int(t)] = float(p)
|
||||
print(f" {a.date()} .. {b.date()} {len(d['price']):5d} intervals",
|
||||
file=sys.stderr)
|
||||
time.sleep(PACE_S)
|
||||
return seen, licences, nulls
|
||||
|
||||
|
||||
def to_hourly(raw: dict):
|
||||
"""Hourly means keyed by UTC hour. Returns (hours, gaps, shape_counts).
|
||||
|
||||
An hour is emitted only when every interval the source used for it arrived:
|
||||
1 of 1 where the source was hourly, 4 of 4 where it was quarter-hourly.
|
||||
Anything else is a gap and is named as one.
|
||||
"""
|
||||
buckets = defaultdict(dict)
|
||||
for t, p in raw.items():
|
||||
buckets[t - (t % 3600)][t % 3600] = p
|
||||
|
||||
hours, gaps, shapes = {}, [], Counter()
|
||||
for h in sorted(buckets):
|
||||
got = buckets[h]
|
||||
if set(got) == {0}:
|
||||
shapes["hourly"] += 1
|
||||
hours[h] = got[0]
|
||||
elif set(got) == {0, 900, 1800, 2700}:
|
||||
shapes["quarter_hourly_mean"] += 1
|
||||
hours[h] = sum(got.values()) / 4.0
|
||||
else:
|
||||
gaps.append({
|
||||
"hour_utc": datetime.fromtimestamp(h, UTC).isoformat(),
|
||||
"intervals_present": sorted(got),
|
||||
"why": "incomplete hour - dropped rather than averaged over "
|
||||
"what arrived; a partial read is not an observation",
|
||||
})
|
||||
return hours, gaps, shapes
|
||||
|
||||
|
||||
def canonical(hours: dict) -> list:
|
||||
"""9.3 points. THREE FIELDS, rendered at the Brussels offset for the instant."""
|
||||
return [{"start": brussels(datetime.fromtimestamp(h, UTC)).isoformat(),
|
||||
"eur_per_kwh": round(hours[h] / 1000.0, 6) + 0.0,
|
||||
"provisional": False}
|
||||
for h in sorted(hours)]
|
||||
|
||||
|
||||
def expected_hours(start: datetime, end: datetime) -> int:
|
||||
"""Whole hours in the window. Derived, rather than 8760 typed in."""
|
||||
return int((end - start).total_seconds() // 3600)
|
||||
|
||||
|
||||
def per_local_day(points) -> Counter:
|
||||
"""Points per BRUSSELS calendar day - the count that exposes a DST bug."""
|
||||
n = Counter()
|
||||
for p in points:
|
||||
n[p["start"][:10]] += 1
|
||||
return n
|
||||
|
||||
|
||||
def digest(points) -> str:
|
||||
"""A checksum over the POINTS, so reformatting the envelope does not move it."""
|
||||
h = hashlib.sha256()
|
||||
for p in points:
|
||||
h.update(f"{p['start']}|{p['eur_per_kwh']:.6f}|{int(p['provisional'])}\n"
|
||||
.encode())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def load(path: str) -> dict:
|
||||
op = gzip.open if path.endswith(".gz") else open
|
||||
with op(path, "rt", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def report(doc, *, start=None, end=None) -> int:
|
||||
"""Re-derive every claim the file makes about itself. Returns failures."""
|
||||
pts = doc["prices"]
|
||||
prov = doc["provenance"]
|
||||
bad = 0
|
||||
|
||||
def ck(name, cond):
|
||||
nonlocal bad
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
bad += 1
|
||||
|
||||
ck(f"the checksum over the {len(pts)} points matches the recorded one "
|
||||
f"({prov['sha256_of_points'][:16]}...)",
|
||||
digest(pts) == prov["sha256_of_points"])
|
||||
ck("every point has exactly the three canonical fields and nothing else",
|
||||
all(set(p) == {"start", "eur_per_kwh", "provisional"} for p in pts))
|
||||
ck("every start carries a UTC offset, and it is +01:00 or +02:00 - a naive "
|
||||
"timestamp here is a wall clock plus an assumption",
|
||||
bool(pts) and all(p["start"][-6:] in ("+01:00", "+02:00") for p in pts))
|
||||
ck("the series is strictly increasing in time, so no instant is duplicated",
|
||||
all(datetime.fromisoformat(a["start"]) < datetime.fromisoformat(b["start"])
|
||||
for a, b in zip(pts, pts[1:])))
|
||||
ck("consecutive points are exactly one hour apart AS INSTANTS, so the two "
|
||||
"02:00s of the October day are two hours rather than one",
|
||||
all((datetime.fromisoformat(b["start"])
|
||||
- datetime.fromisoformat(a["start"])) == timedelta(hours=1)
|
||||
for a, b in zip(pts, pts[1:])))
|
||||
|
||||
days = per_local_day(pts)
|
||||
odd = {d: n for d, n in days.items() if n != 24}
|
||||
ck(f"exactly two Brussels days are not 24 hours long, and they are the "
|
||||
f"transitions: {sorted(odd.items())}",
|
||||
sorted(odd.values()) == [23, 25] and len(odd) == 2)
|
||||
spring = [d for d, n in odd.items() if n == 23]
|
||||
autumn = [d for d, n in odd.items() if n == 25]
|
||||
ck(f"the 23-hour day {spring} is in March and the 25-hour day {autumn} is "
|
||||
f"in October, which is the direction the EU rule actually runs",
|
||||
len(spring) == 1 and len(autumn) == 1
|
||||
and spring[0][5:7] == "03" and autumn[0][5:7] == "10")
|
||||
if autumn:
|
||||
twice = [p["start"] for p in pts
|
||||
if p["start"].startswith(autumn[0] + "T02")]
|
||||
ck(f"...and 02:00 on {autumn[0]} occurs TWICE at two different offsets "
|
||||
f"{twice} - exactly the case a 24-slot local-hour array collapses",
|
||||
len(twice) == 2 and twice[0][-6:] == "+02:00"
|
||||
and twice[1][-6:] == "+01:00")
|
||||
ck(f"...and the two carry DIFFERENT prices, so the 25th hour is a real "
|
||||
f"observation and not the same one written down twice",
|
||||
len({p["eur_per_kwh"] for p in pts
|
||||
if p["start"].startswith(autumn[0] + "T02")}) == 2)
|
||||
if spring:
|
||||
ck(f"...and 02:00 on {spring[0]} does not occur at all, because that "
|
||||
f"wall clock reading never happened in Belgium",
|
||||
not any(p["start"].startswith(spring[0] + "T02") for p in pts))
|
||||
|
||||
if start and end:
|
||||
want = expected_hours(start, end)
|
||||
ck(f"{len(pts)} points for a window of {want} whole hours, and the "
|
||||
f"{len(prov['gaps'])} recorded gaps account for the difference "
|
||||
f"exactly", len(pts) + len(prov["gaps"]) == want)
|
||||
|
||||
# ⚠️ A ZERO IS NOT AUTOMATICALLY A DEFECT HERE, AND SAYING IT WAS WOULD BE
|
||||
# THE WRONG LESSON. The Belgian market really does clear at 0.00 EUR/MWh in
|
||||
# solar-glut hours; this window contains such hours. What must be impossible
|
||||
# is a zero that arrived by COERCION - a dropped read turned into a free
|
||||
# hour. That is excluded structurally rather than by outlawing the value:
|
||||
# nulls are dropped, incomplete hours become gaps, and the accounting above
|
||||
# shows points + gaps covering every hour of the window, so every emitted
|
||||
# point stands for intervals the source actually published.
|
||||
zeros = [p["start"] for p in pts if p["eur_per_kwh"] == 0.0]
|
||||
ck(f"the {len(zeros)} hours priced at exactly zero match the count recorded "
|
||||
f"at build time, so no zero appeared between the fetch and the file",
|
||||
len(zeros) == prov["exact_zero_hours"])
|
||||
ck("...and none of them is a coerced read: every emitted hour is backed by "
|
||||
"published intervals, which is what the points-plus-gaps accounting "
|
||||
"above establishes",
|
||||
len(prov["gaps"]) == 0 or all("intervals_present" in g
|
||||
for g in prov["gaps"]))
|
||||
ck("the figures are declared RAW WHOLESALE, and the all-in assumption is "
|
||||
"recorded as an assumption rather than applied",
|
||||
prov["all_in"] is False
|
||||
and "ASSUMPTION" in prov["all_in_assumption"]["status"])
|
||||
return bad
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--start", default="2025-09-01")
|
||||
ap.add_argument("--end", default="2026-09-01",
|
||||
help="exclusive; both bounds are BRUSSELS-local midnights")
|
||||
ap.add_argument("--out", default=None)
|
||||
ap.add_argument("--verify", default=None,
|
||||
help="re-derive the claims of an existing file; no network")
|
||||
a = ap.parse_args()
|
||||
|
||||
if a.verify:
|
||||
doc = load(a.verify)
|
||||
print(f"verifying {a.verify}")
|
||||
s = datetime.fromisoformat(doc["provenance"]["window_utc"]["start"])
|
||||
e = datetime.fromisoformat(doc["provenance"]["window_utc"]["end"])
|
||||
bad = report(doc, start=s, end=e)
|
||||
print("OK" if not bad else f"{bad} FAILED")
|
||||
return 1 if bad else 0
|
||||
|
||||
start = local_midnight_utc(a.start)
|
||||
end = local_midnight_utc(a.end)
|
||||
out = a.out or f"scenarios/epex-be-dayahead-{a.start}_{a.end}.json"
|
||||
|
||||
print(f"fetching {BZN} day-ahead {start.date()} .. {end.date()}",
|
||||
file=sys.stderr)
|
||||
raw, licences, nulls = fetch(start, end)
|
||||
hours, gaps, shapes = to_hourly(raw)
|
||||
pts = canonical(hours)
|
||||
|
||||
doc = {
|
||||
"provenance": {
|
||||
"card": "DATA-02",
|
||||
"what": "EPEX BE day-ahead auction clearing prices, hourly",
|
||||
"source": f"{API}?bzn={BZN} (Energy-Charts API, Fraunhofer ISE)",
|
||||
"upstream": "Bundesnetzagentur | SMARD.de",
|
||||
"licence": "CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)",
|
||||
"licence_checked": "before committing anything; the API's OpenAPI "
|
||||
"document lists BE among the CC BY 4.0 zones and "
|
||||
"the remaining zones as private/internal use "
|
||||
"only, so redistribution of THIS zone is "
|
||||
"permitted with attribution",
|
||||
"licence_as_returned": sorted(licences),
|
||||
"attribution": ATTRIBUTION,
|
||||
"cross_check": "SMARD chart_data 4996/DE over 2026-04-10 matched "
|
||||
"this feed on all 96 quarter-hours, max abs "
|
||||
"difference 0.00 EUR/MWh (2026-08-31)",
|
||||
"retrieved_utc": datetime.now(UTC).replace(microsecond=0).isoformat(),
|
||||
"fetched_by": "scenarios/fetch_epex_be.py",
|
||||
"window_utc": {"start": start.isoformat(), "end": end.isoformat()},
|
||||
"unit": "EUR per kWh (source publishes EUR/MWh; divided by 1000)",
|
||||
"all_in": False,
|
||||
"all_in_note": "RAW WHOLESALE. No supplier markup, no network or "
|
||||
"transmission tariff, no levies, no VAT. 9.3's "
|
||||
"canonical point is all-in and this is NOT; the "
|
||||
"consumer applies the assumption below, visibly.",
|
||||
"all_in_assumption": ALL_IN_ASSUMPTION,
|
||||
"resolution": {
|
||||
"note": "EPEX day-ahead moved to a 15-minute market time unit "
|
||||
"in Oct 2025, part-way through this window.",
|
||||
"hours_from_hourly_source": shapes["hourly"],
|
||||
"hours_from_quarter_hourly_mean": shapes["quarter_hourly_mean"],
|
||||
"aggregation": "unweighted mean of the four quarters; a stated "
|
||||
"approximation of the hourly product, and a "
|
||||
"LOWER bound on the spread a 15-minute strategy "
|
||||
"would have seen",
|
||||
},
|
||||
"timezone": "starts rendered at the Brussels offset by "
|
||||
"app.price.brussels(); instants carried in UTC, so the "
|
||||
"transition days come out 23 and 25 points by "
|
||||
"construction rather than by a special case",
|
||||
"nulls_dropped": nulls,
|
||||
"gaps": gaps,
|
||||
"gaps_note": "hours the source did not publish completely. Dropped, "
|
||||
"never zero-filled: a missing hour is not a free hour.",
|
||||
"points": len(pts),
|
||||
"exact_zero_hours": sum(1 for p in pts if p["eur_per_kwh"] == 0.0),
|
||||
"negative_hours": sum(1 for p in pts if p["eur_per_kwh"] < 0),
|
||||
"zero_note": "REAL CLEARINGS AT 0.00 EUR/MWh, not dropped reads. "
|
||||
"Nulls are dropped and incomplete hours become gaps, "
|
||||
"so points + gaps == hours in the window and no point "
|
||||
"stands in for something that failed to arrive.",
|
||||
"sha256_of_points": digest(pts),
|
||||
},
|
||||
"prices": pts,
|
||||
}
|
||||
|
||||
with open(out, "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(doc, f, indent=1, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
print(f"wrote {out} {len(pts)} points {os.path.getsize(out)} bytes",
|
||||
file=sys.stderr)
|
||||
print(f"verifying {out}")
|
||||
return 1 if report(doc, start=start, end=end) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,391 @@ check("charging is clamped by the low limit", r.target_w == -1000)
|
||||
r = resolve([Claim.set("loop", P_LOOP, -200), charge_only])
|
||||
check("charge-only permits charging", r.target_w == -200)
|
||||
|
||||
# =============================================================================
|
||||
# ARB-03: the actuator specs.md declared, and the three stages
|
||||
# =============================================================================
|
||||
# ⚠️ EVERY CHECK ABOVE THIS LINE IS UNEDITED, AND THAT IS ITSELF THE FIRST
|
||||
# ACCEPTANCE CRITERION. Not one of them names an actuator, so every claim they
|
||||
# build is a SITE claim - which is exactly the state main.py is in at N=1 - and
|
||||
# they still answer what they answered before the field existed. If a line above
|
||||
# had to be changed to accommodate `actuator`, stage 2 would not be the shipped
|
||||
# arbiter and the refactor would not be pure.
|
||||
|
||||
print()
|
||||
print("ARB-03 1. the field, and what its absence means")
|
||||
|
||||
lone = Claim.set("loop", P_LOOP, 900)
|
||||
check("a claim built without naming an actuator carries one anyway",
|
||||
lone.actuator == ACTUATOR_SITE)
|
||||
# ⚠️ THE TYPE ITSELF, NOT ONLY THE TWO HELPERS. Measured: with only the helper
|
||||
# checked, emptying the DATACLASS default survives this suite untouched, because
|
||||
# `Claim.set()` and `Claim.limit()` pass their own default down and the field's
|
||||
# never runs. Anything that builds a `Claim(...)` directly - a future strategy,
|
||||
# a `dataclasses.replace()` - would have got the unspecified actuator that this
|
||||
# card exists to remove.
|
||||
check("...and so does one built through the dataclass directly, which is the "
|
||||
"constructor the two helpers do not cover",
|
||||
Claim("loop", P_LOOP).actuator == ACTUATOR_SITE)
|
||||
check("...and it is an explicit SITE, not None and not an empty string. "
|
||||
"'unspecified' is what the shipped code had, and 'unspecified' is what "
|
||||
"made every claim implicitly about the one and only actuator",
|
||||
isinstance(lone.actuator, str) and lone.actuator != ""
|
||||
and lone.actuator is not None)
|
||||
check("...and `source` is still the first positional argument, which is what "
|
||||
"test_peakguard.py part 10f's AST scan reads off main.py to pin the "
|
||||
"claim-source set. specs.md declares `actuator` first; the tuple position "
|
||||
"is not the part of that declaration that carries meaning, and moving "
|
||||
"`source` off zero would blind the tripwire that guards SAFETY-25",
|
||||
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
|
||||
|
||||
print()
|
||||
print("ARB-03 2. the split: eight sources, each classified where it is BUILT")
|
||||
# The classification is graded against what each claim is ABOUT, restated here
|
||||
# so that the table in arbiter.py and the expectation in a test are not the same
|
||||
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
|
||||
# own AST, so a ninth source cannot arrive without both files noticing.
|
||||
SITE_SOURCES = {
|
||||
"loop": "regulates grid_w, the P1 measurement - one meter, one site",
|
||||
"peak": "the quarter-hour average offtake AT the grid connection",
|
||||
"arbitrage": "buys and sells at the grid against a day-ahead price",
|
||||
"safety": "'stopped' is the site's switch, 'inputs missing' is the "
|
||||
"site's own meter - neither is about a pack",
|
||||
# SAFETY-31, and it is the entry most easily filed on the wrong side.
|
||||
"circulation": "sum(|Bi|) - |sum(Bi)| is defined OVER THE ROSTER and is "
|
||||
"identically zero for any one unit - there is no fact of "
|
||||
"the matter about which of two opposing inverters is the "
|
||||
"offender, so there is nothing to file it per-unit on",
|
||||
}
|
||||
UNIT_SOURCES = {
|
||||
"device": "one inverter's own reported min/max rating",
|
||||
"supervised": "max_w caps what may be WRITTEN to one setpoint entity",
|
||||
"failsafe": "site-wide evidence, per-unit remedy: SAFETY-02 grades the "
|
||||
"command THIS controller put on THIS actuator",
|
||||
"maintenance": "the monthly recalibration is a pack procedure",
|
||||
}
|
||||
for _src, _why in sorted(SITE_SOURCES.items()):
|
||||
print(f" site {_src:<11} {_why}")
|
||||
for _src, _why in sorted(UNIT_SOURCES.items()):
|
||||
print(f" unit {_src:<11} {_why}")
|
||||
check("all NINE claim sources are classified, and only those nine",
|
||||
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
|
||||
check("the five that are about the GRID CONNECTION are site claims",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
|
||||
check("the four that are about a PACK, a DEVICE or one write are per-inverter",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
|
||||
check("a source nobody classified cannot be routed at all - actuator_for() "
|
||||
"raises rather than defaulting to the site, because defaulting to the "
|
||||
"site is the failure this card removes",
|
||||
_raises(lambda: actuator_for("reserve", "inverter-a", ("inverter-a",))))
|
||||
|
||||
print()
|
||||
print("ARB-03 3. at N=1 the site IS the unit, so nothing moves")
|
||||
# §6.2's identity: with one inverter the fabricated grid reading and the battery
|
||||
# setpoint are the same number, so there is one actuator and it is both.
|
||||
ONE = ("inverter-a",)
|
||||
TWO = ("inverter-a", "inverter-b")
|
||||
check("with one inverter every source routes to the site, per-inverter ones "
|
||||
"included - that is the identity, not the classification being ignored",
|
||||
all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE))
|
||||
check("...and with none configured at all, likewise",
|
||||
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
|
||||
check("with TWO the identity breaks and the split becomes real: the four "
|
||||
"per-inverter sources route to the unit this controller drives, the five "
|
||||
"site sources stay on the grid connection",
|
||||
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
|
||||
== {**{s: "inverter-a" for s in UNIT_SOURCES},
|
||||
**{s: ACTUATOR_SITE for s in SITE_SOURCES}})
|
||||
|
||||
print()
|
||||
print("ARB-03 4. stage 2 IS the shipped arbiter (AC 3)")
|
||||
# ⚠️ NOT ASSERTED, REPLAYED. Every claim list this file resolves above is run
|
||||
# through BOTH entry points and every field the shipped code reads off a
|
||||
# Resolution is compared. `resolve()` at N=1 is `site_resolve()` on the same
|
||||
# list, so a single edit inside stage 2 would show up in every check above as
|
||||
# well as here - and one that only affects the wrapper shows up only here.
|
||||
REPLAY = [
|
||||
[],
|
||||
[Claim.set("loop", P_LOOP, 900)],
|
||||
[Claim.set("loop", P_LOOP, 900), Claim.set("maintenance", P_MAINTENANCE, 2500)],
|
||||
[Claim.set("loop", P_LOOP, 4000),
|
||||
Claim.limit("supervised", P_SAFETY, lo=-2000, hi=2000, reason="max_w")],
|
||||
[Claim.set("loop", P_LOOP, 4000), Claim.limit("a", P_SAFETY, hi=3000),
|
||||
Claim.limit("b", P_SAFETY, hi=1500)],
|
||||
[Claim.set("loop", P_LOOP, 900), charge_only],
|
||||
[Claim.set("loop", P_LOOP, 900), charge_only, Claim.set("peak", P_PEAK, 2500)],
|
||||
[Claim.set("peak", P_PEAK, 9000), charge_only,
|
||||
Claim.limit("device", P_SAFETY, lo=-5000, hi=5000, reason="rating")],
|
||||
[Claim.set("loop", P_LOOP, 900), Claim.set("safety", P_SAFETY, 0, "inputs missing")],
|
||||
[Claim.set("loop", P_LOOP, 500), Claim.limit("x", P_SAFETY, lo=1000),
|
||||
Claim.limit("y", P_SAFETY, hi=200)],
|
||||
[Claim.set("loop", P_LOOP, -3000),
|
||||
Claim.limit("supervised", P_SAFETY, lo=-1000, hi=1000)],
|
||||
[Claim.set("loop", P_LOOP, -200), charge_only],
|
||||
[Claim.set("arbitrage", P_ARBITRAGE, -2000, "buy"),
|
||||
Claim.limit("peak", P_PEAK, lo=800.0, reason="quarter peak"),
|
||||
Claim.limit("supervised", P_SAFETY, lo=-2000, hi=2000, reason="max_w")],
|
||||
]
|
||||
|
||||
|
||||
def _shape(r):
|
||||
return (r.target_w, r.winner, r.bound_by, r.contradiction,
|
||||
list(r.considered), r.explain())
|
||||
|
||||
|
||||
_diff = [i for i, cl in enumerate(REPLAY)
|
||||
if _shape(resolve(cl)) != _shape(site_resolve(cl))]
|
||||
print(f" replayed {len(REPLAY)} claim lists through both entry points")
|
||||
check("NOT ONE of them resolves differently - target, winner, binder, "
|
||||
"contradiction flag, the considered list and the operator line are equal "
|
||||
"on every one. At N=1 the three stages are the shipped arbiter with "
|
||||
"bookkeeping either side of it", _diff == [] and len(REPLAY) >= 12)
|
||||
check("...and stage 3 hands the whole target to the one actuator there is, "
|
||||
"which is the N=1 identity rather than an allocation",
|
||||
resolve([Claim.set("loop", P_LOOP, 900)]).shares == {ACTUATOR_SITE: 900.0})
|
||||
check("...and says it allocated, because at N<=1 there is nothing to divide",
|
||||
resolve([Claim.set("loop", P_LOOP, 900)]).allocated)
|
||||
|
||||
print()
|
||||
print("ARB-03 5. THE CONTRADICTION THAT CANNOT BE WRITTEN DOWN (AC 4)")
|
||||
# SAFETY-25 §0a, constructed. A per-inverter reserve says "this pack must not
|
||||
# discharge" (`hi = 0`); the peak guard says "the site must deliver at least
|
||||
# 1500 W" (`lo = 1500`). At N=1 those are two bounds on ONE number and they
|
||||
# genuinely cross - which is today's behaviour, and today's hazard, and is left
|
||||
# exactly as it is. Filed on TWO actuators they are bounds on two different
|
||||
# numbers, and there is no expression in which one is the other's counterpart.
|
||||
SITE_SIDE = [Claim.set("loop", P_LOOP, -1500.0, "grid"),
|
||||
Claim.limit("peak", P_PEAK, lo=1500.0, reason="quarter peak")]
|
||||
CROSSED = SITE_SIDE + [Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0,
|
||||
reason="charge-only")]
|
||||
SPLIT = SITE_SIDE + [Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0,
|
||||
reason="charge-only", actuator="inverter-b")]
|
||||
r_crossed, r_split = resolve(CROSSED), resolve(SPLIT)
|
||||
print(f" both on one actuator: {r_crossed.explain()}")
|
||||
print(f" the `hi` on inverter-b: {r_split.explain()} "
|
||||
f"inverter-b bounds {r_split.unit_bounds['inverter-b'].explain()}")
|
||||
check("filed on ONE actuator the pair still contradicts and still parks the "
|
||||
"battery at 0 W in the billed quarter. That is the shipped behaviour and "
|
||||
"this card does not change it - at N=1 the two really are bounds on one "
|
||||
"number", r_crossed.contradiction and r_crossed.target_w == 0.0)
|
||||
check("filed on TWO the site keeps its floor: the unit `hi` is not a term of "
|
||||
"the site's `lo > hi` test, so there is no contradiction to flag",
|
||||
not r_split.contradiction and r_split.target_w == 1500.0)
|
||||
check("...and the constraint is not lost, it is CARRIED - inverter-b's own "
|
||||
"interval still says 'do not discharge', for the allocator to honour when "
|
||||
"it divides the 1500 W", r_split.unit_bounds["inverter-b"].hi == 0.0
|
||||
and r_split.unit_bounds["inverter-b"].hi_src == "maintenance")
|
||||
check("...and the site target is bit-for-bit what the site claims alone "
|
||||
"resolve to, so the unit claim contributed NOTHING to it - which is the "
|
||||
"'cannot be expressed' rather than 'is guarded against'",
|
||||
_shape(site_resolve(SITE_SIDE))[:4] == _shape(r_split)[:4])
|
||||
|
||||
# Swept rather than argued: no per-inverter source, at any bound, on any unit,
|
||||
# can reach the site's limits.
|
||||
_SWEEP_N = _SWEEP_BAD = 0
|
||||
for _src in sorted(UNIT_SOURCES):
|
||||
for _unit in ("inverter-a", "inverter-b"):
|
||||
for _lo, _hi in ((0.0, inf), (-inf, 0.0), (2500.0, inf), (-inf, -2500.0),
|
||||
(900.0, 100.0)):
|
||||
_r = resolve(SITE_SIDE + [Claim.limit(_src, P_MAINT_SHAPE, lo=_lo,
|
||||
hi=_hi, actuator=_unit)])
|
||||
_SWEEP_N += 1
|
||||
if _r.contradiction or _r.target_w != 1500.0:
|
||||
_SWEEP_BAD += 1
|
||||
print(f" swept {_SWEEP_N} (per-inverter source x unit x bound) filings "
|
||||
f"against a live site floor: {_SWEEP_BAD} disturbed the site target")
|
||||
check(f"NOT ONE of the {_SWEEP_N} filings contradicts the site floor or moves "
|
||||
f"the site target - including the last bound of each row, which is a unit "
|
||||
f"contradicting ITSELF (lo 900 > hi 100). A unit's own impossible pair "
|
||||
f"parks that unit; it does not park the site",
|
||||
_SWEEP_BAD == 0 and _SWEEP_N >= 40)
|
||||
_self_bad = resolve(SITE_SIDE + [Claim.limit("maintenance", P_MAINT_SHAPE,
|
||||
lo=900.0, hi=100.0,
|
||||
actuator="inverter-b")])
|
||||
check("...and that unit says so on its own bounds, so the fault is reported "
|
||||
"where it was made rather than swallowed",
|
||||
_self_bad.unit_bounds["inverter-b"].contradiction
|
||||
and "CONTRADICTION" in _self_bad.unit_bounds["inverter-b"].explain())
|
||||
|
||||
print()
|
||||
print("ARB-03 6. stage 1: one unit's claims, one unit's interval")
|
||||
mb = pre_resolve([Claim.limit("device", P_SAFETY, -5000, 5000, "rating"),
|
||||
Claim.limit("supervised", P_SAFETY, -2000, 2000, "max_w"),
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, reason="charge-only")])
|
||||
print(f" a unit filing rating, max_w and charge-only: {mb.explain()}")
|
||||
check("the most restrictive of the unit's own limits is the interval",
|
||||
mb.lo == -2000 and mb.hi == 0.0 and mb.hi_src == "maintenance"
|
||||
and mb.lo_src == "supervised")
|
||||
owned = pre_resolve([Claim.set("maintenance", P_MAINTENANCE, -2500, "charge"),
|
||||
Claim.limit("supervised", P_SAFETY, -2000, 2000, "max_w"),
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, reason="charge-only")])
|
||||
print(f" a unit whose maintenance owns the setpoint: {owned.explain()}")
|
||||
check("a unit's own winning SET gates its own limits exactly as clause 2 does "
|
||||
"at the site - the P_MAINT_SHAPE ceiling is not applied to a "
|
||||
"P_MAINTENANCE drain that outranks it", owned.hi == 2000.0)
|
||||
check("...and the SET's value travels BESIDE the interval, not inside it. "
|
||||
"Folding -2500 into [-2500, -2500] would turn 'clamped to max_w' into "
|
||||
"'contradicts max_w', which is a different answer and a wrong one",
|
||||
owned.pinned_w == -2500 and owned.pinned_by == "maintenance"
|
||||
and owned.gate == P_MAINTENANCE and not owned.contradiction)
|
||||
|
||||
print()
|
||||
print("ARB-03 7 / ALLOC-01: the seam, and what now occupies it")
|
||||
# ⚠️ THIS PART WAS "the seam is a seam, and NOT an allocator" AND IT GRADED THE
|
||||
# DECLINE. ALLOC-01 filled the seam, so the decline at N>=2 is gone - and the
|
||||
# checks that graded it are RE-AIMED rather than deleted, because the two halves
|
||||
# they were really about are both still true and both still worth a line: the
|
||||
# N<=1 pass-through is byte-identical, and the seam still does not BROADCAST.
|
||||
check("with no units the whole target goes to the site actuator",
|
||||
allocate(1500.0, {}) == ({ACTUATOR_SITE: 1500.0}, True))
|
||||
check("with one unit the share IS the site target - §6.2's N=1 identity, "
|
||||
"pass-through. ⚠️ A FLOAT, UNROUNDED AND UNCLAMPED: this is the "
|
||||
"compatibility promise every N=1 replay in this repository depends on, "
|
||||
"and the general path below does neither of those things",
|
||||
allocate(1500.0, {"inverter-a": UnitBounds()})
|
||||
== ({"inverter-a": 1500.0}, True))
|
||||
check("...and it is byte-identical on a fractional target too, which is where a "
|
||||
"'tidy this into the general path' refactor would show up: the general "
|
||||
"path rounds to the 1 W register, the pass-through must not",
|
||||
allocate(1500.4, {"inverter-a": UnitBounds()})
|
||||
== ({"inverter-a": 1500.4}, True))
|
||||
_shares, _ok = allocate(1500.0, {"inverter-a": UnitBounds(),
|
||||
"inverter-b": UnitBounds()})
|
||||
print(f" two units, ALLOC-01: shares={_shares} allocated={_ok}")
|
||||
check("⚠️ AND THE ONE THING THE SEAM MUST NEVER DO, WHICH IS THE WHOLE OF "
|
||||
"ARCH-01: it does not BROADCAST. Hand the site target to each inverter "
|
||||
"and each supplies it IN FULL - the gain at the meter becomes N times the "
|
||||
"per-unit gain, which admin/goodwe's two-master rig measures as a 4000 W "
|
||||
"peak-to-peak limit cycle at gain 1.0 with not one guard firing. This "
|
||||
"line went red on ARB-03's decline and it goes red on a broadcast, which "
|
||||
"is the point: it grades the OUTPUT, not the mechanism",
|
||||
1500.0 not in _shares.values())
|
||||
check("...it allocates instead, and the shares SUM to the site target exactly. "
|
||||
"That is the safety property: sum(s_i) = S is what makes the gain at the "
|
||||
"meter a convex combination of the per-unit gains for ANY split, so the "
|
||||
"weights are a comfort choice and a bad split cannot destabilise the site",
|
||||
_ok is True and sum(_shares.values()) == 1500
|
||||
and sorted(_shares) == ["inverter-a", "inverter-b"])
|
||||
check("...and an odd target still sums exactly, which is the half a per-unit "
|
||||
"round() gets wrong: 1501/2 is 750.5 twice, and two roundings of that are "
|
||||
"either 1500 or 1502. Largest remainder gives 751 and 750",
|
||||
sum(allocate(1501.0, {"inverter-a": UnitBounds(),
|
||||
"inverter-b": UnitBounds()})[0].values()) == 1501)
|
||||
check("⚠️ AND THE DECLINE IS NOT GONE, IT IS RE-AIMED. It is still correct when "
|
||||
"no safe split exists at all, and PR #40's reviewer proved that branch was "
|
||||
"unreachable and untested once already - removing its trigger without "
|
||||
"giving it a new one would rebuild that hole. Here: every inverter's "
|
||||
"master stale, so there is no envelope set to allocate into",
|
||||
allocate(1500.0, {"inverter-a": UnitBounds(),
|
||||
"inverter-b": UnitBounds()}, live=()) == ({}, False))
|
||||
|
||||
# ⚠️ AND `resolve()` HAS TO HONOUR THE ROSTER IT IS GIVEN, WHICH IS NOT THE SAME
|
||||
# THING AS HAVING A `units` PARAMETER. Left None the roster is DERIVED from the
|
||||
# claims, and that derivation is right only for a caller that files a claim for
|
||||
# every unit every cycle. Ignore the argument and derive it always - `if units
|
||||
# is None:` -> `if True:` - and a unit that filed nothing this cycle simply
|
||||
# CEASES TO EXIST: the site of two looks like a site of one, the seam passes the
|
||||
# whole grid target through to the single inverter that spoke, and ARCH-01's
|
||||
# fault is built with the evidence for it removed. That mutation left this whole
|
||||
# suite green until this check was written (PR #40 review), so it is graded on
|
||||
# the one observation that distinguishes the two - a SILENT unit.
|
||||
_silent = resolve([Claim.set("loop", P_LOOP, 900.0),
|
||||
Claim.limit("device", P_SAFETY, -2000, 2000, "rating",
|
||||
actuator="inverter-a")],
|
||||
units=TWO)
|
||||
print(f" roster of two, only inverter-a files: bounds="
|
||||
f"{sorted(_silent.unit_bounds)} allocated={_silent.allocated} "
|
||||
f"shares={_silent.shares}")
|
||||
check("resolve() HONOURS the roster it is handed rather than inferring one from "
|
||||
"who happened to speak. inverter-b filed nothing this cycle and is still "
|
||||
"a unit: it gets its own interval, and the site is still a site of TWO, "
|
||||
"so the target is SPLIT across two inverters",
|
||||
sorted(_silent.unit_bounds) == ["inverter-a", "inverter-b"]
|
||||
and _silent.shares == {"inverter-a": 450, "inverter-b": 450})
|
||||
check("...and that is the whole cost of ignoring the argument, stated as the "
|
||||
"number it would produce: derive the roster from the claims instead and "
|
||||
"the site is a site of ONE, so the seam passes ALL 900 W through to "
|
||||
"inverter-a - the silent inverter's absence read as a smaller site, and "
|
||||
"inverter-a told to do the whole site's work on its own. ⚠️ ALLOC-01 "
|
||||
"makes this mutation WORSE, not better: before, both answers refused, so "
|
||||
"only the roster listing distinguished them; now the wrong one produces a "
|
||||
"plausible number that is twice what inverter-a should be given",
|
||||
resolve([Claim.set("loop", P_LOOP, 900.0),
|
||||
Claim.limit("device", P_SAFETY, -2000, 2000, "rating",
|
||||
actuator="inverter-a")]).shares
|
||||
== {"inverter-a": 900.0})
|
||||
check("...and left None the roster IS derived from the claims, which is the "
|
||||
"documented default every N=1 replay above depends on - so the parameter "
|
||||
"changes the answer only where the two disagree",
|
||||
resolve([Claim.set("loop", P_LOOP, 900.0),
|
||||
Claim.limit("device", P_SAFETY, -2000, 2000, "rating",
|
||||
actuator="inverter-a")], units=None).allocated is True)
|
||||
|
||||
print()
|
||||
print("ARB-03 8. verified by breaking it: a claim routed to the wrong stage (AC 6)")
|
||||
# The `peak` floor, misfiled on a unit. It is the site's floor - if it lands on
|
||||
# one inverter's interval the SITE keeps no floor at all, and the loop is free to
|
||||
# go on charging through the quarter that sets the month's bill.
|
||||
mis_peak = SITE_SIDE[:1] + [Claim.limit("peak", P_PEAK, lo=1500.0,
|
||||
reason="quarter peak",
|
||||
actuator="inverter-b")]
|
||||
r_mis = resolve(mis_peak)
|
||||
print(f" peak floor misfiled on inverter-b: {r_mis.explain()} "
|
||||
f"scope_errors={r_mis.scope_errors}")
|
||||
check("the mis-routing is CAUGHT: scope_violations names the source and the "
|
||||
"actuator it does not belong on", len(r_mis.scope_errors) == 1
|
||||
and "peak" in r_mis.scope_errors[0])
|
||||
check("...and the reason it must be caught, measured rather than argued: with "
|
||||
"the floor on a unit the SITE has no floor, so the loop charges 1500 W "
|
||||
"into the quarter the guard was trying to shave",
|
||||
r_mis.target_w == -1500.0 and resolve(SITE_SIDE).target_w == 1500.0)
|
||||
check("a UNIT source filed on the site at a site that HAS named units is "
|
||||
"caught too - that is the direction that produces `lo > hi`",
|
||||
scope_violations(SITE_SIDE + [
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0),
|
||||
Claim.limit("device", P_SAFETY, -1, 1, actuator="inverter-b")]) != [])
|
||||
check("...and at N=1, where every source legitimately routes to the site, the "
|
||||
"same list is clean. The check grades ROUTING, not tidiness",
|
||||
scope_violations(SITE_SIDE + [
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0)]) == [])
|
||||
check("an unclassified ninth source is a violation wherever it is filed",
|
||||
scope_violations([Claim.limit("reserve", P_LOOP, hi=0.0)]) != [])
|
||||
check("and the eight, routed by actuator_for() at a two-inverter site, are "
|
||||
"clean - which is what says the router and the checker agree",
|
||||
scope_violations(
|
||||
[Claim.limit(s, P_SAFETY, -1, 1,
|
||||
actuator=actuator_for(s, "inverter-a", TWO))
|
||||
for s in CLAIM_SCOPE]) == [])
|
||||
|
||||
# --- ALLOC-01: AND NOW SOMETHING HAPPENS ABOUT IT ---------------------------
|
||||
# ⚠️ ARB-03 LEFT THIS DELIBERATELY INERT - `scope_violations()` logged and did
|
||||
# not change the resolution, and nothing graded the consequence. ALLOC-01 had to
|
||||
# decide, and decided the allocator CONSUMES it as a REFUSAL rather than as an
|
||||
# input. The reasoning is at `allocator.plan()`'s REFUSAL 1 and the short form
|
||||
# is: the mis-routed claim has already been partitioned by the time stage 3
|
||||
# runs, so its effect is baked into S or into somebody's interval; "consuming"
|
||||
# it as an allocation input would mean RE-ROUTING it, which needs a guess about
|
||||
# which inverter it belonged to that nothing here can make. What is left is that
|
||||
# S itself may be wrong, and dividing a number that may be wrong is worse than
|
||||
# not dividing it.
|
||||
print(f" and stage 3's answer to it: {r_mis.allocation.explain()}")
|
||||
check("⚠️ the mis-routing is no longer only a log line: stage 3 REFUSES to "
|
||||
"allocate on a claim list it has been told is mis-routed, so main.py "
|
||||
"fails toward inaction and shouts instead of dividing a number that may "
|
||||
"be wrong. That is the same answer clause 3 already gives a contradiction",
|
||||
r_mis.allocated is False and r_mis.shares == {}
|
||||
and "mis-routed" in r_mis.allocation.refused)
|
||||
check("...and it is NOT consumed as an ENVELOPE, which is the tempting reading "
|
||||
"and the wrong one: a unit-scoped claim that landed on the site names no "
|
||||
"unit, so promoting it means either guessing one or applying one "
|
||||
"inverter's rating to a sibling that does not have it. Nothing here is "
|
||||
"promoted - the refusal carries no shares at all",
|
||||
r_mis.allocation.plans == () and r_mis.allocation.dropped == ())
|
||||
check("...and a correctly routed cycle is unaffected, which is what says this "
|
||||
"grades ROUTING and not the presence of units: the same site claims with "
|
||||
"the floor where it belongs allocate normally",
|
||||
resolve(SITE_SIDE, units=TWO).allocated is True)
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,220 @@
|
||||
"""Runnable check for the control loop's cadence. `python3 test_loop.py`
|
||||
|
||||
SAFETY-12: the loop must evaluate on a clock as well as on a change. Before the
|
||||
fix, `run_control()` ran `cycle()` only when the meter value differed from the
|
||||
previous poll, so a meter that repeats a value ran NO control cycles while the
|
||||
heartbeat kept re-emitting the last command - measured on the rig during TEL-05
|
||||
as 70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
|
||||
|
||||
⚠️ THIS TEST TAKES ~95 SECONDS OF WALL CLOCK (measured 93 s), on purpose, and
|
||||
that figure is the only thing standing between it and a future developer who
|
||||
decides a slow test is a broken one. The thing under test is a wall-clock floor
|
||||
inside a loop that sleeps 1 s; mocking the clock would leave the assertion
|
||||
testing the mock. It drives the real `run_control()` and counts what actually
|
||||
happened. Eight runs of the real loop is what the eight scenarios cost.
|
||||
|
||||
⚠️ If you shorten the windows, re-measure and update this number and the one in
|
||||
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
|
||||
import sys
|
||||
import time
|
||||
|
||||
from app.main import Controller
|
||||
|
||||
fails = []
|
||||
NEVER = 1e9 # cycle_floor_s so large the floor can never elapse: pre-fix
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class FakeHass:
|
||||
"""A scripted meter and a plant that tracks the command instantly."""
|
||||
|
||||
def __init__(self, meter, soc_dies_at=None):
|
||||
self.meter = meter
|
||||
self.soc_dies_at = soc_dies_at
|
||||
self.batt = 0.0
|
||||
self.writes = []
|
||||
self.t0 = time.monotonic()
|
||||
|
||||
async def limits(self, entity):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
now = time.monotonic() - self.t0
|
||||
if entity == "sensor.grid":
|
||||
return self.meter(now)
|
||||
if entity == "sensor.batt":
|
||||
return self.batt
|
||||
if entity == "sensor.soc":
|
||||
if self.soc_dies_at is not None and now >= self.soc_dies_at:
|
||||
return None
|
||||
return 50.0
|
||||
return None
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append(float(value))
|
||||
self.batt = float(value)
|
||||
return True
|
||||
|
||||
|
||||
class _Store:
|
||||
data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 1000,
|
||||
"deadband_w": 15, "target_grid_w": -10, "step_w": 10,
|
||||
"heartbeat_s": 10, "stale_input_s": 15}
|
||||
|
||||
|
||||
def drive(meter, floor, seconds, soc_dies_at=None, **over):
|
||||
"""Run the real control loop for `seconds`; return (cycle times, writes)."""
|
||||
async def go():
|
||||
hass = FakeHass(meter, soc_dies_at)
|
||||
c = Controller(dict(BASE, cycle_floor_s=floor, **over), hass, _Store(),
|
||||
_Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
stamps = []
|
||||
real = c.cycle
|
||||
|
||||
async def counted():
|
||||
stamps.append(time.monotonic() - hass.t0)
|
||||
await real()
|
||||
|
||||
c.cycle = counted
|
||||
task = asyncio.create_task(c.run_control())
|
||||
await asyncio.sleep(seconds)
|
||||
c.stopping = True
|
||||
await asyncio.wait_for(task, timeout=10)
|
||||
return stamps, hass.writes, c.target, c.reason
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
def worst_gap(stamps, seconds):
|
||||
"""Longest stretch with no cycle, including the tail after the last one."""
|
||||
edges = [0.0] + list(stamps) + [seconds]
|
||||
return max(b - a for a, b in zip(edges, edges[1:]))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("a meter that repeats the same value")
|
||||
FLAT = lambda t: 350.0 # noqa: E731
|
||||
FLOOR, SECONDS = 3.0, 12.0
|
||||
|
||||
pre_stamps, pre_writes, _, _ = drive(FLAT, NEVER, SECONDS)
|
||||
# The defect itself, demonstrated rather than described: one cycle at startup
|
||||
# and then nothing for the rest of the window, while writes keep happening.
|
||||
check("pre-fix: a constant meter runs exactly one cycle, at startup",
|
||||
len(pre_stamps) == 1)
|
||||
check("...while the heartbeat keeps writing anyway, so nothing looks wrong",
|
||||
len(pre_writes) >= 2)
|
||||
|
||||
post_stamps, post_writes, _, _ = drive(FLAT, FLOOR, SECONDS)
|
||||
check("post-fix: the same constant meter runs cycles throughout",
|
||||
len(post_stamps) >= int(SECONDS / FLOOR) - 1)
|
||||
# The actual acceptance criterion: a BOUND on the gap, not a count.
|
||||
gap = worst_gap(post_stamps, SECONDS)
|
||||
check(f"...with no gap longer than the floor ({gap:.1f}s <= {FLOOR + 1.5:.1f}s)",
|
||||
gap <= FLOOR + 1.5)
|
||||
check("...and the loop actually corrected, instead of holding a stale command",
|
||||
len(post_writes) > len(pre_writes))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("a meter that changes normally - nothing may move")
|
||||
# ⚠️ THE REGRESSION GUARD, and the more important half. The floor is a deadline
|
||||
# on the gap between cycles, reset by every cycle whichever trigger fired - not
|
||||
# a second scheduler. On a healthy meter it must never elapse, so the cycle
|
||||
# count, the write count and the commanded values must be IDENTICAL either side
|
||||
# of the fix. Anything else is a new write cadence, which SAFETY-12 forbids:
|
||||
# the deadband, step_w and the 10 s force-write decide what reaches the wire,
|
||||
# and this change is only allowed to alter when the loop thinks.
|
||||
STEPS = [0.0, 300.0, 850.0, -400.0, 120.0, -900.0, 60.0]
|
||||
LIVE = lambda t: STEPS[int(t // 2) % len(STEPS)] # noqa: E731
|
||||
|
||||
a_stamps, a_writes, _, _ = drive(LIVE, NEVER, 12.0)
|
||||
b_stamps, b_writes, _, _ = drive(LIVE, FLOOR, 12.0)
|
||||
check("a changing meter runs the same number of cycles as before the fix",
|
||||
len(a_stamps) == len(b_stamps))
|
||||
check("...commanding exactly the same sequence of values", a_writes == b_writes)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("a meter parked at the target - the converged operating point")
|
||||
# The case TEL-05 warned about: at the converged target the power figure
|
||||
# legitimately repeats for minutes. The loop must still run, and must still
|
||||
# write nothing but the heartbeat, because the deadband - not the arrival
|
||||
# trigger - is what holds the command still.
|
||||
AT_TARGET = lambda t: -10.0 # noqa: E731
|
||||
c_stamps, c_writes, _, _ = drive(AT_TARGET, NEVER, 12.0)
|
||||
d_stamps, d_writes, _, _ = drive(AT_TARGET, FLOOR, 12.0)
|
||||
check("the loop evaluates at the converged point too", len(d_stamps) > len(c_stamps))
|
||||
check("...and the deadband still holds the command, so writes are unchanged",
|
||||
d_writes == c_writes)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("an input dies while the meter repeats - the safety claim must still fire")
|
||||
# ⚠️ THE STRONGEST PROPERTY IN SAFETY-12, and the one that makes it a safety fix
|
||||
# rather than a latency fix. The `inputs missing -> 0 W` claim lives INSIDE
|
||||
# cycle() (main.py, the stale_input_s branch and the P_SAFETY claim below it).
|
||||
# Pre-fix, on a meter whose value happened to repeat, cycle() was never called
|
||||
# again - so that claim could not fire AT ALL. The controller held its last
|
||||
# command through an input outage of unbounded length with the status line still
|
||||
# reading "loop -> N W".
|
||||
#
|
||||
# Layer 1 does not cover this either on a `meter_source: off` install: no P1 age
|
||||
# sensor is published (see Controller.publish), so the firmware has nothing to
|
||||
# veto on, and the heartbeat keeps its watchdog fed the whole time.
|
||||
#
|
||||
# stale_input_s is shortened to 3 s here so the outage resolves inside a test
|
||||
# rather than inside a quarter of a minute. The mechanism is the default one.
|
||||
c2_stamps, c2_writes, pre_target, pre_reason = drive(
|
||||
FLAT, NEVER, 10.0, soc_dies_at=4.0, stale_input_s=3)
|
||||
check("pre-fix: a dead input during a constant meter never reaches the safety claim",
|
||||
pre_target != 0.0 and "safety" not in pre_reason)
|
||||
check(f"...so it holds its last command instead ({pre_target:g} W, {pre_reason!r})",
|
||||
pre_target == 220.0)
|
||||
|
||||
d2_stamps, d2_writes, post_target, post_reason = drive(
|
||||
FLAT, FLOOR, 10.0, soc_dies_at=4.0, stale_input_s=3)
|
||||
check("post-fix: the same outage commands 0 W", post_target == 0.0)
|
||||
check(f"...through the safety claim, not by accident ({post_reason!r})",
|
||||
"safety" in post_reason)
|
||||
check("...and the 0 W actually reached the device", 0.0 in d2_writes)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -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)}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,918 @@
|
||||
"""Runnable check for FCST-03 - provisional prices. `python3 test_price_forecast.py`
|
||||
|
||||
WHAT THIS FILE IS DEFENDING, in the order the ticket's acceptance criteria name
|
||||
it:
|
||||
|
||||
AC1 Every price point carries a `provisional` flag MATCHING ITS TRUE
|
||||
PROVENANCE - a point filled from yesterday's profile is provisional even
|
||||
when the number happens to be right, a point the feed published keeps
|
||||
whatever flag the feed gave it, and an unmarked carry-forward is not
|
||||
constructible rather than merely discouraged.
|
||||
AC2 An irreversible action gated on a provisional price is REJECTED, with the
|
||||
spec's own example - a maintenance-phase transition - actually refused:
|
||||
the phase machine is still in `idle` afterwards.
|
||||
AC3 Real-time / imbalance prices get MEASURABLY WIDER thresholds. Measured,
|
||||
by finding the smallest spread each regime will accept and comparing the
|
||||
two numbers - not by reading a constant back out of the module that
|
||||
defines it.
|
||||
|
||||
THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
||||
|
||||
* EVERY VERDICT IS DERIVED. The carried/published split is re-derived here
|
||||
from the set of hours the fixture published, by different arithmetic than
|
||||
app/price_forecast.py uses; the DST day lengths come from the offset
|
||||
difference rather than from a 23 or a 25 typed into a check; the widening
|
||||
factor is MEASURED by bisecting the accept/refuse boundary and then
|
||||
compared with the declared one.
|
||||
|
||||
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE, and
|
||||
this ticket is full of that trap:
|
||||
- YESTERDAY AND TODAY MUST PRICE EVERY HOUR DIFFERENTLY, or a fill that
|
||||
carried the wrong day forward is invisible. Asserted first, as a check.
|
||||
- THE PROVISIONAL HOURS MUST CONTAIN THE CHEAPEST HOUR, or "arbitrage
|
||||
ignores provisional hours" grades nothing: the firm-only spread and the
|
||||
whole-series spread would be the same number. Asserted before it is
|
||||
used, and it is the realistic shape of the danger - yesterday's cheap
|
||||
hour luring a charge that today's prices do not justify.
|
||||
- THE TWO WIDENING FACTORS MUST DIFFER, or `max`, `sum` and `product` are
|
||||
indistinguishable combination rules. Asserted, then all three are
|
||||
computed and only one matches what the code does.
|
||||
- THE LOCAL-HOUR FILL AND A NAIVE `t - 24 h` FILL MUST DISAGREE on the
|
||||
transition days, or the DST check passes for both implementations.
|
||||
Asserted, then the correct one is graded.
|
||||
- PRICES ARE WRITTEN TO SIX DECIMALS with distinct 3-, 4- and 6-dp
|
||||
roundings, so an implementation that rounds differently is visible.
|
||||
(TARIFF-03's fixtures were 3-dp, where those three are one number.)
|
||||
|
||||
* A FAILED READ IS NOT AN OBSERVATION. A missing price is not a zero price
|
||||
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
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app import price as _price
|
||||
from app import price_forecast as _fc
|
||||
from app import static_tariff as _st
|
||||
from app.maintenance import (
|
||||
CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN, DRAIN, IDLE, MaintConfig, Maintenance,
|
||||
)
|
||||
from app.price import (
|
||||
Absence, PricePoint, PriceProduct, PriceRead, PROVIDERS, Uncertainty,
|
||||
WIDEN_FACTOR, brussels, brussels_offset, plan_is_provisional,
|
||||
required_spread, uncertainty_of, widen_factor,
|
||||
)
|
||||
from app.price_forecast import (
|
||||
MAINTENANCE_CHARGE, MAINTENANCE_DRAIN, Gate, Irreversible, committed_hours,
|
||||
fill_from_yesterday, price_permits, begin_phase,
|
||||
)
|
||||
from app.arbitrage import ArbitrageDecision
|
||||
from app.static_tariff import Arbitrage, TariffPlan
|
||||
|
||||
UTC = timezone.utc
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def raises(fn, exc):
|
||||
"""True only if fn() raises EXACTLY `exc`. A different exception is False -
|
||||
'it was refused' and 'it broke on the way to being refused' must not grade
|
||||
the same."""
|
||||
try:
|
||||
fn()
|
||||
except exc:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
class _Raised:
|
||||
def __init__(self, e):
|
||||
self.e = e
|
||||
|
||||
def __eq__(self, other):
|
||||
return False
|
||||
|
||||
def __hash__(self):
|
||||
return id(self)
|
||||
|
||||
def __bool__(self):
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return f"<raised {type(self.e).__name__}: {self.e}>"
|
||||
|
||||
|
||||
def derived(fn):
|
||||
"""A value a check grades. An exception becomes `_Raised`, which equals
|
||||
nothing and is falsy - NOT a default."""
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _Raised(e)
|
||||
|
||||
|
||||
def near(a, b, tol=1e-9):
|
||||
try:
|
||||
return abs(float(a) - float(b)) <= tol
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures.
|
||||
#
|
||||
# ⚠️ SIX-DECIMAL PRICES, DELIBERATELY. TARIFF-03's fixtures were 0.3410 /
|
||||
# 0.2570, where rounding to 3, 4 and 6 decimals gives the same number - so an
|
||||
# implementation that rounded differently from the one being graded survived
|
||||
# every check. These are chosen so those three roundings differ.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def y_price(h):
|
||||
"""Yesterday's all-in EUR/kWh for local hour h."""
|
||||
return round(0.123437 + 0.010700 * h, 6)
|
||||
|
||||
|
||||
def t_price(h):
|
||||
"""Today's PUBLISHED all-in EUR/kWh for local hour h. Different from
|
||||
yesterday's at every hour, on purpose - see the degeneracy note above."""
|
||||
return round(0.206562 + 0.009300 * h, 6)
|
||||
|
||||
|
||||
def local_midnight(y, m, d) -> datetime:
|
||||
"""The UTC instant of Brussels local midnight on that date, derived from
|
||||
`brussels_offset` rather than from a table of dates."""
|
||||
naive = datetime(y, m, d, 0, 0)
|
||||
for hours in (1, 2):
|
||||
guess = naive.replace(tzinfo=UTC) - timedelta(hours=hours)
|
||||
if brussels_offset(guess).utcoffset(None) == timedelta(hours=hours):
|
||||
return guess
|
||||
raise AssertionError(f"no consistent Brussels offset for {naive}")
|
||||
|
||||
|
||||
def hours_between(a, b):
|
||||
out, u = [], a
|
||||
while u < b:
|
||||
out.append(u)
|
||||
u += timedelta(hours=1)
|
||||
return out
|
||||
|
||||
|
||||
def series_of(instants, price_of, product, provider, *, provisional):
|
||||
return PriceRead.series(
|
||||
[PricePoint(start=brussels(u), eur_per_kwh=price_of(u),
|
||||
provisional=provisional) for u in instants],
|
||||
product, provider, instants[0])
|
||||
|
||||
|
||||
# A plain winter day: local 2026-01-15, 24 hours, no transition in sight.
|
||||
D0 = local_midnight(2026, 1, 15)
|
||||
D1 = local_midnight(2026, 1, 16)
|
||||
YEST0 = local_midnight(2026, 1, 14)
|
||||
DAY = hours_between(D0, D1)
|
||||
YDAY = hours_between(YEST0, D0)
|
||||
|
||||
YESTERDAY = series_of(YDAY, lambda u: y_price(brussels(u).hour),
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead",
|
||||
provisional=False)
|
||||
# The feed has published the first 12 hours of today and nothing after - the
|
||||
# ordinary state of a dynamic site before the 14-16 h CET print (§5.4).
|
||||
PUBLISHED_N = 12
|
||||
TODAY_PART = series_of(DAY[:PUBLISHED_N], lambda u: t_price(brussels(u).hour),
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead",
|
||||
provisional=False)
|
||||
|
||||
|
||||
print()
|
||||
print("0. the fixtures are not degenerate (checked before anything is graded)")
|
||||
|
||||
check("yesterday and today price EVERY shared hour differently, so a fill that "
|
||||
"carried the wrong day forward would be visible in the value and not "
|
||||
"only in the flag",
|
||||
all(y_price(h) != t_price(h) for h in range(24)))
|
||||
_roundings = derived(lambda: {round(y_price(3), n) for n in (3, 4, 6)})
|
||||
check(f"...and a fixture price rounds to three DIFFERENT numbers at 3, 4 and 6 "
|
||||
f"decimals {sorted(_roundings)}, so a divergent rounding cannot hide",
|
||||
len(_roundings) == 3)
|
||||
check("...and both widening factors differ, so max / sum / product are three "
|
||||
"distinguishable answers",
|
||||
len(set(WIDEN_FACTOR.values())) == len(WIDEN_FACTOR) >= 2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("1. AC1 - the flag matches PROVENANCE, and an unmarked fill is not "
|
||||
"constructible")
|
||||
|
||||
FILLED = fill_from_yesterday(TODAY_PART, yesterday=YESTERDAY,
|
||||
since=D0, until=D1, stamp=D0)
|
||||
|
||||
_pub_starts = {u for u in DAY[:PUBLISHED_N]}
|
||||
_expected_prov = {u: (u not in _pub_starts) for u in DAY}
|
||||
_got_prov = derived(lambda: {p.start.astimezone(UTC): p.provisional
|
||||
for p in FILLED.points})
|
||||
check(f"the filled series covers every hour of the day "
|
||||
f"{len(FILLED.points) if FILLED.kind == 'series' else FILLED}",
|
||||
FILLED.kind == "series" and len(FILLED.points) == len(DAY))
|
||||
check("...and EVERY point's flag equals what its provenance says it should be, "
|
||||
"re-derived here from the set of hours the fixture published",
|
||||
_got_prov == _expected_prov)
|
||||
_carried = derived(lambda: [p for p in FILLED.points if p.provisional])
|
||||
check(f"...which is {len(DAY) - PUBLISHED_N} carried hours and {PUBLISHED_N} "
|
||||
f"published ones, counted rather than asserted",
|
||||
len(_carried) == len(DAY) - PUBLISHED_N)
|
||||
check("...and the carried hours carry YESTERDAY's number for the same wall "
|
||||
"clock hour, not today's and not a neighbour's",
|
||||
all(near(p.eur_per_kwh, y_price(brussels(p.start).hour))
|
||||
for p in _carried))
|
||||
check("...while the published hours carry TODAY's, so the two halves are not "
|
||||
"the same data wearing two flags",
|
||||
all(near(p.eur_per_kwh, t_price(brussels(p.start).hour))
|
||||
for p in FILLED.points if not p.provisional))
|
||||
|
||||
# ⚠️ THE COPY IS THE DEFECT. A published point that survives as a COPY is one
|
||||
# whose flag could have been rewritten on the way through; identity is the only
|
||||
# property that rules that out for every field at once.
|
||||
check("a published point is passed through BY IDENTITY - the object the "
|
||||
"provider produced is the object in the filled series",
|
||||
all(any(p is q for q in TODAY_PART.points)
|
||||
for p in FILLED.points if not p.provisional))
|
||||
check("...and the canonical §9.3 dict carries the flag out to the wire for "
|
||||
"both kinds",
|
||||
FILLED.canonical()["prices"][0]["provisional"] is False
|
||||
and FILLED.canonical()["prices"][-1]["provisional"] is True)
|
||||
check("...and plan_is_provisional() sees the series as provisional overall, "
|
||||
"because at least one hour is",
|
||||
plan_is_provisional(FILLED) is True)
|
||||
|
||||
# The audit is a post-condition that RAISES. Feed it the exact defect.
|
||||
_stale_copy = PricePoint(start=FILLED.points[-1].start,
|
||||
eur_per_kwh=FILLED.points[-1].eur_per_kwh,
|
||||
provisional=False) # the copy-the-flag bug
|
||||
check("the provenance audit REFUSES a carried hour flagged non-provisional - "
|
||||
"the 'stale but plausible' point, which is SAFETY-18's frozen-but-fresh "
|
||||
"meter one layer up",
|
||||
raises(lambda: _fc._audit([_stale_copy], {}), ValueError))
|
||||
check("...and refuses a published hour that arrived as a COPY rather than as "
|
||||
"the published object, which is where a flag gets lost",
|
||||
raises(lambda: _fc._audit(
|
||||
[PricePoint(start=TODAY_PART.points[0].start,
|
||||
eur_per_kwh=TODAY_PART.points[0].eur_per_kwh,
|
||||
provisional=TODAY_PART.points[0].provisional)],
|
||||
{D0: TODAY_PART.points[0]}), ValueError))
|
||||
check("...and never repairs a flag: the audit's only outcomes are silence and "
|
||||
"an exception, so a series it passed was already correct",
|
||||
_fc._audit(list(FILLED.points), {u: TODAY_PART.points[i]
|
||||
for i, u in enumerate(DAY[:PUBLISHED_N])})
|
||||
is None)
|
||||
|
||||
# ⚠️ A PUBLISHED POINT MAY BE PROVISIONAL. eneco_eddy's current-quarter print
|
||||
# is settled after the fact and says so (§9.2). "Published" is not "firm".
|
||||
_eddy = series_of(DAY[:2], lambda u: t_price(brussels(u).hour),
|
||||
PriceProduct.REALTIME, "eneco_eddy", provisional=True)
|
||||
_eddy_filled = derived(lambda: fill_from_yesterday(
|
||||
_eddy, yesterday=YESTERDAY, since=D0, until=D1, stamp=D0))
|
||||
check("a PUBLISHED point that the feed itself flagged provisional keeps that "
|
||||
"flag - the audit checks provenance, and does not force published hours "
|
||||
"to False",
|
||||
_eddy_filled.kind == "series"
|
||||
and all(p.provisional for p in _eddy_filled.points[:2])
|
||||
and _eddy_filled.points[0] is _eddy.points[0])
|
||||
|
||||
# The structural half: the constructor has nothing to pass, and the file is
|
||||
# scanned so the pass-through idiom cannot come back looking ordinary.
|
||||
check("_carried() has NO `provisional` parameter, so a caller has nothing to "
|
||||
"forget, default, or copy from the point being carried",
|
||||
"provisional" not in inspect.signature(_fc._carried).parameters)
|
||||
_SRC = derived(lambda: open(os.path.join(HERE, "app", "price_forecast.py"),
|
||||
encoding="utf-8").read())
|
||||
|
||||
|
||||
def _nonliteral_provisional(src):
|
||||
"""Every `PricePoint(...)` in the module whose `provisional` is not a
|
||||
literal. AST, not a substring: the module docstring quotes the defect it
|
||||
exists to prevent, and a text scan would match its own documentation."""
|
||||
out = []
|
||||
for node in ast.walk(ast.parse(src)):
|
||||
if isinstance(node, ast.Call) and \
|
||||
getattr(node.func, "id", None) == "PricePoint":
|
||||
kw = {k.arg: k.value for k in node.keywords}
|
||||
v = kw.get("provisional")
|
||||
if not isinstance(v, ast.Constant):
|
||||
out.append(ast.dump(node)[:80])
|
||||
return out
|
||||
|
||||
|
||||
_leaks = derived(lambda: _nonliteral_provisional(_SRC))
|
||||
check(f"no PricePoint built in app/price_forecast.py takes its `provisional` "
|
||||
f"from an expression - it is a literal at every construction site, so "
|
||||
f"`provisional=y.provisional` cannot reappear in a later edit {_leaks}",
|
||||
_leaks == [])
|
||||
# The scan must be able to fail, or it grades nothing.
|
||||
check("...and that scan FAILS on the defect it names, so it is a check and not "
|
||||
"a decoration",
|
||||
_nonliteral_provisional(
|
||||
"PricePoint(start=t, eur_per_kwh=v, provisional=y.provisional)") != [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2. the fill keys on the WALL CLOCK, so the 23- and 25-hour days are right")
|
||||
|
||||
for label, (y, m, d) in (("spring forward (23 h)", (2026, 3, 29)),
|
||||
("fall back (25 h)", (2026, 10, 25))):
|
||||
a = local_midnight(y, m, d)
|
||||
b = local_midnight(y, m, d + 1)
|
||||
yb = local_midnight(y, m, d - 1)
|
||||
yesterday = series_of(hours_between(yb, a),
|
||||
lambda u: y_price(brussels(u).hour),
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead",
|
||||
provisional=False)
|
||||
# Nothing published at all yet: the whole day is carried forward.
|
||||
nothing = PriceRead.no_data("the day-ahead print has not landed yet")
|
||||
got = derived(lambda: fill_from_yesterday(nothing, yesterday=yesterday,
|
||||
since=a, until=b, stamp=a))
|
||||
# ⚠️ DERIVED FROM THE OFFSETS, not from a 23 or a 25 typed here.
|
||||
off_a = brussels_offset(a).utcoffset(None).total_seconds() / 3600
|
||||
off_b = brussels_offset(b - timedelta(seconds=1)).utcoffset(None) \
|
||||
.total_seconds() / 3600
|
||||
expect = int(24 - (off_b - off_a))
|
||||
check(f"{label}: the day is {expect} hours long, derived from the offset "
|
||||
f"change {off_a:+.0f} -> {off_b:+.0f}, and the fill emits exactly "
|
||||
f"that many",
|
||||
got.kind == "series" and len(got.points) == expect)
|
||||
check(f"{label}: every hour of it is flagged provisional - nothing was "
|
||||
f"published",
|
||||
got.kind == "series" and all(p.provisional for p in got.points))
|
||||
# The discriminating part: a naive `t - 24 h` fill would give a DIFFERENT
|
||||
# answer here, so this check separates the two implementations.
|
||||
y_by_utc = {u: y_price(brussels(u).hour) for u in hours_between(yb, a)}
|
||||
naive = [y_by_utc.get(u - timedelta(hours=24)) for u in hours_between(a, b)]
|
||||
wall = [y_price(brussels(u).hour) for u in hours_between(a, b)]
|
||||
check(f"{label}: the wall-clock fill and a naive t-24h fill DISAGREE, so "
|
||||
f"the check below can tell them apart",
|
||||
naive != wall)
|
||||
check(f"{label}: ...and the emitted values are the WALL-CLOCK ones",
|
||||
got.kind == "series"
|
||||
and [round(p.eur_per_kwh, 6) for p in got.points] == wall)
|
||||
|
||||
# The 25-hour day's two 02:00 hours are two distinct instants, both filled.
|
||||
_oct = local_midnight(2026, 10, 25)
|
||||
_oct_end = local_midnight(2026, 10, 26)
|
||||
_oct_fill = fill_from_yesterday(
|
||||
PriceRead.no_data("nothing yet"),
|
||||
yesterday=series_of(hours_between(local_midnight(2026, 10, 24), _oct),
|
||||
lambda u: y_price(brussels(u).hour),
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead",
|
||||
provisional=False),
|
||||
since=_oct, until=_oct_end, stamp=_oct)
|
||||
_twos = [p for p in _oct_fill.points if brussels(p.start).hour == 2]
|
||||
check("the 25-hour day has TWO local 02:00 points, at different offsets, and "
|
||||
"both are filled and both are flagged - a 24-slot implementation would "
|
||||
"collide them into one",
|
||||
len(_twos) == 2
|
||||
and _twos[0].start.utcoffset() != _twos[1].start.utcoffset()
|
||||
and all(p.provisional for p in _twos))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. §5.4 - a late publication REPLACES a carried hour and re-flags it")
|
||||
|
||||
_late = fill_from_yesterday(
|
||||
series_of(DAY, lambda u: t_price(brussels(u).hour),
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", provisional=False),
|
||||
yesterday=YESTERDAY, since=D0, until=D1, stamp=D1)
|
||||
_h20 = DAY[20]
|
||||
_before = derived(lambda: FILLED.at(_h20))
|
||||
_after = derived(lambda: _late.at(_h20))
|
||||
check("before the print, hour 20 is provisional and carries YESTERDAY's number",
|
||||
_before.provisional is True and near(_before.eur_per_kwh, y_price(20)))
|
||||
check("...after it, the same hour is FIRM and carries today's - the flag and "
|
||||
"the number move together, because the series is rebuilt and not patched",
|
||||
_after.provisional is False and near(_after.eur_per_kwh, t_price(20)))
|
||||
check("...and the two numbers actually differ, so 'it was replaced' is a "
|
||||
"measurement and not a restatement",
|
||||
not near(_before.eur_per_kwh, _after.eur_per_kwh))
|
||||
check("...and re-running the fill over an already-complete series changes "
|
||||
"nothing: it is idempotent",
|
||||
[p.canonical() for p in fill_from_yesterday(
|
||||
_late, yesterday=YESTERDAY, since=D0, until=D1, stamp=D1).points]
|
||||
== [p.canonical() for p in _late.points])
|
||||
|
||||
# Nothing to fill from, and nothing published: no_data, never an empty series.
|
||||
_nothing = derived(lambda: fill_from_yesterday(
|
||||
PriceRead.no_data("the feed timed out"),
|
||||
yesterday=PriceRead.no_data("and there is no yesterday either"),
|
||||
since=D0, until=D1, stamp=D0))
|
||||
check("with neither a published hour nor a yesterday, the result is no_data - "
|
||||
"not an empty series, and not a series of zeros",
|
||||
_nothing.kind == "no_data")
|
||||
check("...so it has no .points to iterate and no .get() to default out of",
|
||||
raises(lambda: _nothing.points, Absence)
|
||||
and raises(lambda: _nothing.get("points", []), Absence))
|
||||
check("...and its reason names §11.4 for an operator",
|
||||
"§11.4" in _nothing.reason)
|
||||
check("a naive `since` is refused rather than silently interpreted as UTC",
|
||||
raises(lambda: fill_from_yesterday(TODAY_PART, yesterday=YESTERDAY,
|
||||
since=datetime(2026, 1, 15),
|
||||
until=D1, stamp=D0), ValueError))
|
||||
check("...and `yesterday` has no default, so a fill with nothing to fill from "
|
||||
"cannot be written by accident",
|
||||
raises(lambda: fill_from_yesterday(TODAY_PART, since=D0, until=D1,
|
||||
stamp=D0), TypeError))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("4. AC2 - an irreversible action is REJECTED on a provisional price")
|
||||
|
||||
|
||||
class FakeStore:
|
||||
"""Same surface as app/store.Store, no disk. Copied from test_maintenance."""
|
||||
|
||||
def __init__(self):
|
||||
self.data = {"phase": IDLE, "phase_started": None,
|
||||
"last_completed": None, "last_start_attempt": None}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when
|
||||
|
||||
def get_time(self, k):
|
||||
return self.data.get(k)
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def maint():
|
||||
return Maintenance(MaintConfig(enabled=True), FakeStore())
|
||||
|
||||
|
||||
check("the drain's commitment window is DERIVED from the phase machine's own "
|
||||
"timeout, so the gate and maintenance.py cannot drift apart",
|
||||
near(MAINTENANCE_DRAIN.commits_h, DRAIN_TIMEOUT_MIN / 60.0)
|
||||
and near(MAINTENANCE_CHARGE.commits_h, CHARGE_TIMEOUT_MIN / 60.0))
|
||||
# The shipped `maintenance_start_hour` is 10:00 local, so a drain started on
|
||||
# schedule runs into the evening - hours a morning-only day-ahead print has not
|
||||
# covered. That is the ticket's scenario and it is where the window is placed.
|
||||
START = D0 + timedelta(hours=MaintConfig().start_hour)
|
||||
_hours = derived(lambda: committed_hours(START, MAINTENANCE_DRAIN))
|
||||
check(f"...and it commits {len(_hours) if not isinstance(_hours, _Raised) else _hours} "
|
||||
f"whole hours, one per hour of the timeout",
|
||||
len(_hours) == int(DRAIN_TIMEOUT_MIN / 60))
|
||||
check("an action that commits nothing is not an irreversible one and cannot be "
|
||||
"declared", raises(lambda: Irreversible("X", 0), ValueError)
|
||||
and raises(lambda: Irreversible("", 4), ValueError))
|
||||
|
||||
# The stimulus: the first hours are firm, the drain's window reaches into the
|
||||
# carried ones. THIS is the ticket's scenario - a cycle started at 10:00 on
|
||||
# prices that were published for the morning and guessed for the evening.
|
||||
_m = maint()
|
||||
_gate_bad = derived(lambda: begin_phase(_m, START, FILLED, MAINTENANCE_DRAIN))
|
||||
_first_prov = derived(lambda: next(u for u in _hours if FILLED.at(u).provisional))
|
||||
check("the drain's window really does reach past the published hours while "
|
||||
"staying INSIDE the series, so the refusal below is about provenance and "
|
||||
"not about a short series",
|
||||
FILLED.covers(_hours[-1])
|
||||
and any(not FILLED.at(u).provisional for u in _hours)
|
||||
and any(FILLED.at(u).provisional for u in _hours))
|
||||
check("a maintenance DRAIN whose window includes a carried-forward hour is "
|
||||
"BLOCKED", _gate_bad.kind == "blocked")
|
||||
check("...and the phase machine did not move: it is still idle, so the "
|
||||
"rejection is of the ACTION and not merely of a verdict object",
|
||||
_m.phase == IDLE)
|
||||
check("...and the reason names §8.3, the offending hour, and when the real "
|
||||
"price is expected",
|
||||
"§8.3" in _gate_bad.reason
|
||||
and _first_prov.isoformat() in _gate_bad.reason
|
||||
and "14-16" in _gate_bad.reason)
|
||||
|
||||
# The positive arm, so the block above is discriminating rather than universal.
|
||||
_firm_long = series_of(hours_between(START,
|
||||
START + timedelta(hours=len(_hours) + 2)),
|
||||
lambda u: t_price(brussels(u).hour % 24),
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead",
|
||||
provisional=False)
|
||||
_m2 = maint()
|
||||
_gate_ok = derived(lambda: begin_phase(_m2, START, _firm_long,
|
||||
MAINTENANCE_DRAIN))
|
||||
check("...while the SAME transition on a fully published series is PERMITTED, "
|
||||
"and the phase machine actually moves to drain",
|
||||
_gate_ok.kind == "permitted" and _m2.phase == DRAIN)
|
||||
check("...and the permitting arm carries the evidence it checked - one firm "
|
||||
"point per committed hour",
|
||||
_gate_ok.kind == "permitted" and len(_gate_ok.hours) == len(_hours)
|
||||
and not any(p.provisional for p in _gate_ok.hours))
|
||||
|
||||
# The other two ways a price can fail to be an observation.
|
||||
_m3 = maint()
|
||||
_gate_none = begin_phase(_m3, START, PriceRead.no_data("the feed timed out"),
|
||||
MAINTENANCE_DRAIN)
|
||||
check("no price data at all BLOCKS an irreversible action too - a missing "
|
||||
"price is not a cheap one (§11.4)",
|
||||
_gate_none.kind == "blocked" and "§11.4" in _gate_none.reason
|
||||
and _m3.phase == IDLE)
|
||||
_short = series_of(DAY[:4], lambda u: t_price(brussels(u).hour),
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead",
|
||||
provisional=False)
|
||||
_gate_short = price_permits(_short, D0, MAINTENANCE_DRAIN)
|
||||
check("...and so does an hour the series does not cover, which is §11.4 for "
|
||||
"that hour rather than a reason to reach for a neighbour",
|
||||
_gate_short.kind == "blocked" and "no price covers" in _gate_short.reason)
|
||||
|
||||
# The variant type itself, held to the same shape as PriceRead and Arbitrage.
|
||||
check("a BLOCKED gate carries a reason and nothing else: .hours and .action "
|
||||
"raise Absence rather than answering with an empty tuple",
|
||||
raises(lambda: _gate_bad.hours, Absence)
|
||||
and raises(lambda: _gate_bad.action, Absence))
|
||||
check("...it is not iterable and has no .get(), the other two routes to an "
|
||||
"empty, harmless-looking loop",
|
||||
raises(lambda: list(_gate_bad), Absence)
|
||||
and raises(lambda: _gate_bad.get("hours", []), Absence))
|
||||
check("...match() demands BOTH arms, so the block cannot be fallen through",
|
||||
raises(lambda: _gate_bad.match(permitted=lambda g: 1), TypeError))
|
||||
check("...and there is no falsy variant, so `if gate:` cannot look like a "
|
||||
"guard while being none",
|
||||
bool(_gate_bad) is True and bool(_gate_ok) is True)
|
||||
# A genuinely carried point - the flagged one, not the mis-flagged copy above.
|
||||
_prov_pt = FILLED.points[-1]
|
||||
check("the point the two constructor checks below are handed really is "
|
||||
"provisional, or they would be grading nothing",
|
||||
_prov_pt.provisional is True)
|
||||
check("a permitted gate cannot be built over a provisional hour or over no "
|
||||
"hours at all - §8.3 is the one rule this type has",
|
||||
raises(lambda: Gate.permit(MAINTENANCE_DRAIN, [_prov_pt]), ValueError)
|
||||
and raises(lambda: Gate.permit(MAINTENANCE_DRAIN, []), ValueError))
|
||||
|
||||
# TEST-14, PORTED. A default on any of these constructors reopens the exact
|
||||
# hole `_NoData.reason` had - and static_tariff's `_Refused.reason` still has,
|
||||
# because nothing scanned it. Scanned by SIGNATURE, so a default added later is
|
||||
# red without anyone remembering to write a new case.
|
||||
def _defaulted(fn):
|
||||
try:
|
||||
ps = list(inspect.signature(fn).parameters.values())
|
||||
except (TypeError, ValueError) as e: # pragma: no cover
|
||||
return _Raised(e)
|
||||
return [q.name for q in ps
|
||||
if q.name != "self" and q.default is not inspect.Parameter.empty]
|
||||
|
||||
|
||||
_ctors = {"Gate.permit": Gate.permit, "Gate.block": Gate.block,
|
||||
"Gate.match": Gate.match,
|
||||
"_Permitted.__init__": _fc._Permitted.__init__,
|
||||
"_Blocked.__init__": _fc._Blocked.__init__,
|
||||
# ⚠️ static_tariff's outcome type is scanned HERE because nothing
|
||||
# scanned it before: TEST-14 records `_Refused.reason` gaining a
|
||||
# default as a mutation that survived every check in that file.
|
||||
"Arbitrage.allow": Arbitrage.allow, "Arbitrage.refuse": Arbitrage.refuse,
|
||||
"Arbitrage.match": Arbitrage.match,
|
||||
"_Allowed.__init__": _st._Allowed.__init__,
|
||||
"_Refused.__init__": _st._Refused.__init__,
|
||||
# ⚠️ OPT-04's outcome type is scanned HERE, in the same dict, rather
|
||||
# than by a hand-written case in test_arbitrage.py - which is TEST-14's
|
||||
# actual instruction. A new outcome type extends the SCAN; a
|
||||
# hand-written case is the thing that gets forgotten, and the whole
|
||||
# reason this dict exists instead of five separate checks.
|
||||
"ArbitrageDecision.__init__": ArbitrageDecision.__init__}
|
||||
_with_defaults = derived(lambda: {n: d for n, d in
|
||||
((n, _defaulted(f)) for n, f in _ctors.items())
|
||||
if d})
|
||||
check(f"no constructor or handler of an outcome type - Gate's OR Arbitrage's - "
|
||||
f"carries a DEFAULT, so a verdict can never be built out of nothing "
|
||||
f"{_with_defaults}", _with_defaults == {})
|
||||
check("...so a block with no reason is a TypeError at the call site, not a "
|
||||
"blank status line",
|
||||
raises(lambda: Gate.block(), TypeError)
|
||||
and raises(lambda: Gate.block(""), ValueError))
|
||||
check("...and the same holds for the refusal FCST-03 added to Arbitrage",
|
||||
raises(lambda: Arbitrage.refuse(), TypeError))
|
||||
# ⚠️ ONE CALL PER ROLE, AND THE FIRST DRAFT SHOWED WHY. Passing the provisional
|
||||
# point as BOTH `cheapest` and `dearest` is a stimulus that cannot distinguish
|
||||
# "the constructor guards the cheapest hour" from "the constructor guards both
|
||||
# hours": a guard on either one alone raises, and the check goes green for an
|
||||
# implementation that leaves the sell hour unguarded. That is the degeneracy
|
||||
# rule this file states at the top, in a new dress - so the two roles are
|
||||
# stimulated SEPARATELY, each with a firm point in the other seat.
|
||||
_firm_pt = TODAY_PART.points[0]
|
||||
check("the two points these constructor checks use really differ in provenance, "
|
||||
"or neither of them grades anything",
|
||||
_prov_pt.provisional is True and _firm_pt.provisional is False)
|
||||
check("an ALLOWED arbitrage verdict cannot be constructed with a provisional "
|
||||
"CHEAPEST hour - the buy hour - even directly: §8.3 is enforced at the "
|
||||
"constructor, not only in the ladder above it",
|
||||
raises(lambda: Arbitrage.allow(0.1, _prov_pt, _firm_pt, "normal",
|
||||
PriceProduct.DAY_AHEAD, 0.0, frozenset()),
|
||||
ValueError))
|
||||
check("...nor with a provisional DEAREST hour - the sell hour - which a guard "
|
||||
"written for `cheapest` alone would have let through",
|
||||
raises(lambda: Arbitrage.allow(0.1, _firm_pt, _prov_pt, "normal",
|
||||
PriceProduct.DAY_AHEAD, 0.0, frozenset()),
|
||||
ValueError))
|
||||
check("...while two firm points build the verdict perfectly well, so the two "
|
||||
"refusals above are discriminating rather than universal",
|
||||
Arbitrage.allow(0.1, _firm_pt, _firm_pt, "normal",
|
||||
PriceProduct.DAY_AHEAD, 0.0, frozenset()).kind
|
||||
== "allowed")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5. AC3 - real-time thresholds are MEASURABLY wider (measured, not read)")
|
||||
|
||||
|
||||
def plan_with_spread(spread, product, provider):
|
||||
"""A two-hour series with exactly `spread` between its two prices."""
|
||||
lo, hi = 0.150000, round(0.150000 + spread, 6)
|
||||
pts = [PricePoint(start=brussels(D0), eur_per_kwh=lo, provisional=False),
|
||||
PricePoint(start=brussels(D0 + timedelta(hours=1)),
|
||||
eur_per_kwh=hi, provisional=False)]
|
||||
return TariffPlan(PriceRead.series(pts, product, provider, D0), None,
|
||||
"provider")
|
||||
|
||||
|
||||
def min_accepted_spread(product, sources, base, provider="generic_dayahead"):
|
||||
"""The SMALLEST spread this configuration will trade on, found by bisecting
|
||||
the accept/refuse boundary of the real verdict. Nothing here reads a
|
||||
factor out of the module it is grading."""
|
||||
lo, hi = 0.0, 4.0
|
||||
for _ in range(60):
|
||||
mid = (lo + hi) / 2
|
||||
v = plan_with_spread(mid, product, provider).arbitrage(
|
||||
peak_guard_active=False, min_spread_eur_kwh=base,
|
||||
uncertainty=sources)
|
||||
if v.kind == "allowed":
|
||||
hi = mid
|
||||
else:
|
||||
lo = mid
|
||||
return hi
|
||||
|
||||
|
||||
BASE = 0.104000 # a caller's own round-trip cost threshold, EUR/kWh
|
||||
_normal = derived(lambda: min_accepted_spread(PriceProduct.DAY_AHEAD,
|
||||
frozenset(), BASE))
|
||||
_wide = derived(lambda: min_accepted_spread(PriceProduct.REALTIME,
|
||||
frozenset(), BASE))
|
||||
_weather = derived(lambda: min_accepted_spread(
|
||||
PriceProduct.DAY_AHEAD, frozenset({Uncertainty.NO_WEATHER}), BASE))
|
||||
_both = derived(lambda: min_accepted_spread(
|
||||
PriceProduct.REALTIME, frozenset({Uncertainty.NO_WEATHER}), BASE))
|
||||
|
||||
check(f"a day-ahead product trades on any spread above its own threshold "
|
||||
f"{_normal:.6f}, which is the base {BASE} it was given",
|
||||
near(_normal, BASE, 1e-6))
|
||||
check(f"a REAL-TIME / imbalance product needs {_wide:.6f} - MEASURABLY wider, "
|
||||
f"by a factor of {(_wide / _normal) if _normal else 0:.3f} measured from "
|
||||
f"two verdict boundaries and not read from any constant",
|
||||
_wide > _normal + 1e-6)
|
||||
check(f"...and that measured factor equals the declared one for "
|
||||
f"REALTIME_PRODUCT ({WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT]}), so "
|
||||
f"the declaration and the behaviour are the same thing",
|
||||
near(_wide / _normal, WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT], 1e-4))
|
||||
check(f"§11.4's no-weather row reaches the SAME widening path by a different "
|
||||
f"trigger, and lands at {_weather:.6f}",
|
||||
near(_weather / _normal, WIDEN_FACTOR[Uncertainty.NO_WEATHER], 1e-4))
|
||||
|
||||
# ⚠️ THE COMBINATION RULE, ON A STIMULUS WHERE THE THREE CANDIDATES DIFFER.
|
||||
_f1 = WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT]
|
||||
_f2 = WIDEN_FACTOR[Uncertainty.NO_WEATHER]
|
||||
check(f"with BOTH sources active the threshold is max(f1,f2)={max(_f1, _f2)} x "
|
||||
f"base and NOT the product ({_f1 * _f2}) or the sum ({_f1 + _f2}) - two "
|
||||
f"independent doubts must not compound into a threshold nothing can "
|
||||
f"clear",
|
||||
near(_both / _normal, max(_f1, _f2), 1e-4)
|
||||
and not near(_both / _normal, _f1 * _f2, 1e-3)
|
||||
and not near(_both / _normal, _f1 + _f2, 1e-3))
|
||||
|
||||
# The regime must follow the DECLARED product, never the plugin id.
|
||||
_wide_named_dayahead = derived(lambda: min_accepted_spread(
|
||||
PriceProduct.REALTIME, frozenset(), BASE, provider="generic_dayahead"))
|
||||
_normal_named_eddy = derived(lambda: min_accepted_spread(
|
||||
PriceProduct.DAY_AHEAD, frozenset(), BASE, provider="eneco_eddy"))
|
||||
check("the widening follows the DECLARED PriceProduct even when the provider "
|
||||
"is called 'generic_dayahead', and does NOT follow the name "
|
||||
"'eneco_eddy' when the declared product is day-ahead - so a sixth "
|
||||
"supplier is widened by its declaration and not by an id in a list",
|
||||
near(_wide_named_dayahead, _wide, 1e-9)
|
||||
and near(_normal_named_eddy, _normal, 1e-9))
|
||||
check("...and the shipped plugin §9.2 names IS the one that declares REALTIME, "
|
||||
"so the mechanism is pointed at the real product",
|
||||
PROVIDERS["eneco_eddy"].capabilities().product is PriceProduct.REALTIME
|
||||
and uncertainty_of(PriceProduct.REALTIME)
|
||||
== frozenset({Uncertainty.REALTIME_PRODUCT})
|
||||
and uncertainty_of(PriceProduct.DAY_AHEAD) == frozenset())
|
||||
check("a string in the uncertainty set is REFUSED rather than widening by 1.0 "
|
||||
"and reporting that it widened",
|
||||
raises(lambda: widen_factor({"realtime"}), TypeError)
|
||||
and raises(lambda: required_spread(0.1, {"realtime"}), TypeError))
|
||||
check("...and a negative or non-finite base threshold is refused, since it is "
|
||||
"one every spread clears",
|
||||
raises(lambda: required_spread(-0.01, frozenset()), ValueError)
|
||||
and raises(lambda: required_spread(float("nan"), frozenset()), ValueError))
|
||||
check("required_spread() with nothing uncertain is the base itself, so the "
|
||||
"widening is additive machinery and not a permanent surcharge",
|
||||
near(required_spread(BASE, frozenset()), BASE))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("6. the refusal ladder - provisional hours are DROPPED, not traded on")
|
||||
|
||||
# ⚠️ THE PROVISIONAL HOURS CARRY THE CHEAPEST PRICE. That is the realistic
|
||||
# danger (yesterday's cheap night luring a charge) AND the non-degenerate
|
||||
# stimulus: the firm-only spread and the whole-series spread differ, so a check
|
||||
# on the spread can tell which set was used.
|
||||
_cheap_stale = [PricePoint(start=brussels(DAY[i]),
|
||||
eur_per_kwh=0.041250, provisional=True)
|
||||
for i in range(3)]
|
||||
_firm_rest = [PricePoint(start=brussels(DAY[i]),
|
||||
eur_per_kwh=round(0.212500 + 0.013750 * i, 6),
|
||||
provisional=False) for i in range(3, 12)]
|
||||
_mixed = TariffPlan(PriceRead.series(_cheap_stale + _firm_rest,
|
||||
PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", D0), None, "provider")
|
||||
_all_spread = derived(lambda: _mixed.spread_eur_kwh())
|
||||
_firm_spread = derived(lambda: round(
|
||||
max(p.eur_per_kwh for p in _firm_rest)
|
||||
- min(p.eur_per_kwh for p in _firm_rest), 6))
|
||||
check(f"the fixture is non-degenerate: the whole-series spread "
|
||||
f"{_all_spread:.6f} and the firm-only spread {_firm_spread:.6f} are "
|
||||
f"different numbers, so a verdict can be traced to one of them",
|
||||
not near(_all_spread, _firm_spread, 1e-6))
|
||||
_v = derived(lambda: _mixed.arbitrage(peak_guard_active=False,
|
||||
min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()))
|
||||
check("a PART-published series still allows arbitrage - §8.3 forbids acting on "
|
||||
"a provisional price, not trading at all until 16:00",
|
||||
_v.kind == "allowed")
|
||||
check(f"...on the FIRM spread {_firm_spread:.6f}, not the one yesterday's "
|
||||
f"carried-forward cheap hour would have invented",
|
||||
near(_v.spread_eur_kwh, _firm_spread, 1e-9))
|
||||
check("...and the cheapest and dearest hours it hands an optimiser are both "
|
||||
"PUBLISHED hours - the two points ARE the trade",
|
||||
_v.cheapest.provisional is False and _v.dearest.provisional is False)
|
||||
check("...and it reports the threshold it cleared and why, rather than leaving "
|
||||
"OPT-04 to re-derive it from a product enum",
|
||||
near(_v.required_spread_eur_kwh, 0.0) and _v.uncertainty == frozenset())
|
||||
|
||||
_all_prov = TariffPlan(PriceRead.series(_cheap_stale, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", D0), None,
|
||||
"fallback")
|
||||
_vp = derived(lambda: _all_prov.arbitrage(peak_guard_active=False,
|
||||
min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()))
|
||||
check("a WHOLLY provisional series - the §11.4 fallback, or a day before any "
|
||||
"print - is still refused, and the reason counts the firm hours",
|
||||
_vp.kind == "refused" and "0 of 3" in _vp.reason and "§8.3" in _vp.reason)
|
||||
check("...and the refusal carries no numbers to trade on",
|
||||
raises(lambda: _vp.spread_eur_kwh, Absence)
|
||||
and raises(lambda: _vp.cheapest, Absence))
|
||||
_one_firm = TariffPlan(
|
||||
PriceRead.series(_cheap_stale + _firm_rest[:1], PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", D0), None, "provider")
|
||||
check("one firm hour is not enough either: a buy hour and a sell hour must "
|
||||
"BOTH be published before a cycle can be committed",
|
||||
_one_firm.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()).kind == "refused")
|
||||
check("§10.1 still holds above all of it: the peak guard refuses the same "
|
||||
"series that is otherwise allowed",
|
||||
_mixed.arbitrage(peak_guard_active=True, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()).kind == "refused")
|
||||
check("the three questions cannot be skipped: all of peak_guard_active, "
|
||||
"min_spread_eur_kwh and uncertainty are required and keyword-only",
|
||||
raises(lambda: _mixed.arbitrage(peak_guard_active=False), TypeError)
|
||||
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
|
||||
min_spread_eur_kwh=0.0), TypeError)
|
||||
and raises(lambda: _mixed.arbitrage(False, 0.0, frozenset()), TypeError))
|
||||
check("...nor answered with a truthy 'unknown' or a negative threshold",
|
||||
raises(lambda: _mixed.arbitrage(peak_guard_active=None,
|
||||
min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset()), TypeError)
|
||||
and raises(lambda: _mixed.arbitrage(peak_guard_active=False,
|
||||
min_spread_eur_kwh=-0.1,
|
||||
uncertainty=frozenset()), TypeError))
|
||||
|
||||
# The cheap-window flag is the other consumer of provisionality, and it is the
|
||||
# one a live install's maintenance charge floor already reads.
|
||||
_firm_hour = _firm_rest[0].start
|
||||
_prov_hour = _cheap_stale[0].start
|
||||
check("the cheap-window flag is decided per HOUR: a provisional hour is never "
|
||||
"cheap, whatever its (stale) number says",
|
||||
_mixed.cheap_now(_prov_hour) is False)
|
||||
check("...while a firm hour below the firm mean still is, so carry-forward "
|
||||
"filling does not switch the maintenance charge floor off every morning",
|
||||
_mixed.cheap_now(_firm_hour) is True)
|
||||
# ⚠️ GRADED DIRECTLY, BECAUSE ITS CONSUMERS CANNOT GRADE IT. The §11.4 refusal
|
||||
# and `cheap_now` both guard provenance independently, so `firm_points()`
|
||||
# returning the carried hours TOO still leaves both of them answering correctly
|
||||
# - a stimulus that cannot distinguish an over-reporting accessor from a
|
||||
# correct one. So the tuple itself is compared with the published points, by
|
||||
# identity and in order, rather than only the mean it feeds.
|
||||
check("firm_points() returns EXACTLY the published points, in order - not the "
|
||||
"carried ones as well, which its two consumers would have absorbed "
|
||||
"without either of them changing its answer",
|
||||
list(_mixed.firm_points()) == _firm_rest
|
||||
and all(a is b for a, b in zip(_mixed.firm_points(), _firm_rest)))
|
||||
check("...and the firm mean it compares against is derived from published "
|
||||
"hours only",
|
||||
near(sum(p.eur_per_kwh for p in _mixed.firm_points())
|
||||
/ len(_mixed.firm_points()),
|
||||
sum(p.eur_per_kwh for p in _firm_rest) / len(_firm_rest)))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("7. CFG-03 and validate.py rule 7 - what this ticket did NOT widen")
|
||||
|
||||
_defaults = derived(lambda: sorted(_price.PRICE_OPTION_DEFAULTS))
|
||||
_cfg = derived(lambda: open(os.path.join(HERE, "config.yaml"),
|
||||
encoding="utf-8").read())
|
||||
# ⚠️ THIS LIST IS A DRIFT PIN, NOT A BAN ON EVER ADDING AN OPTION. FCST-03 added
|
||||
# none, and said so here. OPT-04 added FOUR - and added them to this ONE dict
|
||||
# and to config.yaml, which is precisely the behaviour CFG-03 asks for: the
|
||||
# alternative is a fourth freehand copy in main.py's `o.get(k, X)` fallbacks.
|
||||
# The pin is what makes such an addition a decision somebody signs rather than a
|
||||
# thing that appears; whoever adds the fifth comes here and says which card and
|
||||
# why, as this line does.
|
||||
_OPT04_KEYS = ["arbitrage_enabled", "cycle_cost_threshold_eur",
|
||||
"arbitrage_soc_floor", "arbitrage_soc_ceiling"]
|
||||
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's and OPT-04's "
|
||||
f"keys - FCST-03 still added none of its own {_defaults}",
|
||||
_defaults == sorted(["price_provider", "price_markup_eur_kwh",
|
||||
"price_taxes_eur_kwh", "export_price_eur_kwh",
|
||||
"price_csv_path", "price_feed_url",
|
||||
"price_horizon_h", "price_timeout_s",
|
||||
"static_profile"] + _OPT04_KEYS))
|
||||
# ...and the four are genuinely NEW, not a rename of something that was already
|
||||
# there, or the line above would pass while the mirror silently lost a key.
|
||||
check("...and OPT-04's four keys are additions rather than replacements: the "
|
||||
"nine TARIFF-02/03 keys are all still present",
|
||||
isinstance(_defaults, list)
|
||||
and len(set(_defaults) - set(_OPT04_KEYS)) == 9)
|
||||
check("...and every one of them is still in config.yaml, which stays the "
|
||||
"source of truth",
|
||||
isinstance(_cfg, str)
|
||||
and all(f"\n {k}:" in _cfg for k in _price.PRICE_OPTION_DEFAULTS))
|
||||
|
||||
|
||||
def _names(path):
|
||||
try:
|
||||
tree = ast.parse(open(path, encoding="utf-8").read())
|
||||
except (OSError, SyntaxError): # pragma: no cover
|
||||
return set()
|
||||
out = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
out |= {a.name for a in node.names}
|
||||
if isinstance(node, ast.Name):
|
||||
out.add(node.id)
|
||||
if isinstance(node, ast.Attribute):
|
||||
out.add(node.attr)
|
||||
return out
|
||||
|
||||
|
||||
_FC = os.path.join(HERE, "app", "price_forecast.py")
|
||||
_used = derived(lambda: _names(_FC))
|
||||
check("app/price_forecast.py reads NO options at all - it takes a PriceRead "
|
||||
"and instants, so there is no fourth place for an option default to live",
|
||||
isinstance(_used, set)
|
||||
and not ({"price_option", "PRICE_OPTION_DEFAULTS", "PluginOptions"}
|
||||
& _used))
|
||||
# ⚠️ RULE 7'S TRIPWIRE, RESPECTED RATHER THAN TRIPPED. test_price.py section 12
|
||||
# pins the set of modules that consume `load_prices()` to exactly
|
||||
# ["static_tariff.py"], and goes red when a second one appears. This module
|
||||
# deliberately does NOT fetch: it transforms a read somebody else obtained, so
|
||||
# rule 7's message about what a configured provider does or does not drive is
|
||||
# unchanged by this ticket and needed no rewording.
|
||||
check("...and it does not consume load_prices(), so validate.py rule 7's "
|
||||
"pinned consumer list is still true and its wording still accurate",
|
||||
"load_prices" not in _used)
|
||||
|
||||
|
||||
print()
|
||||
print("FAILED" if fails else "OK")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1 if fails else 0)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,761 @@
|
||||
"""Runnable check for the startup gate. `python3 test_validate.py`
|
||||
|
||||
Two halves, and the second is the one that matters. The first asserts that the
|
||||
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,
|
||||
actuator_writers, refuse, validate_startup_config,
|
||||
)
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
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). 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",
|
||||
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
def why(problems, needle):
|
||||
return any(needle in p for p in problems)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the baseline must start, or nothing below means anything")
|
||||
check("a good config with a 5000 W device is accepted",
|
||||
validate_startup_config(base(), -5000, 5000) == [])
|
||||
check("...and with no device reachable yet, too",
|
||||
validate_startup_config(base(), None, None) == [])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. max_w against the device's own rating")
|
||||
# ⚠️ THE ORIGINAL BUG. main.py used to log 'the device wins' and run anyway,
|
||||
# with write_setpoint() clamping, so the configured ceiling was never the real
|
||||
# one and the only evidence had scrolled off the log.
|
||||
p = validate_startup_config(base(max_w=8000), -5000, 5000)
|
||||
check("max_w above the device maximum is refused", len(p) == 1)
|
||||
check("...and the message names the configured value", why(p, "8000"))
|
||||
check("...and the device's value", why(p, "5000"))
|
||||
check("...and says which one wins", why(p, "THE DEVICE WINS"))
|
||||
check("max_w exactly at the device maximum is fine",
|
||||
validate_startup_config(base(max_w=5000), -5000, 5000) == [])
|
||||
check("max_w under it is fine",
|
||||
validate_startup_config(base(max_w=2000), -5000, 5000) == [])
|
||||
# The charge side is a separate rail and an asymmetric device is legal.
|
||||
p = validate_startup_config(base(max_w=4000), -2500, 5000)
|
||||
check("a max_w the device cannot take on the CHARGE side is refused too",
|
||||
len(p) == 1 and why(p, "charge side"))
|
||||
check("...naming the device's floor", why(p, "-2500"))
|
||||
# ⚠️ Not guessed at: the entity may simply not have booted yet. run_control()
|
||||
# re-runs this the moment the real rating arrives.
|
||||
check("an unreachable device skips the range check rather than assuming",
|
||||
validate_startup_config(base(max_w=8000), None, None) == [])
|
||||
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,
|
||||
# so no rating is read, no heartbeat is sent, layer 1 pins the inverter at 0 W,
|
||||
# and the add-on looks perfectly healthy the whole time.
|
||||
for blank in ("", " ", None):
|
||||
p = validate_startup_config(base(setpoint_entity=blank), None, None)
|
||||
check(f"setpoint_entity {blank!r} is refused", len(p) == 1)
|
||||
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, 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")
|
||||
# ⚠️ These predicates belong to p1.validate_meter_options(), which build_source()
|
||||
# also uses. Asserted here to prove the gate actually consults it - one entry
|
||||
# point, not two.
|
||||
check("meter_source off asks nothing of the P1 keys",
|
||||
validate_startup_config(base(meter_source="off"), -5000, 5000) == [])
|
||||
check("homewizard_local with no p1_host is refused",
|
||||
why(validate_startup_config(base(meter_source="homewizard_local"),
|
||||
-5000, 5000), "p1_host"))
|
||||
check("...and with a host it is accepted",
|
||||
validate_startup_config(base(meter_source="homewizard_local",
|
||||
p1_host="192.168.2.250"), -5000, 5000) == [])
|
||||
check("a poll cadence at meter_max_age_s is refused at STARTUP, not just disabled",
|
||||
why(validate_startup_config(base(meter_source="homewizard_local",
|
||||
p1_host="m", meter_poll_s=30,
|
||||
meter_max_age_s=30), -5000, 5000),
|
||||
"meter_max_age_s"))
|
||||
check("ha_signed with no p1_net_entity is refused",
|
||||
why(validate_startup_config(base(meter_source="ha_signed"), -5000, 5000),
|
||||
"p1_net_entity"))
|
||||
check("a phase list disagreeing with meter_phases is refused",
|
||||
why(validate_startup_config(base(meter_source="ha_signed",
|
||||
p1_net_entity="sensor.n", meter_phases=3,
|
||||
p1_phase_net_entities=["a", "b"]),
|
||||
-5000, 5000), "phase-count"))
|
||||
check("mqtt_p1 with no topic is refused",
|
||||
why(validate_startup_config(base(meter_source="mqtt_p1"), -5000, 5000),
|
||||
"meter_mqtt_topic"))
|
||||
check("ha_dsmr with only half its entities is refused",
|
||||
why(validate_startup_config(base(meter_source="ha_dsmr",
|
||||
p1_import_entity="sensor.i"), -5000, 5000),
|
||||
"p1_export_entity"))
|
||||
check("a misspelt meter_source is refused rather than silently ignored",
|
||||
why(validate_startup_config(base(meter_source="ha_signd"), -5000, 5000),
|
||||
"is not one of"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("4. meter_max_age_s against the firmware's compile-time veto")
|
||||
# ⚠️ The schema allows int(5,300); the firmware's threshold is baked in at 30 s
|
||||
# and wins silently. Same shape as the max_w case: a configured bound overridden
|
||||
# by a lower layer, with nothing saying so.
|
||||
hw = dict(meter_source="homewizard_local", p1_host="m")
|
||||
p = validate_startup_config(base(**hw, meter_max_age_s=120), -5000, 5000)
|
||||
check("a meter_max_age_s above the firmware veto is refused", len(p) == 1)
|
||||
check("...naming the configured value", why(p, "120"))
|
||||
check("...naming the firmware's value", why(p, "30"))
|
||||
check("...and saying which one wins", why(p, "THE FIRMWARE WINS"))
|
||||
check("the tested pair 30/30 is accepted",
|
||||
validate_startup_config(base(**hw, meter_max_age_s=30), -5000, 5000) == [])
|
||||
check("one second above it is refused",
|
||||
validate_startup_config(base(**hw, meter_max_age_s=31), -5000, 5000) != [])
|
||||
# Below the veto the add-on gives up FIRST, which is fail-safe and visible on
|
||||
# its own status page. Only above is the silent case.
|
||||
check("below the veto is fine - the add-on being stricter is not a conflict",
|
||||
validate_startup_config(base(**hw, meter_max_age_s=10,
|
||||
meter_poll_s=4), -5000, 5000) == [])
|
||||
# ⚠️ Scope: meter_max_age_s is read by nothing but P1Ingest, and the veto keys
|
||||
# off a sensor only published when ingestion runs. With the meter off it governs
|
||||
# nothing, so refusing would be pure trivia.
|
||||
check("with meter_source off the number governs nothing and is not refused",
|
||||
validate_startup_config(base(meter_source="off", meter_max_age_s=300),
|
||||
-5000, 5000) == [])
|
||||
check("the constant is the firmware's, not a rounded guess",
|
||||
FIRMWARE_INGEST_VETO_S == 30.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("6. a maintenance charge the ceiling can actually accommodate (MAINT-04)")
|
||||
# ⚠️ THE SHIPPED BUG. config.yaml shipped max_w 2000 against
|
||||
# maintenance_charge_w 2500, so the cheap-window shaping asked for hi=-2500
|
||||
# against the supervised lo=-2000 - lo > hi, arbiter clause 3, 0 W. Maintenance
|
||||
# silently never charged in a cheap window on a default install.
|
||||
p = validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
|
||||
-5000, 5000)
|
||||
check("maintenance_charge_w above max_w is refused", len(p) == 1)
|
||||
check("...and the message names the charge rate", why(p, "2500"))
|
||||
check("...and the ceiling", why(p, "2000"))
|
||||
check("...and says which one wins", why(p, "THE CEILING WINS"))
|
||||
# ⚠️ Equality is REACHABLE, not a near miss: lo=-2000, hi=-2000 is the single
|
||||
# point -2000 W. Refusing it would be exactly the trivia that gets a validator
|
||||
# deleted, and it is the boundary the rule is written `>` for.
|
||||
check("maintenance_charge_w exactly at max_w starts",
|
||||
validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
|
||||
-5000, 5000) == [])
|
||||
check("below it starts",
|
||||
validate_startup_config(base(max_w=2000, maintenance_charge_w=1500),
|
||||
-5000, 5000) == [])
|
||||
# Not scoped to maintenance_enabled: the pair is contradictory either way, and
|
||||
# scoping would leave the trap armed for the one action - ticking the box - that
|
||||
# cannot be expected to change what a charge rate means.
|
||||
check("...and it is refused with maintenance_enabled off, as shipped",
|
||||
why(validate_startup_config(
|
||||
base(max_w=2000, maintenance_charge_w=2500,
|
||||
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
|
||||
|
||||
# ⚠️ THE SIBLING, CHECKED RATHER THAN ASSUMED. The shaping is NOT symmetric:
|
||||
# drain files Claim.set at P_MAINTENANCE and owns the actuator, so the ceiling
|
||||
# CLAMPS it (+2000 W) instead of contradicting it, and the arbiter's explain()
|
||||
# names the clamp every cycle. Loud, and it still moves energy - so warned, not
|
||||
# refused. If someone later "fixes the asymmetry" by making this a refusal,
|
||||
# this check goes red and they have to justify it.
|
||||
check("maintenance_discharge_w above max_w WARNS but starts",
|
||||
validate_startup_config(base(max_w=2000, maintenance_discharge_w=2500),
|
||||
-5000, 5000) == [])
|
||||
|
||||
# The demonstration the rule is derived from, asserted rather than described.
|
||||
from app.arbiter import ( # noqa: E402
|
||||
Claim, P_LOOP, P_MAINTENANCE, P_MAINT_SHAPE, P_SAFETY, resolve,
|
||||
)
|
||||
|
||||
def _charge(max_w, charge_w):
|
||||
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-charge_w,
|
||||
reason="cheap-window charge"),
|
||||
Claim.set("loop", P_LOOP, 500.0, "grid")])
|
||||
|
||||
def _drain(max_w, disch_w):
|
||||
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
|
||||
Claim.set("maintenance", P_MAINTENANCE, disch_w, "drain"),
|
||||
Claim.set("loop", P_LOOP, 500.0, "grid")])
|
||||
|
||||
r = _charge(2000, 2500)
|
||||
check("arbiter: the refused pair really does emit 0 W",
|
||||
r.target_w == 0.0 and r.contradiction)
|
||||
r = _charge(2000, 2000)
|
||||
check("arbiter: the permitted equality really does charge",
|
||||
r.target_w == -2000.0 and not r.contradiction)
|
||||
r = _charge(3000, 2500)
|
||||
check("arbiter: raising max_w above the rate charges at the rate",
|
||||
r.target_w == -2500.0 and not r.contradiction)
|
||||
r = _drain(2000, 2500)
|
||||
check("arbiter: the drain SET is clamped, not contradicted",
|
||||
r.target_w == 2000.0 and not r.contradiction)
|
||||
check("...and the clamp names itself, which is why it is not refused",
|
||||
"supervised" in (r.bound_by or "") and "limited by" in r.explain())
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
|
||||
# ⚠️ Shipping a default the validator refuses would be worse than the bug it
|
||||
# refuses. Read config.yaml itself rather than a transcription of it - a
|
||||
# transcription is what lets the two drift apart silently. Regex, not PyYAML:
|
||||
# the add-on image has no PyYAML (Dockerfile installs py3-aiohttp/py3-paho-mqtt
|
||||
# only) and a check that skips on ImportError is not a check.
|
||||
import os # noqa: E402
|
||||
import re # noqa: E402
|
||||
|
||||
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
||||
with open(_CFG, encoding="utf-8") as _fh:
|
||||
_txt = _fh.read()
|
||||
_shipped = {k: float(v) for k, v in re.findall(
|
||||
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w): "
|
||||
r"(-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
||||
check("config.yaml really was read (all four defaults found)",
|
||||
set(_shipped) == {"max_w", "maintenance_charge_w",
|
||||
"maintenance_discharge_w", "sign_disagree_w"})
|
||||
check(f"the shipped defaults are accepted {_shipped}",
|
||||
validate_startup_config(base(**_shipped), -5000, 5000) == [])
|
||||
check("...and the shipped charge rate is at or under the shipped ceiling",
|
||||
_shipped["maintenance_charge_w"] <= _shipped["max_w"])
|
||||
check("...and the shipped discharge rate too, so it warns about nothing",
|
||||
_shipped["maintenance_discharge_w"] <= _shipped["max_w"])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the shipped meter_freeze_s, pinned (TEST-06 / AC3)")
|
||||
# ⚠️ WHY A NUMBER GETS ITS OWN CHECK. `meter_freeze_s: 300` is the threshold
|
||||
# SAFETY-18's entire argument is about, and before this it was pinned NOWHERE:
|
||||
# 300 -> 3600 left test_freeze.py AND this file green, because the schema
|
||||
# accepts int(60,3600) and no check ever read the shipped value. The schema
|
||||
# bounds what is CONFIGURABLE; this pins what is SHIPPED, which is a different
|
||||
# question and the one the docs answer.
|
||||
#
|
||||
# Read by regex like _shipped above, for the same reason: no PyYAML in the
|
||||
# image, and a check that skips on ImportError is not a check.
|
||||
_freeze_s = [float(v) for v in re.findall(
|
||||
r"^ meter_freeze_s: (\d+(?:\.\d+)?)\s*$", _txt, re.M)]
|
||||
_freeze_schema = re.findall(r"^ meter_freeze_s: int\((\d+),(\d+)\)\s*$",
|
||||
_txt, re.M)
|
||||
check(f"config.yaml carries exactly one meter_freeze_s default {_freeze_s}",
|
||||
len(_freeze_s) == 1)
|
||||
check("...and exactly one schema range for it", len(_freeze_schema) == 1)
|
||||
_hold_s = _freeze_s[0] if _freeze_s else None
|
||||
_lo, _hi = ((float(_freeze_schema[0][0]), float(_freeze_schema[0][1]))
|
||||
if _freeze_schema else (None, None))
|
||||
check("the shipped meter_freeze_s is still the measured 300 s that freeze.py, "
|
||||
"config.yaml's own comment and test_freeze.py are all written at",
|
||||
_hold_s == 300.0)
|
||||
check(f"...and it is inside its own schema range ({_lo}-{_hi} s)",
|
||||
None not in (_lo, _hi, _hold_s) and _lo <= _hold_s <= _hi)
|
||||
|
||||
# The two properties the number has to have, DERIVED rather than restated - so
|
||||
# they go red on a value that is merely schema-legal. Both come straight from
|
||||
# config.yaml's own comment on the key.
|
||||
#
|
||||
# upper: the alert must land inside the 900 s quarter that bills the capacity
|
||||
# tariff, or the notification arrives after the money is spent;
|
||||
# lower: the slowest a HEALTHY meter can tick a 1 Wh register and still look
|
||||
# unchanged for hold_s is 3600/hold_s W, and that must stay inside the
|
||||
# deadband or the conjunction's two terms stop being mutually
|
||||
# exclusive (freeze.py, "WHERE hold_s COMES FROM").
|
||||
_QUARTER_S = 900.0
|
||||
_shipped_deadband = [float(v) for v in re.findall(
|
||||
r"^ deadband_w: (\d+(?:\.\d+)?)\s*$", _txt, re.M)]
|
||||
check(f"config.yaml carries exactly one deadband_w default {_shipped_deadband}",
|
||||
len(_shipped_deadband) == 1)
|
||||
_db = _shipped_deadband[0] if _shipped_deadband else None
|
||||
_slowest_healthy_w = 3600.0 / _hold_s if _hold_s else None
|
||||
print(f" shipped meter_freeze_s {_hold_s} s: alert inside the "
|
||||
f"{_QUARTER_S:.0f} s billing quarter, and a healthy meter slow enough to "
|
||||
f"look unchanged for it is {_slowest_healthy_w} W against a "
|
||||
f"{_db} W deadband")
|
||||
check(f"...the alarm still lands inside the {_QUARTER_S:.0f} s billing quarter",
|
||||
_hold_s is not None and _hold_s < _QUARTER_S)
|
||||
check("...and a healthy meter that looks unchanged for it is still inside the "
|
||||
"deadband, so the two terms stay mutually exclusive",
|
||||
_slowest_healthy_w is not None and _db is not None
|
||||
and _slowest_healthy_w < _db)
|
||||
|
||||
# ⚠️ AND THE RANGE'S OWN BOUNDS. "300 sits inside the range" is worth nothing
|
||||
# while the range itself is unpinned: widened to `int(1,86400)` an operator can
|
||||
# configure `meter_freeze_s: 86400` and the guard is dead for a day at a time,
|
||||
# with every check in this repo still green. That mutation survived the first
|
||||
# cut of this PR and is what this block closes. Both numbers are argued for in
|
||||
# config.yaml's own comment and in freeze.py, and each is re-derived here:
|
||||
#
|
||||
# 60 s the floor freeze.py calls NOT A RECOMMENDATION - there a healthy
|
||||
# meter can look unchanged at 60 W, four times the shipped deadband, so
|
||||
# the conjunction's two terms stop being mutually exclusive and only
|
||||
# the margin is left;
|
||||
# 3600 s the ceiling - already four times the billing quarter, so the top of
|
||||
# the range has given up detecting a freeze inside the quarter that
|
||||
# bills it. It is the last bound between a supported configuration and
|
||||
# a disabled guard, and moving it is a decision rather than a tidy-up.
|
||||
print(f" schema range {_lo}-{_hi} s: at the floor a healthy meter that looks "
|
||||
f"unchanged is "
|
||||
+ (f"{3600.0 / _lo:.0f} W" if _lo else "?")
|
||||
+ f" against a {_db} W deadband; the ceiling is "
|
||||
+ (f"{_hi / _QUARTER_S:.1f}x" if _hi else "?")
|
||||
+ " the billing quarter")
|
||||
check(f"the schema range itself is still the argued 60-3600 s (found "
|
||||
f"{_lo}-{_hi})", (_lo, _hi) == (60.0, 3600.0))
|
||||
check("...a floor that gives up the mutual exclusion and keeps only the "
|
||||
"margin, which is why config.yaml calls it not a recommendation",
|
||||
None not in (_lo, _db) and 3600.0 / _lo > _db)
|
||||
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("max_w must be a positive magnitude")
|
||||
# Schema-blocked on a real install, but validate_startup_config() is also
|
||||
# sim/inject.py's entry point and can be handed any dict at all.
|
||||
for bad in (0, -1, -2000):
|
||||
p = validate_startup_config(base(max_w=bad), -5000, 5000)
|
||||
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "must be positive"))
|
||||
check("...and the message explains it would read as a trip, not a mistake",
|
||||
why(validate_startup_config(base(max_w=0), -5000, 5000), "contradiction"))
|
||||
# ⚠️ SAFETY-02 changed what this case has to say, and the change is real rather
|
||||
# than bookkeeping: with a 1 W ceiling the default 300 W sign-disagreement
|
||||
# threshold is unreachable, so rule 5 refuses - correctly, because the watchdog
|
||||
# would report itself armed while being incapable of firing. What this check is
|
||||
# FOR is "a positive max_w is not itself a refusal", so it now names a sign
|
||||
# threshold the ceiling can actually reach, and the interaction gets its own
|
||||
# check below.
|
||||
check("the smallest sane max_w still starts",
|
||||
validate_startup_config(base(max_w=1, 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"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("what must NOT be refused - a validator that fires on trivia gets deleted")
|
||||
# DOCS.md: the arbiter's `supervised` limit still binds the wire to +-max_w, so
|
||||
# the only cost of the extra headroom is unwind latency after saturation.
|
||||
check("integrator_max_w above max_w warns but starts",
|
||||
validate_startup_config(base(max_w=2000, integrator_max_w=9000),
|
||||
-5000, 5000) == [])
|
||||
check("integrator_max_w 0 (follow max_w) starts",
|
||||
validate_startup_config(base(integrator_max_w=0), -5000, 5000) == [])
|
||||
# Fail-safe AND already a red NOT READY banner naming the entity: a refusal on
|
||||
# top of that adds nothing an operator can act on.
|
||||
check("a missing soc_entity is left to the readiness banner",
|
||||
validate_startup_config(base(soc_entity=""), -5000, 5000) == [])
|
||||
check("a missing batt_entity likewise",
|
||||
validate_startup_config(base(batt_entity=""), -5000, 5000) == [])
|
||||
# The transient case. ESPHome is routinely not up when the add-on boots, and
|
||||
# refusing here would turn a device that arrives late into a restart loop.
|
||||
check("a setpoint entity that is configured but not yet found starts",
|
||||
validate_startup_config(base(), None, None) == [])
|
||||
check("meter_poll_s with thin but non-zero headroom starts",
|
||||
validate_startup_config(base(meter_source="homewizard_local", p1_host="m",
|
||||
meter_poll_s=20, meter_max_age_s=30),
|
||||
-5000, 5000) == [])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the refusal must be legible as a refusal, not as a crash")
|
||||
p = validate_startup_config(base(max_w=8000, setpoint_entity=""), -5000, 5000)
|
||||
check("several problems are all reported, not just the first", len(p) >= 1)
|
||||
refuse(p) # prints the banner; the eye is the assertion for its wording
|
||||
err = ConfigRefused(p)
|
||||
check("ConfigRefused carries every problem", err.problems == p)
|
||||
check("...and str() names one of them", str(err) == p[0])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the LATE refusal, when the device only shows up after the gate has run")
|
||||
# ⚠️ This path had no test at all and no live demonstration - the review's
|
||||
# catch. ESPHome is routinely not up when the add-on boots, so hass.limits()
|
||||
# returns (None, None), the range checks are skipped, and run_control() is the
|
||||
# first place the device's real rating is ever known. Everything below is about
|
||||
# what happens when it turns out to conflict.
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
|
||||
class _LateHass:
|
||||
"""Reports the range only run_control() gets to see."""
|
||||
|
||||
def __init__(self, lo, hi):
|
||||
self.lo, self.hi = lo, hi
|
||||
self.writes = []
|
||||
|
||||
async def limits(self, entity):
|
||||
return (self.lo, self.hi, 1.0)
|
||||
|
||||
async def number(self, *a, **k):
|
||||
return 0.0
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append(value)
|
||||
return True
|
||||
|
||||
|
||||
class _Store2:
|
||||
data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class _Pub2:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def late(opts_over, lo, hi):
|
||||
"""Drive run_control() against a device that only reports (lo, hi) now.
|
||||
|
||||
⚠️ Returns whether run_control() EXITED ON ITS OWN. That is the assertion
|
||||
that matters: on a refusal it must return without entering the while loop,
|
||||
and on a clean config it must not return at all. So the clean case is stopped
|
||||
from outside after it has had time to write, rather than waited on - a
|
||||
wait_for() here would just time out, which is the correct behaviour dressed
|
||||
up as a failure.
|
||||
"""
|
||||
async def go():
|
||||
stop = asyncio.Event()
|
||||
hass = _LateHass(lo, hi)
|
||||
# A device narrower than the shipped 5000 W schema ceiling is exactly
|
||||
# the case that cannot be reached through the Supervisor on the live
|
||||
# install, so it is reached here instead.
|
||||
c = Controller(base(**opts_over), hass, _Store2(), _Pub2(), stop=stop)
|
||||
c.auto = True
|
||||
task = asyncio.create_task(c.run_control())
|
||||
for _ in range(30): # up to ~3 s
|
||||
if task.done():
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
exited = task.done()
|
||||
if not exited:
|
||||
c.stopping = True
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
return c, stop, hass, exited
|
||||
return asyncio.run(go())
|
||||
|
||||
c, stop, hass, exited = late({"max_w": 5000}, -3600.0, 3600.0)
|
||||
check("a device narrower than max_w, discovered late, is refused",
|
||||
c.refused is not None and len(c.refused) == 1)
|
||||
check("...naming both values", why(c.refused, "5000") and why(c.refused, "3600"))
|
||||
check("...signalling amain to wind down", stop.is_set())
|
||||
check("...and returning instead of looping forever", exited)
|
||||
# The whole point: run_control() must not fall through into the while loop. A
|
||||
# single write here would mean it commanded the inverter under a config it had
|
||||
# just declared unusable.
|
||||
check("...having never entered the control loop, so it wrote nothing",
|
||||
hass.writes == [])
|
||||
check("...and it is recorded in the event log an operator reads",
|
||||
any("REFUSED" in e for e in c.events))
|
||||
|
||||
# The same path when there is no conflict: it must NOT refuse, and it must
|
||||
# actually regulate - asserted by a write reaching the device, not by absence.
|
||||
c2, stop2, hass2, exited2 = late({"max_w": 2000}, -5000.0, 5000.0)
|
||||
check("a late device that agrees does not trip the re-check", c2.refused is None)
|
||||
check("...and the control loop runs, writing to the device", hass2.writes != [])
|
||||
check("...and does not signal a shutdown", not stop2.is_set() and not exited2)
|
||||
|
||||
# ⚠️ THE FOOTGUN THE REVIEW FOUND. `stop` used to default to None, so this
|
||||
# construction was legal and the refusal path then returned into a live process
|
||||
# with no control loop, no exit and no further output.
|
||||
try:
|
||||
Controller(base(), _LateHass(-5000.0, 5000.0), _Store2(), _Pub2())
|
||||
check("a Controller with no stop Event is rejected at construction", False)
|
||||
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:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
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