Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ef9565071 | ||
|
|
cccb2fa07b | ||
|
|
b11bea70d3 | ||
|
|
cfafdffaed | ||
|
|
94da37fc72 | ||
|
|
07ba3433ed | ||
|
|
9f6b9f14cd | ||
|
|
8f9c2cad57 | ||
|
|
c9aaf7f04d | ||
|
|
8a6acd9fa2 | ||
|
|
628279a68a | ||
|
|
239e120bf6 | ||
|
|
9d593429d3 | ||
|
|
b0b24e5d41 | ||
|
|
eb88646899 | ||
|
|
b9f7bd0506 | ||
|
|
43c144b8d9 | ||
|
|
6f5e8e378a | ||
|
|
8a5736136a | ||
|
|
5aba220046 | ||
|
|
33e17dba0e | ||
|
|
16b1d6c422 | ||
|
|
ca8fd01819 | ||
|
|
d2ad080e45 | ||
|
|
074157c4c2 | ||
|
|
186a044a3a | ||
|
|
660d079bf3 | ||
|
|
a6af332048 | ||
|
|
03ef61fa3b | ||
|
|
b6c6da31d8 | ||
|
|
b548525d10 | ||
|
|
68c5ede7b9 | ||
|
|
cb3f686555 | ||
|
|
587495e143 | ||
|
|
e3ccd9f6ea | ||
|
|
e6da478f87 | ||
|
|
8fa4febfef | ||
|
|
8f82dd4e06 | ||
|
|
e1b9254626 | ||
|
|
478ba93710 | ||
|
|
995da777ce | ||
|
|
4dcd9b0fc6 | ||
|
|
9c65ac2cf8 | ||
|
|
7796f168a9 | ||
|
|
2fac0aaf06 | ||
|
|
c518007caf | ||
|
|
a11597e0b3 | ||
|
|
610725bed9 | ||
|
|
b4a3801f68 | ||
|
|
a991438e40 | ||
|
|
068b83f57c | ||
|
|
ac9854886b | ||
|
|
aade2c79fd | ||
|
|
f620f7cf80 | ||
|
|
8d09b30f16 | ||
|
|
028c3e53fb | ||
|
|
a6e64fb66e | ||
|
|
435e1b6f7c | ||
|
|
f5ea39784d | ||
|
|
7f2c4e20e1 | ||
|
|
559f0eb022 | ||
|
|
c81289527f | ||
|
|
e6c45a61e7 | ||
|
|
88c59a9918 | ||
|
|
17b7b447c4 | ||
|
|
4257e38e5a | ||
|
|
7a5281e97f | ||
|
|
6eba18968c | ||
|
|
958f5cd218 | ||
|
|
c45893ffad | ||
|
|
5059e28860 | ||
|
|
375a1e29d2 | ||
|
|
c2787da539 |
+269
-5
@@ -3,12 +3,35 @@
|
||||
# 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
|
||||
# ⚠️ NO EVENT FILTER OF ANY KIND - NO `paths:`, AND SINCE CI-9 NO `branches:`
|
||||
# EITHER - AND THAT ABSENCE IS LOAD-BEARING. Branch protection on release/1.0
|
||||
# requires the context `checks / unit (pull_request)`. Condition a STEP if you
|
||||
# must; never the workflow.
|
||||
#
|
||||
# ⚠️ CI-01 WROTE THE REASON DOWN WRONG, AND THE WRONG REASON WAS THE SAFE-
|
||||
# SOUNDING ONE. It said an unreceived required context sits PENDING forever, so
|
||||
# a sat-out pull request becomes unmergeable - annoying, but fail-safe. That is
|
||||
# not what this Gitea does. MEASURED on Gitea 1.27.3, 2026-09-19, throwaway
|
||||
# PR #94: when a workflow exists but its event filter does not match, Gitea does
|
||||
# not stay silent. It POSTS the required context itself, with state `skipped`:
|
||||
#
|
||||
# GET /api/v1/repos/admin/goodwe-addon/commits/<head sha>/statuses
|
||||
# [{"status":"skipped","context":"checks / unit (pull_request)",
|
||||
# "description":"Skipped","target_url":""}] <- no run, no job, no url
|
||||
#
|
||||
# GET /api/v1/repos/admin/goodwe-addon/commits/<head sha>/status
|
||||
# {"state":"success","total_count":1} <- the rollup LAUNDERS it
|
||||
#
|
||||
# and Gitea's merge gate accepts it. Same PR, same head sha, one status on it
|
||||
# and that status a skip, POST .../pulls/94/merge answered 409 `head out of
|
||||
# date` - the 409 being a deliberately wrong `head_commit_id` in the probe, i.e.
|
||||
# the request had ALREADY CLEARED BRANCH PROTECTION. The control, a required
|
||||
# context with no status at all, answered 405 `Not all required status checks
|
||||
# successful`. So the true failure mode is the exact inverse of the one written
|
||||
# here before: sitting a pull request out does not make it unmergeable, it makes
|
||||
# it MERGEABLE WITHOUT EVER BEING TESTED, and the badge says success while it
|
||||
# happens. Fail-open, not fail-closed. Do not reintroduce a filter here.
|
||||
#
|
||||
# ⚠️ THE RUNNER IS SHARED WITH THE OTHER REPO'S RIG. `goodwe-runner` on
|
||||
# 192.168.2.147 carries the labels `self-hosted` and `goodwe-rig` and is
|
||||
# registered `:host`, and it is the only runner there is - so this job wears the
|
||||
@@ -23,9 +46,116 @@
|
||||
# that step; a green from this workflow is a claim about content that can land.
|
||||
name: checks
|
||||
|
||||
# ⚠️ CI-9: `pull_request:` TAKES NO `branches:` LIST, ON PURPOSE. It used to
|
||||
# read `branches: [release/1.0, master]`, which meant a STACKED pull request -
|
||||
# one whose base is another feature branch rather than a release branch - did
|
||||
# not match, and so was skipped, laundered to `success`, and mergeable untested
|
||||
# by the mechanism described at the top of this file.
|
||||
#
|
||||
# ⚠️ AND RETARGETING DID NOT REPAIR IT, WHICH IS WHAT MADE THIS P1. The skip is
|
||||
# a COMMIT STATUS ON THE HEAD SHA, not a property of the pull request, so moving
|
||||
# the base afterwards leaves it exactly where it was. Observed live on PR #93
|
||||
# (2026-09-19): retargeted TEL-10 -> release/1.0, and the 10:05:00 `skipped`
|
||||
# stayed attached to head a6af332 with the rollup still reading success. It was
|
||||
# caught by a human who forced a real execution by reopening the pull request
|
||||
# (run 799, 10:56). Reproduced deliberately on throwaway PR #94: retargeted onto
|
||||
# a branch carrying a byte-identical copy of release/1.0's protection rule, and
|
||||
# the single `skipped` status survived the retarget unchanged - no new run, no
|
||||
# new status - and still cleared the gate.
|
||||
#
|
||||
# ⚠️ WHY NOT FIX THIS AT THE GATE INSTEAD, which would be the better place. It
|
||||
# cannot be done. Gitea's branch protection cannot express "this context must be
|
||||
# SUCCESS" as opposed to "this context must be PRESENT AND NOT FAILING". The
|
||||
# whole of its status-check vocabulary is two fields - `enable_status_check`
|
||||
# (bool) and `status_check_contexts` (a list of globs MATCHING CONTEXT NAMES) -
|
||||
# confirmed against this server's own schema at /swagger.v1.json, definitions
|
||||
# EditBranchProtectionOption and CreateBranchProtectionOption. There is no field
|
||||
# for the required STATE anywhere in either. The success/skip comparison lives
|
||||
# in Gitea's compiled code, so the only lever left is upstream of it: make sure
|
||||
# the context is never a skip in the first place. Hence this block.
|
||||
#
|
||||
# ⚠️ WHAT THIS COSTS, stated honestly, because it is a real trade. Every pull
|
||||
# request now runs the full suite, including the intermediate steps of a stack
|
||||
# that nobody intends to merge on its own. A run is ~8 minutes wall clock
|
||||
# (run 799: 7m55s) on a runner that is `capacity: 1` and shared with the other
|
||||
# repo's rig, so each extra run is ~8 minutes of slot that a rig campaign then
|
||||
# queues behind.
|
||||
#
|
||||
# SCALE OF THE NEW WORK, re-derived from the API and stated with its denominator,
|
||||
# because the first version of this paragraph had a made-up one. Counting the 93
|
||||
# pull requests that predate this ticket (#1..#93; CI-9's own #94..#98 excluded,
|
||||
# and note /pulls caps a page at 50, so this needs paging to see at all): 92 were
|
||||
# opened against release/1.0 and would have run anyway, and exactly ONE was
|
||||
# stacked - #2, TEL-01 -> SAFETY-04, closed unmerged, 2026-08-24. No pull request
|
||||
# has ever targeted `master`, so half the old filter was dead from the start.
|
||||
#
|
||||
# ⚠️ THAT 92 IS AN UPPER BOUND, NOT A CENSUS, and the reason is this very bug.
|
||||
# The API reports a pull request's CURRENT base, so one that was opened stacked
|
||||
# and later retargeted is recorded against its final base and is invisible here.
|
||||
# #93 is precisely that case - opened 2026-09-19T10:05:00 on TEL-10, retargeted,
|
||||
# merged into release/1.0 at 11:05:26 the same morning, under three hours before
|
||||
# CI-9 was opened - and it counts in the 92. So the true historical stacking rate
|
||||
# is somewhere above 1/93, and unmeasurable from here.
|
||||
#
|
||||
# The bill is therefore small today and grows with however much stacking the team
|
||||
# adopts. It is worth paying: the alternative is that the cheap case stays
|
||||
# untested and indistinguishable from the tested one.
|
||||
#
|
||||
# ⚠️ THIS DOES NOT CHANGE GITEA'S ACCEPTANCE OF A `skipped` REQUIRED CONTEXT -
|
||||
# that half is in its compiled code and is not reachable from this repository.
|
||||
# The CONSEQUENCE, though, is very largely closeable from here, and is closed:
|
||||
# after this edit there is nothing in this repository that produces a skip -
|
||||
# there is exactly one workflow file, it now has no event filter of any kind,
|
||||
# and the other candidate cause was MEASURED and is not one. The `guard the
|
||||
# trigger` step below then keeps it that way mechanically rather than by asking
|
||||
# the next editor to read this comment.
|
||||
#
|
||||
# ⚠️ READ THE BLIND SPOTS ON THAT STEP BEFORE TRUSTING THE SENTENCE ABOVE.
|
||||
# "Mechanically" holds only for a reintroduced filter that still names the
|
||||
# PR's own base. For blind spots 1 and 3 the defence IS this comment, because
|
||||
# the guard lives inside the workflow that a filter makes Gitea skip. Blind
|
||||
# spot 3 is the one that matters: a filter naming a branch THIS pull request
|
||||
# does not target is itself skipped, lands green and untested, and then every
|
||||
# subsequent pull request the filter DOES NOT NAME is skipped too. That is the
|
||||
# gate off, silently.
|
||||
#
|
||||
# ⚠️ THAT DIRECTION IS THE WHOLE POINT AND IT IS EASY TO WRITE BACKWARDS. A
|
||||
# pull request that MATCHES the filter still runs; it is the one the filter
|
||||
# excludes that Gitea sits out, reports `skipped`, and the rollup launders
|
||||
# into `success`. An earlier draft of this very clause said "matching it is
|
||||
# skipped", which is the inverse of the measurement and contradicted the
|
||||
# paragraph directly below.
|
||||
#
|
||||
# ⚠️ AND THE DANGEROUS ONE IS NOT `branches: [main]`, WHICH AT LEAST LOOKS
|
||||
# WRONG. It is `branches: [release/1.0]`, which looks like tightening onto the
|
||||
# protected branch and is what a careful person would write. Every day-to-day
|
||||
# pull request still runs and still goes green, so nothing appears broken -
|
||||
# and the only thing switched off is the `release/1.0` -> `master` release
|
||||
# PR, i.e. the one whose content reaches an installed system. SEC-2 made that
|
||||
# PR the mandated release path (docs/RELEASE.md), so this is now a live route
|
||||
# rather than a hypothetical one.
|
||||
#
|
||||
# Closing it needs a SECOND permanently unfiltered workflow posting its own
|
||||
# context, with that context added to the `status_check_contexts` of BOTH
|
||||
# `release/1.0` AND `master` - both carry the rule since SEC-2, and adding it
|
||||
# to one alone leaves the hole open on the other. A forge change, with the
|
||||
# owner.
|
||||
#
|
||||
# THE MEASUREMENT, because the obvious guess is wrong and was nearly written
|
||||
# here as fact. A skip-ci keyword in a commit message (`[skip ci]`, `[ci skip]`,
|
||||
# `[no ci]`, `[skip actions]`, `[actions skip]`) does NOT behave like a filter
|
||||
# miss. On throwaway PR #94, head b36effb, commit subject ending `[skip ci]`:
|
||||
#
|
||||
# .../commits/b36effb/statuses -> [] <- NO status, not a skip
|
||||
# .../commits/b36effb/status -> {"state":"pending"} <- so the rollup is pending
|
||||
# POST .../pulls/94/merge -> 405 Not all required status checks successful
|
||||
#
|
||||
# i.e. skip-ci is FAIL-CLOSED - it withholds the context entirely, which is the
|
||||
# pending-forever behaviour CI-01's header wrongly attributed to a filter miss.
|
||||
# It blocks the merge rather than laundering it, so it needs no fix and no
|
||||
# app.ini change. Only the filter miss ever posted a `skipped`.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [release/1.0, master]
|
||||
workflow_dispatch:
|
||||
|
||||
# ⚠️ NO `concurrency:` BLOCK, AND ITS ABSENCE IS AS LOAD-BEARING AS THE MISSING
|
||||
@@ -224,6 +354,140 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ⚠️ THE GUARD ON THE TRIGGER. THIS STEP EXISTS BECAUSE PROSE ALREADY
|
||||
# FAILED IN THIS FILE, ONCE, FOR TWENTY DAYS.
|
||||
#
|
||||
# `git show 9c1a23328a` is CI-01's first commit. It shipped the header
|
||||
# "⚠️ NO `paths:` FILTER, AND ITS ABSENCE IS LOAD-BEARING" and the line
|
||||
# `branches: [release/1.0, master]` IN THE SAME COMMIT, EIGHTEEN LINES
|
||||
# APART. The comment forbade one kind of event filter while the code
|
||||
# directly above the job carried another, and nobody caught it through 84
|
||||
# merges until the gate let a stacked pull request through. So the whole of
|
||||
# CI-9's fix rests on an invariant - "checks.yml has no event filter" -
|
||||
# that has already been silently violated in this exact file by the person
|
||||
# who wrote the warning against violating it. A louder comment is not a
|
||||
# remedy for that. A red step is.
|
||||
#
|
||||
# WHY THIS CAN WORK AT ALL, given CI-9 rejected the card's option 3 (a step
|
||||
# that detects its own workflow having been skipped) as impossible. That
|
||||
# option is impossible because a skipped workflow runs no steps, so there
|
||||
# is nothing alive to notice. This is the different, tractable case: it
|
||||
# fires on the pull request that REINTRODUCES a filter, and it does so
|
||||
# whenever the filter being reintroduced STILL NAMES THAT PULL REQUEST'S
|
||||
# OWN BASE - the ordinary case, because a filter put back by hand names
|
||||
# the release branch people are actually targeting. The edit then has to
|
||||
# get past this step before it can start causing skips. It is a ratchet,
|
||||
# not a skip detector.
|
||||
#
|
||||
# ⚠️ AND IT IS THE HEAD'S WORKFLOW THAT DECIDES WHETHER ANYTHING RUNS, NOT
|
||||
# THE BASE'S. An earlier draft of this comment claimed the reintroducing
|
||||
# pull request "is still graded by the unfiltered workflow which is on the
|
||||
# base today". That was wrong, it contradicted the checkout step's own note
|
||||
# a few lines below, and the error was not cosmetic: it made this step
|
||||
# sound like an unconditional ratchet, which is exactly the false comfort
|
||||
# CI-9 exists to stamp out. MEASURED on PR #101 - head carrying a filter
|
||||
# that excludes the base, base carrying the unfiltered workflow - and the
|
||||
# result was no run at all, the context posted `skipped`, the rollup
|
||||
# reading `success`, and the gate CLEARED (409). Gitea takes the workflow
|
||||
# file from refs/pull/<n>/head, so a head that filters itself out is simply
|
||||
# never graded, and nothing on the base can reach out and stop it. That is
|
||||
# what blind spot 3 below is made of.
|
||||
#
|
||||
# WHAT IT INSPECTS is the MERGED tree in $WS - the content that would
|
||||
# actually land on the base - not the branch tip and not the copy of this
|
||||
# file that Gitea used to decide whether to run. Those are three different
|
||||
# trees here (see the long note on the checkout step) and only the merged
|
||||
# one is the thing being authorised.
|
||||
#
|
||||
# ⚠️ THREE ACKNOWLEDGED BLIND SPOTS. NOT ONE OF THEM IS CLOSEABLE FROM
|
||||
# INSIDE THIS FILE, and the reason is the same in all three: each is a case
|
||||
# where THIS WORKFLOW IS ITSELF THE THING THAT GETS SKIPPED, and a guard
|
||||
# that lives inside a skipped workflow does not run. Closing them needs a
|
||||
# SECOND, permanently unfiltered workflow whose own context is added to
|
||||
# release/1.0's `status_check_contexts`, so that something survives to
|
||||
# report when this file is sat out. That is a forge change and the
|
||||
# repository owner's decision; it is being put to them separately and is
|
||||
# deliberately NOT held as a blocker on CI-9.
|
||||
#
|
||||
# 1. A reintroduced `paths:` filter crafted to exclude the reintroducing
|
||||
# pull request's OWN diff would skip that pull request, so this step
|
||||
# would not run to object to it.
|
||||
#
|
||||
# 2. An edit that lands on a feature branch through a stacked pull
|
||||
# request is graded by the HEAD's workflow, so a filter already
|
||||
# present in the head hides the edit from this step - until that
|
||||
# branch is proposed for release/1.0, where (unless it is case 3) the
|
||||
# base matches the reintroduced filter and this step runs and bites.
|
||||
# Caught at the release boundary, which is the boundary that matters.
|
||||
#
|
||||
# 3. ⚠️ THE WORST OF THE THREE, AND WORSE THAN BOTH OF THE OTHERS PUT
|
||||
# TOGETHER: a reintroduced `branches:` filter that simply DOES NOT
|
||||
# NAME THE BASE escapes this step entirely, because the pull request
|
||||
# carrying it is itself skipped. It takes no craft and no malice to
|
||||
# write - `branches: [main]` copied out of a GitHub template, or a
|
||||
# tidy-up to `[master]`, a branch that NO pull request in this
|
||||
# repository's history has ever targeted - and it arrives as a
|
||||
# skipped, laundered, untested merge into release/1.0.
|
||||
#
|
||||
# What makes it categorically worse is what happens the moment after.
|
||||
# Once that file is ON release/1.0, every subsequent pull request is
|
||||
# graded against it and every one of them is skipped too. This is not
|
||||
# the stacked case leaking through a crack; it is the gate off
|
||||
# completely, for everything, silently, with every badge green. The
|
||||
# only thing standing between the repository and that state today is
|
||||
# a human reading this file - which is precisely the kind of defence
|
||||
# this step exists because it has already failed once (9c1a23328a).
|
||||
#
|
||||
# It is deliberately a FAILURE and not a warning: a warning would leave
|
||||
# the required context green, which is the precise defect CI-9 is about.
|
||||
- name: guard the trigger - refuse a checks.yml that has regained an event filter
|
||||
run: |
|
||||
set -eu
|
||||
f="$WS/goodwe-addon/.gitea/workflows/checks.yml"
|
||||
test -f "$f" || { echo "checks.yml is missing from the merged tree."; exit 1; }
|
||||
# Walk the top-level `on:` block of the MERGED file. Full-line comments
|
||||
# at column 0 are skipped outright - this file's own prose quotes
|
||||
# `paths:` and `branches:` many times and must not trip its own guard -
|
||||
# and an inline `#` is trimmed from any line that is kept.
|
||||
bad=$(awk '
|
||||
function scan(t) {
|
||||
sub(/#.*/, "", t)
|
||||
if (t ~ /(^|[^A-Za-z0-9_-])(branches|branches-ignore|paths|paths-ignore|tags|tags-ignore|types)[[:space:]]*:/)
|
||||
return 1
|
||||
return 0
|
||||
}
|
||||
/^[[:space:]]*#/ { next }
|
||||
/^("on"|'"'"'on'"'"'|on)[[:space:]]*:/ {
|
||||
inon = 1
|
||||
rest = $0; sub(/^[^:]*:/, "", rest)
|
||||
if (scan(rest)) print NR ": " $0
|
||||
next
|
||||
}
|
||||
inon == 1 {
|
||||
if ($0 ~ /^[[:space:]]*$/) next
|
||||
if ($0 ~ /^[[:space:]]/) { if (scan($0)) print NR ": " $0; next }
|
||||
inon = 0
|
||||
}
|
||||
' "$f")
|
||||
if [ -n "$bad" ]; then
|
||||
echo "REFUSED: checks.yml has regained an event filter in its \`on:\` block."
|
||||
echo "$bad"
|
||||
echo
|
||||
echo " A filter means some pull request stops matching this workflow. Gitea does"
|
||||
echo " not stay silent about a workflow it sits out - it posts the required context"
|
||||
echo " \`checks / unit (pull_request)\` itself with state \`skipped\`, the combined"
|
||||
echo " status rolls that up as \`success\`, and the merge gate ACCEPTS it. That is"
|
||||
echo " CI-9: a pull request merged on a workflow that never ran a step. It was"
|
||||
echo " reproduced by merging PR #96 for real through a protected branch."
|
||||
echo
|
||||
echo " Branch protection cannot save you here - it can require that a context is"
|
||||
echo " PRESENT, never that it is SUCCESS - so the filter is the whole defence."
|
||||
echo " If you genuinely need to narrow what runs, condition a STEP, never the"
|
||||
echo " workflow. See the \`on:\` block's comments for the measurements."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: the \`on:\` block of the merged checks.yml carries no event filter."
|
||||
|
||||
- name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile)
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
@@ -33,6 +33,8 @@ estop/rs485_log.py optional RS485 e-stop / bus witness
|
||||
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
|
||||
docs/build_guide.py its single source - rebuilds the HTML and PDF
|
||||
FIELD-GUIDE.md how to edit and rebuild the guide
|
||||
docs/RELEASE.md how a change reaches the battery - `master` is
|
||||
protected, releases go via PR from `release/1.0`
|
||||
```
|
||||
|
||||
**Picking this up after a break? Read `HANDOVER.md` first** — current state,
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# Releasing — how a change reaches the battery
|
||||
|
||||
`master` is the **default branch**, and the default branch is what the Home
|
||||
Assistant Supervisor clones when it installs or updates this add-on. Whatever
|
||||
sits on `master` is what runs on a real 5 kW inverter. Treat a push to `master`
|
||||
as a deployment, because that is what it is.
|
||||
|
||||
---
|
||||
|
||||
## 1. `master` is protected — releases go through a pull request
|
||||
|
||||
Until 2026-09-23 `master` had **no protection of any kind**. Every gate this
|
||||
repo built (CI-01, CI-9) sat on `release/1.0`, the branch that does *not* ship.
|
||||
On 2026-09-22 version 0.10.1 went straight onto `master` — no pull request, no
|
||||
CI run, no reviewer — and it succeeded (commit `0f14c2a`, the only commit on
|
||||
`master` that carries no `(#NN)` merge suffix). SEC-2 closed that.
|
||||
|
||||
**The release path is now:**
|
||||
|
||||
1. Merge the work into `release/1.0` as usual, via a pull request that goes
|
||||
green. Bump `version:` in `goodwe_controller/config.yaml` on that branch —
|
||||
the Supervisor keys the built image by `version:`, so without a bump nothing
|
||||
is offered and nothing changes.
|
||||
2. Open a pull request **`release/1.0` → `master`**. Do not push to `master`;
|
||||
it will be refused by the pre-receive hook:
|
||||
|
||||
```
|
||||
remote: error: Not allowed to push to protected branch master
|
||||
! [remote rejected] ... (pre-receive hook declined)
|
||||
```
|
||||
|
||||
3. Wait for `checks / unit (pull_request)` to go **green** on that pull request.
|
||||
The workflow in `.gitea/workflows/checks.yml` carries **no `branches:`
|
||||
filter**, so a `master` base matches and a real run happens — verified on
|
||||
throwaway PR #107 (base `master`), which queued a genuine run.
|
||||
|
||||
⚠️ Green means green. A `skipped` status is laundered into a `success`
|
||||
rollup by this Gitea and the merge gate accepts it — see the long note at
|
||||
the top of `.gitea/workflows/checks.yml`. Never add an event filter there.
|
||||
|
||||
4. Merge the pull request. `required_approvals` is **0**, so a green check is
|
||||
the whole gate; there is no second pair of eyes unless someone asks for one.
|
||||
|
||||
5. **Reload the store.** Merging to `master` does not, by itself, offer the
|
||||
update. The Supervisor only sees the new version after:
|
||||
|
||||
```bash
|
||||
ssh root@192.168.2.158 "ha store reload && ha addons update local_goodwe_controller"
|
||||
```
|
||||
|
||||
Until `ha store reload` runs, the add-on page still shows the old version
|
||||
and the release looks like it did nothing.
|
||||
|
||||
Note that `release/1.0` is normally an *ancestor* of `master` between releases,
|
||||
so a `release/1.0 → master` pull request is empty until new commits land on
|
||||
`release/1.0`. That is expected; it means there is nothing to release yet.
|
||||
|
||||
---
|
||||
|
||||
## 2. Branch protection — before and after SEC-2
|
||||
|
||||
Forge is Gitea at `192.168.2.147:3000`, repo `admin/goodwe-addon`. Read it back
|
||||
with `GET /api/v1/repos/admin/goodwe-addon/branch_protections`.
|
||||
|
||||
**Before (2026-09-23):**
|
||||
|
||||
| branch | protected | status check required | context | approvals |
|
||||
|---|---|---|---|---|
|
||||
| `master` (default) | **no rule at all** | — | — | — |
|
||||
| `release/1.0` | yes | yes | `checks / unit (pull_request)` | 0 |
|
||||
|
||||
**After:**
|
||||
|
||||
| branch | protected | status check required | context | approvals |
|
||||
|---|---|---|---|---|
|
||||
| `master` (default) | yes | yes | `checks / unit (pull_request)` | 0 |
|
||||
| `release/1.0` | yes (unchanged) | yes | `checks / unit (pull_request)` | 0 |
|
||||
|
||||
Both rules are otherwise identical: `enable_push: false`,
|
||||
`enable_force_push: false`, `block_admin_merge_override: true`, no whitelists,
|
||||
no file patterns. `required_approvals` stays at **0** on purpose — raising it
|
||||
would block the review agent from merging, and nobody asked for it.
|
||||
|
||||
The default branch was **not** changed. Changing it would change what the
|
||||
Supervisor clones, which is a different decision from this one.
|
||||
|
||||
### How the gate was proven to actually bite
|
||||
|
||||
A protection rule on this forge can *appear* satisfied while having checked
|
||||
nothing, so the rule was tested rather than trusted. The discriminator is a
|
||||
merge `POST` carrying a deliberately wrong `head_commit_id`: Gitea evaluates
|
||||
branch protection *before* it checks the head, so
|
||||
|
||||
* `409 head out of date` → the request **cleared** protection;
|
||||
* `405 Not all required status checks successful` → protection **refused** it.
|
||||
|
||||
Read the *message*, not just the code: `405` is also returned as
|
||||
`Please try again later` for a pull request with **no commits**, which has
|
||||
nothing to do with protection. An empty pull request on an unprotected base
|
||||
gives that same `405` (verified, throwaway PR #110), so an empty
|
||||
`release/1.0 → master` reports it too.
|
||||
|
||||
Validated against a control on an unprotected base (PR #106), which answered
|
||||
`409` while its check was still pending — i.e. protection was no obstacle
|
||||
there. On `master` (PR #107) the same probe answered `405` while pending, and
|
||||
`409` once the check was genuinely green. A direct `git push` to `master` was
|
||||
rejected by the pre-receive hook with `master`'s SHA unmoved.
|
||||
|
||||
---
|
||||
|
||||
## 3. Rolling back
|
||||
|
||||
There is no force-push escape hatch: `enable_force_push` is `false` on both
|
||||
branches. To undo a bad release, land a revert on `release/1.0`, bump the
|
||||
version again, and take it through §1. The `/addons/` rollback for the
|
||||
reference install is in `HANDOVER.md`.
|
||||
+2353
-16
File diff suppressed because it is too large
Load Diff
@@ -75,8 +75,9 @@ Every point still carries `provisional=True`, so the **flag never lies** — wha
|
||||
decays is the number, behind a flag that is telling the truth.
|
||||
|
||||
⚠️ **No value-level check can catch that**, and claiming one would be worse than
|
||||
claiming none. A carried point and a published-provisional point (`eneco_eddy`'s
|
||||
current quarter, §9.2) are the same three fields carrying the same flag. The
|
||||
claiming none. A carried point and a published-provisional point (a real-time
|
||||
product's current interval, §9.2) are the same three fields carrying the same
|
||||
flag. The
|
||||
difference is provenance, and provenance is not in the value.
|
||||
|
||||
The defence is therefore an **ordering**, not a validation:
|
||||
@@ -139,8 +140,16 @@ file constructs and still holds a reference to.
|
||||
|
||||
## 4. A sub-hourly feed passes through whole; the fill only decides which hours are missing.
|
||||
|
||||
`eneco_eddy` is the Belgian imbalance product and settles **per quarter**, and
|
||||
`price.py`'s `_validate_raw()` accepts any strictly-increasing spacing — so a
|
||||
⚠️ **This section originally said `eneco_eddy` is "the Belgian imbalance
|
||||
product". That was false and [[TARIFF-07]] withdrew the plugin: Eneco's dynamic
|
||||
product is "Zon & Wind Dynamisch" and is indexed to Belpex/EPEX **day-ahead**,
|
||||
not to imbalance, and `api.eneco.be/eddy/prices` never existed. The mechanism
|
||||
below is unaffected, because the real quarter-hourly feed is the one still
|
||||
shipped.**
|
||||
|
||||
Belgian **day-ahead** settles per **quarter** — the 15-minute MTU took effect
|
||||
on 2025-10-01, so `generic_dayahead` reads 96 points a day rather than 24 — and
|
||||
`price.py`'s `_validate_raw()` accepts any strictly-increasing spacing, so a
|
||||
published hour may legitimately hold four points.
|
||||
|
||||
FCST-04's first caller keyed the published set as `{_utc_hour(p.start): p for p
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# FCST-06 — "cannot persist" is one question, and it is asked about the outcome
|
||||
|
||||
Two decisions. The first is the one the ticket put up for judgement; the second
|
||||
is the one that decides what a store answers *after* it has failed to write.
|
||||
|
||||
---
|
||||
|
||||
## 1. One derived predicate, not two places testing two symptoms — yes
|
||||
|
||||
`price_history.py`'s module docstring states, unconditionally:
|
||||
|
||||
> ⚠️ AND THAT RULE IS CARRIED OUT IN `_remember()`, NOT ONLY IN `_save()`. …
|
||||
> A store that cannot persist now records nothing, and both of its answers say
|
||||
> the same thing.
|
||||
|
||||
It was enforced against `not self.ok`, and `ok` is **about the directory**: it
|
||||
says `/data` was there when the store opened. A read-only remount and a full
|
||||
volume both leave it `True`. So the second way to be a store that cannot
|
||||
persist — `ok=True`, the directory present, `_save()` failing — was outside the
|
||||
one condition the rule named, and in that state `words` printed two full days
|
||||
beside the write error while `yesterday()` answered 24 real points, from memory,
|
||||
until a restart dropped them without a word.
|
||||
|
||||
**The ticket's question was whether "cannot persist" should be one derived
|
||||
predicate rather than two places each testing a different symptom. It should,
|
||||
and the reason is not tidiness — it is that the enumeration cannot be
|
||||
completed.** FCST-04's M4 enforced the rule on the symptom that was in front of
|
||||
it. FCST-05's HIGH was the same shape — an `except` clause naming the one
|
||||
content-failure member somebody had met (`json.JSONDecodeError`) instead of the
|
||||
`ValueError` family — and its fix was to name the family. This is the third instance in
|
||||
one module, and it is the last place in this file where the rule can be spelt
|
||||
twice. A fourth cause — an `OSError` subclass, a filesystem that accepts the
|
||||
`mkstemp` and refuses the `os.replace`, a container whose volume is unmounted
|
||||
mid-refresh — needs no fourth branch now, because none of them is asked about.
|
||||
|
||||
### Where the one question can be asked, which is the whole design
|
||||
|
||||
**"Can this store persist?" cannot be answered before the attempt.** A
|
||||
directory that exists and was writable a moment ago is not a write that
|
||||
completed; there is no pre-check that is not itself a guess, and adding one
|
||||
(`os.access`, a probe write) would be a *third* symptom test — precisely what
|
||||
the ticket rules out. So the predicate is an **outcome**:
|
||||
|
||||
_save() -> bool # True only if the file now holds what memory holds
|
||||
|
||||
Both ways of not persisting are the same answer from the same call: no durable
|
||||
volume returns `False` without touching the disk, an `OSError` out of the write
|
||||
returns `False` after recording why. `_remember()` reads that one answer and
|
||||
keeps only what it says was kept. **The `not self.ok` test in `_remember()` is
|
||||
gone** — not moved, gone — because there is nothing left for a second test to
|
||||
add, and a check in `test_price_history.py` 5d asserts its absence from the
|
||||
source.
|
||||
|
||||
⚠️ **That last check is deliberately a source check, and habit 4 is why it is
|
||||
allowed to be.** The claim "there are not two places testing two symptoms" is a
|
||||
claim about the *shape of the code*, not about a behaviour — the behaviour is
|
||||
identical whether the rule is enforced once or twice. Every other check in 5d
|
||||
is driven.
|
||||
|
||||
### What was NOT done, and why
|
||||
|
||||
- **Not a `can_persist()` helper.** It would have to answer before the attempt,
|
||||
which is the thing that cannot be done honestly, and it would be a second
|
||||
place to keep in step with the first.
|
||||
- **Not a probe write at `_open()`.** It would make `ok` mean something new, it
|
||||
would write to `/data` on every start, and a volume that is writable at 08:00
|
||||
and full at 14:00 would still defeat it. A predicate that can go stale is a
|
||||
symptom test wearing a predicate's name.
|
||||
- **Not making a failed write fatal.** §6, and this module's own opening: a
|
||||
broken cache must not be a broken controller. An add-on that will not start
|
||||
because `/data` filled up is a worse defect than the one being fixed. The
|
||||
store degrades; it does not raise. `_save()` reports and returns.
|
||||
|
||||
---
|
||||
|
||||
## 2. The MERGE is undone, not the store
|
||||
|
||||
When `_save()` answers no, `_remember()` restores the state the read started
|
||||
from — **not** an empty one.
|
||||
|
||||
The alternative considered was to clear `self._days` outright, on the argument
|
||||
that a store which cannot write is a store that should stop answering. It is
|
||||
wrong, and measurably so: the state being restored is the last one that **is on
|
||||
disk**, so answering it is not a promise the store cannot keep. A site whose
|
||||
volume fills at 14:00 goes on answering the yesterday it wrote at 13:30 —
|
||||
through the restart, because that record is in the file. What it stops doing is
|
||||
*adding* to it.
|
||||
|
||||
Clearing would throw away a good yesterday that survives a restart in order to
|
||||
punish a write that failed, and it would make the morning fill worse on exactly
|
||||
the day the operator is already dealing with a full disk.
|
||||
|
||||
That gives the file the invariant its rule was always describing, stated once
|
||||
in the class docstring:
|
||||
|
||||
> `self._days` **never holds a point the file does not.**
|
||||
|
||||
⚠️ Not *"mirrors the file"* — `_usable_days()` drops records the file still
|
||||
holds, and that asymmetry is deliberate. The only direction that can **invent a
|
||||
yesterday** is the one named.
|
||||
|
||||
⚠️ **And the snapshot is `copy.deepcopy`, not `dict(self._days)`.** A one-level
|
||||
copy shares the day *records*, and the merge rebinds `points` on the ones it
|
||||
touches — so the "restored" store would answer the merge that never reached the
|
||||
file. Measured: with a shallow copy, a store holding 12 persisted points
|
||||
answered **24** after a failed merge. It would also be correct only for as long
|
||||
as nobody makes the merge mutate a `points` list or a row in place, which is the
|
||||
same "correct for the conditions someone thought of" this ticket is about. The
|
||||
store holds at most `KEEP_DAYS + 2` records and writes 48 times a day; a copy
|
||||
that is right by construction is worth more than the allocations.
|
||||
|
||||
---
|
||||
|
||||
## What this does not change
|
||||
|
||||
- **No shipped path reaches the defect.** `/data` is the Supervisor's durable
|
||||
volume and this module is its only writer, so on a real install `_save()`
|
||||
succeeds and nothing above is on the hot path. The fix is that a guarantee the
|
||||
module states about itself now holds in the condition it did not.
|
||||
- **The failure was already visible and still is** — `_LOG.error("could not
|
||||
persist price history: %s")` and `last_error`, reported by both `words` and
|
||||
`yesterday()`'s reason. What changed is that it is now *acted on*.
|
||||
- **Nothing about the prices.** The series served is the feed's own, every
|
||||
carried point is still flagged provisional, and a broken cache is still a
|
||||
short horizon rather than a §11.4 degradation.
|
||||
@@ -0,0 +1,406 @@
|
||||
# SAFETY-10 — shadow mode, and why "default-on" is not `true`
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-10`.
|
||||
⚠️ **AMENDED BY SAFETY-47, and three of the claims below were false when this
|
||||
was merged.** §5's "the strategies are unaffected" exemption, §4's peak-guard
|
||||
row and §6's evidence table are corrected in place and each correction is
|
||||
marked. The amendments are marked rather than rewritten silently, because what
|
||||
this card believed is part of why the defect shipped. See
|
||||
`DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md`.
|
||||
**Card:** SAFETY-10 — *"Shadow mode (default-on, log-only decisions)"*.
|
||||
**Nothing here reached the inverter.** No deployment, no `ha addons update`, no
|
||||
contact with 192.168.2.158 or 192.168.2.221. Everything below was measured in
|
||||
the CI image and on TEST-23 stage 3's closed-loop rig.
|
||||
|
||||
---
|
||||
|
||||
## 0. The four things a reader should take away
|
||||
|
||||
1. **"Default-on" is delivered as `auto`, not as `true`, and the difference is
|
||||
an install that is regulating a real battery today.** The Supervisor
|
||||
composes an add-on's options as *its defaults, overlaid with the stored user
|
||||
options*, so a new key with a new default reaches **every existing install**
|
||||
on the next update. A boolean `shadow_mode: true` would silently stop the
|
||||
reference site. §1.
|
||||
2. **Shadow mode WRITES. It writes 0 W, on the ordinary heartbeat, through the
|
||||
ordinary single writer.** "Commands nothing" implemented as "stops writing"
|
||||
would leave the inverter latched on whatever was standing — it has no meter
|
||||
timeout — and would put the ESP32 into a tripped stale-input watchdog for
|
||||
the whole run. §2.
|
||||
3. **The sign watchdogs are inert, and that was the decision that had to be got
|
||||
right.** Fed the shadow decision they would trip within `sign_disagree_s`
|
||||
and **latch a trip that survives into live operation and a restart**. §4.
|
||||
4. **The loop leg's shadow decision is one slew step, not a trajectory, and
|
||||
that is a real limitation of this feature.** §5.
|
||||
5. ⚠️ **SAFETY-47: and neither is anything else that depends on the plant
|
||||
answering back. A shadow run cannot surface a closed-loop defect at all**,
|
||||
by the same construction that makes it safe — the battery is held at 0 W, so
|
||||
every measured input describes a plant that did not respond. Of the three
|
||||
defects §"WHAT DEFECT THIS PREVENTS" was written for, exactly one —
|
||||
TARIFF-14's wrong quarter-hour — is visible from a shadow run. §5, §6.
|
||||
|
||||
---
|
||||
|
||||
## 1. The default
|
||||
|
||||
### The literal reading, and what it costs
|
||||
|
||||
`config.yaml`'s `options:` block **is** the default. There is exactly one of it,
|
||||
and HA's Supervisor resolves an add-on's effective options as
|
||||
|
||||
```
|
||||
{**config.yaml options, **the stored user options}
|
||||
```
|
||||
|
||||
A key that has never been stored therefore takes the file's value — on a fresh
|
||||
install and on an install that has been running for months, identically. There
|
||||
is **no mechanism in the add-on options system** by which one key can default
|
||||
differently for the two. So the card's title, taken at face value, is:
|
||||
|
||||
> the next `ha addons update` stops the battery at the reference site, and the
|
||||
> only symptom is a battery that has stopped doing anything.
|
||||
|
||||
That is strictly worse than every defect shadow mode exists to catch. It is
|
||||
refused.
|
||||
|
||||
### What was shipped instead
|
||||
|
||||
`shadow_mode` is a three-valued `list(auto|on|off)` defaulting to `"auto"`:
|
||||
|
||||
| value | resolves to |
|
||||
|---|---|
|
||||
| `off` | live |
|
||||
| `on` | shadow, always |
|
||||
| `auto` | shadow **while control is stopped**; live once it is started |
|
||||
|
||||
`auto` reads `Controller.auto` — the persisted start/stop state, which a fresh
|
||||
install takes from `auto_start` and which the *Start / stop control* button
|
||||
moves. No new persisted state, no migration, no guess:
|
||||
|
||||
* a **fresh** install ships `auto_start: false`, has no stored `auto` key, and
|
||||
therefore comes up **in shadow mode**. It observes a day of decisions before
|
||||
it is ever allowed to command anything. That is the card's intent, delivered
|
||||
where it is safe;
|
||||
* an install that is **controlling** is started, so `auto` resolves to live and
|
||||
**not one watt changes**;
|
||||
* an install that is **stopped** keeps its actuator at 0 W exactly as before —
|
||||
the same number, from the same writer, on the same heartbeat. What changes is
|
||||
what the status page and the action log **say**, and that the decision path
|
||||
now runs and is recorded.
|
||||
|
||||
⚠️ **Under `auto`, pressing Start leaves shadow mode.** That is the point of
|
||||
`auto`: the button means "control the battery". An operator who wants the
|
||||
decisions recorded *while the loop runs* — the configuration this card exists
|
||||
to make possible — sets `on` explicitly, and cannot reach it by accident.
|
||||
|
||||
⚠️ **An unrecognised value resolves to SHADOW, and is REFUSED at startup.**
|
||||
`shadow.mode()` fails toward inaction; `validate.py` rule 11 refuses the value
|
||||
by name so the reinterpretation is never silent. A typo is not a decision, and
|
||||
an install silently left in shadow mode commands nothing indefinitely while its
|
||||
status page calmly explains that this is intentional.
|
||||
|
||||
---
|
||||
|
||||
## 2. "Commands nothing" is a number, not a silence
|
||||
|
||||
The inverter holds its last command **forever** — `HANDOVER.md` §6 says so, and
|
||||
`firmware/goodwe-master.yaml`'s header records what it cost when a controller
|
||||
stopped refreshing: *"Illusmart went silent mid-command and the inverter held
|
||||
+5000 W for 113 s"*. `firmware/goodwe-master.yaml` states the contract that follows — *"HA
|
||||
must now REWRITE the setpoint every ~10 s even when the value has not
|
||||
changed"* — because without a refresh, **"stale" and "steady" are the same
|
||||
thing on the wire**.
|
||||
|
||||
So shadow mode holds the actuator at `shadow.WIRE_W` = **0.0 W**, refreshed on
|
||||
the ordinary heartbeat. Two consequences were weighed and are recorded:
|
||||
|
||||
* **it is not silence.** A shadow mode that stopped writing would leave the
|
||||
standing command up until the ESP32's 30 s watchdog noticed — and at a site
|
||||
with no such board, forever;
|
||||
* **it does not use an alarm as a mode.** Letting that watchdog trip for the
|
||||
duration of a shadow run teaches the operator that `wd_tripped` is normal,
|
||||
which is exactly the training that makes it useless when the add-on has
|
||||
actually died.
|
||||
|
||||
### It is not a second writer
|
||||
|
||||
SAFETY-28's rule is one actuator, one writer. Shadow mode files **no claim**,
|
||||
owns **no transport**, and calls nothing in `hass.py`. It is a **substitution**
|
||||
inside `Controller._write_unit()` — the single choke point every write in this
|
||||
add-on already reaches, including the heartbeat, the stop button, `shutdown()`
|
||||
and `maint_abort()` — in exactly the form of the three substitutions above it
|
||||
(non-finite → 0 W, unauthorised → 0 W, clamped → the rating).
|
||||
|
||||
It is the **last** of them. Below SAFETY-28's gate and below SAFETY-03's clamp,
|
||||
so both still grade the number the decision path actually produced, and a
|
||||
shadow run can report *"it would have hit the rating here"*.
|
||||
|
||||
---
|
||||
|
||||
## 3. What the run records
|
||||
|
||||
| surface | what it carries |
|
||||
|---|---|
|
||||
| status banner | `SHADOW MODE - the battery is NOT being controlled...`, **first in the cascade**, amber — red, with the latch named, if one is standing |
|
||||
| status row `Shadow mode` | which rung decided, the watts it **would have commanded on this cycle**, where the inverter actually is, and `Resolution.explain()` — plus, when a measured billed quarter sized those watts, SAFETY-47's caveat saying so (`shadow.SIZED_FROM_METER`) |
|
||||
| status row `Commanded` | `0 W` — the truth, which the row above reconciles with the `Why` row below it |
|
||||
| action log | one line when shadow mode comes on; one line per **change of decision** (winner, rounded watts, or SAFETY-47's caveat state), carrying the arbiter's own sentence and the same caveat the row carries — `shadow.log_line()` is the one sentence both read |
|
||||
| MQTT | `status: shadow`, its own value, below `failsafe` |
|
||||
|
||||
The action log is **edge**-logged, not level-logged: the log holds 40 rows, and
|
||||
a line per cycle would flush the context of the run's own findings inside four
|
||||
minutes at the 15 s cycle floor.
|
||||
|
||||
---
|
||||
|
||||
## 4. The failsafes, one by one
|
||||
|
||||
| failsafe | in shadow mode | why |
|
||||
|---|---|---|
|
||||
| SAFETY-02/30 sign watchdogs | **INERT** | They grade `Inverter.standing_w()` — what this controller has standing on the wire — and shadow mode holds that at 0 W, so they cannot arm. ⚠️ Fed the shadow decision instead they would disagree with a meter that never saw it and **latch a trip that survives into live operation and a restart**: shadow mode would manufacture the failsafe it exists to help debug. ⚠️ **Two rungs hold it there** — `last_write` (the wire gate) and `inv.target` (the re-seed) — which is MUTATION.md's masking shape, so the mutation that reproduces the hazard defeats both as a pair (M6). Driven singly, dropping the re-seed does not reproduce it at all — measured |
|
||||
| SAFETY-03 clamp | **OBSERVES** | Runs on the decision, sets `clamp_last`, logs every engagement. Changes no command |
|
||||
| SAFETY-28 write gate | **OBSERVES** | Same placement, same reason: the question is who chose the number |
|
||||
| SAFETY-18 meter freeze | **OBSERVES**, unchanged | It takes no action live either; both halves of its conjunction are as true in shadow |
|
||||
| SAFETY-07/24/25 peak guard | **OBSERVES, AND SEES ONLY THE HOUSE** | Level-triggered, unlatched, files its claim as usual, so the recorded decision is the real one *for this meter*. ⚠️ **AMENDED BY SAFETY-47.** This row used to end "SAFETY-45's limit cycle is visible from a shadow run, which is the point". It is not: every term of `wanted = batt_w + excess` is measured, and in shadow mode the battery contributes nothing to either. Measured on the rig, same hour, same unsized code — **410 of 720 cycles engaged live, 0 of 720 in shadow** |
|
||||
| SAFETY-31 circulation | **OBSERVES AND MAY LATCH** | Its evidence is *measured* power. Packs pushing against each other while all are told 0 W is a real fault |
|
||||
| SAFETY-42 delivery | **OBSERVES AND MAY LATCH** | Same, against a share that is 0 W |
|
||||
| operator stop | **SUPPRESSED AS A CLAIM, ENFORCED AT THE WIRE** | The one deliberate change. Its `P_SAFETY` 0 W claim out-ranks everything, so leaving it in makes the recorded decision `"stopped"` on every cycle and the run answers the wrong question. The actuator ends at the same number either way, and the wire substitution is **strictly stronger**: a claim can be out-ranked, and it cannot |
|
||||
| inputs missing, non-finite, allocator refusal, arbiter contradiction | **UNCHANGED** | All resolve to 0 W, which is what the wire gets anyway |
|
||||
|
||||
⚠️ **A latch that is already up stays up.** Nothing here clears one. The shadow
|
||||
banner **names** any standing latch and goes red for it, because a latch that is
|
||||
up while the controller is inert is up the moment it stops being inert.
|
||||
|
||||
⚠️ **Maintenance is not in that table because shadow mode does not touch it.**
|
||||
The state machines are ticked on every cycle whether control is started or not,
|
||||
so a stopped install already runs them against a battery that does not move.
|
||||
Shadow mode inherits that, unchanged and unimproved. Recorded here rather than
|
||||
quietly fixed under another card's name.
|
||||
|
||||
---
|
||||
|
||||
## 5. The limitation, stated rather than discovered later
|
||||
|
||||
`Controller._shadow_settle()` re-seeds the carried command (`self.target` and
|
||||
each `inv.target`) to `WIRE_W` at the **end** of every shadow cycle — after
|
||||
every in-cycle guard has read the decision. Three machines read that value as a
|
||||
statement about the **hardware**: the loop's `prev_w` and integrator,
|
||||
`standing_w()`, and SAFETY-42's share comparison. Re-seeded, they track reality,
|
||||
and **the exit is safe by construction** — the carried command already equals
|
||||
the standing one, so the first live cycle slews from 0 W like any other.
|
||||
|
||||
⚠️ **The cost is real.** The loop's `prev_w` is therefore 0 W on every shadow
|
||||
cycle, so the loop leg's recorded decision is **one slew step from the standing
|
||||
command** and stays there. A shadow run answers *"what would this controller
|
||||
command NOW, from where the plant really is"* — a true statement about the
|
||||
present — and **not** *"what trajectory would it have followed"*. The trajectory
|
||||
is a closed-loop property, it did not happen, and reporting it would be a
|
||||
simulation of a plant that does not exist; three cycles in, such a number
|
||||
describes nothing.
|
||||
|
||||
⚠️ **AMENDED BY SAFETY-47 — THE PARAGRAPH THAT STOOD HERE WAS THE DEFECT.**
|
||||
It read:
|
||||
|
||||
> The strategies are unaffected: arbitrage, the peak guard and maintenance all
|
||||
> file `SET` claims at a fixed power from level-triggered inputs. That is why
|
||||
> the three defects this card was written for — SAFETY-45's 28 onsets an hour,
|
||||
> TARIFF-14's wrong quarter-hour, SAFETY-46's ceiling churn — are all visible
|
||||
> from a shadow run, and the loop's ramp is not.
|
||||
|
||||
It was true until **SAFETY-45 merged, one commit before this card**. That
|
||||
change makes the buy leg `min(asked, headroom)` with
|
||||
|
||||
```
|
||||
headroom = (900 / remaining) x (ceiling - projected)
|
||||
```
|
||||
|
||||
and `projected` reads the **open quarter's accumulator** — the meter — which is
|
||||
a function of what the battery did earlier in that same quarter. The strategy
|
||||
is closed-loop, and the loop's one-slew-step limitation above is not a special
|
||||
case of anything: **it is the general case**, and the limitation is the whole
|
||||
feature's, not the loop leg's.
|
||||
|
||||
**The correct statement, and it is now graded rather than asserted:**
|
||||
|
||||
> A shadow run cannot surface a **closed-loop** defect — one whose stimulus is
|
||||
> the plant's response to the controller — because the battery is held at 0 W
|
||||
> and there is no response to be the stimulus. **Which** hour a strategy picks
|
||||
> and **why** are unaffected, because the clock and the price series are not
|
||||
> functions of the battery. **How hard** it trades is not.
|
||||
|
||||
Of the three defects this card was written for:
|
||||
|
||||
| | visible from a shadow run? | measured |
|
||||
|---|---|---|
|
||||
| SAFETY-45's limit cycle | **no** | 28 charge onsets live, **1** in shadow; grid −455.5 … +3200.0 W live, **flat at the 1200 W house** in shadow; guard engaged 410/720 live, **0/720** in shadow |
|
||||
| SAFETY-46's SoC churn | **no** | the SoC moves **0.000000000 pp** in a shadow run. No SoC limit can be *reached* in one |
|
||||
| TARIFF-14's wrong quarter-hour | **yes**, and it is the one | which hour, at what price, clearing what spread: identical live and shadow on **720 of 720** charging cycles, at a binding cap |
|
||||
|
||||
Graded in `test_arbitrage.py` part **3d** — the same closed loop part 3c uses,
|
||||
run once live and once in shadow — and the loop leg's own limitation stays
|
||||
named in `test_watchdog.py` part 10 as `RECORDED LIMITATION`.
|
||||
|
||||
---
|
||||
|
||||
## 6. How "nothing reaches the inverter" is graded
|
||||
|
||||
**Not on a flag.** A check that asserted `Controller.shadow` would pass on a
|
||||
build in which the flag is set and the substitution is deleted — which is
|
||||
exactly mutation **M1**.
|
||||
|
||||
`test_clamp.py` part 8 drives the **shipped** `Controller.cycle()` and the
|
||||
shipped heartbeat against a recording Home Assistant stub, and asserts on what
|
||||
reached `set_number()`:
|
||||
|
||||
* **the precondition first** — the same stimulus with `shadow_mode: off` puts
|
||||
30 non-zero writes on the wire, the largest 5000 W. MUTATION.md habit 4: a
|
||||
check whose data cannot reach the failure it names is decoration;
|
||||
* then `set(writes) == {0.0}`, **and** that there were 26 of them — a check
|
||||
that only said "no non-zero write" would pass on the silent build too;
|
||||
* entry and exit are measured on `inv.last_write` — what the inverter is
|
||||
latched on, updated only on an **accepted** write — either side of the
|
||||
boundary, against the same run that never entered shadow mode.
|
||||
|
||||
The closed-loop half is `/home/homelab/s10-mut/shadowloop.py` on TEST-23 stage
|
||||
3's rig: the same hour, twice, against `plantsim.Plant` through the firmware's
|
||||
own frame encoder.
|
||||
|
||||
⚠️ **SUPERSEDED BY SAFETY-47 — THE TABLE THAT STOOD HERE COULD NEVER HAVE GONE
|
||||
RED, FOR TWO INDEPENDENT REASONS.** It read `arb_power_w` −2000 W *"identical"*,
|
||||
`arb_reason` *"identical, verbatim"*, arbiter winner *"identical"*, and
|
||||
concluded *"the decision happened, at the same instant, for the same reason, at
|
||||
the same signed power — and the battery did not move."* The battery half is
|
||||
true and is unchanged. The **decision** half was decoration:
|
||||
|
||||
1. **the fixture is `peak_cap_w: 15000` against a 350 W house**, so the
|
||||
capacity guard never engages and SAFETY-45's sizing is in the run and inert.
|
||||
The harness's own comment says so — *"at this scenario's `peak_cap_w: 15000`
|
||||
it is not binding anyway"* — which is MUTATION.md habit 4 written down
|
||||
beside the check it disables;
|
||||
2. **every identity row reads the FIRST charging sample.** At `elapsed = 0`
|
||||
the projection *is* the held level, so the two runs agree to the float there
|
||||
**even at a cap that does bind**. Measured: closing reason (1) alone leaves
|
||||
that comparison passing.
|
||||
|
||||
Both had to be closed. Re-taken below at **`peak_cap_w` 1500 / house 1200 /
|
||||
`max_w` 2000**, one hour at a 5 s cycle, **across the whole run** — harness
|
||||
`/home/homelab/s47-mut/shadowloop47.py`, same rig, same three transcribed
|
||||
lines.
|
||||
|
||||
| | live | shadow |
|
||||
|---|---|---|
|
||||
| grid | 1224.1 … 1506.0 W — **held up at the ceiling** | **1200.0 W flat = the house, exactly** |
|
||||
| SoC | 50.000019 → **51.856789 %** | 50.000000 → **50.000000 %** |
|
||||
| `ARBITRAGE_CHARGE` cycles | 720 / 720 | **720 / 720** |
|
||||
| first charging cycle, `arb_power_w` | −300.0000 W | **identical — and this is why the old table passed** |
|
||||
| `arb_power_w`, **whole run** | −307.799 … −299.930 W | **−2000.000 … −300.000 W** |
|
||||
| …cycles where the two differ | — | **716 of 720** |
|
||||
| `arb_reason`, whole run | the strategy's own sentence | **differs on 719 of 720** |
|
||||
| measured charging headroom | 299.93 … 307.80 W | **300.0 … 54 000.0 W** |
|
||||
| final recorded command | **−302.4 W** | **−2000.0 W — 6.61× the live figure** |
|
||||
| price / spread / which hour | | **identical on 720 of 720** |
|
||||
| arbiter winner | `arbitrage` | **identical** |
|
||||
|
||||
**What the re-taken table says.** The decision still *happens*, at the same
|
||||
instant, on the same hour, at the same price, for the same *kind* of reason,
|
||||
and the battery still does not move — those were the card's real claims and
|
||||
they hold. What does **not** hold is that the decision is the *same decision*:
|
||||
its **size** is a function of a meter the battery never moved, and at this cap
|
||||
the recorded figure is 6.61× what a live controller commands. 54 000 W of
|
||||
"headroom" under a 1500 W ceiling is `900/remaining` multiplying a gap the
|
||||
battery never closed.
|
||||
|
||||
⚠️ **And the SoC row is the second half of it.** 0.000000000 pp of movement is
|
||||
not a curiosity: it is the proof that **no SoC-limit behaviour is reachable
|
||||
from a shadow run at all**, which §5 used to promise.
|
||||
|
||||
SAFETY-47 puts the caveat on both surfaces that carry the number and narrows
|
||||
the three documents' claims; `test_arbitrage.py` part 3d is the CI-side grader
|
||||
so this class cannot recur.
|
||||
|
||||
⚠️ **Three lines of each harness are a TRANSCRIPTION and are marked as one.**
|
||||
`priceloop.Rig` has no `_write_unit()`, no clamp and no failsafe machine
|
||||
(divergence 2 of its README), so the substitution's **placement** is
|
||||
transcribed there. The shipped placement is graded by `test_clamp.py` part 8,
|
||||
which drives the real `Controller`. ⚠️ **And the rig's transcribed cycle
|
||||
predates SAFETY-45**: `arbitrage.decide()` gained a required
|
||||
`charge_headroom_w` after `478ba93`, and the harness supplies it from the
|
||||
**shipped** `peakguard.charge_headroom_w()` reading the **shipped** peak
|
||||
decision rather than from a constant, so SAFETY-45 stays in the run.
|
||||
|
||||
---
|
||||
|
||||
## 7. The mutation battery — 22 mutations, no survivors
|
||||
|
||||
Harness: `/home/homelab/s10-mut/run.py`, backups beside it. Each run restores
|
||||
from backup, purges `__pycache__` **on the host**, and grades through the CI
|
||||
image with the tree bind-mounted `:ro`, so the interpreter cannot write a cache
|
||||
into the tree under test (TEST-15). The full table is in the branch report;
|
||||
one per new behaviour, including **M1** (the substitution deleted — a write
|
||||
escapes), **M2** (it returns instead of substituting — the inverter is left
|
||||
latched), **M3** (it moves above SAFETY-28 and SAFETY-03 — the observers go
|
||||
blind), **M8** (`auto` resolves to shadow unconditionally — the literal
|
||||
default-on that stops the live install), **M12** (the stop claim is filed in
|
||||
shadow — every recorded decision reads `stopped`), **M21** (the substitution
|
||||
and the re-seed together) and **M22** (the re-seed moved one line earlier —
|
||||
SAFETY-18 grades a 0 W no decision produced and goes permanently blind).
|
||||
|
||||
⚠️ **Two findings from driving it, both of the kind MUTATION.md says to expect.**
|
||||
|
||||
* **M2 first came back a SURVIVOR and was not one.** The "stop writing
|
||||
altogether" build left `inv.last_write` as `None`, the check did `float()` on
|
||||
it, and the section died with a `TypeError` — grading none of the checks
|
||||
after it and reporting **zero reds**, which reads exactly like a survivor.
|
||||
That is FCST-06's shape. The fixture now hands `None` back and names it as
|
||||
its own check; M2 is red on three.
|
||||
* **M6 had to become a PAIR, and only the battery said so.** `standing_w()`
|
||||
prefers `last_write` — the 0.0 W the wire gate accepted — and falls back to
|
||||
`inv.target`, which the re-seed also holds at 0.0. Either rung alone keeps
|
||||
the detector inert, so dropping the re-seed **singly** came back red on the
|
||||
limitation check and on **nothing about the latch**: the hazard it names was
|
||||
not reproduced. TEST-16's rule with a new name on it.
|
||||
* **M7/M8/M9/M19 had to MOVE SUITES.** They defeat `shadow.engaged()`, and a
|
||||
defeated default stops `test_clamp.py` part 3's wired run reaching the wire
|
||||
at all — aborting the file with an `IndexError` before part 8 is graded. The
|
||||
red count there was a lower bound naming the saturation freeze. The mode
|
||||
table now lives in `test_validate.py`, which drives no Controller.
|
||||
* **`shadow_would_w = float(self.target)` is an EQUIVALENT mutation and was
|
||||
measured as one.** `_shadow_settle()` runs before the re-seed, so at that
|
||||
line `self.target` **is** `resolution.target_w`. It is recorded as equivalent
|
||||
rather than reported as a survivor, and the non-equivalent form of the same
|
||||
defect — the record becoming the **wire** — is driven instead.
|
||||
|
||||
### The table
|
||||
|
||||
Each row: the mutation, the file, the byte delta, the suite it was graded in,
|
||||
and how many **named** checks went red. `⚠️` marks a row whose run did not
|
||||
reach the end of its file, so its count is a **lower bound** (MUTATION.md).
|
||||
|
||||
| # | mutation | file | Δ | suite | reds |
|
||||
|---|---|---|---|---|---|
|
||||
| M1 | **a write escapes** — the substitution deleted | `main.py` | −6 B | clamp | 5 |
|
||||
| M2 | **silence instead of 0 W** — it returns rather than substituting | `main.py` | −15 B | clamp | 3 |
|
||||
| M3 | **the observers go blind** — it moves above SAFETY-28 and SAFETY-03 | `main.py` | +58 B | clamp | 1 |
|
||||
| M4 | the site re-seed dropped — the exit resumes from a fiction | `main.py` | −36 B | clamp | 3 |
|
||||
| M5 | the per-unit re-seed dropped | `main.py` | −22 B | clamp | 1 |
|
||||
| M6 | **PAIRED**: `standing_w()` reads the target **and** the re-seed dropped — the sign latch that survives into live operation | `inverter.py` + `main.py` | −170 B | watchdog | 3 |
|
||||
| M7 | `auto` inverted — shadow while started, live while stopped | `shadow.py` | +2 B | validate | 4 |
|
||||
| M8 | **default-on for everyone** — `auto` always shadow; the literal reading that stops the live install | `shadow.py` | −15 B | validate | 2 |
|
||||
| M9 | an unreadable value resolves to LIVE instead of shadow | `shadow.py` | +1 B | validate | 1 |
|
||||
| M10 | `validate.py` stops refusing an unknown value | `shadow.py` | −9 B | validate | 9 |
|
||||
| M11 | the loop leg gated on `auto` alone again — the decision path stops running | `main.py` | −17 B | clamp | 1 |
|
||||
| M12 | the stop claim filed in shadow — every decision reads `stopped` | `main.py` | −20 B | clamp | 1 |
|
||||
| M13 | the banner goes green while nothing is commanded | `shadow.py` | −2 B | clamp | 1 |
|
||||
| M14 | the banner loses the standing latch | `shadow.py` | −2 B | clamp | 1 |
|
||||
| M15 | the row renders the standing command instead of the decision | `shadow.py` | −8 B | clamp | 1 |
|
||||
| M16 ⚠️ | the row disappears when live | `main.py` | +43 B | clamp | 1 |
|
||||
| M17 | the record becomes the wire | `main.py` | −6 B | clamp | 7 |
|
||||
| M18 | level-logged — the 40-row action log floods | `main.py` | −23 B | clamp | 1 |
|
||||
| M19 | the two rungs render identically (masking) | `shadow.py` | +22 B | validate | 1 |
|
||||
| M20 | the shipped default moves to `off` in `config.yaml` alone | `config.yaml` | −1 B | defaults_pin | 1 |
|
||||
| M21 | **PAIRED**: the substitution **and** the re-seed deleted together | `main.py` | −103 B | clamp | 10 |
|
||||
| M22 | the re-seed moved one line earlier — SAFETY-18 grades a 0 W no decision produced and goes permanently blind | `main.py` | +40 B | clamp | 1 |
|
||||
|
||||
**Survivors: none.** ⚠️ **M17's `+0 B`-adjacent sibling** —
|
||||
`shadow_would_w = float(self.target)` — is recorded in §7 as **equivalent**
|
||||
rather than counted here.
|
||||
@@ -187,6 +187,23 @@ over-absorption. So the rig does not choose the number — **the constraint
|
||||
chooses it, and the rig says what the constraint does and does not buy.** That
|
||||
is the same relationship the 2026-08-22 table had to its own three columns.
|
||||
|
||||
⚠️ **FORWARD REFERENCE, ADDED BY SAFETY-48 — THIS TABLE AND THIS SENTENCE ARE
|
||||
TRUE OF release/1.0's LAW AND FALSE OF THE ONE THAT REPLACED IT.** Nothing above
|
||||
is retracted: re-run on release/1.0 today the same rig prints the same column
|
||||
(958 / 1107 / 1133 / 1058 / 987 / 1029 / 1150 / 1143 / 1081 W), flat,
|
||||
non-monotone, 800 better than 556. Under SAFETY-48's lead cap the **same script
|
||||
on the same plant** prints 777 / 734 / 663 / 563 / 553 / 507 / 435 / 358 / 321 W
|
||||
— falling monotonically, so a slower slew is now *better* and the sentence above
|
||||
inverts. The mechanism SAFETY-39 identified is exactly why: `slew_w` bounds the
|
||||
OUTPUT while the integrator goes on accumulating `gain · error`, so slowing the
|
||||
slew used to buy nothing; bound the integrator's lead and it starts to.
|
||||
`tools/slew_sim.py --slew` now names which of the two laws it is running under
|
||||
before it narrates the column, and
|
||||
`DECISIONS/SAFETY-48-bounding-the-integrator-lead.md` §6 carries both tables and
|
||||
the independent confirmation from `test_control.py`'s own plant model. ⚠️ **This
|
||||
does NOT re-open the choice of 556**: the constraint picks the number, the rig
|
||||
does not, and that is unchanged in both laws.
|
||||
|
||||
⚠️ **A single step is not evidence here.** The deadband, the 10 W quantisation
|
||||
and the phase of the step against the tick move the result by hundreds of watts,
|
||||
non-monotonically. The first cut of this derivation was a single step and ranked
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# SAFETY-45 — the trade is SIZED by the capacity guard, not switched by it
|
||||
|
||||
**Status: closed.** `peakguard.charge_headroom_w()` publishes the watts the open
|
||||
billed quarter can still afford; `arbitrage.decide()` takes it as a required
|
||||
keyword-only argument and sizes the BUY leg to it; `main.py` passes the measured
|
||||
guard's own number at its one call site. `peakguard.decide()` is **not changed** —
|
||||
byte-identical to `release/1.0` at `478ba93`, `sha256(decide) = 200090d0835a3a55`,
|
||||
7252 B on both trees.
|
||||
|
||||
⚠️ **THE HOUSE LOAD IN EVERY FIGURE BELOW IS SYNTHETIC.** TEST-23 stage 3's
|
||||
`priceloop.py` drives a flat modelled house against a modelled pack
|
||||
(`plantsim.Plant`, 800 W/s ramp into a 10 s lag, 15 360 Wh). The PRICES are real,
|
||||
the decision code is the shipped code, the billed-quarter accumulator is
|
||||
`app/p1.py`'s own — the load is not. Nothing here is a measurement of the live
|
||||
site at 192.168.2.158, which this work did not touch.
|
||||
|
||||
## 0. The defect, measured
|
||||
|
||||
TEST-23 stage 3 found a sustained limit cycle with arbitrage armed and a charge
|
||||
decision active. Re-measured here on the same rig against the same checkout,
|
||||
metrics recomputed rather than copied — a **charge onset** is a cycle at which
|
||||
`arbitrage.decide()` transitions into `ARBITRAGE_CHARGE`, which is the edge the
|
||||
rig already logs:
|
||||
|
||||
| `peak_cap_w` | house | onsets/h | mean period | grid swing | billed quarters (W) |
|
||||
|---|---|---|---|---|---|
|
||||
| 1500 | 1200 W | **28** | 131.1 s | **−455.5 … +3200.0 W** | 1490.1 / 1496.5 / **1508.8** |
|
||||
| 3500 *(shipped)* | 1800 W | 8 | 509.3 s | +127.5 … +3800.0 W | **3511.3 / 3536.0 / 3559.1** |
|
||||
| 3500 *(shipped)* | 3000 W | 32 | 114.8 s | +1202.0 … +5000.0 W | **3518.9 / 3542.2 / 3556.7** |
|
||||
| 5000 | 1200 W | 1 | — | +1240.6 … +3200.0 W | 3169.5 / 3200.0 / 3200.0 |
|
||||
|
||||
The last row is the **negative control**: `load + max_w = 3200 W` never reaches
|
||||
the cap, so there is no cycle to remove. The bite rule is `load + max_w >
|
||||
peak_cap_w`, which at the shipped `peak_cap_w: 3500` / `max_w: 2000` is any house
|
||||
drawing over 1500 W in the cheapest quarter.
|
||||
|
||||
§10.1's ordering did win on the money — the guard kept the quarters within tens
|
||||
of watts of the cap — but **every billed quarter in the three biting rows
|
||||
finished OVER it**, which is the thing §9.1 bills. §10.2: *"an EMS that
|
||||
oscillates is worse than no EMS, both for hardware wear and — per §9.1 — for the
|
||||
peak it is trying to avoid."*
|
||||
|
||||
## 1. ⚠️ The mechanism is NOT mainly the quarter boundary, and the card says it is
|
||||
|
||||
The card, and `.cache/s45.md` before it, describe the loop as *"at each quarter
|
||||
boundary the projection resets to 0, the guard disengages, and arbitrage re-arms
|
||||
at full power."* **That is not what the 28 onsets are.** Their timestamps, off
|
||||
the run above:
|
||||
|
||||
1, 231, 351, 471, 581, 696, 806, 896, 1136, 1256, 1371, 1486, 1596,
|
||||
1711, 1811, 2061, 2176, 2296, 2411, 2521, 2626, 2716, 2971, 3091,
|
||||
3206, 3321, 3431, 3541 (s, quarter boundaries at 900/1800/2700)
|
||||
|
||||
**23 of the 27 gaps are 90–120 s, mean 111.5 s, entirely inside one quarter.**
|
||||
The four long gaps — 230, 240, 250, 255 s — are precisely the four that SPAN a
|
||||
boundary, so the boundary **lengthens** the interval rather than causing an
|
||||
onset. Only three onsets land within 15 s of a boundary and one of those is
|
||||
`t = 1`, the start of the run.
|
||||
|
||||
The real cycle, read off the recorded fields at t = 1000…1180 s:
|
||||
|
||||
1. the trade charges at `−2000 W`; the meter sits at 3200 W against a 1500 W
|
||||
ceiling; `wanted = batt_w + excess` is **negative** (−132 → −8 W), so
|
||||
`decide()` files **nothing** and arbitrage is not constrained at all;
|
||||
2. `wanted` drifts up as the quarter's own history worsens and the `900 /
|
||||
remaining` gain grows, and crosses zero at t = 1055 with **`floor_w = 5.3 W`**;
|
||||
3. that 5 W floor flips `arbitrage.guard_state()` to `True`, which refuses the
|
||||
**whole trade** — so the setpoint goes from −2000 W to +5 W and the
|
||||
grid-following loop, which targets grid = 0 and not grid = ceiling, takes the
|
||||
actuator and discharges to **+1870 W**, exporting 420 W;
|
||||
4. the projection collapses, `wanted` goes negative, the guard releases, and
|
||||
arbitrage re-arms at **`max_w`**. 115 s later, again.
|
||||
|
||||
So the defect is **a binary interlock keyed on a continuous quantity**, and the
|
||||
quarter boundary is one of the ways it is crossed rather than the cause. The
|
||||
card's other claim — *"projection 3092 W against a 1509 W ceiling, `engaged=False`,
|
||||
for 170 consecutive seconds"* — reproduces, but it is the **first** such window
|
||||
(`first_engaged_t_s = 151.0`); the steady-state silent window is ~40–65 s.
|
||||
|
||||
None of this changes what the fix has to do. It is recorded because a card that
|
||||
says "the boundary is the trigger" invites a fix in the boundary handling, and
|
||||
such a fix would have removed four of twenty-eight onsets.
|
||||
|
||||
## 2. What was rejected, and why
|
||||
|
||||
### 2a. Reverting `decide()`'s `wanted <= 0.0` rung — rejected, and it is a trap
|
||||
That rung is 2026-08-20's anti-bang-bang. Its own comment records the cost of not
|
||||
having it: 17 engage/release cycles in 900 s and a quarter finishing 49 W over
|
||||
the limit it had spent a quarter of an hour holding. It is untouched, pinned on
|
||||
the AST in `test_peakguard.py` 13g, and `tools/replay_ac3.py` reproduces the same
|
||||
190/190, 110 engaged cycles, 5 blind, 878 Wh on this branch as on `478ba93`.
|
||||
|
||||
### 2b. Filing the affordable charge as a NEGATIVE `lo` — rejected
|
||||
Arithmetically this is the prettiest answer: `lo = wanted` with no sign gate is
|
||||
continuous, is exactly the control law `decide()` already computes, and would
|
||||
have put the constraint on the arbiter where it binds every claimant rather than
|
||||
only arbitrage. It was rejected on two grounds, in this order:
|
||||
|
||||
1. ⚠️ **IT CREATES A NEW `lo > hi` CONTRADICTION CLASS AGAINST THE MAINTENANCE
|
||||
SHAPING.** `main.py`'s cheap-window maintenance charge files
|
||||
`Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` — a NEGATIVE `hi`.
|
||||
`arbiter.py` clause 2 applies every limit at or above the winner's priority
|
||||
and takes the most restrictive, with **no notion of one limit out-ranking
|
||||
another**; a peak `lo = −300 W` against a shaping `hi = −1000 W` is `lo > hi`,
|
||||
which resolves to **0 W — the battery parked, in the one quarter of the month
|
||||
that sets the bill**. That is the exact failure `peakguard.shaping_suspended()`
|
||||
exists to prevent, and the only way to close it would be to key that
|
||||
suppression on "a claim exists" — which, on every site this card is about,
|
||||
means the maintenance charge-only shaping is suspended **permanently**. A
|
||||
silent, large change to a different subsystem is not a price this card should
|
||||
pay.
|
||||
2. It changes the meaning of `PeakDecision.engaged` at five call sites outside
|
||||
this module — `arbitrage.guard_state()`, `shaping_suspended()`,
|
||||
`claim()`, `main.py`'s edge-logged action line and `main.py`'s status row —
|
||||
each of which today means "the guard is HOLDING" and would silently start
|
||||
meaning "a claim exists".
|
||||
|
||||
`test_peakguard.py` part 5's sweep asserts **no negative floor, ever**, and the
|
||||
reason it gives ("a `lo` limit is incapable of capping discharge or export only
|
||||
while it is non-negative") is, strictly, not the true reason — a `lo` of any sign
|
||||
caps neither. The invariant is still right and is still worth holding; §2b above
|
||||
is the argument that actually supports it, and it is recorded here rather than
|
||||
left for the next person to re-derive.
|
||||
|
||||
### 2c. A backoff, cooldown or hysteresis constant in `arbitrage.py` — rejected
|
||||
The card asks whether the second half of the fix belongs in arbitrage: *"a trade
|
||||
refused at 14:59 and re-armed at 15:00 has learned nothing from the refusal."*
|
||||
It does belong there, but as a **size**, not as a memory. `arbitrage.py`'s
|
||||
hysteresis note is explicit that the switching decision is "which hour is it" and
|
||||
that **no timer is needed and none is invented**; `peakguard.py`'s design note
|
||||
spends a paragraph on why it carries no hysteresis constant. A cooldown would be
|
||||
a second control loop compensating for a limit the first one declined to file,
|
||||
and its constant would outlive whatever it was tuned against. The trade now
|
||||
re-arms at `ceiling − load` instead of at `max_w`, which is what "learning from
|
||||
the refusal" means when the refusal is a measurement.
|
||||
|
||||
## 3. What was done, and the precedent it follows
|
||||
|
||||
`control.maintenance_charge_floor()` has capped MAINTENANCE charging at *"the
|
||||
headroom left under the site's cap"* since the loop was written, and its
|
||||
docstring says why:
|
||||
|
||||
> "A maintenance charge is the only thing this system does that is big enough and
|
||||
> long enough to set that peak, so it is capped by the headroom left under the
|
||||
> site's cap."
|
||||
|
||||
⚠️ **THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED.** Price arbitrage is a
|
||||
second thing that is big enough and long enough — it commands `max_w` for a whole
|
||||
tariff interval, deliberately, against the meter — and it was given no such cap.
|
||||
This card gives it the same shape, with one difference that matters:
|
||||
`maintenance_charge_floor()` sizes itself from `peak_forecast_entity`, which is
|
||||
OPTIONAL and empty on a default install — the exact hole SAFETY-25 had to close
|
||||
in `shaping_suspended()`. `charge_headroom_w()` comes off `decide()`, which runs
|
||||
wherever P1 does.
|
||||
|
||||
**The number is `wanted_w` read in the other direction.** `decide()` already
|
||||
computes `wanted = batt_w + excess` as the DISCHARGE that lands the quarter on the
|
||||
ceiling; a negative `wanted` is a permitted CHARGE of `−wanted`. No new
|
||||
arithmetic, no new constant.
|
||||
|
||||
Two properties make it safe to hand a strategy, and both are swept rather than
|
||||
argued (`test_peakguard.py` part 13):
|
||||
|
||||
* ⚠️ **IT IS INVARIANT IN THE BATTERY.** Raise the charge by dW and the meter
|
||||
rises by dW, so `excess` rises by exactly dW while `batt_w` falls by dW:
|
||||
`wanted` does not move. Measured across `batt_w` from −5000 W to +2000 W on one
|
||||
banked quarter — the headroom is 1350.0 W at every one of the six points while
|
||||
the `excess` those runs see moves through six distinct values. It is a
|
||||
**feed-forward** number, so consuming it adds no feedback path around the plant.
|
||||
The fixed point is `charge = ceiling − load`, with the meter ON the ceiling and
|
||||
`wanted` parked a whole `ceiling − load` clear of the engagement boundary.
|
||||
* **HOLDING IMPLIES ZERO OR UNKNOWN.** Across 1080 swept combinations, not one
|
||||
ENGAGED decision answers a positive headroom, so the function can never hand a
|
||||
caller permission the guard's own `lo` is at that instant refusing.
|
||||
|
||||
## 4. After, on the same rig, same command, same checkout
|
||||
|
||||
| `peak_cap_w` | house | onsets/h | grid swing | energy bought | billed quarters (W) |
|
||||
|---|---|---|---|---|---|
|
||||
| 1500 | 1200 W | 28 → **1** | 3655.5 → **282.0 W** | 180.0 → **285.2 Wh** | **1500.0 / 1500.0 / 1500.0** |
|
||||
| 3500 | 1800 W | 8 → **1** | 3672.5 → 1687.6 W | 1652.9 → 1616.1 Wh | **3500.0 / 3500.0 / 3500.0** |
|
||||
| 3500 | 3000 W | 32 → **1** | 3798.0 → **474.7 W** | 482.9 → 475.3 Wh | **3500.0 / 3500.0 / 3500.0** |
|
||||
| 5000 | 1200 W | 1 → 1 | **identical** | **identical** | **identical** |
|
||||
|
||||
* **One onset** means the trade starts once and runs for the whole window. The
|
||||
battery is trading 100 % of the hour where it used to trade 43 %, 92 % and 54 %.
|
||||
* **Every billed quarter lands exactly on the cap and none over it**, where every
|
||||
quarter in the three biting rows used to finish over.
|
||||
* **The money.** At 1500/1200 the site buys **58 % MORE** energy, because a
|
||||
continuous 300 W trade beats a 2000 W trade that is cancelled for 57 % of the
|
||||
hour. At the two shipped-default rows it buys **2.2 % and 1.6 % less** — and in
|
||||
exchange the worst quarter drops from 59 W over the cap to 0 W over it. The
|
||||
capacity tariff bills the month's single worst quarter with no averaging and no
|
||||
forgiveness (specs.md:583), so that is not a trade-off, it is the ordering
|
||||
§10.1 asks for.
|
||||
* The remaining 1687.6 W at 3500/1800 is the opening **ramp**, not a cycle: the
|
||||
pack goes from 0 to 1700 W of charge in about ten seconds and the meter with
|
||||
it. One onset, no export, no reversal.
|
||||
* The last row is bit-identical before and after. **This card costs nothing on
|
||||
the installs it is not about.**
|
||||
|
||||
## 5. What this does NOT fix, with sizes
|
||||
|
||||
* **TEST-23 finding 1 — the export overshoot at the END of a trade** (356–938 W,
|
||||
≈0.37 × `max_w`) is untouched and is a different card. It happens when the
|
||||
cheap interval ends and the grid-following loop takes back an actuator that was
|
||||
holding a charge; nothing here changes the handover.
|
||||
* **A house whose load ALONE exceeds the cap** (`load > peak_cap_w`) still puts
|
||||
`wanted > 0`, still engages the guard, and still refuses the trade outright
|
||||
through the interlock — correctly, since you cannot buy in a quarter you are
|
||||
already over on. The loop then drives to grid = 0 rather than to grid =
|
||||
ceiling, which is more discharge than the guard asked for. Not swept here: all
|
||||
four (cap, load) pairs on the record have `load < cap`.
|
||||
* **`arbitrage.claim()` still files the SIZED number, but the ARBITER can clamp
|
||||
it further** and the strategy is not told. The reason line names the guard's
|
||||
headroom, not the arbiter's `bound_by`. Pre-existing; not widened.
|
||||
* **The container leg was not re-run.** `hadrive.py` needs `test23s3-addon`
|
||||
started, and that container is deliberately stopped because it is a second
|
||||
writer on the setpoint entity under measurement. The in-process rig's twelve
|
||||
transcribed lines are pinned by `test_arbitrage.py` 3b's AST check on
|
||||
`main.py`'s call site, which is the line this card adds to them.
|
||||
@@ -0,0 +1,319 @@
|
||||
# SAFETY-47 — what a shadow run can show, and what it cannot
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-47`.
|
||||
**Card:** SAFETY-47 — *"shadow mode's claims, and the number on the status
|
||||
row"*.
|
||||
**Nothing here reached the inverter.** No deployment, no `ha addons update`, no
|
||||
contact with 192.168.2.158 or 192.168.2.221. Everything below was measured in
|
||||
the CI image and on TEST-23 stage 3's closed-loop rig.
|
||||
|
||||
⚠️ **This is not a write-path defect and nothing in the write path was
|
||||
touched.** SAFETY-10's substitution, re-seed, authorisation gate and clamp are
|
||||
unchanged. What was wrong is what three merged documents **say** a shadow run
|
||||
can show you, and a number on the status row that can be **6.61×** what a live
|
||||
controller would command.
|
||||
|
||||
---
|
||||
|
||||
## 0. The four things a reader should take away
|
||||
|
||||
1. **Shadow mode cannot surface a closed-loop defect. Not partially — not at
|
||||
all**, and by the same construction that makes it safe. §1.
|
||||
2. **The "strategies are unaffected" exemption was true until SAFETY-45 merged,
|
||||
one commit before SAFETY-10.** Sizing the buy leg to the billed-quarter
|
||||
headroom made the strategy closed-loop. §2.
|
||||
3. **The recorded watts are the right answer to a question nobody asked, and
|
||||
they are CAVEATED rather than re-computed.** Re-computing would mean the
|
||||
recorded decision was no longer the decision the shipped path reached, which
|
||||
is the one thing shadow mode has to be able to say. §3 records what was
|
||||
rejected.
|
||||
4. **SAFETY-10 §6's committed evidence could never have failed, for two
|
||||
independent reasons, and closing either one alone closes nothing.** §4.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why the answer is "not at all", and not "not very well"
|
||||
|
||||
Shadow mode holds every actuator at `shadow.WIRE_W` and re-seeds the carried
|
||||
command to it at the end of each cycle. So every **measured** input — the
|
||||
meter, the billed-quarter accumulator behind it, the battery power, the state
|
||||
of charge — describes a plant that **did not respond**.
|
||||
|
||||
A closed-loop defect is, by definition, one whose stimulus **is** the plant's
|
||||
response to the controller. No response, no stimulus, nothing to see. This is
|
||||
not an implementation gap:
|
||||
|
||||
* feeding the guards a **simulated** plant instead is exactly what SAFETY-10 §4
|
||||
refuses for the sign watchdogs, and the reason generalises — a controller
|
||||
reacting to a plant that does not exist drifts further from reality every
|
||||
tick, and three cycles in its numbers describe nothing;
|
||||
* and SAFETY-10 §5 already conceded the same fact for the grid-following loop
|
||||
("one slew step, not a trajectory"). That was never a property of the loop.
|
||||
**It is the general case**, and the loop leg was simply where it had already
|
||||
been noticed.
|
||||
|
||||
**So the closed-loop answer comes from a closed-loop rig (TEST-23 stage 3) or
|
||||
from a live run. There is no third place, and saying that plainly is worth more
|
||||
than the three sentences it replaces.**
|
||||
|
||||
### Measured, on the rig, at a cap that binds
|
||||
|
||||
`peak_cap_w` 1500 / house 1200 / `max_w` 2000, one hour at a 5 s cycle. Of the
|
||||
three defects SAFETY-10 was written for:
|
||||
|
||||
| | visible? | measured |
|
||||
|---|---|---|
|
||||
| SAFETY-45's limit cycle | **no** | 28 charge onsets live, **1** in shadow. Grid −455.5 … +3200.0 W live, **flat at the 1200 W house** in shadow. Guard engaged 410/720 cycles live, **0/720** in shadow. (Taken with SAFETY-45 reverted — that is the defect being looked for.) |
|
||||
| SAFETY-46's SoC churn | **no** | the SoC moves **0.000000000 pp** in a shadow run. The SoC is the pack's integral of what the pack was commanded, and it was commanded 0 W. No SoC limit can be *reached* in a shadow run at all |
|
||||
| TARIFF-14's wrong quarter-hour | **yes**, and it is the only one | the price lookup's inputs are the clock and the price series, neither a function of the battery. Which hour, at what price, clearing what spread: **identical live and shadow on 720 of 720 charging cycles, at a binding cap** |
|
||||
|
||||
### What a shadow run genuinely is for
|
||||
|
||||
*Which* strategy wins, on *which* hour, at *which* price, for *which* reason,
|
||||
against which limits — the wrong-quarter-hour class, a trade armed on a day it
|
||||
should not be, a refusal whose sentence names the wrong subsystem, a clamp that
|
||||
would have engaged, a standing failsafe latch. That is a real and useful set,
|
||||
and it is the set the documents now claim.
|
||||
|
||||
---
|
||||
|
||||
## 2. The exemption, and exactly how much of it survives
|
||||
|
||||
All three documents carried a version of:
|
||||
|
||||
> The **strategies** are unaffected — price arbitrage, the capacity guard and
|
||||
> maintenance all ask for a fixed power **from inputs that do not depend on
|
||||
> what the battery did**.
|
||||
|
||||
**True before SAFETY-45, false after it.** SAFETY-45 makes the buy leg
|
||||
`min(asked, headroom)`, and
|
||||
|
||||
```
|
||||
headroom = (900 / remaining) x (ceiling - projected)
|
||||
```
|
||||
|
||||
where `projected` reads the **open quarter's accumulator** — the meter — which
|
||||
is a function of what the battery did earlier in that same quarter. SAFETY-45
|
||||
merged one commit before SAFETY-10, so the exemption was stale on the day it
|
||||
was written.
|
||||
|
||||
**What survives, and it is now graded rather than asserted:**
|
||||
|
||||
> **Which** hour a strategy picks, and **why**, are unaffected — the clock and
|
||||
> the price series are not functions of the battery.
|
||||
> **How hard** it trades is not.
|
||||
|
||||
⚠️ **The peak guard is not in the surviving half either.** Every term of
|
||||
`wanted = batt_w + excess` is measured, so in a shadow run the guard engages
|
||||
only if the *house alone* would put the quarter over the ceiling. Its
|
||||
**ceiling** and its **reasons** are worth reading from a shadow run; its
|
||||
**engagements** are not evidence of anything.
|
||||
|
||||
---
|
||||
|
||||
## 3. The number on the status row — decided, and what was rejected
|
||||
|
||||
At `peak_cap_w` 1500 under a 1200 W house the status row read **"WOULD HAVE
|
||||
COMMANDED −2 000 W"**. A live controller commands **−302.4 W**. Measured on the
|
||||
shipped build:
|
||||
|
||||
| cap / house | live commanded | shadow recorded | over by |
|
||||
|---|---|---|---|
|
||||
| 1500 / 1200 | −302.4 W | **−2000.0 W** | **6.61×** |
|
||||
| 3500 / 3000 | −502.6 W | −2000.0 W | 3.98× |
|
||||
| 3500 / 2000 *(shipped cap)* | −1502.6 W | −2000.0 W | 1.33× |
|
||||
| 3500 / 1800 | −1702.6 W | −2000.0 W | 1.17× |
|
||||
| 15000 / 350 | −2000.0 W | −2000.0 W | 1.00× |
|
||||
|
||||
⚠️ **The arithmetic is not wrong.** `900/remaining` is correct for a meter that
|
||||
is genuinely under the ceiling, and in a shadow run the meter genuinely *is*
|
||||
under it, because the battery is not charging. **The recorded number is the
|
||||
right answer to a question nobody asked** — and the last row of that table is
|
||||
the fixture SAFETY-10's own evidence was taken on, which is why nothing
|
||||
reported it.
|
||||
|
||||
### What shipped
|
||||
|
||||
1. **Unconditionally**, the row and the log line stop forecasting: the watts
|
||||
are introduced as what would have been commanded **ON THIS CYCLE**. No
|
||||
number a shadow run records is a prediction of a live run, so no state
|
||||
should read like one.
|
||||
2. **Conditionally**, whenever a **measured** billed quarter sized the number
|
||||
being recorded, both surfaces carry `shadow.SIZED_FROM_METER`:
|
||||
|
||||
> ⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE
|
||||
> HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same
|
||||
> trade against a meter the battery is moving, so this is an UPPER BOUND and
|
||||
> not a prediction; how far under it a live run lands is a closed-loop
|
||||
> question and a shadow run cannot answer it.
|
||||
|
||||
⚠️ **"Upper bound", and not "smaller", and the difference was measured.**
|
||||
Charging only *adds* offtake, so a live `projected` is never below a shadow one
|
||||
and the headroom never above it — with **equality at the start of every
|
||||
quarter**, where `900/remaining` is 1.0 and neither plant has moved. Measured
|
||||
pairwise over 3600 charging cycles in five configurations: **0 cycles below,
|
||||
730 exactly equal.** A sentence reading "smaller" would have been false on
|
||||
every one of those 730, and the first draft of this card said "smaller".
|
||||
|
||||
⚠️ **And it names no factor.** How far under the bound a live run lands is a
|
||||
property of the site. A number invented for the row would be a second helping
|
||||
of the defect the sentence exists to disclose.
|
||||
|
||||
⚠️ **The predicate is NOT keyed on whether the trade was capped, and that is
|
||||
the trap this card nearly walked into.** `arbitrage`'s own sentence says *"SIZED
|
||||
BY THE CAPACITY-TARIFF GUARD"* only when the headroom came out **below** the
|
||||
ask — which in a shadow run is the **start** of the quarter, where the two runs
|
||||
still agree to the float. By the end, `900/remaining` has multiplied the
|
||||
headroom **past** the ask, the trade is **not** capped, the sentence is silent,
|
||||
and the recorded number is at its **most** wrong. A caveat keyed on it would be
|
||||
quietest exactly where it is needed loudest. `arbitrage.sized_from_meter()` is
|
||||
keyed on the headroom having been **measured**, so it fires on both.
|
||||
`test_arbitrage.py` part 3d-8 drives the inversion.
|
||||
|
||||
### Rejected, and why
|
||||
|
||||
* **Re-compute the number so it does not amplify** — e.g. record
|
||||
`ceiling − projected` (gain 1.0) instead of the sized figure. It matches the
|
||||
live answer almost exactly at this cap, and it is the **worst** of the
|
||||
options. It would be a **shadow-only sizing**: the recorded decision would no
|
||||
longer be the decision the shipped path reached, while the arbiter, the
|
||||
clamp, SAFETY-28's gate, the action log and MQTT all ran on the real one. A
|
||||
second copy of the sizing arithmetic living in the display layer is
|
||||
MUTATION.md's transcribed-predicate defect on an operator-facing surface, and
|
||||
it generalises to no other strategy. SAFETY-10's whole safety argument is
|
||||
that shadow mode is a **substitution at the wire**, not a second controller;
|
||||
this would have made it a second controller everywhere except the wire.
|
||||
* **Record nothing where the sizing depends on measured state.** Tempting —
|
||||
"no number beats a number 6.61× out". Rejected on three counts: it blanks the
|
||||
row exactly when arbitrage is trading, which is when a commissioning engineer
|
||||
most wants it; the action log's edge key is the rounded watts, so blanking
|
||||
them either floods or silences the log; and a surface that flickers between a
|
||||
number and a dash is harder to read than a number with a standing caveat.
|
||||
The narrow version of this — dropping the watts only — was the closest
|
||||
runner-up.
|
||||
* **Record the unsized ask alongside the sized one.** It adds a number without
|
||||
adding a warning. The unsized ask is `max_w`, a constant the operator
|
||||
configured; on the shipped 3500/2000 row both figures are 2000 W, so the pair
|
||||
is exactly as misleading as the single figure was.
|
||||
* **A caveat on every cycle in every state.** Rejected as the *whole* answer,
|
||||
accepted as half of it: the unconditional part is a two-word narrowing of the
|
||||
verb ("on this cycle"), not a warning, and the warning proper is conditional
|
||||
so that it still means something when it appears.
|
||||
|
||||
---
|
||||
|
||||
## 4. SAFETY-10 §6's evidence, and why it could not fail
|
||||
|
||||
§6 tabulated `arb_power_w` −2000 W *"identical"*, `arb_reason` *"identical,
|
||||
verbatim"*, arbiter winner *"identical"*. Two independent reasons it could
|
||||
never have gone red:
|
||||
|
||||
1. **the fixture is `peak_cap_w: 15000` against a 350 W house**, so the guard
|
||||
never engages and SAFETY-45's sizing is in the run and inert. ⚠️ **The
|
||||
harness's own comment says so** — *"at this scenario's `peak_cap_w: 15000`
|
||||
it is not binding anyway"* — which is MUTATION.md habit 4 written down
|
||||
beside the check it disables;
|
||||
2. **every identity row reads the FIRST charging sample.** At `elapsed = 0` the
|
||||
projection *is* the held level, so the two runs agree to the float there
|
||||
**even at a cap that does bind**.
|
||||
|
||||
⚠️ **AND THAT SECOND REASON IS THE ONE A REVIEWER CANNOT SEE FROM A DIFF.**
|
||||
Widening the fixture alone — the obvious fix — leaves the `[0]` comparison
|
||||
passing. Measured, not reasoned: at 1500/1200 the first charging cycle is
|
||||
−300.0000 W in **both** runs while 716 of the 720 cycles differ. Part 3d-2
|
||||
grades that agreement **as an expectation**, so nobody rebuilds a check on
|
||||
`[0]` believing it covers something.
|
||||
|
||||
This is habit 4's **twelfth** instance in this repository, its **second**
|
||||
inside a committed evidence artifact, and — like the seventh and the eleventh —
|
||||
it was in a **measuring instrument** rather than in a check.
|
||||
|
||||
§6 is re-taken at `peak_cap_w` 1500 / house 1200 / `max_w` 2000, across the
|
||||
whole run. Harness `/home/homelab/s47-mut/shadowloop47.py`.
|
||||
|
||||
---
|
||||
|
||||
## 5. The grader, so this class cannot recur
|
||||
|
||||
`test_arbitrage.py` part **3d** — placed beside part 3c, which is SAFETY-45's
|
||||
own closed-loop regression, and built on the **same** `s45_loop()` harness so
|
||||
the two runs share every line of shipped code and differ in **one argument**:
|
||||
|
||||
```
|
||||
s45_loop(sized=True) the live hour
|
||||
s45_loop(sized=True, shadow=True) the same hour, battery held at WIRE_W
|
||||
s45_loop(sized=False, shadow=True) SAFETY-45's own defect, watched from
|
||||
inside shadow mode
|
||||
```
|
||||
|
||||
Three lines of `s45_loop()` stand in for `Controller._write_unit()`'s
|
||||
substitution and `_shadow_settle()`'s record and re-seed. They are marked as a
|
||||
**transcription**; `test_clamp.py` part 8 is the check on the shipped
|
||||
placement, and it drives the real `Controller`. Everything that produces a
|
||||
number — the averager, the projection, the guard, the headroom, the strategy,
|
||||
the arbiter, the row and the log sentence — is the shipped one.
|
||||
|
||||
What 3d grades, in order: that the cap **binds** (the precondition the old
|
||||
evidence lacked); that the first charging cycle agrees **anyway**; that the
|
||||
whole run diverges, and by how much; that the record is an **upper bound**, and
|
||||
the two-point arithmetic that makes it one; what survives of the exemption;
|
||||
that SAFETY-45's limit cycle and the guard's engagements are **not** visible;
|
||||
the four rungs of `sized_from_meter()`, each reached with the other three clear
|
||||
(TEST-16); both operator surfaces; the `capped`-keying trap; and main.py's
|
||||
three call sites, pinned on the AST.
|
||||
|
||||
⚠️ **The section carries its own precondition into every check.** A mutation
|
||||
that stops either run trading left the lists empty, the first `min()` raised,
|
||||
the file died before part 4 and the run reported **zero reds — which reads
|
||||
exactly like a survivor**. That is FCST-06's shape and it was **driven**, not
|
||||
imagined. `s47check()` now fails every check in the section when the stimulus
|
||||
was not produced, and the one division in it answers `nan` rather than raising.
|
||||
|
||||
---
|
||||
|
||||
## 6. The mutation battery — 14 mutations, no survivors
|
||||
|
||||
Harness `/home/homelab/s47-mut/run.py`, backups inside it. Each run restores
|
||||
from backup, purges `__pycache__` **on the host**, and grades through the CI
|
||||
image with the tree bind-mounted `:ro`, so the interpreter cannot write a cache
|
||||
into the tree under test (TEST-15). Fingerprints are printed before the first
|
||||
run and re-checked after the last restore.
|
||||
|
||||
| # | mutation | file | Δ | reds |
|
||||
|---|---|---|---|---|
|
||||
| M1 | the row drops the caveat branch | `shadow.py` | −63 B | 2 |
|
||||
| M2 | **the trap**: `sized_from_meter()` keyed on the trade being CAPPED — silent where the record is multiples out | `arbitrage.py` | +33 B | 5 |
|
||||
| M3 | the CHARGE rung dropped — a sell carries a caveat §9.1 makes false | `arbitrage.py` | −30 B | 1 |
|
||||
| M4 | the measured-headroom rung dropped | `arbitrage.py` | −18 B | 1 |
|
||||
| M5 | the arbiter-winner rung dropped — a caveat on somebody else's number | `arbitrage.py` | −33 B | 1 |
|
||||
| M6 | `main.py` renders the row with `sized_from_meter=False` | `main.py` | −23 B | 1 |
|
||||
| M7 | `main.py` drops `winner=` — the rung exists and is never reached | `main.py` | −1 B | 1 |
|
||||
| M8 | `main.py` sizes the trade from a literal — SAFETY-45 out of the shipped path, which the hoist put at risk | `main.py` | −41 B | 2 |
|
||||
| M9 | the headroom assignment moves out of `cycle()` | `main.py` | −39 B | 1 |
|
||||
| M10 | `sized_from_meter` gains a `False` default (TEST-14) | `shadow.py` | +8 B | 1 |
|
||||
| M11 | `ON THIS CYCLE` deleted — the row forecasts again | `shadow.py` | −14 B | 1 |
|
||||
| M12 | `log_line()` drops the caveat — the page discloses, the record does not | `shadow.py` | −63 B | 2 |
|
||||
| M13 ⚠️ | `main.py` re-assembles the log sentence at the call site | `main.py` | **+0 B** | 1 |
|
||||
| M14 ⚠️ | `wanted = batt_w − excess` — the headroom stops being monotone in the battery and the row's UPPER BOUND sentence becomes false | `peakguard.py` | **+0 B** | 38 |
|
||||
|
||||
**Survivors: none.**
|
||||
|
||||
⚠️ **M13 and M14 are `+0 B`, which MUTATION.md names as the risky delta.** Both
|
||||
were confirmed on disk by `sha256` before the run started — the harness prints
|
||||
the fingerprint of every graded file and the hash moved for each — and every
|
||||
run is purged, so neither is at risk of the stale-`.pyc` failure.
|
||||
|
||||
⚠️ **Two findings from driving it, both reported rather than tidied away.**
|
||||
|
||||
* **M14's first form was aimed at the wrong direction and correctly survived.**
|
||||
`min` → `max` in the buy leg's sizing makes the shadow record *larger*, which
|
||||
is still an upper bound, so 3d-3b was right not to fail. The mutation that
|
||||
actually inverts the bound is the sign of the excess term in `wanted`. A
|
||||
survivor that is right is worth more than a red aimed at the wrong claim.
|
||||
* **M12 was a survivor before the fix it forced.** Deleting the caveat from the
|
||||
action-log line left **all 29 files green**: the only check that could see
|
||||
that line drives a fixture with `meter_source: off`, where no arbitrage
|
||||
decision — and therefore no caveat — is reachable. The sentence was an
|
||||
f-string inside `Controller._shadow_settle()`, i.e. a surface no check could
|
||||
grade. It is `shadow.log_line()` now, for the reason `words()` is a function,
|
||||
and M13 is the mutation that puts it back at the call site.
|
||||
@@ -0,0 +1,551 @@
|
||||
# SAFETY-48 — the slew step ceiling reaches the integrator, and what it bounds is the LEAD
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-48`, on top of `TEL-10`.
|
||||
**Card:** SAFETY-48 — *"the integrator goes on winding while the command is
|
||||
slew-limited"*.
|
||||
**Nothing here reached the inverter.** The live install still runs `slew_w: 400`
|
||||
applied by hand on 2026-09-05 12:00 and `release/1.0`'s control law. This branch
|
||||
does not deploy and does not bump the version.
|
||||
|
||||
---
|
||||
|
||||
## 0. The four things a reader should take away
|
||||
|
||||
1. **It is a missing case, not a wrong constant, and the arithmetic says so.**
|
||||
The integrator moves `gain * error` a cycle and the command moves at most
|
||||
`slew_w`, so the integrator outruns the command whenever
|
||||
`|error| > slew_w / gain` — 667 W at the live shape. SAFETY-39's retune made
|
||||
`slew_w` **smaller**, which makes that threshold **lower**. §1.
|
||||
2. ⚠️ **THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, AND THAT IS WHY
|
||||
IT IS NOT WHAT SHIPPED.** Reusing the asymmetric freeze on a carried slew
|
||||
flag cuts export 13 % and moves the worst quarter-hour average **offtake**
|
||||
from 3739 W to **3935 W**, with the mean quarter +2.7 W — and on the billed
|
||||
quarter it is worse at **every plant lag where release/1.0 produces a peak
|
||||
at all** (a tie at lag 0, worse at 2.5 / 5.0 / 7.5 / 10.0 s) and at **every
|
||||
top-N cut of the quarters**, which makes this the least lag-sensitive result
|
||||
in the card. The harm is a tail redistribution inside quarters that already
|
||||
net to offtake, not a weaker answer to import in aggregate. §3, §3a.
|
||||
3. **What ships bounds the LEAD at one slew step, only where the slew is what
|
||||
stopped the command, and only in the leading direction.** The robust result
|
||||
is the **export reduction — 39.6 → 34.1 kWh, and it falls at every plant and
|
||||
tuning tried** — with wasted throughput 4.67 → 3.19 kWh, sign reversals
|
||||
2992 → 2770 and median recovery 232 → 199 s. ⚠️ **The billed quarter is not
|
||||
something this card can claim.** It reads 3739 → **3434 W** at the fitted
|
||||
plant lag of 7.5 s; at every lag ≤ 5.0 s — including the one that best
|
||||
reproduces the recorded export — release/1.0 is *already* at 3434 W and this
|
||||
card moves it by exactly zero. The lag is not identified by the data. §4,
|
||||
§3a.
|
||||
4. **Two committed numbers moved and neither is a silent edit.** SAFETY-13's
|
||||
released command settles lower, because there is less wound integrator to
|
||||
settle onto; and SAFETY-39's *"the slew does not own the over-absorption"* is
|
||||
now **false — inverted, on its own independent plant model.** §5, §6.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect, in the terms the code uses
|
||||
|
||||
`compute()` freezes the integrator with an asymmetric rule, and the comment
|
||||
beside it states the requirement outright:
|
||||
|
||||
> the requirement is that the INTEGRATOR stop accumulating, not only the command
|
||||
|
||||
But `frozen` is computed from **one** signal:
|
||||
|
||||
```python
|
||||
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w # not tracking
|
||||
```
|
||||
|
||||
Slew-limiting never enters it. When the command hits the step ceiling the
|
||||
integrator keeps winding at `gain * error` until it reaches `integrator_max_w`,
|
||||
and the unwind afterwards is `integrator_max_w / slew_w` cycles — 12.5 at the
|
||||
live shape, about a minute at the meter's measured tick.
|
||||
|
||||
⚠️ **AND THE RETUNE MADE IT MORE FREQUENT, NOT LESS.** `|error| > slew_w / gain`
|
||||
is 1667 W at `slew_w: 1000` and **667 W** at `slew_w: 400`. The measured median
|
||||
grid error at the onset of a recorded episode is 1114 W — above the second
|
||||
threshold and below the first. This is why SAFETY-39 did not fix it, and the
|
||||
mechanism is the same one §6 shows inverting.
|
||||
|
||||
## 2. How it was measured, and what the measurement cannot say
|
||||
|
||||
`tools/replay_recorded_loop.py`, over **16.00 days / 256 252 meter instants** of
|
||||
the live install's own recorder, exported read-only.
|
||||
|
||||
**Real:** the disturbance (`D = grid + batt` at every recorded meter instant —
|
||||
the house with this loop's own battery taken back out, which is exogenous and
|
||||
therefore valid to drive a counterfactual with); the tick (the meter's own
|
||||
recorded instants, median **5.01 s**); and the control law, imported out of the
|
||||
checkout. The "before" law is the **same file** with the SAFETY-48 block removed
|
||||
by a named substitution and exec'd into a fresh namespace — never a
|
||||
transcription — and a missing or duplicated anchor is a **refusal** with
|
||||
`NOTHING HAS BEEN MEASURED` on it, because an instrument that quietly compared
|
||||
the shipped law with itself would report the card as changing nothing.
|
||||
|
||||
**Modelled:** the inverter (pure delay 7.5 s + first-order lag 2.0 s +
|
||||
`batt = 0.973 × cmd − 22.4 W`, fitted on this same recording above the SoC
|
||||
floor, residual **375 W RMS**); the pack (SoC integrated, discharge refused at
|
||||
or below the floor); and **everything else the arbiter does** — the recorded
|
||||
setpoint is the arbiter's output after the peak guard, the SoC clamps,
|
||||
maintenance and arbitrage, while the replay runs `compute()` alone.
|
||||
|
||||
⚠️ **THE ABSOLUTE FIGURES ARE PESSIMISTIC BY ABOUT A THIRD AND THE TOOL PRINTS
|
||||
IT ON EVERY RUN.** Replaying the before-law reproduces 39.6 kWh of export where
|
||||
the install actually exported **29.4 kWh** over the same window. The delta is
|
||||
what this instrument is for: both laws run on the same ticks, the same
|
||||
disturbance, the same plant and the same seed.
|
||||
|
||||
⚠️ **THE SoC FILTER ON THE PLANT FIT IS THE WHOLE FIT, AND IT IS A FINDING IN
|
||||
ITS OWN RIGHT.** This install spends **36 % of its ticks at or below a 10 % SoC
|
||||
with the command standing near 2 kW and the battery delivering nothing.** Fitted
|
||||
over all ticks the recording says `batt = 0.50 × cmd − 396 W, r = 0.70` — a
|
||||
plant that follows half the command, which no inverter does. Above the floor the
|
||||
same data says `batt = 1.00 × cmd − 28 W, r = 0.977`. The floor is the PACK and
|
||||
is modelled separately.
|
||||
|
||||
## 3. The form the card asked for, and the number that refused it
|
||||
|
||||
The card asked for the asymmetric rule already in service for saturation, on a
|
||||
flag carried across cycles the way `sat_count` is. It was built — from
|
||||
release/1.0's own source, one substitution per change — and measured on the same
|
||||
16 days through the same pipeline:
|
||||
|
||||
| | release/1.0 | **form A** — carried slew flag + the asymmetric freeze | **SAFETY-48** — the lead cap |
|
||||
|---|---:|---:|---:|
|
||||
| export kWh | 39.61 | 34.43 | **34.07** |
|
||||
| import kWh | 116.07 | 110.62 | 111.20 |
|
||||
| battery sign reversals | 2992 | **2656** | 2770 |
|
||||
| wasted throughput kWh | 4.67 | **2.99** | 3.19 |
|
||||
| **worst 15-min offtake W** | 3739 | ⚠️ **3935** | **3434** |
|
||||
| mean quarter-hour offtake delta | — | ⚠️ **+2.73 W** | **−2.39 W** |
|
||||
| worst quarter-hour delta | — | +992 W | +532 W |
|
||||
| recovery median s | 232 | **184** | 199 |
|
||||
| recovery p99 s | 849 | **781** | 850 |
|
||||
| recovery max s | 901 | 890 | 898 |
|
||||
| unrecovered (> 900 s) | 53 | **26** | 31 |
|
||||
| \|i_w − prev_w\| p99 W | 3361 | 1534 | **800** |
|
||||
| \|i_w − prev_w\| max W | 9200 | 6912 | **800** |
|
||||
|
||||
**Form A wins on four rows and loses on the one that is billed.** The capacity
|
||||
tariff (*capaciteitstarief*) bills the month's worst quarter-hour average
|
||||
offtake, and form A raises it by **196 W** — and raises the MEAN quarter by
|
||||
2.7 W, so this is systematic and not one unlucky quarter. It does so in **five
|
||||
of the six** plant and tuning variants in `--sensitivity`; the sixth is a tie.
|
||||
|
||||
The per-cycle mechanism is that a frozen integrator stops at
|
||||
`prev + gain·error(first cycle)`, which is `(1 − gain)` short of what the site
|
||||
needs — 40 % short at `gain: 0.6`.
|
||||
|
||||
⚠️ **AND THE SENTENCE THAT USED TO FOLLOW THAT ONE WAS TOO BIG FOR THE DATA.**
|
||||
It read *"for an IMPORT it is offtake, so a large import is answered too weakly
|
||||
for as long as the ramp lasts"*. The aggregate does not show a weaker answer to
|
||||
import: form A cuts total import by **5.45 kWh** and export by **5.18 kWh**
|
||||
against release/1.0, and paired tick by tick it delivers **8.56 kWh LESS**
|
||||
offtake energy on the ticks release/1.0 spent importing (lower on 38 229 such
|
||||
ticks, higher on 19 669). What form A moves is the **TAIL, within quarters that
|
||||
already net to offtake** — the worst quarter goes 3739 → 3935 W, the top five
|
||||
3260 → 3300 W, the top fifty 1880.0 → 1882.7 W. The capacity tariff bills the
|
||||
worst quarter and nothing else, so a redistribution that leaves the mean alone
|
||||
and lifts the maximum is exactly the trade that is refused here. The conclusion
|
||||
is unchanged; the causal sentence is now the one the data supports.
|
||||
|
||||
⚠️ **THE CARD SAID "do not invent a second rule", AND THE MEASUREMENT SAYS TO.**
|
||||
That instruction is recorded here rather than quietly dropped. What ships is a
|
||||
different rule; the paragraph above is why.
|
||||
|
||||
## 3a. ⚠️ The plant lag is NOT identified by this recording, and the billed quarter is the claim that depends on it
|
||||
|
||||
`fit_plant()` picks `lag 7.5 s` because it minimises **battery-power RMS**. That
|
||||
objective barely distinguishes the candidates — 375.3 W at 7.5 s against 376.4 W
|
||||
at 2.5 s, **0.3 %** — while the tool's *own* printed fidelity measure, replayed
|
||||
export against the **29.43 kWh this install actually exported**, prefers 2.5 s by
|
||||
the whole of the 35 % gap the header warns about. Two measures on the same data
|
||||
disagree, and nothing here breaks the tie.
|
||||
|
||||
So the sweep has to be read as a sensitivity, not as a confirmation:
|
||||
|
||||
| plant lag | fit RMS W | replayed export kWh (release/1.0) | **worst 15-min offtake W:** release/1.0 | form A | SAFETY-48 | mean quarter ΔW |
|
||||
|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 0.0 | 392.3 | 23.5 | **3434** | 3434 | **3434** | +0.53 |
|
||||
| 2.5 | **376.4** | **28.9** ← nearest 29.43 | **3434** | ⚠️ 3732 | **3434** | −0.59 |
|
||||
| 5.0 | 378.0 | 32.1 | **3434** | ⚠️ 3711 | **3434** | −1.02 |
|
||||
| 7.5 | **375.3** ← chosen | 39.6 | 3739 | ⚠️ 3935 | **3434** | −2.39 |
|
||||
| 10.0 | 384.2 | 75.4 | 3954 | ⚠️ 4326 | **3434** | −4.65 |
|
||||
|
||||
(The RMS column is `fit_plant()`'s own inner sweep pinned to one lag — best `tau`
|
||||
and a re-fitted gain/offset at that lag. The export and quarter columns follow
|
||||
`--sensitivity`'s convention instead: only the lag is substituted into the
|
||||
global fit, everything else held. That is why the lag 5.0 and 10.0 export
|
||||
figures are the ones already printed in §7b.)
|
||||
|
||||
⚠️ **AT EVERY LAG ≤ 5.0 s THE IMPROVEMENT IS NOT AN IMPROVEMENT — IT IS AN
|
||||
ABSENCE.** release/1.0's worst quarter is *already* 3434 W there and SAFETY-48
|
||||
changes it by **exactly zero**. §7b's paragraph used to read the `3434 → 3434`
|
||||
rows as "the direction does not move"; that is the wrong reading. The direction
|
||||
has nothing to move: at those plants release/1.0 never produces a quarter worse
|
||||
than 3434 W in the first place, and the 3739 → 3434 W headline exists only at
|
||||
the single longest lag `fit_plant()` happens to select. **The peak claim is a
|
||||
property of the plant model, not of the card.** What the card can claim on the
|
||||
billed quantity is that it does not make it worse, at any lag.
|
||||
|
||||
⚠️ **AND THE ONE RESULT HERE THAT IS NOT LAG-SENSITIVE IS THE FORM-A
|
||||
REJECTION.** Form A's worst quarter is the worst of the three at **every lag
|
||||
where release/1.0 produces a peak at all** — 3732, 3711, 3935, 4326 against
|
||||
release/1.0's 3434, 3434, 3739, 3954 at lags 2.5 / 5.0 / 7.5 / 10.0, and a tie
|
||||
at lag 0.0 where both sit at 3434. It is worse at every top-N cut of the
|
||||
quarters at the fitted plant (top-1 3934.6 against 3738.7, top-5 3299.7 against
|
||||
3260.5, top-10 2890.6 against 2871.0, top-50 1882.7 against 1880.0, top-100
|
||||
1481.7 against 1479.0), and it raises the mean quarter. §3 understated this: the rejection does not rest on one unlucky
|
||||
quarter at one plant, which is exactly the objection a lag-sensitive result
|
||||
invites.
|
||||
|
||||
⚠️ **WHAT WOULD SETTLE IT IS NOT IN THIS RECORDING.** Both objectives above are
|
||||
computed from the same four recorded entities, and they disagree; nothing else
|
||||
in the capture breaks the tie, and this card did not go looking for a third
|
||||
objective after seeing which lag each of the first two preferred. A driven step
|
||||
response — the command timestamped where it is written rather than where Home
|
||||
Assistant recorded it — is what identifies a lag. That is a rig measurement and
|
||||
it is not this card. Until it exists, the export reduction is what this card
|
||||
delivers and the peak figure is a plant-model artefact.
|
||||
|
||||
## 4. What ships
|
||||
|
||||
```python
|
||||
slew = slew_limit_w(tuning)
|
||||
slewed = max(prev_w - slew, min(prev_w + slew, target))
|
||||
if slewed != target:
|
||||
reason = "slew-limited"
|
||||
_lead_cap = SLEW_LEAD_STEPS * slew
|
||||
if target > slewed:
|
||||
i_w = min(i_w, slewed + _lead_cap)
|
||||
elif target < slewed:
|
||||
i_w = max(i_w, slewed - _lead_cap)
|
||||
target = slewed
|
||||
```
|
||||
|
||||
Four properties, and each of them is a decision:
|
||||
|
||||
* **It is inside the branch**, and ⚠️ **that placement was a SURVIVOR of all
|
||||
thirty files until a check was written for it.** Applying it to every cycle
|
||||
instead — `i_w = max(target - cap, min(target + cap, i_w))` after
|
||||
`target = slewed`, −92 B — is **bit-identical on the shipped configuration**:
|
||||
0 of 256 246 ticks differ over the 16-day recording. The two separate **only**
|
||||
where `integrator_max_w` is set ABOVE `max_w` — the configuration `control.py`
|
||||
says is the only way its `clamped` reason is reachable. There the integrator
|
||||
is *deliberately* allowed above the rail, and an unconditional cap would drag
|
||||
it back to the command every cycle and make the option inert.
|
||||
`test_telemetry.py` part 4 drives that configuration, and the unconditional
|
||||
form is red on it.
|
||||
|
||||
⚠️ **THE COUNT OF SEPARABLE STATES IS DECORATION AND THREE SWEEPS DISAGREE
|
||||
ABOUT IT.** This card's grid gave **2050 of 257 400**; an independent review
|
||||
measured **1220 of 246 064**; a third, smaller grid (four tunings × 7 `prev_w`
|
||||
× 9 `grid_w` × 5 `actual_w` × 8 `i_w` including `None` × 4 `sat_count`) gives
|
||||
**776 of 40 320** — and that third one is spelled out here precisely because
|
||||
the first two are not, which is the whole reason they cannot be reconciled.
|
||||
No count here should be read as exact without the grid beside it. All three
|
||||
agree on the load-bearing part, which is **where**: every separable state sits
|
||||
in the `integrator_max_w > max_w` tuning and nowhere else.
|
||||
|
||||
⚠️ **AND AN EARLIER DRAFT OF THIS BULLET GAVE THE WRONG REASON AND THE WRONG
|
||||
NUMBER.** It said the two forms differ on 118 ticks because an unconditional
|
||||
cap would pull the integrator toward a command the SATURATION freeze is
|
||||
pinning. That 118 was measured against a *different* pair — a band applied
|
||||
after quantisation, at the very end of `compute()` — and the saturation story
|
||||
does not apply to the placement actually being compared, which sits before
|
||||
the freeze. The figures above are the ones that were measured on the pair the
|
||||
sentence names.
|
||||
|
||||
⚠️ **AND THE SATURATION STORY IS NOT MERELY INAPPLICABLE — IT IS BACKWARDS.
|
||||
THE NARROW CAP THAT SHIPPED *DOES* MOVE A SATURATION-FROZEN INTEGRATOR.** It
|
||||
sits before the freeze block and is not gated on `frozen`. Measured on the
|
||||
same 16 days at the live shape: of **79 160** frozen ticks the cap changed
|
||||
`i_w` on **1095**, and on **679** of those it moved it further out on the side
|
||||
it was already on — putting back magnitude the freeze had just let fall, which
|
||||
is the one direction the freeze's rule ("may fall, never rise") exists to
|
||||
forbid. Worst case **2424 W**, at
|
||||
`prev_w −3400 W, grid +4697 W, actual −4144 W, sat_count 2 → 3`: the freeze
|
||||
leaves the integrator at **−176 W**, the slew binds, and the cap drags it to
|
||||
**−2600 W**. It never overrides a *same-cycle* refusal — the freeze clipped
|
||||
`moved` on 75 512 ticks and the slew bound on none of them — but the claim
|
||||
that this placement leaves the frozen path alone is false, and §5 is where
|
||||
that shows up in a committed number.
|
||||
* **It is written one-sided, and that is a CLARITY choice, not a correctness
|
||||
one.** ⚠️ **An earlier draft of this file and of the comment in `control.py`
|
||||
said the two-sided band `max(slewed - cap, min(slewed + cap, i_w))` was a bug
|
||||
that would drag a TRAILING integrator up toward the command. That was
|
||||
invented, and the sweep said so: the two spellings agree on 205 920 of
|
||||
205 920 cases** across four tunings, every sign of `prev_w`, `grid_w`,
|
||||
`actual_w` and `i_w`, and the `i_w=None` path. `target` is monotone in `i_w`,
|
||||
so when the slew binds `i_w` lies on the same side of `slewed` as `target`
|
||||
does and the other clamp cannot reach. The one-sided form stays because it
|
||||
NAMES the side the lead is on; the equivalence is recorded so the next reader
|
||||
finds a measurement instead of an argument.
|
||||
* **It moves `i_w` and not `target`.** This cycle's command is already decided;
|
||||
what is bounded is what gets CARRIED. That is also why the exhaustive
|
||||
`i_w=None` equivalence sweep in `test_control.py` is untouched — it compares
|
||||
`(target_w, sat_count, frozen, reason)`, none of which this moves.
|
||||
* **The cap is in STEPS, not watts**, for SAFETY-39's reason: a watt figure is a
|
||||
constant that outlives the `slew_w` it was derived against. `SLEW_LEAD_STEPS`
|
||||
moves with `slew_limit_w()` and therefore with the rail.
|
||||
|
||||
⚠️ **ONE STEP, AND NOT ZERO.** `i_w = slewed` is full back-calculation, and
|
||||
`control.py`'s own docstring says what that costs: passing `i_w=None` *"reduces
|
||||
this function to the exact velocity form it replaced"*. A zero cap makes `i_w`
|
||||
identically the command on every cycle, which is that collapse written from the
|
||||
other end. Measured, it is **better** — export 32.02 kWh against 34.07, mean
|
||||
quarter −3.69 W against −2.39 — and it is still refused, because it throws away
|
||||
the reason the accumulator was split out at all. That 2 kWh is the price of
|
||||
keeping the bounded form, and it is stated rather than hidden.
|
||||
|
||||
⚠️ **TWO LEADS, AND CONFUSING THEM IS HOW A CHECK GETS WRITTEN WRONG.** The cap
|
||||
bounds `i_w − target`, the command this cycle EMITS, at one step — which is what
|
||||
makes the unwind one cycle. `Decision.i_w − prev_w`, which is what TEL-10's
|
||||
`loop_lead` publishes, is measured against the command the PREVIOUS cycle left
|
||||
standing, and the emitted command is itself one step further on: the published
|
||||
figure is bounded by **two** steps. Both bounds are asserted, each against its
|
||||
own number.
|
||||
|
||||
### The two recorded deadlocks, driven rather than argued
|
||||
|
||||
`control.py` names two encodings that produce a permanently stuck loop, and a
|
||||
gate written on the integrator is exactly the edit that brings them back.
|
||||
Neither is reachable here and both are test cases in `test_telemetry.py` part 4:
|
||||
|
||||
* **the shrink-only encoding** — *"only corrections that shrink |i_w|"* is
|
||||
unsatisfiable for both signs whenever the correction is larger than twice the
|
||||
integrator, and the loop then sits at its last value forever (measured before
|
||||
the fix: 0 W held indefinitely into a 2 kW import). SAFETY-48 is not a shrink
|
||||
rule; it caps the lead beyond one slew step and only while the slew binds.
|
||||
* **`i_w == 0.0`** — `main.py` resets the integrator to exactly 0.0 on every
|
||||
stop and every reseed, and *"may not wind further in the direction it is
|
||||
already pushing"* has no referent there. SAFETY-48 does not touch that gate;
|
||||
the first push from zero is driven and asserted to move.
|
||||
|
||||
And a third, specific to this card: **the cap cannot latch.** The flag is not
|
||||
carried, and after the cap `|i_w − slewed| = cap`, so the next cycle's command
|
||||
covers it in one step and the slew stops binding — after which the integrator
|
||||
accumulates freely again. The driven check asserts the command still walks all
|
||||
the way to the rail on a railed export.
|
||||
|
||||
## 5. What this card COSTS, stated rather than smoothed
|
||||
|
||||
**SAFETY-13's release buys back less.** That card's own scenario — a second
|
||||
writer holding the pack at +700 W, the command pinned, then released — settles
|
||||
at **−800 W** where release/1.0 settled at −1190 W against an integrator of
|
||||
−1194 W; and its sibling run settles at **−1200 W** where release/1.0 settled at
|
||||
−2390 W. The property AC 4 asserts is unchanged: the released command stops at
|
||||
the frozen integrator and not at the rail.
|
||||
|
||||
⚠️ **AND THE TWO MOVED FOR TWO DIFFERENT REASONS, WHICH THE ONE-LINE EXPLANATION
|
||||
"how far that integrator had been allowed to wind" COVERS ONLY HALF OF.** Gating
|
||||
the cap on `not frozen` — one line, `if frozen: _lead_cap = float("inf")`, so
|
||||
the cap becomes a no-op while frozen — puts **part 5 back at release/1.0's
|
||||
−1190 W exactly**. That run moved because the cap **fires on frozen cycles**
|
||||
(§4). **Part 4(b) does not come back**: it is −1200 W gated and −1200 W shipped,
|
||||
against release/1.0's −2390 W, because *that* one moved for the winding reason.
|
||||
Both literals in `test_control.py` were re-baselined **with the reason beside
|
||||
them**, and the second is now asserted against `i_w` rather than against a
|
||||
number.
|
||||
|
||||
**The alternative that avoids it was measured and rejected.** Gating the cap on
|
||||
`not frozen` as above gives export **34.71 kWh** (against 34.07), worst quarter
|
||||
**3434 W** (the same as shipped), wasted **3.49 kWh** (against 3.19) — and a
|
||||
worst observed lead of **4961 W**, i.e. no bound at all on the quantity this
|
||||
card exists to bound. A bound with an exception large enough to drive a truck
|
||||
through is not a bound. ⚠️ These four figures replace an earlier set (34.9,
|
||||
3461 W, 3.5 kWh, 4966 W) that does not reproduce on this tree with the gate
|
||||
spelled as above; the gate spelling is written out here so the next reader can
|
||||
re-run it rather than re-derive it.
|
||||
|
||||
**One quarter-hour in 1534 is 532 W worse.** The billed figure is a MAXIMUM, and
|
||||
at the fitted plant lag the maximum improves (3739 → 3434 W); no quarter under
|
||||
SAFETY-48 reaches release/1.0's own worst. The mean moves the right way. The
|
||||
tail is reported because a maximum over 1534 quarters is decided by one of them
|
||||
— and §3a is the reason that improvement is not claimed as a benefit of the
|
||||
card: at every shorter plant lag release/1.0's own maximum is already 3434 W.
|
||||
|
||||
## 6. The independent confirmation, which arrived as a failing check
|
||||
|
||||
`test_control.py`'s SAFETY-39 section drives a family of 51 synthetic export
|
||||
steps through a **first-order plant this file writes itself** — no relation to
|
||||
the replay, no recorded data in it at all. It carried this assertion:
|
||||
|
||||
> ⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION … Quartering the slew from 1000
|
||||
> to 250 does not reduce the over-absorption at all
|
||||
|
||||
and it was right about the mechanism and therefore wrong about the conclusion:
|
||||
`slew_w` bounds the OUTPUT while the integrator accumulates `gain · error`, so a
|
||||
slower slew left the integrator winding for longer. Worst case over the family:
|
||||
|
||||
| `slew_w` | release/1.0 | this branch |
|
||||
|---|---:|---:|
|
||||
| 1000 | 1005 W | **729 W** |
|
||||
| 0 (the rail, 556) | 1109 W | **550 W** |
|
||||
| 400 (the live interim) | 1285 W | **424 W** |
|
||||
| 250 | 1330 W | **326 W** |
|
||||
|
||||
**The ranking inverts.** Before: slower is worse, monotonically. After: slower is
|
||||
better, monotonically. Two plant models that share nothing agree on the sign,
|
||||
which is worth more than either alone — and it means SAFETY-39's *"gain is the
|
||||
next constant to re-derive"* is now only half true: with the lead bounded, the
|
||||
slew owns a share of the over-absorption it did not own before. `gain` is still
|
||||
not touched here, for SAFETY-39's own reason: two live control-law constants
|
||||
moving in one release makes the next measurement uninterpretable.
|
||||
|
||||
⚠️ **AND THE RIG SAYS THE SAME THING, WHICH MEANS TWO COMMITTED TRANSCRIPTS
|
||||
CHANGED MEANING UNDER THIS CARD.** `tools/slew_sim.py --slew` runs the same
|
||||
derivation on admin/goodwe's `plantsim.Plant`, a third model again. Worst-case
|
||||
over-absorption over its 51-step family, `slew_w` from 1000 down to 250:
|
||||
|
||||
| `slew_w` | 1000 | 800 | 700 | 600 | 556 | 500 | 400 | 300 | 250 |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| release/1.0 | 958 | 1107 | 1133 | 1058 | 987 | 1029 | 1150 | 1143 | 1081 |
|
||||
| **this branch** | 777 | 734 | 663 | 563 | 553 | 507 | 435 | 358 | 321 |
|
||||
|
||||
release/1.0's row is flat and non-monotone and **800 really is better than 556**
|
||||
on that column — SAFETY-39 measured it correctly and its §3 sentence is true of
|
||||
the law it was written against. This branch's row falls monotonically. So
|
||||
SAFETY-39's record is not retracted; it is given a **forward reference**, and
|
||||
`slew_sim.py`'s own narration now reads `SLEW_LEAD_STEPS` out of the control law
|
||||
it imported and says which of the two tables it is printing. MUTATION.md habit 8
|
||||
— a committed transcript is a check, and a transcript whose prose contradicts
|
||||
its own table is a failing one.
|
||||
|
||||
## 7. Verified by breaking it
|
||||
|
||||
**The law.** `test_telemetry.py` part 4 and `test_control.py`'s SAFETY-39
|
||||
section. The mutation table is in `test_telemetry.py`'s header.
|
||||
|
||||
**The instrument** (MUTATION.md habit 8 — anything under `tools/` that a
|
||||
document quotes is subject to the battery):
|
||||
|
||||
| # | mutation to `app/control.py` | what the instrument did |
|
||||
|---|---|---|
|
||||
| I2 | the whole SAFETY-48 block deleted | **REFUSED**, exit 1, `NOTHING HAS BEEN MEASURED` — not a zero delta |
|
||||
| I3 | the anchor line itself edited | **REFUSED**, same message |
|
||||
| I3b | `SLEW_LEAD_STEPS = 1000` — the cap present but inert | **every row +0.00**, which is the honest report that the block is doing nothing |
|
||||
| I4 | `SLEW_LEAD_STEPS = 0` — full back-calculation | every row moves further; this is the rejected form's number in §4 |
|
||||
|
||||
I3b is the one that matters: it proves the delta in §3 is caused by the block
|
||||
and not by the two runs differing for some other reason.
|
||||
|
||||
**The law**, driven in the CI image on a purged tree, fingerprinted before,
|
||||
after and after the revert:
|
||||
|
||||
| # | mutation to `app/control.py` | bytes | reds |
|
||||
|---|---|---:|---|
|
||||
| L1 | the whole SAFETY-48 block deleted | −195 B | 5 in `test_telemetry.py`, 4 in `test_control.py` |
|
||||
| L2 | `SLEW_LEAD_STEPS = 0` — the velocity form | +0 B | 4 + 3 |
|
||||
| L3 | `SLEW_LEAD_STEPS = 12` — a cap above the rail | +1 B | 4 + 3 |
|
||||
| L4 | the cap applied unconditionally, outside the slew branch | −92 B | 1 + 0 — **and it was a SURVIVOR of all thirty files before that check existed** |
|
||||
| L5a | the `min`/`max` FUNCTION names exchanged | +0 B | 5 + 5, one of them `test_control.py`'s own "frozen integrator never deadlocks" over 13 041 states |
|
||||
| L5b | the two BRANCH BODIES exchanged — the other reading of "the cap on the wrong side" | +0 B | 5 + 4, and it does **not** reach the deadlock sweep |
|
||||
| L6 | the lead measured against `prev_w` rather than the command emitted | +0 B | 6 + 3 |
|
||||
|
||||
⚠️ **L5 AND L6 ARE BOTH ZERO BYTES** — `min`/`max` and `slewed`/`prev_w` are
|
||||
each the same length — so no fingerprint and no byte delta can see either, and
|
||||
one of L5a's reds is `test_control.py`'s own *"frozen integrator never
|
||||
deadlocks"* sweep over 13 041 states, written by SAFETY-13 for a different
|
||||
reason.
|
||||
|
||||
⚠️ **THREE OF THESE COUNTS WERE WRONG AND ARE RE-MEASURED ABOVE, WHICH IS
|
||||
MUTATION.md's REPORTING RULE 3 APPLIED TO THIS CARD'S OWN TABLE.** The earlier
|
||||
row gave L5 as 2 + 5 and L6 as 4 + 3; driven again in the CI image on a `cp -a`
|
||||
of this tree, purged, `:ro`, one file at a time, they are **5 + 5** and
|
||||
**6 + 3**. ⚠️ **AND "L5" NAMED TWO DIFFERENT MUTATIONS.** "`min`/`max` swapped"
|
||||
can mean the function names exchanged or the branch bodies exchanged; they are
|
||||
both red, they are both the cap on the wrong side, and they give different
|
||||
counts (5 + 5 against 5 + 4) and reach different checks. A row that writes one
|
||||
number for a mutation a reader cannot re-derive from its own description is a
|
||||
row that cannot be re-run, so both spellings are listed. The directions in the
|
||||
original table were all correct; the counts were decoration, and decoration in
|
||||
a mutation table reads as evidence.
|
||||
|
||||
## 7b. The transcript, quoted rather than summarised
|
||||
|
||||
`python3 tools/replay_recorded_loop.py --sensitivity`, on this branch, 58 s:
|
||||
|
||||
```
|
||||
THE RECORDING: 256252 meter instants over 16.00 days, loop-2026-09-02_2026-09-17
|
||||
tick p5 4.77 s median 5.01 s p95 7.86 s (control.LOOP_TICK_S says 2.0)
|
||||
the recorded command: extremes -5000 / +5000 W -> max_w 5000; most common step above 100 W is 400 W, 9924 times -> slew_w 400
|
||||
the fitted inverter: lag 7.5 s, tau 2.0 s, batt = 0.9731 x cmd -22.4 W, residual 375 W RMS above the SoC floor
|
||||
|
||||
FIDELITY - the BEFORE law replayed against the recording it came from
|
||||
setpoint r=0.887 mean +601 W simulated vs +739 W recorded
|
||||
grid r=0.682 mean +195 W simulated vs +239 W recorded
|
||||
export 39.61 kWh simulated vs 29.43 kWh recorded -> the model is +35 % PESSIMISTIC about the loop's tracking
|
||||
|
||||
THE A/B, same ticks, same disturbance, same plant, same seed
|
||||
release/1.0 SAFETY-48 delta
|
||||
export kWh 39.61 34.07 -5.54 the cost
|
||||
import kWh 116.07 111.20 -4.86
|
||||
battery sign reversals 2992.00 2770.00 -222.00
|
||||
wasted throughput kWh 4.67 3.19 -1.48
|
||||
worst 15-min offtake W 3738.67 3433.94 -304.74 ⚠️ THE BILLED ONE
|
||||
recovery events 549.00 612.00 +63.00
|
||||
recovery median s 232.06 199.04 -33.01
|
||||
recovery p99 s 849.00 849.94 +0.94
|
||||
recovery max s 901.06 898.40 -2.66
|
||||
unrecovered (>900 s) 53.00 31.00 -22.00
|
||||
|i_w - prev_w| p99 W 3361.12 800.00 -2561.12
|
||||
|i_w - prev_w| max W 9200.00 800.00 -8400.00
|
||||
slew-limited ticks 16392.00 11654.00 -4738.00
|
||||
mean quarter delta W -2.39 over 1534 quarters
|
||||
worst quarter delta W +532.27
|
||||
|
||||
SENSITIVITY - the ranking, not the watts
|
||||
variant export kWh worst quarter W mean quarter dW
|
||||
plant lag 5.0 s 32.1 -> 27.2 3434 -> 3434 -1.02
|
||||
plant lag 10.0 s 75.4 -> 68.3 3954 -> 3434 -4.65
|
||||
plant tau 8 s 56.5 -> 49.8 3995 -> 3605 -3.30
|
||||
gain 1.0135, offset -26 46.8 -> 39.3 3713 -> 3434 -3.27
|
||||
slew_w 555.6 (the rail) 36.5 -> 34.4 3434 -> 3434 -0.17
|
||||
gain 0.3 28.1 -> 27.0 3434 -> 3434 +0.16
|
||||
|
||||
(58 s)
|
||||
```
|
||||
|
||||
⚠️ **`--sensitivity` IS THE RANKING AND NOT THE WATTS.** The export figure moves
|
||||
by a factor of two across these plants; what does not move is the direction —
|
||||
**on export**. ⚠️ **THE `worst quarter W` COLUMN IS NOT THE SAME KIND OF
|
||||
EVIDENCE AND THIS PARAGRAPH USED TO TREAT IT AS IF IT WERE.** Three of its six
|
||||
rows read `3434 -> 3434`, and that is not "the direction holds": it is
|
||||
release/1.0 *already sitting at 3434 W* on those plants, with nothing for the
|
||||
card to improve. §3a sweeps the lag properly and finds the same thing at every
|
||||
lag ≤ 5.0 s. The billed-quarter improvement in the A/B table above exists at one
|
||||
plant, the one `fit_plant()` selects by an objective that separates it from its
|
||||
neighbour by 0.3 %.
|
||||
|
||||
Two rows deserve naming rather than rounding: at `slew_w: 555.6` (the rail) and
|
||||
at `gain: 0.3` the mean quarter-hour delta is −0.17 W and **+0.16 W** — ties,
|
||||
not improvements. Both are regimes where `|error| > slew_w / gain` is rarely
|
||||
satisfied (1333 W at `gain: 0.3`, 926 W at the rail against a measured median
|
||||
onset error of 1114 W), so there is little for the card to bite on. That is the
|
||||
mechanism agreeing with itself, and it is also the honest answer to "would this
|
||||
still be worth shipping if the tuning moved": at `gain: 0.3` it would not be.
|
||||
|
||||
## 8. What this card does NOT establish
|
||||
|
||||
* **Nothing here drives an inverter.** The replay is a model with a 375 W
|
||||
residual and no arbiter in it. The closed-loop answer comes from the rig or
|
||||
from a live run — SAFETY-47 records why a shadow run cannot give it.
|
||||
* **The live tick is 5.0 s, not `LOOP_TICK_S`'s 2.0 s**, measured off 9924
|
||||
recorded 400 W steps and off the meter's own cadence. It is wrong in the SAFE
|
||||
direction (a slower meter makes `validate.py` rule 10 conservative) and is NOT
|
||||
changed here — re-deriving `SLEW_RAIL_FRAC` is a refusal boundary on a live
|
||||
install and belongs to its own card. It is recorded in the tool's header and
|
||||
printed on every run.
|
||||
* **The brief's plant figure was close but not exact.** It quoted
|
||||
`delivered = 1.0135 × command − 26 W, r = 0.992` at a 4–6 s publish lag. On
|
||||
this 16-day window, above the SoC floor, the same relation measures
|
||||
`1.00 × command − 28 W, r = 0.977` at a best-fit pure delay of **7.5 s**. The
|
||||
conclusion — the inverter is a pure follower and the plant is not the problem
|
||||
— survives; the coefficients are the ones in §2.
|
||||
* **The brief's cost figure is larger than this measurement.** It quoted ~14 kWh
|
||||
of avoidable export and 65 % of site export inside 7.9 % of the time. Measured
|
||||
here: **5.5 kWh** avoidable of 39.6 kWh modelled (29.4 kWh recorded), with
|
||||
42 % of export inside the 6.3 % of ticks that are slew-limited and 69 % within
|
||||
±60 s of one, over 15.3 % of the time. The concentration claim holds; the
|
||||
avoidable-kWh figure does not, and the difference is not reconcilable from
|
||||
here because the reconstruction it came from was discarded.
|
||||
@@ -0,0 +1,181 @@
|
||||
# SAFETY-56 — what the gate cannot tell apart, said in the sentence
|
||||
|
||||
**Text only. No behaviour changes in this card**, and §5 is the measurement that
|
||||
says so: the same stimuli through the tree before and after produce a
|
||||
**byte-identical** trace of every verdict, every `reading()`, every `inputs_ok`
|
||||
and every readiness row's `(ok, warn)`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect
|
||||
|
||||
0.10.1 is regulating a real GW5000S-BP. Two messages tell the operator that
|
||||
healthy hardware has failed.
|
||||
|
||||
**The log line**, as emitted on the reference site on 2026-09-21:
|
||||
|
||||
> SAFETY-37: inverter-a battery-power register **is not following its own
|
||||
> command** - STALE_READING: -3 unchanged for 36 s while the standing command
|
||||
> moved 800 W to +1600 W - **the reading is not following**, so it is reported
|
||||
> as MISSING rather than as a measurement … Treating it as MISSING, which
|
||||
> commands 0 W.
|
||||
|
||||
**The readiness row**, at the same moment:
|
||||
|
||||
> NOT READY — battery power: `sensor.kelder_goodwe_master_stage1_goodwe_inverter_ac_power`
|
||||
> **is missing or not numeric**
|
||||
|
||||
That entity was present, numeric and updating.
|
||||
|
||||
**SAFETY-37's conjunction is correct and is not touched by this card** —
|
||||
`unchanged > 35 s AND |command| > 250 W AND |travel| > 250 W`. A register
|
||||
genuinely stuck against a real command is exactly what it should catch, and a
|
||||
pack that cannot comply is a state the site must stop in too. What was wrong is
|
||||
what the sentences **concluded** from it.
|
||||
|
||||
## 2. Two worlds satisfy the conjunction
|
||||
|
||||
| | the register | the pack |
|
||||
|---|---|---|
|
||||
| **DEAD** | has stopped reporting reality | is doing what it was told |
|
||||
| **REFUSED** | is reporting reality correctly | cannot comply — the BMS is refusing at its floor |
|
||||
|
||||
A refused pack holds still under any setpoint, so an honest register publishes
|
||||
the same unchanged number a dead one does. **Nothing `freshness.py` is handed
|
||||
separates them.** All three sentences asserted DEAD.
|
||||
|
||||
## 3. The live episode was REFUSED, and it is measured, not argued
|
||||
|
||||
Off the committed capture `scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz`,
|
||||
whose bytes and decoder are already graded by `test_freshness.py` part 10a:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the register | answered **-3 W for 850 s**, 10:51:24 → 11:05:34 |
|
||||
| the commands during that hold | **+260, +660, +1060 W** — discharge only, never a charge |
|
||||
| pack SoC throughout | **11 %** |
|
||||
| first **charge** command after the restart | 11:05:31 at **-400 W** |
|
||||
| the register moves | **11:05:34** — **3 s**, inside one 17.0 s obligation window |
|
||||
|
||||
**A register that answers a reversed command inside one obligation window had
|
||||
not stopped reporting.** What would not move was the pack. The sentence the
|
||||
operator read named the one thing in the loop that was telling the truth.
|
||||
|
||||
⚠️ What this does **not** establish: that no register is ever intermittently
|
||||
wrong. It excludes a register that had simply stopped; it cannot exclude one
|
||||
that was wrong and then right. That is the point — the new sentence claims
|
||||
neither.
|
||||
|
||||
## 4. The fix, and why it is three sentences and not one helper
|
||||
|
||||
Each message states **what was observed** and names **both** explanations.
|
||||
|
||||
| where | before | after |
|
||||
|---|---|---|
|
||||
| `app/freshness.py` `_grade()` | *"…+1600 W - the reading is not following, so it is reported as MISSING…"* | *"…+1600 W. **TWO THINGS LOOK LIKE THIS AND THIS GATE CANNOT TELL THEM APART**: the register may have stopped reporting reality, or the source may be unable to comply with the command - a pack the BMS is refusing holds still under any setpoint, and an honest register then reads exactly like a frozen one. Either way the number cannot be read as this command's effect, so it is reported as MISSING…"* |
|
||||
| `app/main.py` `_read_unit()` | *"SAFETY-37: %s battery-power **register is not following its own command** - %s."* | *"SAFETY-37: %s battery-power reading **did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER** - %s."* |
|
||||
| `app/main.py` `checks()` | *"{label}: {entity} **is missing or not numeric**"* | *"{label}: **no usable value this cycle from** {entity} - **and this row cannot tell which of two things that is**: the entity is missing or not numeric, or it is publishing a number that this controller is withholding (a freshness gate does exactly that, and names itself in a row of its own). Confirm the entity in Developer Tools before replacing it"* |
|
||||
|
||||
**Three sentences, three wordings, no shared helper.** The first two are a log
|
||||
line about a **behaviour**; the third is a banner about a named **entity**, and
|
||||
the two answer different questions ("what did the register do" against "can I
|
||||
trust this entity"). A helper serving both would have to drop whichever half
|
||||
the other caller does not need, and the half it dropped would be the
|
||||
operator's next step. The log line names the pair **once**, inside the `%s` it
|
||||
already interpolates, rather than in both of its own halves.
|
||||
|
||||
**No new input, and SoC is not wired.** `BOUNDS["soc"]` stays *STATED, NOT
|
||||
WIRED*; telling the two worlds apart needs the pack floor (SAFETY-32's number)
|
||||
and SoC (TEL-08's wiring), and a message honest about its own ambiguity needs
|
||||
neither. Part 11b drives the two worlds at **50 %** and **11 %** SoC and
|
||||
asserts the sentence does not move one byte.
|
||||
|
||||
**The readiness row does not branch.** `inv.batt_fresh.stale` is on the same
|
||||
object at the same moment, and the row deliberately does not ask it: the same
|
||||
loop renders the grid and SoC rows, which have no gate, and a branch per row
|
||||
would be a second place where "why is this None" is decided. The gate already
|
||||
has a surface — `stale_note()` rides `liveness_notes()` a few rows below with
|
||||
the whole STALE_READING sentence in it — so the operator's discriminator is
|
||||
*"is that row there?"*, and part 11d asserts that row is **present in REFUSED
|
||||
and absent in ABSENT**.
|
||||
|
||||
## 5. Behaviour is byte-identical, measured
|
||||
|
||||
`tools/safety56_behaviour_trace.py` prints two documents from one stimulus set.
|
||||
The `behaviour` half contains **no sentence**: only `stale`, `reading()`,
|
||||
`releases`, `held`, the window bookkeeping, `inputs_ok`, and every readiness
|
||||
row's `(ok, warn)`. Run in the CI image, purged, `:ro`, against
|
||||
`origin/release/1.0` **archived out of git** and against this branch:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the committed live capture | **2390 reads**, 7 verdict edges |
|
||||
| a swept grid of command × register schedules | **66 cells × 120 reads** — 1060 stale reads, 1309 `MISSING` readings, 6 releases |
|
||||
| six driven controller worlds through the shipped read path | DEAD, REFUSED, ABSENT, HEALTHY, RELEASE, RETRIP — every read's `(batt, inputs_ok, stale)` and every `checks()` row's `(ok, warn)` |
|
||||
| **result** | `6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996c` **on both trees** |
|
||||
|
||||
⚠️ **AND THE CONTROL IS INSIDE THE TOOL, BECAUSE AN IDENTITY BETWEEN TWO TREES
|
||||
THAT ARE THE SAME TREE IS HABIT 4.** `--compare` refuses to report "behaviour
|
||||
unchanged" unless the `sentences` halves **differ**. This card's first
|
||||
invariance run was exactly that mistake — it archived a branch whose edits were
|
||||
not committed yet, so `before` and `after` were the same code and the identity
|
||||
was perfect and worthless.
|
||||
|
||||
Both of the tool's refusals are **driven**, not asserted:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the same file compared with itself | `REFUSED: the sentences halves are IDENTICAL…`, exit **2** |
|
||||
| `MOVE_W` 250.0 → 240.0 in the after tree | `BEHAVIOUR MOVED at 'sweep_stale_reads' / 'sweep_missing_reads' / 'sweep_trace'`, exit **1** |
|
||||
| this card | `OK: sentences differ, behaviour is byte-identical.`, exit **0** |
|
||||
|
||||
## 6. The mutation battery
|
||||
|
||||
Each in its own `mktemp`'d copy (habit 18), purged on the host, mounted `:ro`,
|
||||
graded by `tools/mutation_run.sh` in the CI image.
|
||||
|
||||
| | mutation | Δ bytes | named reds |
|
||||
|---|---|---|---|
|
||||
| M1 | `_grade()` reverted to *"the reading is not following"* | -402 | **3** |
|
||||
| M2 | the log line reverted to *"register is not following its own command"* | -80 | **2** |
|
||||
| M3 | the row reverted to *"{entity} is missing or not numeric"* | -619 | **2** |
|
||||
| M4 | *register* and *source* transposed between the two explanations | **0** | **1** |
|
||||
| M5 | the second explanation deleted, the first kept | -216 | **1** |
|
||||
| M6 | *"CANNOT TELL THEM APART"* replaced by *"IN ORDER OF LIKELIHOOD"* | -13 | **1** |
|
||||
| M7 | the REFUSED world's ground truth set to the DEAD world's | 0 | **2** |
|
||||
| M8 | the row's second cause deleted | -309 | **1** |
|
||||
| M9 | `label` and `entity` transposed in the row | **0** | **3** |
|
||||
| M10 | M1 + M2 + M3 together, i.e. exactly what 0.10.1 ships | -1101 | **5** |
|
||||
| M11 | the accusation literals replaced by one that matches nothing | 0 | **1** |
|
||||
|
||||
⚠️ **M9's first run was a defect in this card's own check, and it is the one
|
||||
worth reading.** The row was fetched with `[...][0]`. Transposing `label` and
|
||||
`entity` makes the row unfindable, so the run died on an `IndexError` — a red
|
||||
with **no name attached**, with every check after that line **ungraded**. That
|
||||
is FCST-06's shape exactly. `_s56_row()` now returns a named absence instead of
|
||||
raising, and a precondition grades it: **0 named reds before, 3 after.**
|
||||
|
||||
⚠️ **M11 is the other one aimed inward.** `not any(...)` over an empty list is
|
||||
True, and so is one over three mistyped literals. The positive control runs the
|
||||
same predicate against the three sentences **as 0.10.1 shipped them** and
|
||||
requires it to find an accusation in every one, and every literal to match at
|
||||
least one of them (habit 7: vary one thing from an accepted baseline).
|
||||
|
||||
## 7. What part 11 does not grade
|
||||
|
||||
* It does not show the live episode was a BMS refusal beyond doubt — see §3.
|
||||
* It does not grade English. A sentence that named both explanations and then
|
||||
said something useless about them would pass.
|
||||
* The `no entity configured` branch beside the rewritten row is untouched and
|
||||
ungraded: a blank entity really is the only thing it can be.
|
||||
* The **grid-power and battery-SoC rows** go through the same rewritten line and
|
||||
have no freshness gate today, so for them the second cause is a hypothesis
|
||||
about this controller rather than a live possibility. That is weaker than the
|
||||
battery-power case and is the price of not branching per row. A gate on either
|
||||
signal — TEL-08 would be one — makes it exact.
|
||||
* **SAFETY-58 is not touched** (the first read after a long rest trips before a
|
||||
healthy register could answer), and neither is the 710/790 disagreement
|
||||
between `test_freshness.py` and the changelog. Both are separate cards; the
|
||||
second has a **fourth site** at
|
||||
`DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md:274` that a fix written
|
||||
off the PR table alone would miss.
|
||||
@@ -0,0 +1,388 @@
|
||||
# SAFETY-57 — the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-57`, off `origin/master` @ `6f5e8e3`
|
||||
(content-identical to `release/1.0` @ `8a57361`).
|
||||
**Card:** SAFETY-57 — *"the freshness gate has no exit reachable from inside the state it creates"*.
|
||||
**Nothing here reached the inverter.** No contact was made with 192.168.2.158 or
|
||||
192.168.2.221. The one live system touched is `sniffer@192.168.2.167`, a
|
||||
**passive** RS-485 listener with no address on the bus, and it was read only.
|
||||
|
||||
---
|
||||
|
||||
## 0. The five things a reader should take away
|
||||
|
||||
1. **The live episode is reproduced from the wire, not modelled.** The capture
|
||||
is committed, the decoder is committed, and the box that produced it was
|
||||
running the defect — so the `0 W` in the trace *is* the add-on's own
|
||||
`inputs missing → command 0 W`. §2.
|
||||
2. **Option 2 from the card — "do not arm the gate when the command is 0 W" —
|
||||
already holds, and it is not the bug.** `_grade()` has required
|
||||
`|standing| >= MOVE_W` since SAFETY-37. The trip happened at **+1060 W** and
|
||||
the stuck state is at **0 W**; they are different moments. §3.
|
||||
3. **The decision is not "how long", it is "when".** The verdict is withdrawn
|
||||
when the **obligation that produced it is no longer standing** — SAFETY-13's
|
||||
shape one module over. The 17.0 s is the obligation window that was already
|
||||
derived in the file, taken at the **floor** of its band, and which half is
|
||||
safe is the **opposite** of SAFETY-13's. §4.
|
||||
4. **It costs 52 % exposure on a permanently dead register, and that number is
|
||||
in the suite so it cannot be forgotten.** It is payable only because one
|
||||
re-grading window (35 s) is shorter than both consumer holds (75 s, 85 s).
|
||||
**No detection SAFETY-37 had is given back.** §5.
|
||||
5. **The battery had been rescuing this code by accident.** 93 trips in the
|
||||
committed 16-day capture; median wait for the BMS keep-alive pulse that
|
||||
cleared them **143 s**, worst **5 h 57 m**. §6.
|
||||
|
||||
---
|
||||
|
||||
## 1. What was there, in the terms the code uses
|
||||
|
||||
`freshness.py`, `Freshness.submit()` and `_grade()`:
|
||||
|
||||
```python
|
||||
if self.changed_mono is None or value != self.held:
|
||||
...
|
||||
self.stale = False # the only place the latch is cleared
|
||||
def _grade(self, standing_w, now_mono):
|
||||
if self.stale or self.changed_mono is None:
|
||||
return # sets the latch; never clears it
|
||||
```
|
||||
|
||||
and `main.py:1777`, `inv.batt = inv.batt_fresh.reading()`, where `reading()`
|
||||
answers `None` while `stale`. `inputs_ok` sees the `None`, the site holds for
|
||||
`stale_input_s`, then files `Claim.set("safety", P_SAFETY, 0.0, "inputs
|
||||
missing")` and keeps filing it.
|
||||
|
||||
The reason the latch exists is written in the class docstring and it is a good
|
||||
one: without it the verdict oscillates at about 1 Hz, because declaring the
|
||||
reading stale drops the command below `MOVE_W`, which un-arms the conjunction,
|
||||
which hands the held number straight back.
|
||||
|
||||
**The defect is that the only thing that ends it is an arrival carrying a
|
||||
different number — and the state's own output is what stops the register being
|
||||
asked for one.** The log line promised the opposite, in the same sentence that
|
||||
announced the 0 W:
|
||||
|
||||
> *Treating it as MISSING, which commands 0 W. It clears itself the moment the
|
||||
> register reports a different value.*
|
||||
|
||||
---
|
||||
|
||||
## 2. Reproducing it, from the wire
|
||||
|
||||
`scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz` is 3840
|
||||
verbatim frames off the RS-485 pair, 10:30–11:10 Europe/Brussels, decoded by
|
||||
`tools/decode_bus_capture.py`. The decoder reports **0 unparsed, 0 bad CRC,
|
||||
0 unknown shapes**, and its mode→sign convention is **graded off the capture**
|
||||
rather than trusted: 298 commands at or under −1000 W are each answered one
|
||||
obligation window later by a negative register reading, and not one positive.
|
||||
|
||||
| capture time | on the wire |
|
||||
|---|---|
|
||||
| 10:51:14 | `0x0518` = **−447 W**; the loop is charging |
|
||||
| 10:51:19 | the command reverses: EMS **mode 3**, 260 W of discharge |
|
||||
| 10:51:24.7 | `0x0518` = **−3 W**. The pack is at **11 %**, the BMS refuses the discharge, the inverter idles. **The register is healthy and truthful.** |
|
||||
| 10:51:29.7 | the command reaches **+1060 W**. 800 W of travel |
|
||||
| 10:52:21.7 | the setpoint is written **0 W** — this is the add-on's own §11.1 rule 1, recorded on the wire |
|
||||
| …850.0 s | `0x0518` does not change value |
|
||||
| …790.0 s | the setpoint does not change value |
|
||||
| ~11:04 | **a person restarts the add-on** — not visible in this file, and the only thing that ends the episode |
|
||||
| 11:05:31.7 | the restarted loop commands **−400 W of charge**, which the pack accepts at 11 % |
|
||||
| 11:05:34.7 | `0x0518` = **−137 W** |
|
||||
|
||||
⚠️ **THE CAPTURE IS CLOSED-LOOP FOR THE WHOLE PARKED INTERVAL**, which is why
|
||||
this is a reproduction and not a simulation: the box was running 0.10.0, so
|
||||
every command in the trace is the shipped code's own. The only event inside the
|
||||
window the file cannot account for is the human restart.
|
||||
|
||||
Replaying the shipped `_read_unit` loop at 1 Hz against those two series
|
||||
(`test_freshness.py` part 10b):
|
||||
|
||||
| | stale at | clear at | parked |
|
||||
|---|---|---|---|
|
||||
| **before** (`release_s = inf`) | 10:52:00 | 11:05:35 | **815 s**, and `reading()` is `None` on all **812** reads from the trip to the first command a human put on the wire, with the register at −3.0 W in every one of them |
|
||||
| **after** | 10:52:00 | **10:52:39** | **39 s**, with `0x0518` still answering the same **−3 W** |
|
||||
|
||||
⚠️ **THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS GRADED RATHER THAN
|
||||
CLAIMED.** With an infinite bound `_release()` never fires, `_open_window()` is
|
||||
only ever called where the register moves, and the window pair is the
|
||||
change-stamp pair — byte-for-byte the pre-SAFETY-57 conjunction. Part 10b
|
||||
asserts that by driving part 1's own fresh/stale pair through both bounds and
|
||||
comparing the verdict streams.
|
||||
|
||||
The replay also found a **one-read trip at 10:48:14** that clears one second
|
||||
later. That is a pre-existing false positive and it is now recorded as
|
||||
survivor 5 — §7.
|
||||
|
||||
---
|
||||
|
||||
## 3. What the card's option 2 actually buys, which is nothing here
|
||||
|
||||
The card offered *"do not arm the gate when the command being graded is 0 W"*
|
||||
and warned that it might hold already. **It does, and it has since SAFETY-37**:
|
||||
`_grade()` returns at `abs(standing_w) < self.bound.move_w`, and
|
||||
`test_freshness.py` part 4 has a named check on that term, added because a
|
||||
mutation removing it survived.
|
||||
|
||||
The warning in the card is the right one and it is worth writing down: **the
|
||||
trip and the stuck state are different moments.** The gate armed at **+1060 W**,
|
||||
where the conjunction is entirely correct — a command travelled 800 W and the
|
||||
register did not move for 35 s. The site then went to **0 W** *because of the
|
||||
verdict*. An entry-side rule cannot reach a state that is entered legitimately
|
||||
and then persists. **The defect is the exit, and nothing else in the conjunction
|
||||
is touched by this card.**
|
||||
|
||||
Option 3 — probing with a small non-zero command — was not taken and does not
|
||||
need to be. It writes to a real actuator to test an assumption, and §4's rule
|
||||
does not need a probe: the site's *normal* control resuming is the test, and it
|
||||
is gated behind the same `inputs_ok` everything else is.
|
||||
|
||||
---
|
||||
|
||||
## 4. The decision: which verdicts get an exit, and what the bound is
|
||||
|
||||
> The stale verdict holds as before, **unless** the obligation that produced it
|
||||
> is no longer standing — `|standing| < MOVE_W` — **and** it has been gone
|
||||
> longer than the register would have needed to answer it. On a release the
|
||||
> latch clears and a **new grading window** opens at that instant.
|
||||
|
||||
This is [[SAFETY-13]]'s §4 in a different module, and deliberately so: that card
|
||||
established the house answer to "a latch with no exit" and its answer was *not*
|
||||
a plain time bound but a **predicate plus a time bound**, where the predicate
|
||||
says whether the state can end on its own. Here the predicate is that **the
|
||||
question is no longer being asked.** A register under a 0 W command is obliged
|
||||
to be nowhere, so a verdict about whether it is following cannot be tested, and
|
||||
a verdict that cannot be tested may not stand for ever.
|
||||
|
||||
### 4.1 Why the window has to re-open, and what happens if it does not
|
||||
|
||||
`_grade()` compares the register's unchanged time against `unchanged_s` and the
|
||||
command's travel against the command standing when the window opened. If a
|
||||
release cleared the latch **without** re-opening the window, the first read at
|
||||
which the resumed command travels 250 W would re-trip immediately — the
|
||||
register would never get the 17 s in which it could answer, and the live
|
||||
episode's recovery (a charge the pack **accepts**) would be killed inside two
|
||||
seconds. Mutating exactly that (`graded_from` re-anchored to `changed_mono`,
|
||||
+82 B) takes four named checks red.
|
||||
|
||||
The new window is `BATT_UNCHANGED_S` = **35 s**, the same bound as entry,
|
||||
because it is the same question — *does this register follow the commands issued
|
||||
from now on?* — and there is no principled shorter answer to it. It is also the
|
||||
number the exposure argument in §5 rests on, so it is not free to move.
|
||||
|
||||
### 4.2 `RELEASE_S` = `BATT_OBLIGED_S` = 17.0 s, and which half is safe
|
||||
|
||||
The quantity needed is *has the register had its chance to answer the command
|
||||
standing now?*, and `freshness.py` already derives it as the floor of
|
||||
`BATT_UNCHANGED_S`'s band:
|
||||
|
||||
```
|
||||
6.0 s plant goodwe/NOTES.md §5.9, measured 2026-08-22
|
||||
10.0 s publisher firmware/goodwe-master.yaml:181, update_interval: 10s
|
||||
1.0 s this add-on run_control()'s await asyncio.sleep(1)
|
||||
```
|
||||
|
||||
⚠️ **IT IS THE FLOOR OF ITS BAND, NOT A SENSITIVITY PREFERENCE** — the mirror of
|
||||
`MOVE_W` taking the **ceiling** of its own for the mirrored reason.
|
||||
|
||||
| | the question | the safe half | why |
|
||||
|---|---|---|---|
|
||||
| SAFETY-13's `SETTLE_CYCLES` | may the plant still be the **explanation**? | the **longer** half | a shorter bound stops believing the plant too early, and then it accuses |
|
||||
| this one | may the verdict still be **tested**? | the **shorter** half | being wrong *long* is the failure being fixed; being wrong *short* costs one re-grading window, and §5 bounds that |
|
||||
|
||||
Below 17.0 s the release pre-empts the **cheaper exit** — the register answering
|
||||
the new 0 W command by itself, which clears the latch on evidence rather than on
|
||||
a timer. That is graded behaviourally rather than asserted: a register that
|
||||
answers at +16 s clears by **movement** at `release_s = 17.0` with `releases`
|
||||
still 0, and by **timer** at `release_s = 5.0`.
|
||||
|
||||
The family is computed and printed (habit 10): `5, 16, 17, 18, 35, inf` give
|
||||
six distinct release instants, so no check in the file can confuse two of them.
|
||||
|
||||
### 4.3 Two clocks, and the operator reads the register's
|
||||
|
||||
`changed_mono` / `unchanged_s()` remain facts about the **register**: after a
|
||||
release they go on reporting that it has not moved for 850 s, because it has
|
||||
not. `graded_from` / `graded_s()` are the **verdict's** window. They coincide
|
||||
until the first release. Collapsing them either lies to the operator or defeats
|
||||
the re-grading, and both directions are mutated red.
|
||||
|
||||
The stale sentence's `unchanged for N s` slot carries the **register's** number;
|
||||
a re-trip appends a clause naming the window and the grade count, so the two
|
||||
durations cannot be read as one number that moved (habit 14).
|
||||
|
||||
---
|
||||
|
||||
## 5. What it costs, measured
|
||||
|
||||
Driven against a register frozen for good, 1201 reads, shipped defaults
|
||||
(`test_freshness.py` part 10c):
|
||||
|
||||
| | parked | the held number reaches a consumer | releases |
|
||||
|---|---|---|---|
|
||||
| before | 1166 s | **35 reads (2.9 %)** | 0 |
|
||||
| after | 571 s | **630 reads (52.5 %)** | 17 |
|
||||
|
||||
⚠️ **52 % IS PAID EVERY CYCLE, NOT OCCASIONALLY.** The period is
|
||||
`stale_input_s` (15 s) + `RELEASE_S` (17 s) parked, then one 35 s window.
|
||||
|
||||
It is payable because of one inequality, and it is the inequality
|
||||
`test_freshness.py` part 6 has graded against the shipped `config.yaml` since
|
||||
SAFETY-37:
|
||||
|
||||
```
|
||||
35 s (the re-grading window) < 75 s (circulation_hold_s) < 85 s (unit_sign_disagree_s)
|
||||
```
|
||||
|
||||
Every unbroken run of exposure is **exactly one window**, so neither consumer
|
||||
can reach a verdict inside one, and the re-trip that ends each run hands them a
|
||||
`None`, which **clears** their windows rather than accumulating into them.
|
||||
|
||||
**And nothing SAFETY-37 detects is given back**, which is the half that makes
|
||||
this a trade rather than a regression: a permanent `None` clears those same two
|
||||
windows for ever as well, so on the shipped-before module those detectors are
|
||||
*equally* unable to speak about the unit. What the held number does reach for
|
||||
35 s is `site_batt_w`, the anti-windup input of `control.compute()` — which
|
||||
answers a large unexplained divergence by **freezing the output**, the
|
||||
conservative direction and SAFETY-13's business — and the command itself is
|
||||
derived from the **P1 meter**, never from `batt`.
|
||||
|
||||
### 5.1 The narrowing this does NOT take, named so the next card can
|
||||
|
||||
The live episode's register refused a **discharge** into an 11 % pack and then
|
||||
followed a **charge** three seconds later. A refusal in one direction is not
|
||||
evidence about the other, and a gate that knew the pack's floor could re-grade
|
||||
on the first command that crosses it instead of on a timer — which would shrink
|
||||
the 52 % above. That needs SoC, which this module deliberately does not take
|
||||
(see the `soc` row and TEL-08), and the pack floor, which is SAFETY-32's number.
|
||||
It is **not needed to give the state an exit**, and folding it in here would
|
||||
make the exit depend on a second measurement being right.
|
||||
|
||||
---
|
||||
|
||||
## 6. The measurement that says the old exit was luck
|
||||
|
||||
`scenarios/loop-2026-09-02_2026-09-17/` replayed at 1 Hz through the shipped
|
||||
conjunction (`test_freshness.py` part 8b): **93 trips in 16 days.** The only
|
||||
thing that could clear any of them was the register carrying a different number,
|
||||
and that took:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| minimum | 0.6 s |
|
||||
| **median** | **143.4 s** |
|
||||
| maximum | **21440.1 s** (5 h 57 m) |
|
||||
| at or over the live episode's 710 s | **17 of 93** |
|
||||
|
||||
The short waits are the BMS keep-alive pulse — SAFETY-50 measured its ceiling at
|
||||
508 W — twitching `0x0518` and satisfying the clearing rule **by accident**.
|
||||
Over a fifth of the trips are cleared in under 20 s by something nothing in this
|
||||
add-on asked for.
|
||||
|
||||
⚠️ **WHAT THAT REPLAY IS AND IS NOT.** The capture runs 2026-09-02…17 and
|
||||
0.10.0 — the first version carrying this gate — was cut on 2026-09-16, so the
|
||||
setpoint column is an **ungated** controller's. That makes each trip an honest
|
||||
register-versus-command divergence off the real bus, and it makes the post-trip
|
||||
command column something a gated box would **not** have written. The figure
|
||||
taken from it is therefore the only one that does not depend on the command at
|
||||
all — how long this register goes without changing value — and it **under**-states
|
||||
the wait, because a site parked at 0 W gives the register even less reason to
|
||||
move than the one recorded here.
|
||||
|
||||
---
|
||||
|
||||
## 7. Survivors, and the two things the battery found
|
||||
|
||||
The battery is 22 mutations over `app/freshness.py`, `app/main.py` and
|
||||
`tools/decode_bus_capture.py`, plus one over the committed capture itself. All
|
||||
run through `ci_run.sh`, a mirror of `checks.yml`'s own step: a purged copy of
|
||||
the tree, bind-mounted `:ro`, one file at a time, `PYTHONDONTWRITEBYTECODE=1`.
|
||||
Per MUTATION.md habit 18 the harness's scratch file is `mktemp`ed per
|
||||
invocation and the tree is fingerprinted **after the revert** as well as before;
|
||||
every revert restored the recorded sha256 and byte count.
|
||||
|
||||
**21 red, 1 argued equivalent, 0 unexplained survivors.** The table is in the
|
||||
PR body. Two of them are worth keeping here.
|
||||
|
||||
### 7.1 The equivalent mutant, argued rather than rounded off
|
||||
|
||||
Swapping `_release()` and `_grade()` in `submit()` is a **zero-byte** edit and
|
||||
survived the whole battery. **It is not a missing fixture and no fixture can
|
||||
close it**: `_grade()` acts only when `|standing| >= move_w` and `_release()`
|
||||
only when `|standing| < move_w`, so at most one of them can do anything on any
|
||||
read, whichever runs first.
|
||||
|
||||
⚠️ **THE FIRST VERSION OF THE CODE CLAIMED THE ORDER WAS LOAD-BEARING**, in a
|
||||
`⚠️` comment, with a reason that sounded right. It was false, and the battery is
|
||||
what said so — **MUTATION.md habit 22 exactly, one card later: an explanation in
|
||||
a comment is a claim, and this one had not been run.** The comment now states
|
||||
the disjointness, and part 10 pins **that** — at 0, 100, 249, 250, 251 and
|
||||
1300 W — instead of pinning an order that is not observable.
|
||||
|
||||
### 7.2 The sibling that survived, which is habit 11 earning its place
|
||||
|
||||
`_release()`'s obligation test is `abs(standing_w) >= self.bound.move_w`.
|
||||
Swapping `move_w` for `unchanged_s` — comparing **watts against 35** — survived
|
||||
the file at **+5 B**, because **every fixture rested at exactly 0 W**, and 0 is
|
||||
below both 35 and 250. They separate only in the band between them, and a site
|
||||
can sit there: a peak-guard floor, a shadow substitution, any claim that wins
|
||||
with a small non-zero number. Closed by a three-point fixture at 0 / 100 / 300 W
|
||||
that asserts the 100 W case releases and the 300 W case does not.
|
||||
|
||||
### 7.3 ⚠️ And the instrument lied once, which is habit 16's second half
|
||||
|
||||
The first form of the `graded_from` mutation was
|
||||
`float(self.changed_mono or now_mono)`. It reported a **survivor at +21 B**, and
|
||||
the tree fingerprint showed the edit really was on disk and really did run.
|
||||
`changed_mono` is **0.0** in the dead-register fixture, and `0.0 or x` is `x` —
|
||||
so the mutant was a *no-op on the fixture*, for a Python truthiness reason that
|
||||
has nothing to do with the property under test. Rewritten as an explicit
|
||||
`is None` test (+82 B) it is red on four named checks.
|
||||
|
||||
**A mutation is subject to every habit in MUTATION.md, including habit 4.** A
|
||||
purge, a fingerprint and a byte delta all say the edit is present; none of them
|
||||
says the edit *changed the answer on this stimulus*. MUTATION.md gains habit 19
|
||||
for it.
|
||||
|
||||
---
|
||||
|
||||
## 8. What this does NOT fix, said plainly
|
||||
|
||||
**It does not tell a BMS refusal apart from a dead register**, and it does not
|
||||
try to. Both are re-graded on the same timer. §5.1 names what telling them apart
|
||||
would need.
|
||||
|
||||
**It does not make the diagnosis right.** The line the operator reads still says
|
||||
*"battery-power register is not following its own command"*, which is wrong in a
|
||||
different way when the pack is simply empty. That wording is **SAFETY-56's**,
|
||||
together with `main.py`'s NOT READY row, and it is deliberately untouched here —
|
||||
two cards editing one sentence is how one of them silently loses. Only the
|
||||
**escape clause** was rewritten, because this card is what made the old one
|
||||
false, and part 10h has a named check asserting the diagnosis half is still
|
||||
there so that rewrite has to be a deliberate act.
|
||||
|
||||
> ⚠️ **SINCE SUPERSEDED, WHICH IS WHAT THAT CHECK WAS FOR.** [[SAFETY-56]] is
|
||||
> the deliberate act: all three sentences now name **both** explanations and
|
||||
> accuse neither, and part 10h's check is inverted — it requires the accusation
|
||||
> to be **gone**. Nothing else in this section moved; the escape clause above is
|
||||
> still this card's and is untouched by it. See
|
||||
> `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`, and
|
||||
> `test_freshness.py` part 11 for the graded pair of worlds.
|
||||
|
||||
**Survivor 5 is older than this card and is not closed by it.** After a long
|
||||
rest the unchanged clock is already past 35 s, so the **first** read carrying a
|
||||
travelled command trips the gate — before the 17.0 s in which a healthy register
|
||||
could have answered it. The live capture contains one (10:48:14, cleared one
|
||||
second later). Its size is one `stale_input_s` hold, during which `cycle()`
|
||||
returns at `inputs missing {n}s` and commands nothing differently. Fixing it
|
||||
means measuring the unchanged clock from when the **obligation** appeared rather
|
||||
than from the register's last move, which re-derives the 35.0 s bound and is a
|
||||
different card.
|
||||
|
||||
**Nothing was measured on two inverters.** Inverter B does not physically exist
|
||||
(`goodwe/NOTES.md` §1.1b). Every two-unit figure in `test_freshness.py` is two
|
||||
copies of A's behaviour with different numbers passed in.
|
||||
|
||||
**No deploy is proposed.** 0.10.0 is on the reference install and this branch's
|
||||
CHANGELOG row is filed under it only because `test_version_gate.py` part 11b
|
||||
requires the newest heading to be a version. Moving it, and deciding whether a
|
||||
version is cut, is a PKG card's.
|
||||
@@ -0,0 +1,230 @@
|
||||
# TARIFF-07 — three plugins that never fetched a price, and the one mechanism they leave behind
|
||||
|
||||
**Status: DECIDED 2026-09-13 — withdraw `engie_dynamic`,
|
||||
`totalenergies_dynamic` and `eneco_eddy`. Keep `PriceProduct.REALTIME` and the
|
||||
widening machinery. Refuse a retired id by name rather than aliasing it.**
|
||||
|
||||
Filed with TARIFF-07. Follows [[TARIFF-08]], which found the same class of
|
||||
defect one layer down — a parser graded against its own guess — and left the
|
||||
question of these three open.
|
||||
|
||||
---
|
||||
|
||||
## 1. The endpoints, verified rather than taken on report
|
||||
|
||||
The card said the three endpoints were dead. They were re-checked by hand on
|
||||
**2026-09-13** before anything was deleted, because "the endpoint is dead" is
|
||||
exactly the kind of claim this repository keeps finding to be second-hand:
|
||||
|
||||
| id | endpoint | observed |
|
||||
|---|---|---|
|
||||
| `engie_dynamic` | `https://api.engie.be/dynamic/be` | **HTTP 404**, body `{"message":"no Route matched with those values","request_id":"..."}` — a Kong gateway with no route configured |
|
||||
| `totalenergies_dynamic` | `https://api.totalenergies.be/prices/dynamic` | **TLS failure**. The handshake completes and the server presents `CN=pge-apigee-dev.direct-energie.com`; `api.totalenergies.be` is not in the SAN list, so the connection is closed before any request is sent |
|
||||
| `eneco_eddy` | `https://api.eneco.be/eddy/prices` | **HTTP 404**, `content-length: 1`, `server: Microsoft-IIS/10.0` — an empty body from a live host that does not serve this path |
|
||||
|
||||
All three failed. Had any one of them answered, this card would have stopped
|
||||
and asked, because an endpoint that answers changes the decision from
|
||||
"withdraw" to "fix the parser".
|
||||
|
||||
## 2. Why this is not "unfinished", and why no replacement can be written
|
||||
|
||||
An unfinished plugin has a right endpoint and a wrong parser. These have
|
||||
neither, and the reason is structural:
|
||||
|
||||
* **ENGIE's dynamic price is `(B × eSpot_15) − A`, and `B` and `A` are
|
||||
PER-CONTRACT.** They live behind the customer's klantenzone login. No
|
||||
unauthenticated public URL can return *a given customer's* price, so the
|
||||
endpoint this plugin wanted cannot exist for anybody to build.
|
||||
* **There is no regulator index to read the coefficients from.** CREG's
|
||||
tariff-card pages 404; VREG was unreachable. Every coefficient found in
|
||||
research came from a supplier's own PDF, which is a document, not a feed.
|
||||
* **"Eddy" is not an Eneco product at all.** Eneco's dynamic product is
|
||||
**"Zon & Wind Dynamisch"**, and it is indexed to **Belpex/EPEX day-ahead** —
|
||||
not to imbalance. The plugin was wrong about the product, the product type
|
||||
and the URL simultaneously.
|
||||
|
||||
So the three were not withdrawn for being broken. They were withdrawn for
|
||||
describing tariffs that no public endpoint can serve.
|
||||
|
||||
## 3. What a site configured on one of these has actually been running
|
||||
|
||||
⚠️ **This is the half that decides what the migration is.** §11.4 turns a
|
||||
failed fetch into a silent fall back: static profile, plan provisional, no
|
||||
arbitrage. That is the *correct* degradation, and it says nothing at all about
|
||||
the cause. Every fetch these three ever made raised `SourceUnavailable`.
|
||||
|
||||
Therefore a site configured on one of them has been running on
|
||||
`static_profile` — **or on nothing at all, where no profile was set** — since
|
||||
the day the id was entered, and **nothing on its status page has ever differed
|
||||
from a working provider that simply never found a cheap hour**.
|
||||
|
||||
The migration is therefore **not** "keep their prices working". There were no
|
||||
prices. It is *"tell them their prices never worked"*, which is why the answer
|
||||
is a refusal and not a fallback.
|
||||
|
||||
### Why not a silent alias to `generic_dayahead`
|
||||
|
||||
It is the friendlier-looking option and it is wrong. These published **retail**
|
||||
prices; `generic_dayahead` reads **wholesale**. Aliasing needs the site's own
|
||||
`B` and `A`, which §2 has just established nobody has. An alias would have to
|
||||
**invent the numbers a battery is priced against** — the same defect as a
|
||||
parser graded against its own guess, one layer further from anything that
|
||||
reports. The scalar `price_markup_eur_kwh` cannot stand in for `B` either: it
|
||||
is an addend, not a multiplier, and it is `float(0,1)` so it cannot even hold a
|
||||
negative `A`.
|
||||
|
||||
## 4. ⚠️ The `config.yaml` schema is deliberately WIDER than the registry
|
||||
|
||||
`price_provider` is a `list(off|…)` enum, and the Supervisor validates the
|
||||
options file against it **before the add-on starts**.
|
||||
|
||||
So the obvious edit — delete the three ids from the enum along with the classes
|
||||
— has a consequence that runs exactly opposite to this card's purpose:
|
||||
|
||||
```
|
||||
operator's config says price_provider: eneco_eddy
|
||||
|
|
||||
+-- id NOT in the schema enum
|
||||
|
|
||||
+-- Supervisor rejects the options file
|
||||
|
|
||||
+-- add-on never starts
|
||||
|
|
||||
+-- validate.py never runs
|
||||
|
|
||||
+-- the named refusal is NEVER SEEN
|
||||
```
|
||||
|
||||
The operator gets an opaque `expected one of ...` from a layer they did not
|
||||
configure, on **exactly the sites the withdrawal exists to inform**.
|
||||
|
||||
**Decision: keep all three ids in the schema enum.** `PROVIDERS` shrinks to
|
||||
two; the enum stays at six. The cost is that the dropdown still offers three
|
||||
dead ids to a *new* operator — and that is the cheaper failure, because
|
||||
choosing one is refused at the next startup **by name**, with the endpoint, the
|
||||
observation and the alternative, which is strictly more than a schema
|
||||
rejection would say. Both translations label them withdrawn in the option
|
||||
description.
|
||||
|
||||
`app/price.py`'s `RETIRED_PROVIDERS` is the list. `test_price.py` section 1
|
||||
asserts the schema offers exactly `PROVIDERS | RETIRED_PROVIDERS | {"off"}` —
|
||||
derived, not typed — and `register()` refuses to re-register a retired id, so
|
||||
the registry and the retired table cannot both claim one and the refusal cannot
|
||||
be taken away by an `@register` somebody adds back.
|
||||
|
||||
## 5. ⚠️ `PriceProduct.REALTIME` and the widening path are KEPT with no shipped declarer
|
||||
|
||||
`eneco_eddy` was the **sole** declarer of `PriceProduct.REALTIME`. Deleting it
|
||||
orphans:
|
||||
|
||||
* `_WIDE = frozenset({PriceProduct.REALTIME})`
|
||||
* `Uncertainty.REALTIME_PRODUCT`
|
||||
* `WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT] = 2.5`
|
||||
|
||||
**They stay.** The reasoning, stated here so the next reader finds intent
|
||||
rather than an apparent dead branch:
|
||||
|
||||
1. A reviewer finding a mechanism with **zero callers** would reasonably delete
|
||||
it. That is good instinct applied to a missing fact, and this file is the
|
||||
fact.
|
||||
2. It is the **AC2 seam**. The whole point of `capabilities().product` is that
|
||||
a new supplier is a new class and nothing else: declare `REALTIME`, get the
|
||||
wide regime, edit no framework code. Deleting the enum member would mean the
|
||||
first genuine imbalance product has to **rebuild** that seam — and rebuilding
|
||||
it is precisely the "editing the optimiser to add a supplier" that AC2
|
||||
forbids.
|
||||
3. It costs nothing. Two enum members and a dict entry.
|
||||
|
||||
**⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME` UNTIL ONE DOES.** Both
|
||||
shipped plugins (`generic_dayahead`, `manual_csv`) are day-ahead. §9.2's row 2
|
||||
is unoccupied. That is not the same as it having no mechanism, and the
|
||||
difference is what this section exists to record.
|
||||
|
||||
It is **pinned, not merely documented**. `test_price.py` section 5 asserts:
|
||||
|
||||
* that **no shipped plugin** declares `REALTIME` — so if one ever does, the
|
||||
check goes red and the right fix is to **edit this record**, not to delete
|
||||
the seam;
|
||||
* that the widening still works **from a declaration alone** —
|
||||
`volatility_regime`, `uncertainty_of` and `widen_factor` are graded on a
|
||||
`ProviderCapabilities` the test file builds;
|
||||
* and section 10 registers `_NewSupplier`, a class defined **outside
|
||||
`price.py`** that declares `REALTIME` and is asserted to land in the wide
|
||||
regime. That class is now the only live declarer in the tree, which makes the
|
||||
seam something this suite exercises end to end rather than a constant it
|
||||
reads.
|
||||
|
||||
## 6. What else went, and one thing that did not
|
||||
|
||||
`_points_from_json()` went with the three. It read a `{"data": [{...}]}`
|
||||
row-per-point document that **no feed anybody here has ever reached
|
||||
publishes** — the shape was invented alongside these plugins, and the only
|
||||
bodies ever fed to it were fixtures written from the same guess. TARIFF-08
|
||||
found that loop on the response side for `generic_dayahead`; this is the rest
|
||||
of it. `needs_credentials` went too: the three were its only setters and
|
||||
nothing read it.
|
||||
|
||||
The shape survives in **one** place and in one direction: `row_shape_body()` in
|
||||
`test_price.py`, feeding `generic_dayahead` so that section 9 grades a row
|
||||
document as a **failure** for it rather than a second dialect it might sniff.
|
||||
It is named for what it is.
|
||||
|
||||
⚠️ **One check had to be rebuilt rather than deleted, and saying so matters.**
|
||||
`test_price.py` section 4b proved that `code_leaks()`'s docstring strip is
|
||||
load-bearing by finding a shipped docstring that a raw text scan would
|
||||
false-positive on. The docstring that did that was **`engie_dynamic`'s** — it
|
||||
spelled `price_markup_eur_kwh` in full. With it gone, no shipped docstring
|
||||
spells a full framework key, so the check would have **passed on a tree with
|
||||
the strip deleted**: a check that had quietly stopped being one, which is the
|
||||
third failure mode in `MUTATION.md`. The stimulus is now constructed in the
|
||||
test file (`_DocumentedPlugin`), and the shipped set is asserted clean
|
||||
separately.
|
||||
|
||||
## 7. Mutations driven
|
||||
|
||||
Every check added or rebuilt here was driven red. Harness:
|
||||
`/home/homelab/.cache/t07-mut/`, which rebuilds the tree from the worktree and
|
||||
purges `__pycache__` per `MUTATION.md`'s rule.
|
||||
|
||||
| | mutation | Δ | red on |
|
||||
|---|---|---|---|
|
||||
| M1 | delete the retired branch from `_price_problems()` | −2785 B | 7 checks in `test_price.py` |
|
||||
| M2 | move the retired branch **after** the unknown-id branch | **+0 B** | the same 7 |
|
||||
| M3 | make `been_running` constant (drop the empty-`static_profile` case) | −212 B | 2 |
|
||||
| M4 | drop the "WHAT TO DO" half of the refusal | −874 B | 1 |
|
||||
| M5 | `RETIRED_PROVIDERS = {}` | −1099 B | 5, then a traceback |
|
||||
| M6 | drop the three ids from `config.yaml`'s enum — §4's trap | −47 B | 2 |
|
||||
| M7 | delete `register()`'s retired guard | −1101 B | 2, then a traceback |
|
||||
| M8 | `_WIDE = frozenset()` | −23 B | 3 + 4 across two suites |
|
||||
| M9 | `WIDEN_FACTOR[REALTIME_PRODUCT]` 2.5 → 1.5 | **+0 B** | 1 + 1 |
|
||||
|
||||
⚠️ **M2 and M9 have a byte delta of zero**, which `MUTATION.md` names as the
|
||||
risky case. The harness rebuilds the tree from source on every run and purges
|
||||
`__pycache__`, so there is no inherited cache to read; and stale bytecode
|
||||
manufactures **survivors**, never reds, so a red from a `+0 B` mutation is not
|
||||
at risk from that defect. M2's grep confirmed the branch had moved on disk.
|
||||
|
||||
⚠️ **M5 and M7's red counts are LOWER BOUNDS.** Both runs ended in a traceback
|
||||
with checks after it ungraded — FCST-06's rule: read the output, not the count.
|
||||
M7's traceback is itself informative: with the guard gone the re-registration
|
||||
**succeeds**, `PROVIDERS` is polluted with a class that has no fixture, and the
|
||||
suite dies downstream. That is the failure the guard prevents, arriving loudly.
|
||||
|
||||
## 8. What this card did not do
|
||||
|
||||
* **It did not pick a version number.** The change is backward-incompatible — a
|
||||
config legal on the current version is refused on the next — and the
|
||||
CHANGELOG says so prominently, in the shape PKG-07 established for `slew_w`.
|
||||
Which number it belongs to depends on whether the current image has already
|
||||
been built and pulled, which is a fact about the live box that this branch
|
||||
cannot observe. Whoever deploys decides.
|
||||
* **It did not implement the formula provider.** `B × spot + A` with per-site
|
||||
coefficients is the real answer for a Belgian dynamic customer and is a
|
||||
separate card. `DOCS.md` references it as planned and is explicit about what
|
||||
`generic_dayahead` can and cannot express in the meantime — in particular
|
||||
that it cannot represent a negative offset, and that
|
||||
`price_markup_eur_kwh: float(0,1)` could not hold one anyway.
|
||||
* **It did not rewrite released CHANGELOG history.** The two historical entries
|
||||
that called `eneco_eddy` an imbalance product are annotated in place with a
|
||||
correction naming this card, rather than silently edited. The record of what
|
||||
the project believed is part of the record.
|
||||
@@ -0,0 +1,141 @@
|
||||
# TARIFF-09 — asking the feed for the horizon, and what `price_feed_url` means
|
||||
|
||||
Two decisions. The first had no defensible alternative once it was measured;
|
||||
the second did, and the alternative is argued below rather than dismissed.
|
||||
|
||||
---
|
||||
|
||||
## 0. The measurement this card exists for
|
||||
|
||||
`generic_dayahead.fetch()` accepted `since` and `until` and requested the bare
|
||||
`https://api.energy-charts.info/price?bzn=BE`. That URL names no range, and the
|
||||
feed answers **one Brussels day**.
|
||||
|
||||
Measured on the merged tree (`4257e38`) at 2026-09-10 09:00 CEST, 48 h horizon,
|
||||
live feed, yesterday seeded through the shipped read path:
|
||||
|
||||
```
|
||||
60 firm points (15 whole hours) + 33 carried hours of 48 requested
|
||||
carried: 24 h on 2026-09-11, 9 h on 2026-09-12
|
||||
arbitrage(0.10): allowed, spread 0.4645, buy 11:45 and sell 19:45 -- BOTH on 2026-09-10
|
||||
firm local days = [2026-09-10]
|
||||
```
|
||||
|
||||
Everything past today was carry-forward, flagged provisional, and §8.3 drops
|
||||
provisional hours from the trade set. **The optimiser never saw tomorrow as
|
||||
firm, at any hour.** The ceiling was structural rather than incidental: with a
|
||||
one-day window, at hour *H* of the local day a 48 h horizon is at most
|
||||
**24−H hours firm and 24+H carried** — 15/33 at 09:00, and **1/47 at 23:00**,
|
||||
where the remaining firm spread (0.0464 EUR/kWh) no longer even clears the
|
||||
default 0.10 round-trip threshold and arbitrage is refused outright.
|
||||
|
||||
That is not what FCST-04's fill was built for. FCST-04 covers the
|
||||
pre-publication window until the ~13:00–14:00 CET auction, after which a
|
||||
healthy feed hands over tomorrow in full.
|
||||
|
||||
**The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z:
|
||||
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns **192 quarter-hours across
|
||||
two local days**, committed unmodified as
|
||||
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`. The
|
||||
plugin simply never asked.
|
||||
|
||||
After the change, same code path, same window, a feed serving both days:
|
||||
**39 firm hours and 9 carried**, firm across **two** Brussels days, and the
|
||||
permitted cycle buys on one day and sells on the other. Graded in
|
||||
`test_price_history.py` section 1c against both feed states.
|
||||
|
||||
---
|
||||
|
||||
## 1. The range is derived on the **Brussels** calendar, from `since`/`until`
|
||||
|
||||
`since` and `until` are **instants**; the API takes **dates**. Those are not
|
||||
the same question, and the conversion is where the day gets lost.
|
||||
|
||||
`brussels_date_range()` returns two inclusive `date`s:
|
||||
|
||||
* the first is `brussels(since).date()`. **Not `since.date()`, and not the UTC
|
||||
date.** 22:30 UTC in summer is already tomorrow in Belgium, so a UTC-derived
|
||||
start asks for the day the controller has just left and drops the day it is
|
||||
standing in — for the last one or two hours of *every* day. This is
|
||||
`price_history._bdate()`'s lesson one layer up, with a worse consequence:
|
||||
`_bdate()` misfiles an hour, this never fetches it.
|
||||
* the last is `brussels(until).date()`, **stepped back a day when `until`
|
||||
falls exactly on local midnight**, because `until` is exclusive and that
|
||||
instant is the first tick of a day nobody asked for. Without it every
|
||||
whole-hour horizon requests one extra day — harmless on this feed, but it
|
||||
makes the request a claim the caller never made.
|
||||
* an empty or inverted window collapses to a single day rather than producing
|
||||
a backwards range, which no feed has a defined answer for.
|
||||
|
||||
Both ends are graded, and both are driven red by mutation — including the two
|
||||
that swap `brussels()` for the UTC date (`test_price.py` section 2c).
|
||||
|
||||
---
|
||||
|
||||
## 2. `price_feed_url` is the **exact URL**, used as given. The plugin appends
|
||||
nothing.
|
||||
|
||||
The alternative — merge our `start`/`end` into whatever query string the
|
||||
operator supplied — was considered and rejected. Three reasons, in the order
|
||||
they decided it:
|
||||
|
||||
1. **`start`/`end` are energy-charts' spelling, and this provider declares a
|
||||
second host.** `generic_dayahead.capabilities().egress_hosts` is
|
||||
`("api.energy-charts.info", "web-api.tp.entsoe.eu")`. That second host's
|
||||
day-ahead date parameters have not been verified by this card and are not
|
||||
guessed at here — but they are not `start`/`end`. So an override to the
|
||||
*other declared host* — a case the declaration itself anticipates — is
|
||||
exactly where merging appends two parameters that host ignores, producing
|
||||
the short horizon anyway, with a URL nobody typed as the receipt.
|
||||
2. **Merging means re-serialising a URL somebody else wrote.** Parsing and
|
||||
rebuilding reorders parameters, collapses duplicates and re-escapes values.
|
||||
"Override the feed" is what the option already says it means, and an
|
||||
override that is silently rewritten is not one.
|
||||
3. **Blind appending is not on the table at all.** `dated_feed_url()` refuses
|
||||
to add a query string to a base that already has one, so
|
||||
`?bzn=BE?bzn=BE&start=…` cannot be produced by this module even by a later
|
||||
edit that folds the parameters back into the constant.
|
||||
|
||||
⚠️ **The cost is real and it is therefore NOT a doc footnote.** An overriding
|
||||
operator gets exactly the short horizon this card exists to fix. So the plugin
|
||||
emits a `WARNING` on the shipped `goodwe.price` logger at **fetch time**, naming
|
||||
the consequence — one Brussels day, the rest carried and flagged, §8.3 dropping
|
||||
them from the trade set — and naming the `start..end` it *would* have asked
|
||||
for. §11.4 is what makes a price defect quiet; an operator who has taken the
|
||||
short horizon has to be able to find out from the log that they did.
|
||||
|
||||
The warning is graded, with a control that the default path stays silent, and
|
||||
a mutation that downgrades it to `debug()` takes that check red.
|
||||
|
||||
**Fetch time rather than startup**, because that is where the URL actually in
|
||||
use is known. The trade-off is one line per refresh (the tariff task, not the
|
||||
30 s control tick). If the status page ever grows a price-source row, this is
|
||||
the sentence it should carry.
|
||||
|
||||
---
|
||||
|
||||
## 3. The plugin contract: `fetch(self, opts, since, until, transport)`
|
||||
|
||||
The signature promises every plugin the range, and until this card **none of
|
||||
the four networked plugins used it**. The obvious follow-up is to enforce the
|
||||
promise. It is **not** enforced, and the reason is TARIFF-08's:
|
||||
|
||||
Three of the five shipped plugins point at endpoints nobody has ever verified
|
||||
(TARIFF-07 is the open decision on withdrawing them). A rule obliging them to
|
||||
pass a date range would be satisfied by an **invented parameter name** — which
|
||||
is the parser-graded-against-its-own-guess defect moved from the response to
|
||||
the request, and it would ship looking like a guarantee.
|
||||
|
||||
What exists instead is a **roster pin** in `test_price.py` section 2c: which
|
||||
plugins reference their `since`/`until` is asserted as a literal, derived from
|
||||
the registry and the AST. A sixth supplier, or a change to any of the three,
|
||||
has to edit that line and make the decision rather than inherit the silence.
|
||||
It is named as a source-shape pin, not as evidence that any feed parses;
|
||||
`generic_dayahead`'s behaviour is graded separately by the URL it really
|
||||
requests.
|
||||
|
||||
**Recommendation for whoever picks up TARIFF-07:** enforce it at the moment
|
||||
those three endpoints are either verified or withdrawn, not before. If they are
|
||||
withdrawn, `generic_dayahead` and `manual_csv` are the whole roster and the
|
||||
rule is one line — `manual_csv` reads a whole file and is windowed downstream,
|
||||
so it would be the single documented exemption.
|
||||
@@ -0,0 +1,277 @@
|
||||
# TARIFF-10 — fetching a price and trading on one are two decisions
|
||||
|
||||
**Status: DECIDED 2026-09-12 — Option A. If a price provider is configured,
|
||||
prices are fetched. Trading stays a separate switch (`arbitrage_enabled`), and
|
||||
this card moves nothing that moves energy.**
|
||||
|
||||
Filed with TARIFF-10. Follows [[TARIFF-09]] and [[FCST-04]]; §4 settles the
|
||||
limitation FCST-04 recorded and did not decide.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect, and why it was invisible
|
||||
|
||||
`app/main.py`'s `amain()` read:
|
||||
|
||||
```
|
||||
if arbitrage.enabled(opts):
|
||||
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||||
```
|
||||
|
||||
`run_tariff_refresh` is the **only** caller of `resolve_tariff()`, which is the
|
||||
**only** caller of `price_history.carry()`. `arbitrage_enabled` ships `false`.
|
||||
|
||||
So on a shipped install with a provider configured:
|
||||
|
||||
* no HTTPS request was ever made to the feed;
|
||||
* no plan was ever resolved, so `self.tariff_plan` stayed `None` forever;
|
||||
* `/data/price_history.json` was never created;
|
||||
* the *Price history* status row read `empty` for the life of the install;
|
||||
* TARIFF-09's short-horizon warning could not fire, because it is logged on a
|
||||
fetch and there was no fetch.
|
||||
|
||||
Measured, not inferred — a 40 h soak, two instances, one feed:
|
||||
|
||||
| | shipped default + provider | same, arbitrage armed |
|
||||
|---|---|---|
|
||||
| feed requests | **0** | fetching |
|
||||
| `/data/price_history.json` | `present: false`, **802/802 samples** | written |
|
||||
| resolved plan | none | 132 points |
|
||||
|
||||
⚠️ **AND FOUR SURFACES ALREADY DESCRIBED THE FIXED BEHAVIOUR.** `DOCS.md`
|
||||
§*Energy price providers*: *"With `arbitrage_enabled: false`, which is the
|
||||
shipped default, turning a provider on still fetches, validates and publishes
|
||||
prices and changes no charging decision."* `config.yaml` and both translations
|
||||
said the same, and additionally carried a claim that had gone stale in the other
|
||||
direction — *"nothing consumes the price series yet"*, untrue since OPT-04
|
||||
shipped the consumer. The code was the only surface that disagreed with all of
|
||||
them, and no check compared the two.
|
||||
|
||||
The dating is worth being exact about, because it says this was never a
|
||||
regression. `git log -S` puts the refresh task, its `arbitrage.enabled()` guard
|
||||
**and** the DOCS sentence in the *same commit* — OPT-04, `bdd7f18`;
|
||||
`config.yaml`'s version of the claim goes back further, to TARIFF-02,
|
||||
`852fc7c`. The prose and the code disagreed from the moment both existed.
|
||||
|
||||
## 2. Why a check on the `if` would not have caught it
|
||||
|
||||
The `if` was exactly what somebody meant to write, and its comment argued for
|
||||
it honestly:
|
||||
|
||||
> ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on reads the
|
||||
> resolved plan … so on a default install this task would be network traffic in
|
||||
> aid of an attribute nobody reads.
|
||||
|
||||
That was true when OPT-04 wrote it and stopped being true when FCST-04 gave the
|
||||
plan a *store* and a *status row*. A pin on the expression would have gone red
|
||||
on the fix and green on the defect. What was wrong is the **set of
|
||||
configurations from which a fetch is reachable**, so that is what
|
||||
`test_price.py` section 13 grades:
|
||||
|
||||
1. the guards on every `asyncio.create_task` in `amain()` are **evaluated**
|
||||
against real option dicts — whatever the guard is spelled as;
|
||||
2. the call graph under `app/` is walked to see whether the coroutines those
|
||||
guards start can arrive at `resolve_tariff()` — wherever it has moved to;
|
||||
3. and then the fetch is **driven**, with a counting transport, a real
|
||||
`Controller` and a real store path, because a reachability graph is an
|
||||
argument and a counted request is an observation.
|
||||
|
||||
⚠️ **Step 3 grades the gate only because it starts the task the way `amain()`
|
||||
would.** The driver asks `_fetchers(opts)` — the evaluated guard — before it
|
||||
calls the coroutine. Measured: with the shipped defect restored **and** that one
|
||||
line defeated (`if False and "run_tariff_refresh" not in names:`), the four
|
||||
driven rows go **green** while only the structural rows stay red. Without it
|
||||
they are decoration, which is MUTATION.md's habit 4 in a harness.
|
||||
|
||||
What the instrument **cannot** see is printed on every run: a coroutine started
|
||||
by anything but `create_task`; a guard that is not an enclosing `if` in
|
||||
`amain()`; anything outside `app/`; and — because the graph is keyed by **name**
|
||||
— two same-named functions as one node, which makes every *reachable* answer an
|
||||
**upper** bound and every *not reachable* answer the strong one.
|
||||
|
||||
## 3. The gate, and why it is an `or`
|
||||
|
||||
```
|
||||
def tariff_refresh_wanted(opts: dict) -> bool:
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
```
|
||||
|
||||
The first term is what this card adds. **The second term is the old gate, kept
|
||||
verbatim, and it is not there for symmetry.**
|
||||
|
||||
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
configuration `validate.py` explicitly **accepts** (rule 7 refuses only the
|
||||
no-prices-at-all case), and it **trades**. Measured on this branch:
|
||||
|
||||
```
|
||||
opts: price_provider off, arbitrage_enabled true,
|
||||
cycle_cost_threshold_eur 0.05,
|
||||
static_profile 0.20 night / 0.284 day
|
||||
plan : static_profile from static_profile, 48 h, 48 points
|
||||
WITH the plan -> ARBITRAGE_CHARGE -5000.0 W
|
||||
"cheapest firm hour at 0.2000 EUR/kWh, 0.0840 below the
|
||||
dearest and clear of the 0.0750 this cycle had to cover"
|
||||
WITHOUT the plan -> None "no tariff plan has been resolved yet"
|
||||
```
|
||||
|
||||
Gating the refresh on the provider **alone** — which is the literal reading of
|
||||
the ticket's second acceptance criterion — would leave that site's
|
||||
`tariff_plan` at `None` forever and answer *"no tariff plan has been resolved
|
||||
yet"* on every cycle. That is a strategy the UI says is running and that cannot
|
||||
fire: the exact state `validate.py`'s OPT-04 rules exist to refuse, arrived at
|
||||
from inside the code instead of from the configuration.
|
||||
|
||||
So the gate is a **strict widening**: every configuration that started the task
|
||||
before still starts it, and `test_price.py` grades that as its own row rather
|
||||
than leaving it to prose.
|
||||
|
||||
⚠️ **The AC's stated harm is still fully met, and it is worth being exact about
|
||||
this rather than quietly declaring victory.** The criterion reads *"with
|
||||
`price_provider: off`: no fetch task at all, whatever `arbitrage_enabled` says
|
||||
— an operator with no provider must not acquire a periodic HTTPS call."* The
|
||||
letter of it is not met in one configuration (provider off, trading armed),
|
||||
because that configuration keeps the task it already had. The harm it names is:
|
||||
|
||||
* with `price_provider` off, `resolve_tariff()` returns `static_plan()` — pure
|
||||
arithmetic, **no socket**. Driven and counted: **0 requests**.
|
||||
* the true shipped default — no provider, no trading — starts **no task at
|
||||
all**.
|
||||
|
||||
Nobody *acquires* a periodic HTTPS call from this card, and the only thing that
|
||||
decides whether a request leaves the machine is `price_provider`. **If the
|
||||
owner wants the literal reading instead, it is a one-token change** (drop the
|
||||
`or` term) **and it costs the site in the measurement above its trading.** That
|
||||
is the trade, stated rather than chosen silently.
|
||||
|
||||
## 4. `TariffPlan.cheap_now()` — available, and deliberately NOT consumed
|
||||
|
||||
**Decision: do not wire it in this card. `self.cheap` stays on
|
||||
`static_status()`.**
|
||||
|
||||
FCST-04 recorded that `cheap_now()` has no production caller on the provider
|
||||
plan: `main.read_inputs()` assigns `self.cheap, self.tariff_words =
|
||||
static_status(o, now)`, which builds its **own** static plan and calls
|
||||
`cheap_now()` on that. Under Option A a provider-backed plan now exists on every
|
||||
configured site, so the limitation stops being structural and becomes a choice.
|
||||
|
||||
**It is not cosmetic, and that is the whole reason for the decision.**
|
||||
`self.cheap` is read in `cycle()` by the maintenance cycle's cheap-window grid
|
||||
top-up — the add-on's one shiftable load, and the one place a *price* reaches
|
||||
something that **moves energy** on an install that has not armed trading.
|
||||
|
||||
⚠️ **And the direction it would move is not "a slightly different window".**
|
||||
`static_status()` returns `(False, "no static profile")` whenever
|
||||
`static_profile` is empty, so today a provider-only site — a plausible shape
|
||||
for a dynamic-tariff install, and the shape this card makes work — has
|
||||
`self.cheap = False` on **every** cycle and never forces a paid grid top-up at
|
||||
all. Wiring `cheap_now()` would take that from *never* to *sometimes*. That is
|
||||
a strictly larger behaviour change than this card's headline, and it belongs to
|
||||
whoever owns the maintenance cycle rather than to the ticket that made it
|
||||
reachable.
|
||||
|
||||
Three further reasons, in the order they would bite:
|
||||
|
||||
1. **Two sources, one attribute, and the loser is silent.** `validate.py`
|
||||
already refuses `static_profile` alongside the Home Assistant price
|
||||
entities *because* `read_inputs()` picks one and the other disappears. A
|
||||
provider plan would be a third writer of the same attribute with no rule
|
||||
saying which wins.
|
||||
2. **The freshness question is not answered for this consumer.** The plan
|
||||
retires after `TARIFF_MAX_AGE_S` and `fresh_tariff_plan()` returns `None`.
|
||||
Arbitrage has an answer for that (refuse). The maintenance top-up has none:
|
||||
"the plan expired" would have to mean *not cheap*, and *not cheap* on this
|
||||
path means **wait for sun**, which is a real decision nobody has taken.
|
||||
3. **The comparison basis changes, and so does the answer on a bad feed.**
|
||||
`cheap_now()` is strictly-below-the-mean-of-the-**firm**-hours, and it is
|
||||
`False` when THIS hour is provisional or is not in the series at all
|
||||
(FCST-03's rule: this hour decides, not the whole plan). So a feed that
|
||||
published nothing, or an hour the §8.3 fill could not cover, reads *not
|
||||
cheap* where a `static_profile` reads whatever the clock says. Defensible
|
||||
either way — and it is a change to the shipped maintenance cycle that needs
|
||||
its own measurement rather than a side effect of this one.
|
||||
|
||||
**What adopting it would take**, so the next card does not start from scratch:
|
||||
|
||||
* a rule for which source wins when both a provider plan and a
|
||||
`static_profile` are configured, enforced in `validate.py` the way the
|
||||
entity/profile conflict already is;
|
||||
* a decision on what an **expired** or **absent** plan means for the grid
|
||||
top-up, stated as a default rather than left to `None`;
|
||||
* `read_inputs()` splitting `self.cheap` from `self.tariff_words`, which are
|
||||
currently one assignment from one function;
|
||||
* a check that the maintenance top-up actually fires and does not fire on the
|
||||
provider plan — driven through `cycle()`, not asserted on the attribute;
|
||||
* and the *Tariff* status row, which FCST-04 pinned as a **pair** with
|
||||
`DOCS.md` in `test_price_history.py` §9: it does not report the provider
|
||||
plan, DOCS does not claim it does, and moving one without the other is
|
||||
already red.
|
||||
|
||||
## 5. The *Arbitrage* status row, re-read and left alone
|
||||
|
||||
`main.status_payload()` renders `("Arbitrage", "off (arbitrage_enabled)" if
|
||||
self.arb is None else self.arb.explain())`. `self.arb` is assigned only inside
|
||||
`if arbitrage.enabled(self.o):` and `arbitrage.decide()` never returns `None`,
|
||||
so `self.arb is None` **iff** the option is off. The string still names the
|
||||
option that is actually off, and under Option A it gains a job it did not have:
|
||||
on a site with a provider configured it is the row that tells an operator the
|
||||
prices arriving on the *Price history* row above are moving nothing. Unchanged,
|
||||
with a note at the call site saying it was checked.
|
||||
|
||||
## 6. What this card did not do
|
||||
|
||||
* It did not touch `run_tariff_refresh()`'s body (TARIFF-12 is there).
|
||||
* It did not change `cycle()`'s `if arbitrage.enabled(self.o):`, the sizing,
|
||||
the thresholds, the SoC bounds or the peak interlock.
|
||||
* It did not wire the provider plan into the *Tariff* status row or into
|
||||
`self.cheap` — §4.
|
||||
* It did not pick a version number (PKG-06).
|
||||
|
||||
## 7. Mutation ledger
|
||||
|
||||
Every mutation applied to a **fresh `cp -a`** of the clean worktree rather than
|
||||
reverted (habit 18: there is no revert to verify and no shared scratch path),
|
||||
`__pycache__` purged, tree mounted `:ro`, run under `python3 -B`.
|
||||
|
||||
| id | mutation | Δ bytes | result |
|
||||
|---|---|---|---|
|
||||
| M1 | the shipped defect: gate back to `arbitrage.enabled(opts)` | **-4 B** | **RED**, 7 named checks |
|
||||
| M2 | drop the `or arbitrage.enabled(opts)` term | -27 B | **RED**, 2 named checks (strict-widening + the static-profile row) |
|
||||
| M3 | `tariff_refresh_wanted` -> always `True` | +8 B | **RED**, the AC2 reachability row |
|
||||
| M4 | `provider_configured`: `"off"` counts as configured | -6 B | **RED**, the AC2 reachability row |
|
||||
| M5 | `cycle()`'s trading gate -> `if True or arbitrage.enabled(self.o)` | +8 B | **RED**, both AC3 rows (`-5000 W` with trading off) |
|
||||
| M6 | instrument: `_reaches()` always `True` | **+0 B** | **RED**, 4 checks incl. its own calibration row |
|
||||
| M7 | instrument: `_task_sites()` returns `[]` | **+0 B** | **RED**, 10 checks incl. "it found 0 sites" |
|
||||
| M8 | fixture: 5 minutes of telegrams instead of a whole quarter | **+0 B** | **RED**, the peak-guard precondition and both armed rows |
|
||||
| M9 | M1 **plus** the harness's guard-driven start defeated | -4 B | the four DRIVEN rows go **GREEN** — see §2 |
|
||||
| M10 | `provider_id()` drops `.strip()` | -8 B | **survivor**, then CLOSED — see below |
|
||||
| M11 | `provider_configured()` -> `return True` | -32 B | **RED**, 4 checks |
|
||||
| M12 | DOCS.md's sentence reverted to the old, vaguer claim | +0 B (docs) | **RED**, the AC4 pairing row (`fetches = True, DOCS says = False`) |
|
||||
| M13 | the Dutch translation left behind while English moved | +0 B (docs) | **RED**, the both-languages row |
|
||||
|
||||
⚠️ **M10 was a real survivor and is recorded as one.** Dropping `.strip()` left
|
||||
`test_price.py`, `test_price_history.py`, `test_static_tariff.py` and
|
||||
`test_validate.py` all green: nothing in the repository fed a `price_provider`
|
||||
with surrounding whitespace, and the `.strip()` predates this card — it was
|
||||
carried into `provider_id()` from the three call sites it replaced. The
|
||||
Supervisor's `list(off|…)` schema cannot produce `" off "`, but
|
||||
`validate_startup_config()` is also `sim/inject.py`'s entry point and takes any
|
||||
dict at all. Closed in the same commit by the padded-id pair in section 13;
|
||||
re-run as **M10b**, red on two named checks.
|
||||
|
||||
⚠️ **M8 is the fixture defect this card walked into and then fenced.** The
|
||||
first draft fed five minutes of telegrams. Whether that **covers** the current
|
||||
billing quarter depends on where the wall clock sits inside it, so the peak
|
||||
guard was blind and engaged on some runs and not others — and `decide()` refuses
|
||||
outright while the guard is holding (§9.1 over §10.1). On those runs the armed
|
||||
baseline did not trade, and "trading off never trades" would have been graded
|
||||
on a fixture in which nothing could trade either way. It is now 900 s of
|
||||
telegrams, and the guard's clear state is a **check** rather than an assumption.
|
||||
|
||||
## 8. What is still open
|
||||
|
||||
* The literal reading of AC2 (§3). One token, and it costs a measured trading
|
||||
configuration its plan. **Owner's call.**
|
||||
* `cheap_now()` (§4). Owner's call, with the adoption list above.
|
||||
* The *Tariff* status row still shows the static-profile sentence on a
|
||||
provider-backed site; the provider plan's own line goes to the log. Pinned as
|
||||
a pair with `DOCS.md` by FCST-04 and untouched here.
|
||||
@@ -0,0 +1,252 @@
|
||||
# TARIFF-12 — a returned degradation is not a refresh
|
||||
|
||||
Three decisions. The first is what the ticket put up for judgement; the second
|
||||
and third are what fall out of it and would otherwise be decided by accident.
|
||||
|
||||
---
|
||||
|
||||
## 0. What was actually wrong, stated once
|
||||
|
||||
`run_tariff_refresh()`'s docstring:
|
||||
|
||||
> ⚠️ **AND A FAILURE DOES NOT CLEAR THE PLAN.** … Zeroing the plan here would
|
||||
> replace a coarse tariff with no tariff, which is the opposite of what §11.4
|
||||
> asks for.
|
||||
|
||||
The guarantee was real. The condition it was enforced on was not:
|
||||
|
||||
```python
|
||||
try:
|
||||
plan = await resolve_tariff(...)
|
||||
self.tariff_plan, self.tariff_at = plan, now # unconditional
|
||||
except Exception as err:
|
||||
_LOG.warning("tariff refresh failed (%s); keeping the plan ...")
|
||||
```
|
||||
|
||||
`load_prices()` says in its own docstring that it **"RETURNS, NEVER RAISES, for
|
||||
every failure a site can actually have"**, because a degradation arriving as a
|
||||
traceback into the planning tick would stop the controller regulating over an
|
||||
expired certificate. So a supplier outage — the case the docstring names first —
|
||||
is a **return**: the `try` succeeds, `_degrade()` hands back a §11.4 plan, and
|
||||
the good plan is overwritten. The `except` is reachable only by something
|
||||
nobody expected, and nothing in the shipped path produces it.
|
||||
|
||||
This is the fourth card in one family: FCST-04's M4 (`not ok` only), FCST-05's
|
||||
HIGH (`(JSONDecodeError, OSError)` only), FCST-06 (`not self.ok` only), and now
|
||||
`except Exception` only, for a path that never raises. **A guarantee enforced on
|
||||
the condition someone thought of, rather than the condition that occurs.**
|
||||
|
||||
Reproduced before the fix, driving the shipped chain with nothing stubbed but
|
||||
the socket — 48 h horizon, `static_profile` empty, one good fetch then a
|
||||
`ClientConnectorError` 1800 s later, `TARIFF_MAX_AGE_S` = 5400 s:
|
||||
|
||||
```
|
||||
after a good fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
|
||||
after the failed fetch origin='none' n_points=0 'no tariff - generic_dayahead: ClientConnectorError: ...'
|
||||
tariff_at moved: True
|
||||
```
|
||||
|
||||
and after it:
|
||||
|
||||
```
|
||||
after the failed fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
|
||||
tariff_at moved: False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. The question is asked ONCE, after the attempt, from the evidence that exists
|
||||
|
||||
FCST-06's answer, one module over, and for the same reason: **the enumeration
|
||||
of failure conditions cannot be completed.** There is no pre-check that a feed
|
||||
will answer which is not itself a guess, and adding one would be a second
|
||||
symptom test beside the `except`. So the predicate is an **outcome**, read off
|
||||
the object the attempt produced:
|
||||
|
||||
```python
|
||||
standing = self.fresh_tariff_plan(now)
|
||||
keep = (not plan.from_feed
|
||||
and standing is not None and standing.from_feed)
|
||||
```
|
||||
|
||||
Two facts, both of which exist by the time the question is asked:
|
||||
|
||||
* **`plan.from_feed`** — did *this* resolution observe the configured provider?
|
||||
`TariffPlan.origin` already names the four ways a plan is obtained; this
|
||||
collapses them to the one question a caller holding a previous plan has to
|
||||
ask. It lives on `TariffPlan` rather than as an `origin == "provider"` at the
|
||||
call site for `fresh_tariff_plan()`'s own stated reason — *a check has to be
|
||||
able to assert on the shipped expression rather than on a transcription that
|
||||
drifts* — and because `origin`'s vocabulary belongs to `static_tariff.py`.
|
||||
* **`fresh_tariff_plan(now)`** — is a standing observation still current. It
|
||||
already existed, it is already the one place the age is judged, and it is
|
||||
**reused, not transcribed**. `test_arbitrage.py` 8b pins that reuse on the
|
||||
AST; a copy of the comparison behaves identically until the day the two
|
||||
drift, which is a claim about the code's shape, so a shape check is what
|
||||
grades it (FCST-06's precedent for the one source check among driven ones).
|
||||
|
||||
### What was NOT done
|
||||
|
||||
* **Not `except Exception` widened, or a second `if` beside it.** That is the
|
||||
defect's own shape — a place where the rule is spelt twice, so a fifth cause
|
||||
needs a fifth branch. The `except` stays, relabelled *"not the outage path"*,
|
||||
keeps the plan on the same terms, and is now honestly the
|
||||
something-nobody-expected branch.
|
||||
* **Not the persisted price store as a cache.** Out of scope by the product
|
||||
owner's call, and it is the right call: FCST-03 exists precisely to stop
|
||||
stale prices being acted on, and "recent" is not "current". Keeping the last
|
||||
**fetched** plan until it ages out is a different promise from serving the
|
||||
store, and that one needs its own ticket.
|
||||
* **Not a `no_data`-only guard.** `not plan.has_prices` is the tempting
|
||||
spelling and it is wrong — see decision 2. It leaves the §11.4 fallback free
|
||||
to replace a fresh observation, and it is a 1-byte edit from the shipped
|
||||
line, which is why it is in the mutation table rather than in a comment.
|
||||
|
||||
---
|
||||
|
||||
## 2. A §11.4 static fallback does NOT replace a fresh provider plan
|
||||
|
||||
The ticket asked this directly, because the fallback is *not* nothing: it is
|
||||
the operator's own configured profile, with real prices, for the requested
|
||||
window. The answer is still no, and the reason is not that it is worse data —
|
||||
it is that **nothing may be acted on it, and it is no more current.**
|
||||
|
||||
* Every point `_degrade()` builds carries `provisional=True`. So
|
||||
`firm_points()` is empty, `arbitrage()` finds fewer than two firm hours and
|
||||
refuses, and `cheap_now()` is False. Both of the two things `TariffPlan`
|
||||
exposes for a consumer to act on are already off.
|
||||
* A day-ahead price for a given hour **does not change once published**. A plan
|
||||
fetched 30 minutes ago is exactly as true about 19:00 as one fetched now; at
|
||||
most `TARIFF_MAX_AGE_S` of its horizon has elapsed. The observation is not
|
||||
stale in any sense the fallback is fresher than.
|
||||
|
||||
So replacing the observation with the fallback trades a plan with firm hours
|
||||
for a plan with none, and buys no currency with it. The fallback takes over
|
||||
when the observation **retires** — which is exactly the row §11.4 describes,
|
||||
arrived at when there is no longer an observation to prefer.
|
||||
|
||||
### And the rule is deliberately NOT symmetrical
|
||||
|
||||
`standing.from_feed` is a term in its own right. A standing plan that is itself
|
||||
a degradation **is** replaced by the next degradation, and its timestamp moves.
|
||||
Without that term:
|
||||
|
||||
* an outage on a static-profile site would freeze the operator's profile at the
|
||||
window it was first generated for, and the guard would be *"never overwrite"*
|
||||
wearing a bound's name;
|
||||
* and on a `price_provider: off` install — the shipped default — the plan would
|
||||
flip between kept and re-derived every `TARIFF_MAX_AGE_S`, for no behavioural
|
||||
gain, changing which reason the status page prints on a two-hour cycle.
|
||||
|
||||
Each of the three terms is the only false one in some check in 8b, which is
|
||||
MUTATION.md habit 17's test for whether a suite can tell an `AND` from an `OR`.
|
||||
|
||||
---
|
||||
|
||||
## 3. `tariff_at` does not move on a kept plan
|
||||
|
||||
The docstring's other half is that a plan must not *"freeze at its last value
|
||||
and look current forever"*. `tariff_at` is what makes that work: it is the
|
||||
moment of the fetch that produced the plan standing, and `fresh_tariff_plan()`
|
||||
judges the age from it.
|
||||
|
||||
Re-stamping it on a kept plan would be this task asserting the feed answered
|
||||
when it did not. The plan would be re-certified as current at every refresh, an
|
||||
outage of any length would never retire it, and `TARIFF_MAX_AGE_S` would be
|
||||
**exactly as dead as it was before this card** — reachable only by the refresh
|
||||
task itself dying. Leaving it alone is what makes the bound govern: the plan is
|
||||
served for the age bound after its own fetch, and no longer.
|
||||
|
||||
It is also the whole of **"distinguishable from a freshly-fetched one"**. A
|
||||
kept plan and a fetched plan are the same object shape; what separates them is
|
||||
that one's timestamp advanced and the other's did not, and every consumer that
|
||||
asks how old the plan is already reads that number. The kept case additionally
|
||||
logs at WARNING, naming the degradation's own reason, the fetch it is keeping,
|
||||
its age, and the deadline it retires at.
|
||||
|
||||
⚠️ **What this does not give the operator, and why it is not here.** The status
|
||||
page has no row for "the last refresh failed" — the banner/level not moving
|
||||
when the plan is lost is separately carded, and the ticket rules it out of
|
||||
scope. This card's change makes that surface *more* truthful, not less: during
|
||||
the first `TARIFF_MAX_AGE_S` of an outage there is now a real plan behind the
|
||||
Arbitrage row instead of a no-plan refusal, so the row says something true for
|
||||
longer. The no-plan state is entered later by up to 5400 s; it is not removed,
|
||||
and the 30-minute lag reported on that row is untouched by anything here.
|
||||
|
||||
---
|
||||
|
||||
## 4. Merged after TARIFF-10 — what the wider gate changed, and what it did not
|
||||
|
||||
TARIFF-10 landed on `release/1.0` while this card was open. It changed **which
|
||||
installs run this task** — `if arbitrage.enabled(opts)` became
|
||||
`tariff_refresh_wanted(opts)`, i.e. `provider_configured(opts) or
|
||||
arbitrage.enabled(opts)` — while this card changed **what the task does on a
|
||||
failed fetch**. Git auto-merged the two hunks, which is exactly when the
|
||||
interaction needs reading rather than trusting.
|
||||
|
||||
**Nothing in the decisions above changed.** Two configurations are newly
|
||||
reachable, and both are now driven in `test_arbitrage.py` 8b rather than
|
||||
reasoned about:
|
||||
|
||||
* **A provider configured with trading DISARMED** — TARIFF-10's headline case.
|
||||
This is precisely the configuration the guard was designed for: the plans are
|
||||
`origin == "provider"`, and a dead feed must not delete one that is still
|
||||
inside `TARIFF_MAX_AGE_S`. It needs no second fixture, because **nothing on
|
||||
this path reads `arbitrage_enabled` at all**, and that is asserted rather
|
||||
than assumed — over the AST's symbols and the non-docstring string constants,
|
||||
because two earlier drafts of that check were **red against a correct tree**:
|
||||
the first matched the docstring paragraph that *says* `arbitrage_enabled` is
|
||||
not read, the second matched the log line's "arbitrage, where it is armed".
|
||||
A substring test over a function grades its prose as much as its behaviour,
|
||||
which is habit 4 in a third costume. A fixture that set the option and
|
||||
asserted no difference would have been the other defect — comparing a thing
|
||||
against itself.
|
||||
* **`price_provider: off` + a `static_profile`** — the configuration TARIFF-10's
|
||||
`or` exists to protect, and the one where a latching guard would do real harm.
|
||||
`resolve_tariff()` returns `static_plan(provisional=False)`, whose origin is
|
||||
`"static"`: the CONFIGURED product, not a §11.4 row. So `standing.from_feed`
|
||||
is false on every refresh, `keep` is false on every refresh, and the plan is
|
||||
regenerated for the current window every 30 minutes exactly as before this
|
||||
card. **Driven, twice in a row**, because a guard that latched here would
|
||||
freeze a static site's plan at its first window for the life of the install —
|
||||
and mutation M3, which drops the `standing.from_feed` term, is red on that
|
||||
check as well as on the fallback one.
|
||||
|
||||
Two further facts settle the rest of the interaction:
|
||||
|
||||
* **This decision is strictly downstream of every side effect.** The §8.3 carry
|
||||
into `price_history` happens inside `resolve_tariff()`, on the `series`
|
||||
branch, before `keep` is computed — so which of two plans is kept can neither
|
||||
add nor lose a recorded day, and TARIFF-10's *Price history* row is
|
||||
unaffected either way.
|
||||
* **The kept-plan log line was reworded.** It said "arbitrage stops with it",
|
||||
which after TARIFF-10 is false on a site that fetches without trading; it now
|
||||
says "arbitrage, **where it is armed**, stops with it".
|
||||
|
||||
All eleven mutations were **re-driven on the merged tree** rather than carried
|
||||
over — the surrounding code changed, so a red against the old base is not
|
||||
evidence about the new one. Eleven red, no survivors, every revert
|
||||
fingerprinted. The added member is M11: making the guard conditional on
|
||||
`arbitrage.enabled(self.o)` (+86 B), red on five named checks.
|
||||
|
||||
⚠️ **And one process note that belongs in this file.** This card's mutation
|
||||
harness was written to a shared scratchpad path and was **overwritten mid-run
|
||||
by another agent's harness for a different card**, pointing at a different
|
||||
worktree. That is MUTATION.md habit 18's second bullet almost verbatim. It was
|
||||
caught because the harness fingerprints the tree after every revert and the
|
||||
runs are what proved this worktree was never left mutated; the harness now
|
||||
lives at a card-specific path. **A harness that edits source must not share a
|
||||
name, a directory or a backup path with another one.**
|
||||
|
||||
---
|
||||
|
||||
## What this does not change
|
||||
|
||||
* **Nothing on the 30 s tick.** `cycle()` still reads two attributes and awaits
|
||||
no fetch; the AST pin in `test_arbitrage.py` section 8 is unchanged and green.
|
||||
* **Nothing about the prices themselves.** No series is invented, extended,
|
||||
re-stamped or carried. The only question this card answers is *which of two
|
||||
plans the controller holds*.
|
||||
* **A `price_provider: off` install is bit-for-bit unaffected.** Its standing
|
||||
plan is never `origin == "provider"`, so `keep` is false on every refresh it
|
||||
will ever run, and the assignment happens exactly as it did before.
|
||||
@@ -0,0 +1,225 @@
|
||||
# TARIFF-13 — the coverage of a peak, and which quarter gets the credit
|
||||
|
||||
Two decisions. §1 is the defect the ticket was raised on; §2 is the smaller
|
||||
question it asked to be settled deliberately rather than left as an accident.
|
||||
§3 is what this branch deliberately did **not** do, and why.
|
||||
|
||||
---
|
||||
|
||||
## 0. What was actually wrong, stated once
|
||||
|
||||
`main.py`'s capacity-tariff status row prints a peak read from SQLite and a
|
||||
coverage figure read from **process memory**:
|
||||
|
||||
```python
|
||||
_pk = self.peak.peak(when) # SQLite, durable
|
||||
...
|
||||
+ f"; {self.peak.recorded} quarter(s) banked" # an int on the tracker
|
||||
```
|
||||
|
||||
`PeakTracker.recorded` was `self.recorded = 0` in `__init__`, incremented once
|
||||
per block inside `sync()`. A 40-hour soak caught what that means at a driven
|
||||
restart, on both instances independently:
|
||||
|
||||
```
|
||||
before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 120 quarter(s) banked
|
||||
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
|
||||
```
|
||||
|
||||
Reproduced here before anything was changed, through the real `Controller`, the
|
||||
real `P1Ingest`, and a **real restart** — a second `Controller` object against
|
||||
the same file on disk, never a re-read of the first:
|
||||
|
||||
```
|
||||
before: 2026-09: peak 365 W, set by the quarter at 2026-09-10T09:00:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 12 quarter(s) banked
|
||||
after : 2026-09: peak 365 W, set by the quarter at 2026-09-10T09:00:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
|
||||
|
||||
peak survives the restart: True
|
||||
count survives the restart: False (12 -> 0, store held 12 rows)
|
||||
```
|
||||
|
||||
**TARIFF-01's argument, one column over.** That module's whole design is that
|
||||
an unreadable store must not read as a zero peak, *because a zero peak is
|
||||
disarming*. A coverage figure of zero is disarming in exactly the same way and
|
||||
about the same number: the count is what says how much of the billing month the
|
||||
watt figure actually covers, and 365 W over 120 quarters and 365 W over 0
|
||||
quarters are different claims about the same 365 W. After a restart the page
|
||||
made the weaker claim while displaying the stronger one's value. It did not
|
||||
under-report the peak — it under-reported **its own evidence**.
|
||||
|
||||
Two further defects fell out of the same variable once it was driven, neither
|
||||
of them in the ticket:
|
||||
|
||||
* **it was never scoped to a month.** It counted every block the *process* had
|
||||
banked, so on 1 October the row read September's tally beside October's "no
|
||||
completed quarter recorded yet". Measured, on the shipped tree:
|
||||
`2026-10: no completed quarter recorded yet - 0 % of the 3500 W soft limit;
|
||||
1 quarter(s) banked`.
|
||||
* **it missed the block `restore()` banks.** A stale block closed from the
|
||||
checkpoint goes into `quarter_blocks` without passing through `sync()`, so
|
||||
the counter never saw it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Decision: the count is READ FROM THE TIER THE PEAK IS READ FROM
|
||||
|
||||
The acceptance criterion allowed either route — make the count durable, or stop
|
||||
printing a number that silently resets. **Durable**, because the count is the
|
||||
useful half of the row and removing it would have cost the operator the one
|
||||
thing that says whether the peak means anything yet.
|
||||
|
||||
`PeakStore.banked_count(month)` asks the database `SELECT COUNT(*) FROM
|
||||
quarter_blocks WHERE month=?`, and `PeakTracker.recorded` is a property over it
|
||||
rather than an attribute. Three consequences, and all three are the defects
|
||||
above:
|
||||
|
||||
* a restart re-reads the same rows, so the figure survives it;
|
||||
* the count is per **month**, so it agrees with the peak it is printed beside;
|
||||
* a block banked by `restore()` is a row like any other and is counted.
|
||||
|
||||
Three things this route deliberately does **not** do:
|
||||
|
||||
* **it does not count `len(blocks_in(month))`.** `blocks_in()` returns `[]` on a
|
||||
read error — safe there only because no caller reads its emptiness as a peak,
|
||||
which its own docstring says — so taking its length would rebuild the
|
||||
disarming zero one call further out. `banked_count()` returns **`None`** when
|
||||
the database will not answer.
|
||||
* **it does not count `monthly_peak`.** That tier holds one row per *month* and
|
||||
would answer `1` for every month ever measured — wrong by exactly the amount
|
||||
that makes it look plausible. The two tables are proven equal on the **peak**
|
||||
by `recompute_month()`; they were never equal on the **count**.
|
||||
* **it does not remove the dedupe.** `_seen` is still what stops `cycle()`'s
|
||||
once-a-second `sync()` re-banking a block that `P1Ingest.blocks` keeps for a
|
||||
day, and `sync()`'s return value is still the number newly banked. What moved
|
||||
is only where the *status page* reads its figure. ⚠️ The check that graded the
|
||||
dedupe used to assert `recorded == 1` — the dedupe and the figure reporting it
|
||||
were the same variable. Counting rows in `quarter_blocks` cannot see a
|
||||
re-bank at all (it is a PRIMARY KEY no-op), so that check is now graded on
|
||||
`sync()`'s **return**, with the store's tally as the second half.
|
||||
|
||||
### ⚠️ The unreadable case renders a WORD, not a number
|
||||
|
||||
`banked_count()` returns `int | None`; `recorded_in()` renders the `None` as
|
||||
the module constant `COUNT_UNREADABLE = "an unreadable number of"`, so the
|
||||
sentence reads
|
||||
|
||||
```
|
||||
... ; an unreadable number of quarter(s) banked
|
||||
```
|
||||
|
||||
`0` was never available. `None` was rejected too: `f"{None} quarter(s) banked"`
|
||||
renders **"None quarter(s) banked"**, which an operator reads as the same zero.
|
||||
This is the `PeakReading` trade — no `peak_w_or(default)`, ever — applied to the
|
||||
coverage figure: a caller that formats it gets a true sentence in both states,
|
||||
and a caller that does arithmetic on it gets a `TypeError`, which is loud.
|
||||
|
||||
"0 quarter(s) banked" remains a **true and reachable** sentence: it is what a
|
||||
month nothing has been written to says, and part 11 asserts that the two states
|
||||
are spelled differently.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision: at EQUAL wattage, the EARLIEST quarter keeps the attribution
|
||||
|
||||
**First, not most recent.** The reasoning, in the order it matters:
|
||||
|
||||
1. **It is not a money question.** The utility bills the wattage; the timestamp
|
||||
is not part of the bill. So the decision is settled entirely on what the
|
||||
status row is *for*.
|
||||
2. **The row answers "when did my monthly peak happen".** An operator reading it
|
||||
goes and looks at one event — what was running, what the weather was. A
|
||||
stable answer is the one that can be investigated; "most recent wins"
|
||||
guarantees the answer moves every time the wattage recurs, and an operator
|
||||
who looked yesterday is sent somewhere else today for no reason.
|
||||
3. **It is what one reader already did unconditionally.** `recompute_month()`
|
||||
is `ORDER BY offtake_avg_w DESC, quarter_start ASC LIMIT 1` — earliest wins.
|
||||
Choosing "most recent" would have meant changing both readers; choosing
|
||||
"earliest" meant making the other one say out loud what it was already doing
|
||||
by accident.
|
||||
|
||||
### ⚠️ And the two readers DID diverge, which is why this is a code change
|
||||
|
||||
The `monthly_peak` upsert was `WHERE excluded.peak_w > monthly_peak.peak_w` —
|
||||
strictly greater, so an exact tie never wrote and the row kept whichever tied
|
||||
block was **written first**. That coincides with *earliest* only while blocks
|
||||
arrive in time order. Fed four equal 365 W blocks newest-first, measured on the
|
||||
shipped tree:
|
||||
|
||||
```
|
||||
ascending arrival monthly_peak -> 09:00:00 recompute_month -> 09:00:00 agree=True
|
||||
DESCENDING arrival monthly_peak -> 09:45:00 recompute_month -> 09:00:00 agree=False
|
||||
```
|
||||
|
||||
That is a divergence in the derived record that part 8 of `test_tariff.py`
|
||||
exists to prove cannot happen — latent, because nothing in the shipped path
|
||||
banks out of order today. The upsert now carries the tie term explicitly:
|
||||
|
||||
```sql
|
||||
WHERE excluded.peak_w > monthly_peak.peak_w
|
||||
OR (excluded.peak_w = monthly_peak.peak_w
|
||||
AND excluded.quarter_start < monthly_peak.quarter_start)
|
||||
```
|
||||
|
||||
**`peak_w` is untouched.** The `>` term is exactly as it was, no tie can move
|
||||
the billed number, and SAFETY-07's guard reads the same watt figure it read
|
||||
before. The second term moves only the timestamp.
|
||||
|
||||
### ⚠️ AND THE SOAK'S DRIFT WAS NOT A TIE AT ALL
|
||||
|
||||
The ticket's reading — "ties at the same wattage are re-attributed to the most
|
||||
recent matching quarter" — is **false**, and driving it is what showed that.
|
||||
Six exactly-equal 365.0 W quarters on the shipped tree moved the attribution
|
||||
**zero** times, because `>` is strict.
|
||||
|
||||
What the soak saw was a genuine new maximum **smaller than the row's own
|
||||
resolution**. The row renders `{peak_w:.0f}` — whole watts — so 365.0000001 W is
|
||||
a new peak to the database and the same `365 W` to the reader:
|
||||
|
||||
```
|
||||
q0 fed 365.0 -> renders 365 W, attributed 09:00:00
|
||||
q1 fed 365.0000001 -> renders 365 W, attributed 09:15:00
|
||||
q2 fed 365.0000002 -> renders 365 W, attributed 09:30:00
|
||||
```
|
||||
|
||||
**Decision: left alone, deliberately.** It is the instrument behaving correctly
|
||||
— the peak really did rise — and the alternative, printing more decimal places,
|
||||
puts noise in the one row an operator reads at the moment the guard engages, to
|
||||
explain a movement that is real. Both cases are pinned in part 12 so that
|
||||
neither can be changed by accident, and the distinction is recorded here so the
|
||||
next reader does not re-diagnose it as a tie.
|
||||
|
||||
---
|
||||
|
||||
## 3. What this branch did NOT do, and who owns it
|
||||
|
||||
**`main.py`'s status row was not touched.** TARIFF-11 holds `main.py`'s status
|
||||
rows on a concurrent branch, and the standing instruction is to stop rather than
|
||||
race there. Everything above is in `app/tariff.py`, and the row picks the fix up
|
||||
without editing, because `self.peak.recorded` is an attribute access and is now
|
||||
a property.
|
||||
|
||||
**The residual, stated with its scope.** `recorded` answers for
|
||||
`datetime.now()`. The peak beside it follows `checks(when=...)`. The shipped
|
||||
install has **exactly one** call site — `self.checks()` with no argument, at
|
||||
`main.py:4359` — where the two are the same month, so **the live page is correct
|
||||
and durable**. They part company only under the `when=` affordance TEST-17 added
|
||||
for the suite, and the mismatch there is strictly smaller than the one it
|
||||
replaces (this month's tally, rather than a process-wide total across all
|
||||
months).
|
||||
|
||||
`PeakTracker.recorded_in(when)` is the scoped form and is shipped. Moving the
|
||||
row onto it is:
|
||||
|
||||
```python
|
||||
- + f"; {self.peak.recorded} quarter(s) banked"
|
||||
+ + f"; {self.peak.recorded_in(when)} quarter(s) banked"
|
||||
```
|
||||
|
||||
one token, on one line, inside the block TARIFF-11 owns. `test_tariff.py`
|
||||
part 10 **pins the present behaviour** — graded against a row count taken
|
||||
straight from `quarter_blocks`, not against the property that produces it — so
|
||||
the gap goes red if it changes and cannot be quietly forgotten.
|
||||
@@ -0,0 +1,349 @@
|
||||
# TARIFF-14 — how long one price point covers, and what the last one does
|
||||
|
||||
One defect and one genuinely open decision. The defect had no defensible
|
||||
alternative once it was measured; the decision — the **last** point's window —
|
||||
did, and the alternative is argued below rather than dismissed.
|
||||
|
||||
---
|
||||
|
||||
## 0. The measurement this card exists for
|
||||
|
||||
`_Series.at()` asked `s <= u < s + 1 h` of every point **in list order** and
|
||||
returned the **first** match. Run against the shipped code at `5059e28` on a
|
||||
quarter-hourly series:
|
||||
|
||||
```
|
||||
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14 ...
|
||||
|
||||
ask + 0min -> 02:00 0.10
|
||||
ask +15min -> 02:00 0.10 <-- WRONG (wanted 02:15)
|
||||
ask +30min -> 02:00 0.10 <-- WRONG
|
||||
ask +45min -> 02:00 0.10 <-- WRONG
|
||||
ask +60min -> 02:15 0.11 <-- WRONG (wanted 03:00)
|
||||
ask +75min -> 02:30 0.12 <-- WRONG (wanted 03:15)
|
||||
```
|
||||
|
||||
**Two failure modes, not one.** Inside the first hour the `:00` point wins
|
||||
because its hour has not run out. From the second hour on the answer is not
|
||||
even the top of the hour — the first point still inside its own hour wins — so
|
||||
**the error compounds with depth into the series**.
|
||||
|
||||
Same code, same run, after the fix:
|
||||
|
||||
```
|
||||
ask + 0min -> 02:00 0.10 ask +60min -> 03:00 0.14
|
||||
ask +15min -> 02:15 0.11 ask +75min -> 03:15 0.15
|
||||
ask +30min -> 02:30 0.12 ask +90min -> 03:30 0.16
|
||||
ask +45min -> 02:45 0.13 ask+120min -> 04:00 0.18
|
||||
```
|
||||
|
||||
An hourly series is **unchanged at every instant**, before and after — which is
|
||||
the whole reason nothing caught this. Every price fixture in this repository
|
||||
before TARIFF-08 was the invented hourly row shape, `static_tariff.py` emits
|
||||
hourly points, and the DST checks count points on the 23- and 25-hour days
|
||||
without ever asking `at()` for a quarter-hour. **The suite had been exercising
|
||||
the one resolution on which the broken rule and the right one agree.**
|
||||
|
||||
Belgium moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
|
||||
returns **96 points at 900 s**, so this is reachable only down the dynamic path
|
||||
TARIFF-08/09 just made work. Static sites are immune.
|
||||
|
||||
## 0.1 What it cost, which is not "a slightly wrong price"
|
||||
|
||||
`arbitrage._act()` calls `_point_at(plan, now)` → `series.at(now)` and then
|
||||
tests `here.start == v.cheapest.start` / `== v.dearest.start` to decide whether
|
||||
this interval is the buy one or the sell one. That is an **identity test
|
||||
between the matched point and the point the verdict was built from**, and on a
|
||||
quarter-hourly series it almost never matched — so the strategy idled through
|
||||
exactly the interval it exists to trade, reporting *"this hour is neither the
|
||||
buy hour nor the sell hour"*. It also read `here.provisional` (§8.3) and §17's
|
||||
`price < 0` off that same wrong point.
|
||||
|
||||
---
|
||||
|
||||
## 1. The rule
|
||||
|
||||
> **A point covers from its own start until the next point's start, and never
|
||||
> more than one hour.**
|
||||
|
||||
* **900 s** → each point covers its own quarter.
|
||||
* **3600 s** → each point covers its own hour. Identical to what shipped.
|
||||
* **MIXED** → **each point takes its own gap.** FCST-04's carry-forward emits
|
||||
hourly carried points beside quarter-hourly firm ones, and the soak measured
|
||||
spacing `[900, 3600]` in **70 % of samples** — so a window derived from one
|
||||
global spacing would be wrong in the **normal steady state**, not in an edge
|
||||
case.
|
||||
|
||||
### 1.1 Why the one-hour cap is not decoration
|
||||
|
||||
FCST-04 **leaves holes on purpose**: an hour with no published price and no
|
||||
yesterday value is left *out* of the series, and `price_forecast.py`'s module
|
||||
note names `at()` raising `Absence` for it as the behaviour that sends the
|
||||
caller to the static profile (§11.4 for that hour).
|
||||
|
||||
Uncapped, "until the next point's start" stretches the point **before** a
|
||||
three-hour hole across the whole hole and answers every instant in it with a
|
||||
stale price. Filling holes is the defect this module exists to refuse, and it
|
||||
would have been introduced **by the fix for a different one**. Graded in
|
||||
`test_price.py` 14d; the mutation that removes the cap is red there.
|
||||
|
||||
---
|
||||
|
||||
## 2. The decision: what the **last** point covers
|
||||
|
||||
There is no next start, and §9.3 says nothing about how wide the final interval
|
||||
is — a `PricePoint` has **three fields** and must not learn a fourth, because
|
||||
that is the wire format every store, replay, fixture and captured body in this
|
||||
repository is written against. So the tail is a **choice**, and both candidates
|
||||
are defensible.
|
||||
|
||||
**Chosen: the last point is given the same span as the point before it** (one
|
||||
hour when it is the only point).
|
||||
|
||||
| | flat one hour | the span in front of it (**chosen**) |
|
||||
|---|---|---|
|
||||
| 96-point 900 s day, `at(23:50)` | answers 23:45 | answers 23:45 |
|
||||
| the same day, `at(00:30)` **tomorrow** | **answers with today's 23:45 price** | `Absence` |
|
||||
| 24-point hourly day, `at(23:50)` | answers 23:00 | answers 23:00 |
|
||||
| mixed series ending at the firm/carried seam | answers for an hour | `Absence` after 15 min |
|
||||
|
||||
**The second row is why.** A flat one-hour tail reaches across a day boundary
|
||||
and prices tomorrow morning with yesterday's last print — which is
|
||||
"reaching for a neighbouring one", the single thing the `Absence` message has
|
||||
always refused to do. The chosen rule never extends a point past a span the
|
||||
series itself demonstrates.
|
||||
|
||||
### 2.1 The cost of the choice, stated rather than buried
|
||||
|
||||
On a **mixed** series whose last point is a carried **hourly** one sitting
|
||||
behind quarter-hourly firm ones, the tail is **900 s**, and the last 45 minutes
|
||||
of the horizon raise `Absence` for an hour something did carry a value for.
|
||||
|
||||
That is accepted, not tolerated, and the direction is the safe one:
|
||||
|
||||
* the caller falls back to the static profile (§11.4) instead of trading on a
|
||||
span the series never demonstrated;
|
||||
* the point is **carried**, so §8.3 forbids any irreversible action resting on
|
||||
it regardless;
|
||||
* it is at the far end of a 48 h horizon, where nothing is decided at 30 s.
|
||||
|
||||
Under-covering costs a missed decision. Over-covering prices a real interval
|
||||
with a number that does not describe it. Those are not symmetric.
|
||||
|
||||
### 2.2 The degenerate case
|
||||
|
||||
A series of **one** point has no gap in front of it to copy and gets **one
|
||||
hour** — the shipped behaviour, kept deliberately. `test_price.py` 14e states
|
||||
it as its own check so it cannot decay into an accident.
|
||||
|
||||
---
|
||||
|
||||
## 3. Does `at()` now depend on sortedness? Yes — and it establishes it itself
|
||||
|
||||
The old rule read one point at a time and did not care about order. The new one
|
||||
reads a point's **neighbours**, so it *would* depend on the series being
|
||||
ascending.
|
||||
|
||||
It does not, because **the lookup index is sorted by UTC start at construction**
|
||||
(`_cover_table()`, called once from `_Series.__init__`). No caller's list order
|
||||
can reach `at()`.
|
||||
|
||||
That matters because the three things that "guarantee" order today each
|
||||
guarantee it somewhere else:
|
||||
|
||||
| | what it guarantees | where |
|
||||
|---|---|---|
|
||||
| `_validate_raw()` | strictly increasing starts | the `load_prices()` path only |
|
||||
| `price_history._parse_points()` | re-sorts by instant | the store read path only |
|
||||
| FCST-04's fill | emits ascending | that one producer only |
|
||||
| `PriceRead.series()` | **nothing** | every other caller, including tests |
|
||||
|
||||
Three producers agreeing today is not an invariant; it is a coincidence with
|
||||
good references. `test_price.py` 14f drives a series handed its 96 points in
|
||||
**reverse** order and asserts `at()` answers identically at every one of them —
|
||||
red the moment the sort is removed.
|
||||
|
||||
**Duplicated starts** (refused by `_validate_raw()`, constructible by hand) give
|
||||
the earlier list entry a zero-width window and the later one the real span. One
|
||||
of the pair becomes unreachable through `at()`; **nothing raises**, at
|
||||
construction or on the 30 s control path. Stated as a check rather than left to
|
||||
be discovered.
|
||||
|
||||
---
|
||||
|
||||
## 4. `covers()` shared it. `window()` did not
|
||||
|
||||
* **`covers()`** has no window rule of its own — it calls `at()` — so it shared
|
||||
the defect exactly and the same change fixes it. It is kept as a delegation
|
||||
for that reason: a second copy of the rule is a second place for it to be
|
||||
wrong.
|
||||
* **`window(since, until)`** grades a point's **start** against a half-open
|
||||
range and never asks how long a point lasts, so it carries no resolution
|
||||
assumption at all. It is **not** a coverage query — a point that starts before
|
||||
`since` and runs into it is absent from its result by design. That is now
|
||||
said in its docstring and pinned by a check, because the next reader will ask.
|
||||
It has **no callers** in `app/`.
|
||||
|
||||
---
|
||||
|
||||
## 5. What this card does NOT fix, with its shape
|
||||
|
||||
`price_forecast._walk()` — §8.3's irreversible-action gate — iterates
|
||||
`committed_hours()`, which steps in **whole hours**, and asks `series.at(u)` at
|
||||
each. On a quarter-hourly series that now reliably returns the hour's `:00`
|
||||
point (it previously returned something earlier and arbitrary), so the gate
|
||||
reads **one point per hour** and never looks at the `:15`/`:30`/`:45` flags.
|
||||
|
||||
A partially-provisional hour would therefore be gated on its first quarter
|
||||
alone. **Two things keep it off every shipped install, and both are
|
||||
circumstantial rather than structural:** `price_permits()`/`begin_phase()` have
|
||||
**no caller in `app/main.py`** — the §12 price-justified entry point is built
|
||||
and not yet wired — and FCST-04 carries **whole** hours while
|
||||
`generic_dayahead` publishes whole days, so the four quarters of an hour are
|
||||
firm or carried together anyway. Both are facts about the *callers and
|
||||
producers*, not properties of the *gate*, so this is written down here rather
|
||||
than left for the first sub-hourly feed or the first wiring-up to find. Fixing it means teaching
|
||||
`committed_hours()` about resolution, which is a data-model question this card
|
||||
deliberately does not open.
|
||||
|
||||
---
|
||||
|
||||
## 6. Mutations
|
||||
|
||||
Eight, each purged before the run (TEST-15), each reverted with the tree
|
||||
fingerprint checked afterwards (MUTATION.md habit 18), backup at a `mktemp`
|
||||
path per invocation.
|
||||
|
||||
⚠️ **Re-driven in full on the MERGED tree** after TARIFF-10 landed on
|
||||
`release/1.0` (base `88c59a9` -> `559f0eb`). All eight give the same verdicts
|
||||
and the same red-check counts as before the merge, which is the answer that
|
||||
matters: TARIFF-10 added 650 lines of checks to `test_price.py` and none of
|
||||
them moves under any of these edits, because they grade `resolve_tariff()`'s
|
||||
*reachability from a configuration* and not the lookup. Clean `app/price.py` on
|
||||
the merged tree = **`90d8ca31f7c0109d`, 85953 B** (pre-merge it was
|
||||
`30d1a56f0117e571`, 83742 B; the difference is TARIFF-10's `provider_id()` /
|
||||
`provider_configured()`, which this card does not touch).
|
||||
|
||||
| | mutation | Δ | red in |
|
||||
|---|---|---|---|
|
||||
| M1 | `at()` back to the shipped fixed one-hour scan over `self.points` | +68 B | **24 named checks**: `test_price.py` 14b ×4, 14c ×3, 14d, 14e ×3, 14f, 14g; `test_arbitrage.py` 10b ×6, 10c ×3, 10d ×2 |
|
||||
| M2 | drop the one-hour cap | −68 B | `test_price.py` 14d |
|
||||
| M3 | flat one-hour tail instead of the span in front | **+0 B** | `test_price.py` 14e ×3, 14g — 4 checks |
|
||||
| M4 | trust the caller's list order — drop the sort | −61 B | `test_price.py` 14f |
|
||||
| M5 | `covers()` grows its own hour-wide rule | +95 B | `test_price.py` 14e, 14g |
|
||||
| M6 | `window()` becomes a coverage query | −37 B | `test_price.py` 14g |
|
||||
| M7 | the window's end becomes inclusive | +1 B | `test_price.py` 14b ×4, 14c ×4, 14d, 14e ×4, 14f ×2, 14g — 16 checks |
|
||||
| M8 | the window's start becomes exclusive | −1 B | `test_price.py` 14b ×4, 14c ×4, 14d, 14f ×2, 14g, 14h, plus the pre-existing section 6 check — 14 checks |
|
||||
|
||||
**M3 is the zero-byte one and it is the tail decision itself** — the case
|
||||
MUTATION.md names as the risky size, caught by the check written for it.
|
||||
|
||||
**What the arbitrage file does NOT see, with sizes.** M2, M4, M5, M6 and M8
|
||||
leave `test_arbitrage.py` green, and that is honest rather than a gap to round
|
||||
off: they are `at()`-level properties (holes, caller order, `covers()`,
|
||||
`window()`, the start boundary) that no arbitrage fixture reaches, because the
|
||||
probes are seven minutes into a slot and the plans have no holes. The one that
|
||||
matters end to end — M1, the shipped rule — is red in **both** files, on the
|
||||
named check `THE PROPERTY THE DEFECT BROKE`.
|
||||
|
||||
---
|
||||
|
||||
## 7. TARIFF-10 landed on the base while this was open — what it changes here
|
||||
|
||||
Base moved `88c59a9` → `559f0eb` mid-card. The question is not whether the
|
||||
branches merge; it is whether TARIFF-10 makes any sentence above **false**.
|
||||
|
||||
**In `app/price.py` the two do not meet.** TARIFF-10 added `provider_id()` and
|
||||
`provider_configured()` beside `price_option()` and routed `load_prices()`
|
||||
through them. That is the option-reading layer; it touches nothing in
|
||||
`_Series`, `at()`, `covers()`, `window()` or `_cover_table()`. Git merged the
|
||||
file without a conflict, and the merged `at()` is byte-identical to the one the
|
||||
mutations above were driven against.
|
||||
|
||||
**Behaviourally it widens who has a series at all, and that cuts one way only.**
|
||||
`run_tariff_refresh()` now runs on installs with `arbitrage_enabled` **off**, so
|
||||
a provider-configured site has a standing plan — and therefore a `_Series` —
|
||||
where before it had none. **It does not add a new `at()` consumer to the 30 s
|
||||
tick**, which is the claim worth checking rather than assuming:
|
||||
|
||||
| caller of `at()`/`covers()` | reached by a non-trading install? |
|
||||
|---|---|
|
||||
| `arbitrage._point_at()` (`arbitrage.py:300`) | no — trading only |
|
||||
| `static_tariff.TariffPlan.price_at()` / `.words` `here` (`:750`, `:819`) | on the tick these are reached through `static_status()`, which builds a **`static_plan`** — hourly points, immune. `main.py` calls only `plan.words` on the fetched plan, which counts points and never looks one up |
|
||||
| `price_forecast._walk()` (`:562`, `:568`) | **`price_permits()` and `begin_phase()` have no caller in `app/main.py` at all** — §12's price-justified entry point is built and not yet wired |
|
||||
|
||||
So TARIFF-10 neither creates nor closes any exposure this card is about. What it
|
||||
does do is make the defect's reach larger the moment such a site arms trading,
|
||||
because the series it would trade against now exists on every configured
|
||||
install rather than only on armed ones. That strengthens the case for the fix
|
||||
and changes nothing in its reasoning.
|
||||
|
||||
**In `test_price.py` both cards appended a section 13.** Nothing is dropped:
|
||||
TARIFF-10's stays **13**, this card's is renumbered **14** (which is also this
|
||||
card's number), and the temp-directory teardown TARIFF-10 put at the end of the
|
||||
file stays at the end of the file. The two sections share no fixture, no option
|
||||
dict and no helper name; TARIFF-10's grades `resolve_tariff()`'s *reachability
|
||||
from a configuration* and drives real fetches with a counting transport, and
|
||||
**none of its checks moves under any of the eight mutations above** — measured,
|
||||
not assumed.
|
||||
|
||||
---
|
||||
|
||||
## 8. TARIFF-12 landed too — a plan may now be 90 minutes old when it is asked
|
||||
|
||||
Base moved `559f0eb` → `a6e64fb` while this was open. TARIFF-12 stops a
|
||||
returned §11.4 degradation from overwriting a standing plan that is still
|
||||
inside `TARIFF_MAX_AGE_S`, so **a served plan can now be up to
|
||||
`3 × TARIFF_REFRESH_S = 5400 s` old** where before it was at most one refresh
|
||||
interval. `at()` is what answers *"what does it cost right now"* out of that
|
||||
older plan, so the question is fair and the answer is written out rather than
|
||||
assumed.
|
||||
|
||||
**Per-point span: unaffected, structurally.** Every window is derived from the
|
||||
neighbouring points' **absolute `start` instants**. Those are `datetime`s inside
|
||||
a frozen dataclass inside a tuple — they cannot move, and `_cover_table()` runs
|
||||
once at construction over data that cannot change afterwards. A plan's *age* is
|
||||
a property of `fetched_at` / `tariff_at`, never of a window. **The index cannot
|
||||
go stale**, which is a small positive under TARIFF-12 rather than a risk: a kept
|
||||
plan now lives longer, and there is nothing in it that needed recomputing.
|
||||
|
||||
**The one-hour cap: unaffected.** It bites only where a gap exceeds an hour,
|
||||
i.e. at a hole FCST-04 left. Walking further into a series crosses more windows;
|
||||
each is still resolved on its own neighbours.
|
||||
|
||||
**The tail: reachable for the first time, and the choice made in §2 is the one
|
||||
that survives it.** This is the half worth spelling out.
|
||||
|
||||
* At the **shipped 48 h horizon** the tail is unreachable by staleness. A plan
|
||||
is served for at most 90 minutes, so `now` is at most 90 minutes into a
|
||||
~48 h series — the last point is still about 46½ hours away.
|
||||
* But `price_horizon_h` is `int(1,72)` in `config.yaml`, so an operator **can**
|
||||
configure a **1 h** horizon. A 90-minute serve-stale window against a 1 h
|
||||
series puts `now` genuinely past the end. That is not hypothetical; it is a
|
||||
supported configuration meeting a supported outage.
|
||||
* Under the **chosen** rule a quarter-hourly 1 h series ends at `:45` and that
|
||||
point's window closes at the top of the next hour: `at()` raises `Absence`,
|
||||
`_point_at()` returns None, `_act()` idles with its "no price for this
|
||||
instant" reason, and §11.4 sends the site to the static profile.
|
||||
* Under the **rejected** flat one-hour tail the same stale plan would have gone
|
||||
on trading against the `:45` print for **up to 45 minutes after the interval
|
||||
it describes had ended** — pricing an outage with a number for a quarter that
|
||||
is over.
|
||||
|
||||
So TARIFF-12 does not weaken the tail decision. It is the first thing that makes
|
||||
it reachable on a shipped configuration, and it independently selects the same
|
||||
answer. ⚠️ **Note which direction that runs**: the more stale a plan is allowed
|
||||
to be, the more the tail rule is doing real work, so it should be re-read if
|
||||
`TARIFF_MAX_AGE_S` is ever widened again.
|
||||
|
||||
**`TariffPlan.from_feed` cannot produce a series this code has not seen.** It is
|
||||
a pure predicate on `origin` (`== "provider"`), and TARIFF-12's decision keeps or
|
||||
replaces a **whole plan object**. It never splices two reads into one series, so
|
||||
it cannot hand `at()` an unsorted or duplicated point list — the case §3 covers
|
||||
defensively and that nothing shipped produces.
|
||||
|
||||
**Re-graded on this base.** All 29 files green. `app/price.py` is byte-identical
|
||||
to the TARIFF-10 merge (`90d8ca31f7c0109d`, 85953 B) because TARIFF-12 does not
|
||||
touch it. **M1** re-driven: +68 B, red on the same **24 named checks** across
|
||||
both files. **M3** re-driven: **+0 B**, red on the same 4. This branch
|
||||
contributes **zero diff** to `app/main.py` and `app/static_tariff.py`.
|
||||
@@ -0,0 +1,166 @@
|
||||
# TARIFF-15 — the operator's own tariff card, and why the card has to grade it
|
||||
|
||||
**Status: DECIDED 2026-09-13 — ship `custom_formula` with FIXED coefficients and
|
||||
a REQUIRED `card` block whose printed price the add-on reproduces at startup or
|
||||
refuses to run. No expression evaluation, ever. Declare `{ENERGY, MARKUP}`,
|
||||
never `ALL_IN`.**
|
||||
|
||||
Filed with TARIFF-15. Follows [[TARIFF-07]], which withdrew three per-supplier
|
||||
plugins and named a formula provider as the answer it could not give.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why a formula rather than more plugins
|
||||
|
||||
TARIFF-07 established the negative half: a per-supplier plugin has no source
|
||||
for its numbers. This card establishes the positive one.
|
||||
|
||||
* **There is no machine-readable source of Belgian tariff coefficients
|
||||
anywhere.** CREG's consumer tariff-card pages 404. CREG Scan is a
|
||||
JavaScript-only wizard. The annual report carries the supplier list and no
|
||||
per-product coefficients. VREG was unreachable. Every coefficient found in
|
||||
research came from a supplier's **own PDF**, one of them image-only.
|
||||
* So there is nothing to scrape, and fourteen plugins would still have no
|
||||
source for their numbers — they would be fourteen copies of the invention
|
||||
TARIFF-07 deleted three of.
|
||||
* **The customer is the only party who can see their own coefficients**, and
|
||||
the card they read them from **is the only artefact that can grade them**.
|
||||
That second clause is the whole design; everything below follows from it.
|
||||
|
||||
## 2. The shape is the market's published arithmetic
|
||||
|
||||
Both suppliers whose 2026 cards were readable print three things on one line:
|
||||
the formula, the reference index it assumes, **and the resulting price**.
|
||||
|
||||
| | computed | the card prints |
|
||||
|---|---|---|
|
||||
| Eneco `(0.102 × 133.9905 + 1) × 1.06` | **15.5471** | **15,55** |
|
||||
| ENGIE `(0.1 × 61.96 + 0.9) × 1.06` | **7.5218** | **7,522** |
|
||||
|
||||
Two independent suppliers, reproduced to the last printed digit, through the
|
||||
shipped code (`test_price.py` 15a runs both, and then runs both again end to
|
||||
end through `load_prices()`).
|
||||
|
||||
That third column is not a nicety. It is the only externally-published number
|
||||
in this whole feature, and it is what makes the design safe.
|
||||
|
||||
## 3. The double-counting risk, and which half of it is new
|
||||
|
||||
`price.py`'s trap 1 already mechanises that a **plugin** cannot double-count:
|
||||
`PluginOptions` refuses the framework keys, `code_leaks()` refuses a plugin
|
||||
whose *code* mentions them, `assemble()` adds the complement exactly once.
|
||||
|
||||
`custom_formula` introduces a risk none of those can see, **because it is not
|
||||
in code**: the **operator** double-counting, by folding their network charges
|
||||
into `offset` while `price_taxes_eur_kwh` also carries them. `code_leaks()`
|
||||
scans code. This is config, typed by a person reading a PDF.
|
||||
|
||||
It is a real reading of a real card, not a hypothetical. A Belgian card's
|
||||
`Tariefformule` line is the **energy component alone** — Eneco tabulates
|
||||
network tariffs in one table and levies in a second; ENGIE states in so many
|
||||
words that the formula excludes network, distribution, transport, grid levies
|
||||
and excise. An operator who wants "the price I actually pay" and adds ~12
|
||||
c€/kWh to `offset` has done something entirely reasonable and is now billed for
|
||||
network charges twice, on every hour, with §11.4 making the whole class of
|
||||
price defect invisible.
|
||||
|
||||
**Decision: the card grades it.** At startup `validate.py` recomputes the
|
||||
card's own printed price from the operator's coefficients and refuses to start
|
||||
unless the two agree to within `0.01` — which is the last digit a card prints.
|
||||
An inflated `offset` cannot pass, because the printed estimate is by
|
||||
construction the energy component: 15.55 becomes 28.27 and the add-on stops and
|
||||
says why.
|
||||
|
||||
**And the limit, stated in the same sentence as the claim**, exactly as
|
||||
`price.py` states trap 1's: *it grades the coefficients against the card, not
|
||||
the card against reality.* A consistent-but-wrong pair — both numbers off the
|
||||
wrong product, or off a card the contract has since rolled over from — agrees
|
||||
with itself perfectly and passes. Nothing here or anywhere else can catch that.
|
||||
|
||||
### 3b. The one unit error the card check cannot see
|
||||
|
||||
A factor applied to **both** sides — coefficients *and* printed price — cancels
|
||||
out of a comparison between them. That is a second quantity, so it is a second
|
||||
refusal: the card's own point must land inside 0.02–1.00 EUR/kWh.
|
||||
|
||||
⚠️ **Its floor is applied to the ENERGY figure and not to the all-in one, and
|
||||
that asymmetry was found by a mutation rather than by reasoning.** The shipped
|
||||
`price_taxes_eur_kwh` was 0.06 — three times the floor — so a card read a
|
||||
hundred times too small comes out at 0.0016 EUR/kWh of energy, is carried over
|
||||
0.02 by the flat tax addend alone, and passes a band applied to the total. The
|
||||
hundredfold error is exactly what the band exists for. Graded from both
|
||||
directions in `test_price.py` 15e/15i and driven red as M10. *(Annotated
|
||||
2026-09-13: TARIFF-18 raised the default to 0.11736, nearly six times the
|
||||
floor. The asymmetry is unchanged in kind and larger in degree — the flat
|
||||
addend that carries a hundredfold-small card over 0.02 got bigger.)*
|
||||
|
||||
## 4. Fixed coefficients. No expression evaluation of any kind
|
||||
|
||||
**Decided: never a grammar.** Two arguments, and the second is the one that
|
||||
decides it.
|
||||
|
||||
* **Security.** The add-on serves `POST config/save` → `POST
|
||||
/addons/self/options` on its own ingress page, so `price_formula` is
|
||||
**settable over HTTP**. `eval` on it is remote code execution in a root
|
||||
container holding `SUPERVISOR_TOKEN`, with `homeassistant_api` and
|
||||
`hassio_api`, driving an inverter over RS485. `eval(s, {"__builtins__": {}})`
|
||||
is not a sandbox and this repository is not going to pretend otherwise.
|
||||
* **⚠️ AND THE DECIDING ARGUMENT IS NOT SECURITY: AN EXPRESSION IS
|
||||
UNVERIFIABLE.** Two coefficients can be graded against the card's printed
|
||||
estimate. An arbitrary expression cannot be graded against **anything** — so
|
||||
it makes the invisible-wrong-coefficient problem *strictly worse*, which is
|
||||
the exact failure this provider exists to prevent. An expression would buy
|
||||
the operator freedom to be wrong in a way nothing could report.
|
||||
|
||||
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
|
||||
`B × index + A`, so an expression buys nothing the market needs either.
|
||||
|
||||
**Future shapes become NAMED FIELDS** — `clamp_lo`, `factor_negative`,
|
||||
`offset_bands` — each individually refusable and each individually gradeable
|
||||
against a card. `parse_price_formula()` therefore **refuses an unknown key**
|
||||
rather than ignoring it: an operator who types `"expr"` is told it does not
|
||||
exist, instead of being priced on the fields they also typed with the one they
|
||||
believed in dropped in silence.
|
||||
|
||||
`test_price.py` 15f walks the module's AST for `eval`, `exec`, `compile`,
|
||||
`__import__` and `literal_eval`, and exercises the scanner's **red** direction
|
||||
on every run so it cannot become a scanner that scans nothing.
|
||||
|
||||
## 5. `{ENERGY, MARKUP}`, not `ALL_IN`
|
||||
|
||||
The contract's formula **is** the supplier's markup — that is what `B` and `A`
|
||||
are — but it is not the levies. Declaring `ALL_IN` would lie on behalf of every
|
||||
operator who typed exactly what their card says and would drop
|
||||
`price_taxes_eur_kwh` from every hour: measured at 0.2125 → 0.1555 EUR/kWh.
|
||||
`price_markup_eur_kwh` is therefore **ignored** under this provider, and logged
|
||||
as ignored, which is the framework's existing rule rather than a new one.
|
||||
|
||||
## 6. What was reused rather than rebuilt
|
||||
|
||||
The index path is `generic_dayahead`'s: same EPEX BE feed, same
|
||||
Brussels-calendar range, same columnar parser, same declared egress hosts. The
|
||||
override/range decision moved into one shared `dayahead_feed_url()` that both
|
||||
plugins call, because two copies of that rule would be two places for the
|
||||
short-horizon defect TARIFF-09 fixed to come back in only one of them.
|
||||
|
||||
`resolution: hourly` averages each hour's members. ⚠️ **That is not
|
||||
`_Series.at()`'s hourly assumption returning (TARIFF-14):** it decides how many
|
||||
points are *emitted*; the window each point covers is still derived from its
|
||||
neighbours in `_cover_table()`. A feed that is already hourly averages one
|
||||
member per hour — the identity — so the option cannot silently assume the
|
||||
granularity it was handed. Graded.
|
||||
|
||||
## 7. What this card did NOT do
|
||||
|
||||
* **The injection leg.** Eneco uses `0,102` on consumption and `0,100` on
|
||||
injection, and injection is VAT-exempt where consumption is at 6 % — a second
|
||||
per-contract formula, not a second copy of this one. Separate card.
|
||||
* **The hour-varying network charge that bites Wallonia.** Separate card.
|
||||
* **Raising `price_taxes_eur_kwh` from 0.06 to ~0.12.** Separate card, and an
|
||||
owner's decision. Note §3b: the band's floor is deliberately insensitive to
|
||||
whatever that number becomes. *(Annotated 2026-09-13: that card is
|
||||
**TARIFF-18** and it shipped on this branch at **0.11736** — the Eneco
|
||||
residential card for Flanders, September 2026, incl. 6 % VAT, summed from
|
||||
Fluvius Antwerpen distribution 5,35 + bijzondere accijns 4,876 + bijdrage
|
||||
groene stroom en WKK 1,51 c€/kWh. §3b held: the floor is still on the energy
|
||||
figure and nothing in this module needed a new number.)*
|
||||
@@ -0,0 +1,182 @@
|
||||
# TEL-10 — the loop's integrator is published, as three site rows
|
||||
|
||||
**Status:** decided and implemented on branch `TEL-10`.
|
||||
**Card:** TEL-10 — *"`self.i_w` is carried between cycles and published nowhere"*.
|
||||
**Nothing here reached the inverter, and nothing here can.** `app/control.py`
|
||||
is not touched by this branch; `compute()`, its returned `Decision` and every
|
||||
constant it reads are byte-identical to `release/1.0`. The diff is a controller
|
||||
recording three values it already had, and a publisher announcing three rows.
|
||||
|
||||
---
|
||||
|
||||
## 0. The three things a reader should take away
|
||||
|
||||
1. **The row that matters is the one that is usually indistinguishable from
|
||||
`Setpoint`.** With nothing binding, the command *is* the integrator rounded
|
||||
to 10 W, so on a quiet day publishing the wrong one looks correct. §2 is
|
||||
the fixture that separates them by kilowatts, and §4 is the mutation that
|
||||
proves the separation is what the check reads.
|
||||
2. **`Loop reason` is a genuinely new sentence, not a second copy of one.** The
|
||||
status page's `Why` row and `sensor.goodwe_controller_status` both carry the
|
||||
*arbiter's* line (`loop -> -5000 W`), which prints the winning claim's NAME.
|
||||
`compute()`'s own reason has never left the process, and `slew-limited` — the
|
||||
value SAFETY-48 is about — appears in neither of the other two. §3.
|
||||
3. ⚠️ **Three entities are three entities.** This is an addition to a live
|
||||
install's entity list, which TEL-07 refused to make for four per-unit rows
|
||||
at a roster of one. The argument for making it here is in §5, and so is the
|
||||
argument that keeps it to three rather than to one per inverter.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this is a card at all
|
||||
|
||||
`self.i_w` has been a separate quantity from the command since SAFETY-04, which
|
||||
split the accumulator out of the velocity form precisely so the two could be
|
||||
bounded apart. Since then it has been:
|
||||
|
||||
* carried between cycles by `Controller.cycle()`,
|
||||
* read by nothing outside `compute()`,
|
||||
* and written to no entity, no MQTT topic, no attribute and no log line.
|
||||
|
||||
The consequence is not theoretical. The investigation behind SAFETY-48 needed to
|
||||
know what the integrator was doing during the export episodes and could only
|
||||
**reconstruct** it, from the grid trace plus a replay of the real `compute()`.
|
||||
The reconstruction reproduced about a third of the engagements it was checked
|
||||
against and was **discarded entirely** — so the card it was gathered for had to
|
||||
be argued from a measurement nobody could stand behind.
|
||||
|
||||
⚠️ **And the reconstruction could not have been made to work.** `i_w` at a given
|
||||
instant depends on every cycle before it, including every one in which the
|
||||
arbiter overrode the loop and `main.py` re-seeded `self.i_w = self.target`. That
|
||||
re-seed is invisible in the grid trace. A replay can only recover the integrator
|
||||
on a run where the loop won every arbitration, which is not the run anybody is
|
||||
investigating.
|
||||
|
||||
## 2. What is published, and against what
|
||||
|
||||
Three rows, on the SITE device, appended after `release/1.0`'s eight sensors:
|
||||
|
||||
| key | entity | what it is |
|
||||
|---|---|---|
|
||||
| `loop_i` | `sensor.goodwe_loop_integrator` | `Decision.i_w` — the accumulator AFTER this cycle, BEFORE the output clamp, the slew limit and quantisation, which is what its own docstring says it is |
|
||||
| `loop_lead` | `sensor.goodwe_loop_integrator_lead` | `decision.i_w - prev_w`, where `prev_w` is the command `compute()` was handed |
|
||||
| `loop_reason` | `sensor.goodwe_loop_reason` | `Decision.reason`, unmodified |
|
||||
|
||||
⚠️ **`loop_lead` IS NOT A SUBTRACTION OF THE OTHER TWO ROWS, WHICH IS WHY IT IS
|
||||
A ROW.** `Setpoint` is the ARBITER's output, after every other strategy has had
|
||||
its say, and it is THIS cycle's; the lead is measured against the command the
|
||||
PREVIOUS cycle left standing. On any cycle where the arbiter overrode the loop,
|
||||
subtracting the two published rows gives a different number — and that is
|
||||
exactly the cycle on which `main.py` re-seeds the integrator, i.e. the cycle a
|
||||
reader most needs the real figure for.
|
||||
|
||||
`cycle()` therefore records all three from the one `compute()` call, and names
|
||||
the command it passes (`loop_prev`) rather than re-reading `self.target` at
|
||||
publish time. `self.target` is reassigned later in the same method, so a lead
|
||||
computed at publish time would subtract a different cycle's command and call the
|
||||
answer a lead. ⚠️ **At the line as written the two expressions are the same
|
||||
value, and the mutation that swaps them is a recorded SURVIVOR** — see §4 M2.
|
||||
The name is kept for the coupling, not for a behaviour it currently buys.
|
||||
|
||||
### The not-running branch
|
||||
|
||||
`cycle()`'s `else` arm — stopped AND shadow mode off — already zeroes
|
||||
`self.i_w`, because no accumulation may survive the outage. The three rows are
|
||||
zeroed and `loop_reason` becomes `not-running` on the same branch. A row that
|
||||
kept repeating the last integrator there would say the loop was winding while
|
||||
nothing is being controlled.
|
||||
|
||||
⚠️ **`auto: false` ALONE IS NOT THAT BRANCH.** The shipped `shadow_mode: auto`
|
||||
means a stopped controller still runs the whole decision path and records it
|
||||
(SAFETY-10), so the loop is live and the integrator is real. The first draft of
|
||||
the check asserted `not-running` about a loop that was running; it reads the
|
||||
shadow case as its own claim now.
|
||||
|
||||
## 3. Why `loop_reason` is not the row that already exists
|
||||
|
||||
`self.reason` is `Resolution.explain()`:
|
||||
|
||||
```
|
||||
"loop -> -5000 W" # winner, watts
|
||||
"loop -> -5000 W, limited by soc" # ...and which limit bit
|
||||
"CONTRADICTION between limits - commanding 0 W (loop)"
|
||||
```
|
||||
|
||||
`Claim.set("loop", P_LOOP, decision.target_w, decision.reason, ...)` does carry
|
||||
the loop's reason INTO the arbiter — and `explain()` prints the winner's
|
||||
**source name**, never the claim's reason. So the loop's own word has always
|
||||
been built, handed over, and dropped one line later.
|
||||
|
||||
Measured on the fixture in `test_telemetry.py` part 2, same cycle:
|
||||
|
||||
```
|
||||
the arbiter's sentence: 'loop -> -5000 W'
|
||||
the loop's own word : 'i-clamped'
|
||||
```
|
||||
|
||||
## 4. Verified by breaking it
|
||||
|
||||
Nine mutations, run in the CI image on a purged tree, each fingerprinted before,
|
||||
after, and again after the revert. The table with byte deltas and red counts is
|
||||
in `test_telemetry.py`'s header; two entries are worth repeating here.
|
||||
|
||||
**M1 — `self.loop_i_w = loop_prev` (−3 B), the card's own named mutation:
|
||||
publish the COMMAND under the integrator's name.** The first draft of the
|
||||
acceptance check was *"the published integrator reaches its bound"*, and that
|
||||
check was **GREEN** under M1 — because the command reaches the bound too, twelve
|
||||
cycles later. MUTATION.md habit 4 in the check written to close the card. It
|
||||
asserts the **lead time** now (the integrator is at the bound at least ten
|
||||
cycles before the command is), and is red on 5 named checks.
|
||||
|
||||
**M2 — `decision.i_w - self.target` (+2 B) is a SURVIVOR, and is reported as
|
||||
one.** At that line `cycle()` has not yet executed `self.target =
|
||||
resolution.target_w`, so the two expressions denote the same value and the
|
||||
mutation changes nothing that runs. The run was purged, which is not the
|
||||
direction stale bytecode fabricates. M2b (`decision.i_w - decision.target_w`,
|
||||
+8 B) is the mutation that does change the value and is red.
|
||||
|
||||
## 5. What was rejected
|
||||
|
||||
**Publishing as an MQTT attribute on an existing entity.** There is no
|
||||
`json_attributes_topic` anywhere in this add-on; adding one would be a second
|
||||
convention for one card, and HA's recorder keeps long-term statistics for a
|
||||
state with `state_class`, not for an attribute. The reason this card exists is
|
||||
that nobody had history to read.
|
||||
|
||||
**A status-page row instead.** The page is not recorded. `Why` already carries
|
||||
the arbiter's sentence and stays that way; DOCS.md gains the section that says
|
||||
how to read the three entities.
|
||||
|
||||
**One integrator row per inverter.** §6.2's architecture is ONE integrator on
|
||||
the P1 meter whose output is then SPLIT. A per-unit copy would invent N of a
|
||||
thing there is one of, and the two copies would disagree the moment the
|
||||
allocator gave the units different shares. `test_mqtt.py` part 5 grades this at
|
||||
every roster length from 1 to 6, and the mutation that moves `loop_i` into
|
||||
`UNIT_SENSORS` is red on 8 named checks.
|
||||
|
||||
**Adding the rows at N≥2 only, the way TEL-07 gates its per-unit rows.**
|
||||
TEL-07's gate exists because at a roster of one its four rows carry information
|
||||
the site rows already carry exactly. These three carry information NOTHING
|
||||
carries, at every roster length, including one.
|
||||
|
||||
**A `## Unreleased` block in `CHANGELOG.md`.** `test_version_gate.py` part 11b
|
||||
refuses a changelog whose newest `## ` heading is not `config.yaml`'s
|
||||
`version:` — PKG-14's rule that the bump and the closed section are one act.
|
||||
Opening a section here would mean bumping the version, which is a packaging
|
||||
decision this card does not get to take. The operator-facing account is in
|
||||
DOCS.md; the release note belongs to whichever PKG ticket cuts the next
|
||||
version, and this file is what it should be written from.
|
||||
|
||||
## 6. What this card does NOT establish
|
||||
|
||||
* **It says nothing about whether the integrator's behaviour is right.** That is
|
||||
SAFETY-48. This branch adds an observable and changes no decision; the
|
||||
15.7-day replay that grades the behaviour is in that card.
|
||||
* **The three rows do not exist on the live install until the add-on is
|
||||
updated**, and this branch does not update it. `tools/rehearsal/announced-
|
||||
reference.txt` (what the BUILD announces) records them; `live-entities-
|
||||
recorded.txt` (what the INSTALL has) deliberately does not, because an id the
|
||||
build announces and the install lacks is a new entity, not an orphan.
|
||||
* **`loop_lead` is signed and is not a distance.** Where the integrator and the
|
||||
command have opposite signs it is the sum of two magnitudes. A reader wanting
|
||||
"how far behind is the command" should take the magnitude and say so.
|
||||
@@ -0,0 +1,351 @@
|
||||
# TEL-11 — the PV production channel
|
||||
|
||||
*What was decided, what it was decided against, and what the evidence was.*
|
||||
|
||||
## 0. The problem, in OPT-10's numbers
|
||||
|
||||
Over sixteen days at the reference site the pack reached 90 % SoC on **two** of
|
||||
them, and **20.5 kWh of surplus left the site while the pack still had room** —
|
||||
**12.6 kWh** of it with the battery **not charging at all**. That single
|
||||
measurement is upstream of most of what else was found that week: the nightly
|
||||
floor, SAFETY-37's misdiagnosis, the SAFETY-57 deadlock, a 2 h 47 min saturation
|
||||
freeze.
|
||||
|
||||
It cannot be **attributed**, because **PV production is recorded nowhere in this
|
||||
system**. Three causes need three different fixes:
|
||||
|
||||
| cause | fix |
|
||||
|---|---|
|
||||
| there was no irradiance | none — the site behaved correctly |
|
||||
| production was there, charge power was limited | a limit, somewhere |
|
||||
| production was there, the pack had room, the optimiser declined | **the only software defect** |
|
||||
|
||||
Guessing between them is how an optimiser gets tuned to solve a cloud problem.
|
||||
**This card adds the channel and does not do the attribution.** Ship it, let it
|
||||
record a week, OPT-10 picks it up with data.
|
||||
|
||||
## 1. It is an HA entity, not a register — and that is a product decision
|
||||
|
||||
The RS485 bus this add-on speaks carries the **battery** inverter it drives. On
|
||||
nearly every site the PV inverter is a different box from a different vendor,
|
||||
reporting into Home Assistant through its own integration; it is **not** on this
|
||||
bus and there is no reason it would be. So the signal comes from an entity:
|
||||
`pv_entity` takes whatever `sensor.*` the site's PV integration provides, any
|
||||
brand HA can read.
|
||||
|
||||
Reading a register would have been cheaper on one house and wrong for a fleet —
|
||||
the trade [[product-not-one-house]] names.
|
||||
|
||||
## 2. The sign convention: **positive is production**, and there is no `pv_invert`
|
||||
|
||||
### The convention is derived, not assumed
|
||||
|
||||
Two sign rules already ship, and both are recorded in `DOCS.md`'s *Sources*
|
||||
table rather than inferred here:
|
||||
|
||||
* `meter_entity` — **positive must mean importing** (`meter_invert` flips it);
|
||||
* `batt_entity` — **`+` = discharging** (`batt_invert` flips it).
|
||||
|
||||
Both are *"positive = power arriving at the house's AC bus"*. Production arrives
|
||||
there too, so **positive = producing** is the continuation of the shipped
|
||||
convention rather than a fresh choice.
|
||||
|
||||
### Why an invert flag would be over-building
|
||||
|
||||
The question is not "could a sensor be reversed" — it could. It is what a flag
|
||||
would **buy**, and the answer is nothing, for two reasons that do not apply to
|
||||
the two keys it would sit beside:
|
||||
|
||||
1. **Those two quantities are read by the control path; this one is read by
|
||||
nothing.** `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
|
||||
circulating-power sensor (`_read_unit()`'s own docstring says so), so a
|
||||
reversed battery sensor hands the site a plausible number it then **acts**
|
||||
on — a latch that trips when it should not, or does not trip when it should.
|
||||
`pv_w` reaches no decision at all (§4), so a reversed PV sensor cannot
|
||||
mislead one.
|
||||
2. **Production is unipolar, so the error is self-announcing.** Battery power
|
||||
and grid power are legitimately negative half the time, which is exactly why
|
||||
a flipped sign is invisible in them. A PV array does not consume: a reversed
|
||||
sensor reads **−4 kW at noon**, in the first day of the very history this card
|
||||
exists to create. An invert flag would let an installer *hide* that by
|
||||
flipping it to something that looks right, and a second place for the sign to
|
||||
be wrong is a second thing to get wrong.
|
||||
|
||||
So: the convention is documented in `config.yaml`, both translations, `DOCS.md`
|
||||
and `app/pv.py`, the value is published **exactly as the sensor reports it**,
|
||||
and there is no flag. `test_pv.py` part 1 pins `-8 W` through unchanged.
|
||||
|
||||
### And no negative-value alarm either
|
||||
|
||||
The tempting middle path — warn when the reading is negative — needs a
|
||||
threshold, because a PV inverter's own standby draw makes a small negative
|
||||
reading at night entirely ordinary. Any threshold here would be a number nobody
|
||||
measured, in a card whose whole subject is not fabricating numbers. The
|
||||
published row is the detector.
|
||||
|
||||
## 3. Absence is never zero, and it is not "dropped" either
|
||||
|
||||
This is the constraint that shaped the publication, and the obvious
|
||||
implementation fails it.
|
||||
|
||||
`MqttPublisher.publish()` **skips** a `None` value. That is right for
|
||||
`circulation` — its note in `main.py` says a missing per-unit reading should
|
||||
leave the last figure standing rather than publish a zero that reads as health —
|
||||
and it is **exactly wrong here**: a skipped row leaves Home Assistant holding
|
||||
the last number it was told, *forever*, and the recorder then draws a **flat
|
||||
line at that value through the outage**. Read back a week later that is
|
||||
indistinguishable from a real measurement, and if the last value happened to be
|
||||
small it manufactures precisely the *"there was no surplus to store"* conclusion
|
||||
the card exists to **test**.
|
||||
|
||||
So TEL-11 publishes the absence, using Home Assistant's own mechanism:
|
||||
|
||||
> `homeassistant/components/mqtt/const.py`: `PAYLOAD_NONE = "None"`.
|
||||
> `homeassistant/components/mqtt/sensor.py`, in the state-message handler:
|
||||
> `if payload == PAYLOAD_NONE: self._attr_native_value = None; return` — and it
|
||||
> is tested **before** the numeric branch, so it holds for a row carrying
|
||||
> `device_class: power`.
|
||||
> (github.com/home-assistant/core, branch `dev`, retrieved 2026-09-24. The
|
||||
> shipped MQTT-sensor documentation says the same thing in its `state_topic`
|
||||
> entry: *"a 'None' value will set the sensor to an unknown state"*.)
|
||||
|
||||
The entity therefore goes to **`unknown`** and the recorder stores a **gap**.
|
||||
`mqtt.ABSENT_AS_UNKNOWN` is a one-key set and `test_pv.py` part 3 asserts both
|
||||
that the pv row gets this treatment **and that `circulation` still does not** —
|
||||
widening the rule silently would be a behaviour change to another card's row.
|
||||
|
||||
⚠️ **What was NOT built, and why.** A per-entity `availability` block
|
||||
(`availability_mode: all`, an extra retained topic) would also produce a
|
||||
distinguishable state — `unavailable` rather than `unknown` — but it needs a
|
||||
second topic, a second retained message, a change to `_payload()`'s
|
||||
byte-order-sensitive key construction, and it survives restarts only if the
|
||||
retention is right. The payload route is one branch in `publish()` and one
|
||||
constant, and it is graded as **bytes on the topic** rather than as a claim
|
||||
about HA's availability handling.
|
||||
|
||||
### Three states, not two
|
||||
|
||||
| state | frame | wire | page |
|
||||
|---|---|---|---|
|
||||
| no source (`pv_entity` unset) | no `pv` key | no topic, no entity | no row |
|
||||
| no reading | `pv: None` | `goodwe_ctl/pv` = `None` | `PV —` + a caution naming the reason |
|
||||
| zero watts | `pv: 0.0` | `goodwe_ctl/pv` = `0.0` | `PV 0 W` |
|
||||
|
||||
A failed read also **clears** the last good value (`_read_pv()` assigns on every
|
||||
call), so a reading never outlives its own sensor. `test_pv.py` part 2 drives a
|
||||
good read followed by a dead one, because a fixture that answers the same thing
|
||||
forever cannot reach that failure — MUTATION.md habit 9's shape.
|
||||
|
||||
## 4. It must not become a control input, and that is graded twice
|
||||
|
||||
### In the suite
|
||||
|
||||
`test_pv.py` part 6 drives twelve cycles of the real `Controller.cycle()`
|
||||
against a 6 kW export and asserts the commands written to the inverter are
|
||||
**bit-identical** across `unset` / `no reading` / `0 W` / `8 kW` of production,
|
||||
with a precondition check that the stimulus commands something at all (habit 1).
|
||||
`inputs_ok` is asserted not to move either: a broken PV integration is **not** a
|
||||
missing input, because nothing computes with it.
|
||||
|
||||
A computed census (habit 12 — the scope is derived from the tree, not typed)
|
||||
then reports which of the 33 modules under `app/` mention the material in any
|
||||
spelling, comments included. It is **three**: `pv.py`, `main.py`, `mqtt.py`. The
|
||||
scanner's own red direction is exercised on every run.
|
||||
|
||||
### Across trees, by hash
|
||||
|
||||
`tools/safety56_behaviour_trace.py` is the pattern; `tools/tel11_pv_trace.py` is
|
||||
this card's version of it, and **its control clause had to be different**, which
|
||||
is worth stating because the difference is the interesting part.
|
||||
|
||||
SAFETY-56 changed wording and had to show behaviour did not move, so its
|
||||
`--compare` **refuses** unless the `sentences` halves **differ** — otherwise the
|
||||
two runs might be one tree twice. TEL-11's headline claim is the opposite shape:
|
||||
with the option unset, **nothing** differs. "The documents are identical" is the
|
||||
*result*, so it cannot also be the control. The two things proved instead are:
|
||||
|
||||
* the two runs came from **different trees** (`app/*.py` sha256 + byte count,
|
||||
compared, and an identity is **refused**);
|
||||
* **exactly one** side reacts to `pv_entity` — that side is the HEAD, the other
|
||||
is a tree that does not have the option. Neither, or both, is refused.
|
||||
|
||||
It drives 41 stimulus cells (5 grid worlds × 8 PV schedules, plus a run stopped
|
||||
part-way) and prints two documents per setting: `control` — every command
|
||||
written, every `self.target`, `inputs_ok`, the loop's three fields, the
|
||||
arbiter's resolution, the maintenance phase, tick by tick, **with no TEL-11
|
||||
material in it** — and `telemetry`, which is everything the card is allowed to
|
||||
move.
|
||||
|
||||
Measured, in the CI image, on purged trees:
|
||||
|
||||
```
|
||||
HEAD is B; baseline is A (it does not react to `pv_entity`).
|
||||
tree A 73296909170ec789237d6e704cce9ab0d9f83b3df1495a14d2497285c3041a12
|
||||
tree B d5011b6a18e2e3803dc09cac0214b0378fa4c7388fc0ad8ad89b832a5c92af48
|
||||
control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
control B set 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
telemetry A unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
|
||||
telemetry B unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
|
||||
telemetry B set 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
|
||||
OK 1/3 UNSET: control AND telemetry are byte-identical to the baseline tree.
|
||||
OK 2/3 SET: the control document is byte-identical with the channel switched on.
|
||||
OK 3/3 CONTROL OF THE CONTROL: the telemetry document DOES move when the
|
||||
option is set (42 stimulus cells differ).
|
||||
```
|
||||
|
||||
Note what clause 1 claims and clause 2 does not: **unset** is identical in
|
||||
`control` *and* `telemetry`, which is stronger than "the control path is
|
||||
untouched" — an install that does not set the option sees no change of any kind,
|
||||
including its announcement and its status rows. **Set** is identical in
|
||||
`control` only, which is the whole of "instrumentation, not an input".
|
||||
|
||||
## 5. Genuinely optional — and not a second `price_now_entity`
|
||||
|
||||
`price_now_entity` / `price_avg_entity` are legal **only** while
|
||||
`price_provider` is `off`, and the add-on **refuses to start** otherwise. That
|
||||
is the trap this card was told not to rebuild, and it is avoided structurally
|
||||
rather than by intention:
|
||||
|
||||
* `validate.py` does not mention `pv_entity` at all. It cannot conflict with
|
||||
anything, because nothing else reads it.
|
||||
* the MQTT row lives in `OPTIONAL_SENSORS`, not `SENSORS`, and
|
||||
`discovery_payloads()`'s new `include` argument **defaults to empty**. So a
|
||||
caller that has never heard of `pv_entity` — every fixture in `test_mqtt.py`,
|
||||
`tools/rehearse_roster.py`, anything written next year — announces exactly
|
||||
what it announced before. `p1_age` solves the same problem the other way
|
||||
round, with `omit`, because it is announced by default and withdrawn when the
|
||||
meter is off; getting that direction wrong here would have left most installs
|
||||
with an entity nothing feeds, which is the hazard `omit`'s own note names.
|
||||
* the read is gated on the option, so an unset install makes **no state call at
|
||||
all** — not a read that fails quietly.
|
||||
* there is **no status row** when it is unset. `peak_forecast_entity` nags when
|
||||
it is unset because an unset forecast leaves a money guard without an input;
|
||||
this is instrumentation, and a permanent caution on every install that never
|
||||
asked for a PV channel is the warning spam the card forbids.
|
||||
* when it **is** set and the read fails, the row is `ok=False, warn=True` — a
|
||||
**caution**. `status_payload()` builds `NOT READY` out of rows that are
|
||||
neither `ok` nor `warn`, so a telemetry channel can never put a battery site
|
||||
into `NOT READY`.
|
||||
|
||||
## 6. The unit is read off the entity, and an unreadable one is refused
|
||||
|
||||
`read_inputs()` guesses kW-or-W for `peak_forecast_entity` with
|
||||
`abs(raw) < 50`, and that is safe there: nobody's quarter-hour demand forecast
|
||||
is 50 W. It is **not** safe here — 30 W of real production at dusk is an
|
||||
ordinary reading, and that heuristic would publish it as 30 kW.
|
||||
|
||||
So `pv.watts()` reads the entity's own `unit_of_measurement` and converts
|
||||
against Home Assistant's own `UnitOfPower` table (`mW`, `W`, `kW`, `MW`, `GW`,
|
||||
`TW`; every factor an exact power of ten). A sensor with **no** unit, or a unit
|
||||
outside that table, is **refused by name** and the status page says which. The
|
||||
refusal is loud; a guess would be silent, and a factor of 1000 in the one
|
||||
channel that exists to be believed a week later is the worse failure.
|
||||
|
||||
⚠️ `BTU/h` is in HA's enum and is deliberately **not** in the table: its factor
|
||||
is a measured conversion rather than a decimal shift, no PV integration
|
||||
publishes in it, and a fabricated constant is what this repository's whole
|
||||
process exists to prevent. It lands in the refusal, by name, and `test_pv.py`
|
||||
part 1 pins that.
|
||||
|
||||
## 7. The mutation battery
|
||||
|
||||
Every check this card adds was driven by the mutation that defeats the claim it
|
||||
names. Method: `cp -a` of the tree per mutation into a **unique** directory,
|
||||
the edit applied there, `__pycache__` purged, the suites run in the CI image
|
||||
with the tree bind-mounted `:ro`, one file at a time. **The shipped tree is
|
||||
never edited**, so habit 18's revert hazard — SAFETY-37's harness killed between
|
||||
the edit and the `cp` that put the file back, and two harnesses sharing one
|
||||
backup path — is closed by construction rather than by a verified revert. Each
|
||||
row below records the byte delta, the file's sha256 before and after, and that
|
||||
the mutated line was **read back off disk** before the run started.
|
||||
|
||||
| # | mutation | file | bytes | sha256 (first 16) before → after | red on |
|
||||
|---|---|---|---|---|---|
|
||||
| M1 | the `pv` row moved into `SENSORS`, i.e. announced on every install | `mqtt.py` | −79 B | `328d249b9d698ef6` → `532d3954c023bf3d` | 3 named in `test_mqtt.py`, 1 in `test_pv.py` |
|
||||
| M2 | `publish()` drops a `None` `pv` row again, so HA keeps the last number | `mqtt.py` | −98 B | `328d249b9d698ef6` → `dd54be458f446475` | 1 named (the acceptance criterion itself) |
|
||||
| M3 | `ABSENT_AS_UNKNOWN` widened past the one key it was written for | `mqtt.py` | +23 B | `328d249b9d698ef6` → `8fe571ebff3dacf4` | 1 named (`circulation` must still be dropped) |
|
||||
| M4 | a dead sensor reported as **0 W** instead of an absence | `pv.py` | −1 B | `55ba7471e9995436` → `9eecc34152b7ac12` | 4 named |
|
||||
| M5 | `_read_pv()` assigns only on a good read — a value outlives its sensor | `main.py` | +46 B | `c4d2cab34e50b5fd` → `189802aa69ce166d` | 2 named |
|
||||
| M6 | the unit is read and then ignored — a kW sensor published as watts | `pv.py` | −18 B | `55ba7471e9995436` → `ab19ef66c52f9379` | 2 named |
|
||||
| **M7** | **`self.grid = self.grid − self.pv_w` in `read_inputs()` — PV wired into the control path** | `main.py` | +106 B | `c4d2cab34e50b5fd` → `c38216edb7c5c8db` | **2 named, plus `tel11_pv_trace.py` clause 2/3 on 26 of the 41 stimulus cells** |
|
||||
| M8 | `optional_keys()` returns `("pv",)` unconditionally | `mqtt.py` | −52 B | `328d249b9d698ef6` → `86ff23efe3088ad8` | 3 named |
|
||||
| M9 | the PV caution made a `bad` row, i.e. a dead sensor is a `NOT READY` | `main.py` | +1 B | `c4d2cab34e50b5fd` → `45c74cab89943564` | 1 named |
|
||||
| M10 | the read gate removed — an unset option is read once a second forever | `main.py` | −10 B | `c4d2cab34e50b5fd` → `b67baa8aba3019e8` | 2 named, **plus two crashes** |
|
||||
|
||||
`on_disk = True` for all ten: the mutated line was grepped back out of the file
|
||||
after the edit and before the run (MUTATION.md reporting rule 2), and no
|
||||
mutation is size-preserving, so rule 1's `+0 B` caveat does not arise.
|
||||
|
||||
⚠️ **M10's two extra reds are CRASHES, not named checks, and that is recorded
|
||||
rather than tidied away.** Every `Hass` double in `test_inverter.py` and
|
||||
`test_telemetry.py` predates this read and has no `state()` method, so an
|
||||
unconditional PV read is an `AttributeError` rather than a failed assertion —
|
||||
habit 20's shape, a red with no name attached. Fixing those fixtures would be a
|
||||
change to what *their* checks report and belongs to whoever next touches them;
|
||||
this card's own fixture has `state()` and grades the property by name.
|
||||
|
||||
⚠️ **M7's trace limb is where the two halves meet.** The mutant moves the
|
||||
control document on 26 of 41 cells — every cell whose PV schedule produces a
|
||||
READING. The five `none` cells and the five `zero` cells do **not** move, which
|
||||
is correct and is worth seeing: subtracting nothing, and subtracting zero,
|
||||
change nothing. A battery that only drove a dead sensor would have reported this
|
||||
mutation as a survivor for the third time.
|
||||
|
||||
The clean tree those `before` hashes belong to: `app/main.py`
|
||||
`c4d2cab34e50b5fd`, `app/mqtt.py` `328d249b9d698ef6`, `app/pv.py`
|
||||
`55ba7471e9995436`.
|
||||
|
||||
### ⚠️ M7 was a SURVIVOR on the first battery, and the fixture was the reason
|
||||
|
||||
The card's **central** mutation — `self.grid = self.grid - self.pv_w` in
|
||||
`read_inputs()`, i.e. exactly the defect the whole card forbids — came back
|
||||
**green** on a six-file run at **+106 B**, on disk, purged, genuinely running.
|
||||
|
||||
The property did not hold; the fixture could not reach it. `test_pv.py` part 6
|
||||
drove a **6 kW export over 12 cycles**, which puts the loop **against its
|
||||
rail**: the command moves `slew_w` every cycle and −6000 W and −14 000 W of
|
||||
apparent error produce the **same ramp, tick for tick**. MUTATION.md habit 23,
|
||||
with a saturated actuator in place of a coinciding value — and the habit's list
|
||||
of no-op-prone constructs is all *expressions*, so this is worth its own entry,
|
||||
which it now has.
|
||||
|
||||
The fixture now drives a **600 W import the loop settles on**, with a
|
||||
responsive meter (`grid = base − batt`) so the error actually closes, asserts
|
||||
the resting target is well inside the rail, and asks habit 23's own question as
|
||||
its own check: **shift the meter by the same 8 kW and confirm the commands
|
||||
really would have moved.** On the first fixture that question answers *no*, in
|
||||
one line. Every number in the table above is from the battery re-run against
|
||||
the fixed tree.
|
||||
|
||||
⚠️ **A red count is a property of the suite as much as of the mutation.** These
|
||||
name **this** tree at this commit; a later card that adds or re-bases a check
|
||||
moves the number with the mutation untouched. Re-measure rather than inherit.
|
||||
|
||||
## 8. What none of this covers, with shapes
|
||||
|
||||
* **Home Assistant itself.** That `None` renders as `unknown` and that the
|
||||
recorder keeps a gap is read out of HA's source and quoted in §3; no HA runs
|
||||
in this suite. What is graded here is the bytes this add-on publishes.
|
||||
* **Whether the operator's sensor tells the truth.** A sensor wired backwards,
|
||||
or reporting DC where AC was meant, or a string instead of the array, is
|
||||
published faithfully. §2 is the argument for why that is the right failure
|
||||
mode; it is not a claim that it cannot happen.
|
||||
* **A roster of more than one.** The row is a **site** row — there is one PV
|
||||
array as far as this add-on is concerned — so nothing per-unit exists to
|
||||
separate, and every fixture is the shipped roster of one.
|
||||
* **The attribution.** This card records production. Whether the 20.5 kWh was
|
||||
cloud, a limit or the optimiser is OPT-10's, and it needs a week of this
|
||||
channel's history before it can be asked.
|
||||
* **`tools/`, the firmware and the ingress HTML** are outside part 6's census.
|
||||
* **The identity is only as wide as the stimulus.** `tel11_pv_trace.py` prints
|
||||
its 41 cells with the documents for exactly that reason.
|
||||
|
||||
## 9. No `CHANGELOG.md` entry
|
||||
|
||||
`test_version_gate.py` 11b refuses a newest `## ` heading that is not
|
||||
`config.yaml`'s `version:`, and opening an `## Unreleased` section would mean
|
||||
taking a packaging decision this card does not get to take. The operator account
|
||||
is in `DOCS.md`; the release note belongs to the next PKG ticket, which should
|
||||
say that **0.10.1 → next** adds one optional key, announces no new entity on an
|
||||
install that does not set it, and changes no control behaviour.
|
||||
@@ -0,0 +1,359 @@
|
||||
# TEL-12 — several PV inverters, and why a partial total is worse than none
|
||||
|
||||
TEL-11 shipped `pv_entity` as a single `str?` in 0.11.0. The owner of the
|
||||
reference site reported **within minutes of it going live** that they have
|
||||
several PV inverters and could configure one.
|
||||
|
||||
That is not a property of one house. A multi-string or multi-orientation roof
|
||||
routinely carries two or three inverters, each publishing its own power sensor
|
||||
into Home Assistant, and the add-on is sold to clients. So the channel had to
|
||||
become a roster.
|
||||
|
||||
**OPT-10 is blocked on this and cannot start recording until it lands.** Which
|
||||
sets the bar: the number this channel publishes has to be one an attribution can
|
||||
believe, or the week of history OPT-10 is waiting for is worse than no history.
|
||||
|
||||
---
|
||||
|
||||
## 1. The surface: `pv_entities` beside `pv_entity`, and the measurement that
|
||||
decided it
|
||||
|
||||
Two candidates, and they differ on exactly one question nobody in this
|
||||
repository could answer by reading: **does Supervisor accept a stored STRING for
|
||||
a key whose schema has become a LIST?** 0.11.0 shipped `pv_entity: str?`; the
|
||||
live install *has* a string persisted in it; and an upgrade that refuses that
|
||||
string leaves an add-on which will not start — CFG-04 §B's trap, in the other
|
||||
direction, with the schema needed to fix it pinned to the version that cannot be
|
||||
installed.
|
||||
|
||||
It was measured, against the release image, by a new **§G** of
|
||||
`tools/supervisor_shape_probe.py` — the instrument CFG-04 built for exactly this
|
||||
kind of question:
|
||||
|
||||
```
|
||||
docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" --entrypoint python3 \
|
||||
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
||||
/probe/supervisor_shape_probe.py
|
||||
```
|
||||
|
||||
```
|
||||
== G. TEL-12: the SHAPE of a several-inverter PV option ==
|
||||
PASS the shipped defaults with a 0.11.0 `pv_entity` STRING on top are ACCEPTED - the baseline every refusal below is varied from
|
||||
pv_entity='sensor.pv_power' pv_entities=[]
|
||||
PASS ⭐ THE MEASUREMENT THAT DECIDED THE SURFACE: with `pv_entity` declared as a LIST, the string an 0.11.0 install has persisted is REFUSED, and the refusal names the key - so that shape would leave the live add-on unable to start on upgrade
|
||||
Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS ...and a shipped `pv_entity: []` DEFAULT does not rescue it: deepmerge's list strategy is `override`, so the operator's string survives the merge and is refused anyway
|
||||
Invalid list for option 'pv_entity' in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS ...(the list shape itself is fine - it is only the STORED STRING it refuses, which is what makes this a migration and not a schema question)
|
||||
a list value validates
|
||||
PASS ⚠️ ONE SPELLING DOES ACCEPT BOTH - `match(.*)?` - AND IT IS REJECTED FOR A DIFFERENT REASON, MEASURED HERE: it validates NOTHING. A string passes, a list passes, and so does the integer 417; and the Configuration tab renders it as a plain text box rather than a repeatable field, so an operator cannot enter a second inverter in the UI at all
|
||||
string=True list=True integer-417=True ui=[{"name": "pv_entity", "optional": true, "type": "string"}]
|
||||
PASS ⭐ THE SHIPPED SHAPE: an 0.11.0 blob - `pv_entity` a string, no `pv_entities` key at all, because it did not exist when that blob was written - validates against THIS manifest, with the new key defaulted to the empty list. The live install upgrades without touching its options
|
||||
PASS ...and a three-entity roster validates, per-entry, with both keys set - which the add-on resolves by REPLACEMENT and says so on its status page
|
||||
["sensor.pv_east", "sensor.pv_west", "sensor.pv_garage"]
|
||||
PASS ⭐ ...and the shipped `options:` block with ONLY `pv_entities` removed is REFUSED, BY NAME - `Missing option 'pv_entities' in root`. That is why the `pv_entities: []` default is load-bearing, exactly as `inverters: []` is: Supervisor treats a nested list as required unless its element is an OPTIONAL scalar, and `- str` is not one
|
||||
Missing option 'pv_entities' in root in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS ...and the Configuration tab renders it as a REPEATABLE string field (`multiple: true`), so an operator adds their second and third inverter in the UI rather than in YAML
|
||||
[{"name": "pv_entity", "optional": true, "type": "string"}, {"name": "pv_entities", "multiple": true, "required": true, "type": "string"}]
|
||||
PASS a DOWNGRADE to a schema without `pv_entities` drops the key with a warning rather than refusing, and leaves `pv_entity` standing - so rolling back to 0.11.0 returns the install to its single entity rather than bricking it
|
||||
|
||||
== all cases passed ==
|
||||
```
|
||||
|
||||
| Claim | Evidence |
|
||||
|---|---|
|
||||
| **Option 1 — `pv_entity` becomes a list — cannot ship** | §G: `Invalid list for option 'pv_entity'` on the string an 0.11.0 install has stored |
|
||||
| …and a `[]` default does not rescue it | §G: `_OPTIONS_MERGER`'s list strategy is `override`, so the operator's string survives the merge and is refused anyway |
|
||||
| …and it is not a *schema* problem | §G: a list **value** validates fine against a list schema. It is a **migration**, and a migration is a bigger ticket than this |
|
||||
| **One key CAN carry both shapes** — `match(.*)?` | §G, and reported even though it *passes*, because "no spelling accepts both" would have been a **false reason** to reject option 1 |
|
||||
| …and it loses anyway, for a measured reason | §G: it validates nothing at all — a string, a list, and the integer `417` all pass — and the Configuration tab renders it as a plain text box, so an operator cannot add a second inverter in the UI |
|
||||
| **Option 2 — `pv_entities` beside `pv_entity` — ships** | §G: the 0.11.0 blob validates untouched, the new key defaults to `[]`, three entities validate, the UI renders `multiple: true` |
|
||||
| `pv_entities: []` in `options:` is load-bearing | §G: the shipped defaults **minus only that key** are refused with `Missing option 'pv_entities' in root` — CFG-04 §B's isolated counterfactual, for this key |
|
||||
| A downgrade is not the trap in reverse | §G: a schema without the key **drops** it and leaves `pv_entity` standing |
|
||||
|
||||
`p1_phase_import_entities` (`options: []` + `schema: - str`) is the shipped
|
||||
precedent for the pair; nothing new was invented.
|
||||
|
||||
**So the surface is uglier than option 1 and that is the correct trade.** The
|
||||
cleanest surface that breaks the one install this card exists to fix is not the
|
||||
cleanest surface.
|
||||
|
||||
### And `pv_entities` REPLACES `pv_entity` rather than extending it
|
||||
|
||||
CFG-04's rule for `inverters:` ("declared, it REPLACES those six keys"), applied
|
||||
to the only upgrade path that matters. The install being upgraded **has**
|
||||
`pv_entity` set, to inverter A. Opening the Configuration tab, the natural action
|
||||
is to put *all* the inverters in the new repeatable field — and under a union
|
||||
rule that **double-counts A** unless the operator also remembers to clear the old
|
||||
key. A doubled total is the same defect as a partial one pointing the other way,
|
||||
in the one channel that exists to be believed a week later.
|
||||
|
||||
Under replacement the effective roster is exactly what one key says, and no
|
||||
arithmetic depends on a de-duplication rule the operator has to know about. It
|
||||
de-duplicates *within* the list (a site cannot have two sensors with one
|
||||
entity_id, so a repeated id is a typo that would double that inverter), and
|
||||
`Controller.checks()` prints a **caution** whenever both keys are set saying
|
||||
which one won — a replacement nobody is told about is the "one of which silently
|
||||
wins" `config.yaml` refuses elsewhere.
|
||||
|
||||
⚠️ It is a **caution and never a refusal to start**. `validate.py` still does not
|
||||
mention either key. A telemetry channel that could stop a battery site from
|
||||
starting would be observability with a foot in the control path.
|
||||
|
||||
---
|
||||
|
||||
## 2. ⚠️⚠️ The decision the card turns on: absence of ONE is absence of the TOTAL
|
||||
|
||||
Three inverters configured, one `unavailable`. The sum of the other two is **a
|
||||
smaller number indistinguishable from a real one.** Fed into OPT-10's
|
||||
attribution it reads as *"there was less PV than there was"* — which is the "no
|
||||
irradiance" conclusion the channel exists to **test** rather than to manufacture.
|
||||
|
||||
Two designs were genuinely available:
|
||||
|
||||
**(a) publish the total as absent** — `pv.total()` answers `None`, the topic
|
||||
carries HA's own `None`, the entity goes to `unknown`, the recorder stores a gap.
|
||||
|
||||
**(b) publish the partial sum alongside a count of how many contributed.**
|
||||
|
||||
**(a) shipped.** It wins on the one question that matters, which is *who reads
|
||||
the number and when*:
|
||||
|
||||
* The **MQTT row** is read back **cold**, a week later, by OPT-10 and by anything
|
||||
that plots `sensor.goodwe_pv_power` over that week. A partial sum there is a
|
||||
smaller number indistinguishable from a real one, and the count that would
|
||||
have qualified it lives in a **second entity with its own history** — so
|
||||
believing the total requires joining two recorder series by timestamp to find
|
||||
out which samples to distrust. Nobody re-derives that. And it is worse than a
|
||||
gap in exactly the direction that matters: **a gap makes the attribution say
|
||||
"I do not know"; a partial makes it say something false.**
|
||||
* The **status page** is read **live**, by a human who wants the diagnostic. So
|
||||
the partial information is *not* thrown away — the row names every entity that
|
||||
did report, with its watts, and every one that did not, with its reason, and
|
||||
states plainly that there is no site total. Different reader, different
|
||||
instant, different rule.
|
||||
|
||||
A count published beside the total would also carry **no information** in the
|
||||
state (a) admits: the total is published *only* when every entity contributed, so
|
||||
the count would be the constant `len(roster)`.
|
||||
|
||||
And this **extends** TEL-11's rule rather than weakening it. `mqtt.py`'s
|
||||
`ABSENT_AS_UNKNOWN` is still exactly one key and `publish()`'s `None` handling is
|
||||
untouched: the line that turns an absence into HA's `unknown` did not have to
|
||||
change, because "absence is never zero" already covered this one layer up.
|
||||
|
||||
### The mirror, which matters just as much
|
||||
|
||||
A member honestly reporting **0 W** is **summed**, not treated as absent. The
|
||||
test is `watts is None`, never falsiness. Shading, a tripped string and
|
||||
nightfall are all real zeros, and a roster that blanked the site whenever one
|
||||
member read zero would report an absence **on every clear evening** and defeat
|
||||
the channel from the other end.
|
||||
|
||||
### ⚠️ And the answer is NOT "document a template sensor that adds them up"
|
||||
|
||||
An operator may legitimately prefer one, and `pv_entity` will happily point at
|
||||
it. Shipping it as *the* answer pushes the absence problem onto them unseen: a
|
||||
template sensor summing an `unavailable` source silently yields the partial
|
||||
total — the same defect one layer down, in a place this add-on cannot see or
|
||||
report. DOCS.md says so in those terms.
|
||||
|
||||
### The three published states, side by side
|
||||
|
||||
| what happened | frame | `goodwe_ctl/pv` | status page |
|
||||
|---|---|---|---|
|
||||
| no roster at all | no `pv` key | no topic, no entity | no row |
|
||||
| 1200 / 2100 / 450 W, all three | `pv: 3750.0` | `3750.0` | `PV 3 750 W` + one ok row naming all three |
|
||||
| all three answer, one of them `0 W` | `pv: 1650.0` | `1650.0` | `PV 1 650 W` |
|
||||
| one of the three `unavailable` | `pv: None` | `None` → `unknown`, recorder **gap** | `PV —` + a caution naming the missing entity, its reason, and what the others reported |
|
||||
| all three gone | `pv: None` | as above | as above |
|
||||
|
||||
---
|
||||
|
||||
## 3. Still not a control input
|
||||
|
||||
Unchanged, and re-proved at the new roster size.
|
||||
`tools/tel11_pv_trace.py` was **extended rather than replaced** — the file name
|
||||
still says TEL-11 because `DECISIONS/TEL-11-pv-production.md` §5 and
|
||||
`test_pv.py`'s mutation table quote it, and a renamed instrument makes both
|
||||
references dangle for no gain.
|
||||
|
||||
It now produces **three** document sets per tree — `unset`, `one`, `multi` — and
|
||||
grades five claims. The roster cells live in their own document precisely so
|
||||
`unset` and `one` keep the `control` hashes TEL-11 recorded; appending cells to
|
||||
an existing document would have moved a hash whose stability is itself the
|
||||
evidence.
|
||||
|
||||
```
|
||||
$ python3 tools/tel11_pv_trace.py --compare base.json head.json
|
||||
HEAD is B; baseline is A - its roster cells ARE its unset cells, which is what a tree that has never heard of `pv_entities` does with them.
|
||||
supports_roster A False / B True (self-reported; the verdict above is MEASURED)
|
||||
tree A fa92ba6a6fc6d49c0f87a494e03b530a8575212a0b2c3046629cb400ef2ae0d4
|
||||
tree B 0b39198015b6a5a3f7538d4157d77ecadc89311daef18fce883762d94d8351eb
|
||||
control A unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
control B unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
control A one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
control B one 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
|
||||
control A multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
|
||||
control B multi 2aafbfedfb9cc939082b36eeb7493289808fb21cd41113322d4817de0e414a30
|
||||
telemetry A unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
|
||||
telemetry B unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
|
||||
telemetry A one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
|
||||
telemetry B one 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
|
||||
telemetry A multi d269168ffece375027c2c18ea33631a961ff92775781884a0592d6a37d78845c
|
||||
telemetry B multi 550a49a73f39267ba28b1e78cd12b8f5c71ef7269bbbd2e12112696be0fef1ff
|
||||
OK 1/5 UNSET: control AND telemetry are byte-identical to the baseline tree. …
|
||||
OK 2/5 ONE ENTITY: control AND telemetry are byte-identical to 0.11.0's single-entity path - the same commands, the same payloads, the same status rows, the same announcement.
|
||||
OK 3/5 CONTROL, ONE ENTITY: the control document is byte-identical with the channel switched on - PV reaches no decision.
|
||||
OK 4/5 CONTROL, THREE ENTITIES: every roster cell's control document equals its grid world's `unset` one - 30 cells over 5 grid worlds, covering three healthy entities, one dead at EVERY position, all three dead and one honestly reporting 0 W. Neither the total nor any PARTIAL total reaches a decision, and the document is byte-identical to the BASELINE tree's for the same cells.
|
||||
OK 5/5 CONTROL OF THE CONTROL: the telemetry document DOES move under a roster (30 stimulus cells differ from their grid world's unset telemetry), so 1/5-4/5 are identities this instrument could have broken. AND in no cell with a dead member does the PV row carry the partial sum of the survivors, while every cell whose members all ANSWER publishes its total - including the one where a member honestly reports 0 W.
|
||||
$ echo $?
|
||||
0
|
||||
```
|
||||
|
||||
**The control hash is unmoved: `846a10a5b89e…` on `unset` and on `one`, on both
|
||||
trees.** The roster control document `2aafbfedfb9c…` is byte-identical between
|
||||
the two trees, which is the strongest available form of the claim — the baseline
|
||||
runs those cells with a key it has never heard of, so its control document for
|
||||
them *is* 0.11.0's.
|
||||
|
||||
⚠️ **2/5 is a claim TEL-11 could not make.** TEL-11's baseline had no channel at
|
||||
all to be identical to; TEL-12's baseline is 0.11.0, so "one entity behaves
|
||||
exactly as 0.11.0 does" is a real cross-tree identity over **both** documents.
|
||||
`pv.total()` passes a single entity's `(watts, why)` through verbatim for exactly
|
||||
this reason, and `Controller.checks()` keeps 0.11.0's two f-strings in a
|
||||
`len(...) == 1` branch rather than generalising them.
|
||||
|
||||
### ⚠️ TEL-11's `--compare` refusal had to change, and that is a finding
|
||||
|
||||
TEL-11's rule was *"exactly ONE side's `set` document may differ from its own
|
||||
`unset` — that side is the HEAD"*. It was correct evidence when written, because
|
||||
`release/1.0` predated `pv_entity` entirely. `release/1.0` is 0.11.0 now, **both**
|
||||
sides react to `pv_entity`, and that refusal fires on **every honest run**. The
|
||||
discriminator is the roster instead: only the HEAD has heard of `pv_entities`, so
|
||||
only the HEAD's `multi` document fails to reduce to its own `unset` one.
|
||||
|
||||
It is asserted from the **documents**, not from `tree.supports_roster` — a
|
||||
self-report is the tree describing itself, whereas "this side's behaviour did not
|
||||
move when `pv_entities` was set" is the tree being measured. `supports_roster` is
|
||||
printed beside the verdict so a disagreement between the two is visible.
|
||||
|
||||
See MUTATION.md habit 24.
|
||||
|
||||
---
|
||||
|
||||
## 4. Mutation battery
|
||||
|
||||
Twelve mutations, each on a **fresh `cp -a`** of the clean worktree (never
|
||||
reverted — habit 18 / TARIFF-09's method), `__pycache__` purged before the run
|
||||
and the tree checked for one afterwards, byte delta from `stat` and the edit
|
||||
confirmed on disk with `cmp`, suites run in the CI image with `checks.yml`'s own
|
||||
command one file at a time under a `:ro` mount.
|
||||
|
||||
| mutation | bytes | reds |
|
||||
|---|---|---|
|
||||
| N1 `total()` returns the partial sum, not an absence | +42 B | 6 named in `test_pv.py` + 1 crash |
|
||||
| **N1b ⭐ THE REJECTED DESIGN: the partial sum with a contributor count** | −282 B | **10 named + the trace `REFUSED 5/5`** |
|
||||
| N2 absence tested by falsiness (`not w`) | −4 B | 2 named |
|
||||
| N3 `pv_entities` UNIONS with `pv_entity` | −27 B | 3 named |
|
||||
| N4 the de-duplication dropped | −15 B | 1 named |
|
||||
| N5 `sum` → `max` over the roster | **+0 B** | 6 named |
|
||||
| N6 the roster-of-one stops passing `why` through | +13 B | 8 named |
|
||||
| N7 `optional_keys()` back to reading `pv_entity` | −6 B | 1 named + 1 crash |
|
||||
| N8 `self.grid -= self.pv_w` in `read_inputs()` | +106 B | 3 named + the trace (52 cells on 2/5, 26 on 3/5, 10 on 4/5) |
|
||||
| N9 the roster caution made a `bad` row (NOT READY) | +1 B | 1 named |
|
||||
| N10 `entity_is_ignored()` always False | +10 B | 2 named |
|
||||
| N11 `pv_entities: []` deleted from `options:` | −18 B | 2 named in `test_translations.py` + `11 CASE(S) FAILED` in the probe |
|
||||
|
||||
**No survivors.**
|
||||
|
||||
⚠️ **N5 is `+0 B` — the risky delta.** `sum` and `max` are the same three
|
||||
characters, so neither the file size nor its mtime moved, and TEST-15's
|
||||
stale-bytecode defect is exactly what manufactures a survivor there. The run was
|
||||
on a fresh purged copy and `cmp` confirmed the edit on disk before it started.
|
||||
|
||||
⚠️ **N1b is the row that matters, and it is not a defect.** It is the *other*
|
||||
defensible design, implemented and run. The point is that the checks and the
|
||||
instrument can **tell the two apart** — an unexamined partial sum is what the
|
||||
card forbids, so a check that could not see one would be decoration:
|
||||
|
||||
```
|
||||
REFUSED 5/5: THE PARTIAL SUM IS ON THE WIRE at [('EXPORT-6K/EAST-DEAD', '2550.0'),
|
||||
('EXPORT-6K/GARAGE-DEAD', '3300.0'), ('EXPORT-6K/WEST-DEAD', '1650.0'),
|
||||
('IMPORT-3K/EAST-DEAD', '2550.0'), ('IMPORT-3K/GARAGE-DEAD', '3300.0'),
|
||||
('IMPORT-3K/WEST-DEAD', '1650.0')] … - a smaller number indistinguishable from a
|
||||
real one, which is the defect this card exists to prevent.
|
||||
```
|
||||
|
||||
⚠️ **N8's 1/5 stays GREEN and that is correct rather than a miss.** With no PV
|
||||
entity configured `pv_w` is `None` and the leak cannot fire — which is what makes
|
||||
1/5 a claim about the **default** and 2/5–4/5 the claims about the channel.
|
||||
|
||||
### The fixture values are the design (habit 10 and habit 23)
|
||||
|
||||
`test_pv.py` carries **two** rosters over the same three entities, because the
|
||||
two claims need different magnitudes and one fixture cannot serve both.
|
||||
|
||||
* **Parts 8–10: 1200 W + 2.1 kW + 450 W = 3750 W.** The sum is separable from
|
||||
max (2100), min (450), first (1200), last (450), mean (1250), count (3) and
|
||||
every partial of itself (2550 / 1650 / 3300) — the family is computed, printed
|
||||
and asserted non-colliding as its own precondition. One member is in **kW** so
|
||||
"correct against their own units" is exercised. **Not one member is zero**: a
|
||||
member contributing 0 W could go absent without moving the sum of the rest, and
|
||||
a fixture like that cannot tell "published the absence" from "published the
|
||||
partial sum" — habit 23 with a coinciding value in the *fixture* rather than in
|
||||
the plant, which is precisely the trap the brief named. Absence is driven at
|
||||
**every position** of the roster, not at one (habit 10's fixture-as-a-SET), and
|
||||
each position's partial is **derived from the fixture** rather than typed
|
||||
beside it.
|
||||
* **Part 6: 1500 W + 4.5 kW + 2000 W = 8000 W.** Sized so every *partial*
|
||||
(6500 / 3500 / 6000) would still **move** a loop settling on a 600 W import.
|
||||
Habit 23's own question is asked twice — shift the meter by 8000 W, and again
|
||||
by 3500 W, the smallest partial any single dead member could produce — because
|
||||
a leak of the partial is a different mutation leaking a smaller number.
|
||||
|
||||
### Two false results this card produced, and what they were
|
||||
|
||||
Both were in the **instrument**, and both are recorded rather than tidied away.
|
||||
|
||||
1. `partial_w`/`partial_wire` were first written **inside** each telemetry cell.
|
||||
They are a fact about the *stimulus*, produced by the tool and identical on
|
||||
both trees — so every roster cell differed from its unset reference for a
|
||||
reason that was not behaviour, and the HEAD/baseline discriminator fired on
|
||||
*both* sides. They live in a separate top-level `partials` map now.
|
||||
2. The `WEST-ZERO` cell (all three answer, one honestly reporting 0 W) had its
|
||||
**true total** labelled as its "partial". 5/5 then refused the *shipped* tree
|
||||
for publishing 1650 W, which it is required to publish. A cell with no dead
|
||||
member has no partial to leak, and `never` is `None` there.
|
||||
|
||||
---
|
||||
|
||||
## 5. What none of this can see
|
||||
|
||||
* **Home Assistant itself.** That `None` on the state topic renders as state
|
||||
`unknown` and that the recorder keeps a gap is read out of HA's source and
|
||||
quoted in `app/mqtt.py`; whether the long-term statistics table then
|
||||
interpolates across the gap is HA's behaviour, and OPT-10 will be reading it.
|
||||
* **Whether the operator's sensors are telling the truth.** A sensor wired
|
||||
backwards reads −4 kW at noon and is summed as −4 kW. There is deliberately no
|
||||
`pv_invert` — §2 of the TEL-11 record is the argument, and it holds at a roster.
|
||||
* **A roster larger than three.** Three is the smallest size at which the sum
|
||||
separates from the reduction family *and* absence can be driven at every
|
||||
position. Nothing here says what twenty entities do to a cycle's latency — the
|
||||
read is `asyncio.gather`ed across the roster for exactly that reason, so a slow
|
||||
sensor costs what one costs, but that is an argument rather than a measurement.
|
||||
* **A PV roster combined with a two-inverter battery roster.** No fixture
|
||||
crosses CFG-04's `inverters:` with `pv_entities`.
|
||||
* **The real MQTT transport, and wall-clock behaviour.** As TEL-11.
|
||||
|
||||
---
|
||||
|
||||
## 6. Not deployable as merged
|
||||
|
||||
`config.yaml` stays at `0.11.0`, so `tools/predeploy_check.py` reports a NO-OP
|
||||
until PKG cuts a version. A CHANGELOG entry is **wanted** and was deliberately
|
||||
not written: `test_version_gate.py` 11b forbids an `## Unreleased` heading and
|
||||
the bump is PKG's call. The material an entry needs is this file.
|
||||
|
||||
The option surface moves **76 keys → 77**, one new optional key, `pv_entities`,
|
||||
empty on every install that does not set it.
|
||||
+1203
-39
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,43 @@ Two rules come out of it, and both are cheap:
|
||||
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.
|
||||
|
||||
⚠️ **AND FCST-05 WALKED INTO IT AGAIN, IN ITS OWN NEW CHECK, WHICH IS WHY THE
|
||||
RULE IS “mutate every check you add”.** `price_history.py` reports a malformed
|
||||
point row from **two** places into **one** field: `_usable_days()` records what
|
||||
it FOUND when the file was opened, and `_remember()` records what it DROPPED on
|
||||
the next refresh. Both land in `last_error`, and `words` prints `last_error`.
|
||||
The first draft of the new check asked `"malformed point row" in words` — which
|
||||
whichever rung is still standing satisfies — so deleting `_remember()`'s note
|
||||
entirely was a **−1 B SURVIVOR of the whole suite**, on the half of the fix that
|
||||
tells an operator the cache **self-healed**. It is graded on the TENSE now
|
||||
(`… in <day>` at open, `… dropped from <day>` after the refresh), the two are
|
||||
asserted to DIFFER, and the paired mutation — both notes deleted, −91 B — is
|
||||
red on 15 named checks. Same shape as TEST-16 and the same fix: **grade the
|
||||
reason, not the verdict**, and when two rungs answer one stimulus, mutate them
|
||||
together.
|
||||
|
||||
⚠️ **AND FCST-06 FOUND THE COUNT ITSELF LYING — LOW, NOT SILENT.** A mutation
|
||||
whose red is a **traceback** does not report one red; it reports however many
|
||||
checks ran before the traceback, and grades **none** of the ones after it. The
|
||||
new section drove M4 (`self._days = before` → `self._days = {}`) and the run
|
||||
printed **1 FAIL**. The true figure is **3**: an f-string in the next check read
|
||||
`len(read.points)`, and `.points` on a `no_data` **raises `Absence` by design**,
|
||||
so the harness died mid-section with the remaining checks ungraded. That is
|
||||
`why()`'s and `derived()`'s rule — *"it answered nothing" and "it broke on the
|
||||
way to answering" must not grade the same* — arriving in the **printed operand**
|
||||
of a check rather than in its condition, which is the half that is easy to miss
|
||||
because it looks like formatting. Two things follow:
|
||||
|
||||
* **a mutation report's red COUNT is only a lower bound until the run is seen to
|
||||
have reached the end of its section.** Read the output, not the count;
|
||||
* **the total accessor has to cover what a check PRINTS, not only what it
|
||||
asserts.** `test_price_history.py` 5d added `npts()` beside `why()` for
|
||||
exactly this, and re-driving M4 against it moved 1 FAIL to 3.
|
||||
|
||||
Worse, had the traceback landed one check earlier the mutation would have
|
||||
produced **no named red at all** — a result that reads like a broken harness and
|
||||
gets retried rather than read.
|
||||
|
||||
## 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
|
||||
@@ -135,7 +172,7 @@ 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. ELEVEN instances in two weeks; four
|
||||
It is not hypothetical and it is not rare. TWELVE instances in two weeks; four
|
||||
were found by review, two - CFG-04's fixture pair - by an author's own mutation
|
||||
battery, which is the cheaper place, one by review again, in the one
|
||||
place nobody had thought to point the rule at: not a check in the suite, but a
|
||||
@@ -377,6 +414,44 @@ same place twice, the check is decoration.
|
||||
|
||||
The general habits are **14** and **15** below.
|
||||
|
||||
* ⚠️ **SAFETY-47 IS THE TWELFTH, AND IT IS THE SECOND INSIDE A COMMITTED
|
||||
EVIDENCE ARTIFACT.** `DECISIONS/SAFETY-10-shadow-mode.md` §6 tabulated a live
|
||||
run and a shadow run agreeing on `arb_power_w`, on the reason **verbatim**
|
||||
and on the arbiter's winner, and concluded that shadow mode reaches the same
|
||||
decision. **Two independent reasons it could not have gone red, and closing
|
||||
either one alone closes nothing:**
|
||||
* its fixture is `peak_cap_w: 15000` against a 350 W house, so the capacity
|
||||
guard never engages and the thing whose invariance is being claimed —
|
||||
SAFETY-45's sizing of the buy leg from the billed-quarter meter — is in the
|
||||
run and **inert**. ⚠️ **The harness's own comment says so**: *"at this
|
||||
scenario's `peak_cap_w: 15000` it is not binding anyway"*. The rule was
|
||||
written down beside the check it disabled and nobody read it as a
|
||||
disclosure;
|
||||
* and every identity row reads `_sa[0]`/`_la[0]` — **the first charging
|
||||
sample**. At `elapsed = 0` the projection **is** the held level, so the two
|
||||
runs agree **to the float even at a cap that does bind**. Measured:
|
||||
widening the fixture alone — the obvious fix — leaves that comparison
|
||||
passing.
|
||||
|
||||
⚠️ **THE SECOND REASON IS A NEW SHAPE FOR THIS LIST: AN INDEX, NOT A
|
||||
FIXTURE.** Ask of every identity claim not only *"could this data differ"*
|
||||
but *"**at the point I sampled it**, could it differ yet"*. A comparison
|
||||
taken at `t = 0` between two runs that diverge **because one of them acts**
|
||||
is a comparison of a plant with itself, and no amount of widening the fixture
|
||||
changes that. The replacement, `test_arbitrage.py` part 3d, grades the whole
|
||||
run — 716 of 720 charging cycles differ — and grades the `[0]` agreement **as
|
||||
an expectation**, so nobody rebuilds a check on it believing it covers
|
||||
something.
|
||||
|
||||
⚠️ **AND THE FIX'S OWN FIRST DRAFT CARRIED A THIRTEENTH.** The new part's
|
||||
action-log check could not reach the log line at all: the sentence was an
|
||||
f-string inside `Controller._shadow_settle()`, and the only suite that drives
|
||||
that method uses a `meter_source: off` fixture where no arbitrage decision —
|
||||
and therefore no caveat — exists. Deleting the caveat from it left **all 29
|
||||
files green**. It is `shadow.log_line()` now. *A sentence assembled at a call
|
||||
site is a sentence no check can grade*, which is CFG-04's substring lesson
|
||||
arriving from the other direction.
|
||||
|
||||
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
|
||||
@@ -658,6 +733,255 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
that two of it cannot silently overwrite each other's work. A fixed path
|
||||
in a tool that edits source is a shared mutable global with a filename.
|
||||
|
||||
19. ⚠️ **A FIXTURE IN A SHAPE NOBODY PUBLISHES GRADES THE PARSER AGAINST THE
|
||||
PARSER — FOURTEENTH INSTANCE OF HABIT 4, AND THE FIRST WHERE THE FIXTURE
|
||||
AND THE CODE UNDER TEST SHARED A *SHAPE* RATHER THAN A NUMBER.** TARIFF-08.
|
||||
`price.py`'s `_points_from_json()` read `doc["data"]` as a list of
|
||||
`{start, price_eur_mwh}` objects. `api.energy-charts.info` publishes
|
||||
parallel `unix_seconds` / `price` arrays and has no `data` key at all, so
|
||||
`generic_dayahead` raised `SourceUnavailable` on **every live fetch it ever
|
||||
made** — and the suite was green, because all three price suites built
|
||||
their bodies with `json.dumps({"data": [...]})`. The fixture was derived
|
||||
from the parser's guess, so no mutation of the parser and no amount of
|
||||
coverage inside that loop could ever have reached the failure. §11.4 then
|
||||
turned it into a *correct* silent degradation: parse error -> `no_data` ->
|
||||
static profile -> arbitrage off, with nothing naming the cause.
|
||||
|
||||
Habit 2 with a network on the end of it: **the reference has to be data the
|
||||
code under test did not shape.** For a parser that means BYTES, captured
|
||||
from the source and committed unmodified, with the URL, the retrieval time
|
||||
and a checksum beside them - `scenarios/energy-charts-be-price-*.json` and
|
||||
its `-captures.json` provenance, re-derived by the replay so an edited
|
||||
fixture stops being a capture. Two granularities, because the same endpoint
|
||||
answers hourly before 2025-10-01 and quarter-hourly after it and a fixture
|
||||
at one spacing cannot grade an assumption about the other.
|
||||
|
||||
⚠️ **AND THE SURVIVING SYNTHETIC FIXTURES ARE NAMED FOR WHAT THEY ARE.**
|
||||
Three shipped plugins point at endpoints nobody has verified (TARIFF-07),
|
||||
so no capture exists for them; their fixture builder is called
|
||||
`synthetic_row_feed` at every call site, because the next reader's default
|
||||
assumption will otherwise be that a green check means the feed parses.
|
||||
|
||||
⚠️ **ONE OF THIS CARD'S OWN NEW LINES WAS DECORATION AND THE BATTERY SAID
|
||||
SO.** The columnar parser first rendered its instants with `brussels()`
|
||||
before returning them. `assemble()` already renders every plugin's points,
|
||||
so removing the parser's call changed nothing observable - **M4 came back a
|
||||
survivor on a `:ro`, purged tree**, which is not the direction stale
|
||||
bytecode fabricates. The line was removed rather than fenced with a check:
|
||||
a second copy of a rule that already has an owner is indistinguishable from
|
||||
a no-op, and the mutation that proves the rendering is real belongs on
|
||||
`assemble()`, where it is now aimed.
|
||||
|
||||
20. ⚠️ **A FIXTURE GRADES THE RESPONSE. NOTHING ABOUT THE REQUEST IS REACHABLE
|
||||
FROM ONE, AND THE TRANSPORT HAD BEEN RECORDING IT ALL ALONG.** TARIFF-09,
|
||||
and it is habit 19's closed loop with the other half still shut.
|
||||
`generic_dayahead.fetch()` took `since` and `until` and asked for the bare
|
||||
`?bzn=BE`, which answers ONE Brussels day - so §8.3 dropped every hour past
|
||||
today from the trade set as provisional and the optimiser never saw
|
||||
tomorrow as firm, at any hour of any day. Measured at 09:00: **15 firm
|
||||
hours and 33 carried of 48**, both ends of the permitted cycle inside one
|
||||
calendar day; at 23:00, **1 and 47**, with the firm spread no longer
|
||||
clearing the round-trip cost.
|
||||
|
||||
TARIFF-08 had just fixed the RESPONSE side of this same plugin by
|
||||
committing real bytes. It could not have caught this: a captured body is
|
||||
the answer to a URL, and **no property of any body says which URL was
|
||||
asked for**. `canned()` in `test_price.py` had recorded `(url, timeout_s)`
|
||||
on every call since TARIFF-02 and not one check had ever asserted on the
|
||||
first element - so the harness *knew* the plugin was ignoring its arguments
|
||||
and nothing asked it. The fix is one line of habit: **where a plugin takes
|
||||
arguments that shape a request, assert on the REQUEST off the injected
|
||||
transport, not only on what comes back.** Section 2c does, and the
|
||||
shipped-defect mutation (restore the bare URL, `-173 B`) is red on six
|
||||
named checks in `test_price.py` and two in `test_price_history.py`.
|
||||
|
||||
⚠️ **AND A MUTATION THAT TURNS A READ INTO `no_data` CRASHES A SUITE
|
||||
INSTEAD OF NAMING A CHECK, UNLESS THE CHECK EXPECTS IT.** `_NoData.points`
|
||||
raises `Absence` by design, so `derived(...) < derived(...)` over two
|
||||
absent reads is a `TypeError` and `[p for p in plan.read.points]` is a
|
||||
traceback - habit 5's shape with an exception in place of a hang. The
|
||||
mutation that matters here is the "defensive" one somebody writes after
|
||||
reading this card: *refuse when the feed returned fewer days than we asked
|
||||
for*. It is exactly what a healthy feed does every morning before the
|
||||
~13:00-14:00 CET auction, and switching arbitrage off for it would be a
|
||||
worse defect than the one being fixed. It is red BY NAME in
|
||||
`test_price.py` (33 checks, the AC's own line among them) and takes
|
||||
`test_price_history.py` down at **FCST-04's section 1**, whose `_before`
|
||||
read is unguarded - a red, but an unnamed one. That is recorded rather than
|
||||
retrofitted: guarding another card's section is a change to what its
|
||||
checks report, and it belongs to whoever next touches it.
|
||||
|
||||
21. ⚠️ **A CHECK CAN BE GREEN UNDER ITS OWN CARD'S NAMED MUTATION, AND TWICE
|
||||
IN ONE FILE THE REASON WAS THAT THE WRONG QUANTITY EVENTUALLY REACHES THE
|
||||
RIGHT VALUE.** TEL-10's acceptance was *"a run that winds the integrator
|
||||
into the bound must show the published number reaching the bound"*, and its
|
||||
named mutation was *"publish `prev_w` instead of `i_w`"*. The check was
|
||||
**GREEN** on the mutant: the command reaches the same bound too, twelve
|
||||
cycles later, and a check that only reads the VALUE cannot tell the two
|
||||
apart. The same file's *"a stopped controller publishes `not-running`"* was
|
||||
green under deleting that branch's assignments outright (−45 B and −67 B),
|
||||
because the fixture STARTED stopped and `__init__` writes the same values.
|
||||
Both are habit 4 - the data could not reach the failure - and both were
|
||||
found by the card's own battery. The fixes are the shape to copy:
|
||||
* where two quantities CONVERGE, grade the **order or the interval**, not
|
||||
the value: "the integrator reaches the bound BEFORE the command does",
|
||||
and a PLATEAU (the lead sits at exactly −800 W for eight consecutive
|
||||
cycles) rather than a maximum, because a maximum is one tick and a
|
||||
plateau is the shape;
|
||||
* where a branch WRITES a value the constructor also writes, the fixture
|
||||
has to pass THROUGH the other state first. Stop the controller part-way
|
||||
through a run that has already wound the integrator to −5000 W, so a row
|
||||
left standing is a WRONG row and not merely an unset one.
|
||||
|
||||
22. ⚠️ **AN EXPLANATION IN A COMMENT IS A CLAIM, AND SAFETY-48 SHIPPED TWO THAT
|
||||
WERE INVENTED.** A gate was written one-sided (`if target > slewed: min(...)
|
||||
elif target < slewed: max(...)`) under a comment saying the two-sided band
|
||||
`max(lo, min(hi, i_w))` was a BUG that would drag a trailing integrator
|
||||
toward the command, with a worked example. It is not a bug and the example
|
||||
was wrong: `target` is monotone in `i_w`, so the other clamp cannot reach,
|
||||
and the two spellings agree on **205 920 of 205 920** swept states. A second
|
||||
bullet said two placements of the same cap *"differ on 118 of 256 252
|
||||
ticks"*; that 118 had been measured against a **different pair** and the
|
||||
placement actually being compared is bit-identical on the shipped
|
||||
configuration - 0 of 256 246 ticks. Both sentences were plausible, both were
|
||||
load-bearing for a design choice, and neither had been run.
|
||||
**The rule: a comment that says "X would be wrong" is subject to the same
|
||||
battery as a check - BUILD X AND MEASURE IT.** Where the answer is
|
||||
"equivalent", say so with the sweep size and keep the spelling for what it
|
||||
NAMES; where the answer is a number, quote the run it came from. An
|
||||
invented justification for a correct decision is still a defect, and it is
|
||||
the kind a reviewer cannot see because the code is right.
|
||||
|
||||
23. ⚠️ **THE MUTATION IS SUBJECT TO EVERY HABIT ABOVE, HABIT 4 INCLUDED - AND
|
||||
THE PURGE, THE FINGERPRINT AND THE BYTE DELTA ALL SAY IT TOOK.** SAFETY-57's
|
||||
battery reported a **survivor at +21 B** for a mutation of
|
||||
`Freshness._open_window()`: `self.graded_from = float(now_mono)` rewritten
|
||||
as `float(self.changed_mono or now_mono)`, which is meant to re-anchor the
|
||||
verdict's window to the register's own history and so delete the card's
|
||||
whole exit. The harness purged, the tree fingerprint showed the edit on
|
||||
disk, the delta was non-zero, and the mutant really did run. It was still a
|
||||
survivor for a reason that has nothing to do with the property: in the
|
||||
fixture the register never moves after `t = 0`, so `changed_mono` is
|
||||
**`0.0`** - and `0.0 or x` is `x`. **The mutant was a no-op on that
|
||||
stimulus.** Rewritten with an explicit `is None` test (+82 B) it is red on
|
||||
four named checks.
|
||||
|
||||
Stale bytecode is a mutation that never *ran*. This is a mutation that ran
|
||||
and could not *change the answer* on the data it was given, which is habit 4
|
||||
exactly, one level up - and none of the three things a mutation report is
|
||||
required to state (MUTATION.md's own list) can see it, because all three are
|
||||
about the file rather than about the stimulus. Two rules:
|
||||
* **Ask of every survivor what it would have had to change, and check that
|
||||
the fixture reaches that value.** Here: "does any fixture have a non-zero
|
||||
`changed_mono` at the moment `_open_window()` is called?" The answer was
|
||||
no, and it is one line to ask.
|
||||
* **Prefer a mutation that CANNOT be a no-op.** `or`, `and`, truthiness on
|
||||
a numeric, a default argument and a `getattr` fallback all have a value at
|
||||
which the mutant and the shipped tree coincide. An explicit comparison,
|
||||
a constant swap or a deleted statement does not.
|
||||
|
||||
⚠️ Note where this one was found: in the battery of a card whose whole
|
||||
subject is a check that cannot reach the failure it names. **Second
|
||||
instance in an instrument rather than in a check since habit 16, and the
|
||||
third overall.**
|
||||
|
||||
⚠️ **AND THE SAME CARD'S BATTERY CAUGHT A HABIT-22 COMMENT IN THE FIX
|
||||
ITSELF, WHICH IS WHY THESE TWO BELONG NEXT TO EACH OTHER.**
|
||||
`freshness.py` shipped `⚠️ RELEASE BEFORE GRADE, AND THE ORDER IS
|
||||
LOAD-BEARING` with a worked reason. Swapping the two calls is **zero bytes**
|
||||
and survived the battery: `_grade()` acts only when `|standing| >= move_w`
|
||||
and `_release()` only when `|standing| < move_w`, so at most one can act on
|
||||
any read and the order is **not observable**. Habit 22 says build X and
|
||||
measure it; the battery is what builds X. The comment now states the
|
||||
disjointness and the suite pins **that** - at 0, 100, 249, 250, 251 and
|
||||
1300 W - rather than an order nothing depends on.
|
||||
|
||||
⚠️ **SECOND INSTANCE, TEL-11, AND THE NO-OP WAS A SATURATED RAIL RATHER
|
||||
THAN A VALUE.** The card's whole subject is that a PV reading must never
|
||||
reach the control law, so its central mutation is
|
||||
`self.grid = self.grid - self.pv_w` in `read_inputs()` - **+106 B**, on
|
||||
disk, purged, genuinely running - and it came back a **SURVIVOR** of a
|
||||
six-file battery. The property held in no sense: the fixture drove a 6 kW
|
||||
export over 12 cycles, which puts the loop **against its rail**, and a
|
||||
command moving `slew_w` every cycle produces the SAME ramp for -6000 W and
|
||||
for -14000 W of apparent error. The mutant ran and could not change the
|
||||
answer.
|
||||
Two things generalise past PV:
|
||||
* **A SATURATED ACTUATOR IS A VALUE AT WHICH EVERY INPUT COINCIDES.** The
|
||||
habit-23 list above names `or`, `and`, truthiness, default arguments and
|
||||
`getattr` fallbacks - all *expressions* with a coinciding value. A rail,
|
||||
a clamp, a slew limit, a deadband and a latched alarm are the same hazard
|
||||
in the **plant**: they are where a controller's output stops being a
|
||||
function of its input. A fixture that parks there cannot separate any two
|
||||
inputs, and this repository's control fixtures park there by default,
|
||||
because the dramatic stimulus is the saturating one.
|
||||
* **SO THE PRECONDITION IS NOT "DOES IT COMMAND SOMETHING", IT IS "DOES IT
|
||||
REST SOMEWHERE THE MUTATION COULD MOVE IT FROM".** `test_pv.py` part 6
|
||||
now drives a 600 W import the loop SETTLES on, asserts the resting target
|
||||
is well inside the rail, and asks habit 23's own question as a check:
|
||||
shift the METER by the same 8 kW and confirm the commands really would
|
||||
have moved. On the first fixture that question answers NO, in one line,
|
||||
and it is the line that turns the identity into evidence.
|
||||
|
||||
24. **A REFUSAL DEFINED RELATIVE TO "THE BASELINE LACKS THIS FEATURE" DIES ON
|
||||
THE RELEASE THAT SHIPS THE FEATURE — AND IT DIES AS A FALSE REFUSAL, WHICH
|
||||
IS THE LOUD DIRECTION.** TEL-11 built `tools/tel11_pv_trace.py` to grade a
|
||||
cross-tree identity, and because "the documents are identical" was its
|
||||
*result* rather than its warning sign, it had to prove instead that the two
|
||||
runs came from different trees and that **exactly one side reacts to
|
||||
`pv_entity`**. That was correct evidence: `release/1.0` predated the option
|
||||
entirely, so a side that reacted to it was the HEAD by construction.
|
||||
|
||||
TEL-11 then **merged**, `release/1.0` became 0.11.0, and the baseline for
|
||||
the next card reacts to `pv_entity` too. `--compare` now answers
|
||||
`REFUSED: set differs from unset on ['A', 'B']` on every honest run — the
|
||||
instrument refuses its own shipped tree. TEL-12 found it on the first
|
||||
comparison it ran.
|
||||
|
||||
* **A discriminator is a fact about a MOMENT, not about a card.** "The
|
||||
baseline does not have X" is true until X ships and then false forever.
|
||||
Write the discriminator against something the NEXT baseline still will not
|
||||
have — TEL-12's is the roster key, not the channel — and say in the tool
|
||||
which fact it rests on, so the next card can see what it has to replace.
|
||||
* **Prefer a discriminator that is MEASURED over one the tree REPORTS.**
|
||||
`tree.supports_roster` is the tree describing itself and would have been
|
||||
one line; the shipped test is "this side's roster cells ARE its unset
|
||||
cells", which is the tree being measured. Both are printed, side by side,
|
||||
so a disagreement between the self-report and the measurement is visible
|
||||
rather than silently trusted.
|
||||
* ⚠️ **And this is the SAFE direction of the same defect, which is why it
|
||||
was found in ten minutes rather than in a review.** A stale refusal is a
|
||||
developer chasing a failure they cannot explain (the false-red case at the
|
||||
top of this file). A stale *identity* — an instrument that goes on printing
|
||||
OK because its reference drifted into agreement — is the survivor-
|
||||
manufacturing direction, and nothing announces it. Ask of every cross-tree
|
||||
instrument: *what does this print when the feature it is about has been in
|
||||
the baseline for a year?*
|
||||
|
||||
TEL-12's own battery produced two more instrument defects of the ordinary
|
||||
kind, both recorded in `DECISIONS/TEL-12-several-pv-inverters.md` §4: a
|
||||
stimulus annotation written into the document being compared (so every cell
|
||||
differed for a reason that was not behaviour, and the discriminator fired on
|
||||
both sides), and a fixture cell whose TRUE total was labelled as its
|
||||
"partial" (so the check refused the shipped tree for publishing a number it
|
||||
is required to publish). ⚠️ **Both were caught by running the instrument
|
||||
against the shipped tree BEFORE running it against a mutant** — which is the
|
||||
one ordering rule that separates "the instrument is wrong" from "the tree is
|
||||
wrong", and `test_mqtt.py` part 8's first line ("the shipped module satisfies
|
||||
every property this instrument grades") is the same rule as a check.
|
||||
|
||||
⚠️ **AND TEL-12's SUBJECT WAS HABIT 23 IN THE FIXTURE ITSELF.** The card adds
|
||||
a SUM over a roster, and the defect it exists to prevent is publishing the
|
||||
partial sum when a member is absent. A fixture whose absent member
|
||||
contributes **0 W** cannot see that defect at all: the partial and the full
|
||||
total are **the same number**, so "published the absence" and "published the
|
||||
partial" produce identical output. That is a rail in the *arithmetic* rather
|
||||
than in the plant — the same hazard as TEL-11's saturated actuator, one
|
||||
domain over. `test_pv.py`'s roster is three distinct non-zero contributions
|
||||
in two different units, absence is driven at **every position** rather than
|
||||
at one (habit 10), and each position's partial is **computed from the
|
||||
fixture** and asserted to differ from the true total as its own precondition.
|
||||
|
||||
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
|
||||
@@ -665,6 +989,92 @@ 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.
|
||||
|
||||
## TARIFF-15 — the card-graded formula, mutation by mutation
|
||||
|
||||
Twenty-two mutations, one per check the card added, **run** rather than
|
||||
reasoned about. Harness: `/home/homelab/.cache/t15-mut/run.py`, backups beside
|
||||
it rather than in a shared scratchpad — two agents' harnesses collided in one
|
||||
this week. Each run restores from backup, purges `__pycache__` **on the host**
|
||||
first, and grades through the CI image with the tree bind-mounted `:ro`, so the
|
||||
interpreter cannot write a cache into the tree under test at all (TEST-15's
|
||||
structural immunity, borrowed).
|
||||
|
||||
**No survivors.** Two entries are `+0 B` and are called out as such, per the
|
||||
reporting rule.
|
||||
|
||||
| | mutation | file | Δ | red |
|
||||
|---|---|---|---|---|
|
||||
| M1 | `CARD_TOLERANCE` widened to 100 — the self-check stops checking | `custom_formula.py` | +1 B | **8** |
|
||||
| M2 | `CARD_TOLERANCE` tightened to 0 — the other direction, so the tolerance is a number and not a shape | `custom_formula.py` | −1 B | **16** |
|
||||
| M3 | `units` becomes optional | `custom_formula.py` | +10 B | 1 |
|
||||
| M4 | the `factor == 0` rung removed | `custom_formula.py` | −8 B | 1 |
|
||||
| M5 | the `factor < 0` rung removed | `custom_formula.py` | −7 B | 1 |
|
||||
| M6 | the `vat_pct` band widened to ±1000 | `custom_formula.py` | +6 B | 2 |
|
||||
| M7 | the non-finite rung removed from `_finite()` | `custom_formula.py` | −26 B | 2 |
|
||||
| M8 | the non-numeric rung removed from `_finite()` | `custom_formula.py` | −16 B | 4 |
|
||||
| M9 | an unknown top-level field ignored instead of refused | `custom_formula.py` | −2 B | 1 |
|
||||
| M10 | the plausibility **floor** applied to the all-in figure instead of the energy one | `custom_formula.py` | **+0 B** | 2 |
|
||||
| M11 | `hourly` stops averaging and emits the feed's own points | `custom_formula.py` | +12 B | 3 |
|
||||
| M12 | the hourly bucket takes the FIRST quarter instead of the mean | `custom_formula.py` | −11 B | 1 |
|
||||
| M13 | the preview drops the index column from each row | `custom_formula.py` | −13 B | 1 |
|
||||
| M14 | the preview is emitted on every refresh rather than once | `price.py` | −8 B | 1 |
|
||||
| M15 | `custom_formula` declares `ALL_IN` — the double-counting lie | `price.py` | −41 B | **10** |
|
||||
| M16 | a stale card never warns | `custom_formula.py` | −8 B | 1 |
|
||||
| M17 | a stale card REFUSES instead of warning | `validate.py` | −3 B | 1 |
|
||||
| M18 | the empty-formula refusal removed from the startup gate | `validate.py` | −38 B | 1 |
|
||||
| M19 | the index scaled to EUR/kWh before the formula — the factor-1000 | `price.py` | +2 B | **7** |
|
||||
| M20 | an `eval` call planted in `app/custom_formula.py` | `custom_formula.py` | +14 B | 1 |
|
||||
| M21 | `custom_formula` stops passing the caller's range to the feed | `price.py` | **+0 B** | 1 |
|
||||
| M22 | **PAIRED**: M1 *and* the plausibility band removed, together | `custom_formula.py` | −60 B | **12** |
|
||||
|
||||
### The four that are worth reading rather than counting
|
||||
|
||||
**M13 was a SURVIVOR of the whole suite on the first attempt, at −13 B.** The
|
||||
check read `"index EUR/MWh -> all-in EUR/kWh" in log` — which is the preview's
|
||||
**heading**. Deleting the index column from every **row** left the heading
|
||||
saying it prints something it no longer printed, and nothing went red. That is
|
||||
this file's third failure mode arriving in the check written to close it: the
|
||||
assertion ran, the sentence was true, and the data could not reach the failure
|
||||
it named. It is graded on a rendered ROW now, built here from the captured
|
||||
document (`' 216.080 -> 0.30123'`), and M13 is red.
|
||||
|
||||
**M10 is `+0 B` and it is a real defect, not a synthetic one.** It came out of
|
||||
writing the mutation rather than out of review: `implausible_all_in()`'s floor
|
||||
was applied to the all-in figure, and `price_taxes_eur_kwh` shipped at 0.06 —
|
||||
**three times the 0.02 floor** — so a card read a hundred times too small
|
||||
(0.0016 EUR/kWh of energy) is carried over the floor by the flat tax addend
|
||||
alone and passes. The hundredfold error is exactly what that band exists for.
|
||||
The floor is applied to the **energy** figure now and the ceiling to the all-in
|
||||
one, both directions are graded, and the trap is stated as a check in its own
|
||||
right so the asymmetry cannot be "tidied up" later. *(Annotated 2026-09-13:
|
||||
TARIFF-18 raised that default to 0.11736, **nearly six times** the floor. The
|
||||
addend that does the carrying got BIGGER, so the trap is wider than when M10
|
||||
was measured and the asymmetry is more load-bearing rather than less.)*
|
||||
|
||||
**M15 is the one the framework CANNOT catch, and the reds say which half does.**
|
||||
Declaring `ALL_IN` makes `assemble()` add nothing, and section 3's all-in check
|
||||
is *derived from the declaration* — so it passes on the lie, exactly as
|
||||
`price.py`'s trap 1 says no code can catch a misdeclaration. What goes red is
|
||||
the **docstring/declaration agreement** check, the card reproductions, and the
|
||||
`sources` pin: ten reds, none of them from the arithmetic. That is the
|
||||
guarantee at its true size — the two claims are held to agree, and whether
|
||||
either is true about somebody else's tariff remains human.
|
||||
|
||||
**M22 is the paired run (TEST-16).** The card self-check and the plausibility
|
||||
band both answer a unit error, so each could be masking the other. Weakened
|
||||
**together** they produce twelve reds rather than the eight M1 alone produces,
|
||||
and the band's own checks stay red — so neither rung is doing the other's work.
|
||||
|
||||
### What this battery does NOT establish
|
||||
|
||||
It does not establish that a card is the right card. Every mutation above moves
|
||||
the relationship between the coefficients and the printed price; **none of them
|
||||
can move the relationship between the printed price and the operator's actual
|
||||
contract**, because nothing in this repository observes that. A
|
||||
consistent-but-wrong pair passes every one of these checks, by construction,
|
||||
and `DECISIONS/TARIFF-15-the-card-is-the-grader.md` §3 says so in the same
|
||||
sentence as the claim.
|
||||
|
||||
## Where each surface stands
|
||||
|
||||
* **Local and dev-harness runs — the exposed one.** This is where FCST-03 hit
|
||||
@@ -715,6 +1125,7 @@ commit and PR bodies.
|
||||
| **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. |
|
||||
| **MAINT-05 round 3**, `migrate_maint_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **was a survivor — now CLOSED** | measured against head `65cddf3` under the purge, tree mounted `:ro`, `19/19 PASS`. It stamps the schema marker on a migration that LOST state, defeating the older `lost` guard as well as the new `misdirected` one, because every check that reached the marker did so on a store whose `kept` list was empty. Closed by `test_maintenance.py` **9d**; the mutation is now red on three named checks. |
|
||||
| **MAINT-05 round 3**, `migrate_unit_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **found here — SAFETY-02's half, now CLOSED** | the SAME mutation one function up, on the LATCH migration rather than the history one. Measured a survivor against this branch under the purge, `:ro`, `19/19 PASS`, then closed by `test_inverter.py` part 10's new fixture — a store with `unit/inverter-a/sign_reason` already present (so `kept` is non-empty) whose `sign_tripped: true` write is dropped. Shipped: marker withheld. Mutant: marker stamped on a **disarmed latch**, and the next start skips the migration that would have re-armed it. |
|
||||
| **TARIFF-09** 11 mutations over `app/price.py` and the two-day capture, all red, no survivors | **not at risk** | direction, and method: every mutation was applied to a FRESH `cp -a` of the clean worktree rather than reverted, so there is no revert to verify (habit 18) and no shared scratch path two runs could collide on. Each ran through `tools/mutation_run.sh`, which purges and fingerprints. Byte deltas recorded on the PR; one is **+0 B** - a single price digit edited inside the committed capture, the risky delta - and the re-derived sha256 catches it. |
|
||||
| **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
|
||||
|
||||
@@ -184,6 +184,17 @@ IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
|
||||
IDLE_NO_SIZE = (
|
||||
"no pack's state of charge permits this side of the trade, so it is sized "
|
||||
"at 0 W - and a 0 W arbitrage claim is a park order, not a trade")
|
||||
# SAFETY-45. THE BUY LEG'S SIZE, AND ONLY THE BUY LEG'S - §9.1: "Discharge and
|
||||
# export are never capped by the peak guard; only charging and import are."
|
||||
IDLE_NO_HEADROOM = (
|
||||
"the capacity-tariff guard has measured this billed quarter and it can "
|
||||
"afford no charging at all, so a trade of any size would push it over the "
|
||||
"ceiling - and a 0 W arbitrage claim is a park order, not a trade")
|
||||
IDLE_HEADROOM_UNMEASURED = (
|
||||
"the capacity-tariff guard did not size this quarter's charging headroom "
|
||||
"this cycle, and an UNMEASURED headroom is not an unlimited one - charging "
|
||||
"creates the billed quantity (§9.1) and nothing is measuring the quarter "
|
||||
"this one would land in")
|
||||
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 "
|
||||
@@ -303,7 +314,7 @@ def _point_at(plan, when: datetime):
|
||||
return None
|
||||
|
||||
|
||||
def decide(plan, now: datetime, *, peak_guard_active, soc,
|
||||
def decide(plan, now: datetime, *, peak_guard_active, charge_headroom_w, 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.
|
||||
@@ -314,6 +325,18 @@ def decide(plan, now: datetime, *, peak_guard_active, soc,
|
||||
None refuses, because the alternative is a strategy that creates the billed
|
||||
quantity while the thing that would have limited it is switched off.
|
||||
|
||||
⚠️ `charge_headroom_w` IS SAFETY-45's SECOND, CONTINUOUS ANSWER FROM THE
|
||||
SAME GUARD, AND IT IS NOT A DUPLICATE OF THE FIRST. `peak_guard_active` is a
|
||||
SWITCH - it says whether the guard is holding - and a switch keyed on a
|
||||
continuous quantity is what made the loop oscillate: the guard engaged at a
|
||||
5 W floor, this module refused the whole trade, the loop took the actuator,
|
||||
the meter fell, the guard released and the trade re-armed AT FULL POWER
|
||||
130 s later. 28 times an hour, measured. `peakguard.charge_headroom_w()` is
|
||||
the watts the billed quarter can still afford; sizing the buy leg to it means
|
||||
the trade is CLAMPED rather than switched, which is the same shape
|
||||
`control.maintenance_charge_floor()` has always given a maintenance charge.
|
||||
⚠️ None IS NOT UNLIMITED here either - see IDLE_HEADROOM_UNMEASURED.
|
||||
|
||||
⚠️ 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
|
||||
@@ -330,10 +353,12 @@ def decide(plan, now: datetime, *, peak_guard_active, soc,
|
||||
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))
|
||||
soc_floor_pct, soc_ceiling_pct,
|
||||
charge_headroom_w))
|
||||
|
||||
|
||||
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||
def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct,
|
||||
headroom_w):
|
||||
"""The permitted case: which hour is this, and may the pack do it."""
|
||||
here = _point_at(plan, now)
|
||||
if here is None:
|
||||
@@ -403,18 +428,110 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
|
||||
f"nowhere to put the cheap energy", **common)
|
||||
if not abs(float(charge_w)) > 0.0:
|
||||
return _idle(IDLE_NO_SIZE, **common)
|
||||
# SAFETY-45. THE BILLED QUARTER IS ASKED LAST, AND THE PLACEMENT IS
|
||||
# DELIBERATE: every refusal that existed before this card keeps the
|
||||
# precedence it had, so no sentence an operator already reads changes when
|
||||
# it fires, and the two added here are placed where they cannot displace
|
||||
# one. The pack's reasons come first because they are properties of the
|
||||
# hardware in the cupboard; this one is a property of the fifteen minutes
|
||||
# the trade would land in.
|
||||
if headroom_w is None:
|
||||
# ⚠️ NOT "the guard is clear". This is `guard_state()`'s None argument
|
||||
# one layer down: a headroom nothing measured is not an unlimited one,
|
||||
# and charging at max_w creates the billed quantity (§9.1).
|
||||
return _idle(IDLE_HEADROOM_UNMEASURED, **common)
|
||||
asked_w = abs(float(charge_w))
|
||||
size_w = min(asked_w, max(0.0, float(headroom_w)))
|
||||
if not size_w > 0.0:
|
||||
# ⚠️ NOT IDLE_NO_SIZE, WHICH IS THE LINE ABOVE AND BLAMES THE PACKS.
|
||||
# `asked_w > 0` is already established here, so the pack may be empty
|
||||
# and willing and it is the billed quarter that has run out. A refusal
|
||||
# naming the wrong subsystem sends an operator to the wrong page
|
||||
# (§10.2), and these two are a whole quarter-hour apart in what they
|
||||
# ask a human to do about them.
|
||||
return _idle(IDLE_NO_HEADROOM, **common)
|
||||
# ⚠️ THE SENTENCE CARRIES BOTH NUMBERS IN THEIR OWN SLOTS, not one figure
|
||||
# twice. MUTATION.md habit 14: two values in one sentence can be transposed
|
||||
# by a ZERO-BYTE edit that no byte delta and no shape pin can see, so the
|
||||
# trade's own size and the guard's headroom are each named by the literal
|
||||
# text around them.
|
||||
capped = size_w < asked_w
|
||||
return ArbitrageDecision(
|
||||
action=CHARGE, power_w=-abs(float(charge_w)),
|
||||
action=CHARGE, power_w=-size_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} %"),
|
||||
f"had to cover; charging {size_w:.0f} W from {soc:g} %"
|
||||
+ (f" - SIZED BY THE CAPACITY-TARIFF GUARD: the trade asked for "
|
||||
f"{asked_w:.0f} W and this billed quarter can afford "
|
||||
f"{float(headroom_w):.0f} W (§9.1, §10.1 priority 2)"
|
||||
if capped else "")),
|
||||
spread_eur_kwh=v.spread_eur_kwh,
|
||||
required_eur_kwh=v.required_spread_eur_kwh,
|
||||
price_now_eur_kwh=price)
|
||||
|
||||
|
||||
def sized_from_meter(decision: ArbitrageDecision, headroom_w, *,
|
||||
winner: str | None) -> bool:
|
||||
"""SAFETY-47. Was the number a shadow run just recorded SIZED FROM THE
|
||||
BILLED QUARTER - i.e. from a measurement of what the battery itself did?
|
||||
|
||||
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
|
||||
`peakguard.charge_headroom_w()`, `discharge_permitted()` 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.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
THE DEFECT IT PREVENTS
|
||||
|
||||
`shadow.words()` and the action log carry "would have commanded N W". In
|
||||
SHADOW MODE the battery is held at `shadow.WIRE_W`, so the billed quarter
|
||||
the buy leg was sized against contains THE HOUSE AND NOTHING ELSE - while
|
||||
a live run's meter rises as the trade charges. The same code, the same
|
||||
hour, `peak_cap_w` 1500 under a 1200 W house: live commands -302.4 W and a
|
||||
shadow run records -2000.0 W. 6.61x, on the status row, with no caveat.
|
||||
This is the flag that puts the caveat there.
|
||||
|
||||
⚠️ IT IS TRUE WHENEVER A MEASURED HEADROOM ENTERED THE ARITHMETIC, NOT
|
||||
ONLY WHEN IT BOUND. `_act()` sizes the buy leg as `min(asked, headroom)`,
|
||||
and "the headroom did not bind" is ITSELF a measured fact about a quarter
|
||||
the battery never entered. Keying this on `capped` - on whether the
|
||||
shipped sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" - would answer
|
||||
True at the START of the quarter, where a shadow run and a live run still
|
||||
agree to the watt, and False at the END, where `(900/remaining)` has
|
||||
multiplied the headroom past the ask and the recorded number is at its
|
||||
most wrong. The quietest caveat exactly where it is needed loudest.
|
||||
|
||||
⚠️ THE BUY LEG ONLY. §9.1: "Discharge and export are never capped by the
|
||||
peak guard; only charging and import are." A sell is sized from
|
||||
`discharge_w`, which is the roster's own rating and not a measurement of
|
||||
the quarter, so a sell's recorded watts are NOT subject to this and must
|
||||
not carry a caveat that would then be false.
|
||||
|
||||
⚠️ AND THE ARBITER'S WINNER IS THE THIRD RUNG, NOT AN AFTERTHOUGHT. A trade
|
||||
that was sized from the meter and then OUT-RANKED is not the number being
|
||||
recorded - `Resolution.target_w` belongs to somebody else - and a caveat
|
||||
filed against somebody else's number is a false sentence on the status row,
|
||||
which is the defect class this whole card is about. It is in HERE and not
|
||||
in `main.py` for the reason the two rungs above are: all three answer one
|
||||
question, a check has to be able to reach each of them with the other two
|
||||
clear (TEST-16), and a rung that lives at a call site is a rung no check
|
||||
can drive.
|
||||
|
||||
`headroom_w` is whatever `peakguard.charge_headroom_w()` answered for the
|
||||
cycle this decision was taken on. ⚠️ None IS "NOTHING MEASURED IT", and
|
||||
`decide()` REFUSES the trade there (IDLE_HEADROOM_UNMEASURED), so a None
|
||||
can only ever arrive here beside a non-trading decision - but it is tested
|
||||
rather than assumed, because the caller is `main.py` and not this module.
|
||||
|
||||
`winner` is `Resolution.winner`: the source that actually set the watts.
|
||||
"""
|
||||
return (winner == SOURCE
|
||||
and decision is not None and decision.action == CHARGE
|
||||
and headroom_w is not None)
|
||||
|
||||
|
||||
def claim(decision: ArbitrageDecision) -> Claim | None:
|
||||
"""The decision as one arbiter claim, or None when nothing is to be traded.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ since the status page shipped; this file has five more chances to get it wrong.
|
||||
|
||||
⚠️ AND IT IS BUILT FOR A PHONE FIRST. This is the screen someone stands in
|
||||
front of the inverter with: one column, big touch targets, sections that start
|
||||
collapsed so 71 options do not arrive as one wall, and the refusal block pinned
|
||||
collapsed so 72 options do not arrive as one wall, and the refusal block pinned
|
||||
where it cannot be scrolled past.
|
||||
"""
|
||||
|
||||
|
||||
@@ -191,6 +191,80 @@ SETTLE_CYCLES = math.ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) # 12
|
||||
# edge rather than in prose, because it is a property of two files agreeing.
|
||||
_SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-48. HOW FAR THE INTEGRATOR MAY STAND AHEAD OF THE COMMAND.
|
||||
#
|
||||
# ⚠️ THE MISSING CASE, AND IT IS A CASE AND NOT A CONSTANT. The freeze below
|
||||
# stops the integrator accumulating, and the comment beside it says what the
|
||||
# requirement is: "the INTEGRATOR stop accumulating, not only the command". But
|
||||
# `frozen` is computed from ONE signal - `abs(prev_w - actual_w) >
|
||||
# saturation_w`, the inverter not tracking - and SLEW-LIMITING NEVER ENTERS IT.
|
||||
# When the command hits the step ceiling, the integrator goes on winding at
|
||||
# `gain * error` until it reaches `integrator_max_w`.
|
||||
#
|
||||
# The arithmetic, and it is the whole card:
|
||||
#
|
||||
# the integrator moves gain * error per cycle
|
||||
# the command moves at most slew per cycle
|
||||
# so the integrator outruns the command whenever
|
||||
#
|
||||
# |error| > slew / gain
|
||||
#
|
||||
# At the live install's shape (gain 0.6, slew_w 400) that is 667 W. The median
|
||||
# grid error at the onset of a recorded episode is 1114 W. This is why
|
||||
# SAFETY-39's `slew_w` retune did not fix it: a smaller step makes the
|
||||
# threshold LOWER, so the case fires more often, not less.
|
||||
#
|
||||
# ⚠️ WHAT IS BOUNDED IS THE LEAD, NOT THE INTEGRATOR. Two forms were measured
|
||||
# over 16.0 days of the live install's own recorded disturbances
|
||||
# (`tools/replay_recorded_loop.py`, and DECISIONS/SAFETY-48 has the table):
|
||||
#
|
||||
# (a) reuse the asymmetric freeze on a carried slew flag - "may not wind
|
||||
# further in the direction it is already pushing", exactly as saturation
|
||||
# does. It cuts export 13 % AND RAISES THE BILLED QUANTITY: the worst
|
||||
# quarter-hour average OFFTAKE goes 3739 -> 3935 W and the mean quarter
|
||||
# moves +2.7 W, in five of six plant and tuning variants, and on the
|
||||
# billed quarter it is worse at every plant lag where release/1.0
|
||||
# produces a peak at all (a tie at lag 0, worse at 2.5/5/7.5/10 s)
|
||||
# and at every top-N cut of the quarters.
|
||||
#
|
||||
# ⚠️ AND THE MECHANISM IS A REDISTRIBUTION, NOT A WEAKER ANSWER. An
|
||||
# earlier draft of this line said a frozen integrator undershoots by
|
||||
# (1 - gain) "so a large import is answered too weakly". The aggregate
|
||||
# does not show that: form A cuts total import by 5.45 kWh, and paired
|
||||
# tick by tick against release/1.0 it delivers 8.56 kWh LESS offtake
|
||||
# energy on the ticks release/1.0 was importing. What it moves is the
|
||||
# TAIL - within quarters that already net to offtake, the worst of them
|
||||
# gets worse - and the capacity tariff bills the worst quarter and
|
||||
# nothing else. That is the wrong trade here and the reason this is not
|
||||
# the fix that ships.
|
||||
#
|
||||
# (b) cap how far the integrator may LEAD the command, one slew step. Export
|
||||
# 39.6 -> 34.1 kWh, wasted throughput 4.7 -> 3.2 kWh, sign reversals
|
||||
# 2992 -> 2770, worst quarter-hour offtake 3739 -> 3434 W at the fitted
|
||||
# plant lag (⚠️ AND AT NO SHORTER ONE: at every lag <= 5.0 s release/1.0
|
||||
# is already at 3434 W and this changes it by zero - DECISIONS/SAFETY-48
|
||||
# §3a), median recovery 232 -> 199 s. It is what ships.
|
||||
#
|
||||
# ⚠️ AND THE CAP IS NOT ZERO. `i_w = slewed` is full back-calculation, and this
|
||||
# module's own docstring says what that costs: passing `i_w=None` "reduces this
|
||||
# function to the exact velocity form it replaced". A zero cap makes `i_w`
|
||||
# identically the command on every cycle, which is that same collapse written
|
||||
# from the other end - it measures 32.0 kWh, better than (b) by 2 kWh, and it
|
||||
# throws away the reason the accumulator was split out at all. One step is the
|
||||
# smallest cap that is not that.
|
||||
#
|
||||
# WHAT THE NUMBER MEANS: the command can cover a one-step lead in ONE cycle, so
|
||||
# the worst-case unwind after a reversal is one cycle instead of
|
||||
# `integrator_max_w / slew_w` - 12.5 cycles at the live shape, ~60 s at the
|
||||
# meter's measured 5.0 s tick.
|
||||
#
|
||||
# ⚠️ IT IS IN STEPS RATHER THAN IN WATTS FOR SAFETY-39's REASON, WHICH IS THE
|
||||
# REASON THIS FILE KEEPS RE-LEARNING. A watt figure is a constant that outlives
|
||||
# the `slew_w` it was derived against; expressed in steps it moves with
|
||||
# `slew_limit_w()` and therefore with the rail.
|
||||
SLEW_LEAD_STEPS = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
@@ -507,6 +581,83 @@ def compute(
|
||||
slewed = max(prev_w - slew, min(prev_w + slew, target))
|
||||
if slewed != target:
|
||||
reason = "slew-limited"
|
||||
# --- SAFETY-48: the step ceiling reaches the integrator too ---------
|
||||
# ⚠️ INSIDE THIS BRANCH AND NOWHERE ELSE - AND THE REASON IS NOT THE
|
||||
# SATURATION FREEZE. An earlier draft of this comment said that applied
|
||||
# unconditionally the cap "would also pull the integrator toward a
|
||||
# command the SATURATION freeze is pinning", and priced that at 118 of
|
||||
# 256 250 ticks. Both halves were invented, and MUTATION.md habit 22 is
|
||||
# what fell out of it. The 118 had been measured against a DIFFERENT
|
||||
# pair - a band applied after quantisation, at the very end of
|
||||
# `compute()`. On the pair the sentence actually names - this same cap
|
||||
# moved out of this branch to just after `target = slewed` - the two
|
||||
# forms are BIT-IDENTICAL on the shipped configuration: 0 of 256 246
|
||||
# ticks differ over the 16-day recording. They separate only where
|
||||
# `integrator_max_w` is deliberately set ABOVE `max_w`, the one
|
||||
# configuration this file says can reach its `clamped` reason; there
|
||||
# the integrator is meant to run above the rail and an unconditional
|
||||
# cap would drag it back to the command every cycle and make the option
|
||||
# inert. THAT is why the cap stays in the branch, and
|
||||
# `test_telemetry.py` part 4 drives it. (The separable-state COUNT is a
|
||||
# property of whatever grid was swept - 2050 of 257 400 on this card's,
|
||||
# 1220 of 246 064 on the review's, 776 of 40 320 on a third - so the
|
||||
# claim to carry forward is WHERE they separate, not how many.)
|
||||
#
|
||||
# ⚠️ AND THE HALF A READER WILL OTHERWISE ASSUME AWAY: THIS CAP DOES
|
||||
# MOVE A SATURATION-FROZEN INTEGRATOR. It runs BEFORE the freeze block
|
||||
# below and is not gated on `frozen`. Measured over the same recording
|
||||
# at the live shape: of 79 160 frozen ticks the cap changed `i_w` on
|
||||
# 1095 of them, and on 679 of those it moved it FURTHER OUT on the side
|
||||
# it was already on - putting back magnitude the freeze had just let
|
||||
# fall, which is the one direction the freeze's own rule ("may fall,
|
||||
# never rise") exists to forbid. Worst case 2424 W, and the worked tick
|
||||
# is `prev_w -3400 W, grid +4697 W, actual -4144 W, sat_count 2 -> 3`:
|
||||
# the freeze leaves the integrator at -176 W, the slew binds, and the
|
||||
# cap drags it to -2600 W. It never overrides a SAME-CYCLE refusal -
|
||||
# the freeze clipped `moved` on 75 512 ticks and the slew bound on none
|
||||
# of them - but it does not leave the frozen path alone either.
|
||||
#
|
||||
# ⚠️ AND THAT IS WHERE SAFETY-13's SETTLE VALUES WENT. Gating this cap
|
||||
# on `not frozen` puts `test_control.py` part 5 back at release/1.0's
|
||||
# -1190 W exactly; part 4(b)'s -2390 -> -1200 W does NOT come back,
|
||||
# because that one moved for the other reason - the winding BEFORE the
|
||||
# freeze is bounded now. The gate is refused anyway: measured the same
|
||||
# way it leaves a worst observed lead of 4961 W, i.e. no bound at all
|
||||
# on the quantity this card exists to bound. DECISIONS/SAFETY-48 §5
|
||||
# carries the rest of the price.
|
||||
#
|
||||
# ⚠️ WRITTEN ONE-SIDED, AND IT IS **EQUIVALENT** TO THE TWO-SIDED BAND
|
||||
# `max(slewed - cap, min(slewed + cap, i_w))` - MEASURED, NOT REASONED.
|
||||
# An earlier draft of this comment claimed the two-sided spelling was a
|
||||
# bug that would drag a TRAILING integrator up toward the command. That
|
||||
# is false and the sweep said so: `target` is monotone in `i_w`
|
||||
# (`target = clamp(clamp(i_w, +-limit), +-max_w)`) and `slewed` is
|
||||
# `target` pulled toward `prev_w`, so whenever the slew binds, `i_w`
|
||||
# lies on the same side of `slewed` as `target` does and the other
|
||||
# clamp cannot reach. The two spellings agree on **205 920 of 205 920**
|
||||
# cases across four tunings, every sign of `prev_w`, `grid_w`,
|
||||
# `actual_w` and `i_w`, and the `i_w=None` path.
|
||||
#
|
||||
# It stays one-sided because it SAYS which side the lead is on - the
|
||||
# side the command wanted to move and could not - and because a reader
|
||||
# reaching for the band spelling should find the sweep rather than an
|
||||
# argument. ⚠️ The "descending" case is NOT an exception to the rule: a
|
||||
# command falling from +2400 W toward an integrator at +100 W has an
|
||||
# integrator 2300 W ahead of it IN THE DIRECTION OF TRAVEL, and it is
|
||||
# pulled back to one step beyond the emitted command exactly as a
|
||||
# rising one is.
|
||||
#
|
||||
# ⚠️ `i_w` IS CHANGED AND `target` IS NOT. This cycle's command is
|
||||
# already decided; what is bounded is what gets CARRIED. That is also
|
||||
# why the exhaustive `i_w=None` equivalence sweep in test_control.py is
|
||||
# untouched by this card: it compares `(target_w, sat_count, frozen,
|
||||
# reason)`, none of which this moves, and the seeded path re-seeds from
|
||||
# `prev_w` next cycle anyway.
|
||||
_lead_cap = SLEW_LEAD_STEPS * slew
|
||||
if target > slewed:
|
||||
i_w = min(i_w, slewed + _lead_cap)
|
||||
elif target < slewed:
|
||||
i_w = max(i_w, slewed - _lead_cap)
|
||||
target = slewed
|
||||
|
||||
if frozen:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -175,6 +175,124 @@ only one of them is WIRED - see `BOUNDS` below and the note on SoC.
|
||||
integrator, and K. It does NOT owe a second freshness mechanism - `Freshness`
|
||||
below takes its obligation term from the caller precisely so that an energy
|
||||
integral can be handed in where command travel is handed in here.
|
||||
|
||||
|
||||
THE EXIT, AND WHY THE FIRST VERSION OF THIS MODULE DID NOT HAVE ONE (SAFETY-57)
|
||||
==============================================================================
|
||||
⚠️ THE VERDICT ABOVE IS ABOUT AN OBLIGATION, AND ITS OWN CONSEQUENCE REMOVES
|
||||
THE OBLIGATION. Declaring the reading stale makes `Controller.inputs_ok` False,
|
||||
which commands 0 W (§11.1 rule 1). At 0 W nothing is asking the register to be
|
||||
anywhere - `|standing| >= MOVE_W` is false, so the conjunction cannot arm - and
|
||||
the class docstring's clearing rule, "an arrival carrying a DIFFERENT number",
|
||||
asks the register for evidence that only a command could have produced. A pack
|
||||
sitting at its BMS floor answers the same integer watt for hours. **The state
|
||||
had no exit reachable from inside it**, and the operator-facing line said the
|
||||
opposite: *"It clears itself the moment the register reports a different
|
||||
value."*
|
||||
|
||||
MEASURED ON THE LIVE SITE, 2026-09-21, and committed as
|
||||
`scenarios/safety57-2026-09-21/` - a passive RS-485 capture, decoded by
|
||||
`tools/decode_bus_capture.py`:
|
||||
|
||||
10:51:24.7 0x0518 reaches -3 W. The pack is at 11 % and the loop has just
|
||||
reversed from -447 W of charge into discharge; the BMS refuses
|
||||
and the inverter idles. The register is HEALTHY and TRUTHFUL.
|
||||
10:51:29.7 the standing command reaches +1060 W. 800 W of travel, and the
|
||||
register is obliged to follow it.
|
||||
10:52:00 the conjunction arms. `Inverter.batt` becomes None.
|
||||
10:52:21.7 the site commands 0 W - on the wire, in the capture.
|
||||
... 0x0518 does not change value for 850.0 s. The setpoint does not
|
||||
change value for 790.0 s. Nothing in this add-on can end it.
|
||||
11:04 a human restarts the add-on.
|
||||
11:05:31.7 the restarted loop commands -400 W of charge, which the pack
|
||||
ACCEPTS at 11 %, and the register moves 3 s later.
|
||||
|
||||
⚠️ AND IT HAD BEEN ESCAPING BY LUCK. This BMS emits a keep-alive pulse of a few
|
||||
hundred watts, which twitches 0x0518 and satisfies the clearing rule by
|
||||
accident. Replaying the committed 16-day loop capture
|
||||
(`scenarios/loop-2026-09-02_2026-09-17/`) through the shipped conjunction at
|
||||
1 Hz gives **93 trips**, and the time from each trip to the register's next
|
||||
change - which was the ONLY exit - has a median of 143 s, 17 episodes at or
|
||||
over the live one's 710 s, and a worst case of 21440 s. That distribution is a
|
||||
property of this battery's firmware. It is not a property of this code, and the
|
||||
add-on must not depend on it.
|
||||
|
||||
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
|
||||
exit, not how long the block lasts":
|
||||
|
||||
RELEASE <=> the verdict is standing
|
||||
AND |standing command| < MOVE_W has held continuously for
|
||||
>= RELEASE_S
|
||||
|
||||
On a release the latch clears and a NEW grading window opens at that instant:
|
||||
the register is re-graded, from scratch, against the commands issued AFTER it.
|
||||
Nothing about the register's own history is rewritten - `changed_mono`,
|
||||
`held` and `unchanged_s()` go on reporting that the register has not moved for
|
||||
850 s, because it has not.
|
||||
|
||||
⚠️ `RELEASE_S` IS `BATT_OBLIGED_S`, 17.0 s, AND IT IS THE FLOOR OF ITS BAND
|
||||
RATHER THAN A PREFERENCE - the mirror of `MOVE_W` taking the ceiling of its
|
||||
own. The question the bound answers is *has the register had its chance to
|
||||
answer the command that is now standing?*, and that is exactly the 17.0 s
|
||||
already derived above: 6.0 s of plant, one 10 s publish, one 1 s read. Below it
|
||||
the release happens before the cheaper exit - the register moving by itself -
|
||||
could have occurred. Which half is safe is the opposite of SAFETY-13's, and for
|
||||
the reason that card states in its §4.2 table: being wrong LONG here is the
|
||||
failure being fixed (a site parked while it exports), and being wrong SHORT
|
||||
costs one re-grading window, which is bounded below.
|
||||
|
||||
WHAT A RELEASE COSTS, MEASURED AND NOT ABSORBED - AND IT IS NOT SMALL. For the
|
||||
duration of the new window the consumers get the held number again, which is
|
||||
the exposure this module was written to close. Driven at the live install's
|
||||
shape against a register frozen for good (`test_freshness.py` part 10c), over
|
||||
1200 s:
|
||||
|
||||
shipped parked 1166 s, the held number reaches a consumer 35 s 2.9 %
|
||||
SAFETY-57 parked 571 s, the held number reaches a consumer 630 s 52.5 %
|
||||
|
||||
⚠️ **52 % IS THE PRICE OF THE EXIT AND IT IS PAID EVERY CYCLE**, not an
|
||||
occasional slip. The period is the whole of `stale_input_s` (15 s) + `RELEASE_S`
|
||||
(17 s) parked, then one `BATT_UNCHANGED_S` (35 s) window of re-grading. It is
|
||||
bounded by construction, and that bound is the one thing that makes it payable:
|
||||
|
||||
the new window is AT MOST BATT_UNCHANGED_S = 35 s, and 35 < 75 < 85 - the
|
||||
circulation hold and the per-unit sign hold. NEITHER CONSUMER CAN REACH A
|
||||
VERDICT INSIDE ONE WINDOW, and a re-trip returns them a None, which CLEARS
|
||||
both windows rather than accumulating into them.
|
||||
|
||||
That inequality is the same one part 6 already grades, and it is why the window
|
||||
is `BATT_UNCHANGED_S` rather than a number chosen to make the table above look
|
||||
better. Neither detector can be made to say anything by a held number under
|
||||
this scheme - which is exactly the state the shipped code leaves them in as
|
||||
well, since a permanent None clears both windows for ever. **So nothing that
|
||||
SAFETY-37 detects is given back.** What the held number does reach for those
|
||||
35 s is `site_batt_w`, the anti-windup input of `control.compute()`, which
|
||||
responds to a large unexplained divergence by FREEZING the output - the
|
||||
conservative direction, and SAFETY-13's business. And the command itself is
|
||||
derived from the P1 meter, never from `batt`, so a site regulating through a
|
||||
re-grading window is regulating on the signal it always regulates on.
|
||||
|
||||
**A register frozen under a command that never travels 250 W is still not
|
||||
detected at all** (survivor 2, unchanged), and a dead publisher whose value
|
||||
alternates is still invisible (survivor 3).
|
||||
|
||||
⚠️ AND THE NARROWING THIS DOES NOT TAKE, named so the next card can. The live
|
||||
episode's register refused a DISCHARGE into an 11 % pack and then followed a
|
||||
CHARGE immediately; a refusal in one direction is not evidence about the other,
|
||||
and a gate that knew the pack's floor could re-grade on the first command that
|
||||
crosses it instead of on a timer. That needs SoC, which this module deliberately
|
||||
does not take (see the `soc` row), and the pack floor, which is SAFETY-32's
|
||||
number. It would shrink the 52 % above; it is not needed to give the state an
|
||||
exit, and mixing it in here would make the exit depend on a second measurement
|
||||
being right.
|
||||
|
||||
⚠️ WHAT THIS DOES NOT DO, because the temptation is to call it a fix for the
|
||||
whole episode: it does not tell a BMS refusal apart from a dead register. The
|
||||
site above was parked for 790 s by a register that was working perfectly, and
|
||||
after this change it is parked for `stale_input_s + RELEASE_S` instead - about
|
||||
32 s on the shipped default - and then regrades. Telling the two apart needs
|
||||
the pack's own floor, which is SAFETY-32's number and not this module's input;
|
||||
the wording the operator reads about it is SAFETY-56's.
|
||||
"""
|
||||
|
||||
import time
|
||||
@@ -211,6 +329,19 @@ BATT_UNCHANGED_S = 35.0
|
||||
# and goes red if the two drift.
|
||||
MOVE_W = 250.0
|
||||
|
||||
# SAFETY-57. How long the obligation must have been ABSENT before a standing
|
||||
# verdict is withdrawn and the register re-graded. See the module docstring's
|
||||
# last section for the live episode this exists for and for which half of the
|
||||
# band is the safe one.
|
||||
#
|
||||
# ⚠️ IT IS `BATT_OBLIGED_S` AND NOT A NEW NUMBER, and that identity is the
|
||||
# derivation rather than a convenience. The question is "has the register had
|
||||
# its chance to answer the command standing NOW?", which is the same physical
|
||||
# quantity the floor of `BATT_UNCHANGED_S`'s band is: 6.0 s of plant, one 10 s
|
||||
# ESPHome publish, one 1 s add-on read. Releasing sooner would throw away the
|
||||
# cheaper exit - the register moving by itself - before it could have happened.
|
||||
RELEASE_S = BATT_OBLIGED_S # 17.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bound:
|
||||
@@ -219,6 +350,7 @@ class Bound:
|
||||
signal: str
|
||||
unchanged_s: float
|
||||
move_w: float
|
||||
release_s: float
|
||||
wired: bool
|
||||
derivation: str
|
||||
|
||||
@@ -233,13 +365,19 @@ BOUNDS = {
|
||||
signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)",
|
||||
unchanged_s=BATT_UNCHANGED_S,
|
||||
move_w=MOVE_W,
|
||||
release_s=RELEASE_S,
|
||||
wired=True,
|
||||
derivation="settle 6.0 + publish 10.0 + read 1.0 = 17.0 s floor; "
|
||||
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]"),
|
||||
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]. "
|
||||
"SAFETY-57: the verdict is withdrawn after 17.0 s of the "
|
||||
"standing command below 250 W, because at 0 W the register "
|
||||
"is obliged to nothing and the verdict cannot be tested "
|
||||
"again from inside the state it creates"),
|
||||
"soc": Bound(
|
||||
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
|
||||
unchanged_s=float("inf"),
|
||||
move_w=float("inf"),
|
||||
release_s=float("inf"),
|
||||
wired=False,
|
||||
derivation="TEL-08. Command travel is the WRONG obligation term for "
|
||||
"SoC - a rested pack holds a percent for hours under a 0 W "
|
||||
@@ -263,10 +401,33 @@ class Freshness:
|
||||
Declaring the reading stale makes `Controller.inputs_ok` False, which
|
||||
commands 0 W (§11.1 rule 1), which drops the standing command below
|
||||
`MOVE_W`, which un-arms the conjunction, which would return the held number
|
||||
to the consumers - and round again every read. So the ONLY thing that clears
|
||||
the latch is an arrival carrying a DIFFERENT number, which is exactly the
|
||||
evidence the verdict was about. A register that is working clears it on its
|
||||
next 10 s publish; a register that is not, does not.
|
||||
to the consumers - and round again every read. So an arrival carrying a
|
||||
DIFFERENT number clears the latch, which is exactly the evidence the verdict
|
||||
was about. A register that is working clears it on its next 10 s publish; a
|
||||
register that is not, does not.
|
||||
|
||||
⚠️ AND IT IS NOT THE ONLY THING THAT CLEARS IT ANY MORE (SAFETY-57), BECAUSE
|
||||
THAT MADE THE STATE INESCAPABLE FROM INSIDE. The command the verdict itself
|
||||
produces is 0 W, and at 0 W the register is obliged to be nowhere - so
|
||||
"report a different value" is evidence a parked site cannot ask for. The
|
||||
live site sat at 0 W for 790 s on 2026-09-21 with a register that was
|
||||
working correctly, and was freed by a human. `_release()` below withdraws a
|
||||
verdict whose obligation has been gone for `bound.release_s`, and opens a
|
||||
new grading window at that instant. The 1 Hz oscillation the latch exists to
|
||||
prevent is still prevented: the new window is `bound.unchanged_s` wide, so
|
||||
no re-trip can land sooner than 35 s after a release, rather than on the
|
||||
next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if
|
||||
the site rests for longer than the window before it asks for anything, the
|
||||
window is already old and the re-trip lands on the first read at which the
|
||||
command travels 250 W again. That is survivor 5's shape, it predates this
|
||||
card, and its cost is one `stale_input_s` hold.
|
||||
|
||||
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY.
|
||||
`changed_mono` / `held` are facts about the REGISTER: when it last carried a
|
||||
different number. `graded_from` / `standing_at_grade` are the window the
|
||||
VERDICT is computed over, which a release re-opens. They are the same thing
|
||||
until the first release and diverge afterwards, and `unchanged_s()` - the
|
||||
number an operator reads - is always the register's.
|
||||
"""
|
||||
|
||||
def __init__(self, bound: Bound):
|
||||
@@ -281,10 +442,20 @@ class Freshness:
|
||||
# ability to fetch it.
|
||||
self.held = None
|
||||
self.changed_mono = None
|
||||
# The standing command at that same instant. The conjunction compares
|
||||
# against THIS, not against the previous cycle's command: what matters
|
||||
# is whether the register has followed anything since it last moved.
|
||||
self.standing_at_change = None
|
||||
# SAFETY-57. The window the VERDICT is graded over, as opposed to the
|
||||
# register's own history above, and the standing command at the instant
|
||||
# it opened. The conjunction compares against THIS, not against the
|
||||
# previous cycle's command: what matters is whether the register has
|
||||
# followed anything since the current question was put to it. A register
|
||||
# that moves opens a window, so until the first release these two are
|
||||
# the change-stamps; `_release()` is where they diverge.
|
||||
self.graded_from = None
|
||||
self.standing_at_grade = None
|
||||
# The instant the standing command last FELL below `move_w`, or None
|
||||
# while an obligation is standing. The release timer runs off this.
|
||||
self.rested_since = None
|
||||
self.released_at = None
|
||||
self.releases = 0
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
self.arrivals = 0
|
||||
@@ -311,20 +482,67 @@ class Freshness:
|
||||
if self.changed_mono is None or value != self.held:
|
||||
self.held = value
|
||||
self.changed_mono = float(now_mono)
|
||||
self.standing_at_change = (None if standing_w is None
|
||||
else float(standing_w))
|
||||
# Movement is the ONLY thing that clears the latch. See the class
|
||||
# docstring on the 1 Hz oscillation this prevents.
|
||||
self._open_window(standing_w, now_mono)
|
||||
# Movement clears the latch, because it is exactly the evidence the
|
||||
# verdict was about. See the class docstring on the 1 Hz oscillation
|
||||
# the latch prevents, and `_release()` for the other way out.
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
self.last = value
|
||||
# ⚠️ THE ORDER OF THESE TWO IS NOT OBSERVABLE, AND THIS COMMENT SAYS SO
|
||||
# BECAUSE THE FIRST VERSION OF IT CLAIMED THE OPPOSITE. Swapping them is
|
||||
# a ZERO-BYTE edit and it SURVIVED the battery; the reason is structural
|
||||
# rather than a missing fixture, so no fixture could have closed it:
|
||||
# `_grade()` acts only when `|standing| >= move_w` and `_release()` only
|
||||
# when `|standing| < move_w`, so at most one of them can do anything on
|
||||
# any given read, whichever runs first. Release is written first because
|
||||
# it reads in the order the state machine moves - withdraw, then
|
||||
# re-grade - not because the code depends on it.
|
||||
self._release(standing_w, now_mono)
|
||||
self._grade(standing_w, now_mono)
|
||||
|
||||
def _open_window(self, standing_w, now_mono) -> None:
|
||||
"""Start grading the register again, from this instant (SAFETY-57).
|
||||
|
||||
Called when the register moves - the window and the register's history
|
||||
coincide there - and when a verdict is withdrawn, where they do not.
|
||||
"""
|
||||
self.graded_from = float(now_mono)
|
||||
self.standing_at_grade = (None if standing_w is None
|
||||
else float(standing_w))
|
||||
|
||||
def _release(self, standing_w, now_mono) -> None:
|
||||
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
|
||||
|
||||
⚠️ THE TIMER RUNS ON THE COMMAND, NOT ON THE VERDICT. `rested_since` is
|
||||
maintained on every arrival, latched or not, so the elapsed rest is the
|
||||
real one and not "time since we last looked". An obligation reappearing
|
||||
resets it to None, which is the whole point: a site that goes back to
|
||||
asking the register for 250 W of movement has put the question back, and
|
||||
the verdict stands until the question is withdrawn again.
|
||||
"""
|
||||
obliged = (standing_w is not None
|
||||
and abs(float(standing_w)) >= self.bound.move_w)
|
||||
if obliged:
|
||||
self.rested_since = None
|
||||
return
|
||||
if self.rested_since is None:
|
||||
self.rested_since = float(now_mono)
|
||||
if not self.stale:
|
||||
return
|
||||
if float(now_mono) - self.rested_since < self.bound.release_s:
|
||||
return
|
||||
self.released_at = float(now_mono)
|
||||
self.releases += 1
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
self._open_window(standing_w, now_mono)
|
||||
|
||||
def _grade(self, standing_w, now_mono) -> None:
|
||||
"""The conjunction. Sets the latch; never clears it."""
|
||||
if self.stale or self.changed_mono is None:
|
||||
if self.stale or self.graded_from is None:
|
||||
return
|
||||
held_s = self.unchanged_s(now_mono)
|
||||
held_s = self.graded_s(now_mono)
|
||||
if held_s < self.bound.unchanged_s:
|
||||
return
|
||||
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
|
||||
@@ -333,30 +551,98 @@ class Freshness:
|
||||
# consumer today. They are here for TEL-08, whose obligation term is an
|
||||
# energy integral that has nothing to say until it has a window - and
|
||||
# "nothing to say" must withhold the verdict, not manufacture one.
|
||||
if standing_w is None or self.standing_at_change is None:
|
||||
if standing_w is None or self.standing_at_grade is None:
|
||||
return
|
||||
standing_w = float(standing_w)
|
||||
if abs(standing_w) < self.bound.move_w:
|
||||
return
|
||||
moved_w = abs(standing_w - self.standing_at_change)
|
||||
moved_w = abs(standing_w - self.standing_at_grade)
|
||||
if moved_w < self.bound.move_w:
|
||||
return
|
||||
self.stale = True
|
||||
# ⚠️ THE SENTENCE SAYS "unchanged", SO IT GETS THE REGISTER'S OWN
|
||||
# NUMBER, NOT THE WINDOW'S. They are the same until the first release
|
||||
# and different afterwards, and an operator comparing this line against
|
||||
# a history graph is reading the register.
|
||||
shown_s = self.unchanged_s(now_mono)
|
||||
# ⚠️ SAFETY-56. THE OBSERVATION IS A FACT, THE DIAGNOSIS WAS NOT, AND
|
||||
# THE SENTENCE NOW STOPS WHERE THE EVIDENCE DOES. The conjunction above
|
||||
# is satisfied by TWO disjoint worlds, and nothing this object is handed
|
||||
# separates them:
|
||||
# (1) the register has stopped reporting reality - what the gate was
|
||||
# built for - or
|
||||
# (2) reality is not moving, because the SOURCE cannot comply with
|
||||
# the command. A pack the BMS is refusing at its floor holds
|
||||
# still under any setpoint, and a register reporting that
|
||||
# truthfully publishes the same unchanged number a dead one does.
|
||||
# The live 2026-09-21 episode was (2) - the pack read 11 % for the
|
||||
# whole 850 s hold, the commands were discharge-only, and the register
|
||||
# answered the first CHARGE command within one obligation window
|
||||
# (test_freshness.py 11a measures all three off the committed capture)
|
||||
# - and this sentence asserted (1), which sent the operator to look at
|
||||
# healthy hardware.
|
||||
# Naming both is what the module can honestly say. Naming WHICH would
|
||||
# need SoC and the pack floor - deliberately not this module's inputs
|
||||
# (see the `soc` row in BOUNDS and the docstring's last section), and
|
||||
# TEL-08's and SAFETY-32's numbers rather than this sentence's.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT SHARED WITH `main.py`'s NOT READY ROW, WHICH SAYS
|
||||
# THE SAME AMBIGUITY IN DIFFERENT WORDS ON PURPOSE. This sentence is a
|
||||
# LOG LINE about a behaviour, quoted verbatim by `note()` onto the
|
||||
# status page and by `_read_unit()` into the action log; that one is a
|
||||
# BANNER about a named ENTITY, and the two answer different questions
|
||||
# ("what did the register do" against "can I trust this entity"). One
|
||||
# helper serving both would have to drop whichever half the other does
|
||||
# not need, and the half it dropped would be the operator's next step.
|
||||
self.reason = (
|
||||
f"STALE_READING: {self.held:+.0f} unchanged for {held_s:.0f} s "
|
||||
f"STALE_READING: {self.held:+.0f} unchanged for {shown_s:.0f} s "
|
||||
f"while the standing command moved {moved_w:.0f} W to "
|
||||
f"{standing_w:+.0f} W - the reading is not following, so it is "
|
||||
f"{standing_w:+.0f} W. TWO THINGS LOOK LIKE THIS AND THIS GATE "
|
||||
f"CANNOT TELL THEM APART: the register may have stopped reporting "
|
||||
f"reality, or the source may be unable to comply with the command "
|
||||
f"- a pack the BMS is refusing holds still under any setpoint, and "
|
||||
f"an honest register then reads exactly like a frozen one. Either "
|
||||
f"way the number cannot be read as this command's effect, so it is "
|
||||
f"reported as MISSING rather than as a measurement (limits: "
|
||||
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
|
||||
f"{self.bound.move_w:.0f} W and moved by over "
|
||||
f"{self.bound.move_w:.0f} W)")
|
||||
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
|
||||
# in its own slot: the register's unchanged time is in the sentence
|
||||
# above, the WINDOW this verdict actually rests on is here, and so is
|
||||
# how many times this unit has been round the loop. Without the second
|
||||
# one the sentence reads as though 92 s of unchanged register were what
|
||||
# was compared against the 35 s limit, and it was not.
|
||||
if self.releases:
|
||||
self.reason += (
|
||||
f" [re-graded: the {held_s:.0f} s window this verdict rests on "
|
||||
f"opened when the previous one was withdrawn, not when the "
|
||||
f"register last moved - this unit has now been graded "
|
||||
f"{self.releases + 1} times]")
|
||||
|
||||
def unchanged_s(self, now_mono) -> float | None:
|
||||
"""Seconds since an arrival carried a different number. Status page."""
|
||||
"""Seconds since an arrival carried a different number. Status page.
|
||||
|
||||
⚠️ THE REGISTER'S CLOCK, NEVER THE VERDICT'S (SAFETY-57). A release
|
||||
re-opens the grading window; it does not make the register younger, and
|
||||
an operator reading "unchanged for 12 s" off a register that has held
|
||||
one value for 850 s would be reading this module's bookkeeping back as a
|
||||
measurement. `graded_s()` is the other one and is named differently for
|
||||
that reason.
|
||||
"""
|
||||
if self.changed_mono is None:
|
||||
return None
|
||||
return max(0.0, float(now_mono) - self.changed_mono)
|
||||
|
||||
def graded_s(self, now_mono) -> float | None:
|
||||
"""Seconds the CURRENT verdict window has been open (SAFETY-57).
|
||||
|
||||
Equal to `unchanged_s()` until the first release, and shorter after one.
|
||||
"""
|
||||
if self.graded_from is None:
|
||||
return None
|
||||
return max(0.0, float(now_mono) - self.graded_from)
|
||||
|
||||
def reading(self):
|
||||
"""What the consumers get: the value, or None when it cannot be trusted.
|
||||
|
||||
@@ -366,6 +652,13 @@ class Freshness:
|
||||
rather than accumulating it, and `circulation.circulating_w` returns None
|
||||
for a roster with a missing term, which clears the circulation window
|
||||
too. This module is the thing that was missing between them.
|
||||
|
||||
⚠️ AND IT CAN GO BACK TO BEING A NUMBER WITHOUT THE REGISTER MOVING
|
||||
(SAFETY-57). That is the exit, and it is bounded rather than free: the
|
||||
window a release opens is `bound.unchanged_s` = 35 s, shorter than both
|
||||
consumer holds (75 s and 85 s), so neither can reach a verdict inside
|
||||
one - and a re-trip hands them a None, which CLEARS their windows. See
|
||||
the module docstring's last section.
|
||||
"""
|
||||
return None if self.stale else self.last
|
||||
|
||||
@@ -373,6 +666,26 @@ class Freshness:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
return f"{unit_name}: {self.reason}" if self.stale else ""
|
||||
|
||||
def release_note(self, unit_name: str) -> str:
|
||||
"""SAFETY-57's release, for the log. Empty when nothing was withdrawn.
|
||||
|
||||
⚠️ IT NAMES WHAT IS AND IS NOT BEING CLAIMED, because a line reading
|
||||
"recovered" would be a lie: nothing has been learned about the register.
|
||||
The verdict was withdrawn because it can no longer be tested, and the
|
||||
next 35 s are the test.
|
||||
"""
|
||||
if self.released_at is None:
|
||||
return ""
|
||||
return (f"{unit_name}: STALE_WITHDRAWN after the standing command sat "
|
||||
f"below {self.bound.move_w:.0f} W for "
|
||||
f"{self.bound.release_s:.0f} s. NOTHING HAS BEEN LEARNED ABOUT "
|
||||
f"THE REGISTER - it is still holding {self.held:+.0f} - but a "
|
||||
f"verdict about a command that is no longer standing cannot be "
|
||||
f"tested, and the 0 W this one produces is what stopped it "
|
||||
f"being tested. The register is now re-graded over a fresh "
|
||||
f"{self.bound.unchanged_s:.0f} s window; if it still does not "
|
||||
f"follow, this returns")
|
||||
|
||||
|
||||
def monotonic() -> float:
|
||||
"""The clock this module's callers use, in one place so tests can find it.
|
||||
|
||||
+984
-30
File diff suppressed because it is too large
Load Diff
@@ -85,8 +85,114 @@ SENSORS = [
|
||||
# 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),
|
||||
# --- TEL-10: the loop's integrator, published for the first time ---------
|
||||
#
|
||||
# ⚠️ APPENDED, AND THE POSITION IS THE POINT. `discovery_payloads()` walks
|
||||
# this list in order and `_announce()` publishes what it returns, so a row
|
||||
# inserted ABOVE would move every later entity's place in the announcement.
|
||||
# Nothing existing is renamed, re-typed or re-ordered by these three; they
|
||||
# are three NEW retained topics after the nine that are already there, and
|
||||
# test_mqtt.py part 1 grades exactly that claim.
|
||||
#
|
||||
# ⚠️ AND THEY ARE SITE ROWS, NOT PER-UNIT ONES. There is one integrator -
|
||||
# §6.2's architecture is ONE integrator on the P1 meter whose output is then
|
||||
# SPLIT - so a per-unit copy would invent N of a thing there is one of, the
|
||||
# same argument `grid` and `p1_age` are site rows for.
|
||||
#
|
||||
# `state_class: measurement` on the two numbers is what puts them in the
|
||||
# recorder's statistics, which is the whole point: the reason this card
|
||||
# exists is that the last investigation had no history to read.
|
||||
("loop_i", "goodwe_loop_integrator", "Loop integrator", "W", "power", "measurement", None),
|
||||
# ⚠️ NOT DERIVABLE FROM THE TWO ROWS EITHER SIDE OF IT, which is why it is
|
||||
# its own row rather than a subtraction a dashboard could do. `setpoint` is
|
||||
# the ARBITER's output after every other strategy has had its say, and it is
|
||||
# also THIS cycle's; the lead is measured against the command `compute()`
|
||||
# was handed, which is the PREVIOUS one. Subtracting the published rows
|
||||
# gives a different number on every cycle the arbiter overrode the loop.
|
||||
("loop_lead", "goodwe_loop_integrator_lead", "Loop integrator lead", "W", "power", "measurement", None),
|
||||
# ⚠️ NOT `sensor.goodwe_controller_status` AND NOT THE "Why" ROW. Both of
|
||||
# those carry the ARBITER's sentence; this is `compute()`'s own word for
|
||||
# what the control law did with this cycle - `slew-limited` is the value
|
||||
# SAFETY-48 is about and it appears in neither of the other two.
|
||||
("loop_reason", "goodwe_loop_reason", "Loop reason", None, None, None, "mdi:sigma"),
|
||||
]
|
||||
|
||||
# --- TEL-11: rows that exist ONLY when the operator configured a source ------
|
||||
#
|
||||
# ⚠️ A SECOND LIST RATHER THAN A TENTH ENTRY IN `SENSORS`, AND THE DEFAULT IS
|
||||
# WHAT MAKES IT ONE. `SENSORS` is announced by every caller of
|
||||
# `discovery_payloads()`; `OPTIONAL_SENSORS` is announced only by a caller that
|
||||
# ASKS, so a caller that has not been taught about `pv_entity` - `test_mqtt.py`'s
|
||||
# fixtures, `tools/rehearse_roster.py`, anything written next year - announces
|
||||
# exactly what it announced before. `p1_age` solves the same problem the other
|
||||
# way round, with `omit`, because `p1_age` is announced by DEFAULT and withdrawn
|
||||
# when the meter is off; this one does not exist by default and appears when a
|
||||
# source for it does. Getting that direction wrong is the hazard `omit`'s own
|
||||
# note names: an entity that exists and is never fed is a worse signal than one
|
||||
# that does not exist at all, and MOST installs will never set `pv_entity`.
|
||||
#
|
||||
# ⚠️ `state_class: measurement` IS THE WHOLE POINT OF THE ROW. It is what puts
|
||||
# the number into the recorder's long-term statistics, and a week of statistics
|
||||
# is what OPT-10 needs to say whether the surplus that left this site was cloud,
|
||||
# a charge-power limit, or the optimiser declining to charge.
|
||||
OPTIONAL_SENSORS = [
|
||||
("pv", "goodwe_pv_power", "PV power", "W", "power", "measurement", None),
|
||||
]
|
||||
|
||||
# ⚠️ HA's OWN "this sensor has no value" PAYLOAD, and the one place this add-on
|
||||
# spells it. `homeassistant/components/mqtt/const.py` declares
|
||||
# `PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric
|
||||
# branch - `if payload == PAYLOAD_NONE: self._attr_native_value = None` - so the
|
||||
# entity's state becomes `unknown` rather than a number. (Read out of
|
||||
# github.com/home-assistant/core `dev` on 2026-09-24; the shipped MQTT-sensor
|
||||
# documentation says the same thing in its `state_topic` entry: "a 'None' value
|
||||
# will set the sensor to an unknown state".)
|
||||
PAYLOAD_NONE = "None"
|
||||
|
||||
# The keys whose ABSENCE MUST BE PUBLISHED rather than skipped. ⚠️ THIS IS THE
|
||||
# DIFFERENCE BETWEEN "no PV reported" AND "PV reported 0 W" IN A WEEK OF
|
||||
# HISTORY, AND WITHOUT IT THE CHANNEL LIES IN THE WORSE DIRECTION. `publish()`
|
||||
# below drops a `None` - which is right for `circulation`, where the last figure
|
||||
# standing is better than a zero that reads as health - but a DROPPED pv row
|
||||
# leaves Home Assistant holding the last number it was told, forever, and the
|
||||
# recorder then draws a flat line at that value through an outage. Someone
|
||||
# reading it back would conclude the sun was not shining, which is precisely the
|
||||
# conclusion this card exists to TEST rather than to manufacture.
|
||||
ABSENT_AS_UNKNOWN = frozenset({"pv"})
|
||||
|
||||
|
||||
def optional_keys(opts: dict) -> tuple:
|
||||
"""Which `OPTIONAL_SENSORS` rows this configuration has a source for.
|
||||
|
||||
⚠️ ONE ANSWER TO "IS THE PV ROW LIVE", read by the ANNOUNCEMENT (through
|
||||
`MqttPublisher`) and by `Controller.publish()`. `announced_units()` above
|
||||
exists for exactly this reason and says it in full: two spellings of one
|
||||
predicate is how an entity gets announced that nothing feeds, or fed on a
|
||||
topic nothing announced.
|
||||
|
||||
⚠️ AND SINCE TEL-12 IT IS `pv.entities()` RATHER THAN A SECOND READING OF
|
||||
THE OPTION - `Controller.pv_roster` is the SAME call. TEL-11 got away with
|
||||
two spellings because both were `bool()` of one string; with `pv_entities`
|
||||
beside `pv_entity`, and `pv_entities` REPLACING it, a second spelling is how
|
||||
the row gets announced off one key and summed off the other. ⚠️ THE ROW IS
|
||||
ONE SITE TOTAL AT EVERY ROSTER SIZE: there is deliberately no per-entity
|
||||
topic, because the recorder statistic OPT-10 reads back is the site figure
|
||||
and a per-inverter breakdown is a dashboard question the operator's own PV
|
||||
integration already answers.
|
||||
"""
|
||||
# ⚠️ THE IMPORT IS FUNCTION-LOCAL AND THAT IS NOT A STYLE CHOICE. This
|
||||
# module is EXEC'D STANDALONE, outside its package, by `test_mqtt.py`
|
||||
# part 8's mutation harness - which says so in its own comment, and whose
|
||||
# honesty depends on it ("`app/mqtt.py` imports nothing from this package,
|
||||
# which is what makes exec'ing it standalone honest rather than a stub").
|
||||
# A module-scope `from . import pv` turns that whole instrument into an
|
||||
# `ImportError` before it grades anything. `tools/tel11_pv_trace.py`'s
|
||||
# `_load()` is the same decision for the same kind of reason. Measured, not
|
||||
# assumed: move this line to module scope and test_mqtt.py part 8 dies.
|
||||
from . import pv as _pv
|
||||
return ("pv",) if _pv.entities(opts) else ()
|
||||
|
||||
|
||||
BASE = "goodwe_ctl"
|
||||
AVAILABILITY = f"{BASE}/availability"
|
||||
|
||||
@@ -269,15 +375,23 @@ def _payload(name, object_id, unique_id, state_topic, device,
|
||||
return cfg
|
||||
|
||||
|
||||
def discovery_payloads(units=(), omit=()) -> list:
|
||||
def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
"""Every `(topic, payload)` this add-on announces, in announcement order.
|
||||
|
||||
Split out of `_announce()` so that the acceptance criterion can be graded as
|
||||
a DIFF OF THE ANNOUNCED PAYLOADS against `release/1.0`, rather than as a
|
||||
reading of the code. `_announce()` publishes exactly this list and nothing
|
||||
else, which is itself checked.
|
||||
|
||||
`include` names `OPTIONAL_SENSORS` keys to announce. ⚠️ IT DEFAULTS TO
|
||||
NOTHING AND THE ROWS GO LAST, so no existing row's POSITION moves under any
|
||||
configuration - not the site rows, not the per-unit ones. Order is part of
|
||||
the output here (`_payload()`'s note, and test_mqtt.py part 1 asserts an
|
||||
ordered LIST precisely because a re-order is a zero-byte edit that
|
||||
re-announces every entity on a live install).
|
||||
"""
|
||||
omit = set(omit)
|
||||
include = [k for k in include if k not in omit]
|
||||
out = []
|
||||
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
|
||||
if key in omit:
|
||||
@@ -306,12 +420,19 @@ def discovery_payloads(units=(), omit=()) -> list:
|
||||
unique_id,
|
||||
f"{BASE}/{unit_state_key(slug, key)}",
|
||||
device, unit, dev_class, state_class, icon)))
|
||||
for key, object_id, name, unit, dev_class, state_class, icon in OPTIONAL_SENSORS:
|
||||
if key not in include:
|
||||
continue
|
||||
out.append((
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
|
||||
DEVICE, unit, dev_class, state_class, icon)))
|
||||
return out
|
||||
|
||||
|
||||
class MqttPublisher:
|
||||
def __init__(self, host, port, username=None, password=None, omit=(),
|
||||
units=()):
|
||||
units=(), include=()):
|
||||
# `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
|
||||
@@ -323,6 +444,10 @@ class MqttPublisher:
|
||||
# per-unit is announced, so every caller that has not been taught about
|
||||
# the roster keeps publishing what it published before.
|
||||
self.units = [str(u) for u in units]
|
||||
# TEL-11. `mqtt.optional_keys(opts)` - the shipped predicate, not a
|
||||
# second reading of the option. Defaults to empty, so a caller that has
|
||||
# not been taught about it announces exactly what it announced before.
|
||||
self.include = [str(k) for k in include]
|
||||
self.enabled = mqtt is not None and bool(host)
|
||||
self.client = None
|
||||
if not self.enabled:
|
||||
@@ -359,7 +484,8 @@ class MqttPublisher:
|
||||
# announcement, so the check that diffs it against `release/1.0` is
|
||||
# diffing what actually goes on the wire - and a payload built here
|
||||
# instead would be one this add-on publishes and no check has ever seen.
|
||||
for topic, cfg in discovery_payloads(self.units, self.omit):
|
||||
for topic, cfg in discovery_payloads(self.units, self.omit,
|
||||
self.include):
|
||||
self.client.publish(topic, json.dumps(cfg), retain=True)
|
||||
self.client.publish(AVAILABILITY, "online", retain=True)
|
||||
|
||||
@@ -369,7 +495,16 @@ class MqttPublisher:
|
||||
try:
|
||||
for key, value in values.items():
|
||||
if value is None:
|
||||
continue
|
||||
# ⚠️ TEL-11. A DROPPED ROW IS NOT AN ABSENT ROW. Skipping
|
||||
# leaves Home Assistant holding the last value it was told,
|
||||
# which is what `circulation` wants (its note in main.py
|
||||
# says why) and is exactly wrong for a measurement somebody
|
||||
# will read back as history: a flat line at the last number
|
||||
# is indistinguishable from a real one. See
|
||||
# `ABSENT_AS_UNKNOWN`.
|
||||
if key not in ABSENT_AS_UNKNOWN:
|
||||
continue
|
||||
value = PAYLOAD_NONE
|
||||
self.client.publish(f"{BASE}/{key}", str(value))
|
||||
except OSError as err: # pragma: no cover
|
||||
_LOG.debug("MQTT publish failed: %s", err)
|
||||
|
||||
@@ -822,6 +822,145 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
f"only give {ceil_top:.0f} W" if saturated else "")))
|
||||
|
||||
|
||||
def charge_headroom_w(decision) -> float | None:
|
||||
"""SAFETY-45. The hardest this SITE may charge without putting the billed
|
||||
quarter over the ceiling, in W, positive - or None when nothing measured it.
|
||||
|
||||
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
|
||||
`discharge_permitted()`, `shaping_suspended()`, `guard_state()` and
|
||||
`looks_frozen()` are: the check that grades it has to assert on THE SHIPPED
|
||||
EXPRESSION, and a predicate transcribed into its own test is one copy plus a
|
||||
future disagreement about what the check means.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
THE DEFECT IT PREVENTS, AND IT IS NOT A NEW IDEA IN THIS REPOSITORY
|
||||
|
||||
`control.maintenance_charge_floor()` already caps MAINTENANCE charging at
|
||||
"the headroom left under the site's cap", and its docstring says why in
|
||||
words this card only has to widen:
|
||||
|
||||
"A maintenance charge is the only thing this system does that is big
|
||||
enough and long enough to set that peak, so it is capped by the
|
||||
headroom left under the site's cap."
|
||||
|
||||
⚠️ THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED. Price arbitrage is a
|
||||
SECOND thing that is big enough and long enough - it commands `max_w` for a
|
||||
whole tariff interval, deliberately, against the meter - and it was given no
|
||||
such cap. Measured on TEST-23 stage 3's closed rig at the shipped
|
||||
`peak_cap_w: 3500` / `max_w: 2000`: 28 charge onsets in one hour, mean period
|
||||
131 s, grid swinging -456 <-> +3200 W, on any house drawing over 1500 W in
|
||||
the cheapest quarter. §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."
|
||||
|
||||
⚠️ AND IT IS THE **MEASURED** GUARD, NOT `peak_forecast_entity`.
|
||||
`maintenance_charge_floor()` sizes itself from the forecast sensor, which is
|
||||
OPTIONAL and empty on a default install - the exact hole SAFETY-25 had to
|
||||
close in `shaping_suspended()`, where a suspension gated on the forecast
|
||||
alone could not fire on the very site that needed it. This number comes off
|
||||
`decide()`, which runs wherever P1 does.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
WHERE THE NUMBER COMES FROM: IT IS `wanted_w`, READ IN THE OTHER DIRECTION
|
||||
|
||||
`decide()` already computes
|
||||
|
||||
wanted = batt_w + excess = (the offtake there would be with the battery
|
||||
at zero) - (the level this quarter can still afford)
|
||||
|
||||
as the battery DISCHARGE that would land the quarter exactly on the ceiling.
|
||||
A negative `wanted` is not "nothing to say" - it is a permitted CHARGE of
|
||||
`-wanted` watts, and throwing it away is SAFETY-45's whole defect. So this
|
||||
function invents no arithmetic; it reads the sign the guard already had.
|
||||
|
||||
⚠️ AND THE QUANTITY IS INVARIANT IN THE BATTERY, WHICH IS WHY SIZING A TRADE
|
||||
TO IT CANNOT OSCILLATE. Raise the charge by dW and the meter rises by dW, so
|
||||
`excess` rises by exactly dW while `batt_w` falls by dW: `wanted` does not
|
||||
move. It is a FEED-FORWARD number - a function of the house, the ceiling and
|
||||
the seconds already banked - so consuming it adds no feedback path around the
|
||||
plant. The fixed point is `charge = ceiling - load`, with the meter sitting
|
||||
ON the ceiling and `wanted` parked at `load - ceiling`, a whole `ceiling -
|
||||
load` clear of the engagement boundary rather than chattering across it.
|
||||
|
||||
⚠️ AND A QUARTER BOUNDARY DOES NOT HAND IT BACK AT FULL POWER, WHICH IS THE
|
||||
OTHER HALF OF SAFETY-45 AND IS NOT THE SAME CLAIM AS "IT DOES NOT CHANGE".
|
||||
It DOES change - it is `(900 / remaining) * (ceiling - projected)`, so it is
|
||||
largest in the last seconds of a block and collapses at the boundary. What
|
||||
matters is WHAT IT COLLAPSES TO: at `elapsed = 0` the projection IS the held
|
||||
level, so the answer is `ceiling - (load - pv)` - the house and the cap, and
|
||||
nothing else. A trade refused at 14:59 therefore resumes at 15:00 at the rate
|
||||
the new quarter can afford, not at `max_w`, and §10.1's answer survives the
|
||||
boundary because the arithmetic does: no timer, no backoff constant, no state
|
||||
carried between calls.
|
||||
|
||||
⚠️ AND THE AMPLIFICATION IS BOUNDED IN ITS EFFECT, NOT MERELY LARGE. Hold a
|
||||
charge of exactly this size for the rest of the block and the block's average
|
||||
lands on `ceiling` EXACTLY, wherever in the block it started:
|
||||
|
||||
(acc + (held + headroom) * remaining) / 900
|
||||
= projected + (900/remaining) * (ceiling - projected) * remaining/900
|
||||
= ceiling
|
||||
|
||||
so 162 kW of headroom with 5 s left and 900 W of it with the whole quarter to
|
||||
go are the same statement about the bill. `test_peakguard.py` part 13 sweeps
|
||||
that identity across the block rather than arguing it here.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
THE THREE ANSWERS
|
||||
|
||||
`None` nothing measured it: the guard never ran (`meter_source: off`), or
|
||||
it ran with no battery reading to size a floor from, or the block
|
||||
had no seconds left to influence. ⚠️ None IS NOT UNLIMITED. A caller
|
||||
that treats it as "charge away" is `arbitrage.guard_state()`'s
|
||||
collapse of None into False, one layer down - a strategy creating
|
||||
the billed quantity while nothing measures the quarter it lands in.
|
||||
`0.0` measured, and the answer is no charging: the guard is BLIND (its
|
||||
claim already says so at `floor 0 W` and this agrees with it rather
|
||||
than restating it), or it is holding and `wanted >= 0`.
|
||||
`> 0` the watts a charge may pull this cycle.
|
||||
|
||||
⚠️ HOLDING IMPLIES ZERO, BY CONSTRUCTION AND NOT BY COINCIDENCE. Every state
|
||||
that files a claim with `engaged=True` has `wanted > 0`, `wanted is None` or
|
||||
`blind`, so this answers 0.0 or None there and can never hand a strategy
|
||||
permission the guard's own `lo` is at that instant refusing.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
⚠️ WHY THIS IS A SIZING AND NOT A CLAIM, AND DO NOT "SIMPLIFY" IT INTO ONE
|
||||
|
||||
The obvious tidier answer is to delete `decide()`'s `wanted <= 0` gate and
|
||||
let it file `lo = wanted` unconditionally - a NEGATIVE floor, meaning "you
|
||||
may charge, but no harder than this". The arithmetic is identical, it is
|
||||
continuous, and it would bind every claimant rather than only arbitrage. It
|
||||
was measured, considered and REJECTED, and the reason is not style:
|
||||
|
||||
* `main.py`'s cheap-window maintenance charge files
|
||||
`Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` - a NEGATIVE `hi`.
|
||||
arbiter.py clause 2 applies EVERY limit at or above the winner's priority
|
||||
and takes the most restrictive, with no notion of one limit out-ranking
|
||||
another. A peak `lo = -300 W` against a shaping `hi = -1000 W` is
|
||||
`lo > hi`: a CONTRADICTION, which resolves to 0 W - the battery PARKED,
|
||||
in the one quarter of the month that sets the bill. That is the exact
|
||||
failure `shaping_suspended()` exists to prevent, and the only way to
|
||||
close it would be to key that suppression on "a claim exists", which on
|
||||
every site this card is about suspends the maintenance charge-only
|
||||
shaping PERMANENTLY.
|
||||
* and it would change what `engaged` MEANS at five call sites outside this
|
||||
module - `arbitrage.guard_state()`, `shaping_suspended()`, `claim()`,
|
||||
main.py's edge-logged action line and main.py's status row - each of
|
||||
which today reads it as "the guard is HOLDING".
|
||||
|
||||
`control.maintenance_charge_floor()` is a sizing and not a limit for the same
|
||||
reason, which is why this is the shape that already fits.
|
||||
DECISIONS/SAFETY-45-sizing-the-trade.md §2b carries the full argument.
|
||||
"""
|
||||
if decision is None:
|
||||
return None
|
||||
if decision.blind:
|
||||
return 0.0
|
||||
if decision.wanted_w is None:
|
||||
return None
|
||||
return max(0.0, -float(decision.wanted_w))
|
||||
|
||||
|
||||
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
|
||||
"""Must the maintenance charge-only shaping stand down this cycle?
|
||||
|
||||
|
||||
+1112
-162
File diff suppressed because it is too large
Load Diff
@@ -55,17 +55,35 @@ So the flag is not passed through anywhere in this file:
|
||||
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.
|
||||
|
||||
⚠️ IT GRADES THE POINTS THAT ARE EMITTED AND NOTHING ABOUT THE ONES THAT
|
||||
ARE NOT (FCST-05). `_audit()` is a provenance check, NOT a completeness
|
||||
check: fed FCST-04's own decimation defect - four published points in an
|
||||
hour, only the last emitted - it ACCEPTS, and it accepts an empty output
|
||||
too. Measured, and the FCST-03 audit it replaced was equally blind, so
|
||||
this is a limit rather than a regression. What actually guards against a
|
||||
decimated series is the point COUNT and the pass-through IDENTITY checks
|
||||
in `test_price_forecast.py` (section on the quarter-hourly feed) and
|
||||
`test_price_history.py`; see `_audit()`'s own docstring for why the
|
||||
completeness rule is not expressible from its two arguments.
|
||||
* 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.
|
||||
⚠️ "PUBLISHED" DOES NOT MEAN "FIRM". §9.3's canonical format carries
|
||||
`provisional` PER POINT, not per series, precisely because a real-time /
|
||||
imbalance product's current-interval print is settled after the fact and is
|
||||
genuinely provisional while it is published. 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.
|
||||
|
||||
⚠️ NO SHIPPED PLUGIN EMITS ONE TODAY (TARIFF-07 withdrew the only declarer of
|
||||
`PriceProduct.REALTIME`), and that is exactly why this paragraph stays. The
|
||||
rule is a property of the FORMAT, not of any supplier; weakening it to
|
||||
"published implies False" while nothing exercises it is how the first real
|
||||
imbalance feed would arrive mispriced and flagged firm.
|
||||
|
||||
|
||||
THE FILL, AND WHY IT KEYS ON THE WALL CLOCK
|
||||
@@ -93,8 +111,9 @@ because an empty series is not constructible on purpose (price.py trap 3).
|
||||
|
||||
⚠️ A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR, and the hourly walk
|
||||
below is about WHICH HOURS NEED FILLING - never about what a published hour is
|
||||
allowed to contain. `eneco_eddy` is the Belgian imbalance product and settles
|
||||
per QUARTER, `_validate_raw()` accepts any strictly-increasing spacing, and a
|
||||
allowed to contain. BELGIAN DAY-AHEAD ITSELF SETTLES PER QUARTER since the
|
||||
15-minute MTU took effect on 2025-10-01 - `generic_dayahead` reads 96 points a
|
||||
day, not 24 - `_validate_raw()` accepts any strictly-increasing spacing, and a
|
||||
published hour may therefore hold four points. So EVERY published point inside
|
||||
the window is passed through, in order, and a carried point is added only for a
|
||||
whole hour with NO published coverage at all.
|
||||
@@ -160,7 +179,7 @@ class Provenance(Enum):
|
||||
flag" and "the others".
|
||||
"""
|
||||
PUBLISHED = "published" # the provider printed this hour. Flag: the
|
||||
# feed's own - an imbalance print may be
|
||||
# feed's own - a real-time print may be
|
||||
# published AND provisional (§9.2).
|
||||
CARRIED = "carried" # yesterday's same-wall-clock hour (§8.3).
|
||||
# Flag: ALWAYS True, by construction.
|
||||
@@ -225,14 +244,67 @@ def _audit(points, published) -> None:
|
||||
* CARRIED - the hour must not appear in `published` AT ALL, the flag must
|
||||
be exactly True, and that hour may hold no second point.
|
||||
|
||||
⚠️ WHAT REPLACED "AT MOST ONE POINT PER HOUR", AND WHY IT IS STRICTER. This
|
||||
audit began as a `seen` set: any repeated `_utc_hour` raised, because a
|
||||
duplicated hour is how a DST day gets mispriced. A bare hour count cannot
|
||||
express a quarter-hourly feed, where four distinct published points share
|
||||
one hour legitimately - so the count was replaced by the two rules above,
|
||||
which subsume it. A repeated CARRIED hour still raises; so does the same
|
||||
published point emitted twice; and a carried point may no longer share an
|
||||
hour with a published one, which the count never checked at all.
|
||||
⚠️ WHAT REPLACED "AT MOST ONE POINT PER HOUR", AND WHAT THAT DID AND DID
|
||||
NOT CHANGE. This audit began as FCST-03's `seen` set: any repeated
|
||||
`_utc_hour` raised, beside an identity rule `p is not src`. A bare hour
|
||||
count cannot express a quarter-hourly feed, where four distinct published
|
||||
points share one hour legitimately - so the count was replaced by the two
|
||||
rules above, which subsume it.
|
||||
|
||||
⚠️ AND IT IS NOT STRICTER. FCST-04's version of this paragraph claimed two
|
||||
strengthenings and BOTH WERE FALSE; FCST-05 measured it. A differential
|
||||
over the whole domain this audit can express - every published mask over 3
|
||||
hours crossed with every output series of length 0-3 drawn from
|
||||
{published, published-provisional, carried, carried-unflagged,
|
||||
copy-of-published} - found ZERO divergence in EITHER direction:
|
||||
|
||||
OLD raised, NEW accepted (WEAKER): 0
|
||||
OLD accepted, NEW raised (STRONGER): 0
|
||||
|
||||
The two specific claims, one at a time:
|
||||
|
||||
* "so does the same published point emitted twice" - the OLD `seen`
|
||||
count already raised on that, because emitting one point twice is one
|
||||
HOUR twice. Preserved by the identity/`emitted` rules, not added.
|
||||
* "a carried point may no longer share an hour with a published one,
|
||||
which the count never checked at all" - true of the COUNT, and the old
|
||||
IDENTITY rule raised on it anyway: a carried point in a published hour
|
||||
took the `else` branch and failed `p is not src`. The message was about
|
||||
a copy rather than about a carry, which is the only thing that moved.
|
||||
|
||||
The one real behavioural change is the intended RELAXATION - several
|
||||
distinct published points in one hour, which the old audit could not
|
||||
express at all and refused. That relaxation is why this was rewritten, and
|
||||
it stays. A right decision with a fabricated reason is still a defect here.
|
||||
|
||||
⚠️ AND THE REAL LIMIT, WHICH IS NOT A REGRESSION AND IS NOT SMALL: THIS
|
||||
AUDIT DOES NOT CHECK COMPLETENESS. It grades every point that IS emitted
|
||||
and says nothing about the ones that are not. Fed the H1 decimation
|
||||
directly - four published points in an hour, output emitting only the last
|
||||
- it ACCEPTS; so does two of four, and so does an EMPTY output. So `_audit`
|
||||
is NOT what stops H1 recurring. What stops it is the point COUNT and the
|
||||
IDENTITY checks in `test_price_history.py` and `test_price_forecast.py`,
|
||||
which compare the emitted series against the feed's own prints.
|
||||
|
||||
It is not made complete here, and the reason is structural rather than
|
||||
effort: `published` is `_published_by_hour(read)` over the WHOLE read,
|
||||
unwindowed, while `fill_from_yesterday()` emits only [since, until). A
|
||||
published point outside the window is legitimately absent, so "every
|
||||
published point appears" is not a true post-condition of these two
|
||||
arguments - it would need the window passed in, which is a different
|
||||
signature and a different check.
|
||||
|
||||
⚠️ AND THAT IS MEASURED, NOT ASSUMED. FCST-05 built the rule - raise unless
|
||||
every point of every `published[u]` is emitted, +162 B - and ran it. It
|
||||
takes the 1 h, 12 h and 48 h HORIZON checks in test_price_forecast.py RED,
|
||||
on the shipped fill and a healthy feed: at an off-the-hour instant the
|
||||
feed's already-elapsed prints are outside [since, until) and are correctly
|
||||
dropped, and the rule calls that a provenance failure. (The differential
|
||||
beside those checks scores the same rule as 49 STRONGER cases, so it is a
|
||||
real change of behaviour and not a no-op.) The rule as stated is therefore
|
||||
not merely unimplemented, it is WRONG - and stating a false post-condition
|
||||
is MUTATION.md habit 16's defect: an instrument that asserts a completeness
|
||||
it does not have is worse than no instrument.
|
||||
|
||||
⚠️ RAISES, NEVER REPAIRS. A module that silently corrects its own
|
||||
provenance is one whose provenance means nothing; the only safe response to
|
||||
@@ -350,9 +422,19 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
_audit(out, published)
|
||||
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
|
||||
if carried:
|
||||
# ⚠️ THREE QUANTITIES, THREE NAMES (TARIFF-11). This line read
|
||||
# "%d of %d hours ... ; %d published" with `carried` (a count of
|
||||
# carried HOURS - the fill emits exactly one point per unpublished
|
||||
# hour) over `len(out)` (a count of POINTS, four per hour wherever the
|
||||
# feed printed) and `len(out) - carried` (POINTS again) under the same
|
||||
# noun. On the Belgian quarter-hourly feed that reads "33 of 141
|
||||
# hours" about a 48-hour horizon. The fill's unit is the HOUR and the
|
||||
# feed's is the POINT; nothing derived from one answers for the other.
|
||||
total_h = len({_utc_hour(p.start) for p in out})
|
||||
_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)
|
||||
"profile and flagged provisional; the feed published %d "
|
||||
"points over the other %d hours",
|
||||
carried, total_h, len(out) - carried, total_h - carried)
|
||||
|
||||
product = read.match(series=lambda r: r.product,
|
||||
no_data=lambda _r: yesterday.product)
|
||||
|
||||
@@ -61,6 +61,38 @@ yesterday that works all day and is gone on the next restart, which is exactly
|
||||
the silently-forgetting cache this rule exists to refuse. A store that cannot
|
||||
persist now records nothing, and both of its answers say the same thing.
|
||||
|
||||
⚠️ AND "CANNOT PERSIST" IS ONE QUESTION, ASKED ONCE, ABOUT THE OUTCOME -
|
||||
FCST-06. The paragraph above enforced that rule against `not self.ok`, which is
|
||||
one SYMPTOM of not persisting: the durable volume is missing. There is a second,
|
||||
and FCST-06 measured it. With `ok=True` and the directory present, `_save()` can
|
||||
still fail - a read-only remount (`[Errno 30] Read-only file system`), a full
|
||||
volume (`[Errno 28] No space left on device`) - and it logs, records
|
||||
`last_error` and returns. `_remember()` had already populated `self._days`, so
|
||||
`words` read `'2026-01-14 (24 points over 24 h); 2026-01-15 (24 points over
|
||||
24 h) [could not write (...)]'` while the file on disk held `{}`, and
|
||||
`yesterday()` answered 24 REAL POINTS for the rest of the process's life. Gone
|
||||
on the next restart, and nothing said it would be - the same silently-forgetting
|
||||
cache, reached by the condition the rule did not name.
|
||||
|
||||
⚠️ SO IT IS NOT TWO RULES, AND IT IS DELIBERATELY NOT TWO TESTS. "Can this
|
||||
store persist?" CANNOT BE ANSWERED BEFORE THE ATTEMPT - a directory that existed
|
||||
and was writable a moment ago is not a write that completed - so it is answered
|
||||
AFTER it, once, by the only thing holding the evidence: `_save()` returns
|
||||
whether the file now holds what memory holds. A missing volume is one of its
|
||||
False cases and an `OSError` from the write is another, and `_remember()` KEEPS
|
||||
ONLY WHAT THAT ONE ANSWER SAYS WAS KEPT. There is no `not self.ok` test in
|
||||
`_remember()` any more, because there is nothing left for a second test to add -
|
||||
and a third symptom nobody has thought of is how this defect arrived both times.
|
||||
|
||||
⚠️ AND WHAT IS KEPT IS THE LAST STATE THAT IS ON DISK, NOT AN EMPTY ONE. The
|
||||
MERGE is undone, not the store. A site whose volume fills at 14:00 goes on
|
||||
answering the yesterday it wrote at 13:30 - that record survives a restart, so
|
||||
answering it is not a promise the store cannot keep. What it stops doing is
|
||||
adding to it. The invariant the rule was always describing, stated once:
|
||||
`self._days` NEVER HOLDS A POINT THE FILE DOES NOT. (Not "mirrors the file" -
|
||||
`_usable_days()` drops records the file still holds. The only direction that can
|
||||
invent a yesterday is the one named.)
|
||||
|
||||
|
||||
THE FOUR RULES THIS FILE IS SHAPED BY
|
||||
=====================================
|
||||
@@ -75,7 +107,8 @@ THE FOUR RULES THIS FILE IS SHAPED BY
|
||||
|
||||
⚠️ NO VALUE-LEVEL CHECK CAN CATCH THAT, and saying so is more useful than a
|
||||
guarantee that would not hold. A carried point and a published-provisional
|
||||
point (`eneco_eddy`'s current quarter, §9.2) are the same three fields with
|
||||
point (a real-time product's current interval, §9.2) are the same three
|
||||
fields with
|
||||
the same flag; the difference is provenance, and provenance is not in the
|
||||
value. So the mechanism is ORDERING, not validation: `carry()` is the only
|
||||
public entry point, it remembers the argument and returns the fill, and the
|
||||
@@ -148,6 +181,7 @@ and turning it on is a product decision, not a wiring one. See
|
||||
DECISIONS/FCST-04-the-day-that-is-carried.md.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -208,8 +242,65 @@ def _yesterday_of(instant: datetime) -> str:
|
||||
return (brussels(instant).date() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _n_points(rec) -> int:
|
||||
"""How many points a day record holds. 0 for anything that is not one.
|
||||
def _point_of(row):
|
||||
"""One stored row -> a `PricePoint`, or `None` if the row is not one.
|
||||
|
||||
THE ONE VALIDATOR, AND THE ASYMMETRY IT CLOSES (FCST-05's MEDIUM). This
|
||||
file used to hold TWO standards for what a stored point is. The READ path
|
||||
(`_parse_points()`) was properly defensive - every field checked, a
|
||||
malformed row dropped and reported. The WRITE path was
|
||||
`{q["start"]: q for q in raw if isinstance(q, dict) and "start" in q}`
|
||||
followed by `sorted(..., key=lambda q: q["start"])`, which trusts
|
||||
`q["start"]` to be a hashable, sortable string it never checked. Measured,
|
||||
with the malformed row under the day the feed is currently writing to:
|
||||
|
||||
start is an int -> TypeError: '<' not supported between instances
|
||||
of 'str' and 'int'
|
||||
start is a dict -> TypeError: cannot use 'dict' as a dict key
|
||||
start is null -> TypeError: '<' not supported between 'str' and
|
||||
'NoneType'
|
||||
start is a list -> TypeError: cannot use 'list' as a dict key
|
||||
|
||||
⚠️ AND THE BLAST RADIUS IS PERMANENT, WHICH IS WHY IT IS NOT MERELY UNTIDY.
|
||||
`_remember()` is reached through `carry()` -> `resolve_tariff()` ->
|
||||
`run_tariff_refresh`, which catches `Exception` - so the add-on stays UP
|
||||
and EVERY refresh raises forever. The plan retires after
|
||||
`TARIFF_MAX_AGE_S`, and arbitrage and the cheap window are off from then
|
||||
on. There is no self-heal, because `_remember()` raises before it reaches
|
||||
`_save()`. `_usable_days()` only required `rec["points"]` to be a LIST; the
|
||||
elements were unchecked on the write path, and the existing fixture put its
|
||||
malformed row under a day the feed was NOT writing to, so `_remember()`
|
||||
never touched it.
|
||||
|
||||
So both paths call this, and neither of them has a standard of its own.
|
||||
Returning `None` rather than raising is the contract: a caller decides
|
||||
whether a bad row is dropped (both of them do) and how it is reported.
|
||||
"""
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
try:
|
||||
start = datetime.fromisoformat(row["start"])
|
||||
value = float(row["eur_per_kwh"])
|
||||
flag = row["provisional"]
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if start.tzinfo is None or start.utcoffset() is None \
|
||||
or not isinstance(flag, bool):
|
||||
return None
|
||||
return PricePoint(start=start, eur_per_kwh=value, provisional=flag)
|
||||
|
||||
|
||||
def _held(rec) -> tuple:
|
||||
"""`(points, whole hours they cover)` for a day record. `(0, 0)` otherwise.
|
||||
|
||||
⚠️ POINTS AND HOURS ARE TWO NUMBERS, AND FCST-04's STATUS ROW PRINTED THE
|
||||
FIRST UNDER THE SECOND'S NAME. The row was `f"{d} ({_n_points(rec)} h)"`,
|
||||
which counts POINTS: on the quarter-hourly feed this add-on already
|
||||
supports it read `'2026-01-13 (96 h)'` - 96 "h" for a 24-hour day. That is
|
||||
the points/hours conflation H1 was raised for, on the row H1's own ticket
|
||||
shipped. Both numbers are reported now, so an operator can see a day that
|
||||
is 24 points over 24 h and a day that is 96 points over 24 h and tell them
|
||||
apart, and neither number is wearing the other's unit.
|
||||
|
||||
⚠️ TOLERANT, AND IT IS THE SECOND LINE OF DEFENCE, NOT THE FIRST. `_load()`
|
||||
drops a malformed day record and says so, so this should never see one. It
|
||||
@@ -220,11 +311,52 @@ def _n_points(rec) -> int:
|
||||
"oops"}}` gave `ok=True`, `last_error=None`, and an `AttributeError` out of
|
||||
`words`, `yesterday()` and `resolve_tariff()` alike, with no self-heal
|
||||
because `_remember()` raised before it could rewrite the file.
|
||||
|
||||
⚠️ AND IT COUNTS ONLY ROWS THAT ARE POINTS. A row `_point_of()` refuses is
|
||||
not held prices, and a row count that included it would report a store as
|
||||
healthier than it is - which is the second half of FCST-05's MEDIUM: the
|
||||
status row said `'2026-01-15 (1 h)'` about a record whose single row was
|
||||
killing every tariff refresh. What is dropped is named in `last_error`, and
|
||||
`words` prints that beside the counts.
|
||||
"""
|
||||
if not isinstance(rec, dict):
|
||||
return (0, 0)
|
||||
raw = rec.get("points")
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return (0, 0)
|
||||
n, hours = 0, set()
|
||||
for row in raw:
|
||||
pt = _point_of(row)
|
||||
if pt is None:
|
||||
continue
|
||||
n += 1
|
||||
hours.add(pt.start.astimezone(timezone.utc)
|
||||
.replace(minute=0, second=0, microsecond=0))
|
||||
return (n, len(hours))
|
||||
|
||||
|
||||
def _row_of(day: str, points: int, hours: int) -> str:
|
||||
"""One day's cell of the status row: what is held, in BOTH of its units.
|
||||
|
||||
⚠️ THE UNIT IS SPELT OUT ON EACH NUMBER. FCST-04's cell was
|
||||
`f"{day} ({points} h)"` - the point count wearing the hour's name, which on
|
||||
a quarter-hourly feed reads `'2026-01-13 (96 h)'` for a 24-hour day. What
|
||||
an operator needs from this row is whether tomorrow morning's fill has a
|
||||
whole day to carry, and that is the HOUR figure; the point count says how
|
||||
finely it is priced. Neither answers for the other.
|
||||
"""
|
||||
return (f"{day} ({points} point{'' if points == 1 else 's'} "
|
||||
f"over {hours} h)")
|
||||
|
||||
|
||||
def _bad_rows(rec) -> int:
|
||||
"""How many rows of a day record are not points. Total; never raises."""
|
||||
if not isinstance(rec, dict):
|
||||
return 0
|
||||
pts = rec.get("points")
|
||||
return len(pts) if isinstance(pts, (list, tuple)) else 0
|
||||
raw = rec.get("points")
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return 0
|
||||
return sum(1 for row in raw if _point_of(row) is None)
|
||||
|
||||
|
||||
class PriceHistory:
|
||||
@@ -235,6 +367,14 @@ class PriceHistory:
|
||||
site's first day, and a different thing from a store that could not be
|
||||
opened. Both are visible: `ok`/`last_error` describe the file, and
|
||||
`yesterday()`'s reason describes the answer.
|
||||
|
||||
⚠️ AND `ok` IS NOT "CAN PERSIST" (FCST-06). It says the directory was
|
||||
there when the store opened, which a full volume and a read-only remount
|
||||
both leave true. The one thing that answers "can this store persist" is
|
||||
`_save()`'s return value, and `self._days` NEVER HOLDS A POINT THE FILE DOES
|
||||
NOT, because `_remember()` undoes a merge that answer refused. That is what
|
||||
makes `words` and `yesterday()` describe the same store in EVERY degraded
|
||||
state rather than only in the ones somebody enumerated.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str = "/data/price_history.json"):
|
||||
@@ -269,11 +409,33 @@ class PriceHistory:
|
||||
"one, and the fill has nothing to carry until the day "
|
||||
"after that", self.path)
|
||||
return
|
||||
except (json.JSONDecodeError, OSError) as err:
|
||||
except (ValueError, OSError) as err:
|
||||
# A corrupt cache must not stop the controller, and must not be
|
||||
# silently re-created either: the record is discarded, the reason
|
||||
# is kept, and `yesterday()` reports it rather than answering
|
||||
# "no record", which would read as a normal first day.
|
||||
#
|
||||
# ⚠️ `ValueError`, NOT `json.JSONDecodeError`, AND THAT IS
|
||||
# FCST-05's HIGH. Reading this file has two failure families: the
|
||||
# BYTES (`OSError`) and the CONTENT - and the content family has at
|
||||
# least two members, because `open(..., encoding="utf-8")` decodes
|
||||
# before `json` ever sees a character. `UnicodeDecodeError` is
|
||||
# `ValueError -> UnicodeError -> UnicodeDecodeError`; it is NOT a
|
||||
# `json.JSONDecodeError` and it is NOT an `OSError`
|
||||
# (`issubclass(UnicodeDecodeError, OSError)` is False). So ONE
|
||||
# non-UTF-8 byte in /data/price_history.json escaped `_load()` ->
|
||||
# `_open()` -> `PriceHistory.__init__`, which is unguarded at
|
||||
# main.py's `Controller.__init__`, AND THE ADD-ON DID NOT START.
|
||||
# Measured: `UnicodeDecodeError: 'utf-8' codec can't decode byte
|
||||
# 0xff in position 39`. Valid-UTF-8-invalid-JSON, a UTF-8 BOM and a
|
||||
# non-dict day record all degraded correctly - it was the one byte
|
||||
# class left uncovered, which is the same class as the defect the
|
||||
# `_usable_days()` rework was raised for.
|
||||
#
|
||||
# The tuple names the FAMILY rather than the two members known
|
||||
# today, because "the file's content is not readable as JSON" is
|
||||
# exactly what this handler says, and a third `ValueError` subclass
|
||||
# nobody has thought of is how this defect arrived the first time.
|
||||
self.last_error = f"unreadable ({err})"
|
||||
_LOG.warning("price history at %s is %s - starting from empty. "
|
||||
"§8.3 carry-forward has nothing to carry until the "
|
||||
@@ -318,33 +480,71 @@ class PriceHistory:
|
||||
|
||||
Dropped, not repaired: an unusable record has nothing in it to keep, and
|
||||
the next `_remember()` writes a fresh one under that date.
|
||||
|
||||
⚠️ AND THE ROWS INSIDE A KEPT RECORD ARE COUNTED, NOT ONLY THE RECORD
|
||||
(FCST-05). This checked that `rec["points"]` is a LIST and stopped
|
||||
there, so a record holding one row with a `start` of `7` loaded as
|
||||
healthy: `last_error=None`, and `words` said `'2026-01-15 (1 h)'` about
|
||||
a store whose next `_remember()` would raise on that row and go on
|
||||
raising forever. The rows are NOT dropped here - `_parse_points()` is
|
||||
the read path's one filter and stays so - but they are COUNTED, so the
|
||||
store stops reporting health it does not have from the moment it is
|
||||
opened. `_remember()` drops them on the next refresh and `_save()`
|
||||
rewrites the file, after which a restart clears the note.
|
||||
"""
|
||||
kept, dropped = {}, []
|
||||
kept, dropped, malformed = {}, [], {}
|
||||
for key, rec in days.items():
|
||||
if not isinstance(key, str) or not isinstance(rec, dict) \
|
||||
or not isinstance(rec.get("points"), list):
|
||||
dropped.append(str(key))
|
||||
continue
|
||||
bad = _bad_rows(rec)
|
||||
if bad:
|
||||
malformed[key] = bad
|
||||
kept[key] = rec
|
||||
notes = []
|
||||
if dropped:
|
||||
notes.append(f"{len(dropped)} unusable day record(s) dropped "
|
||||
f"({', '.join(sorted(dropped))})")
|
||||
if malformed:
|
||||
notes.append(
|
||||
f"{sum(malformed.values())} malformed point row(s) in "
|
||||
+ ", ".join(f"{d} ({n})" for d, n in sorted(malformed.items())))
|
||||
if notes:
|
||||
# Recorded, not silent: `yesterday()` and `words` both report
|
||||
# `last_error`, and a cache that quietly discards half a file reads
|
||||
# to an operator exactly like a normal first day.
|
||||
self.last_error = (f"{len(dropped)} unusable day record(s) dropped "
|
||||
f"({', '.join(sorted(dropped))})")
|
||||
self.last_error = "; ".join(notes)
|
||||
_LOG.warning("price history at %s: %s - the controller starts "
|
||||
"anyway and the horizon is as short as the feed makes "
|
||||
"it", self.path, self.last_error)
|
||||
return kept
|
||||
|
||||
def _save(self) -> None:
|
||||
def _save(self) -> bool:
|
||||
"""Write `self._days` out. True only if the file now holds it.
|
||||
|
||||
⚠️ THE RETURN VALUE IS THE "CAN THIS STORE PERSIST?" PREDICATE, AND
|
||||
IT IS THE ONLY ONE (FCST-06). Both ways of not persisting end here and
|
||||
are the same answer: no durable volume returns False without touching
|
||||
the disk, an `OSError` out of the write returns False after recording
|
||||
why. `_remember()` acts on that one answer, so the second place that
|
||||
used to test the first symptom on its own - and missed the second - has
|
||||
nothing left to test. See the module docstring's fifth warning.
|
||||
|
||||
⚠️ AND IT REPORTS, IT DOES NOT RAISE. §6 and this module's opening: a
|
||||
broken cache must not be a broken controller. An add-on that will not
|
||||
start because /data filled up is a worse defect than the one FCST-06
|
||||
fixed, so a failed write is logged, kept in `last_error` - where `words`
|
||||
and `yesterday()` both read it - and RETURNED, never thrown.
|
||||
|
||||
⚠️ THE TEMP FILE IS REMOVED ON EVERY FAILING PATH. A write that dies
|
||||
between `mkstemp` and `os.replace` used to leave its temp behind, and
|
||||
the refresh task runs 48 times a day: a persistent write failure - a
|
||||
full volume being the likeliest cause - leaked 48 inodes a day onto the
|
||||
volume that was already the problem.
|
||||
"""
|
||||
if not self.ok:
|
||||
return
|
||||
# ⚠️ THE TEMP FILE IS REMOVED ON EVERY FAILING PATH. A write that dies
|
||||
# between `mkstemp` and `os.replace` used to leave its temp behind, and
|
||||
# the refresh task runs 48 times a day: a persistent write failure -
|
||||
# a full volume being the likeliest cause - leaked 48 inodes a day onto
|
||||
# the volume that was already the problem.
|
||||
return False
|
||||
tmp = None
|
||||
try:
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path))
|
||||
@@ -357,12 +557,14 @@ class PriceHistory:
|
||||
except OSError as err:
|
||||
self.last_error = f"could not write ({err})"
|
||||
_LOG.error("could not persist price history: %s", err)
|
||||
return False
|
||||
finally:
|
||||
if tmp is not None:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError: # nothing further to try, and a
|
||||
pass # failed cleanup is not a fault
|
||||
return True
|
||||
|
||||
# -- the one public entry point -----------------------------------------
|
||||
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
|
||||
@@ -399,15 +601,6 @@ class PriceHistory:
|
||||
nothing outside this file names it, and `test_price_history.py` checks
|
||||
the tree for that rather than trusting the underscore.
|
||||
"""
|
||||
if not self.ok:
|
||||
# ⚠️ A STORE THAT CANNOT PERSIST RECORDS NOTHING. See the module
|
||||
# docstring's third warning: filling `self._days` here would give a
|
||||
# site with no /data an in-memory yesterday that answers a series
|
||||
# all day and is gone on the next restart - a cache that forgets
|
||||
# without saying so, which is the one thing that rule refuses. The
|
||||
# honest degraded state is `words` = "unavailable" and `yesterday()`
|
||||
# = `no_data`, and those two must agree.
|
||||
return
|
||||
points = read.match(series=lambda r: r.points, no_data=lambda _r: ())
|
||||
if not points:
|
||||
return
|
||||
@@ -416,7 +609,27 @@ class PriceHistory:
|
||||
provider = read.match(series=lambda r: r.provider,
|
||||
no_data=lambda _r: None)
|
||||
|
||||
# ⚠️ THE STATE THIS READ STARTED FROM, KEPT SO IT CAN BE PUT BACK
|
||||
# (FCST-06). Everything below merges into `self._days` IN PLACE, and
|
||||
# whether any of it may be KEPT is not knowable until `_save()` has
|
||||
# tried - so the merge runs, and it is undone if the file did not take
|
||||
# it. There is no `not self.ok` branch above any more: a store with no
|
||||
# durable volume is one of the answers `_save()` gives, not a separate
|
||||
# symptom with a test of its own. Module docstring, fifth warning.
|
||||
#
|
||||
# ⚠️ DEEP, NOT `dict(self._days)`. A one-level copy shares the day
|
||||
# RECORDS, and the loop below rebinds keys on the ones it touches - so
|
||||
# the "previous" state would hand back records the merge had already
|
||||
# edited, and a store with a good yesterday on disk would go on
|
||||
# answering the merge of it that never reached the file. It would also
|
||||
# be right only for as long as nobody makes this loop mutate a `points`
|
||||
# list or a row in place. A copy that is right by construction outlives
|
||||
# that, and the store holds at most KEEP_DAYS + 2 records, 48 times a
|
||||
# day.
|
||||
before = copy.deepcopy(self._days)
|
||||
|
||||
touched = set()
|
||||
junked = {}
|
||||
for p in points:
|
||||
day = _bdate(p.start)
|
||||
rec = self._days.setdefault(
|
||||
@@ -424,16 +637,71 @@ class PriceHistory:
|
||||
# Rule 4: MERGE by instant. A later read updates the hours it
|
||||
# carries and leaves the rest; a revised price for an hour already
|
||||
# held wins, because it is the newer statement about that hour.
|
||||
#
|
||||
# ⚠️ EVERY HELD ROW GOES THROUGH `_point_of()` (FCST-05's MEDIUM).
|
||||
# This was `{q["start"]: q for q in raw if isinstance(q, dict) and
|
||||
# "start" in q}` sorted by `q["start"]` - a key and a sort key that
|
||||
# trusted a value read off disk to be a hashable, sortable string.
|
||||
# An `int`, `null`, a `dict` or a `list` there raised `TypeError`
|
||||
# out of `carry()` on the day the feed was writing to, and
|
||||
# `run_tariff_refresh` caught it, so the add-on stayed up while
|
||||
# EVERY refresh failed for good. A row that is not a point is now
|
||||
# dropped and reported here, exactly as the read path drops it, and
|
||||
# `_save()` below writes the record back clean - which is the
|
||||
# self-heal the raising version could never reach.
|
||||
#
|
||||
# ⚠️ AND THE MERGE KEY IS THE VALIDATED INSTANT, NOT ITS SPELLING.
|
||||
# Aware datetimes hash and compare by instant, so this is rule 4 as
|
||||
# rule 4 is written. The old string key merged by SPELLING, and the
|
||||
# old lexicographic sort put the second 02:00 of the 25-hour
|
||||
# October day (`+01:00`) BEFORE the first (`+02:00`). Only the
|
||||
# stored order was affected - `_parse_points()` re-sorts by instant
|
||||
# on the way out - but the store now holds what it says it holds.
|
||||
raw = rec.get("points")
|
||||
held = {q["start"]: q for q in (raw if isinstance(raw, list) else ())
|
||||
if isinstance(q, dict) and "start" in q}
|
||||
held[p.start.isoformat()] = p.canonical()
|
||||
rec["points"] = sorted(held.values(), key=lambda q: q["start"])
|
||||
held = {}
|
||||
for q in (raw if isinstance(raw, list) else ()):
|
||||
kept = _point_of(q)
|
||||
if kept is None:
|
||||
junked[day] = junked.get(day, 0) + 1
|
||||
continue
|
||||
held[kept.start] = q
|
||||
held[p.start] = p.canonical()
|
||||
order = sorted(held, key=lambda t: t.astimezone(timezone.utc))
|
||||
rec["points"] = [held[t] for t in order]
|
||||
rec["product"], rec["provider"] = product, provider
|
||||
touched.add(day)
|
||||
|
||||
if junked:
|
||||
# Said, not swallowed. `words` prints `last_error`, so a store that
|
||||
# has just discarded rows off disk stops reporting plain health -
|
||||
# and after `_save()` below the file is clean, so a restart clears
|
||||
# this. `_save()` overwrites it if the write itself fails, which is
|
||||
# the more urgent fact of the two - and on that path the drop is
|
||||
# undone with the rest of the merge, so those rows are still in the
|
||||
# file and the next write that completes drops them again.
|
||||
self.last_error = (
|
||||
f"{sum(junked.values())} malformed point row(s) dropped from "
|
||||
+ ", ".join(f"{d} ({n})" for d, n in sorted(junked.items())))
|
||||
_LOG.warning("price history at %s: %s - the record is rewritten "
|
||||
"without them and the refresh continues",
|
||||
self.path, self.last_error)
|
||||
self._prune(stamp)
|
||||
self._save()
|
||||
if not self._save():
|
||||
# ⚠️ A STORE THAT CANNOT PERSIST RECORDS NOTHING - the rule, asked
|
||||
# ONCE as a question about the outcome instead of tested twice
|
||||
# against two symptoms of it. Keeping the merge here is exactly the
|
||||
# cache the module docstring refuses: `yesterday()` would answer a
|
||||
# series the file does not contain, all day, until a restart
|
||||
# dropped it without a word - and `words` and `yesterday()` would
|
||||
# be describing two different stores. The reason is already in
|
||||
# `last_error` (from `_save()`, or from `_open()` when there is no
|
||||
# volume at all), so both answers now describe what is on disk.
|
||||
#
|
||||
# It does NOT raise, and §6 is why: a broken cache must not be a
|
||||
# broken controller. `carry()` still returns the fill, the horizon
|
||||
# is as short as the feed makes it, and the add-on regulates.
|
||||
self._days = before
|
||||
return
|
||||
_LOG.debug("price history: %d point(s) filed under %s; %d day(s) held",
|
||||
len(points), ", ".join(sorted(touched)), len(self._days))
|
||||
|
||||
@@ -523,15 +791,21 @@ class PriceHistory:
|
||||
an exception here is an add-on that does not start. It went out that
|
||||
way: a day record that was a string instead of a dict raised
|
||||
`AttributeError` from the `len(...)` below. `_load()` now drops such a
|
||||
record and `_n_points()` is total, so this has two independent reasons
|
||||
not to fail on a broken file.
|
||||
record and `_held()` is total, so this has two independent reasons not
|
||||
to fail on a broken file.
|
||||
|
||||
⚠️ AND IT COUNTED POINTS AND CALLED THEM HOURS (FCST-05's LOW). The row
|
||||
was `f"{d} ({_n_points(rec)} h)"`; on the quarter-hourly feed this
|
||||
add-on already supports that reads `'2026-01-13 (96 h)'` for a 24-hour
|
||||
day. Both numbers are printed now - see `_held()` - because they are
|
||||
two different facts and only one of them was ever being shown.
|
||||
"""
|
||||
if not self.ok:
|
||||
return f"unavailable - {self.last_error}"
|
||||
if not self._days:
|
||||
why = f" - {self.last_error}" if self.last_error else ""
|
||||
return f"empty{why} - nothing to carry forward yet"
|
||||
held = "; ".join(f"{d} ({_n_points(self._days[d])} h)"
|
||||
held = "; ".join(_row_of(d, *_held(self._days[d]))
|
||||
for d in sorted(self._days, key=str))
|
||||
# A file that PARTLY loaded says so, or the dropped days read as days
|
||||
# that were simply never written.
|
||||
@@ -550,21 +824,14 @@ def _parse_points(raw) -> tuple:
|
||||
return (), "the `points` field is not a list"
|
||||
out, dropped = [], 0
|
||||
for row in raw:
|
||||
if not isinstance(row, dict):
|
||||
# ⚠️ `_point_of()` IS THE ONE VALIDATOR, and this loop used to BE it.
|
||||
# The write path had its own, weaker standard; see `_point_of()` for
|
||||
# what that cost. One standard, two callers, no asymmetry to re-open.
|
||||
pt = _point_of(row)
|
||||
if pt is None:
|
||||
dropped += 1
|
||||
continue
|
||||
try:
|
||||
start = datetime.fromisoformat(row["start"])
|
||||
value = float(row["eur_per_kwh"])
|
||||
flag = row["provisional"]
|
||||
except (KeyError, TypeError, ValueError):
|
||||
dropped += 1
|
||||
continue
|
||||
if start.tzinfo is None or start.utcoffset() is None \
|
||||
or not isinstance(flag, bool):
|
||||
dropped += 1
|
||||
continue
|
||||
out.append(PricePoint(start=start, eur_per_kwh=value, provisional=flag))
|
||||
out.append(pt)
|
||||
out.sort(key=lambda p: p.start.astimezone(timezone.utc))
|
||||
return tuple(out), (f"{dropped} malformed point(s)" if dropped else "")
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""TEL-11/TEL-12 - the site's PV production, read from Home Assistant entities.
|
||||
|
||||
WHY THIS IS AN ENTITY AND NOT A REGISTER. This add-on's RS485 bus carries the
|
||||
BATTERY inverter it drives. A household's PV inverter is, on nearly every site,
|
||||
a different box from a different vendor that reports into Home Assistant
|
||||
through its own integration - so the only place a product can read production
|
||||
for an arbitrary install is an HA entity. `pv_entities` therefore takes whatever
|
||||
`sensor.*` the site's own PV integration provides.
|
||||
|
||||
⚠️ AND THERE IS USUALLY MORE THAN ONE OF THEM, WHICH IS TEL-12. TEL-11 shipped
|
||||
`pv_entity` as a single string and the owner of the reference site reported
|
||||
within minutes of 0.11.0 going live that they have SEVERAL PV inverters and
|
||||
could configure one. That is not a property of one house: a multi-string or
|
||||
multi-orientation roof routinely carries two or three inverters, each
|
||||
publishing its own power sensor. `entities()` below is the roster and `total()`
|
||||
is the site figure over it.
|
||||
|
||||
⚠️ AND A PARTIAL TOTAL IS WORSE THAN NO TOTAL, WHICH IS THE WHOLE OF TEL-12.
|
||||
Three inverters configured, one `unavailable`, and the sum of the other two is A
|
||||
SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE. Fed into OPT-10's attribution
|
||||
that reads as "there was less PV than there was" - which is the "no irradiance"
|
||||
conclusion the card exists to TEST rather than to manufacture. So `total()`
|
||||
answers `None` the moment ANY configured entity is absent. See the note on
|
||||
`total()` for the alternative that was weighed and why it lost.
|
||||
|
||||
WHY THE CHANNEL EXISTS AT ALL. OPT-10 measured that 20.5 kWh of surplus left the
|
||||
site in 16 days while the pack had room, 12.6 kWh of it with the battery not
|
||||
charging at all - and could not say WHY, because PV production is recorded
|
||||
NOWHERE. Three causes need three different fixes (no irradiance: nothing to fix;
|
||||
a charge-power limit; the optimiser declining to charge - the only software
|
||||
defect), and guessing between them is how an optimiser gets tuned to solve a
|
||||
cloud problem. This module is the missing measurement and NOTHING ELSE.
|
||||
|
||||
⚠️ NOTHING HERE MAY EVER REACH A CONTROL DECISION, AND THAT IS THE CARD'S FIRST
|
||||
CONSTRAINT RATHER THAN A STYLE NOTE. The add-on regulates a real battery; adding
|
||||
production as a term of the control law would be an unrequested behaviour change
|
||||
AND it would destroy the measurement, because the loop would then be reacting to
|
||||
the quantity being measured. `tools/tel11_pv_trace.py` is what says the path is
|
||||
untouched: it grades a control document that contains none of this material on
|
||||
both trees and with `pv_entity` set and unset.
|
||||
|
||||
⚠️ ABSENCE IS NEVER ZERO, AND THAT IS THE SECOND CONSTRAINT. Every refusal below
|
||||
returns `None` with a REASON, never `0.0`. A PV channel that reported 0 W for a
|
||||
dead sensor would manufacture exactly the "there was no surplus to store"
|
||||
conclusion the card exists to TEST - and it would do it silently, in a week of
|
||||
recorder history nobody would re-derive. `app/hass.py`'s own note on `state()`
|
||||
is the same rule one layer down. ⚠️ TEL-12 EXTENDS THAT RULE RATHER THAN
|
||||
WEAKENING IT: at a roster of three, "one of them is absent" is an absence of the
|
||||
SITE figure, and it publishes as an absence. A partial sum is a second way of
|
||||
reporting 0-where-there-was-production, one layer up, and it is refused for the
|
||||
same reason.
|
||||
|
||||
THE SIGN CONVENTION: POSITIVE IS PRODUCTION. Derived from the two conventions
|
||||
this add-on already ships rather than assumed:
|
||||
|
||||
`meter_entity` positive = IMPORTING from the grid (DOCS.md, Sources)
|
||||
`batt_entity` positive = DISCHARGING the pack (DOCS.md, Sources)
|
||||
|
||||
Both are "positive = power arriving at the house's AC bus", and production
|
||||
arrives there too. There is deliberately NO `pv_invert` to go with
|
||||
`meter_invert` and `batt_invert`: those two quantities are BIPOLAR and are READ
|
||||
BY THE CONTROL PATH - a reversed `batt_entity` feeds SAFETY-30's per-unit sign
|
||||
check and SAFETY-31's circulating-power sensor with a plausible number the site
|
||||
then acts on - whereas production is unipolar and is read by nothing. A reversed
|
||||
PV sensor announces itself as -4 kW at noon in the very history this card
|
||||
creates, and flipping it here would only hide it. See
|
||||
`DECISIONS/TEL-11-pv-production.md` §2.
|
||||
"""
|
||||
|
||||
from .hass import BAD_STATES
|
||||
|
||||
# Home Assistant's OWN power units, each as the number of watts one of it is.
|
||||
# ⚠️ READ OUT OF HA, NOT LISTED FROM MEMORY: `UnitOfPower` in
|
||||
# `homeassistant/const.py` (github.com/home-assistant/core, `dev`, retrieved
|
||||
# 2026-09-24) declares mW, W, kW, MW, GW, TW and BTU/h. Every factor here is an
|
||||
# exact power of ten, so none of them is a number anybody chose.
|
||||
#
|
||||
# ⚠️ BTU/h IS IN HA's ENUM AND DELIBERATELY NOT HERE. Its factor (0.293071…)
|
||||
# is a measured conversion rather than a decimal shift, no PV integration
|
||||
# publishes in it, and a wrong constant in the one channel that exists to
|
||||
# attribute a 20.5 kWh shortfall is worse than a refusal an operator can read.
|
||||
# It lands in the "not a power unit" refusal below, BY NAME.
|
||||
WATTS_PER = {
|
||||
"mW": 0.001,
|
||||
"W": 1.0,
|
||||
"kW": 1000.0,
|
||||
"MW": 1000000.0,
|
||||
"GW": 1000000000.0,
|
||||
"TW": 1000000000000.0,
|
||||
}
|
||||
|
||||
# What `watts()` answers when there is nothing to report. ⚠️ THE REASON IS PART
|
||||
# OF THE RETURN VALUE, not a log line: it is what the status page prints, and an
|
||||
# absence whose cause is invisible sends an operator to look at the wrong box.
|
||||
UNIT_KEY = "unit_of_measurement"
|
||||
|
||||
|
||||
def watts(state: dict | None) -> tuple:
|
||||
"""`(watts, why)` from a Home Assistant state object.
|
||||
|
||||
`watts` is `None` whenever there is no reading - and `why` then says which
|
||||
of the five refusals it was, in words an operator can act on. On a good read
|
||||
`why` is the unit the value was read in, because "1234 W" and "1234, unit
|
||||
unknown" are different claims and the page has to be able to make the first.
|
||||
|
||||
⚠️ THE UNIT IS READ OFF THE ENTITY AND NOT ASSUMED, AND THERE IS NO
|
||||
MAGNITUDE HEURISTIC. `read_inputs()` guesses kW-or-W for
|
||||
`peak_forecast_entity` with `abs(raw) < 50`, which is safe there because
|
||||
nobody's quarter-hour demand forecast is 50 W. It is NOT safe here: 30 W of
|
||||
real production at dusk is an ordinary reading, and that heuristic would
|
||||
publish it as 30 kW - a fabricated number, in the channel whose entire
|
||||
purpose is to be believed a week later. A sensor whose unit this cannot read
|
||||
is REFUSED rather than guessed at.
|
||||
"""
|
||||
if state is None:
|
||||
# `hass.state()` answers None for a non-existent entity, a 404 and a
|
||||
# transport failure alike. All three are "no reading", and HA does not
|
||||
# consider a non-existent entity an error - see `hass.state()`.
|
||||
return None, "no state (entity missing, or the read failed)"
|
||||
raw = str(state.get("state", "")).strip()
|
||||
if raw.lower() in BAD_STATES:
|
||||
# ⚠️ THE SAME LIST `hass.number()` REFUSES ON, IMPORTED RATHER THAN
|
||||
# RESPELLED. A second copy of it is how one path starts treating
|
||||
# `unavailable` as a number the other one rejects.
|
||||
return None, f"entity is {raw or 'empty'}"
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return None, f"state {raw!r} is not numeric"
|
||||
unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip()
|
||||
if not unit:
|
||||
return None, ("no unit_of_measurement on the entity - it cannot be "
|
||||
"read in watts")
|
||||
if unit not in WATTS_PER:
|
||||
return None, (f"unit {unit!r} is not one of "
|
||||
f"{', '.join(WATTS_PER)} - it cannot be read in watts")
|
||||
return value * WATTS_PER[unit], unit
|
||||
|
||||
|
||||
# --- TEL-12: the ROSTER, and the SITE figure over it --------------------------
|
||||
|
||||
def entities(opts: dict) -> tuple:
|
||||
"""Every PV entity this configuration names, in configured order. TEL-12.
|
||||
|
||||
⚠️ ONE ANSWER TO "WHICH ENTITIES ARE THE PV CHANNEL", and it is the answer
|
||||
BOTH sides read. `Controller.pv_roster` is this call and
|
||||
`mqtt.optional_keys()` is `bool()` of this call, so the thing that decides
|
||||
whether the row is ANNOUNCED, the thing that decides whether it is READ and
|
||||
the thing that decides HOW MANY entities are summed cannot disagree. TEL-11
|
||||
already had that discipline for one entity and spelled the predicate TWICE
|
||||
- `str(opts.get("pv_entity") or "").strip()` in `main.py` and again in
|
||||
`mqtt.py`. Two spellings of one predicate is how an entity gets announced
|
||||
that nothing feeds; a roster makes it how a SUM gets taken over a different
|
||||
list from the one that was announced. It is one function now.
|
||||
|
||||
⚠️ `pv_entities` REPLACES `pv_entity` RATHER THAN EXTENDING IT, and that is
|
||||
CFG-04's shipped precedent (`inverters:` replaces the six flat keys) applied
|
||||
to the only upgrade path that matters. The install this card exists for HAS
|
||||
`pv_entity` set, to inverter A. Opening the Configuration tab, the natural
|
||||
action is to put ALL the inverters in the new repeatable field - and under a
|
||||
UNION rule that double-counts A unless the operator also remembers to clear
|
||||
the old key. A doubled total is the same defect as a partial one in the
|
||||
other direction, in the one channel that exists to be believed a week later.
|
||||
Under REPLACE the effective roster is exactly what one key says, and no
|
||||
arithmetic depends on a de-duplication rule the operator has to know about.
|
||||
⚠️ `Controller.checks()` says WHICH key is in use whenever both are set, so
|
||||
the replacement is stated on the page rather than being silent.
|
||||
|
||||
⚠️ AND IT DE-DUPLICATES WITHIN THE LIST, ORDER PRESERVED. A site cannot have
|
||||
two sensors with one entity_id, so a repeated id in the list is never a
|
||||
second inverter - it is a typo that would DOUBLE that inverter's production
|
||||
in the total. `dict.fromkeys` is the order-preserving spelling.
|
||||
"""
|
||||
listed = [e for e in (str(x or "").strip()
|
||||
for x in (opts.get("pv_entities") or [])) if e]
|
||||
if listed:
|
||||
return tuple(dict.fromkeys(listed))
|
||||
one = str(opts.get("pv_entity") or "").strip()
|
||||
return (one,) if one else ()
|
||||
|
||||
|
||||
def entity_is_ignored(opts: dict) -> bool:
|
||||
"""Is a set `pv_entity` being REPLACED by a set `pv_entities`? TEL-12.
|
||||
|
||||
⚠️ HERE RATHER THAN IN `Controller.checks()`, for the reason `entities()`
|
||||
exists: the rule and the sentence that reports the rule must not be two
|
||||
readings of the options. This is the negative half of `entities()`' first
|
||||
branch and it is spelled once, beside it, where an edit to one is in front
|
||||
of whoever is editing the other.
|
||||
"""
|
||||
return bool(str(opts.get("pv_entity") or "").strip()
|
||||
and [x for x in (opts.get("pv_entities") or [])
|
||||
if str(x or "").strip()])
|
||||
|
||||
|
||||
def total(readings) -> tuple:
|
||||
"""`(watts, why, parts)` - the SITE's production, or an ABSENCE. TEL-12.
|
||||
|
||||
`readings` is `[(entity_id, state), ...]` in `entities()` order, one entry
|
||||
per configured entity, each `state` shaped as `hass.state()` returns.
|
||||
|
||||
`parts` is `[(entity_id, watts, why), ...]` - EVERY entity's own answer from
|
||||
`watts()`, kept because the operator surfaces have to be able to name which
|
||||
sensor is missing. `watts` is the sum when every entity answered, and `None`
|
||||
otherwise; `why` always says which of those it was, in words a status page
|
||||
can print.
|
||||
|
||||
⚠️ ONE OR MORE ABSENT -> THE TOTAL IS ABSENT. NOT THE PARTIAL SUM. This is
|
||||
the decision the card turns on and it was taken deliberately against the
|
||||
alternative, which was to publish the partial sum ALONGSIDE a count of how
|
||||
many entities contributed. Both are defensible; this one wins on the ONE
|
||||
question that matters, which is who reads the number and when:
|
||||
|
||||
* the MQTT row is read back COLD, a week later, by OPT-10's attribution
|
||||
and by whatever plots `sensor.goodwe_pv_power` over that week. A partial
|
||||
sum there is a smaller number indistinguishable from a real one, and the
|
||||
count that would have qualified it lives in a SECOND entity with its own
|
||||
history - so believing the total requires joining two recorder series by
|
||||
timestamp to find out which samples to distrust. Nobody re-derives that,
|
||||
which is the same "nobody will re-derive it" TEL-11's note names. And it
|
||||
is worse than a gap in exactly the direction that matters: a gap makes
|
||||
the attribution say "I do not know", a partial sum makes it say "there
|
||||
was less sun than there was".
|
||||
* the STATUS PAGE is read LIVE, by a human, who wants the diagnostic. So
|
||||
the partial information is not thrown away - `Controller.checks()` names
|
||||
every entity that DID report, with its watts, and every one that did
|
||||
not, with its reason, in a row that states plainly that there is no site
|
||||
total. Different reader, different instant, different rule.
|
||||
|
||||
A count published beside the total would also carry no information in the
|
||||
state this function actually admits: the total is published ONLY when every
|
||||
entity contributed, so the count would be the constant `len(parts)`.
|
||||
|
||||
⚠️ AND `0 W` IS STILL A READING AT EVERY ROSTER SIZE - the test is
|
||||
`watts is None`, never falsiness. An inverter honestly reporting 0 W at dusk
|
||||
beside two that are producing must give the SUM of the three, not blank the
|
||||
site: shading, a tripped string and nightfall are all real 0s, and a roster
|
||||
that went to `unknown` whenever one member read zero would report an absence
|
||||
on every clear evening and defeat the channel from the other end.
|
||||
"""
|
||||
parts = [(ent, *watts(st)) for ent, st in readings]
|
||||
if not parts:
|
||||
# No roster. `Controller` does not call this in that state - the gate is
|
||||
# `pv_roster` - but a reader of `parts` should never have to wonder.
|
||||
return None, "not configured", parts
|
||||
if len(parts) == 1:
|
||||
# ⚠️ AT ONE ENTITY THIS IS `watts()` AND NOTHING ELSE: the same value
|
||||
# and the SAME `why` string, passed through rather than re-composed, so
|
||||
# 0.11.0's single-entity surfaces are byte-identical rather than
|
||||
# similar. `tools/tel11_pv_trace.py` grades that identity across trees.
|
||||
return parts[0][1], parts[0][2], parts
|
||||
missing = [(ent, why) for ent, w, why in parts if w is None]
|
||||
if missing:
|
||||
return None, (
|
||||
f"only {len(parts) - len(missing)} of {len(parts)} entities are "
|
||||
f"reporting and a partial sum would understate production: "
|
||||
+ "; ".join(f"{ent} - {why}" for ent, why in missing)), parts
|
||||
return (sum(w for _e, w, _y in parts),
|
||||
f"{len(parts)} entities", parts)
|
||||
@@ -0,0 +1,518 @@
|
||||
"""SAFETY-10 - shadow mode: the whole decision path, and no command.
|
||||
|
||||
WHAT DEFECT THIS PREVENTS
|
||||
=========================
|
||||
Not one defect - the CLASS of them. Every optimiser fault this repository has
|
||||
found was invisible from outside while it was happening, and each one was paid
|
||||
for at the meter before anybody could see it:
|
||||
|
||||
* SAFETY-45 arbitrage against a binding `peak_cap_w` ran 28 charge onsets an
|
||||
hour, the grid swinging -455 .. +3200 W, and put a billed
|
||||
quarter 59 W over the cap;
|
||||
* TARIFF-14 the price lookup answered for the wrong quarter-hour 45 minutes
|
||||
in every 60, and traded on it;
|
||||
* SAFETY-46 the SoC ceiling churns 598 Wh to gain 310 Wh, no hysteresis.
|
||||
|
||||
There was no way to WATCH what the optimiser would do without letting it do it.
|
||||
A commissioning engineer could arm arbitrage and find out, or not arm it and
|
||||
learn nothing. Shadow mode is the third option: strategies, arbiter, clamps and
|
||||
guards all run, the decision is recorded with its reason, and NOTHING THE
|
||||
CONTROLLER DECIDES REACHES THE INVERTER.
|
||||
|
||||
⚠️ AND OF THOSE THREE, A SHADOW RUN CAN SHOW YOU EXACTLY ONE - SEE THE NEXT
|
||||
SECTION. This header used to name SAFETY-45's limit cycle as the example
|
||||
of what shadow mode surfaces. That was false, and SAFETY-47 measured it.
|
||||
|
||||
⚠️ WHAT A SHADOW RUN CAN AND CANNOT SHOW (SAFETY-47)
|
||||
=====================================================
|
||||
SHADOW MODE CANNOT SURFACE A CLOSED-LOOP DEFECT. Not badly, not partially -
|
||||
NOT AT ALL, and by the same construction that makes it safe. The actuator is
|
||||
held at WIRE_W and the carried command is re-seeded to WIRE_W, so every
|
||||
MEASURED input - the meter, the quarter accumulator behind it, the battery
|
||||
power, the state of charge - describes a plant that DID NOT RESPOND. A
|
||||
closed-loop defect is by definition one whose stimulus is the plant's response
|
||||
to the controller. No response, no stimulus, no defect to see.
|
||||
|
||||
That is not a gap to be closed later. Feeding the guards a simulated plant
|
||||
instead is what SAFETY-10 §4 refuses for the sign watchdogs, for a reason that
|
||||
applies to all of this: a controller reacting to a plant that does not exist
|
||||
drifts further from reality every tick, and three cycles in its numbers
|
||||
describe nothing. THE CLOSED-LOOP ANSWER COMES FROM A CLOSED-LOOP RIG
|
||||
(TEST-23 stage 3) OR FROM A LIVE RUN. There is no third place.
|
||||
|
||||
So, of the three defects above, measured on that rig at `peak_cap_w` 1500 /
|
||||
house 1200 / `max_w` 2000, one hour at a 5 s cycle:
|
||||
|
||||
* SAFETY-45's limit cycle NOT VISIBLE. Live: 28 charge onsets, the grid
|
||||
swinging -455.5 .. +3200.0 W, the guard engaged
|
||||
on 410 of 720 cycles. Shadow, same hour, same
|
||||
code: 1 onset, the grid flat at the 1200 W
|
||||
house, the guard engaged on 0 of 720. Every
|
||||
term of `wanted = batt_w + excess` is measured.
|
||||
* SAFETY-46's SoC churn NOT VISIBLE. The SoC moves 0.000000000 pp in a
|
||||
shadow run, because it is the pack's integral of
|
||||
what the pack was commanded and the pack was
|
||||
commanded 0 W. No SoC limit can be REACHED in a
|
||||
shadow run; the ones already crossed when it
|
||||
started are reported, and that is all.
|
||||
* TARIFF-14's wrong quarter VISIBLE, and it is the one. The price lookup's
|
||||
inputs are the CLOCK and the PRICE SERIES, and
|
||||
neither is a function of what the battery did.
|
||||
Measured: which hour the strategy picked, its
|
||||
price and the spread it cleared are identical
|
||||
live and shadow on 720 of 720 charging cycles,
|
||||
AT A BINDING CAP.
|
||||
|
||||
⚠️ THE EXEMPTION THAT USED TO BE HERE WAS TRUE UNTIL SAFETY-45 MERGED. This
|
||||
module, DOCS.md and DECISIONS/SAFETY-10-shadow-mode.md all said "the strategies
|
||||
are unaffected - they ask for a fixed power from inputs that do not depend on
|
||||
what the battery did". SAFETY-45 made the buy leg `min(asked, headroom)` with
|
||||
|
||||
headroom = (900 / remaining) x (ceiling - projected)
|
||||
|
||||
and `projected` reads the OPEN QUARTER'S ACCUMULATOR - the meter - which is a
|
||||
function of what the battery did earlier in that same quarter. The strategy
|
||||
became closed-loop the day that landed. What survives of the exemption, and
|
||||
only this:
|
||||
|
||||
WHICH hour a strategy picks, and WHY, are unaffected.
|
||||
HOW HARD it trades is not.
|
||||
|
||||
⚠️ AND THE SIZE A SHADOW RUN RECORDS IS THE RIGHT ANSWER TO A QUESTION NOBODY
|
||||
ASKED. `900/remaining` is correct arithmetic for a meter that is genuinely
|
||||
under the ceiling - and in shadow the meter genuinely IS under it, because the
|
||||
battery is not charging. So the number is not a bug; it is an answer computed
|
||||
for a plant that did not exist. Measured on the same rig, shipped code:
|
||||
|
||||
cap / house live commanded shadow recorded over
|
||||
1500 / 1200 -302.4 W -2000.0 W 6.61x
|
||||
3500 / 3000 -502.6 W -2000.0 W 3.98x
|
||||
3500 / 2000 -1502.6 W -2000.0 W 1.33x
|
||||
3500 / 1800 -1702.6 W -2000.0 W 1.17x
|
||||
15000 / 350 -2000.0 W -2000.0 W 1.00x
|
||||
|
||||
The last row is the fixture SAFETY-10's own evidence was taken on, which is why
|
||||
nothing reported this. `words()` carries the caveat rather than a re-computed
|
||||
number, and `DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md` §3 records the
|
||||
three alternatives that were rejected and why.
|
||||
|
||||
⚠️ "REACHES NOTHING" IS NOT "WRITES NOTHING", AND THE DIFFERENCE IS THE WHOLE
|
||||
SAFETY ARGUMENT
|
||||
=======================================================================
|
||||
The inverter holds its last command FOREVER - it has no meter timeout
|
||||
(HANDOVER.md §6), and firmware/goodwe-master.yaml's header records what that
|
||||
cost when a controller stopped refreshing: Illusmart went silent mid-command
|
||||
and the inverter held +5000 W for 113 s. main.py's own header carries the same
|
||||
measurement. So a shadow mode that
|
||||
simply stopped writing would leave whatever was standing when it was switched
|
||||
on standing for as long as it was left on. That is the exact failure this
|
||||
repository keeps finding - LOOKS HEALTHY BUT IS INERT - with the status page
|
||||
calmly reporting that nothing is being commanded.
|
||||
|
||||
So shadow mode does write, and it writes ONE number:
|
||||
|
||||
the actuator is held at WIRE_W (0.0 W), refreshed on the ordinary
|
||||
heartbeat, by the ordinary single writer.
|
||||
|
||||
That is a DEFINED, SAFE actuator state, it is the same state the operator's
|
||||
stop button reaches, and it honours the contract firmware/goodwe-master.yaml
|
||||
§"CONTRACT CHANGE" imposes on us - HA must rewrite the setpoint every ~10 s or
|
||||
the ESP32's 30 s stale-input watchdog cannot tell "steady" from "dead". Going
|
||||
silent would put that board into a tripped-watchdog state for the whole shadow
|
||||
run, which is an ALARM being used as a MODE: the operator who learns that
|
||||
`wd_tripped` is normal during shadow runs is the operator who ignores it when
|
||||
the add-on has actually died.
|
||||
|
||||
⚠️ AND IT IS NOT A SECOND WRITE PATH. SAFETY-28's rule is one actuator, one
|
||||
writer. Shadow mode files no claim, owns no transport and calls nothing in
|
||||
hass.py. It is a SUBSTITUTION inside `Controller._write_unit()` - the single
|
||||
choke point every write in this add-on already goes through - in exactly the
|
||||
form the three substitutions above it take (non-finite -> 0 W, unauthorised ->
|
||||
0 W, clamped -> the rating). It is the LAST of them, so SAFETY-28's
|
||||
authorisation gate and SAFETY-03's clamp still grade the number the decision
|
||||
path actually produced, and a shadow run therefore records that the clamp WOULD
|
||||
have engaged.
|
||||
|
||||
THE DEFAULT, AND WHY IT IS NOT A PLAIN `true` (§ the ticket's own warning)
|
||||
==========================================================================
|
||||
The card is titled "default-on". Taken literally - `shadow_mode: true` in
|
||||
`config.yaml`'s `options:` block - it is the worst change in this repository's
|
||||
history. Home Assistant's Supervisor composes an add-on's effective options as
|
||||
`{**config.yaml defaults, **the stored user options}`, so a NEW key with a new
|
||||
default reaches EVERY install on the next update, including the one regulating
|
||||
a real battery in the author's house today. Literal default-on silently stops
|
||||
an existing install from controlling, and the only symptom is a battery that
|
||||
has stopped doing anything.
|
||||
|
||||
There is no mechanism in the add-on options system that lets one key default
|
||||
differently for a new install and an existing one: `options:` IS the default,
|
||||
and there is exactly one of it. So "default-on for new" has to be expressed as
|
||||
a value the add-on RESOLVES, not as a constant the Supervisor merges.
|
||||
|
||||
Hence three values rather than two, defaulting to `auto`:
|
||||
|
||||
off never shadow. The controller commands the battery.
|
||||
on always shadow, whatever the start/stop button says.
|
||||
auto shadow WHILE CONTROL IS STOPPED; live once it is started.
|
||||
|
||||
`auto` is default-on for a new install and a no-op for an existing one, and it
|
||||
is that without any persisted marker, any migration, and any guess:
|
||||
|
||||
* a FRESH install ships `auto_start: false` and has no stored `auto` key, so
|
||||
it comes up stopped -> shadow. It observes a day of decisions before it is
|
||||
ever allowed to command anything. That is the card's intent, delivered.
|
||||
* an install that is CONTROLLING - `auto_start: true`, or the operator has
|
||||
pressed *Start / stop control* and the store carries `auto: true` - is
|
||||
started, so `auto` resolves to LIVE and its behaviour does not change by
|
||||
one watt.
|
||||
* an install that is STOPPED keeps its actuator at 0 W exactly as before -
|
||||
the same number, from the same writer, on the same heartbeat - and gains
|
||||
the recording. The only thing that changes for it is what the status page
|
||||
and the action log SAY.
|
||||
|
||||
⚠️ AND `auto` MEANS THE START BUTTON LEAVES SHADOW MODE. That is deliberate:
|
||||
under `auto` the button reads "Start / stop control" and pressing Start is the
|
||||
operator saying "control the battery". An operator who wants the decisions
|
||||
recorded WHILE the loop runs sets `shadow_mode: on` explicitly - that is the
|
||||
configuration this card exists to make possible, and it is the one that cannot
|
||||
be reached by accident.
|
||||
|
||||
⚠️ AN UNRECOGNISED VALUE RESOLVES TO SHADOW, NOT TO LIVE. `validate.py` refuses
|
||||
it at startup so this is not meant to be reachable, but the fail-safe direction
|
||||
for "the operator's intent is unreadable" is the one that does not move energy.
|
||||
|
||||
THE FAILSAFES, ONE BY ONE (the card asks for each to be decided and recorded)
|
||||
============================================================================
|
||||
| failsafe | in shadow mode |
|
||||
|--------------------------------|-------------------------------------------|
|
||||
| SAFETY-02/30 sign watchdogs | INERT, and by construction rather than by |
|
||||
| (per-unit and site) | a special case. They grade |
|
||||
| | `Inverter.standing_w()` - what this |
|
||||
| | controller has standing on the wire - and |
|
||||
| | shadow mode holds that at 0 W. A 0 W |
|
||||
| | command cannot oppose a meter, so the |
|
||||
| | detector never arms. ⚠️ THIS IS THE ONE |
|
||||
| | THAT HAD TO BE GOT RIGHT: fed the SHADOW |
|
||||
| | decision instead, every shadow run would |
|
||||
| | latch a trip within `sign_disagree_s` - |
|
||||
| | the decision opposes a meter that never |
|
||||
| | saw it - and that latch SURVIVES INTO |
|
||||
| | LIVE OPERATION and a restart. Shadow mode |
|
||||
| | would manufacture the failsafe it is |
|
||||
| | meant to help debug. |
|
||||
| SAFETY-03 clamp | OBSERVES. It runs on the number the |
|
||||
| | decision path produced, `clamp_last` is |
|
||||
| | set and every engagement goes to the |
|
||||
| | action log, because "it would have hit |
|
||||
| | the rating" is part of what a shadow run |
|
||||
| | is for. It changes no command: the value |
|
||||
| | it narrows is replaced by 0 W below it. |
|
||||
| SAFETY-28 write authorisation | OBSERVES, for the same reason and in the |
|
||||
| | same place - above the substitution, so |
|
||||
| | it still asks who chose the number. |
|
||||
| SAFETY-18 meter freeze | OBSERVES, unchanged. It takes no action |
|
||||
| | in live operation either; it grades the |
|
||||
| | resolved intent against a meter that has |
|
||||
| | stopped moving, and both halves are as |
|
||||
| | true in shadow as out of it. |
|
||||
| SAFETY-07/24/25 peak guard | OBSERVES, AND SEES ONLY THE HOUSE. It is |
|
||||
| | level-triggered and unlatched and it files|
|
||||
| | its claim as usual, so the recorded |
|
||||
| | decision is the real one FOR THIS METER - |
|
||||
| | but every term of `wanted = batt_w + |
|
||||
| | excess` is measured, and in shadow mode |
|
||||
| | the battery contributes nothing to either.|
|
||||
| | ⚠️ SAFETY-47: this row used to claim |
|
||||
| | SAFETY-45's limit cycle was visible from a|
|
||||
| | shadow run. It is not - 410/720 engaged |
|
||||
| | cycles live against 0/720 in shadow, on |
|
||||
| | the same hour and the same code. |
|
||||
| SAFETY-31 circulation | OBSERVES AND MAY LATCH, legitimately. Its |
|
||||
| SAFETY-42 delivery | evidence is MEASURED power, not a |
|
||||
| | command: packs pushing against each other |
|
||||
| | while every one of them is told 0 W is a |
|
||||
| | real fault and a latch is the right |
|
||||
| | answer to it. |
|
||||
| operator stop (`auto` False) | SUPPRESSED AS A CLAIM, ENFORCED AT THE |
|
||||
| | WIRE. It is the one deliberate change. |
|
||||
| | Its `P_SAFETY` 0 W claim out-ranks |
|
||||
| | everything, so leaving it in would make |
|
||||
| | the recorded decision "stopped" on every |
|
||||
| | cycle - the shadow run would answer the |
|
||||
| | wrong question. The actuator state is |
|
||||
| | identical either way, and the wire |
|
||||
| | substitution is STRICTLY STRONGER than |
|
||||
| | the claim: a claim can be out-ranked, and |
|
||||
| | this cannot. |
|
||||
| inputs missing, non-finite, | UNCHANGED. All of them resolve to 0 W, |
|
||||
| allocator refusal, arbiter | which is what the wire gets in shadow |
|
||||
| contradiction | mode anyway, so they act redundantly and |
|
||||
| | are recorded as they always were. |
|
||||
|
||||
⚠️ MAINTENANCE IS NOT IN THAT TABLE BECAUSE SHADOW MODE DOES NOT TOUCH IT. The
|
||||
state machines are ticked on every cycle today whether control is started or
|
||||
not, so a stopped install already runs them against a battery that does not
|
||||
move. Shadow mode inherits that, unchanged and unimproved; it is a pre-existing
|
||||
property of `auto_start: false` and it is recorded here rather than quietly
|
||||
fixed under another card's name.
|
||||
|
||||
LEAVING SHADOW MODE
|
||||
===================
|
||||
`Controller.cycle()` re-seeds its carried command (`self.target` and each
|
||||
`inv.target`) to WIRE_W at the END of every shadow cycle, AFTER every in-cycle
|
||||
guard has read the decision. Two things follow, and both are safety properties:
|
||||
|
||||
* the loop's `prev_w` and its integrator track WHAT IS ACTUALLY STANDING ON
|
||||
THE WIRE, so every shadow cycle answers "from where the plant really is,
|
||||
what would this controller do NOW" - a true statement about the present,
|
||||
rather than a simulation of a counterfactual plant that drifts further from
|
||||
reality every tick;
|
||||
* the exit is safe BY CONSTRUCTION. The carried command already equals the
|
||||
standing command, so the first live cycle slews from 0 W like any other
|
||||
cycle. Carrying the shadow decision instead would let the slew limiter
|
||||
depart from a base that never existed - up to a full `slew_w` step onto a
|
||||
battery sitting at zero.
|
||||
|
||||
The entry is safe for the ordinary reason: the substitution happens on the very
|
||||
first write of the very first shadow cycle, and `last_write` was not 0 W, so
|
||||
that write is issued rather than skipped as unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# ⚠️ CFG-03's mirror. `test_defaults_pin.py` part 2 discovers every
|
||||
# module-level name ending in OPTION_DEFAULTS and asserts each value
|
||||
# reproduces config.yaml EXACTLY, so this table cannot drift from the shipped
|
||||
# default. Nothing in the add-on may carry a literal fallback for a key a
|
||||
# mirror answers for - part 3 scans for that - which is why `main.py` asks the
|
||||
# functions below and never reads `shadow_mode` itself.
|
||||
SHADOW_OPTION_DEFAULTS = {
|
||||
"shadow_mode": "auto",
|
||||
}
|
||||
|
||||
#: The three values `shadow_mode` may take, in the order config.yaml's schema
|
||||
#: lists them. `validate.py` refuses anything else by name.
|
||||
MODES = ("auto", "on", "off")
|
||||
|
||||
#: What stands on the wire while shadow mode is engaged. ⚠️ A NUMBER, NOT A
|
||||
#: SILENCE - see this module's header. It is 0.0 W for the same reason every
|
||||
#: other refusal in this add-on is 0.0 W: fail toward inaction, and keep
|
||||
#: commanding it so the inverter cannot be left latched on anything else.
|
||||
WIRE_W = 0.0
|
||||
|
||||
#: SAFETY-47. What the recorded watts mean when a MEASURED billed quarter sized
|
||||
#: them.
|
||||
#:
|
||||
#: ⚠️ "UPPER BOUND" AND NOT "SMALLER", AND THE DIFFERENCE WAS MEASURED RATHER
|
||||
#: THAN ARGUED. Charging only ADDS offtake, so a live meter's `projected` is
|
||||
#: never below a shadow meter's and the headroom is never above it: the shadow
|
||||
#: size is >= the live size, with EQUALITY at the start of every quarter, where
|
||||
#: `900/remaining` is 1.0 and neither plant has moved. Measured pairwise over
|
||||
#: 3600 charging cycles in five configurations - 0 cycles below, 730 exactly
|
||||
#: equal. A sentence reading "smaller" would have been false on every one of
|
||||
#: those 730.
|
||||
#:
|
||||
#: ⚠️ AND IT NAMES NO FACTOR. How far under the bound a live run lands is a
|
||||
#: property of the site - 6.61x at a 1500 W cap under a 1200 W house, 1.00x at
|
||||
#: a cap that does not bind - and a number invented for the row would be a
|
||||
#: second helping of the defect this constant exists to disclose.
|
||||
SIZED_FROM_METER = (
|
||||
"⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE "
|
||||
"HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same "
|
||||
"trade against a meter the battery is moving, so this is an UPPER BOUND "
|
||||
"and not a prediction; how far under it a live run lands is a closed-loop "
|
||||
"question and a shadow run cannot answer it")
|
||||
|
||||
|
||||
def mode(opts: dict) -> str:
|
||||
"""The configured `shadow_mode`, normalised to one of `MODES`.
|
||||
|
||||
⚠️ AN UNRECOGNISED VALUE ANSWERS `"on"`, NOT `"auto"` AND NOT `"off"`.
|
||||
`validate.py` refuses such a value at startup, so this branch is not meant
|
||||
to be reachable at all; it exists because the fail-safe direction for "the
|
||||
operator's intent cannot be read" is the one that does not move energy. A
|
||||
typo that silently resolved to `off` would put a battery under a controller
|
||||
whose configuration nobody could parse.
|
||||
"""
|
||||
raw = opts.get("shadow_mode", SHADOW_OPTION_DEFAULTS["shadow_mode"])
|
||||
text = str("" if raw is None else raw).strip().lower()
|
||||
if text in MODES:
|
||||
return text
|
||||
return "on"
|
||||
|
||||
|
||||
def engaged(opts: dict, *, control_started: bool) -> bool:
|
||||
"""Is the battery being left alone right now?
|
||||
|
||||
`control_started` is `Controller.auto` - the persisted start/stop state,
|
||||
which a fresh install gets from `auto_start` and an operator moves with the
|
||||
*Start / stop control* button. See the header on why `auto` reads it.
|
||||
"""
|
||||
m = mode(opts)
|
||||
if m == "off":
|
||||
return False
|
||||
if m == "on":
|
||||
return True
|
||||
return not control_started
|
||||
|
||||
|
||||
def why(opts: dict, *, control_started: bool) -> str:
|
||||
"""Which rung decided, in the words an operator can act on.
|
||||
|
||||
⚠️ THE REASON, NOT ONLY THE VERDICT - MUTATION.md's masking rule. `on` and
|
||||
`auto`-while-stopped produce the same boolean and want different operator
|
||||
actions: one is turned off by editing an option, the other by pressing a
|
||||
button. A surface that showed only "shadow" would make a mutation that
|
||||
swapped the two rungs invisible.
|
||||
"""
|
||||
# ⚠️ THE LIVE SENTENCES SAY "NOT IN SHADOW MODE", NOT "THE BATTERY IS
|
||||
# BEING CONTROLLED". The second is FALSE on a stopped install with
|
||||
# `shadow_mode: off` - nothing is regulating there either - and a row that
|
||||
# is false in one of the four live states is a row an operator learns to
|
||||
# discount. What is true in every live state is that what this add-on
|
||||
# decides reaches the inverter; whether control is RUNNING is the
|
||||
# `Stopped` banner's question and it is answered there.
|
||||
m = mode(opts)
|
||||
if m == "off":
|
||||
return ("off (shadow_mode: off) - NOT in shadow mode: what this "
|
||||
"add-on decides DOES reach the inverter")
|
||||
if m == "on":
|
||||
return ("on (shadow_mode: on) - decisions are recorded and nothing "
|
||||
"is commanded")
|
||||
if control_started:
|
||||
return ("off (shadow_mode: auto, control is started) - NOT in shadow "
|
||||
"mode: what this add-on decides DOES reach the inverter")
|
||||
return ("on (shadow_mode: auto, control is stopped) - decisions are "
|
||||
"recorded and nothing is commanded")
|
||||
|
||||
|
||||
def words(opts: dict, *, control_started: bool, would_w: float | None,
|
||||
sized_from_meter: bool, reason: str = "") -> str:
|
||||
"""The status page's `Shadow mode` row.
|
||||
|
||||
⚠️ THE ROW IS RENDERED IN EVERY STATE, INCLUDING `off`. A surface whose
|
||||
ABSENCE is how you learn which mode you are in is a surface an operator
|
||||
has to already know about; TARIFF-11's rule is that the rendered text is
|
||||
what gets graded, so there has to be text in both states.
|
||||
|
||||
`would_w` is the decision the path reached this cycle - what a live
|
||||
controller would have put on the wire GIVEN THIS METER AND A BATTERY AT
|
||||
0 W - and `reason` is `Resolution.explain()`, the same sentence the `Why`
|
||||
row carries. Together they are the whole of "what would it have done, and
|
||||
why"; without them the row would say a shadow run happened and nothing
|
||||
about what it found.
|
||||
|
||||
⚠️ `sized_from_meter` HAS NO DEFAULT, AND THAT IS SAFETY-47's WHOLE POINT.
|
||||
It is True when a MEASURED billed-quarter headroom sized the number being
|
||||
recorded - `arbitrage.sized_from_meter()` answers it, and it is the one
|
||||
place the question is asked. A default of False here would be a caller
|
||||
that forgot the caveat rendering a row that reads exactly like a caller
|
||||
that decided it did not apply, which is the shape of defect this card is
|
||||
about. TEST-14's rule with a new name on it.
|
||||
|
||||
⚠️ AND THE PHRASE IS "ON THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
|
||||
CAVEAT FIRES. Every number a shadow run records was reached from a plant
|
||||
that did not respond; "WOULD HAVE COMMANDED -2 000 W" reads as a forecast
|
||||
of a live run and no number here is one. The ⚠️ below is the STRONGER,
|
||||
conditional statement about the cycles where the answer can be multiples
|
||||
out - see this module's header for the measured table.
|
||||
|
||||
⚠️ AND IT IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED. `arbitrage`'s own
|
||||
sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" only when the headroom
|
||||
came out BELOW the ask - which in a shadow run is the START of the
|
||||
quarter, where the two runs still agree. By the end of the quarter
|
||||
`900/remaining` has multiplied the headroom past the ask, the trade is
|
||||
NOT capped, the sentence is silent and the recorded number is at its most
|
||||
wrong. A caveat keyed on that sentence would be quietest exactly where it
|
||||
is needed loudest. `test_arbitrage.py` part 3d-8 drives that.
|
||||
"""
|
||||
head = why(opts, control_started=control_started)
|
||||
if not engaged(opts, control_started=control_started):
|
||||
return head
|
||||
if would_w is None:
|
||||
return (f"{head}; no decision has been reached yet - the inverter is "
|
||||
f"held at {WIRE_W:.0f} W")
|
||||
# ⚠️ THE THOUSANDS SEPARATOR IS SWAPPED ON THE NUMBER ALONE. Applied to
|
||||
# the whole sentence it ate the comma out of `head` - and `head` is the
|
||||
# half that says WHICH RUNG decided, which is the claim MUTATION.md's
|
||||
# masking rule says must survive.
|
||||
num = f"{float(would_w):+,.0f}".replace(",", " ")
|
||||
out = (f"{head}; ON THIS CYCLE WOULD HAVE COMMANDED {num} W, inverter "
|
||||
f"held at {WIRE_W:.0f} W")
|
||||
if sized_from_meter:
|
||||
out += f" {SIZED_FROM_METER}"
|
||||
if reason:
|
||||
out += f" - {reason}"
|
||||
return out
|
||||
|
||||
|
||||
def log_line(*, would_w: float, reason: str, sized_from_meter: bool) -> str:
|
||||
"""The action-log line for one change of shadow decision.
|
||||
|
||||
⚠️ IT IS A FUNCTION FOR THE REASON `words()` IS ONE, AND SAFETY-47 LEARNED
|
||||
IT THE EXPENSIVE WAY. This sentence used to be an f-string inside
|
||||
`Controller._shadow_settle()`. Deleting the caveat from it there left all
|
||||
29 files GREEN - the only check that could see the line drove a fixture
|
||||
with `meter_source: off`, where no arbitrage decision and therefore no
|
||||
caveat can ever be reached. A sentence assembled at a call site is a
|
||||
sentence no check can grade, and the page would have said one thing while
|
||||
the record a later analysis reads said another.
|
||||
|
||||
⚠️ AND IT IS THE SAME `SIZED_FROM_METER` CONSTANT THE ROW CARRIES, not a
|
||||
second wording of it. Two sentences meaning one thing is how the two
|
||||
surfaces start disagreeing about which cycles are caveated.
|
||||
"""
|
||||
out = (f"SHADOW: would have commanded {float(would_w):+.0f} W ON THIS "
|
||||
f"CYCLE - {reason}")
|
||||
if sized_from_meter:
|
||||
out += f" {SIZED_FROM_METER}"
|
||||
return out
|
||||
|
||||
|
||||
def banner(opts: dict, *, control_started: bool, latched: tuple = ()) -> tuple:
|
||||
"""`(level, text)` for the top of the status page, or `None` when live.
|
||||
|
||||
⚠️ IT NAMES A STANDING LATCH AND GOES RED FOR ONE. The shadow banner sits
|
||||
ABOVE every other branch of the cascade, because "the battery is not being
|
||||
controlled" is the one sentence that may never be displaced - but a
|
||||
failsafe latch that is up while the controller is inert is up when it
|
||||
stops being inert, and an operator who turns shadow mode off without
|
||||
clearing it gets a battery that will not move and a green page that
|
||||
never mentioned why. So the latch is carried INTO the shadow banner
|
||||
rather than losing its own.
|
||||
"""
|
||||
if not engaged(opts, control_started=control_started):
|
||||
return None
|
||||
text = ("SHADOW MODE - the battery is NOT being controlled. Every "
|
||||
f"inverter is held at {WIRE_W:.0f} W and nothing this add-on "
|
||||
"decides reaches the inverter.")
|
||||
if latched:
|
||||
return ("bad", text + " ⚠️ A FAILSAFE LATCH IS STILL UP ("
|
||||
+ ", ".join(latched) + ") and it will still be up when shadow "
|
||||
"mode is turned off.")
|
||||
return ("warn", text)
|
||||
|
||||
|
||||
def problems(opts: dict) -> list:
|
||||
"""`validate.py`'s rule: `shadow_mode` must be one of the three values.
|
||||
|
||||
⚠️ THE SENTENCE SPELLS `shadow_mode`. `configui.problems_by_field()` files
|
||||
a refusal under the field whose key its text contains; a rule that never
|
||||
names a key lands in the page's top-level block instead, where the operator
|
||||
cannot see which control is wrong. UX-02's whole point is that the shipped
|
||||
validator is the only rule surface, so the rule has to be readable there.
|
||||
|
||||
⚠️ AND IT IS A REFUSAL RATHER THAN A COERCION. `mode()` resolves an
|
||||
unreadable value to shadow, which is the safe direction but is also a
|
||||
SILENT reinterpretation of what the operator wrote - and an install left in
|
||||
shadow mode by a typo commands nothing at all, indefinitely.
|
||||
"""
|
||||
raw = opts.get("shadow_mode", SHADOW_OPTION_DEFAULTS["shadow_mode"])
|
||||
text = str("" if raw is None else raw).strip().lower()
|
||||
if text in MODES:
|
||||
return []
|
||||
return [f"shadow_mode is {raw!r}, which is not one of "
|
||||
+ ", ".join(f"'{m}'" for m in MODES)
|
||||
+ ". 'auto' leaves the battery alone while control is stopped and "
|
||||
"commands it once control is started; 'on' records every "
|
||||
"decision and commands nothing at all; 'off' is ordinary "
|
||||
"control."]
|
||||
@@ -13,7 +13,7 @@ is an EMS for a minority." The §9.2 product table maps four products onto four
|
||||
EMS behaviours, and TARIFF-02 shipped the machinery for the top two only:
|
||||
|
||||
Dynamic day-ahead hourly EPEX BE + markup full arbitrage
|
||||
Real-time/imbalance e.g. Eneco Eddy Flex arbitrage, wider thresholds
|
||||
Real-time/imbalance no shipped plugin (TARIFF-07) arbitrage, wider thresh.
|
||||
Static day/night two-period tariff TIME-WINDOW SHIFTING <- here
|
||||
Fully fixed single price SELF-CONSUMPTION ONLY <- here
|
||||
|
||||
@@ -207,7 +207,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from .price import (
|
||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
||||
required_spread, uncertainty_of, widen_factor,
|
||||
provider_configured, required_spread, uncertainty_of, widen_factor,
|
||||
)
|
||||
|
||||
_LOG = logging.getLogger("goodwe.tariff.static")
|
||||
@@ -222,6 +222,12 @@ _DAY_ALIASES = {
|
||||
|
||||
MINUTES_PER_DAY = 1440
|
||||
|
||||
# TARIFF-17. The option key whose value is a period table of the TAXES
|
||||
# component. One spelling, here, because price.py, validate.py and config.yaml
|
||||
# all name it and `provider_id()`'s note says what three spellings of one
|
||||
# decision cost.
|
||||
TAXES_PROFILE_OPTION = "price_taxes_profile"
|
||||
|
||||
# The mean that `cheap_now()` grades against is taken over this many local days.
|
||||
# Seven, so each weekday appears exactly once - see the module docstring. It is
|
||||
# a MODULE CONSTANT and not an option on purpose: CFG-03 already tracks three
|
||||
@@ -320,13 +326,37 @@ def _days(value, field: str) -> frozenset:
|
||||
return frozenset(out)
|
||||
|
||||
|
||||
def _price(value, field: str) -> float:
|
||||
"""The all-in EUR/kWh for a period, bounded exactly as price.py bounds a feed."""
|
||||
# What a period's `eur_per_kwh` MEANS, per option. The grammar is one grammar
|
||||
# (TARIFF-17); the quantity it carries is not, so the "you left this out"
|
||||
# sentence is the one thing the two tables do not share.
|
||||
_PERIOD_MEANS = {
|
||||
"static_profile":
|
||||
"an all-in eur_per_kwh (energy + supplier markup + taxes, §9.3) - the "
|
||||
"same basis the dynamic providers are assembled onto, so the two are "
|
||||
"comparable",
|
||||
}
|
||||
_TAXES_MEANS = (
|
||||
"the TAXES component alone in eur_per_kwh - the non-energy part of the "
|
||||
"bill for that band (distribution, levies, VAT), NOT an all-in price. The "
|
||||
"framework adds it to what the feed publishes, exactly as the scalar "
|
||||
"price_taxes_eur_kwh is added"
|
||||
)
|
||||
_PERIOD_MEANS[TAXES_PROFILE_OPTION] = _TAXES_MEANS
|
||||
|
||||
|
||||
def _price(value, field: str, means: str = None) -> float:
|
||||
"""One period's EUR/kWh, bounded exactly as price.py bounds a feed.
|
||||
|
||||
`means` names the quantity for the error sentence only. TARIFF-17: the same
|
||||
grammar carries an ALL-IN price under `static_profile` and a TAXES-ONLY
|
||||
component under `price_taxes_profile`, and an operator who confuses the two
|
||||
is out by an energy price - so the refusal says which one is wanted.
|
||||
"""
|
||||
if means is None:
|
||||
means = _PERIOD_MEANS["static_profile"]
|
||||
if value is None:
|
||||
raise ProfileInvalid(
|
||||
f"{field} is missing. Every period needs an all-in eur_per_kwh "
|
||||
f"(energy + supplier markup + taxes, §9.3) - the same basis the "
|
||||
f"dynamic providers are assembled onto, so the two are comparable.")
|
||||
f"{field} is missing. Every period needs {means}.")
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
raise ProfileInvalid(f"{field} is {value!r}, which is not a number")
|
||||
try:
|
||||
@@ -383,14 +413,19 @@ class Period:
|
||||
class StaticProfile:
|
||||
"""A validated two-or-more-period tariff. Immutable, pure, I/O-free."""
|
||||
|
||||
def __init__(self, periods):
|
||||
def __init__(self, periods, *, option: str = "static_profile"):
|
||||
self.periods = tuple(periods)
|
||||
# ⚠️ TARIFF-17: the OPTION NAME travels with the profile so that every
|
||||
# refusal names the key the operator actually typed. It is a default
|
||||
# argument on purpose - every pre-existing call site and every
|
||||
# pre-existing sentence is byte-identical without it.
|
||||
self.option = option
|
||||
if not self.periods:
|
||||
raise ProfileInvalid(
|
||||
"static_profile has no periods. A profile with no periods is "
|
||||
"not a fixed tariff, it is an absent one - leave the option "
|
||||
"empty to say that on purpose (§11.4 then applies).")
|
||||
_check_tiling(self.periods)
|
||||
f"{option} has no periods. A profile with no periods is "
|
||||
f"not a fixed tariff, it is an absent one - leave the option "
|
||||
f"empty to say that on purpose (§11.4 then applies).")
|
||||
_check_tiling(self.periods, option=option)
|
||||
|
||||
# -- what kind of tariff this is, DERIVED --------------------------------
|
||||
@property
|
||||
@@ -477,7 +512,7 @@ class StaticProfile:
|
||||
u += timedelta(hours=1)
|
||||
if not out:
|
||||
raise ProfileInvalid(
|
||||
f"static_profile.series({since.isoformat()}, "
|
||||
f"{self.option}.series({since.isoformat()}, "
|
||||
f"{until.isoformat()}) spans no whole hour. An empty series is "
|
||||
f"not constructible downstream on purpose (price.py trap 3).")
|
||||
return out
|
||||
@@ -489,7 +524,7 @@ class StaticProfile:
|
||||
return f"{kind}: " + "; ".join(p.words for p in self.periods)
|
||||
|
||||
|
||||
def _check_tiling(periods) -> None:
|
||||
def _check_tiling(periods, *, option: str = "static_profile") -> None:
|
||||
"""Every day type must be covered by EXACTLY ONE period, every minute.
|
||||
|
||||
⚠️ REFUSES BOTH DIRECTIONS. A gap means an hour with no price, and the only
|
||||
@@ -506,22 +541,22 @@ def _check_tiling(periods) -> None:
|
||||
if n > 1 and over_from is None:
|
||||
over_from = (minute, n)
|
||||
if n != 0 and gap_from is not None:
|
||||
_raise_gap(weekday, gap_from, minute)
|
||||
_raise_gap(weekday, gap_from, minute, option)
|
||||
if n <= 1 and over_from is not None:
|
||||
_raise_overlap(weekday, over_from, minute, periods)
|
||||
_raise_overlap(weekday, over_from, minute, periods, option)
|
||||
if gap_from is not None:
|
||||
_raise_gap(weekday, gap_from, MINUTES_PER_DAY)
|
||||
_raise_gap(weekday, gap_from, MINUTES_PER_DAY, option)
|
||||
if over_from is not None:
|
||||
_raise_overlap(weekday, over_from, MINUTES_PER_DAY, periods)
|
||||
_raise_overlap(weekday, over_from, MINUTES_PER_DAY, periods, option)
|
||||
|
||||
|
||||
def _hhmm(m):
|
||||
return f"{m // 60:02d}:{m % 60:02d}"
|
||||
|
||||
|
||||
def _raise_gap(weekday, start, end):
|
||||
def _raise_gap(weekday, start, end, option="static_profile"):
|
||||
raise ProfileInvalid(
|
||||
f"static_profile leaves {DAY_NAMES[weekday]} "
|
||||
f"{option} leaves {DAY_NAMES[weekday]} "
|
||||
f"{_hhmm(start)}-{_hhmm(end)} with no period, so those hours have no "
|
||||
f"price. Refused rather than defaulted: an invented price is one an "
|
||||
f"optimiser would trade on, and §11.4 already says what a missing price "
|
||||
@@ -529,19 +564,28 @@ def _raise_gap(weekday, start, end):
|
||||
f"\"22:00-07:00 mon-fri\" does NOT cover Saturday morning.")
|
||||
|
||||
|
||||
def _raise_overlap(weekday, over, end, periods):
|
||||
def _raise_overlap(weekday, over, end, periods, option="static_profile"):
|
||||
start, n = over
|
||||
names = [p.name for p in periods if p.covers(weekday, start)]
|
||||
raise ProfileInvalid(
|
||||
f"static_profile gives {DAY_NAMES[weekday]} {_hhmm(start)}-"
|
||||
f"{option} gives {DAY_NAMES[weekday]} {_hhmm(start)}-"
|
||||
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Two prices for "
|
||||
f"one hour means one of them silently wins on list order. Narrow one of "
|
||||
f"the windows or split the day selector.")
|
||||
|
||||
|
||||
def parse_static_profile(value):
|
||||
def parse_static_profile(value, *, option: str = "static_profile",
|
||||
means: str = None):
|
||||
"""`static_profile` -> a `StaticProfile`, or None when unset.
|
||||
|
||||
⚠️ TARIFF-17 MADE `option`/`means` PARAMETERS RATHER THAN FORKING THIS
|
||||
FUNCTION, and that is the whole of the reuse. A second period parser would
|
||||
be a second tiling rule, a second day-type rule and a second set of DST
|
||||
bugs, diverging first on the 23- and 25-hour days where nobody looks. The
|
||||
two arguments move only the OPTION NAME and the "what this number is"
|
||||
clause through the refusals; the grammar, the tiling check and the wrapping
|
||||
rule are one implementation with one set of checks over it.
|
||||
|
||||
Accepts what the option can actually arrive as:
|
||||
* `None` / `""` / `"null"` - unset. §14 ships `static_profile: null`.
|
||||
* a JSON string - what config.yaml's flat `str?` option
|
||||
@@ -555,6 +599,8 @@ def parse_static_profile(value):
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if means is None:
|
||||
means = _PERIOD_MEANS.get(option, _PERIOD_MEANS["static_profile"])
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text or text.lower() in ("null", "none", "off"):
|
||||
@@ -563,7 +609,7 @@ def parse_static_profile(value):
|
||||
value = json.loads(text)
|
||||
except ValueError as e:
|
||||
raise ProfileInvalid(
|
||||
f"static_profile is not valid JSON ({e}). It is a JSON object "
|
||||
f"{option} is not valid JSON ({e}). It is a JSON object "
|
||||
f"with a \"periods\" list, on one line, e.g.\n"
|
||||
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
|
||||
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
|
||||
@@ -573,7 +619,7 @@ def parse_static_profile(value):
|
||||
tz = str(value.get("timezone", "") or "").strip().lower()
|
||||
if tz not in SUPPORTED_TZ:
|
||||
raise ProfileInvalid(
|
||||
f"static_profile.timezone is {value.get('timezone')!r}. Only "
|
||||
f"{option}.timezone is {value.get('timezone')!r}. Only "
|
||||
f"Europe/Brussels is supported: the shipped image carries no tz "
|
||||
f"database (see app/price.py), so any other zone could not be "
|
||||
f"honoured and would be silently treated as Brussels - which is "
|
||||
@@ -581,24 +627,24 @@ def parse_static_profile(value):
|
||||
rows = value.get("periods")
|
||||
if rows is None:
|
||||
raise ProfileInvalid(
|
||||
"static_profile has no \"periods\" list. Even a fully-fixed "
|
||||
"tariff is written as one period covering 00:00-24:00 all days "
|
||||
"- one shape for both products, so nothing downstream branches "
|
||||
"on which kind of site this is.")
|
||||
f"{option} has no \"periods\" list. Even a fully-fixed "
|
||||
f"tariff is written as one period covering 00:00-24:00 all days "
|
||||
f"- one shape for both products, so nothing downstream branches "
|
||||
f"on which kind of site this is.")
|
||||
else:
|
||||
rows = value
|
||||
|
||||
if not isinstance(rows, (list, tuple)):
|
||||
raise ProfileInvalid(
|
||||
f"static_profile.periods is {type(rows).__name__}, not a list")
|
||||
f"{option}.periods is {type(rows).__name__}, not a list")
|
||||
|
||||
periods = []
|
||||
for i, row in enumerate(rows, start=1):
|
||||
if not isinstance(row, dict):
|
||||
raise ProfileInvalid(
|
||||
f"static_profile.periods[{i}] is {type(row).__name__}, not an "
|
||||
f"{option}.periods[{i}] is {type(row).__name__}, not an "
|
||||
f"object with from/to/days/eur_per_kwh")
|
||||
where = f"static_profile.periods[{i}]"
|
||||
where = f"{option}.periods[{i}]"
|
||||
name = str(row.get("name", "") or f"period{i}").strip() or f"period{i}"
|
||||
start = _minutes(row.get("from", row.get("start")), f"{where}.from")
|
||||
end = _minutes(row.get("to", row.get("end")), f"{where}.to")
|
||||
@@ -617,8 +663,47 @@ def parse_static_profile(value):
|
||||
end_min=end,
|
||||
days=_days(row.get("days"), f"{where}.days"),
|
||||
eur_per_kwh=_price(row.get("eur_per_kwh"),
|
||||
f"{where}.eur_per_kwh")))
|
||||
return StaticProfile(periods)
|
||||
f"{where}.eur_per_kwh",
|
||||
means)))
|
||||
return StaticProfile(periods, option=option)
|
||||
|
||||
|
||||
def parse_taxes_profile(value):
|
||||
"""`price_taxes_profile` -> a `StaticProfile` of TAX values, or None.
|
||||
|
||||
TARIFF-17. THE DEFECT THIS EXISTS TO CLOSE: `price.py` modelled the whole
|
||||
non-energy part of a Belgian bill as ONE scalar. In Flanders that is
|
||||
correct - Fluvius distribution is a single rate and its tariff card offers
|
||||
only `Normaal | Uitsl. nacht`, where *exclusive night* is a separate
|
||||
physical circuit rather than a night register. The Walloon and Brussels
|
||||
DSOs are on the same card with four columns - `Enkelvoudig | Dag | Nacht |
|
||||
Uitsl. nacht` - and the day/night gap is real money:
|
||||
|
||||
ORES dag 13,27 nacht 7,39 -> 5,88 c EUR/kWh
|
||||
AIESH dag 15,17 nacht 8,21 -> 6,96
|
||||
Tecteo RESA dag 12,20 nacht 7,01 -> 5,19
|
||||
Sibelga (BXL) dag 9,39 nacht 7,10 -> 2,29
|
||||
ORES IMPACT dag 16,578 nacht 5,090 -> 11,49 (2026)
|
||||
|
||||
⚠️ AND THE BANDS ARE ANTI-CORRELATED WITH WHAT THE BATTERY WANTS TO DO.
|
||||
The cheap network hours are the cheap spot hours, so flattening them does
|
||||
not merely shift the curve - it SYSTEMATICALLY UNDERSTATES the true spread
|
||||
and makes the add-on decline cycles that are genuinely profitable. See
|
||||
DOCS.md "The three buckets" for why the flat scalar could never have done
|
||||
this: an additive constant, identical every hour, cancels exactly in
|
||||
`max - min` and cannot move `min(firm, key=...)` either.
|
||||
|
||||
⚠️ THE VALUE IS THE TAXES COMPONENT, NOT AN ALL-IN PRICE. `static_profile`
|
||||
uses the same grammar for an all-in figure; the two are one parser and two
|
||||
meanings, and `_price()`'s refusal names which one it wanted.
|
||||
|
||||
⚠️ NOT PARSED ON THE FETCH PATH. validate.py refuses a malformed table at
|
||||
STARTUP, by the rule `price.py` trap 3 states for `static_profile`: a table
|
||||
that cannot be parsed at 03:00 is a site that stops pricing at 03:00, and
|
||||
§11.4 makes that quiet.
|
||||
"""
|
||||
return parse_static_profile(value, option=TAXES_PROFILE_OPTION,
|
||||
means=_TAXES_MEANS)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -728,6 +813,33 @@ class TariffPlan:
|
||||
self.origin = origin
|
||||
|
||||
# -- derived facts -------------------------------------------------------
|
||||
@property
|
||||
def from_feed(self) -> bool:
|
||||
"""Is this series an OBSERVATION of the configured provider's feed?
|
||||
|
||||
TARIFF-12. `origin` names four ways a plan is obtained; this collapses
|
||||
them to the one question a caller holding a PREVIOUS plan has to ask
|
||||
about a new one - did this resolution see the feed, or is it standing
|
||||
in for one that did not? Only `"provider"` saw it. `"fallback"` and
|
||||
`"none"` are the two §11.4 rows, and `"static"` is a site with no
|
||||
feed to observe at all, where the question does not arise.
|
||||
|
||||
⚠️ IT IS NOT `has_prices`, AND THE DIFFERENCE IS THE WHOLE POINT. A
|
||||
§11.4 fallback HAS prices - the operator's own static profile - but
|
||||
every point `_degrade()` builds carries `provisional=True`, so
|
||||
`firm_points()` is empty, `arbitrage()` refuses and `cheap_now()` is
|
||||
False. It is a real tariff and it is not an observation, and a caller
|
||||
deciding whether to REPLACE an observation with it needs the second
|
||||
fact, not the first.
|
||||
|
||||
⚠️ AND IT IS A PROPERTY HERE RATHER THAN AN `origin == "provider"` AT
|
||||
THE CALL SITE, for `main.fresh_tariff_plan()`'s stated reason: a check
|
||||
has to be able to assert on the shipped expression rather than on a
|
||||
transcription that drifts, and `origin`'s vocabulary belongs to this
|
||||
module.
|
||||
"""
|
||||
return self.origin == "provider"
|
||||
|
||||
@property
|
||||
def has_prices(self) -> bool:
|
||||
return self.read.match(series=lambda _r: True, no_data=lambda _r: False)
|
||||
@@ -744,6 +856,63 @@ class TariffPlan:
|
||||
series=lambda r: any(p.provisional for p in r.points),
|
||||
no_data=lambda _r: True)
|
||||
|
||||
# -- the plan's SHAPE, in the two units it actually has (TARIFF-11) ------
|
||||
# ⚠️ POINTS AND HOURS ARE TWO NUMBERS AND THIS PLAN HAD ONLY EVER
|
||||
# REPORTED ONE OF THEM, UNDER THE OTHER'S NAME. `words` read
|
||||
# `f"... {len(self.read.points)} h"`, which counts POINTS: on the
|
||||
# quarter-hourly Belgian feed (15-minute MTU, 2025-10-01) a 33-hour plan
|
||||
# logged `dynamic_dayahead from generic_dayahead, 132 h`. It is the same
|
||||
# conflation TARIFF-14 removed from `at()` and FCST-04 removed from the
|
||||
# *Price history* row, left standing in the prose. ⚠️ AND THE ERROR IS
|
||||
# NOT A FLAT 4x: a 40 h soak measured 1.79x .. 5.33x, because §8.3's
|
||||
# carried points are HOURLY while the firm ones are quarter-hourly, so
|
||||
# the ratio moves with the firm/carried mix as the day goes on. Nothing
|
||||
# derived from ONE of these numbers can answer for the other.
|
||||
|
||||
@property
|
||||
def n_points(self) -> int:
|
||||
"""How many points the series carries. A `no_data` read carries none."""
|
||||
return self.read.match(series=lambda r: len(r.points),
|
||||
no_data=lambda _r: 0)
|
||||
|
||||
@property
|
||||
def hours(self) -> int:
|
||||
"""How many distinct WHOLE CLOCK HOURS those points touch.
|
||||
|
||||
Same rule as `price_history._held()`, deliberately - the *Tariff* and
|
||||
*Price history* rows describe the same feed and an operator reading
|
||||
`132 points over 33 h` beside `96 points over 24 h` must be able to
|
||||
compare them. A point is attributed to the UTC hour its own start
|
||||
falls in; hours the feed never printed are simply not in the set, so
|
||||
this counts what is PRICED rather than what was asked for.
|
||||
"""
|
||||
return self.read.match(series=lambda r: _whole_hours(r.points),
|
||||
no_data=lambda _r: 0)
|
||||
|
||||
@property
|
||||
def n_firm(self) -> int:
|
||||
"""Points carrying a published price for their own interval (§8.3)."""
|
||||
return len(self.firm_points())
|
||||
|
||||
@property
|
||||
def n_carried(self) -> int:
|
||||
"""Points filled from yesterday's profile, or from §11.4's fallback."""
|
||||
return self.n_points - self.n_firm
|
||||
|
||||
@property
|
||||
def spacings_s(self) -> tuple:
|
||||
"""The distinct gaps between consecutive points, ascending, in seconds.
|
||||
|
||||
⚠️ MIXED IS THE NORMAL STEADY STATE, NOT AN EDGE CASE. FCST-04's
|
||||
carry-forward emits HOURLY carried points beside QUARTER-HOURLY firm
|
||||
ones and the soak measured `[900, 3600]` in 70 % of samples - so a
|
||||
surface reporting one global spacing would be wrong about the usual
|
||||
case. `price._cover_table()` is where that rule is enforced; this
|
||||
only reports it.
|
||||
"""
|
||||
return self.read.match(
|
||||
series=lambda r: _spacings(r.points), no_data=lambda _r: ())
|
||||
|
||||
def price_at(self, instant: datetime) -> float:
|
||||
"""EUR/kWh now. Raises `Absence` when there is no price - never 0.0."""
|
||||
return self.read.match(
|
||||
@@ -899,20 +1068,30 @@ class TariffPlan:
|
||||
pts = self.read.points
|
||||
firm = tuple(p for p in pts if not p.provisional)
|
||||
if len(firm) < 2:
|
||||
# ⚠️ POINTS, AND THE HOUR FIGURE BESIDE IT RATHER THAN INSTEAD OF
|
||||
# IT (TARIFF-11). This sentence read "only 1 of 96 hours carry a
|
||||
# published price" about 96 quarter-hourly points, which are 24
|
||||
# hours - the point<->hour conflation TARIFF-14 removed from
|
||||
# `at()`, still standing here, and this one reaches an OPERATOR
|
||||
# through main.py's *Arbitrage* row and not only the log.
|
||||
return Arbitrage.refuse(
|
||||
f"§8.3: only {len(firm)} of {len(pts)} hours carry a published "
|
||||
f"price - the rest are provisional (origin {self.origin!r}), "
|
||||
f"and no irreversible action may be taken on a provisional "
|
||||
f"price. A buy hour and a sell hour must BOTH be firm before a "
|
||||
f"cycle can be committed. Load shifting, self-consumption and "
|
||||
f"peak shaving continue")
|
||||
f"§8.3: only {len(firm)} of {len(pts)} points carry a "
|
||||
f"published price - {_whole_hours(firm)} of "
|
||||
f"{_whole_hours(pts)} hours - and the rest are provisional "
|
||||
f"(origin {self.origin!r}), so no irreversible action may be "
|
||||
f"taken on them. A buy interval and a sell interval must BOTH "
|
||||
f"be firm before a cycle can be committed. Load shifting, "
|
||||
f"self-consumption and peak shaving continue")
|
||||
|
||||
spread = round(max(p.eur_per_kwh for p in firm)
|
||||
- min(p.eur_per_kwh for p in firm), 6)
|
||||
if spread == 0.0:
|
||||
# ⚠️ POINTS AND HOURS, SEPARATELY, for the §8.3 rung's reason -
|
||||
# this sentence counted points and called them "firm hours" too.
|
||||
return Arbitrage.refuse(
|
||||
f"the tariff is flat - every one of the {len(firm)} firm hours "
|
||||
f"costs {firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
|
||||
f"the tariff is flat - every one of the {len(firm)} firm "
|
||||
f"points, spanning {_whole_hours(firm)} hours, costs "
|
||||
f"{firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
|
||||
f"selling high is arithmetically impossible. §9.2: a fully "
|
||||
f"fixed product is self-consumption only")
|
||||
|
||||
@@ -942,15 +1121,77 @@ class TariffPlan:
|
||||
|
||||
@property
|
||||
def words(self) -> str:
|
||||
"""The plan's whole shape in one sentence - LOG *AND* STATUS PAGE.
|
||||
|
||||
⚠️ THIS STRING IS OPERATOR-FACING SINCE TARIFF-11, which is a reason
|
||||
for its wording to matter MORE rather than less. It used to reach only
|
||||
`_LOG.info("tariff refreshed: %s")`; main.py's *Tariff* row now carries
|
||||
it, age-stamped, so every number in it is read by somebody who cannot
|
||||
see the object.
|
||||
|
||||
⚠️ EVERY QUANTITY IS NAMED IN ITS OWN UNIT. `{n} points over {h} h` is
|
||||
FCST-04's *Price history* wording on purpose - the two rows describe
|
||||
the same feed and have to be comparable - and the firm/carried split
|
||||
and the spacing are here because they were the two facts the soak
|
||||
found an operator could not learn at all: the plan was `provisional`
|
||||
for 76 % of a 40-hour soak and mixed 900/3600 s for 70 %, and no HTTP
|
||||
surface said either.
|
||||
"""
|
||||
if not self.has_prices:
|
||||
return f"no tariff - {self.read.reason}"
|
||||
tag = {"provider": self.read.provider, "static": "static_profile",
|
||||
"fallback": "static_profile (feed down)"}.get(self.origin,
|
||||
self.origin)
|
||||
return (f"{self.product.value} from {tag}, {len(self.read.points)} h"
|
||||
gaps = self.spacings_s
|
||||
return (f"{self.product.value} from {tag}, "
|
||||
f"{self.n_points} points over {self.hours} h, "
|
||||
f"{self.n_firm} firm / {self.n_carried} carried"
|
||||
+ (f", {'+'.join(str(g) for g in gaps)} s spacing"
|
||||
if gaps else "")
|
||||
+ (", provisional" if self.provisional else ""))
|
||||
|
||||
|
||||
def _whole_hours(points) -> int:
|
||||
"""How many distinct whole CLOCK HOURS a point list touches. Never raises.
|
||||
|
||||
THE DEFECT THIS PREVENTS (TARIFF-11). Three sentences in this module
|
||||
reported `len(points)` under the name "hours": `TariffPlan.words`, §8.3's
|
||||
firm-pair refusal and the flat-tariff refusal. On the hourly series that
|
||||
was the only resolution any fixture carried before TARIFF-08 the two
|
||||
numbers are identical, which is why it shipped; on the Belgian
|
||||
quarter-hourly feed (15-minute MTU, 2025-10-01) `words` read
|
||||
`dynamic_dayahead from generic_dayahead, 132 h` for 33 hours and the
|
||||
refusal read `only 1 of 96 hours` for 24.
|
||||
|
||||
⚠️ IT IS NOT `n / 4`, AND A CONSTANT FACTOR IS THE WRONG FIX. §8.3's
|
||||
carry-forward emits ONE HOURLY point per unpublished hour beside the
|
||||
feed's four-per-hour firm ones, so the measured overstatement over a
|
||||
40-hour soak ranged 1.79x .. 5.33x as the mix moved. The hour count has
|
||||
to be COUNTED.
|
||||
|
||||
⚠️ IT COUNTS PRICED HOURS, NOT THE HORIZON. FCST-04 leaves an hour out
|
||||
of the series when neither the feed nor yesterday can price it, so this
|
||||
is at most the horizon and is smaller whenever the plan has holes - which
|
||||
is the honest direction for a figure an operator reads as "how far ahead
|
||||
do I have prices".
|
||||
"""
|
||||
return len({p.start.astimezone(timezone.utc)
|
||||
.replace(minute=0, second=0, microsecond=0) for p in points})
|
||||
|
||||
|
||||
def _spacings(points) -> tuple:
|
||||
"""Distinct gaps between consecutive points, ascending, in whole seconds.
|
||||
|
||||
Sorted by START, not by list order: `price._cover_table()` establishes
|
||||
that order for `at()` rather than assuming it, and a surface that reported
|
||||
the gaps of an unsorted list would print negative spacings about a series
|
||||
that answers correctly.
|
||||
"""
|
||||
starts = sorted(p.start.astimezone(timezone.utc) for p in points)
|
||||
return tuple(sorted({int((b - a).total_seconds())
|
||||
for a, b in zip(starts, starts[1:])}))
|
||||
|
||||
|
||||
def _absent(reason):
|
||||
raise Absence(
|
||||
f"this plan observed no prices ({reason}). A number invented here is "
|
||||
@@ -1055,7 +1296,8 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
|
||||
⚠️ THE FILL IS PER WHOLE HOUR AND ITS COVERAGE IS CONDITIONAL. Every point
|
||||
the provider published inside the window passes through BY IDENTITY - four
|
||||
of them in one hour on a quarter-hourly product like `eneco_eddy` - and a
|
||||
of them in one hour on the quarter-hourly day-ahead feed `generic_dayahead`
|
||||
reads (Belgium's 15-minute MTU, 2025-10-01) - and a
|
||||
carried point is added only for a whole hour the feed did not print at all
|
||||
AND yesterday did. An hour neither source can price is left out, which is
|
||||
§11.4 for that hour: `plan.price_at()` raises rather than answering, and an
|
||||
@@ -1071,8 +1313,7 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
missing and loses the elapsed ones it should never have had.
|
||||
"""
|
||||
stamp = now or datetime.now(timezone.utc)
|
||||
pid = str(price_option(opts, "price_provider") or "off").strip()
|
||||
if pid in ("", "off"):
|
||||
if not provider_configured(opts):
|
||||
return static_plan(opts, since, until, provisional=False, now=stamp)
|
||||
|
||||
read = await load_prices(opts, since, until, transport)
|
||||
|
||||
@@ -132,6 +132,18 @@ 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.
|
||||
|
||||
# TARIFF-13. What the COVERAGE figure reads when the store cannot count.
|
||||
#
|
||||
# ⚠️ NOT 0, AND NOT `None`. This is the reading-state argument of TARIFF-01
|
||||
# applied to the number printed BESIDE the peak rather than to the peak. A
|
||||
# coverage figure of 0 says "this month has been measured over no quarters at
|
||||
# all", which is the strongest possible claim that the peak next to it means
|
||||
# nothing - and `f"{None} quarter(s) banked"` renders "None quarter(s) banked",
|
||||
# which an operator reads as that same zero. The one honest answer to "how many
|
||||
# quarters have I banked" when the table will not answer is that it is not
|
||||
# known, and it has to SAY so in the sentence the operator reads.
|
||||
COUNT_UNREADABLE = "an unreadable number of"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# local time, without a hard dependency on the tz database
|
||||
@@ -388,6 +400,23 @@ class PeakStore:
|
||||
" per_phase_json=excluded.per_phase_json"
|
||||
" WHERE excluded.offtake_avg_w > quarter_blocks.offtake_avg_w",
|
||||
(start, month, w, pp_json))
|
||||
# TARIFF-13. THE TIE RULE, DECIDED RATHER THAN EMERGENT:
|
||||
# at EQUAL wattage the EARLIEST quarter keeps the attribution.
|
||||
# See DECISIONS/TARIFF-13-the-coverage-of-a-peak.md §2.
|
||||
#
|
||||
# `peak_w` itself is still only ever RAISED - the `>` term is
|
||||
# untouched and no tie can move the billed number - so SAFETY-07
|
||||
# reads exactly the watt figure it read before. The second term
|
||||
# moves only the TIMESTAMP the status page attributes it to.
|
||||
#
|
||||
# ⚠️ WITHOUT THE SECOND TERM THE TWO READERS DISAGREE, measured:
|
||||
# `>` alone keeps whichever tied block was WRITTEN FIRST, while
|
||||
# `recompute_month()`'s `ORDER BY offtake_avg_w DESC,
|
||||
# quarter_start ASC` keeps the EARLIEST. Those coincide only
|
||||
# while blocks arrive in time order. Fed four equal 365 W blocks
|
||||
# newest-first, `monthly_peak` answered 09:45 and the recompute
|
||||
# answered 09:00 - a divergence in the derived record part 8 of
|
||||
# test_tariff.py exists to prove cannot happen.
|
||||
self._db.execute(
|
||||
"INSERT INTO monthly_peak(month, peak_w, quarter_start, per_phase_json)"
|
||||
" VALUES(?,?,?,?)"
|
||||
@@ -395,7 +424,9 @@ class PeakStore:
|
||||
" 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",
|
||||
" WHERE excluded.peak_w > monthly_peak.peak_w"
|
||||
" OR (excluded.peak_w = monthly_peak.peak_w"
|
||||
" AND excluded.quarter_start < monthly_peak.quarter_start)",
|
||||
(month, w, start, pp_json))
|
||||
return True
|
||||
except sqlite3.Error as err:
|
||||
@@ -449,6 +480,49 @@ class PeakStore:
|
||||
quarter_start=datetime.fromisoformat(row[1]),
|
||||
per_phase_w=pp)
|
||||
|
||||
def banked_count(self, month: str) -> int | None:
|
||||
"""How many quarters this month has banked. `None` when unknowable.
|
||||
|
||||
TARIFF-13. THE DEFECT THIS PREVENTS: the status page printed a coverage
|
||||
figure held in PROCESS MEMORY - `PeakTracker.recorded`, incremented once
|
||||
per block banked by THIS process - beside a peak read from SQLite. The
|
||||
peak outlives a restart and the counter does not, so a driven restart
|
||||
mid-month took a real reading of
|
||||
|
||||
2026-09: peak 365 W ... ; 120 quarter(s) banked
|
||||
|
||||
to the same peak, the same attributed quarter and `0 quarter(s) banked`,
|
||||
on both instances independently. The count is what tells an operator how
|
||||
much of the billing month the number beside it actually covers: 365 W
|
||||
over 120 quarters and 365 W over 0 quarters are different claims about
|
||||
the same watt figure, and after a restart the page made the weaker claim
|
||||
while displaying the stronger one's value. Reading the count from the
|
||||
same durable tier the peak comes from is what makes the two agree.
|
||||
|
||||
⚠️ `None`, NOT 0, and NOT `len(blocks_in(month))`. `blocks_in()` returns
|
||||
`[]` on a read error - safe there only because no caller reads its
|
||||
emptiness as a peak (§ its own docstring) - so counting its length would
|
||||
rebuild the disarming zero one call further out. This asks the database
|
||||
for a COUNT and refuses to answer when the database refuses.
|
||||
|
||||
⚠️ Counts `quarter_blocks`, the tier with one row per closed block, and
|
||||
NOT `monthly_peak`, which has one row per MONTH and would answer 1 for
|
||||
every month that has ever been measured. The two tables are proven equal
|
||||
on the PEAK by `recompute_month()`; they were never equal on the COUNT
|
||||
and are not meant to be.
|
||||
"""
|
||||
if self._db is None:
|
||||
return None
|
||||
try:
|
||||
row = self._db.execute(
|
||||
"SELECT COUNT(*) FROM quarter_blocks WHERE month=?",
|
||||
(month,)).fetchone()
|
||||
except sqlite3.Error as err:
|
||||
self.last_error = str(err)
|
||||
_LOG.error("could not count quarters for %s (%s)", month, err)
|
||||
return None
|
||||
return int(row[0])
|
||||
|
||||
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
|
||||
@@ -539,11 +613,59 @@ class PeakTracker:
|
||||
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
|
||||
|
||||
# -- coverage -----------------------------------------------------------
|
||||
#
|
||||
# TARIFF-13. `recorded` USED TO BE AN `int` ATTRIBUTE ON THIS OBJECT,
|
||||
# starting at 0 and incremented in `sync()`. It is a property over the store
|
||||
# now, and the three things that changes are all defects it was carrying:
|
||||
#
|
||||
# * it RESET TO 0 ON EVERY RESTART, beside a peak that did not - see
|
||||
# `PeakStore.banked_count()` for the driven reading;
|
||||
# * it was NOT SCOPED TO A MONTH. It counted every block this process had
|
||||
# banked, so on 1 October the row still read September's tally beside
|
||||
# October's "no completed quarter recorded yet";
|
||||
# * it MISSED the block `restore()` banks. A stale block closed from the
|
||||
# checkpoint goes into `quarter_blocks` and never went through `sync()`,
|
||||
# so the counter never saw it. Counting the table cannot miss it.
|
||||
#
|
||||
# ⚠️ REMOVING THE COUNTER DOES NOT REMOVE THE DEDUPE. `_seen` is still what
|
||||
# stops `cycle()`'s once-a-second `sync()` re-banking a closed quarter that
|
||||
# `P1Ingest.blocks` keeps for a DAY, and `sync()`'s RETURN value is still
|
||||
# the count of blocks newly banked on that call. What changed is only where
|
||||
# the STATUS PAGE's figure is read from. The check that grades the dedupe
|
||||
# must therefore grade `sync()`'s return, not this property, which now
|
||||
# answers 1 from the database however many times the block was offered.
|
||||
|
||||
def recorded_in(self, when: datetime | None = None):
|
||||
"""Quarters banked in `when`'s billing month - `int`, or the sentinel.
|
||||
|
||||
⚠️ The return type is `int | str` on purpose, and the `str` is
|
||||
`COUNT_UNREADABLE`. A caller that formats this into a sentence gets a
|
||||
true sentence in both cases; a caller that wants to do arithmetic on it
|
||||
gets a TypeError, which is loud. That is the same trade `PeakReading`
|
||||
makes by having no `peak_w_or(default)`.
|
||||
"""
|
||||
n = self.store.banked_count(month_key(when or datetime.now(timezone.utc)))
|
||||
return COUNT_UNREADABLE if n is None else n
|
||||
|
||||
@property
|
||||
def recorded(self):
|
||||
"""Quarters banked in the month being billed TODAY.
|
||||
|
||||
⚠️ This is the live status page's reading, where the peak beside it is
|
||||
also read for `datetime.now()`. `checks(when=X)` for an X in another
|
||||
month is a test affordance (TEST-17); the row scoped that way gets this
|
||||
month's count beside that month's peak, which is strictly better than
|
||||
the process-wide counter it replaces but is still a mismatch.
|
||||
`recorded_in(when)` is the scoped form the row should read once
|
||||
`main.py`'s status rows are free to change - TARIFF-11 holds them.
|
||||
"""
|
||||
return self.recorded_in()
|
||||
|
||||
# -- startup ------------------------------------------------------------
|
||||
def restore(self, averager, now: datetime | None = None) -> str:
|
||||
"""Put the open block back. Returns which of the three cases happened."""
|
||||
@@ -600,7 +722,6 @@ class PeakTracker:
|
||||
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
|
||||
|
||||
@@ -118,8 +118,14 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
It is warned about below, not refused.
|
||||
|
||||
7. A price configuration that cannot mean what it says. [(a)]
|
||||
Three cases, all in `_price_problems()`: an unknown `price_provider` id
|
||||
(refused rather than defaulted to 'off', per the §14 warning box);
|
||||
Four cases, all in `_price_problems()`: an unknown `price_provider` id
|
||||
(refused rather than defaulted to 'off', per the §14 warning box); a
|
||||
RETIRED id - one this add-on really did ship and has been accepting ever
|
||||
since, whose plugin never once fetched a price because its endpoint does
|
||||
not exist (TARIFF-07), which is case (a) in its purest form: §11.4's
|
||||
silent fall back to the static profile IS the layer the operator cannot
|
||||
see, and it has been overriding their chosen source since the day they
|
||||
set it;
|
||||
`price_provider` set alongside `price_now_entity`/`price_avg_entity`,
|
||||
which is TWO PRICE SOURCES ON ONE SITE where the Home Assistant pair
|
||||
silently wins; and `manual_csv` with no `price_csv_path`, which turns the
|
||||
@@ -318,12 +324,20 @@ import logging
|
||||
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from .custom_formula import (
|
||||
EXPORT_FORMULA_OPTION, FormulaInvalid, implausible_all_in,
|
||||
parse_export_formula, parse_price_formula, stale_card)
|
||||
from .inverter import roster_entries
|
||||
from .maintenance import MAINT_OPTION_DEFAULTS
|
||||
from .p1 import is_enabled, validate_meter_options
|
||||
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS
|
||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||
from .price import (
|
||||
PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS,
|
||||
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||
from .shadow import problems as shadow_problems
|
||||
from .static_tariff import (
|
||||
ProfileInvalid, TAXES_PROFILE_OPTION, parse_static_profile,
|
||||
parse_taxes_profile)
|
||||
from .watchdog import SIGN_OPTION_DEFAULTS
|
||||
|
||||
_LOG = logging.getLogger("goodwe.config")
|
||||
@@ -827,6 +841,18 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# missing rating one line up.
|
||||
problems.extend(_roster_problems(opts))
|
||||
|
||||
# --- 11. SAFETY-10's shadow mode, and it is a REFUSAL ---------------------
|
||||
# `shadow_mode` decides whether this controller commands the battery at all,
|
||||
# so an unreadable value is not a detail to log and carry on past.
|
||||
# `shadow.mode()` resolves an unrecognised one to SHADOW - the direction
|
||||
# that does not move energy - and an install silently reinterpreted that way
|
||||
# commands nothing, indefinitely, while its status page calmly explains
|
||||
# that this is intentional. UX-02 made this validator the ONE rule surface
|
||||
# both editors read, so the refusal belongs here rather than in a coercion
|
||||
# nobody can see. The sentence spells `shadow_mode` so
|
||||
# `configui.problems_by_field()` can file it under the field.
|
||||
problems.extend(shadow_problems(opts))
|
||||
|
||||
# --- 2. the configured ceiling against the device's own rating -----------
|
||||
# ⚠️ THE FLAT `max_w`, AND ONLY THE FLAT ONE. `dev_min`/`dev_max` are ONE
|
||||
# device's range - the flat `setpoint_entity`'s - so this rule can only ever
|
||||
@@ -1242,17 +1268,74 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
without a price feed is an add-on that a Belgian fixed-tariff site - a large
|
||||
share of the installed base, per §9.2 - cannot run at all.
|
||||
|
||||
Four refusals and two log lines (TARIFF-03 added one refusal, one parse and
|
||||
two notices), and every refusal is still case (a) from this module's
|
||||
docstring: a source the operator SET being overridden by a layer they cannot
|
||||
see. The additions are `static_profile` alongside the Home Assistant price
|
||||
pair - the same silent-loser shape as provider-plus-pair, and a LIVE one -
|
||||
and a `static_profile` that does not tile the day, which would leave hours
|
||||
with no price at all.
|
||||
Five refusals and two log lines (TARIFF-03 added one refusal, one parse and
|
||||
two notices; TARIFF-07 added the retired-id one; TARIFF-15 added three -
|
||||
a `custom_formula` with no formula, a formula that does not parse or does
|
||||
not reproduce its own card, and one whose card lands outside the plausible
|
||||
band - plus two notices, a formula nothing reads and a stale card), and
|
||||
every refusal is still case (a) from this module's docstring: a source the
|
||||
operator SET being overridden by a layer they cannot see. The additions are `static_profile`
|
||||
alongside the Home Assistant price pair - the same silent-loser shape as
|
||||
provider-plus-pair, and a LIVE one - and a `static_profile` that does not
|
||||
tile the day, which would leave hours with no price at all.
|
||||
|
||||
⚠️ THE RETIRED-ID BRANCH IS NOT A FRIENDLIER SPELLING OF THE UNKNOWN-ID
|
||||
ONE, AND MUST NOT COLLAPSE INTO IT. They answer different questions. An
|
||||
unknown id is a TYPO: the operator meant something else and the fix is to
|
||||
spell it right. A retired id is a CHOICE THE ADD-ON ACCEPTED - it was in
|
||||
the schema, it was in the dropdown, it resolved to a class, and every
|
||||
startup since said nothing. What that operator needs is not "this id is
|
||||
wrong" but "this id has never once returned a price, and here is what your
|
||||
site has actually been running instead" - because §11.4 made the failure
|
||||
silent and NOTHING on their status page has ever distinguished it from a
|
||||
working provider that never finds a cheap hour. The retired branch is
|
||||
therefore FIRST, and it names the id, the endpoint, the observation, the
|
||||
tariff that has really been in force, and the move to make.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
pid = str(opts.get("price_provider", "") or "off").strip() or "off"
|
||||
|
||||
if pid in RETIRED_PROVIDERS:
|
||||
# ⚠️ BEFORE the unknown-id branch, and the order is load-bearing: a
|
||||
# retired id is ALSO not in PROVIDERS, so the generic message would
|
||||
# win on a reordering and the operator would be told they mistyped
|
||||
# something they chose on purpose.
|
||||
# ⚠️ AND THE SECOND SENTENCE CHANGES WITH `static_profile`, because
|
||||
# "you have been on the static profile" is FALSE on a site that never
|
||||
# set one. §11.4 falls back to the static profile; with no profile to
|
||||
# fall back to, the site has had no tariff at all - a worse state, and
|
||||
# one this message must not paper over.
|
||||
been_running = (
|
||||
f"the static day/night tariff in static_profile"
|
||||
if str(opts.get("static_profile", "") or "").strip()
|
||||
else "NOTHING AT ALL - static_profile is empty too, so there was "
|
||||
"no tariff to fall back to either")
|
||||
problems.append(
|
||||
f"price_provider is {pid!r}, which WAS a shipped plugin and has "
|
||||
f"been WITHDRAWN (TARIFF-07). This is not a typo and you did not "
|
||||
f"mistype it - the add-on offered this id, accepted it, and has "
|
||||
f"said nothing at every startup since. "
|
||||
f"⚠️ IT HAS NEVER FETCHED A PRICE, HERE OR ANYWHERE: "
|
||||
f"{RETIRED_PROVIDERS[pid]}. Every fetch it ever made ended in "
|
||||
f"SourceUnavailable, and §11.4 turns that into a SILENT fall back "
|
||||
f"- so what this site has actually been running since the day the "
|
||||
f"id was set is {been_running}. Nothing you could see would have "
|
||||
f"differed from a working provider that simply never found a cheap "
|
||||
f"hour, which is why you are being told now rather than left on "
|
||||
f"it. WHAT TO DO: set price_provider to 'generic_dayahead' for the "
|
||||
f"wholesale EPEX BE day-ahead curve, to which the add-on adds your "
|
||||
f"price_markup_eur_kwh and price_taxes_eur_kwh - that APPROXIMATES "
|
||||
f"a Belgian dynamic tariff and cannot reproduce your supplier's "
|
||||
f"B x spot + A formula, so the level will be off even when the "
|
||||
f"shape is right; or 'manual_csv' to enter all-in prices you "
|
||||
f"compute yourself; or 'off' to run on static_profile "
|
||||
f"deliberately. There is deliberately NO alias to your old "
|
||||
f"supplier: aliasing would need your contract's own two "
|
||||
f"coefficients, which live behind your klantenzone login and are "
|
||||
f"in no public index, so the add-on would have to invent the "
|
||||
f"numbers it prices your battery against.")
|
||||
return problems # nothing below can be judged against a dead id
|
||||
|
||||
if pid != "off" and pid not in PROVIDERS:
|
||||
problems.append(
|
||||
f"price_provider is {pid!r}, which is not a shipped plugin. The "
|
||||
@@ -1355,6 +1438,274 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
"for this product. Peak shaving, reserve management and the "
|
||||
"maintenance cycle are unaffected.", profile.prices[0])
|
||||
|
||||
# ⚠️ TARIFF-17: THE TAXES PERIOD TABLE, PARSED AT STARTUP RATHER THAN AT
|
||||
# 03:00, FOR `static_profile`'s REASON ONE OPTION OVER. This table is read
|
||||
# by `price.assemble()` on the ~30 s planning path; a table that only fails
|
||||
# when a fetch happens is a site that stops pricing mid-afternoon, and
|
||||
# §11.4 turns that into a quiet fallback that looks exactly like a healthy
|
||||
# fixed-tariff day. So the parse happens here, once, where a refusal is a
|
||||
# banner naming the key.
|
||||
raw_taxes_profile = opts.get(TAXES_PROFILE_OPTION, "")
|
||||
taxes_table = None
|
||||
if str(raw_taxes_profile or "").strip() \
|
||||
or isinstance(raw_taxes_profile, (list, dict)):
|
||||
try:
|
||||
taxes_table = parse_taxes_profile(raw_taxes_profile)
|
||||
except ProfileInvalid as e:
|
||||
problems.append(f"{TAXES_PROFILE_OPTION} cannot be used: {e}")
|
||||
|
||||
if taxes_table is not None:
|
||||
# ⚠️ TWO WRITERS OF ONE QUANTITY, ONE OF WHICH SILENTLY LOSES - the
|
||||
# exact case (a) this module exists for, and the same refusal shape as
|
||||
# the price-entity/static_profile pair above. The table decides when it
|
||||
# is set, so a non-default scalar left beside it is a number the
|
||||
# operator typed that changes nothing, on the quantity §11.4 makes
|
||||
# hardest to notice. It is compared against the SHIPPED DEFAULTS rather
|
||||
# than against presence, because the Supervisor writes every schema key
|
||||
# into the options file whether the operator touched it or not - so
|
||||
# "the operator did not type this" has to be read off the VALUE.
|
||||
#
|
||||
# ⚠️ TARIFF-18: "UNTOUCHED" IS A SET, NOT ONE NUMBER, AND THE DEFECT
|
||||
# THAT MAKES IT ONE IS AN UPGRADE THAT STOPS A BATTERY CONTROLLER
|
||||
# STARTING. The Supervisor sets `App.options = deepmerge(config.yaml
|
||||
# defaults, stored options)`, so a saved blob carries every schema key
|
||||
# whether the operator typed it or not - the reference install's export
|
||||
# is 70 of 71. When TARIFF-18 moved this default 0.06 -> 0.11736, every
|
||||
# such blob went from "equal to the shipped default" to "not equal to
|
||||
# it" without anybody editing anything, and a site that also carries a
|
||||
# taxes table would have met `SystemExit(78)` on the upgrade. The
|
||||
# sentence below is about a number the OPERATOR TYPED; a superseded
|
||||
# shipped default is not one, so it still reads as untouched. The set
|
||||
# is in price.py beside the default it supersedes, because the two only
|
||||
# ever move together.
|
||||
scalar = _number(opts, "price_taxes_eur_kwh",
|
||||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||||
shipped = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
|
||||
untouched = (shipped,) + tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||
superseded = ", ".join(
|
||||
str(u) for u in SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||
if scalar is not None \
|
||||
and not any(abs(scalar - u) <= 1e-12 for u in untouched):
|
||||
problems.append(
|
||||
f"{TAXES_PROFILE_OPTION} is set AND price_taxes_eur_kwh is "
|
||||
f"{scalar} rather than its shipped {shipped}. THE TABLE WINS: "
|
||||
f"the add-on reads the per-period figures and never looks at "
|
||||
f"the scalar, so the number you typed there would decide "
|
||||
f"nothing and nothing would say so. Set price_taxes_eur_kwh "
|
||||
f"back to {shipped}, or clear {TAXES_PROFILE_OPTION} to keep "
|
||||
f"the flat figure. (A scalar still sitting on a SUPERSEDED "
|
||||
f"default - {superseded} - is accepted and NOT refused: "
|
||||
f"that is a number this add-on wrote into your options "
|
||||
f"blob, not one you typed.)")
|
||||
|
||||
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
|
||||
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
|
||||
# (ORES `13,27`), and the whole table is copied off one card - so the
|
||||
# factor-100 error arrives on EVERY row at once and nothing internal to
|
||||
# the table disagrees with itself. The parser's own +-10 EUR/kWh bound
|
||||
# catches a 13,27 typed raw, but NOT ORES IMPACT's night band of 5,090,
|
||||
# which would be a 5.09 EUR/kWh levy sailing through. Belgian
|
||||
# non-energy components run roughly 0.05-0.25 EUR/kWh, so anything at
|
||||
# or above 1.0 is the wrong unit and not a tariff.
|
||||
steep = sorted({p.eur_per_kwh for p in taxes_table.periods
|
||||
if abs(p.eur_per_kwh) >= 1.0})
|
||||
if steep:
|
||||
problems.append(
|
||||
f"{TAXES_PROFILE_OPTION} carries {steep} EUR/kWh. Belgian "
|
||||
f"levies and distribution run about 0.05-0.25 EUR/kWh, so "
|
||||
f"these are almost certainly the CENT figures off the tariff "
|
||||
f"card (ORES prints `13,27`, which is 0.1327 EUR/kWh) typed "
|
||||
f"without the conversion - a factor of 100 that would make "
|
||||
f"every hour look like an emergency. Divide by 100, or clear "
|
||||
f"the option.")
|
||||
negative = sorted({p.eur_per_kwh for p in taxes_table.periods
|
||||
if p.eur_per_kwh < 0})
|
||||
if negative:
|
||||
# ⚠️ A WARNING, NOT A REFUSAL. A negative non-energy component is
|
||||
# implausible rather than impossible - a prosumer rebate is
|
||||
# expressible this way - and refusing a site over it would be this
|
||||
# module asserting a fact about Belgian tariff design it cannot
|
||||
# observe. §14's warning box governs CONTRADICTIONS, and this is
|
||||
# not one.
|
||||
_LOG.warning(
|
||||
"%s carries negative levies %s EUR/kWh. That is accepted, but "
|
||||
"a tax component below zero is usually a sign error: the "
|
||||
"add-on will SUBTRACT it from the published price in those "
|
||||
"hours.", TAXES_PROFILE_OPTION, negative)
|
||||
|
||||
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
||||
# STARTUP RATHER THAN AT 03:00.
|
||||
#
|
||||
# THE DEFECT THIS EXISTS TO PREVENT, and it is not one `code_leaks()` can
|
||||
# see because it is not in code: an operator folds their NETWORK CHARGES
|
||||
# into `offset` while `price_taxes_eur_kwh` carries them as well, and the
|
||||
# site is billed against a price ~12 c EUR/kWh high on every hour for as
|
||||
# long as nobody re-reads the configuration. §11.4 makes every price
|
||||
# defect quiet, so nothing on the status page would ever differ.
|
||||
#
|
||||
# The card is what grades it. A Belgian tariff card prints the formula,
|
||||
# the reference index it assumed AND the resulting price, so the add-on
|
||||
# recomputes that printed price from the numbers the operator typed and
|
||||
# refuses to start unless the two agree to within CARD_TOLERANCE. An
|
||||
# inflated `offset` cannot pass - the printed estimate is the energy
|
||||
# component alone, so ~12 c EUR/kWh moves 15.55 to ~27. The same check
|
||||
# catches VAT omitted, VAT applied twice, a transposed digit, and a unit
|
||||
# MISMATCH between the coefficients and the printed estimate. The one unit
|
||||
# error it cannot see is the one applied to BOTH sides at once, because
|
||||
# that cancels out of a comparison between them - which is why
|
||||
# `implausible_all_in()` below is a second refusal on a second quantity
|
||||
# rather than a restatement of this one.
|
||||
#
|
||||
# ⚠️ AND ITS LIMIT, IN THE SAME SENTENCE AS THE CLAIM, exactly as price.py
|
||||
# states trap 1's: IT GRADES THE COEFFICIENTS AGAINST THE CARD, NOT THE
|
||||
# CARD AGAINST REALITY. A consistent-but-wrong pair - both numbers off the
|
||||
# wrong product - reproduces its own printed price and passes. Nothing can
|
||||
# catch that.
|
||||
spec = None # TARIFF-16 reads this back, below
|
||||
raw_formula = opts.get("price_formula", "")
|
||||
has_formula = bool(str(raw_formula or "").strip()) \
|
||||
or isinstance(raw_formula, dict)
|
||||
if pid == "custom_formula" and not has_formula:
|
||||
problems.append(
|
||||
"price_provider is 'custom_formula' but price_formula is empty, "
|
||||
"so the add-on has no coefficients to price anything with. Every "
|
||||
"fetch would return no-price-data and §11.4 would fall back "
|
||||
"silently - which is the shape of defect this provider exists to "
|
||||
"remove, arrived at by leaving one field blank. Copy the "
|
||||
"Tariefformule line off your supplier's tariff card, e.g. "
|
||||
"{\"index\":\"epex_be\",\"resolution\":\"hourly\","
|
||||
"\"factor\":0.102,\"offset\":1.00,"
|
||||
"\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,"
|
||||
"\"card\":{\"month\":\"2026-09\","
|
||||
"\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}} "
|
||||
"- see DOCS.md. Or set price_provider to 'generic_dayahead' for "
|
||||
"the wholesale curve with a flat markup.")
|
||||
elif has_formula:
|
||||
try:
|
||||
spec = parse_price_formula(raw_formula)
|
||||
except FormulaInvalid as e:
|
||||
spec = None
|
||||
problems.append(f"price_formula cannot be used: {e}")
|
||||
if spec is not None and pid != "custom_formula":
|
||||
# ⚠️ A WARNING AND NOT A REFUSAL. This is not two price sources
|
||||
# fighting over one flag - it is one that is simply not wired to
|
||||
# anything - so the §14 warning box does not apply. But an option
|
||||
# an operator filled in and that decides nothing is exactly the
|
||||
# silent loser this module's case (a) is about, so it is said out
|
||||
# loud rather than left to be discovered from a bill.
|
||||
_LOG.warning(
|
||||
"price_formula is filled in but price_provider is %r, so the "
|
||||
"coefficients decide NOTHING - only 'custom_formula' reads "
|
||||
"them. Set price_provider to 'custom_formula' to use them, or "
|
||||
"clear price_formula so the configuration says what the "
|
||||
"add-on is doing.", pid)
|
||||
if spec is not None and pid == "custom_formula":
|
||||
taxes = _number(opts, "price_taxes_eur_kwh",
|
||||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||||
band = implausible_all_in(spec, taxes if taxes is not None else 0.0)
|
||||
if band:
|
||||
problems.append(band)
|
||||
aged = stale_card(spec)
|
||||
if aged:
|
||||
# ⚠️ WARN, NEVER REFUSE. ENGIE freezes a contract's
|
||||
# coefficients for its duration, so a two-year-old card can be
|
||||
# exactly right and a site refused over one is a site that
|
||||
# cannot start.
|
||||
_LOG.warning("%s", aged)
|
||||
|
||||
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
|
||||
#
|
||||
# THE DEFECT THIS EXISTS TO PREVENT: `export_price_eur_kwh` models
|
||||
# injection as ONE scalar, and the published market is a line -
|
||||
# Eneco `0,1 x BELPEX-H - 1,188` c EUR/kWh, "het injectietarief is
|
||||
# dynamisch en kan per uur wijzigen", identical on the January and the
|
||||
# September 2026 cards. A scalar cannot hold its own factor (0,100 against
|
||||
# 0,102 on the same supplier's consumption leg), cannot hold a negative
|
||||
# offset, cannot state that injection is VAT-EXEMPT while consumption is
|
||||
# at 6%, and cannot go below zero in the midday hours a PV household
|
||||
# actually exports in.
|
||||
#
|
||||
# ⚠️ AND WHAT IT DOES NOT AFFECT TODAY, said here rather than left to be
|
||||
# inferred: `arbitrage.py` does not read the injection price at all. Both
|
||||
# hours it compares are all-in CONSUMPTION prices, by its own
|
||||
# accounting-basis comment. No trade turns on this option. What it changes
|
||||
# is what the operator is told and what a future export-aware strategy
|
||||
# rests on - which is why it is validated here rather than trusted.
|
||||
raw_export = opts.get(EXPORT_FORMULA_OPTION, "")
|
||||
has_export = bool(str(raw_export or "").strip()) \
|
||||
or isinstance(raw_export, dict)
|
||||
if has_export:
|
||||
try:
|
||||
xspec = parse_export_formula(raw_export)
|
||||
except FormulaInvalid as e:
|
||||
xspec = None
|
||||
problems.append(f"{EXPORT_FORMULA_OPTION} cannot be used: {e}")
|
||||
if xspec is not None:
|
||||
# ⚠️ THE 6% ASYMMETRY, PRINTED RATHER THAN ASSUMED. The two legs
|
||||
# carry INDEPENDENT `vat_pct` values and a Belgian site's are 6 and
|
||||
# 0. Printing both beside each other is the only place an operator
|
||||
# can see that the add-on knows it - a status page shows one price
|
||||
# and no arithmetic.
|
||||
_LOG.info(
|
||||
"%s is set: injection priced as (%g x index %+g) x "
|
||||
"(1 + %g/100) %s, i.e. %.4g x the wholesale price with a "
|
||||
"%+.5f EUR/kWh offset. ⚠️ NO STRATEGY IN THIS ADD-ON READS "
|
||||
"IT: arbitrage.py prices the sell side as displaced IMPORT "
|
||||
"and compares two all-in CONSUMPTION prices, so this option "
|
||||
"changes no decision the add-on takes today. It is what you "
|
||||
"are shown, and the shape a future export-aware strategy "
|
||||
"would read.",
|
||||
EXPORT_FORMULA_OPTION, xspec.factor, xspec.offset,
|
||||
xspec.vat_pct, xspec.units, xspec.passthrough,
|
||||
xspec.offset_eur_kwh)
|
||||
if spec is not None:
|
||||
# ⚠️ THE ASYMMETRY, AS AN ORDERED PAIR RATHER THAN TWO NUMBERS
|
||||
# IN ONE SENTENCE (MUTATION.md habit 14). "6 and 0 appear
|
||||
# somewhere" is satisfied by the two legs transposed, which is
|
||||
# the sentence telling an operator that the add-on has their
|
||||
# VAT treatment exactly backwards.
|
||||
_LOG.info(
|
||||
"the two legs are priced independently: consumption "
|
||||
"factor %g at vat_pct %g, injection factor %g at vat_pct "
|
||||
"%g. In Belgium those differ - \"injectietarieven zijn "
|
||||
"vrijgesteld van btw\" while consumption is at 6%% - and "
|
||||
"%s is the option that makes the difference "
|
||||
"expressible; a single scalar could not state it.",
|
||||
spec.factor, spec.vat_pct, xspec.factor, xspec.vat_pct,
|
||||
EXPORT_FORMULA_OPTION)
|
||||
if not xspec.card_graded:
|
||||
# ⚠️ A WARNING AND NEVER A REFUSAL, AND THE GAP IS NAMED
|
||||
# RATHER THAN WORKED AROUND. `price_formula`'s card block is
|
||||
# required because a Belgian card prints the consumption
|
||||
# formula, the reference index AND the resulting price. Eneco's
|
||||
# INJECTION formula is printed with no price beside it, so on
|
||||
# that card there is nothing to reproduce. Requiring a block
|
||||
# here would make the market's own published line
|
||||
# inexpressible; inventing the estimate would grade the
|
||||
# coefficients against arithmetic done with the same
|
||||
# coefficients. So the leg is accepted, the two coefficient
|
||||
# bounds in `implausible_injection()` are applied, and the
|
||||
# operator is told which of the two checks did NOT run.
|
||||
_LOG.warning(
|
||||
"%s has no \"card\" block, so its coefficients are "
|
||||
"UNGRADED: nothing recomputed a printed price from them. "
|
||||
"That is accepted because it is the real market - Eneco "
|
||||
"prints its injection formula with no price beside it, so "
|
||||
"there is nothing to grade against on that card - and "
|
||||
"refusing would make the published line inexpressible. "
|
||||
"What IS checked is the shape of the coefficients: the "
|
||||
"pass-through (%.4g x the wholesale price) and the offset "
|
||||
"(%+.5f EUR/kWh). If YOUR card prints an injection "
|
||||
"estimate, add {\"month\": ..., \"index_eur_per_mwh\": "
|
||||
"..., \"expect_incl_vat\": ...} and the add-on will "
|
||||
"refuse to start unless your numbers reproduce it.",
|
||||
EXPORT_FORMULA_OPTION, xspec.passthrough,
|
||||
xspec.offset_eur_kwh)
|
||||
else:
|
||||
xaged = stale_card(xspec)
|
||||
if xaged:
|
||||
_LOG.warning("%s", xaged)
|
||||
|
||||
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
||||
# site with no price data falls back to the static profile; a dynamic site
|
||||
# with none configured has nothing to fall back to when its feed dies. That
|
||||
|
||||
+348
-13
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.9.0"
|
||||
version: "0.11.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -147,6 +147,74 @@ options:
|
||||
inverters: []
|
||||
estop_fitted: false
|
||||
|
||||
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
|
||||
# DECIDES ANYTHING. Optional, empty on every install that does not set it, and
|
||||
# when it is empty the add-on reads no entity, announces no entity, publishes
|
||||
# no topic and shows no status row.
|
||||
#
|
||||
# ⚠️ AN HA ENTITY AND NOT A REGISTER, BECAUSE MOST SOLAR INVERTERS ARE NOT ON
|
||||
# THIS BUS. The RS485 bus this add-on speaks carries the BATTERY inverter it
|
||||
# drives; a household's PV inverter is normally a different box from a
|
||||
# different vendor, reporting into Home Assistant through its own integration.
|
||||
# So this takes whatever `sensor.*` the site's PV integration provides, any
|
||||
# brand Home Assistant can read.
|
||||
#
|
||||
# ⚠️ POSITIVE IS PRODUCTION, and there is deliberately no `pv_invert` beside
|
||||
# `meter_invert` and `batt_invert`. Those two quantities are bipolar AND are
|
||||
# read by the control path, so a reversed sensor there is a plausible number
|
||||
# the site acts on; production is unipolar and is read by nothing, so a
|
||||
# reversed sensor announces itself as -4 kW at noon in the history this key
|
||||
# exists to create. See DECISIONS/TEL-11-pv-production.md.
|
||||
#
|
||||
# ⚠️ THE ENTITY'S OWN `unit_of_measurement` IS WHAT IS BELIEVED - W, kW, MW
|
||||
# and the rest of Home Assistant's power units. A sensor with no unit, or with
|
||||
# a unit that is not power, is REFUSED and the status page says which: there
|
||||
# is no magnitude heuristic that can tell 30 W of dusk from 30 kW of noon, and
|
||||
# a factor of 1000 in the one channel that exists to be believed a week later
|
||||
# is worse than a refusal an operator can read.
|
||||
pv_entity: ""
|
||||
|
||||
# TEL-12 - THE SAME CHANNEL WHEN THE ROOF HAS SEVERAL INVERTERS, WHICH IS THE
|
||||
# ORDINARY CASE AND NOT AN EDGE ONE. A multi-string or multi-orientation roof
|
||||
# routinely carries two or three PV inverters, each publishing its own power
|
||||
# sensor; 0.11.0 shipped `pv_entity` as a single string and the reference
|
||||
# site's owner reported within minutes that they could configure one of
|
||||
# theirs. List them all here and the row carries the SITE total.
|
||||
#
|
||||
# ⚠️ DECLARED, THIS REPLACES `pv_entity` - it does not extend it. Set both and
|
||||
# ONLY this list is read, and the status page says so. That is CFG-04's rule
|
||||
# for `inverters:` applied to the upgrade path this key exists for: the
|
||||
# install being upgraded HAS `pv_entity` set, and an operator who adds all
|
||||
# their inverters to this list while the old key still names one of them would
|
||||
# DOUBLE that inverter under a union rule. Clear `pv_entity` when you fill
|
||||
# this in.
|
||||
#
|
||||
# ⚠️ AND IF ANY ONE OF THEM IS NOT REPORTING, THERE IS NO SITE TOTAL - the row
|
||||
# publishes `unknown` and the recorder records a GAP. It does NOT publish the
|
||||
# sum of the ones that answered: two of three inverters add up to a smaller
|
||||
# number that is indistinguishable from a real one, and a week later that
|
||||
# reads as "there was less sun than there was" - the exact conclusion this
|
||||
# channel exists to TEST rather than to manufacture. The status page names
|
||||
# which entity is missing and why, and lists what the others reported, because
|
||||
# a human reading it live wants the diagnostic; the recorded number does not,
|
||||
# because nobody re-derives a week of history. See
|
||||
# DECISIONS/TEL-12-several-pv-inverters.md.
|
||||
#
|
||||
# ⚠️ THE FIX IS NOT A TEMPLATE SENSOR THAT SUMS THEM. You may legitimately
|
||||
# prefer one, but a template sensor summing an `unavailable` source yields the
|
||||
# partial total silently - the same defect one layer down, in a place this
|
||||
# add-on cannot see or report.
|
||||
#
|
||||
# pv_entities:
|
||||
# - sensor.pv_east_power
|
||||
# - sensor.pv_west_power
|
||||
# - sensor.pv_garage_power
|
||||
#
|
||||
# Each entity's OWN `unit_of_measurement` is read, so a kW inverter beside a W
|
||||
# one sums correctly. Empty is the shipped default and means "use `pv_entity`,
|
||||
# which is also empty" - i.e. no PV channel at all, which is every install.
|
||||
pv_entities: []
|
||||
|
||||
# ==========================================================================
|
||||
# METER - where the grid reading comes from
|
||||
# ==========================================================================
|
||||
@@ -265,9 +333,17 @@ options:
|
||||
# TARIFF - what energy costs, and whether to trade on it
|
||||
# ==========================================================================
|
||||
# `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.
|
||||
# static profile, plan provisional, NO ARBITRAGE.
|
||||
#
|
||||
# ⚠️ SELECTING A PROVIDER IS WHAT STARTS THE FETCH, AND IT IS NOT
|
||||
# `arbitrage_enabled` (TARIFF-10). With a provider set and trading off - the
|
||||
# shipped default - the add-on fetches the feed every 30 minutes, validates
|
||||
# it, records the day in /data/price_history.json and reports what it holds
|
||||
# on the *Price history* status row. It changes NO energy decision: the cheap
|
||||
# window the maintenance cycle reads still comes from `static_profile`, and
|
||||
# §10.1's arbitrage is gated on `arbitrage_enabled` alone. Until TARIFF-10
|
||||
# the fetch was gated on `arbitrage_enabled` too, so this option did nothing
|
||||
# whatsoever on a shipped install - the store was never even created.
|
||||
#
|
||||
# ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's
|
||||
# PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two
|
||||
@@ -276,18 +352,65 @@ options:
|
||||
#
|
||||
# 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.
|
||||
# does not already contain. manual_csv carries an all-in figure, so setting
|
||||
# either under it is ignored - and logged as ignored, never applied twice.
|
||||
# `custom_formula` (TARIFF-15) carries energy AND markup - your contract's
|
||||
# formula IS the markup - so it takes price_taxes_eur_kwh only, and your
|
||||
# price_markup_eur_kwh is ignored under it, with a line in the log saying so.
|
||||
price_provider: "off"
|
||||
price_markup_eur_kwh: 0.03
|
||||
price_taxes_eur_kwh: 0.06
|
||||
|
||||
# TARIFF-18. The all-in Flemish non-energy figure, EUR/kWh, incl. 6 % VAT.
|
||||
# Its components are named in app/price.py's FLEMISH_TAXES_COMPONENTS_EUR_KWH
|
||||
# and test_price.py section 1b re-adds them:
|
||||
#
|
||||
# Fluvius distribution (Antwerpen) 5,35 c EUR/kWh
|
||||
# Bijzondere accijns, "Alle verbruik" 4,876
|
||||
# Bijdrage groene stroom en WKK Vlaanderen 1,51
|
||||
# Energiebijdrage (0 EUR/MWh since 2026-08-01) 0
|
||||
# ------
|
||||
# 11,736 c EUR/kWh = 0.11736
|
||||
#
|
||||
# ⚠️ THIS IS PER-DSO AND YOURS IS PROBABLY DIFFERENT. Only the distribution
|
||||
# row moves: across the eight Fluvius DSOs it runs 5,28 c EUR/kWh
|
||||
# (Midden-Vlaanderen) to 6,70 (West), so the Flemish total runs about 0.117
|
||||
# to 0.131. Antwerpen is near the LOW end, which is the direction this
|
||||
# default should err in - it is inert on which hour is cheapest, but §17's
|
||||
# negative-price guard reads the ABSOLUTE all-in price, and a figure set too
|
||||
# high would carry a genuinely negative hour above zero.
|
||||
#
|
||||
# ⚠️ WALLONIA AND BRUSSELS DO NOT BELONG IN THIS SCALAR AT ALL. Their
|
||||
# distribution tariff has a day and a night band; use price_taxes_profile.
|
||||
price_taxes_eur_kwh: 0.11736
|
||||
|
||||
# §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.
|
||||
# which by itself strongly favours self-consumption over export. This is the
|
||||
# FLAT injection price, for a site paid the same for every exported kWh.
|
||||
#
|
||||
# ⚠️ THE REASON THIS COMMENT USED TO GIVE - "no shipped supplier publishes a
|
||||
# dynamic injection curve" - WAS FALSE, and TARIFF-07 struck the same
|
||||
# sentence from app/price.py and DOCS.md without reaching this file. Eneco
|
||||
# publishes `0,1 x BELPEX-H - 1,188` c EUR/kWh on the same card as its
|
||||
# consumption formula, ENGIE publishes `(B x eSpot_15) - A`, and Aspiravi
|
||||
# `0,07 x Belpex - 2`. A site on one of those sets price_export_formula
|
||||
# below; this scalar stays the fixed-product default and is what every site
|
||||
# uses today.
|
||||
export_price_eur_kwh: 0.04
|
||||
|
||||
# TARIFF-16. The INJECTION leg as a formula, in price_formula's own grammar
|
||||
# and with its OWN coefficients: Eneco uses 0,102 on consumption and 0,100 on
|
||||
# injection, and injection is VAT-EXEMPT where consumption is at 6%, so
|
||||
# `vat_pct` here is normally 0. A negative `offset` is expected - it is what
|
||||
# the published lines carry - and the resulting price legitimately goes below
|
||||
# zero in the midday hours a PV household exports in.
|
||||
#
|
||||
# ⚠️ NO STRATEGY IN THIS ADD-ON READS IT. arbitrage.py prices the sell side
|
||||
# as displaced IMPORT and compares two all-in CONSUMPTION prices; it does not
|
||||
# read the injection price at all. This option is what the operator is shown
|
||||
# and the shape a future export-aware strategy would rest on. Empty by
|
||||
# default, which leaves every existing site on the scalar above.
|
||||
price_export_formula: ""
|
||||
|
||||
# §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".
|
||||
@@ -317,7 +440,100 @@ options:
|
||||
# to fall back to and a dead feed leaves the site with no tariff at all.
|
||||
# validate.py warns about exactly that.
|
||||
static_profile: ""
|
||||
|
||||
# TARIFF-17. THE TAXES COMPONENT AS A DAY/NIGHT TABLE, for the sites whose
|
||||
# network tariff actually has one. "" means the flat price_taxes_eur_kwh
|
||||
# above decides, which is what every install has had until now and is CORRECT
|
||||
# IN FLANDERS: Fluvius distribution is a single rate, and its row on the
|
||||
# tariff card offers only `Normaal | Uitsl. nacht` - exclusive night being a
|
||||
# separate physical circuit, not the night register of a dual-rate meter.
|
||||
#
|
||||
# The Walloon and Brussels DSOs are on the same card with FOUR columns -
|
||||
# `Enkelvoudig | Dag | Nacht | Uitsl. nacht` - and the gap is real money
|
||||
# (c EUR/kWh): ORES dag 13,27 / nacht 7,39; AIESH 15,17 / 8,21; Tecteo RESA
|
||||
# 12,20 / 7,01; Sibelga 9,39 / 7,10; ORES IMPACT 2026 16,578 / 5,090.
|
||||
#
|
||||
# ⚠️ WHY THIS IS NOT COSMETIC. The optimiser reads the SPREAD (max - min) and
|
||||
# which hour is cheapest. A flat number is additive and identical every hour,
|
||||
# so it cancels out of both - it cannot distort anything, and it also cannot
|
||||
# express this. These bands are ANTI-CORRELATED with what the battery wants
|
||||
# to do (cheap network hours are cheap spot hours), so flattening them
|
||||
# UNDERSTATES the true spread and declines cycles that would have paid. See
|
||||
# DOCS.md, "The three buckets".
|
||||
#
|
||||
# Same JSON grammar as static_profile above - the same tiling rule, the same
|
||||
# day selectors, the same DST handling, because it is literally the same
|
||||
# parser. ⚠️ THE VALUE IS THE TAXES COMPONENT ALONE, NOT AN ALL-IN PRICE:
|
||||
#
|
||||
# {"periods": [
|
||||
# {"name":"dag", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.1327},
|
||||
# {"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.0739},
|
||||
# {"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.0739}]}
|
||||
#
|
||||
# ⚠️ SET THIS *OR* price_taxes_eur_kwh, NOT BOTH. Two writers of one quantity,
|
||||
# one of which silently wins, is refused at startup: leave the scalar at its
|
||||
# shipped default when you fill this in. A scalar still sitting on the
|
||||
# SUPERSEDED 0.06 is accepted too (TARIFF-18 moved that default, and your
|
||||
# stored options carry the old number whether you typed it or not) - the
|
||||
# refusal is about a figure you chose, not about one this add-on wrote.
|
||||
price_taxes_profile: ""
|
||||
|
||||
# TARIFF-15. YOUR OWN TARIFF CARD, as two fixed coefficients. Used only when
|
||||
# `price_provider: custom_formula`; "" is unset.
|
||||
#
|
||||
# A Belgian dynamic contract prices energy as `B x index + A`, and the two
|
||||
# coefficients exist in exactly ONE place - the tariff card in your
|
||||
# klantenzone. There is no machine-readable source for them anywhere (CREG's
|
||||
# tariff-card pages 404, CREG Scan is a JS wizard, VREG was unreachable), so
|
||||
# no plugin and no scraper can ever know YOUR numbers. You are the only party
|
||||
# who can see them.
|
||||
#
|
||||
# A JSON object, on one line. You type what your card prints, IN THE CARD'S
|
||||
# OWN UNITS; every unit conversion and the VAT multiplication are the
|
||||
# add-on's job, because those are exactly the two steps that silently cost a
|
||||
# factor of 10, 100 or 1.06.
|
||||
#
|
||||
# {"index":"epex_be","resolution":"hourly","factor":0.102,"offset":1.00,
|
||||
# "units":"c_eur_per_kwh","vat_pct":6,
|
||||
# "card":{"month":"2026-09","index_eur_per_mwh":133.9905,
|
||||
# "expect_incl_vat":15.55}}
|
||||
#
|
||||
# price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100), in
|
||||
# `units`, converted to EUR/kWh by the add-on.
|
||||
#
|
||||
# ⚠️ THE `card` BLOCK IS REQUIRED AND IT IS THE SAFETY MECHANISM. Your card
|
||||
# prints the formula, the reference index it assumed AND the resulting price.
|
||||
# At startup the add-on recomputes that printed price from the numbers you
|
||||
# typed and REFUSES TO START unless the two agree to within 0.01 - naming the
|
||||
# discrepancy. That catches VAT left out or applied twice, a transposed
|
||||
# digit, a unit MISMATCH between the coefficients and the printed price
|
||||
# (10x/100x/1000x), and the expensive one: NETWORK CHARGES FOLDED INTO
|
||||
# `offset`. A unit error applied to BOTH sides at once cancels out of that
|
||||
# comparison; the add-on refuses that separately, by checking the card's own
|
||||
# point against the band a Belgian retail tariff can occupy. Your card's formula line is the ENERGY component
|
||||
# alone; the add-on adds price_taxes_eur_kwh on top, so folding ~12 c/kWh of
|
||||
# network charge into `offset` would bill you for it twice - and it cannot
|
||||
# pass, because it moves the computed 15.55 to about 27.
|
||||
#
|
||||
# ⚠️ AND THE LIMIT OF THAT CHECK, IN THE SAME BREATH: it grades your
|
||||
# COEFFICIENTS AGAINST YOUR CARD, not your card against reality. Two numbers
|
||||
# copied off the wrong product agree with each other perfectly and pass.
|
||||
#
|
||||
# ⚠️ FIXED COEFFICIENTS ONLY - the add-on evaluates NO expression of any kind
|
||||
# and there is no field that takes one. An unknown key is refused rather than
|
||||
# ignored. See DOCS.md for why (it is not only security: two coefficients can
|
||||
# be graded against your card and an arbitrary expression cannot be graded
|
||||
# against anything).
|
||||
price_formula: ""
|
||||
price_csv_path: "/share/goodwe_prices.csv"
|
||||
# ⚠️ AN OVERRIDE IS THE EXACT URL (TARIFF-09). The plugin sends it verbatim
|
||||
# and appends nothing - including the `start`/`end` date range it derives from
|
||||
# the horizon for its own feed - so an override gets whatever range that URL
|
||||
# answers, which for a bare day-ahead endpoint is ONE Brussels day. Every hour
|
||||
# past it is then carried from yesterday and flagged provisional, and §8.3
|
||||
# keeps provisional hours out of the trade set. The plugin logs a WARNING
|
||||
# naming that consequence on every fetch that uses an override; clearing this
|
||||
# option is what restores the full horizon.
|
||||
price_feed_url: ""
|
||||
price_horizon_h: 48
|
||||
price_timeout_s: 10
|
||||
@@ -344,6 +560,12 @@ options:
|
||||
# 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 IS NOT WHAT TURNS THE PRICE FEED ON (TARIFF-10). `price_provider`
|
||||
# above decides whether prices are FETCHED; this decides whether they move
|
||||
# energy. Leaving this off on a site with a provider configured is a
|
||||
# supported, documented state: prices are fetched, validated and recorded,
|
||||
# and nothing trades.
|
||||
#
|
||||
# ⚠️ 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
|
||||
@@ -592,6 +814,38 @@ options:
|
||||
# and not 150 or 200. 37 control cycles.
|
||||
delivery_hold_s: 185
|
||||
|
||||
# SAFETY-10. Run the whole decision path and command NOTHING - strategies,
|
||||
# arbiter, clamps and guards all run, the decision and its reason are
|
||||
# recorded, and every inverter is held at 0 W. `app/shadow.py` carries the
|
||||
# argument; the three things to know before changing this line are:
|
||||
#
|
||||
# auto shadow WHILE CONTROL IS STOPPED, live once it is started. This is
|
||||
# the shipped value and it is what "default-on" means here.
|
||||
# on always shadow, whatever the Start / stop button says. This is the
|
||||
# one an operator picks to watch a day of decisions on an armed site
|
||||
# before letting it trade.
|
||||
# off ordinary control.
|
||||
#
|
||||
# ⚠️ IT IS NOT A BOOLEAN DEFAULTING TO `true`, AND THAT IS THE WHOLE CARD.
|
||||
# The Supervisor composes an add-on's options as `{**these defaults, **the
|
||||
# stored user options}`, so a NEW key with a new default reaches every
|
||||
# EXISTING install on the next update - including one regulating a real
|
||||
# battery. `shadow_mode: true` would silently stop it, and the only symptom
|
||||
# would be a battery that has stopped doing anything. There is no mechanism
|
||||
# in this options system that lets one key default differently for a new
|
||||
# install and an existing one, so "default-on for new" has to be a value the
|
||||
# add-on RESOLVES. `auto` is that value: a fresh install ships
|
||||
# `auto_start: false` and therefore comes up in shadow mode; an install that
|
||||
# is controlling is started, so `auto` resolves to live and NOTHING about it
|
||||
# changes.
|
||||
#
|
||||
# ⚠️ AND UNDER `auto` THE START BUTTON LEAVES SHADOW MODE. Pressing *Start /
|
||||
# stop control* is the operator saying "control the battery". Somebody who
|
||||
# wants the decisions recorded WHILE the loop runs sets `on` explicitly -
|
||||
# which is the configuration this option exists for, and the one that cannot
|
||||
# be reached by accident.
|
||||
shadow_mode: "auto"
|
||||
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
@@ -686,6 +940,37 @@ schema:
|
||||
capacity_kwh: float(0.1,200)?
|
||||
|
||||
estop_fitted: bool
|
||||
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
|
||||
# `price_now_entity`/`price_avg_entity`, which are legal only while
|
||||
# `price_provider` is `off` and make the add-on REFUSE TO START otherwise.
|
||||
# Nothing in validate.py mentions this key: it cannot conflict with anything,
|
||||
# because nothing else reads it.
|
||||
pv_entity: str?
|
||||
|
||||
# TEL-12. ⚠️ A SECOND KEY RATHER THAN A LIST SHAPE ON `pv_entity`, AND THAT IS
|
||||
# A MEASUREMENT AND NOT A PREFERENCE. `pv_entity` shipped in 0.11.0 as `str?`
|
||||
# and the reference site has a string persisted in it. Declared as a list,
|
||||
# Supervisor 2026.08.0 answers that stored string with
|
||||
#
|
||||
# Invalid list for option 'pv_entity' in GoodWe RS485 Controller
|
||||
#
|
||||
# and `deepmerge`'s list strategy is `override`, so a shipped `[]` default does
|
||||
# NOT rescue it - the operator's string stands and is refused. That is CFG-04
|
||||
# §B's trap in the other direction: an add-on that will not start, and the
|
||||
# schema needed to fix it pinned to the version that cannot be installed. The
|
||||
# only single-key spelling Supervisor accepts for BOTH shapes is `match(.*)?`,
|
||||
# which validates nothing at all (it admits a number, a dict, any list of
|
||||
# anything) and renders as a plain text box. Measured, both of them, by
|
||||
# `tools/supervisor_shape_probe.py` §G - the transcript is in
|
||||
# DECISIONS/TEL-12-several-pv-inverters.md §1.
|
||||
#
|
||||
# ⚠️ AND `pv_entities: []` IN THE `options:` BLOCK IS LOAD-BEARING, exactly as
|
||||
# `inverters: []` is. §G measures the same isolated counterfactual: the
|
||||
# shipped defaults MINUS only this key are refused with `Missing option
|
||||
# 'pv_entities' in root`, which is every options blob written before 0.12.
|
||||
# `p1_phase_import_entities` is the shipped precedent for the pair.
|
||||
pv_entities:
|
||||
- str
|
||||
|
||||
# ==========================================================================
|
||||
# METER - where the grid reading comes from
|
||||
@@ -736,9 +1021,32 @@ schema:
|
||||
# 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)
|
||||
# Adding a plugin means adding it here and to PROVIDERS - and nowhere else,
|
||||
# which is AC2.
|
||||
#
|
||||
# ⚠️ THREE OF THESE FIVE IDS NO LONGER RESOLVE TO A PLUGIN, AND LEAVING THEM
|
||||
# HERE IS A DECISION (TARIFF-07). engie_dynamic, totalenergies_dynamic and
|
||||
# eneco_eddy were withdrawn - their endpoints do not exist and never did - and
|
||||
# the obvious edit is to delete them from this line too. DO NOT. The Supervisor
|
||||
# validates the options file against this schema BEFORE the add-on starts, so
|
||||
# an id that is not listed here makes an EXISTING config schema-invalid: the
|
||||
# operator gets an opaque "expected one of ..." from the Supervisor, the add-on
|
||||
# never runs, and validate.py's retired-id refusal - the message that tells
|
||||
# them their prices never worked and what to do instead - IS NEVER REACHED, on
|
||||
# exactly the sites that need it. Schema tightness would cost the only thing
|
||||
# this withdrawal is FOR.
|
||||
#
|
||||
# The cost of keeping them is that the dropdown still offers three dead ids to
|
||||
# a NEW operator. That is the cheaper failure: choosing one is refused at the
|
||||
# next startup by name, with the endpoint, the observation and the alternative
|
||||
# - which is strictly more than a typo-shaped schema rejection would say. Both
|
||||
# translations label them withdrawn in the option description as well.
|
||||
#
|
||||
# app/price.py's RETIRED_PROVIDERS is the list, test_price.py section 1 asserts
|
||||
# this line offers exactly PROVIDERS + RETIRED_PROVIDERS + 'off' - derived, not
|
||||
# typed - and register() refuses to re-register a retired id, so the two
|
||||
# cannot both claim one.
|
||||
price_provider: list(off|generic_dayahead|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
price_markup_eur_kwh: float(0,1)
|
||||
price_taxes_eur_kwh: float(0,1)
|
||||
|
||||
@@ -747,11 +1055,29 @@ schema:
|
||||
# bottoms out at 0 would make that site inexpressible.
|
||||
export_price_eur_kwh: float(-1,1)
|
||||
|
||||
# TARIFF-16. A JSON string in `price_formula`'s grammar, parsed by
|
||||
# `custom_formula.parse_export_formula()` - the SAME parser with its option
|
||||
# name as an argument, so a second unit table and a second card rule do not
|
||||
# exist to diverge. The `card` block is OPTIONAL on this leg and required on
|
||||
# the other: Eneco prints its injection formula with no price beside it, so
|
||||
# on that card there is nothing to grade against, and the add-on says at
|
||||
# startup that the leg is ungraded rather than inventing an estimate.
|
||||
price_export_formula: str?
|
||||
|
||||
# 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?
|
||||
# TARIFF-17. A JSON string for `static_profile`'s reason, and parsed by
|
||||
# `static_tariff.parse_taxes_profile()` - the SAME period grammar, so a
|
||||
# second tiling rule and a second set of DST bugs do not exist to diverge.
|
||||
price_taxes_profile: str?
|
||||
# TARIFF-15. A JSON string for the same reason `static_profile` is one: it is
|
||||
# ONE option key, it survives config.yaml's flat options block, and
|
||||
# parse_price_formula() accepts an already-parsed dict too, so a future
|
||||
# nested schema is a config.yaml change and no code change at all.
|
||||
price_formula: str?
|
||||
price_csv_path: str?
|
||||
price_feed_url: str?
|
||||
price_horizon_h: int(1,72)
|
||||
@@ -870,6 +1196,15 @@ schema:
|
||||
# running short of one unit's share must be reported well inside one.
|
||||
delivery_hold_s: int(30,900)
|
||||
|
||||
# SAFETY-10. ⚠️ A THREE-VALUED LIST AND NOT A `bool`, for the reason written
|
||||
# against the default in the options block above: a boolean has one default
|
||||
# and the Supervisor gives it to every install, new and existing alike.
|
||||
# `app/shadow.MODES` is this tuple and `validate.py` rule 11 refuses anything
|
||||
# else BY NAME, because `shadow.mode()` resolves an unreadable value to
|
||||
# SHADOW - safe, but a silent reinterpretation that commands nothing for as
|
||||
# long as nobody notices.
|
||||
shadow_mode: list(auto|on|off)
|
||||
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1757455200,1757458800,1757462400,1757466000,1757469600,1757473200,1757476800,1757480400,1757484000,1757487600,1757491200,1757494800,1757498400,1757502000,1757505600,1757509200,1757512800,1757516400,1757520000,1757523600,1757527200,1757530800,1757534400,1757538000],"price":[73.0,61.31,64.32,62.49,67.0,64.48,94.52,149.26,132.43,91.61,84.8,79.61,69.23,66.75,68.91,69.31,80.29,66.91,105.2,161.05,116.73,84.2,64.1,42.41],"unit":"EUR / MWh","deprecated":false}
|
||||
@@ -0,0 +1 @@
|
||||
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1788904800,1788905700,1788906600,1788907500,1788908400,1788909300,1788910200,1788911100,1788912000,1788912900,1788913800,1788914700,1788915600,1788916500,1788917400,1788918300,1788919200,1788920100,1788921000,1788921900,1788922800,1788923700,1788924600,1788925500,1788926400,1788927300,1788928200,1788929100,1788930000,1788930900,1788931800,1788932700,1788933600,1788934500,1788935400,1788936300,1788937200,1788938100,1788939000,1788939900,1788940800,1788941700,1788942600,1788943500,1788944400,1788945300,1788946200,1788947100,1788948000,1788948900,1788949800,1788950700,1788951600,1788952500,1788953400,1788954300,1788955200,1788956100,1788957000,1788957900,1788958800,1788959700,1788960600,1788961500,1788962400,1788963300,1788964200,1788965100,1788966000,1788966900,1788967800,1788968700,1788969600,1788970500,1788971400,1788972300,1788973200,1788974100,1788975000,1788975900,1788976800,1788977700,1788978600,1788979500,1788980400,1788981300,1788982200,1788983100,1788984000,1788984900,1788985800,1788986700,1788987600,1788988500,1788989400,1788990300,1788991200,1788992100,1788993000,1788993900,1788994800,1788995700,1788996600,1788997500,1788998400,1788999300,1789000200,1789001100,1789002000,1789002900,1789003800,1789004700,1789005600,1789006500,1789007400,1789008300,1789009200,1789010100,1789011000,1789011900,1789012800,1789013700,1789014600,1789015500,1789016400,1789017300,1789018200,1789019100,1789020000,1789020900,1789021800,1789022700,1789023600,1789024500,1789025400,1789026300,1789027200,1789028100,1789029000,1789029900,1789030800,1789031700,1789032600,1789033500,1789034400,1789035300,1789036200,1789037100,1789038000,1789038900,1789039800,1789040700,1789041600,1789042500,1789043400,1789044300,1789045200,1789046100,1789047000,1789047900,1789048800,1789049700,1789050600,1789051500,1789052400,1789053300,1789054200,1789055100,1789056000,1789056900,1789057800,1789058700,1789059600,1789060500,1789061400,1789062300,1789063200,1789064100,1789065000,1789065900,1789066800,1789067700,1789068600,1789069500,1789070400,1789071300,1789072200,1789073100,1789074000,1789074900,1789075800,1789076700],"price":[150.93,151.41,150.99,147.59,153.25,147.26,144.04,141.53,147.79,146.53,143.55,139.93,145.58,139.56,139.11,138.06,138.92,138.52,138.04,138.65,137.29,139.03,144.59,150.34,154.12,174.5,178.43,186.62,199.35,200.21,200.11,197.43,220.07,202.0,196.01,174.03,198.32,167.49,152.54,142.05,161.5,145.45,128.61,100.85,139.18,115.83,94.41,80.39,111.25,89.61,84.99,74.38,80.53,70.58,62.27,57.1,62.24,64.95,68.99,71.19,66.9,87.32,109.0,123.12,68.48,125.53,155.99,161.9,138.77,160.57,167.35,173.0,142.2,172.83,196.19,211.26,205.85,218.77,217.08,222.0,210.98,208.46,208.88,210.02,209.77,202.61,200.27,191.49,194.39,195.04,197.26,190.92,188.39,198.05,203.05,191.84,216.08,216.08,214.92,200.0,223.37,201.43,196.32,190.0,199.53,194.83,180.53,172.23,179.88,175.77,167.1,173.89,163.29,165.99,171.41,178.73,170.27,177.37,187.71,201.07,193.62,207.61,218.27,226.77,227.17,234.05,236.99,229.94,248.93,227.5,216.08,195.67,215.99,195.01,177.1,155.09,174.91,152.71,139.59,123.83,140.77,123.19,114.96,94.5,102.77,96.63,94.57,116.34,114.63,126.82,129.89,123.95,105.0,119.11,121.0,136.79,123.41,135.01,135.0,156.68,146.88,152.16,162.02,181.78,154.76,184.28,194.3,215.76,197.23,234.15,253.03,296.58,345.32,389.16,482.99,559.0,551.11,446.41,358.97,295.9,271.39,268.06,241.43,238.01,239.7,226.41,223.05,211.56,218.21,210.61,197.78,171.79],"unit":"EUR / MWh","deprecated":false}
|
||||
@@ -0,0 +1 @@
|
||||
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1788991200,1788992100,1788993000,1788993900,1788994800,1788995700,1788996600,1788997500,1788998400,1788999300,1789000200,1789001100,1789002000,1789002900,1789003800,1789004700,1789005600,1789006500,1789007400,1789008300,1789009200,1789010100,1789011000,1789011900,1789012800,1789013700,1789014600,1789015500,1789016400,1789017300,1789018200,1789019100,1789020000,1789020900,1789021800,1789022700,1789023600,1789024500,1789025400,1789026300,1789027200,1789028100,1789029000,1789029900,1789030800,1789031700,1789032600,1789033500,1789034400,1789035300,1789036200,1789037100,1789038000,1789038900,1789039800,1789040700,1789041600,1789042500,1789043400,1789044300,1789045200,1789046100,1789047000,1789047900,1789048800,1789049700,1789050600,1789051500,1789052400,1789053300,1789054200,1789055100,1789056000,1789056900,1789057800,1789058700,1789059600,1789060500,1789061400,1789062300,1789063200,1789064100,1789065000,1789065900,1789066800,1789067700,1789068600,1789069500,1789070400,1789071300,1789072200,1789073100,1789074000,1789074900,1789075800,1789076700],"price":[216.08,216.08,214.92,200.0,223.37,201.43,196.32,190.0,199.53,194.83,180.53,172.23,179.88,175.77,167.1,173.89,163.29,165.99,171.41,178.73,170.27,177.37,187.71,201.07,193.62,207.61,218.27,226.77,227.17,234.05,236.99,229.94,248.93,227.5,216.08,195.67,215.99,195.01,177.1,155.09,174.91,152.71,139.59,123.83,140.77,123.19,114.96,94.5,102.77,96.63,94.57,116.34,114.63,126.82,129.89,123.95,105.0,119.11,121.0,136.79,123.41,135.01,135.0,156.68,146.88,152.16,162.02,181.78,154.76,184.28,194.3,215.76,197.23,234.15,253.03,296.58,345.32,389.16,482.99,559.0,551.11,446.41,358.97,295.9,271.39,268.06,241.43,238.01,239.7,226.41,223.05,211.56,218.21,210.61,197.78,171.79],"unit":"EUR / MWh","deprecated":false}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"card": "TARIFF-08, extended by TARIFF-09",
|
||||
"what": "UNMODIFIED response bodies from the public EPEX BE day-ahead feed, captured so the SHIPPED parser can be replayed against what the source really publishes",
|
||||
"why": "every price fixture in the suite was built from a `{\"data\": [{\"start\": ..., \"price_eur_mwh\": ...}]}` document that this feed has never published. The suite therefore graded `_points_from_json` against its own assumption - a closed loop that could not notice the assumption was wrong - and §11.4 turned the resulting failure into a silent fall back to the static profile. A captured real body is what breaks that loop, so these files are bytes, not a reconstruction: nothing here is pretty-printed, re-keyed or trimmed. TARIFF-09 adds a body from a request that names a DATE RANGE, for the same reason one layer out: the shipped plugin took `since`/`until` and asked for the bare default URL, so the feed answered ONE Brussels day and every hour past today was carried forward and dropped from the trade set as provisional. A two-day body captured from the range request is what makes \"asking for the range gets the range\" checkable against something the plugin did not shape.",
|
||||
"source": "Energy-Charts API (Fraunhofer ISE), api.energy-charts.info",
|
||||
"upstream": "Bundesnetzagentur | SMARD.de",
|
||||
"licence": "CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)",
|
||||
"licence_note": "the BE bidding zone is in the API's CC BY 4.0 list, so redistribution is permitted with attribution. DATA-02 established this against the API's own OpenAPI document before committing `epex-be-dayahead-2025-09-01_2026-09-01.json`; both bodies below carry the same `license_info` string, recorded per file and asserted by the replay test.",
|
||||
"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.",
|
||||
"retrieved_utc": "2026-09-10T06:35:00+00:00 (TARIFF-08's two), 2026-09-10T07:45:49+00:00 (TARIFF-09's)",
|
||||
"retrieved_with": "curl, by hand; no code in `app/` fetches these",
|
||||
"shape": "COLUMNAR: parallel `unix_seconds` (UTC epoch seconds) and `price` arrays, plus `unit`, `license_info` and `deprecated`. There is no `data` key and no per-point object.",
|
||||
"replayed_by": "test_price.py sections 2b (TARIFF-08) and 2c (TARIFF-09)",
|
||||
"files": {
|
||||
"energy-charts-be-price-2026-09-10-quarterhourly.json": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE",
|
||||
"note": "the SHIPPED default URL of `generic_dayahead`, verbatim and with no parameters, so the replay drives the bytes the add-on's own request produces",
|
||||
"covers_local_day": "2026-09-10 (Europe/Brussels)",
|
||||
"granularity_s": 900,
|
||||
"intervals": 96,
|
||||
"bytes": 1877,
|
||||
"sha256": "176309d0020ad208320797385ef68b6a98ac248218539416cddf6579dd060487",
|
||||
"unit_as_returned": "EUR / MWh",
|
||||
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated_as_returned": false,
|
||||
"top_level_keys": [
|
||||
"deprecated",
|
||||
"license_info",
|
||||
"price",
|
||||
"unit",
|
||||
"unix_seconds"
|
||||
],
|
||||
"also_returned_verbatim_by": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE&start=2026-09-10&end=2026-09-11",
|
||||
"retrieved_utc": "2026-09-10T07:46:55+00:00",
|
||||
"note": "TARIFF-09's PRE-PUBLICATION case, and it needed no second file because the answer was BYTE-IDENTICAL to the bare-URL capture above - the same 1877 bytes and the same sha256. Asked for two days at 09:46 CEST, before the ~13:00-14:00 CET auction publishes 2026-09-11, the feed returned the ONE day that exists. That is the short answer this card requires to stay a `series` of what exists rather than becoming `no_data`, and the replay re-derives the identity from the bytes rather than restating it."
|
||||
}
|
||||
},
|
||||
"energy-charts-be-price-2025-09-10-hourly.json": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE&start=2025-09-10&end=2025-09-10",
|
||||
"note": "a day BEFORE Belgium's 15-minute MTU (2025-10-01), so the same endpoint answers HOURLY. Kept because the parser must not assume either spacing, and a fixture at only one granularity cannot grade that",
|
||||
"covers_local_day": "2025-09-10 (Europe/Brussels)",
|
||||
"granularity_s": 3600,
|
||||
"intervals": 24,
|
||||
"bytes": 572,
|
||||
"sha256": "03fe41690810df224611f4016899f856457f67a3c12c7654da17b1331ff2eff4",
|
||||
"unit_as_returned": "EUR / MWh",
|
||||
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated_as_returned": false,
|
||||
"top_level_keys": [
|
||||
"deprecated",
|
||||
"license_info",
|
||||
"price",
|
||||
"unit",
|
||||
"unix_seconds"
|
||||
]
|
||||
},
|
||||
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE&start=2026-09-09&end=2026-09-10",
|
||||
"retrieved_utc": "2026-09-10T07:45:49+00:00",
|
||||
"note": "TARIFF-09. A RANGE request, and the counterfactual the ticket is built on: `start`/`end` are INCLUSIVE Brussels calendar dates and this one answers 192 quarter-hours across TWO local days, where the bare `?bzn=BE` of the same endpoint answers 96 across one. The shipped `generic_dayahead` now derives these two dates from the `since`/`until` it is handed.",
|
||||
"covers_local_days": [
|
||||
"2026-09-09",
|
||||
"2026-09-10"
|
||||
],
|
||||
"granularity_s": 900,
|
||||
"intervals": 192,
|
||||
"bytes": 3579,
|
||||
"sha256": "4d83f5efb4294a4a682562eac619bf35e45c95edde957bfa94c402e1777cc9b3",
|
||||
"unit_as_returned": "EUR / MWh",
|
||||
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated_as_returned": false,
|
||||
"top_level_keys": [
|
||||
"deprecated",
|
||||
"license_info",
|
||||
"price",
|
||||
"unit",
|
||||
"unix_seconds"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user