Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d593429d3 | ||
|
|
b0b24e5d41 | ||
|
|
eb88646899 | ||
|
|
b9f7bd0506 | ||
|
|
43c144b8d9 | ||
|
|
6f5e8e378a | ||
|
|
8a5736136a | ||
|
|
5aba220046 | ||
|
|
16b1d6c422 | ||
|
|
ca8fd01819 | ||
|
|
d2ad080e45 | ||
|
|
074157c4c2 | ||
|
|
186a044a3a | ||
|
|
660d079bf3 | ||
|
|
a6af332048 | ||
|
|
03ef61fa3b | ||
|
|
b6c6da31d8 | ||
|
|
b548525d10 | ||
|
|
68c5ede7b9 | ||
|
|
cb3f686555 | ||
|
|
587495e143 | ||
|
|
e3ccd9f6ea | ||
|
|
e6da478f87 | ||
|
|
8fa4febfef | ||
|
|
8f82dd4e06 | ||
|
|
e1b9254626 | ||
|
|
478ba93710 |
+238
-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,85 @@
|
||||
# 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.
|
||||
#
|
||||
# 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 +323,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`.
|
||||
+787
-27
@@ -1,32 +1,700 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## 0.10.1
|
||||
|
||||
⚠️ **THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.**
|
||||
PKG-06's and PKG-07's rule: the Supervisor keys the built image by `version:`,
|
||||
so `ha apps update` is a silent no-op on an unbumped change. This branch
|
||||
deliberately does **not** pick the number, because the right one depends on a
|
||||
fact the branch cannot observe - whether 0.9.0's image has already been built
|
||||
and pulled. If it has not, this belongs inside 0.9.0's section before it ships;
|
||||
if it has, it needs 0.10.0. Whoever deploys decides.
|
||||
**A PATCH RELEASE, AND ONE TICKET IS THE WHOLE OF IT: [[SAFETY-57]].** The last
|
||||
place moves because this is a bug fix to a version that has shipped and is
|
||||
regulating a battery, not a change to what the add-on offers. **The option
|
||||
surface does not move at all** - no key added, none removed, no default changed,
|
||||
no enum widened, no rule added to `validate.py` - so ⚠️ **a stored options blob
|
||||
that starts the add-on on 0.10.0 starts it on 0.10.1**, and there is no
|
||||
`SystemExit(78)` to plan around on this hop. If you are on 0.10.0, the only
|
||||
question worth your time is whether you have been hit by what it fixes, and
|
||||
`### SAFETY-57` below is written to answer exactly that.
|
||||
|
||||
⚠️ **WHY THIS IS A NEW NUMBER RATHER THAN A FOLD INTO 0.10.0, WHICH IS THE
|
||||
REASON THE TICKET EXISTS.** The Supervisor decides that an update is available
|
||||
by comparing `config.yaml`'s version string with the one the box reports.
|
||||
0.10.0 is installed and running, so a fix delivered inside `## 0.10.0` would
|
||||
leave every box reporting `0.10.0` believing it was already current - the
|
||||
PKG-06/PKG-07 no-op, in the form where the tree it hides is a safety fix.
|
||||
0.10.0's own section argued it could still be folded into; ⚠️ **that argument
|
||||
was explicitly conditional on 0.10.0 never having been installed, and the
|
||||
condition has fired** - see the closing note at the end of `## 0.10.0` below.
|
||||
|
||||
⚠️ **IF YOU ARE CROSSING FROM A VERSION OLDER THAN 0.10.0, THIS SECTION IS
|
||||
NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.** `ha addons update`
|
||||
does not walk the versions; it installs 0.10.1 in one step, so every validator
|
||||
rule added in 0.8.0, 0.9.0 and 0.10.0 arrives at the same moment as this
|
||||
section's nothing. Two of them can refuse a config that nobody has edited:
|
||||
[[SAFETY-39]]'s **rule 10** against 0.7.0's own shipped default `slew_w: 1000`,
|
||||
and [[TARIFF-07]]'s three withdrawn `price_provider` ids - **`engie_dynamic`,
|
||||
`totalenergies_dynamic` and `eneco_eddy`** - each of which is still refused by
|
||||
name at startup on this version, with the same message. Both are written out in
|
||||
full under `## 0.10.0`, and `tools/predeploy_check.py` checks both against your
|
||||
live options before you press *Update*.
|
||||
|
||||
Contents of this release, in merge order since 0.10.0 (`cb3f686`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| _this branch_ | FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart |
|
||||
| _this branch_ | FCST-05 - one byte stops stopping the add-on, and the write path gets the read path's standard |
|
||||
| _this branch_ | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says |
|
||||
| _this branch_ | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser |
|
||||
| _this branch_ | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried |
|
||||
| _this branch_ | TARIFF-10 - configuring a price provider is what fetches prices; trading stays its own switch |
|
||||
| _this branch_ | TARIFF-12 - a supplier outage stops deleting the plan on its first failed fetch, and TARIFF_MAX_AGE_S starts governing |
|
||||
| _this branch_ | TARIFF-14 - the price a trade is decided against is the price of the interval being traded |
|
||||
| _this branch_ | TARIFF-07 - three plugins that never fetched a price are withdrawn, and a site configured on one is told so **⚠️ BACKWARD-INCOMPATIBLE** |
|
||||
| _this branch_ | TARIFF-15 - `custom_formula`: your own tariff card's two coefficients, graded against the card's own printed price |
|
||||
| _this branch_ | TARIFF-13 - the capacity-tariff row's "quarter(s) banked" stops resetting to 0 at every restart, and a tied peak's quarter stops moving |
|
||||
| _this branch_ | TARIFF-11 - the add-on's account of the tariff plan stops flattering it, and the status page is graded as a surface |
|
||||
| _this branch_ | TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest |
|
||||
| _this branch_ | TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real Flemish figure **⚠️ CHANGED DEFAULT** |
|
||||
| _this branch_ | TARIFF-16 - the injection leg stops being one number: an injection formula with its own coefficients, its own VAT and no floor under zero |
|
||||
| `b9f7bd0` | SAFETY-57 - the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested **⚠️ MEASURED ON THE LIVE SITE** |
|
||||
|
||||
⚠️ **AND THAT IS THE WHOLE TABLE, WHICH IS THE POINT OF A PATCH NUMBER.**
|
||||
[[TEL-10]] and [[SAFETY-48]] also merged after `cb3f686`, but [[PKG-15]] folded
|
||||
both into `## 0.10.0` **before 0.10.0 was installed**, so they shipped under
|
||||
that number and are recorded there rather than here. [[CI-9]] and [[PKG-15]]
|
||||
itself touch `.gitea/workflows/checks.yml` and this file only - nothing an
|
||||
operator can observe on an installed add-on - and are excluded for the reason
|
||||
`## 0.10.0` already gives. ⚠️ **Two defects that the reference install's own
|
||||
field history found are NOT in this release**: [[SAFETY-56]] (the stale-reading
|
||||
message says *"is not following its own command"* when the true cause is an
|
||||
empty pack, and the NOT READY row that goes with it) and [[SAFETY-58]] (a trip
|
||||
on the first read after a rest). Both are open, both are wording or bound
|
||||
changes rather than deadlocks, and neither was allowed to delay this one.
|
||||
|
||||
### SAFETY-57 — the stale-reading verdict gets an exit
|
||||
|
||||
⚠️ **WHETHER YOU WANT THIS UPDATE, AND HOW TO TELL.** On 0.10.0 the add-on
|
||||
can enter a state it cannot leave: the command is parked at **0 W**, the
|
||||
battery-power reading is gated to **MISSING**, and the only condition that
|
||||
would clear the gate is one that a parked command cannot produce. **It is a
|
||||
deadlock, not a slow recovery.** On the reference site on 2026-09-21 it held
|
||||
for about **thirteen minutes while the house exported 2.4 kW**, and it ended
|
||||
only because a human restarted the add-on.
|
||||
|
||||
**Recognising it costs one look at the status page and one at the log:**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the setpoint | sits at **exactly 0 W** and does not move, while the house is plainly importing or exporting |
|
||||
| the battery-power sensor | is **flat at one number** for the whole of it - on the live episode, −3 W for 850 s |
|
||||
| the action log | carries a `STALE batt` line and **no** clearing line after it |
|
||||
|
||||
**What ends it on 0.10.0 is a restart of the add-on**, and a restart is enough -
|
||||
nothing is latched to disk and the pack is not harmed. ⚠️ **But nothing ends it
|
||||
by itself**, and the replay below says the wait can run to hours. On 0.10.1 the
|
||||
state releases itself after a bounded **32 s** and the register is re-graded
|
||||
from scratch.
|
||||
|
||||
⚠️ **IT IS NOT AN EMERGENCY AND IT IS NOT COSMETIC.** The pack is not at
|
||||
risk in this state - 0 W is the safe command, and it is the command §11.1 rule 1
|
||||
asks for on a missing input. What is lost is the site's own control while it
|
||||
lasts: the battery neither charges nor discharges, so a solar export goes to the
|
||||
grid and an evening load comes off it.
|
||||
|
||||
⚠️ **WHAT IT FIXES, ON THE OWNER'S OWN BATTERY.** [[SAFETY-37]]'s freshness gate
|
||||
reports a battery-power register as MISSING when it stops following its own
|
||||
command, and a missing input is `command 0 W and keep commanding` (§11.1
|
||||
rule 1). The gate's only exit was *"an arrival carrying a different number"* —
|
||||
**and at 0 W nothing is asking the register to be anywhere.** The state had no
|
||||
exit reachable from inside it, and the log line said the opposite.
|
||||
|
||||
Measured off the RS-485 bus, 2026-09-21, and committed as
|
||||
`scenarios/safety57-2026-09-21/`:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 10:51:24 | 0x0518 reaches **−3 W**. The pack is at 11 %, the loop has just reversed out of a charge, and the BMS refuses the discharge. The register is **healthy and truthful**. |
|
||||
| 10:51:29 | the command reaches **+1060 W**, 800 W of travel |
|
||||
| 10:52:00 | the gate arms. `Inverter.batt` becomes `None` |
|
||||
| 10:52:21 | **the site commands 0 W — on the wire, in the capture** |
|
||||
| … | the register does not change value for **850 s**. The setpoint does not change value for **790 s**. The house exports 2.4 kW throughout |
|
||||
| ~11:04 | **a human restarts the add-on.** That is what ends it |
|
||||
|
||||
It had been escaping by luck - and ⚠️ **on this episode it did not escape at
|
||||
all.** `0x0518` answered the same −3 W on 90 consecutive responses and never
|
||||
once carried a different number between 10:51:24 and 11:05:35. The register did
|
||||
not move.
|
||||
|
||||
⚠️ **AND WHY IT USUALLY DOES MOVE IS NOT SOMETHING THIS REPOSITORY HAS
|
||||
MEASURED.** An earlier draft of this entry named a BMS keep-alive pulse as the
|
||||
thing that twitches the register, stated as fact; **no committed capture
|
||||
identifies such a pulse**, distinguishes one from the register simply following
|
||||
a command, or sizes it. The honest statement is that the register changes value
|
||||
for reasons that belong to the battery's firmware. What IS measured is how long
|
||||
it goes without changing: replaying the committed 16-day loop capture through
|
||||
the shipped conjunction finds **93 trips** whose wait has a **median of 143 s**
|
||||
and a worst case of **5 h 57 m**, with **16 of the 93** at or beyond this
|
||||
episode's **790 s**. That figure is stronger than the firmware story it
|
||||
replaces, and it needs no firmware story at all.
|
||||
|
||||
**The rule, and it is [[SAFETY-13]]'s shape one module over** — which latches
|
||||
get an exit, not how long the block lasts:
|
||||
|
||||
> the verdict is withdrawn when the obligation that produced it has been gone
|
||||
> for `RELEASE_S`, and the register is then re-graded from scratch.
|
||||
|
||||
`RELEASE_S` is **17.0 s**, the obligation window already derived in
|
||||
`freshness.py` — 6.0 s of plant, one 10 s ESPHome publish, one 1 s add-on read.
|
||||
It is the **floor** of its band rather than a preference: below it the release
|
||||
pre-empts the cheaper exit (the register answering by itself), and being wrong
|
||||
*long* is the failure being fixed. **The whole parked interval is now
|
||||
`stale_input_s` + `RELEASE_S` = 32 s**, bounded by two shipped numbers and by
|
||||
nothing the battery does. On the live episode: telemetry back at **10:52:39
|
||||
instead of 11:05:35**, with **0x0518 still answering the same −3 W**.
|
||||
|
||||
⚠️ **WHAT IT COSTS, AND IT IS NOT SMALL.** A register that is dead for good is
|
||||
re-graded for ever. Driven over 1201 reads at the shipped defaults: the held
|
||||
number reaches a consumer on **630 reads (52.5 %)** against 35 (2.9 %) before.
|
||||
That is payable only because **one re-grading window is 35 s and the two
|
||||
consumers hold for 75 s and 85 s** — neither can reach a verdict inside one,
|
||||
and the re-trip that ends each window hands them a `None`, which *clears* their
|
||||
windows. **No detection SAFETY-37 had is given back**: a permanent `None`
|
||||
clears those same windows for ever too. What the site gets back is its
|
||||
meter-derived control, and the command was never derived from `batt`.
|
||||
|
||||
⚠️ **THAT 35 s HOLDS WHILE THE OBLIGATION COMES BACK, AND ONLY THEN - IT IS NOT
|
||||
A UNIVERSAL BOUND, AND THE FIXTURE THAT GRADES IT CANNOT SHOW OTHERWISE.** A
|
||||
window closes when the gate re-arms, and the gate needs a command that is
|
||||
*moving* to re-arm against. If the release lands where the site then regulates
|
||||
below `MOVE_W`, nothing re-arms, the window never closes, and the held number
|
||||
reaches a consumer for an unbounded time - where before this card a `None` did.
|
||||
The driver behind the 35 < 75 < 85 inequality puts a 1300 W obligation back on
|
||||
every release, so it is the one shape that cannot produce that case. On the
|
||||
shipped roster of **one** inverter neither consumer can reach it anyway - the
|
||||
per-unit sign watchdog is compared against the **meter** at `n_units <= 1`, and
|
||||
`circulating_w()` is identically `0.0` there - so this is **a stated gap rather
|
||||
than a covered one**, and it needs a second inverter to bite.
|
||||
|
||||
⚠️ **WHAT YOU WILL SEE.** A new `STALE WITHDRAWN <unit>` line in the action log
|
||||
beside the existing `STALE <unit>` one, and a log warning that says in its own
|
||||
words that **nothing has been learned about the register**. If the pair repeats
|
||||
every ~70 s, the register really is not following and the unit needs looking at;
|
||||
if it happens once and stops, the pack was simply empty and the site has
|
||||
resumed. The stale line's escape clause was also corrected — it used to promise
|
||||
a clearance the state could not produce. ⚠️ Its **diagnosis** half still says
|
||||
*"is not following its own command"*, which is wrong in a different way when the
|
||||
pack is merely empty; that wording is [[SAFETY-56]]'s and is deliberately
|
||||
untouched here.
|
||||
|
||||
## 0.10.0
|
||||
|
||||
⚠️ **A CONFIG THAT IS LEGAL ON 0.9.0 IS REFUSED ON 0.10.0, AND THAT - NOT THE
|
||||
NUMBER OF TICKETS - IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH.** Same
|
||||
shape as 0.8.0's `slew_w` rule and the same test: is there a stored options
|
||||
blob the previous version started on and this one will not? There is. A refusal
|
||||
is `SystemExit(78)` (`EX_CONFIG`) - the add-on does not start, ⚠️ **and the
|
||||
ingress panel goes with it**, because the add-on serving it is not running, so
|
||||
recovery means the Supervisor UI.
|
||||
|
||||
Measured on the reference install's own exported blob (70 keys), merged over
|
||||
this version's 75 defaults the way the Supervisor merges it, inside the CI
|
||||
image. The three below are what a reader who checks only one thing should
|
||||
check.
|
||||
|
||||
* ⚠️ **[[TARIFF-07]] - THREE `price_provider` IDS ARE RETIRED, AND A SITE SET
|
||||
TO ONE OF THEM IS REFUSED BY NAME AT STARTUP.** `engie_dynamic`,
|
||||
`totalenergies_dynamic` and `eneco_eddy` are gone from `PROVIDERS`. Each was
|
||||
accepted, silently, at every startup on 0.9.0 and earlier; on 0.10.0 each is
|
||||
a refusal that names the id, the endpoint, what that endpoint actually
|
||||
answers - a 404 from a gateway with no route, a TLS handshake against a
|
||||
hostname the certificate does not cover, a 404 with an empty body - which
|
||||
tariff the site has really been running since the id was entered, and what to
|
||||
set instead. **The refusal IS the migration.** Every fetch any of the three
|
||||
ever made ended in `SourceUnavailable`, and §11.4 turns that into a SILENT
|
||||
fall back to the static profile, so there is no working behaviour to preserve
|
||||
and nothing on the status page ever differed from a provider that simply
|
||||
never found a cheap hour. ⚠️ **All three ids stay in `config.yaml`'s
|
||||
`price_provider` enum on purpose**: drop one and the Supervisor rejects the
|
||||
options file *before the add-on runs*, the operator gets an opaque `expected
|
||||
one of ...`, and `validate.py`'s message is never seen - on exactly the sites
|
||||
that need it. `PROVIDERS` is three; the enum is seven.
|
||||
⚠️ **The reference install is on `price_provider: off` and is unaffected.**
|
||||
|
||||
* **[[TARIFF-18]] - `price_taxes_eur_kwh` moves from `0.06` to `0.11736`, and
|
||||
the non-obvious half is the refusal that deliberately does NOT happen.** A
|
||||
site that *stored* `0.06` is unaffected in both directions. It keeps `0.06`,
|
||||
because `App.options` is `deepmerge(config.yaml defaults, stored options)`
|
||||
and a moved default reaches only keys the blob does not carry - so the new
|
||||
figure arrives at a **fresh** install, and at an existing one only when its
|
||||
options are re-saved. ⚠️ **AND IT DOES NOT BECOME A STARTUP REFUSAL EITHER,
|
||||
WHICH TOOK A MECHANISM.** [[TARIFF-17]] made a `price_taxes_profile` period
|
||||
table beside a **non-default** `price_taxes_eur_kwh` a refusal, comparing
|
||||
against the shipped default precisely because the Supervisor writes the key
|
||||
whether the operator typed it or not. Moving the default turns every stored
|
||||
`0.06` into "non-default" **with nobody editing anything**, so a working
|
||||
Walloon or Brussels site with a period table would have stopped starting on
|
||||
this upgrade, over an inert number the table overrides anyway.
|
||||
`price.py` carries `SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,)` and
|
||||
`validate.py` reads a superseded default as *untouched*. **The rung is not
|
||||
weakened**: the same table beside a figure that is neither the shipped nor a
|
||||
superseded default - ORES's `0.1198` - is still refused, by name, and the
|
||||
refusal now says which two figures it will accept.
|
||||
|
||||
* **[[SAFETY-10]] - a new `shadow_mode` option, shipped `auto`, and BOTH halves
|
||||
of what that does matter.** ⚠️ **AN EXISTING INSTALL THAT IS CONTROLLING DOES
|
||||
NOT CHANGE BY ONE WATT.** `auto` is not a constant the Supervisor merges; it
|
||||
is a value the add-on **resolves**, and it resolves to `not control_started`
|
||||
- `shadow.engaged()` reads `Controller.auto`, the persisted start/stop state.
|
||||
An install with `auto_start: true`, or whose operator has pressed *Start /
|
||||
stop control*, is started, so `auto` resolves to live. An install that is
|
||||
**stopped** keeps its actuator at 0 W exactly as before, from the same writer
|
||||
on the same heartbeat; what changes is what the status page says and that the
|
||||
decision path now runs and is recorded. ⚠️ **A FRESH INSTALL COMES UP
|
||||
OBSERVING**: it ships `auto_start: false`, has no stored `auto` key, and
|
||||
therefore runs its whole decision path in shadow before it is ever allowed to
|
||||
command anything. That is the "default-on" the card asked for, delivered
|
||||
where it is safe and withheld where it is not - there is no mechanism in the
|
||||
add-on options system by which one key can default differently for a new
|
||||
install and an existing one, so a literal `shadow_mode: true` default would
|
||||
have stopped the battery at the reference site on the next update.
|
||||
**Measured cross-revision** - the same stimulus through the shipped
|
||||
`Controller.cycle()` on `8f82dd4` (the merge before SAFETY-10) and on
|
||||
`e6da478` (SAFETY-10 itself), start button ON: the write streams are
|
||||
**byte-identical**, 13 / 13 / 25 writes on a 6000 W import, a 4000 W export
|
||||
and a 300 W trickle, same values in the same order. An install that is
|
||||
**stopped** is byte-identical across the same pair too, at one 0 W write.
|
||||
⚠️ The positive control is what makes that readable: the same driver with
|
||||
`shadow_mode: on` and the button ON gets 13 writes up to 5000 W on the older
|
||||
tree and **one 0 W write** on the newer, so the driver does reach shadow mode
|
||||
and an identical pair is a result rather than a silence. The shadow case is
|
||||
also graded in `test_clamp.py` part 8 on what reached `set_number()`, against
|
||||
a precondition run that puts 30 non-zero writes on the wire. ⚠️ An
|
||||
unrecognised `shadow_mode` fails toward inaction
|
||||
**and** is refused by name (rule 11), so the reinterpretation is never
|
||||
silent; no blob written before 0.10.0 can carry one.
|
||||
|
||||
⚠️ **AND 0.10.0 CHANGES THE CONTROL LAW ITSELF, WHICH NOTHING ABOVE AND NO
|
||||
CONFIG CHECK WILL TELL YOU ABOUT.** [[SAFETY-48]] bounds how far the
|
||||
integrator may run ahead of the command on the cycles where the slew limit is
|
||||
what stopped the command moving. It adds no option, moves no default and
|
||||
refuses no blob, so every check in this preamble passes it in silence - and it
|
||||
is the one change here that alters what the inverter is ASKED TO DO on a tree
|
||||
that is otherwise configured identically. ⚠️ **What it is measured to buy is
|
||||
less EXPORT. It is NOT a lower capacity-tariff peak, and your billed quarter
|
||||
should not be expected to improve.** The whole account, including what it costs
|
||||
and what it does not establish, is `### SAFETY-48` below.
|
||||
|
||||
⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL WAS
|
||||
WHEN THIS WAS WRITTEN, AND HAS NOT BEEN SINCE 2026-09-20 - THE REFUSAL THAT CAN
|
||||
STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha
|
||||
addons update` does not walk the versions; it installs 0.10.0 in one step, so
|
||||
**every validator rule added in 0.8.0 and 0.9.0 lands at the same moment as
|
||||
0.10.0's own**, and a config that has not been looked at since 0.7.0 meets all
|
||||
of them at once. Of those, exactly one can refuse a config nobody has edited:
|
||||
|
||||
> [[SAFETY-39]]'s **rule 10**, added in 0.8.0, refuses an explicit `slew_w`
|
||||
> above `SLEW_RAIL_FRAC x` the site rail, where `SLEW_RAIL_FRAC` is
|
||||
> `LOOP_TICK_S / PLANT_SETTLE_S` = 2/18. On a 5000 W rail that ceiling is
|
||||
> **555.6 W** - and ⚠️ **0.7.0's own shipped default was `slew_w: 1000`**, so
|
||||
> an install that never touched the key and simply took the default does not
|
||||
> start on 0.10.0 until the value is lowered.
|
||||
|
||||
Measured on this tree: the reference blob with `slew_w: 1000` is refused, by
|
||||
name, and the same blob at its actual `slew_w: 400` is accepted. 0.9.0's own
|
||||
additions cannot reach a pre-0.9.0 config at all - [[OPT-09]]'s two refusals
|
||||
fire only on a roster entry that USES `capacity_kwh`, a key that did not exist
|
||||
- so 0.8.0's is the whole of the crossing. **Read the live options and check
|
||||
`slew_w` against the ceiling and `price_provider` against the withdrawn list
|
||||
BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both.
|
||||
|
||||
✅ **RUN AGAINST THIS RELEASE: CLEARED, exit 0.** ⚠️ This is the record of
|
||||
a run made on 2026-09-19, when the reference box was still on 0.7.0. That
|
||||
crossing has since been made; the box is on 0.10.0 and the transcript below is
|
||||
history rather than the state of any install.
|
||||
|
||||
docker run --rm -v <repo>:/w:ro -v <dir>:/opt:ro goodwe-addon-checks:1 \
|
||||
python3 /w/goodwe_controller/tools/predeploy_check.py \
|
||||
--deployed-version 0.7.0 --options /opt/live-options.json
|
||||
|
||||
deployed on the box : 0.7.0
|
||||
shipped in this tree: 0.10.0
|
||||
live options pass this version's validator
|
||||
CLEARED for deploy.
|
||||
|
||||
⚠️ **THAT RUN WAS MADE ON PKG-14'S TREE, AND [[PKG-15]] DID NOT RE-RUN IT.
|
||||
HERE IS WHY IT STILL HOLDS, CHECKED RATHER THAN ASSUMED.** The two tickets
|
||||
folded in below touch three files between them - `app/control.py`,
|
||||
`app/main.py` and `app/mqtt.py` - and **`config.yaml` is not among them**, so
|
||||
the option surface and the 75 defaults the blob is merged over are byte-for-
|
||||
byte what that run measured. `app/validate.py` imports neither `main` nor
|
||||
`mqtt`; from `control` it imports exactly `SLEW_RAIL_FRAC`, `LOOP_TICK_S` and
|
||||
`PLANT_SETTLE_S`, and all three are **unchanged** (`2.0`, `18.0`, and their
|
||||
quotient) - [[SAFETY-48]] adds `SLEW_LEAD_STEPS` and reads it inside
|
||||
`compute()`, which the validator never calls. ⚠️ **And the tool's OTHER half -
|
||||
the no-op check - did not run at all in that transcript**, because it is
|
||||
gated on `shipped == deployed` and the box reported `0.7.0` at the time. It is
|
||||
the half that refuses this fold on a box already running 0.10.0 - ⚠️ **which
|
||||
the reference box now is**; see the closing note below the contents table.
|
||||
|
||||
**The option surface: 71 keys -> 75, `options:` and `schema:` in step.** Four
|
||||
new keys, and none of them can refuse a blob written before it existed:
|
||||
`price_formula`, `price_export_formula` and `price_taxes_profile` are `str?` -
|
||||
**note the trailing `?`, they are OPTIONAL** - defaulting to `""`;
|
||||
`shadow_mode` is `list(auto|on|off)` with no `?`, which is the shape that
|
||||
becomes exit 78, and it is safe for the same reason 0.9.0's `delivery_hold_s`
|
||||
was: it carries an `options:` default, so the Supervisor's merge supplies it to
|
||||
every stored blob that lacks it. Nothing was removed. One default moved
|
||||
(`price_taxes_eur_kwh`, above) and one enum widened (`price_provider` gains
|
||||
`custom_formula`).
|
||||
|
||||
⚠️ **WHY 0.10.0 AND NOT 0.9.0, SINCE 0.9.0'S IMAGE NEVER REACHED THE BOX.**
|
||||
The block this section closes used to say the choice depended on whether
|
||||
0.9.0 had been built and pulled, and that if it had not, this work belonged
|
||||
*inside* 0.9.0's section. That was written before 0.9.0's section was cut. It
|
||||
has been cut since `86bdf49`, on 2026-09-07, and **28 runtime commits** have
|
||||
landed behind it; folding them in would make the string `0.9.0` name two
|
||||
different trees, and the Supervisor keys the built image on exactly that
|
||||
string. Reusing it is the PKG-06/PKG-07 no-op in its worst form - a cached
|
||||
image the Supervisor is entitled to serve while reporting success. The version
|
||||
a tree has already shipped under is not available to be re-cut.
|
||||
|
||||
⚠️ **AND THIS SECTION IS NOT THE WHOLE OF WHAT 0.10.0 SHIPS. THREE TICKETS
|
||||
WITH RUNTIME CHANGES LANDED AFTER `86bdf49` AND ARE NOT IN THE TABLE BELOW.**
|
||||
Saying so is cheaper than moving them, and moving them is [[PKG-08]]'s card,
|
||||
not this one:
|
||||
|
||||
| ticket | runtime files | where it is written down |
|
||||
|---|---|---|
|
||||
| [[SAFETY-43]] | `app/delivery.py`, `app/packshare.py`, `config.yaml` | the `## Unreleased` block **below `## 0.9.0`**, which is misfiled |
|
||||
| [[TEST-21]] | `app/delivery.py`, `config.yaml`, `translations/` | the same block |
|
||||
| [[SAFETY-44]] | `app/main.py`, `app/store.py` | ⚠️ **nowhere in this file** |
|
||||
|
||||
Five more landed that touch no runtime file and so cannot change what the
|
||||
image does - PKG-13, UX-04, SAFETY-31, ARCH-02, INSTR-01. The count that
|
||||
matters for the no-op check is `tools/predeploy_check.py`'s own: **28 runtime
|
||||
commits since `86bdf49`**, of which the table below accounts for 21.
|
||||
|
||||
⚠️ **RE-MEASURED BY [[PKG-15]] ON 2026-09-19, AFTER FOLDING [[TEL-10]] AND
|
||||
[[SAFETY-48]] INTO THIS SECTION: 32 RUNTIME COMMITS SINCE `86bdf49`, OF WHICH
|
||||
THE TABLE BELOW NOW ACCOUNTS FOR 24.** Re-derived with `is_runtime()` itself
|
||||
rather than by adding to the old figure. The other eight are the three misfiled
|
||||
tickets in the table above - SAFETY-43 two commits, SAFETY-44 three, TEST-21
|
||||
two - plus `cb3f686`, PKG-14's own bump, which edits `config.yaml` and is
|
||||
therefore a runtime commit by that function's rule; it was `HEAD` when PKG-14
|
||||
counted, so `last..HEAD` excluded it then and includes it now. The three
|
||||
misfiled tickets are still misfiled and still [[PKG-08]]'s card; nothing below
|
||||
moves them.
|
||||
|
||||
⚠️ **THE VERSION WAS NOT BUMPED FOR THE FOLDS ABOVE, AND THAT ARGUMENT HAS
|
||||
NOW EXPIRED. THIS SECTION IS CLOSED.** As written on 2026-09-19 it ran: 0.10.0
|
||||
was cut on 2026-09-16, had never been built or pulled anywhere, the reference
|
||||
install was on 0.7.0 and this repository carried exactly one tag (`v0.2.1`) and
|
||||
no release - so the string `0.10.0` did not yet name a tree anyone was running,
|
||||
the PKG-06/PKG-07 no-op could not apply, and cutting 0.11.0 would have burned a
|
||||
number on a release that never left the repository. ⚠️ **IT WAS STATED AS
|
||||
CONDITIONAL ON THAT REMAINING TRUE, AND IT STOPPED BEING TRUE ON 2026-09-20 AT
|
||||
ABOUT 22:18 CEST**, when 0.10.0 was installed on the reference site as a
|
||||
repository add-on (slug `68285ac7_goodwe_controller`) and began regulating a
|
||||
real battery. **0.10.0 has been built and pulled, and the reference install is
|
||||
on 0.10.0, not on 0.7.0.**
|
||||
|
||||
⚠️ **AND THE FIELD HISTORY IS NOW THE STRONGEST EVIDENCE IN THIS FILE, WHICH
|
||||
IS WHY THE CORRECTION IS WORTH ITS SPACE.** That install has since run a full
|
||||
day-night cycle on 0.10.0 - an empty pack, a guard trip, a 2 h 47 min saturation
|
||||
freeze, the [[SAFETY-57]] deadlock and a clean recovery - and **two defects came
|
||||
out of it that no test in this repository had reached**: [[SAFETY-56]] and
|
||||
[[SAFETY-57]]. So nothing may be folded into this section again, and
|
||||
`tools/predeploy_check.py` enforces that by itself: run against a box already
|
||||
reporting `0.10.0` it refuses with `THE DEPLOY WOULD BE A NO-OP` and names the
|
||||
runtime commits since the bump. **SAFETY-57's fix is in `## 0.10.1` above**,
|
||||
which is what a patch number is for.
|
||||
|
||||
Contents of this release, in merge order since 0.9.0 (`86bdf49`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `c2787da` | FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart |
|
||||
| `5059e28` | FCST-05 - one byte stops stopping the add-on, and the write path gets the read path's standard |
|
||||
| `958f5cd` | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says |
|
||||
| `4257e38` | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser |
|
||||
| `88c59a9` | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried |
|
||||
| `559f0eb` | TARIFF-10 - configuring a price provider is what fetches prices; trading stays its own switch |
|
||||
| `a6e64fb` | TARIFF-12 - a supplier outage stops deleting the plan on its first failed fetch, and TARIFF_MAX_AGE_S starts governing |
|
||||
| `8d09b30` | TARIFF-14 - the price a trade is decided against is the price of the interval being traded |
|
||||
| `aade2c7` | TARIFF-07 - three plugins that never fetched a price are withdrawn, and a site configured on one is told so **⚠️ BACKWARD-INCOMPATIBLE** |
|
||||
| `068b83f` | TARIFF-15 - `custom_formula`: your own tariff card's two coefficients, graded against the card's own printed price |
|
||||
| `b4a3801` | TARIFF-13 - the capacity-tariff row's "quarter(s) banked" stops resetting to 0 at every restart, and a tied peak's quarter stops moving |
|
||||
| `c518007` | TARIFF-11 - the add-on's account of the tariff plan stops flattering it, and the status page is graded as a surface |
|
||||
| `7796f16` | TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest |
|
||||
| `4dcd9b0` | TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real Flemish figure **⚠️ CHANGED DEFAULT** |
|
||||
| `478ba93` | TARIFF-16 - the injection leg stops being one number: an injection formula with its own coefficients, its own VAT and no floor under zero |
|
||||
| `8f82dd4` | SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it **⚠️ CONTROL STABILITY** |
|
||||
| `e6da478` | SAFETY-10 - shadow mode: the whole decision path runs and nothing reaches the inverter **⚠️ NEW OPTION, DEFAULT-ON FOR A NEW INSTALL** |
|
||||
| `587495e` | SAFETY-47 - what a shadow run can show you, and the watts on its status row **⚠️ CORRECTS THREE CLAIMS SAFETY-10 SHIPPED** |
|
||||
| `660d079` | TEL-10 - the loop's integrator stops being invisible: three new site sensors **⚠️ THREE NEW ENTITIES** |
|
||||
| `186a044` | SAFETY-48 - the integrator's lead over the command is bounded at one slew step **⚠️ THE CONTROL LAW CHANGED** |
|
||||
| _this branch_ | SAFETY-57 - the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested **⚠️ MEASURED ON THE LIVE SITE** |
|
||||
|
||||
⚠️ **[[CI-9]] ALSO MERGED INTO `release/1.0` IN THIS WINDOW AND IS
|
||||
DELIBERATELY NOT IN THE TABLE ABOVE, NOR ANYWHERE ELSE IN THIS FILE.** It
|
||||
changes exactly one file, `.gitea/workflows/checks.yml`, which is not under
|
||||
`goodwe_controller/` at all: it is not in the built image, it is not in
|
||||
`predeploy_check.py`'s `RUNTIME` list, and there is no version of this add-on
|
||||
on which an operator can observe it. This file is the **add-on's** release note
|
||||
- the text the Supervisor puts beside the version number - and the rule PKG-15
|
||||
applied is that a row here has to be something the person deciding whether to
|
||||
press *Update* could act on. CI-9 is not; the repository's history and PR #95
|
||||
are where it is written down, and they have it in full. ⚠️ **THE RULE IS ABOUT
|
||||
THE AUDIENCE, NOT ABOUT IMPORTANCE.** CI-9 repaired a merge gate that was
|
||||
grading the wrong tree, which is the same class of defect this ticket exists to
|
||||
clean up after - and it is still not an operator-facing change. If a future CI
|
||||
ticket changes what the shipped image contains, it belongs here; this one does
|
||||
not.
|
||||
|
||||
### SAFETY-48 — the integrator's lead over the command is bounded, and the control law changed
|
||||
|
||||
⚠️ **THIS CHANGES WHAT THE INVERTER IS ASKED TO DO.** It is the only entry in
|
||||
0.10.0 that does so without touching an option, a default or the validator, so
|
||||
nothing in this section's preamble and no run of `tools/predeploy_check.py`
|
||||
will warn you about it. `app/control.py` is in the diff; `config.yaml` is not.
|
||||
|
||||
**The defect is a missing case, not a wrong constant.** `compute()` freezes the
|
||||
integrator when the inverter is not following, and the comment beside that
|
||||
block states the requirement outright — *the INTEGRATOR must stop accumulating,
|
||||
not only the command*. But `frozen` was computed from one signal,
|
||||
`abs(prev_w - actual_w) > saturation_w`, and **slew-limiting never entered it**.
|
||||
The integrator moves `gain × error` per cycle while the command may move at
|
||||
most `slew_w`, so the integrator outruns the command whenever
|
||||
`|error| > slew_w / gain` — **667 W** at the live tuning, against a measured
|
||||
median grid error of **1114 W** at the onset of a recorded episode. ⚠️
|
||||
[[SAFETY-39]]'s retune made this **more** frequent, not less: a smaller step is
|
||||
a lower threshold.
|
||||
|
||||
**What ships** bounds the integrator's **lead over the command** at one slew
|
||||
step, inside the branch where the slew is what bound the command, and only in
|
||||
the leading direction. Nothing else in the law moves: the output clamp, the
|
||||
saturation freeze, the deadband and the 10 W quantisation are unchanged, and
|
||||
`test_control.py`'s exhaustive `i_w=None` equivalence sweep against the
|
||||
transcribed release/1.0 law still passes untouched — the cap moves `i_w`, not
|
||||
`target`.
|
||||
|
||||
⚠️ **WHAT IT IS MEASURED TO BUY IS LESS EXPORT — AND EXPLICITLY NOT A LOWER
|
||||
CAPACITY-TARIFF PEAK. DO NOT EXPECT YOUR BILLED QUARTER TO IMPROVE.** Replayed
|
||||
over 256 252 recorded meter instants spanning 16.00 days of this install
|
||||
(`scenarios/loop-2026-09-02_2026-09-17`, committed), same ticks, same
|
||||
disturbance, same plant, same seed:
|
||||
|
||||
| | release/1.0 | SAFETY-48 |
|
||||
|---|---:|---:|
|
||||
| **export kWh** | **39.61** | **34.07** |
|
||||
| wasted throughput kWh | 4.67 | 3.19 |
|
||||
| battery sign reversals | 2992 | 2770 |
|
||||
| median recovery s | 232 | 199 |
|
||||
| unrecovered (>900 s) | 53 | 31 |
|
||||
| worst \|i_w − prev_w\| W | 9200 | 800 |
|
||||
|
||||
That is **5.54 kWh less export — 14 % of the replayed figure**, and ⚠️ **the
|
||||
direction is what is robust, not the size**: across every plant and tuning
|
||||
variant swept it falls, by between ~4 % (at `gain: 0.3`) and ~16 % (at a
|
||||
re-fitted gain/offset), the median variant landing near an eighth. At `gain: 0.3` and at `slew_w` set to the rail the
|
||||
mean-quarter effect is a tie (−0.17 W and **+0.16 W**), because
|
||||
`|error| > slew_w / gain` is rarely satisfied in those regimes and there is
|
||||
little for the cap to bite on.
|
||||
|
||||
⚠️ **AND THE BILLED QUARTER IS NOT A CLAIM THIS RELEASE MAKES.** The A/B above
|
||||
also shows the worst 15-minute average **offtake** moving 3739 → 3434 W, and
|
||||
that figure exists at **one plant model**. `fit_plant()` selects a 7.5 s
|
||||
inverter lag on battery-power RMS, which separates it from 2.5 s by **0.3 %**,
|
||||
while the instrument's own fidelity measure — replayed export against the
|
||||
**29.43 kWh this install actually exported** — prefers 2.5 s by the whole 35 %
|
||||
gap. Swept properly: **at every lag ≤ 5.0 s, including the one that best
|
||||
reproduces the recorded export, release/1.0's worst quarter is already 3434 W
|
||||
and this change moves it by exactly zero.** The lag is not identified by this
|
||||
recording, and identifying it needs a driven step response that does not exist
|
||||
yet. **What this release can say about the billed quantity is that it does not
|
||||
make it worse at any lag tried — not that it improves it.**
|
||||
|
||||
⚠️ **AND EVERY NUMBER ABOVE IS SIMULATED.** The replay is a first-order plant
|
||||
model with a 375 W RMS residual; it prints its own fidelity first and it
|
||||
reproduces **39.6 kWh** of export where the install exported **29.4**, i.e. it
|
||||
is ~35 % pessimistic about the loop's tracking. Only the **delta** is claimed.
|
||||
Nothing here drove an inverter.
|
||||
|
||||
**What it costs, stated rather than smoothed:**
|
||||
|
||||
* ⚠️ **The cap does move a saturation-frozen integrator**, because it runs
|
||||
before the freeze block and is not gated on `frozen`. Of 79 160 frozen ticks
|
||||
it 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 own rule forbids. Worst
|
||||
case **2424 W**. It never overrides a *same-cycle* refusal.
|
||||
* **Two committed numbers moved.** [[SAFETY-13]]'s released command settles at
|
||||
−800 W and −1200 W where release/1.0 settled at −1190 W and −2390 W, because
|
||||
there is less wound integrator to settle onto; the property asserted is that
|
||||
it stops at the frozen integrator rather than at the rail.
|
||||
* ⚠️ **[[SAFETY-39]]'s claim that "the slew does not own the over-absorption"
|
||||
is now FALSE — inverted**, on the independent first-order plant
|
||||
`test_control.py` writes for itself: 1005/1109/1285/1330 W rising with a
|
||||
slower slew becomes 729/550/424/326 falling. Two plant models that share
|
||||
nothing agree on the sign. SAFETY-39's record carries a forward reference to
|
||||
this one.
|
||||
* **The form the card originally asked for was rejected on measurement**, and
|
||||
this is the least lag-sensitive result in the work: reusing the asymmetric
|
||||
freeze on a carried slew flag moves the worst quarter-hour offtake
|
||||
3739 → **3935 W** at the fitted plant, and across the lag sweep it is the
|
||||
worst of the three at **every lag from 2.5 s upward** — 3732 / 3711 / 3935 /
|
||||
4326 W against release/1.0's 3434 / 3434 / 3739 / 3954 — with a tie at lag
|
||||
0.0, and it is worse at **every top-N cut** of the quarters. It also raises
|
||||
the mean quarter (+2.73 W).
|
||||
|
||||
`DECISIONS/SAFETY-48-bounding-the-integrator-lead.md` carries all of it,
|
||||
including §8, *what this card does NOT establish*.
|
||||
|
||||
### TEL-10 — the loop's integrator stops being invisible
|
||||
|
||||
**Three new MQTT sensors, and nothing in the control path moved.**
|
||||
`app/control.py` is not in this diff: `compute()`, its returned `Decision` and
|
||||
every constant it reads are identical to release/1.0.
|
||||
|
||||
`self.i_w` has been a separate quantity from the command since [[SAFETY-04]]
|
||||
and left the process **nowhere at all** — no entity, no attribute, no log line.
|
||||
The investigation behind SAFETY-48 could only *reconstruct* it from the grid
|
||||
trace plus a replay; the reconstruction reproduced about a third of the
|
||||
engagements it was checked against and was discarded, so a control card had to
|
||||
be argued from a measurement nobody could stand behind. It could not have been
|
||||
made to work, either: `i_w` depends on every cycle before it, including the
|
||||
ones where the arbiter overrode the loop and `main.py` re-seeded it — and that
|
||||
re-seed is invisible in the grid trace.
|
||||
|
||||
| entity | topic | unit | what it is |
|
||||
|---|---|---|---|
|
||||
| `Loop integrator` | `goodwe_loop_integrator` | W | the accumulator **after** this cycle — before the output clamp, the slew limit and quantisation |
|
||||
| `Loop integrator lead` | `goodwe_loop_integrator_lead` | W | that value minus **the command `compute()` was handed**, i.e. the one the previous cycle left standing |
|
||||
| `Loop reason` | `goodwe_loop_reason` | — | the control law's own word for this cycle: `tracking`, `deadband`, `i-clamped`, `clamped`, `slew-limited`, `saturated-freeze`, `freeze-released` or `not-running` |
|
||||
|
||||
⚠️ **`Loop integrator lead` IS NOT A SUBTRACTION OF TWO PUBLISHED ROWS, WHICH
|
||||
IS WHY IT IS ITS OWN ENTITY.** `Setpoint` is the **arbiter's** output after
|
||||
every strategy has had its say, and it is **this** cycle's; the lead is
|
||||
measured against the command `compute()` was given, which is the previous one.
|
||||
On any cycle the arbiter overrode the loop the two differ — and that is exactly
|
||||
the cycle on which the integrator was re-seeded, so a dashboard doing the
|
||||
subtraction would get a different number precisely where it matters.
|
||||
|
||||
⚠️ **`Loop reason` IS NOT THE `Why` ROW AND NOT `Controller status`.** Both of
|
||||
those carry the *arbiter's* sentence — `loop -> -5000 W` — which names the
|
||||
winning claim and its watts and has never carried that claim's own reason.
|
||||
`slew-limited`, the value [[SAFETY-48]] above is entirely about, appears in
|
||||
neither. Measured on one cycle: arbiter `'loop -> -5000 W'`, loop `'i-clamped'`.
|
||||
|
||||
⚠️ **NOTHING EXISTING IS RENAMED, RE-TYPED OR RE-ORDERED.** The three are
|
||||
**appended** after the nine messages release/1.0 already announces, and all
|
||||
nine stay **byte-identical**. ⚠️ Eight of them also stay **first**, which is
|
||||
what `test_mqtt.py` part 1's prefix comparison grades; the ninth,
|
||||
`goodwe_ctl/availability`, is byte-identical but deliberately moves to **last**,
|
||||
so a prefix comparison cannot cover it and the check on the next line pins it
|
||||
in its new position instead. **They are SITE rows, not per-unit ones** — there
|
||||
is one integrator, so a per-unit copy would invent N of a thing there is one of
|
||||
— and they therefore appear at **every** roster length, **including a roster of
|
||||
one**, where the per-unit machinery announces nothing new at all.
|
||||
|
||||
Both numbers carry `state_class: measurement`, so the recorder keeps long-term
|
||||
statistics for them. `DOCS.md` has the operator account, including how to read
|
||||
a standing lead: the cycles needed to unwind it are that lead divided by
|
||||
`slew_w`.
|
||||
|
||||
### SAFETY-47 — what a shadow run can show you, and the watts on its status row
|
||||
|
||||
⚠️ **Nothing about the write path changed, and nothing needed to.** Shadow
|
||||
mode still holds every inverter at 0 W through the one writer. What was wrong
|
||||
is what three merged documents **said** a shadow run can show you, and a number
|
||||
on the status page that could be **6.61×** what a live controller would
|
||||
command.
|
||||
|
||||
**A shadow run cannot show you a closed-loop defect. Not partially — not at
|
||||
all.** The battery is held at 0 W, 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, and a closed-loop defect is one whose
|
||||
stimulus *is* the plant's response. That is the same property that makes shadow
|
||||
mode safe, so it is not a gap to be closed later: **the closed-loop answer
|
||||
comes from the closed-loop rig or from a live run.**
|
||||
|
||||
SAFETY-10 promised otherwise, because of a sentence that was true when it was
|
||||
written and false one commit later:
|
||||
|
||||
> the **strategies** are unaffected — they ask for a fixed power from inputs
|
||||
> that do not depend on what the battery did — so a shadow run does show you
|
||||
> the trades, the peak-guard engagements and the SoC-limit behaviour.
|
||||
|
||||
SAFETY-45 (above) sizes the buy leg to the billed quarter's charging headroom,
|
||||
and that headroom is `(900 / seconds remaining) × (ceiling − projected)` with
|
||||
the projection read off the **open quarter's meter**. The strategy became
|
||||
closed-loop the day that landed. Measured on the rig, one hour at `peak_cap_w`
|
||||
1500 under a 1200 W house:
|
||||
|
||||
| | live | shadow |
|
||||
|---|---|---|
|
||||
| charge onsets | 28 | **1** |
|
||||
| grid | −455.5 … +3200.0 W | **1200.0 W flat — the house** |
|
||||
| capacity guard engaged | 410 / 720 cycles | **0 / 720** |
|
||||
| state of charge | 50.000019 → 51.856789 % | **50.000000 → 50.000000 %** |
|
||||
|
||||
**What the documents now say.** The exemption narrows to exactly what is still
|
||||
true — **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 shadow mode was written for, one —
|
||||
TARIFF-14's wrong quarter-hour — is genuinely visible from a shadow run, and it
|
||||
is graded that way: identical live and shadow on 720 of 720 charging cycles at
|
||||
a binding cap.
|
||||
|
||||
**What you will see on the status page.** The `Shadow mode` row and the action
|
||||
log now introduce the watts as what the controller would have commanded **on
|
||||
this cycle** — in every state, because no number a shadow run records is a
|
||||
forecast of a live run. And when a **measured** billed quarter sized those
|
||||
watts, both surfaces carry:
|
||||
|
||||
> ⚠️ 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.
|
||||
|
||||
⚠️ **The number is caveated, not re-computed, and that was the decision.**
|
||||
Re-computing it to "look right" would mean the recorded decision was no longer
|
||||
the decision the shipped path reached — while the arbiter, the clamp, the
|
||||
write-authorisation gate, the action log and MQTT all ran on the real one. A
|
||||
shadow run's whole worth is that what it records is what the controller
|
||||
actually decided. `DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md` §3
|
||||
records the three alternatives that were rejected and why.
|
||||
|
||||
⚠️ **On a site whose cap does not bind, nothing changes by one watt.** At
|
||||
`peak_cap_w` 15000 against a 350 W house the recorded figure is the live one
|
||||
exactly, and no caveat is a lie there either — the row still says the size came
|
||||
off a meter with no battery in it, which is true and is the point.
|
||||
|
||||
**And SAFETY-10's own committed evidence could never have failed.** Its
|
||||
comparison table ran at a cap the guard never reaches, *and* read only the
|
||||
first charging sample — the one instant at which the two runs agree even at a
|
||||
cap that does bind. Both had to be closed; closing either alone closes nothing.
|
||||
The table is re-taken across the whole run at a binding cap, and
|
||||
`test_arbitrage.py` part 3d is the CI-side grader so this class cannot recur.
|
||||
14 mutations, no survivors; all 29 check files green.
|
||||
|
||||
### SAFETY-10 — shadow mode, and what its default does to YOUR install
|
||||
|
||||
A new option, `shadow_mode`, with three values and a shipped default of
|
||||
`auto`. In shadow mode the add-on runs its entire decision path — strategies,
|
||||
arbiter, clamps, guards — and **sends nothing to the inverter**: every inverter
|
||||
is held at **0 W**, refreshed on the ordinary heartbeat by the ordinary single
|
||||
writer, and the status page says plainly that the battery is not being
|
||||
controlled. What it *would* have commanded, and why, is on the status page and
|
||||
in the action log.
|
||||
|
||||
⚠️ **WHAT HAPPENS TO AN EXISTING INSTALL: NOTHING.** `auto` resolves to shadow
|
||||
mode only while control is **stopped**. An install that is controlling —
|
||||
`auto_start: true`, or *Start / stop control* pressed — is started, so `auto`
|
||||
resolves to ordinary control and not one watt changes. An install that is
|
||||
**stopped** keeps its actuator at 0 W exactly as it did before, from the same
|
||||
writer on the same heartbeat; what changes for it is what the status page and
|
||||
the action log **say**, and that the decision path now runs and is recorded.
|
||||
|
||||
⚠️ **AND THAT IS WHY IT IS NOT A BOOLEAN.** The card is titled "default-on".
|
||||
Taken literally — `shadow_mode: true` in `config.yaml` — it would be the worst
|
||||
change in this repository's history: 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,
|
||||
including the one regulating a real battery at the reference site. It would
|
||||
have silently stopped it, and the only symptom would have been a battery that
|
||||
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, so
|
||||
"default-on for new" is expressed as a value the add-on **resolves** rather
|
||||
than a constant the Supervisor merges.
|
||||
|
||||
A **new** install ships `auto_start: false`, so it comes up in shadow mode and
|
||||
observes a day of decisions before it is allowed to command anything. That is
|
||||
the default-on the card asked for, delivered where it is safe and withheld
|
||||
where it is not.
|
||||
|
||||
⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT
|
||||
ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and
|
||||
@@ -62,6 +730,80 @@ separate card. *(Annotated 2026-09-13: that card is **TARIFF-15**, below, and it
|
||||
shipped on this branch as `custom_formula`. It is still not an alias — the
|
||||
operator types their own coefficients; nothing is invented on their behalf.)*
|
||||
|
||||
### SAFETY-45 - the trade is sized by the capacity guard, not switched by it
|
||||
|
||||
**A price-driven charge now asks the peak guard HOW HARD it may pull, instead of
|
||||
only whether it may pull at all.** On any site where `load + max_w` reaches
|
||||
`peak_cap_w` - at the shipped `peak_cap_w: 3500` and `max_w: 2000`, any house
|
||||
drawing over 1500 W in the cheapest quarter - arbitrage and the capacity-tariff
|
||||
guard were driving each other in a sustained limit cycle. Measured on TEST-23
|
||||
stage 3's closed rig at `peak_cap_w: 1500` against a 1200 W house: **28 charge
|
||||
onsets in one hour, mean period 131 s, the grid swinging −456 to +3200 W**, and
|
||||
every billed quarter in the three affected configurations finishing **over** the
|
||||
cap it was oscillating about. §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."*
|
||||
|
||||
**What was happening.** The guard computes `wanted = batt_w + excess` - the
|
||||
battery discharge that would land the quarter exactly on the ceiling - and files
|
||||
a claim only while that number is positive. While a trade charges at `max_w`
|
||||
the number is negative for the first minute or so of every cycle, so the guard
|
||||
files **nothing** and the trade is not constrained at all. `wanted` then crosses
|
||||
zero, the guard engages on a floor of a few watts, and that flips
|
||||
`arbitrage.guard_state()` - which cancels **the whole trade**. The
|
||||
grid-following loop takes the actuator, drives the meter to zero and past it,
|
||||
the projection collapses, the guard releases, and the trade comes back at
|
||||
`max_w`. 115 s later, again.
|
||||
|
||||
**The fix, and it is a shape this add-on already had.**
|
||||
`control.maintenance_charge_floor()` has always capped a MAINTENANCE charge at
|
||||
"the headroom left under the site's cap", because *"a maintenance charge is the
|
||||
only thing this system does that is big enough and long enough to set that
|
||||
peak"*. ⚠️ That stopped being true when OPT-04 landed: a trade commands `max_w`
|
||||
for a whole tariff interval, deliberately, against the meter.
|
||||
`peakguard.charge_headroom_w()` publishes the same quantity off the **measured**
|
||||
guard rather than off the optional `peak_forecast_entity`, and
|
||||
`arbitrage.decide()` sizes the buy leg to it.
|
||||
|
||||
**No new arithmetic and no new constant.** The number is `wanted_w` read in the
|
||||
other direction: a negative `wanted` is not "nothing to say", it is a permitted
|
||||
charge of `−wanted`. `peakguard.decide()` is **unchanged** - byte-identical to
|
||||
`release/1.0`, `sha256 = 200090d0835a3a55` - so 2026-08-20's anti-bang-bang rung
|
||||
is exactly where it was, and `tools/replay_ac3.py` reproduces the same 190/190
|
||||
guarded quarters, 110 engaged cycles and 878 Wh on this branch as on the tip.
|
||||
|
||||
**After, on the same rig, same command:**
|
||||
|
||||
| `peak_cap_w` / house | onsets per hour | grid swing | energy bought | billed quarters |
|
||||
|---|---|---|---|---|
|
||||
| 1500 / 1200 W | 28 → **1** | 3656 → **282 W** | 180 → **285 Wh** | 1490/1497/**1509** → **1500/1500/1500** |
|
||||
| 3500 / 1800 W | 8 → **1** | 3673 → 1688 W | 1653 → 1616 Wh | **3511/3536/3559** → **3500/3500/3500** |
|
||||
| 3500 / 3000 W | 32 → **1** | 3798 → **475 W** | 483 → 475 Wh | **3519/3542/3557** → **3500/3500/3500** |
|
||||
| 5000 / 1200 W | 1 → 1 | *identical* | *identical* | *identical* |
|
||||
|
||||
One onset means the trade starts once and runs for the whole window - the
|
||||
battery is now trading 100 % of the cheap hour where it traded 43 %, 92 % and
|
||||
54 %. **Every billed quarter now lands on the cap and none over it.** At the
|
||||
aggressive cap the site buys 58 % MORE energy, because a continuous 300 W trade
|
||||
beats a 2000 W trade that is cancelled for well over half the hour; at the two
|
||||
shipped-default rows it buys about 2 % less and the worst quarter drops from
|
||||
59 W over the cap to zero. The last row is the negative control - `load + max_w`
|
||||
never reaches the cap there, and the two runs are bit-identical. **This costs
|
||||
nothing on the installs it is not about.**
|
||||
|
||||
⚠️ **NOT A CONFIG CHANGE.** No option is added, removed or re-defaulted, and
|
||||
arbitrage remains off in the shipped configuration. A site that has not armed
|
||||
`arbitrage_enabled` is unaffected in every respect.
|
||||
|
||||
⚠️ **WHAT IS NOT FIXED, AND IT IS NAMED RATHER THAN IMPLIED.** TEST-23 finding 1
|
||||
- the export overshoot at the END of any trade, 356-938 W, about 0.37 × `max_w`
|
||||
- is untouched and is a different card. A house whose load ALONE exceeds
|
||||
`peak_cap_w` still has the guard engage and the trade refused outright, which is
|
||||
correct, and the grid-following loop then drives to grid = 0 rather than to the
|
||||
ceiling. See `DECISIONS/SAFETY-45-sizing-the-trade.md` §5, and §2 there for the
|
||||
negative-`lo` design that was rejected because it creates a new `lo > hi`
|
||||
contradiction against the maintenance charge shaping.
|
||||
|
||||
### TARIFF-16 - the injection leg stops being one number
|
||||
|
||||
**`price_export_formula`: what a site is PAID for export, as `factor × index +
|
||||
@@ -1446,12 +2188,30 @@ and pulled. If it has not, this belongs inside 0.8.0's section before it ships;
|
||||
if it has, it needs 0.8.1. Whoever deploys decides, and PKG-07 is the record of
|
||||
what happens when nobody does.
|
||||
|
||||
⚠️ **ANNOTATED BY PKG-14, 2026-09-16 - EVERY ROW BELOW HAS SHIPPED, AND THE
|
||||
PARAGRAPH ABOVE IS NO LONGER TRUE OF ANY OF THEM.** It is annotated rather
|
||||
than rewritten, and nothing is moved out of it, because re-filing this file's
|
||||
misplaced entries is [[PKG-08]]'s card and guessing a boundary is what that
|
||||
card exists to stop. What is measurable, and what PKG-14 measured:
|
||||
|
||||
* **UX-02 shipped in 0.9.0.** Its merge `3d29993` is an ancestor of the 0.9.0
|
||||
bump `86bdf49` and it is already listed in `## 0.9.0`'s own contents table,
|
||||
so this row is a duplicate of a released one.
|
||||
* **UX-04, TEST-21 and SAFETY-43 shipped in 0.10.0.** All three merged after
|
||||
`86bdf49`. TEST-21 (`app/delivery.py`, `config.yaml`, `translations/`) and
|
||||
SAFETY-43 (`app/delivery.py`, `app/packshare.py`, `config.yaml`) are runtime
|
||||
changes and are named in `## 0.10.0`'s preamble as shipping there and being
|
||||
written down here; UX-04 touches no runtime file.
|
||||
* ⚠️ **[[SAFETY-44]] also shipped in 0.10.0 and is nowhere in this file** -
|
||||
`app/main.py` and `app/store.py`, three commits. It is named in
|
||||
`## 0.10.0`'s preamble for the same reason.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen |
|
||||
| _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed |
|
||||
| _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce |
|
||||
| _this branch_ | SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression |
|
||||
| `3d29993` | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen **⚠️ SHIPPED IN 0.9.0, see that section** |
|
||||
| `bb46916` | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed **⚠️ SHIPPED IN 0.10.0** |
|
||||
| `6719bd8` | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce **⚠️ SHIPPED IN 0.10.0** |
|
||||
| `37dd0f0` | SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression **⚠️ SHIPPED IN 0.10.0** |
|
||||
|
||||
⚠️ **SAFETY-43 CLOSES THE DETECTION REGRESSION OPT-09 DISCLOSED, AND IT IS A
|
||||
TWO-INVERTER FIX. N=1 IS BYTE-IDENTICAL.** `delivery.delinquent()` named a unit
|
||||
|
||||
@@ -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,380 @@
|
||||
# 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.
|
||||
|
||||
**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,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.
|
||||
+293
-3
@@ -287,8 +287,10 @@ it always has.
|
||||
published — `Setpoint`, `Grid power`, `Battery power`, `Battery SoC`,
|
||||
`Circulating power`, `Maintenance phase`, `Controller status`, `P1 sample age` —
|
||||
keep their device, their `unique_id`s and therefore their **entity ids and their
|
||||
whole recorded history**. At a roster of one, nothing new is announced at all:
|
||||
no second device, no renamed entity, not one added topic.
|
||||
whole recorded history**. At a roster of one, nothing new is announced by the
|
||||
per-unit machinery at all: no second device, no renamed entity, not one added
|
||||
topic. (TEL-10's three loop rows, below, are site rows and are added at every
|
||||
roster length, including one.)
|
||||
|
||||
Declare a second inverter and **four rows per unit** appear beside them:
|
||||
|
||||
@@ -321,7 +323,90 @@ arrives, and it fails silently and looks correct.
|
||||
There is one P1 meter and one grid connection; a per-inverter copy would invent
|
||||
two of a thing there is one of, and `P1 sample age` is a safety input. Same for
|
||||
`Circulating power` (it is a property of the *set* of inverters), the controller
|
||||
status, the maintenance phase and the capacity-tariff peak.
|
||||
status, the maintenance phase, the capacity-tariff peak and the three loop rows
|
||||
below.
|
||||
|
||||
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
|
||||
|
||||
The control law is a bounded integrator: an accumulator that is **not** the
|
||||
command. Each cycle it moves by `gain × error`; the command is then derived
|
||||
from it and passed through the output clamp, the slew limit and a 10 W
|
||||
quantisation before anything is written. Three entities publish what the
|
||||
accumulator is doing:
|
||||
|
||||
| entity | unit | what it is |
|
||||
|---|---|---|
|
||||
| `Loop integrator` | W | the accumulator **after** this cycle — the value *before* the clamp, the slew limit and quantisation |
|
||||
| `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing |
|
||||
| `Loop reason` | — | the law's own word for this cycle |
|
||||
|
||||
`Loop reason` takes one of eight values:
|
||||
|
||||
| value | what happened |
|
||||
|---|---|
|
||||
| `tracking` | ordinary regulation |
|
||||
| `deadband` | the error is inside `deadband_w`; the loop is holding |
|
||||
| `i-clamped` | the integrator hit `integrator_max_w` (which follows `max_w` by default) |
|
||||
| `clamped` | the *command* came out over `max_w` — only reachable if `integrator_max_w` is deliberately set above the rail |
|
||||
| `slew-limited` | the command could not move as far as the integrator asked for; `slew_w` is what stopped it |
|
||||
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
|
||||
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
|
||||
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
|
||||
|
||||
⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will
|
||||
often disagree.** `Why` is the *arbiter's* sentence — `loop -> -1200 W` — which
|
||||
names the strategy that won and the watts it won with. It has never carried the
|
||||
strategy's own reason, so a loop that is slew-limited or frozen says so **only**
|
||||
here.
|
||||
|
||||
⚠️ **`Loop integrator` is not `Setpoint`, even though it usually looks like
|
||||
it.** With nothing binding, the command *is* the integrator rounded to 10 W, so
|
||||
on a quiet day the two rows track each other and the difference is a rounding
|
||||
step. They separate exactly when something is wrong: a large disturbance sends
|
||||
the integrator to its bound in one or two cycles while the command may only
|
||||
travel `slew_w` per cycle, and `Loop integrator lead` is the gap. A standing,
|
||||
large lead means the loop has wound up further than the command can follow, and
|
||||
the number of cycles needed to unwind it is that lead divided by `slew_w`.
|
||||
|
||||
Both numbers are `state_class: measurement`, so Home Assistant's recorder keeps
|
||||
long-term statistics for them.
|
||||
|
||||
⚠️ **`Loop integrator lead` is BOUNDED, and the bound is two slew steps.** While
|
||||
the slew limit is what is holding the command back, the integrator may not stand
|
||||
more than **one** `slew_w` ahead of the command it emits — so the command always
|
||||
covers the gap in one cycle, and a reversal costs one cycle of unwind rather
|
||||
than `integrator_max_w / slew_w` (12.5 at `max_w: 5000`, `slew_w: 400`).
|
||||
Measured against the *previous* cycle's command, which is what this row
|
||||
publishes, that is two steps: 800 W at the live shape. **A published lead
|
||||
persistently above two slew steps means something is wrong** — either the
|
||||
saturation freeze is holding the command (`Loop reason` says
|
||||
`saturated-freeze`), or the bound is not doing its job.
|
||||
|
||||
Before this bound existed, the same 6 kW export drove the lead to 4600 W and the
|
||||
integrator to its 5000 W rail, and the command then needed twelve cycles — about
|
||||
a minute at this install's meter rate — to walk back down once the disturbance
|
||||
cleared.
|
||||
|
||||
⚠️ **What the bound buys — and how much of it is a model rather than a meter
|
||||
reading.** Sixteen days of the reference install's own recorded disturbances
|
||||
were replayed through both laws (`tools/replay_recorded_loop.py`). The result
|
||||
that survives every plant and tuning tried is the **export reduction: about an
|
||||
eighth of the modelled export** — 5.5 kWh of 39.6 at the plant lag the replay
|
||||
fits, 3.8 kWh of 28.9 at the shorter lag that best reproduces what this install
|
||||
actually exported — with wasted battery throughput falling alongside it, 4.67 to
|
||||
3.19 kWh at the fitted plant. ⚠️ **Those are simulated watt-hours.** The
|
||||
replay's fitted inverter carries a ~375 W RMS residual and prints `+35 %
|
||||
PESSIMISTIC` beside its own export figure on every run; the *direction* is what
|
||||
it establishes, not the absolute kWh.
|
||||
|
||||
⚠️ **And it does NOT promise you a lower capacity-tariff peak.** The worst
|
||||
quarter-hour average offtake — the billed quantity — improves from 3739 W to
|
||||
3434 W **only at the longest plant lag the replay fits**. At every shorter lag,
|
||||
including the one that best reproduces the recorded export, release/1.0 already
|
||||
sits at 3434 W and this bound changes it by exactly zero. The plant lag is not
|
||||
identified by the recording, so treat the peak as unchanged and the export as
|
||||
the reason this exists. `DECISIONS/SAFETY-48-bounding-the-integrator-lead.md`
|
||||
§3a has the table.
|
||||
|
||||
⚠️ **Two roster names that differ only in punctuation or case** — `inverter-b`
|
||||
and `Inverter B` — are two names to the validator and one id once slugged. The
|
||||
@@ -441,6 +526,35 @@ next 10 s publish. There is **no option to configure**: the 35 s is derived from
|
||||
the ESPHome publish interval and the two watchdog windows it has to stay inside,
|
||||
and the add-on's own checks refuse a value that does not.
|
||||
|
||||
⚠️ **AND IF THE REGISTER DOES NOT REPORT A DIFFERENT VALUE, THE ADD-ON STOPS
|
||||
WAITING AFTER 17 s (SAFETY-57).** This paragraph used to end at the sentence
|
||||
above, and on 2026-09-21 that turned out to be a promise the state could not
|
||||
keep: at 0 W **nothing is asking the register to be anywhere**, so "report a
|
||||
different value" is evidence a parked site cannot ask for. The live site sat at
|
||||
0 W for **790 s** while the house exported 2.4 kW, on a register that was
|
||||
working perfectly — the pack was simply at its floor and the BMS was refusing
|
||||
the discharge — and it was freed by a person restarting the add-on.
|
||||
|
||||
So: once the standing command has been **under 250 W for 17 s**, the verdict is
|
||||
**withdrawn** and the register is graded again from scratch over a fresh 35 s.
|
||||
The whole parked interval is now `stale_input_s` + 17 s — **32 s at the
|
||||
defaults** — and it does not depend on what the battery decides to do.
|
||||
|
||||
| in the action log | what it means | what to do |
|
||||
|---|---|---|
|
||||
| `STALE <unit>` alone, then nothing | the register stopped following and has not come back | look at the unit — comms, the ESP32, the inverter |
|
||||
| `STALE <unit>` then `STALE WITHDRAWN <unit>`, **once** | the pack was probably empty or full and the BMS refused the command. The site has resumed | nothing |
|
||||
| the pair repeating every ~70 s | the register really is not following, and the add-on is re-testing it | look at the unit; the site is regulating on the meter in between |
|
||||
|
||||
⚠️ **`STALE WITHDRAWN` IS NOT "RECOVERED", AND THE LOG LINE SAYS SO ITSELF.**
|
||||
Nothing has been learned about the register when it is printed; the verdict is
|
||||
withdrawn because it can no longer be *tested*, and the next 35 s are the test.
|
||||
During those 35 s the held number reaches the site's telemetry again. That is
|
||||
deliberate and it is bounded: 35 s is shorter than both the circulating-power
|
||||
hold (75 s) and the per-unit sign hold (85 s), so neither of those alarms can be
|
||||
made to say anything by a held number, and the command itself is derived from
|
||||
the **meter**, never from this register.
|
||||
|
||||
⚠️ **What this does NOT catch: a register whose value is WRONG rather than
|
||||
STOPPED**, including a dead publisher whose last two samples happen to
|
||||
alternate. Detecting that needs an arrival stamp inside the ESP32 firmware
|
||||
@@ -601,6 +715,168 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
| `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise |
|
||||
| `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two |
|
||||
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
|
||||
| `shadow_mode` | `auto` | **Run every decision and command nothing.** `auto` = shadow mode while control is stopped, ordinary control once you press Start; `on` = shadow mode always; `off` = ordinary control. See *Shadow mode* below |
|
||||
|
||||
#### Shadow mode (`shadow_mode`, SAFETY-10)
|
||||
|
||||
Shadow mode runs the **whole** decision path — the price strategy, the capacity
|
||||
guard, the maintenance shaping, the grid-following loop, the arbiter that ranks
|
||||
them, SAFETY-28's authorisation gate and SAFETY-03's clamp — and **sends nothing
|
||||
to the inverter**. It is how you find out what this add-on *would* do on a site
|
||||
before you let it do it.
|
||||
|
||||
Every defect in the optimiser that this project has found was invisible from
|
||||
outside while it was happening: a price lookup answering for the wrong
|
||||
quarter-hour, a trade against a binding capacity cap that ran 28 charge onsets
|
||||
in an hour, an SoC ceiling churning 598 Wh to gain 310 Wh. Each one was paid for
|
||||
at the meter first and noticed afterwards. Shadow mode is the surface that
|
||||
would have shown all three on the day.
|
||||
|
||||
**What "commands nothing" means, exactly.** Every inverter is held at **0 W**,
|
||||
written by the ordinary single writer and refreshed on the ordinary heartbeat.
|
||||
It is not silence. ⚠️ **The inverter holds its last command forever** — it has
|
||||
no meter timeout — so a mode that simply stopped writing would leave whatever
|
||||
was standing when you switched it on standing for as long as you left it on,
|
||||
under a page saying nothing was being commanded. Refreshing 0 W is also what
|
||||
the ESP32's 30 s stale-input watchdog requires of us: going quiet would put
|
||||
that board into a *tripped watchdog* state for the whole run, which is an alarm
|
||||
being used as a mode.
|
||||
|
||||
**The three values.**
|
||||
|
||||
| value | what happens |
|
||||
|---|---|
|
||||
| `auto` | **the shipped default.** Shadow mode while control is **stopped**; ordinary control once control is **started**. A new install ships `auto_start: false`, so it comes up in shadow mode and observes before it ever commands anything. An install that is already controlling is started, so nothing about it changes |
|
||||
| `on` | shadow mode **always**, whatever the *Start / stop control* button says. This is the one to pick to watch an armed site for a day — the loop runs, arbitrage decides, the peak guard files its claims, and the battery does not move |
|
||||
| `off` | ordinary control |
|
||||
|
||||
⚠️ **Existing installs are not changed.** The default is `auto` rather than a
|
||||
boolean `true` precisely because a boolean would have changed them: the
|
||||
Supervisor composes an add-on's options as *its defaults, overlaid with your
|
||||
stored options*, so a new key with a new default reaches every install on the
|
||||
next update. `shadow_mode: true` would have silently stopped a battery that is
|
||||
regulating today, and the only symptom would be a battery that stopped doing
|
||||
anything.
|
||||
|
||||
⚠️ **Under `auto`, pressing *Start* leaves shadow mode.** That is the point of
|
||||
`auto` — the button means "control the battery". Set `on` if you want the
|
||||
decisions recorded while the loop runs.
|
||||
|
||||
**What you get out of it.** The status page carries a red-amber banner saying
|
||||
the battery is not being controlled, a `Shadow mode` row saying which rung
|
||||
decided, what it **would have commanded on that cycle** and why — with the
|
||||
caveat below, where the number was sized from a meter the battery never
|
||||
moved — and the `Why` row with the arbiter's own sentence. The action log gets one line each time the decision
|
||||
changes — the winner or the watts — rather than one per cycle, so a 40-row log
|
||||
still contains the context of what it found. MQTT reports `status: shadow`.
|
||||
|
||||
**The failsafes.** The sign watchdogs (SAFETY-02/30) are **inert**: they grade
|
||||
what this controller has standing on the wire, and that is 0 W, so they cannot
|
||||
arm. That is deliberate and it is the single most important decision in this
|
||||
feature — 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**. The clamp (SAFETY-03), the write-authorisation gate (SAFETY-28), the
|
||||
frozen-meter alarm (SAFETY-18) and the capacity guard all **observe**: they run
|
||||
on the number the decision path produced, so a shadow run can tell you the
|
||||
clamp would have engaged. ⚠️ **The capacity guard observes a meter with no
|
||||
battery in it.** Every term of the quantity it thresholds is measured, so it
|
||||
engages in a shadow run only if the *house alone* would have put the quarter
|
||||
over the ceiling — 410 engaged cycles live against 0 in shadow, on the hour
|
||||
tabulated below. Its *ceiling* and its *reasons* are worth reading from a
|
||||
shadow run; its *engagements* are not evidence of anything. The circulation (SAFETY-31) and delivery (SAFETY-42)
|
||||
watchdogs observe and may legitimately latch, because their evidence is
|
||||
*measured* power, not a command. The operator stop is enforced at the wire
|
||||
instead of as a claim, so that the recorded decision is the real one.
|
||||
|
||||
⚠️ **A latch that was already up stays up.** Shadow mode does not clear
|
||||
anything. The 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 unchanged.** The maintenance state machines are ticked on
|
||||
every cycle whether control is started or not, so they already run against a
|
||||
battery that does not move on a stopped install. Shadow mode inherits that
|
||||
exactly; it neither improves it nor makes it worse.
|
||||
|
||||
⚠️ **What a shadow run will NOT tell you — and it is a whole category, not a
|
||||
footnote.** The controller's carried command is re-seeded to the 0 W standing
|
||||
on the wire at the end of every shadow cycle — that is what makes turning
|
||||
shadow mode off safe, because the loop then resumes from where the battery
|
||||
actually is. Everything below follows from the same fact.
|
||||
|
||||
**A shadow run cannot show you a closed-loop defect. Not partially — not at
|
||||
all.** The battery is held at 0 W, 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 one whose
|
||||
stimulus **is** the plant's response, and there is no response to be the
|
||||
stimulus. This is not a gap waiting to be closed: feeding the guards a
|
||||
simulated plant instead is exactly what the sign-watchdog decision below
|
||||
refuses, and for the same reason — a controller reacting to a plant that does
|
||||
not exist drifts further from reality every tick. **The closed-loop answer
|
||||
comes from the closed-loop rig or from a live run, and there is no third
|
||||
place.**
|
||||
|
||||
Concretely, measured on the rig at `peak_cap_w` 1500 under a 1200 W house,
|
||||
`max_w` 2000, one hour at a 5 s cycle — the same hour run twice, live and in
|
||||
shadow, on the same code:
|
||||
|
||||
| | live | shadow |
|
||||
|---|---|---|
|
||||
| charge onsets in the hour | **28** | **1** |
|
||||
| grid | **−455.5 … +3200.0 W** | **1200.0 W flat — the house** |
|
||||
| capacity guard engaged | **410 / 720 cycles** | **0 / 720** |
|
||||
| state of charge | 50.000019 → 51.856789 % | **50.000000 → 50.000000 %** |
|
||||
|
||||
(The onset and engagement figures are taken with the trade-sizing fix reverted,
|
||||
because that is the defect being looked for; the SoC and grid figures are the
|
||||
shipped build.)
|
||||
|
||||
⚠️ **The "strategies are unaffected" exemption that used to stand here was true
|
||||
until the trade-sizing fix landed, and is now false by hours.** That fix makes
|
||||
the buy leg `min(asked, headroom)`, where the headroom is
|
||||
`(900 / seconds remaining) × (ceiling − projected quarter average)` — and the
|
||||
projection reads the **open quarter's accumulator**, i.e. the meter, i.e. a
|
||||
function of what the battery did earlier in that same quarter. What survives
|
||||
of the exemption is exactly this much:
|
||||
|
||||
> **Which** hour a strategy picks, and **why**, are unaffected — the clock and
|
||||
> the price series are not functions of the battery. Measured: identical live
|
||||
> and shadow on 720 of 720 charging cycles at a binding cap. **How hard** it
|
||||
> trades is not.
|
||||
|
||||
⚠️ **So the watts on the `Shadow mode` row are the right answer to a question
|
||||
nobody asked, and the row now says so.** In a shadow run the billed quarter
|
||||
really *is* under its ceiling, because the battery really *is* not charging, so
|
||||
`900 / remaining` multiplies a gap a live run would have closed. On the same
|
||||
rig, shipped code:
|
||||
|
||||
| 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× |
|
||||
|
||||
Whenever a measured billed quarter sized the number, the row and the action log
|
||||
carry **⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE
|
||||
HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W** — and the watts are introduced
|
||||
as what it would have commanded **on this cycle**, in every state, because no
|
||||
number a shadow run records is a forecast of a live one. The figure is **not**
|
||||
re-computed to look right: a shadow-only sizing would mean the recorded
|
||||
decision was no longer the decision the shipped path reached, which is the one
|
||||
thing this feature has to be able to say.
|
||||
|
||||
The **grid-following loop** is the same story told first: its recorded decision
|
||||
is **one slew step** and stays there, because a trajectory depends on a battery
|
||||
responding and none did.
|
||||
|
||||
**What a shadow run genuinely is for**, then: *which* strategy wins, on *which*
|
||||
hour, at *which* price, for *which* reason, against which limits — the
|
||||
wrong-quarter-hour class of defect, 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 this feature covers.
|
||||
|
||||
#### The sign-disagreement failsafe
|
||||
|
||||
@@ -979,6 +1255,7 @@ What exists today, and what does not:
|
||||
|---|---|
|
||||
| `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude |
|
||||
| Maintenance charging capped under `peak_cap_w` | ✅ |
|
||||
| **Arbitrage charging capped under `peak_cap_w`** | ✅ — SAFETY-45, sized to the guard's own MEASURED headroom rather than to `peak_forecast_entity` |
|
||||
| **Loop capped under `peak_cap_w`** | ✅ — SAFETY-07, on every `meter_source` but `off` |
|
||||
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
|
||||
| Frozen meter *stopped* | ✅ — SAFETY-07 forbids importing while the meter is frozen |
|
||||
@@ -1041,6 +1318,19 @@ projected over — raises one notification (`goodwe_peak_saturated`), because th
|
||||
is the one state where the money will be spent and shedding load is the only
|
||||
thing left that changes the bill.
|
||||
|
||||
**And it sizes a price-driven charge, not only a forced discharge (SAFETY-45).**
|
||||
The same arithmetic read the other way answers *"how hard may this site charge
|
||||
without putting the quarter over"* — `peakguard.charge_headroom_w()` — and
|
||||
`arbitrage.decide()` sizes its buy leg to it. Before that, a trade commanded
|
||||
`max_w` until the projection rose far enough to engage the guard, at which point
|
||||
the interlock cancelled the trade outright, the loop took the actuator, the
|
||||
projection collapsed and the trade came back at `max_w`: **28 charge onsets in
|
||||
one hour** on TEST-23 stage 3's closed rig, with the grid swinging −456 to
|
||||
+3200 W and every billed quarter finishing over the cap. It is one onset now,
|
||||
the trade runs continuously at the affordable rate, and the billed quarters land
|
||||
**on** the cap. ⚠️ The guard is what changed *around* arbitrage; `decide()`
|
||||
itself is untouched, so nothing above this line is different.
|
||||
|
||||
**No new option for the CEILING.** It is `peak_cap_w`, which already shipped. The
|
||||
coverage threshold is `meter_max_age_s`, the freeze predicate is
|
||||
`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device
|
||||
|
||||
@@ -172,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
|
||||
@@ -414,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
|
||||
@@ -773,6 +811,91 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,64 @@ 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)
|
||||
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"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 +618,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 +632,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.
|
||||
|
||||
@@ -66,6 +66,7 @@ from .mqtt import (MqttPublisher, announced_units, unit_slugs,
|
||||
unit_state_key)
|
||||
from . import peakguard
|
||||
from . import packshare
|
||||
from . import shadow
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option, provider_configured
|
||||
from .price_history import PriceHistory
|
||||
@@ -830,9 +831,46 @@ class Controller:
|
||||
|
||||
# live state
|
||||
self.auto = bool(store.data.get("auto", opts.get("auto_start", False)))
|
||||
# --- SAFETY-10 -------------------------------------------------------
|
||||
# What the decision path reached on the last shadow cycle, in W, or
|
||||
# None before the first one. ⚠️ IT IS KEPT SEPARATELY FROM
|
||||
# `self.target` BECAUSE THEY ARE DIFFERENT FACTS: `self.target` is what
|
||||
# is STANDING ON THE WIRE - held at `shadow.WIRE_W` for the whole of a
|
||||
# shadow run, and re-seeded to it at the end of every shadow cycle so
|
||||
# the loop and the sign watchdogs track reality - and this is what a
|
||||
# live controller WOULD have put there. Folding them into one field is
|
||||
# how a shadow run starts telling an operator a number nothing is
|
||||
# doing.
|
||||
self.shadow_would_w = None
|
||||
# SAFETY-47. Was the number above SIZED FROM THE BILLED-QUARTER METER
|
||||
# on the cycle it was recorded? ⚠️ IT IS RECORDED WHERE THE DECISION
|
||||
# WAS TAKEN, NOT RE-DERIVED WHERE THE PAGE IS RENDERED. The status page
|
||||
# runs on its own task and `self.peak_guard` has moved on by then, so a
|
||||
# page that asked the question again would be answering it about a
|
||||
# different cycle from the one whose watts it is displaying - which is
|
||||
# the same two-facts-one-field defect the comment above this one is
|
||||
# about. ⚠️ False AND NOT None BEFORE THE FIRST SHADOW CYCLE, unlike
|
||||
# the field above: this one is a claim ABOUT a number, and there is no
|
||||
# number yet, so the honest value is "nothing to disclose". The row's
|
||||
# `would_w is None` branch renders no watts at all there.
|
||||
self.shadow_sized_from_meter = False
|
||||
# Edge state for the action log, both of them. `_shadow_was` is None
|
||||
# until the first cycle, so an install that comes up IN shadow mode
|
||||
# says so in the log it will be read from rather than leaving the
|
||||
# operator to infer it from a page nobody is looking at yet. Coming up
|
||||
# LIVE is silent - see `_shadow_settle()`, which says why.
|
||||
self._shadow_was = None
|
||||
self._shadow_winner = None
|
||||
self.target = 0.0
|
||||
self.sat_count = 0
|
||||
self.i_w = 0.0 # the loop's integrator, carried between cycles
|
||||
# TEL-10. What the last cycle's loop did, kept for publication. Seeded
|
||||
# to the SAME values the "stopped or blind" branch of `cycle()` writes,
|
||||
# because before the first cycle the loop has not run - which is the
|
||||
# thing that branch is for.
|
||||
self.loop_i_w = 0.0
|
||||
self.loop_i_lead = 0.0
|
||||
self.loop_reason = "not-running"
|
||||
self.loop_w = None # what the loop asked for last cycle, or None
|
||||
# Did the grid-following loop actually win the LAST arbitration? That is
|
||||
# the question SAFETY-02 has to ask about the command standing on the
|
||||
@@ -904,6 +942,12 @@ class Controller:
|
||||
# None (startup) or the PREVIOUS cycle's decision (an input hold), and
|
||||
# the status row rendered all three identically.
|
||||
self.arb_at = None
|
||||
# SAFETY-45/47. The billed-quarter charging headroom the arbitrage step
|
||||
# was handed on the cycle above, or None when nothing measured it. It
|
||||
# is CARRIED rather than recomputed for `arbitrage.sized_from_meter()`,
|
||||
# because `self.peak_guard` is a different cycle's decision by the time
|
||||
# the status page renders.
|
||||
self.arb_headroom_w = None
|
||||
# TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at`
|
||||
# is the exact, derived statement of "the latest cycle did not get as
|
||||
# far as the arbitrage step", which is the fact the *Arbitrage* row
|
||||
@@ -1725,6 +1769,7 @@ class Controller:
|
||||
inv.soc = await self.hass.number(inv.soc_entity)
|
||||
raw = await self.hass.number(inv.batt_entity, inv.batt_invert)
|
||||
was_stale = inv.batt_fresh.stale
|
||||
was_releases = inv.batt_fresh.releases
|
||||
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
|
||||
# ⚠️ `inv.batt` IS THE GATED VALUE. A stale register is a MISSING input,
|
||||
# not a measurement - `inputs_ok` sees the None, the site holds for
|
||||
@@ -1733,11 +1778,73 @@ class Controller:
|
||||
inv.batt = inv.batt_fresh.reading()
|
||||
if inv.batt_fresh.stale and not was_stale:
|
||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason)
|
||||
# ⚠️ SAFETY-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING
|
||||
# ELSE IN IT. The escape clause used to read "It clears itself the
|
||||
# moment the register reports a different value", full stop - and
|
||||
# that was FALSE for the state this same line announces, because the
|
||||
# 0 W it announces in the sentence before is exactly what stops the
|
||||
# register ever being asked to report anything different. The live
|
||||
# site sat in it for 790 s on 2026-09-21 and was freed by a human.
|
||||
# ⚠️ THE DIAGNOSIS HALF IS SAFETY-56'S AND IS DELIBERATELY UNTOUCHED
|
||||
# HERE - "is not following its own command" is wrong in a different
|
||||
# way (a pack the BMS is refusing is not a faulty register) and that
|
||||
# card is rewriting it. Two cards editing one sentence is how one of
|
||||
# them silently loses.
|
||||
_LOG.error("SAFETY-37: %s battery-power register is not following "
|
||||
"its own command - %s. Treating it as MISSING, which "
|
||||
"commands 0 W. It clears itself the moment the register "
|
||||
"reports a different value.",
|
||||
inv.name, inv.batt_fresh.reason)
|
||||
"reports a different value, and failing that %.0f s "
|
||||
"after the standing command falls below %.0f W "
|
||||
"(SAFETY-57: a verdict whose own consequence is a 0 W "
|
||||
"command cannot be tested again from inside it).",
|
||||
inv.name, inv.batt_fresh.reason,
|
||||
inv.batt_fresh.bound.release_s,
|
||||
inv.batt_fresh.bound.move_w)
|
||||
elif inv.batt_fresh.releases > was_releases:
|
||||
# ⚠️ SAFETY-57. REPORTED, NEVER SILENT. A latch that lets go on a
|
||||
# timer and says nothing is a latch no operator can audit - and this
|
||||
# one is withdrawing a SITE STOP. `release_note()` is written to say
|
||||
# that nothing has been learned about the register, because nothing
|
||||
# has.
|
||||
self.log_event(f"STALE WITHDRAWN {inv.name}")
|
||||
_LOG.warning("SAFETY-57: %s",
|
||||
inv.batt_fresh.release_note(inv.name))
|
||||
|
||||
@property
|
||||
def shadow(self) -> bool:
|
||||
"""SAFETY-10. Is the battery being left alone right now?
|
||||
|
||||
⚠️ A PROPERTY, NOT A FIELD, AND FOR THE REASON `self.units` IS ONE.
|
||||
Three surfaces ask this question at three different moments - the
|
||||
cycle, the write, and the status page rendered between two cycles - and
|
||||
a cached flag is a fourth answer that can disagree with the option and
|
||||
the button it is derived from. Derived, they cannot.
|
||||
|
||||
⚠️ AND THE WRITE GATE ASKS THIS, NOT A PARAMETER PASSED DOWN TO IT. A
|
||||
suppression that had to be threaded through `write_setpoint()` would be
|
||||
absent from every call site that forgot it - the heartbeat, the stop
|
||||
button, `shutdown()` - which is exactly the shape of hole this add-on's
|
||||
single choke point exists to close.
|
||||
"""
|
||||
return shadow.engaged(self.o, control_started=self.auto)
|
||||
|
||||
def shadow_latches(self) -> tuple:
|
||||
"""The failsafe latches that are UP, named, for the shadow banner.
|
||||
|
||||
A latch that is standing while the controller is inert is standing when
|
||||
it stops being inert. `shadow.banner()` carries them so an operator who
|
||||
turns shadow mode off is not handed a battery that will not move and a
|
||||
page that never mentioned why.
|
||||
"""
|
||||
out = []
|
||||
if self.circ_wd.tripped:
|
||||
out.append("circulating power")
|
||||
if self.site_sign_wd.tripped and len(self.inverters) > 1:
|
||||
out.append("site sign watchdog")
|
||||
for inv in self.inverters:
|
||||
if inv.sign_wd.tripped:
|
||||
out.append(f"{inv.name} sign watchdog")
|
||||
return tuple(out)
|
||||
|
||||
def write_refusal(self, value: float, inv=None) -> str | None:
|
||||
"""Why `value` may NOT be written to `inv` right now, or None if it may.
|
||||
@@ -1930,6 +2037,40 @@ class Controller:
|
||||
self.log_event(f"{inv.name}: {result.describe()}")
|
||||
value = result.applied_w
|
||||
|
||||
# --- SAFETY-10: AND THIS IS THE LAST THING BEFORE THE WIRE -----------
|
||||
# Shadow mode runs the whole decision path and commands nothing. The
|
||||
# suppression is HERE, in the one method every write in this add-on
|
||||
# reaches - the cycle, the heartbeat, the stop button, shutdown() and
|
||||
# maint_abort() alike - so there is no call site that can route round
|
||||
# it. app/shadow.py carries the whole argument; three things about the
|
||||
# placement belong here:
|
||||
#
|
||||
# ⚠️ IT IS A SUBSTITUTION, NOT A RETURN, exactly like the two refusals
|
||||
# above it. Returning would leave the inverter LATCHED ON ITS LAST
|
||||
# COMMAND with the ESP32's heartbeat still being fed - a battery held
|
||||
# at whatever it was doing when shadow mode was switched on, under a
|
||||
# status page saying nothing is being commanded. That is the failure
|
||||
# this add-on keeps finding, and shadow mode must not be a new way to
|
||||
# reach it. `shadow.WIRE_W` is 0.0 W and it is REFRESHED on the
|
||||
# ordinary heartbeat, which is also what firmware/goodwe-master.yaml's
|
||||
# 30 s stale-input watchdog requires of us.
|
||||
#
|
||||
# ⚠️ IT IS BELOW SAFETY-28's GATE AND BELOW SAFETY-03's CLAMP, so both
|
||||
# still grade the number the decision path actually produced. A shadow
|
||||
# run that could not say "it would have hit the rating here" would be
|
||||
# answering a different question from the one it is for.
|
||||
#
|
||||
# ⚠️ IT IS NOT A SECOND WRITER. No claim is filed, no transport is
|
||||
# opened and no arithmetic of its own reaches the wire: one actuator,
|
||||
# one writer, and this is that writer emitting the value it emits for
|
||||
# every other refusal.
|
||||
if self.shadow and value != shadow.WIRE_W:
|
||||
_LOG.info("SHADOW MODE: not commanding %+.0f W to %s - holding "
|
||||
"%+.0f W. The decision was reached and recorded; "
|
||||
"nothing was sent to the inverter.",
|
||||
value, inv.name, shadow.WIRE_W)
|
||||
value = shadow.WIRE_W
|
||||
|
||||
changed = (inv.last_write is None or value != inv.last_write)
|
||||
if not (changed or force):
|
||||
return
|
||||
@@ -2549,7 +2690,16 @@ class Controller:
|
||||
# operator switching control off for the whole site; "inputs missing" is
|
||||
# the SITE's own telemetry gone - the meter this controller regulates
|
||||
# against. Neither is about a pack, and both must stop every inverter.
|
||||
if not self.auto:
|
||||
# ⚠️ SAFETY-10: `and not self.shadow`, AND IT IS THE ONE FAILSAFE THIS
|
||||
# CARD CHANGES. `P_SAFETY` out-ranks every other claim, so leaving the
|
||||
# stop claim in while shadow mode is engaged would make the recorded
|
||||
# decision "stopped" on every cycle - a shadow run that answers the
|
||||
# wrong question, which is the same as no shadow run. The actuator ends
|
||||
# at the same number either way, and the substitution in `_write_unit()`
|
||||
# that replaces it is STRICTLY STRONGER than this claim: a claim can be
|
||||
# out-ranked by a higher priority, and the wire gate cannot be
|
||||
# out-ranked by anything.
|
||||
if not self.auto and not self.shadow:
|
||||
claims.append(Claim.set("safety", P_SAFETY, 0.0, "stopped",
|
||||
actuator=self.actuator_of("safety")))
|
||||
elif not self.inputs_ok:
|
||||
@@ -2720,12 +2870,35 @@ class Controller:
|
||||
# `arb_at` is then the age the status row reports rather than a
|
||||
# decision presented as current.
|
||||
self.arb, self.arb_at = None, now
|
||||
self.arb_headroom_w = None
|
||||
if arbitrage.enabled(self.o):
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
|
||||
# SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED. One evaluation of
|
||||
# the shipped expression feeds both the sizing below and
|
||||
# `arbitrage.sized_from_meter()` in `_shadow_settle()`, so the
|
||||
# caveat on the recorded watts can never be answered from a
|
||||
# different cycle's guard than the watts were.
|
||||
self.arb_headroom_w = peakguard.charge_headroom_w(self.peak_guard)
|
||||
self.arb = arbitrage.decide(
|
||||
self.fresh_tariff_plan(now), now,
|
||||
peak_guard_active=arbitrage.guard_state(self.peak_guard),
|
||||
# SAFETY-45. THE SAME GUARD'S SECOND ANSWER, AND THE CONTINUOUS
|
||||
# ONE. `guard_state()` is a switch and it was the whole loop:
|
||||
# the guard engaged on a 5 W floor, the trade was refused
|
||||
# outright, the loop took the actuator, the meter fell, the
|
||||
# guard released and the trade came back AT max_w. Measured on
|
||||
# TEST-23 stage 3 at the shipped peak_cap_w/max_w: 28 charge
|
||||
# onsets an hour, grid -456 <-> +3200 W. This is the watts the
|
||||
# billed quarter can still afford, so the buy leg is CLAMPED
|
||||
# instead - the shape control.maintenance_charge_floor() has
|
||||
# always given a maintenance charge, off the MEASURED guard
|
||||
# rather than off the optional peak_forecast_entity.
|
||||
# ⚠️ SAME ORDERING ARGUMENT AS THE LINE ABOVE: this reads the
|
||||
# peak block's conclusion, ~50 lines up, so it must stay after
|
||||
# it. Evaluated earlier it would size this quarter's trade from
|
||||
# the previous cycle's quarter.
|
||||
charge_headroom_w=self.arb_headroom_w,
|
||||
# The emptiest pack again, and for the sell side that is the
|
||||
# right reduction. ⚠️ THE BUY SIDE WANTS THE FULLEST, and there
|
||||
# is one `soc` argument - `pack_floor_soc` names that residual
|
||||
@@ -2877,7 +3050,14 @@ class Controller:
|
||||
inv)))
|
||||
|
||||
# --- the grid-following loop -----------------------------------------
|
||||
if self.auto and self.inputs_ok:
|
||||
# ⚠️ SAFETY-10: `or self.shadow`. The loop is the leg a shadow run most
|
||||
# needs and the one that is switched off by `auto_start: false`, so
|
||||
# without this an install in `auto` shadow mode would record every
|
||||
# strategy's decision and a permanent silence where the grid-following
|
||||
# loop should be. It is safe because the loop's `prev_w` is
|
||||
# `self.target`, which the end of this method re-seeds to what is
|
||||
# actually standing on the wire.
|
||||
if (self.auto or self.shadow) and self.inputs_ok:
|
||||
# ⚠️ prev is the ARBITER's last output, not the loop's own last
|
||||
# wish. If something outranked the loop, that is what the hardware
|
||||
# actually did, and the controller must track reality or it jumps
|
||||
@@ -2908,8 +3088,15 @@ class Controller:
|
||||
# level: the two halves of §6.2's identity break in opposite
|
||||
# directions. See `site_batt_w` and test_alloc.py part 8.
|
||||
site_batt = self.site_batt_w
|
||||
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
|
||||
# AGAINST. `self.target` is reassigned further down this method, so
|
||||
# a `decision.i_w - self.target` written at publish time would
|
||||
# subtract a DIFFERENT cycle's command and call the answer a lead.
|
||||
# The published lead is built from the value `compute()` was
|
||||
# actually handed, on the line that hands it over.
|
||||
loop_prev = self.target
|
||||
decision = compute(
|
||||
prev_w=self.target,
|
||||
prev_w=loop_prev,
|
||||
grid_w=self.grid,
|
||||
actual_w=site_batt,
|
||||
tuning=self.tuning,
|
||||
@@ -2922,6 +3109,41 @@ class Controller:
|
||||
self.sat_count = decision.sat_count
|
||||
self.i_w = decision.i_w
|
||||
self.loop_w = decision.target_w
|
||||
# --- TEL-10 ------------------------------------------------------
|
||||
# ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT
|
||||
# THE CARD IS. `self.i_w` is carried between cycles and published
|
||||
# nowhere - no entity, no attribute, no log line - so the only way
|
||||
# anyone has ever asked what it was doing is to RECONSTRUCT it from
|
||||
# the grid trace and a replay. That reconstruction reproduced about
|
||||
# a third of the engagements it was checked against and was thrown
|
||||
# away; a card that has to be argued from a discarded reconstruction
|
||||
# is a card that cannot be argued.
|
||||
#
|
||||
# Three rows, and the third is not derivable from the first two by
|
||||
# anyone reading the dashboard afterwards:
|
||||
#
|
||||
# `loop_i` the integrator AFTER this cycle - `Decision.i_w`,
|
||||
# which its own docstring says is the value BEFORE
|
||||
# the output clamp, the slew limit and quantisation.
|
||||
# It is therefore NOT `setpoint` and must never be
|
||||
# published from it.
|
||||
# `loop_lead` how far the integrator stands AHEAD of the command
|
||||
# it was computed against. This is the quantity
|
||||
# SAFETY-48 bounds, and `integrator_max_w / slew_w`
|
||||
# is the number of cycles it takes to unwind.
|
||||
# `loop_reason` `compute()`'s OWN reason string - `deadband`,
|
||||
# `i-clamped`, `clamped`, `slew-limited`,
|
||||
# `saturated-freeze`, `freeze-released`, `tracking`.
|
||||
#
|
||||
# ⚠️ AND `loop_reason` IS NOT `self.reason`. `self.reason` is the
|
||||
# ARBITER's sentence (`resolution.explain()`, e.g. "loop -> 1200 W")
|
||||
# and it does not carry the loop's own word at all: the Claim filed
|
||||
# below takes `decision.reason` and `Resolution.explain()` prints the
|
||||
# winner's NAME, not the winner's reason. The status page's "Why"
|
||||
# row is the arbiter's sentence and stays that way.
|
||||
self.loop_i_w = decision.i_w
|
||||
self.loop_i_lead = decision.i_w - loop_prev
|
||||
self.loop_reason = decision.reason
|
||||
# --- SAFETY-03 ---------------------------------------------------
|
||||
# ⚠️ READ THE BOOLEANS, NOT `decision.reason`. On a default install
|
||||
# the integrator bound is what bites and the output clamp is
|
||||
@@ -2946,6 +3168,15 @@ class Controller:
|
||||
# Stopped or blind: no accumulation may survive the outage.
|
||||
self.i_w = 0.0
|
||||
self.loop_w = None
|
||||
# TEL-10, and it is the same zero the line above writes rather than
|
||||
# a second opinion about it. ⚠️ `not-running` rather than a stale
|
||||
# `tracking`: a reason row that keeps repeating the last word the
|
||||
# loop said while the loop is not running is a row that reads as
|
||||
# health, which is the failure `publish()`'s own None-dropping note
|
||||
# is about one field over.
|
||||
self.loop_i_w = 0.0
|
||||
self.loop_i_lead = 0.0
|
||||
self.loop_reason = "not-running"
|
||||
# A loop that is not running cannot be winding. Clearing this here
|
||||
# rather than letting the last value stand is what makes the alarm
|
||||
# de-assert when control is stopped.
|
||||
@@ -3211,6 +3442,133 @@ class Controller:
|
||||
# reads only `self.p1`, which no line in this method writes.
|
||||
self.peak.sync(self.p1)
|
||||
|
||||
# --- SAFETY-10, AND IT IS LAST FOR A REASON THAT IS NOT TIDINESS -----
|
||||
# Every guard above has now read `self.target` - the resolved intent -
|
||||
# and SAFETY-18 in particular grades exactly that. Re-seeding before
|
||||
# them would hand each one a 0 W that no decision produced. Re-seeding
|
||||
# AFTER them hands the NEXT cycle, and the sign watchdogs, what is
|
||||
# actually standing on the wire. See `_shadow_settle()`.
|
||||
self._shadow_settle(resolution)
|
||||
|
||||
def _shadow_settle(self, resolution) -> None:
|
||||
"""SAFETY-10. Record the decision, then re-seed the carried command.
|
||||
|
||||
TWO JOBS, AND THEY ARE IN THIS ORDER BECAUSE THE SECOND DESTROYS THE
|
||||
FIRST'S EVIDENCE.
|
||||
|
||||
1. THE RECORD. A shadow run is only worth running if it answers "what
|
||||
would it have done, and why". `self.shadow_would_w` and
|
||||
`Resolution.explain()` are that answer, and the action log gets one
|
||||
line per CHANGE OF DECISION rather than one per cycle - the log
|
||||
holds 40 rows, and a level-logged shadow run would flush the context
|
||||
of its own findings inside four minutes at a 15 s cycle floor. The
|
||||
same edge rule the peak guard and the arbitrage strategy already
|
||||
use, for the same reason.
|
||||
|
||||
2. THE RE-SEED. `self.target` and each `inv.target` are set back to
|
||||
`shadow.WIRE_W` - what the write gate actually put on the wire.
|
||||
|
||||
⚠️ WHY THE RE-SEED IS A SAFETY PROPERTY AND NOT BOOKKEEPING. Three
|
||||
machines read the carried command as a statement about the HARDWARE:
|
||||
|
||||
* `compute(prev_w=self.target, ...)` - the loop's slew limiter and
|
||||
its integrator. Left holding a shadow decision, the first cycle
|
||||
after shadow mode is turned off would slew from a base that never
|
||||
existed: up to a full `slew_w` step onto a battery sitting at 0 W.
|
||||
Re-seeded, the exit is safe BY CONSTRUCTION and needs no separate
|
||||
transition code - the carried command already equals the standing
|
||||
one.
|
||||
* `Inverter.standing_w()`, which is what SAFETY-02/30's sign
|
||||
watchdogs grade. Fed a shadow decision they would compare a number
|
||||
against a meter that never saw it, disagree for as long as the
|
||||
decision asked for power, and LATCH A TRIP THAT SURVIVES INTO LIVE
|
||||
OPERATION AND A RESTART. Shadow mode would manufacture the failsafe
|
||||
it exists to help debug. ⚠️ THIS ONE IS ALREADY HELD BY THE WIRE
|
||||
GATE - `standing_w()` prefers `last_write`, and that is the 0.0 W
|
||||
the gate accepted - so the re-seed is the SECOND rung, covering
|
||||
the fallback before any write has been accepted. Two rungs
|
||||
answering one question is MUTATION.md's masking shape, and the
|
||||
mutation that reproduces the hazard has to defeat BOTH: driven
|
||||
singly, dropping this line leaves the detector inert and names
|
||||
nothing about the latch. Measured.
|
||||
* SAFETY-42's delivery watchdog compares `inv.target` against what
|
||||
the pack is producing. A share nothing was told to produce is a
|
||||
delinquency report on every cycle of every shadow run.
|
||||
|
||||
⚠️ AND EVERY CYCLE'S DECISION IS THEREFORE COMPUTED FROM WHERE THE
|
||||
PLANT REALLY IS, not from a counterfactual plant that drifts further
|
||||
from reality every tick. "What would this controller command NOW,
|
||||
given this meter and a battery at 0 W" is a true statement about the
|
||||
present; "what would the eleventh cycle of a run that never happened
|
||||
have commanded" is not a statement about anything.
|
||||
"""
|
||||
engaged = self.shadow
|
||||
if engaged != self._shadow_was:
|
||||
# ⚠️ `first` EXISTS SO A LIVE INSTALL'S FIRST CYCLE IS SILENT. The
|
||||
# edge is against `None` on cycle one, and an install that comes up
|
||||
# CONTROLLING has nothing to report - a "shadow mode off" line at
|
||||
# every start is one more row of boilerplate in a 40-row log, and
|
||||
# it was measured pushing a real clamp episode's context out of
|
||||
# test_clamp.py part 2's ring. Coming up IN shadow mode is the
|
||||
# other direction and it is always worth a line: it is the log an
|
||||
# operator reads to find out why nothing is being commanded.
|
||||
first = self._shadow_was is None
|
||||
self._shadow_was = engaged
|
||||
if engaged:
|
||||
self.log_event(
|
||||
"SHADOW MODE ON - " + shadow.why(
|
||||
self.o, control_started=self.auto)
|
||||
+ f"; inverter held at {shadow.WIRE_W:.0f} W")
|
||||
_LOG.warning(
|
||||
"SAFETY-10 SHADOW MODE IS ON. The full decision path runs "
|
||||
"and NOTHING is commanded: every inverter is held at "
|
||||
"%+.0f W on the ordinary heartbeat. %s", shadow.WIRE_W,
|
||||
shadow.why(self.o, control_started=self.auto))
|
||||
elif not first:
|
||||
self.log_event(
|
||||
"SHADOW MODE OFF - the battery is being controlled again, "
|
||||
f"resuming from {shadow.WIRE_W:.0f} W")
|
||||
_LOG.warning(
|
||||
"SAFETY-10 shadow mode is OFF. This controller is "
|
||||
"commanding the battery again, starting from the "
|
||||
"%+.0f W that was standing on the wire.", shadow.WIRE_W)
|
||||
if not engaged:
|
||||
self.shadow_would_w = None
|
||||
self.shadow_sized_from_meter = False
|
||||
self._shadow_winner = None
|
||||
return
|
||||
self.shadow_would_w = float(resolution.target_w)
|
||||
# SAFETY-47. ⚠️ NO LOGIC HERE, DELIBERATELY. All three rungs - the
|
||||
# arbiter's winner, the leg being a CHARGE, and the headroom having
|
||||
# been MEASURED - live inside `arbitrage.sized_from_meter()`, which
|
||||
# says why, including why it is NOT keyed on whether the trade was
|
||||
# capped. A rung written at a call site is a rung no check can reach
|
||||
# with the other two clear, which is TEST-16's rule.
|
||||
self.shadow_sized_from_meter = arbitrage.sized_from_meter(
|
||||
self.arb, self.arb_headroom_w, winner=resolution.winner)
|
||||
# ⚠️ THE WINNER AND THE ROUNDED WATTS, NOT THE WATTS ALONE. A decision
|
||||
# that moves from the loop to the peak guard at the same number is a
|
||||
# different decision and the operator needs the line; a loop target
|
||||
# that wanders by a watt is not, and 15 s of those would be the whole
|
||||
# log.
|
||||
# ⚠️ SAFETY-47 ADDS THE CAVEAT FLAG TO THE EDGE. The same winner at the
|
||||
# same watts with and without the caveat are two different claims about
|
||||
# the same number, and an edge key blind to that would print the first
|
||||
# one and silently keep it standing. It cannot flood: the flag only
|
||||
# moves when arbitrage starts or stops owning the resolution, or when
|
||||
# the guard starts or stops measuring the quarter, and both of those
|
||||
# move the winner or the watts as well on every cycle measured.
|
||||
edge = (resolution.winner, round(float(resolution.target_w)),
|
||||
self.shadow_sized_from_meter)
|
||||
if edge != self._shadow_winner:
|
||||
self._shadow_winner = edge
|
||||
self.log_event(shadow.log_line(
|
||||
would_w=resolution.target_w, reason=resolution.explain(),
|
||||
sized_from_meter=self.shadow_sized_from_meter))
|
||||
self.target = shadow.WIRE_W
|
||||
for inv in self.inverters:
|
||||
inv.target = shadow.WIRE_W
|
||||
|
||||
async def check_output_clamp(self) -> None:
|
||||
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
|
||||
|
||||
@@ -4000,6 +4358,14 @@ class Controller:
|
||||
or (self.site_sign_wd.tripped
|
||||
and len(self.inverters) > 1)
|
||||
or self.circ_wd.tripped
|
||||
# SAFETY-10: "shadow" is its own value and not a
|
||||
# flavour of "stopped". A fleet dashboard reporting a
|
||||
# shadow install as `running` would be calling a
|
||||
# battery nobody is controlling healthy; one reporting
|
||||
# it as `stopped` would lose the fact that the decision
|
||||
# path is live and being recorded. ⚠️ BELOW `failsafe`:
|
||||
# a latch that is up is up, and shadow does not clear it.
|
||||
else "shadow" if self.shadow
|
||||
else "running" if self.auto else "stopped"),
|
||||
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
|
||||
# `site_circulating_w`. `publish()` drops None values, so a cycle
|
||||
@@ -4007,6 +4373,14 @@ class Controller:
|
||||
# rather than publishing a zero that would read as health.
|
||||
"circulation": (None if self.site_circulating_w is None
|
||||
else round(self.site_circulating_w)),
|
||||
# TEL-10. The loop's own three, written from the fields `cycle()`
|
||||
# recorded and not re-derived here - the page and the publisher run
|
||||
# on their own tasks and `self.target` has moved on by the time
|
||||
# either of them looks (SAFETY-47's note on `sized_from_meter`, one
|
||||
# field up, is the same hazard).
|
||||
"loop_i": round(self.loop_i_w, 1),
|
||||
"loop_lead": round(self.loop_i_lead, 1),
|
||||
"loop_reason": self.loop_reason,
|
||||
}
|
||||
# ⚠️ ONLY when P1 ingestion is actually running. The ESP32's stale-input
|
||||
# watchdog subscribes to sensor.goodwe_p1_sample_age - see the entity-id
|
||||
@@ -4500,7 +4874,24 @@ class Controller:
|
||||
def status_payload(self) -> dict:
|
||||
checks = self.checks()
|
||||
bad = [c for c in checks if not c["ok"] and not c["warn"]]
|
||||
if self.circ_wd.tripped:
|
||||
# --- SAFETY-10, AND IT IS FIRST IN THE CASCADE -----------------------
|
||||
# "The battery is NOT being controlled" is the one sentence on this
|
||||
# page that may never be displaced by another condition. A banner that
|
||||
# can be out-ranked does not STATE a thing plainly; it states it
|
||||
# whenever nothing else is happening, which is the half of the time
|
||||
# nobody needs telling.
|
||||
#
|
||||
# ⚠️ AND IT CARRIES A STANDING LATCH RATHER THAN HIDING ONE. The three
|
||||
# `bad` branches below all say some version of "held at 0 W", which is
|
||||
# still true in shadow mode - but an operator who turns shadow mode off
|
||||
# with a latch up gets a battery that will not move and a page that
|
||||
# never mentioned it. `shadow.banner()` names the latch and goes red
|
||||
# for it, so nothing this cascade would have said is lost.
|
||||
_shadow = shadow.banner(self.o, control_started=self.auto,
|
||||
latched=self.shadow_latches())
|
||||
if _shadow is not None:
|
||||
level, banner = _shadow
|
||||
elif self.circ_wd.tripped:
|
||||
# ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision. Both are
|
||||
# latched P_SAFETY stops, so the site is at 0 W either way and the
|
||||
# banner can only carry one sentence. This one goes first because it
|
||||
@@ -4600,6 +4991,21 @@ class Controller:
|
||||
("State of charge", "—" if self.pack_floor_soc is None
|
||||
else f"{self.pack_floor_soc:g} %"),
|
||||
("Commanded", w(self.target)),
|
||||
# SAFETY-10, and it sits BETWEEN the two rows it reconciles.
|
||||
# "Commanded 0 W" above and a "Why" below that explains a trade is
|
||||
# the exact pair an operator would otherwise have to guess at; this
|
||||
# row is what makes the two of them consistent, and it is rendered
|
||||
# in EVERY state - including `off`, where it says the battery IS
|
||||
# being controlled - because a surface whose ABSENCE is how you
|
||||
# learn something is a surface you have to already know about.
|
||||
("Shadow mode", shadow.words(
|
||||
self.o, control_started=self.auto,
|
||||
would_w=self.shadow_would_w,
|
||||
# SAFETY-47. THE FLAG THE CYCLE RECORDED, NOT ONE DERIVED HERE
|
||||
# - the page runs on its own task and `self.peak_guard` has
|
||||
# moved on. See `Controller.__init__`.
|
||||
sized_from_meter=self.shadow_sized_from_meter,
|
||||
reason=self.reason)),
|
||||
("Why", self.reason),
|
||||
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
|
||||
else f"{self.maint.phase} ({self.maint_unit.name})"),
|
||||
|
||||
@@ -85,6 +85,36 @@ 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"),
|
||||
]
|
||||
|
||||
BASE = "goodwe_ctl"
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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."]
|
||||
@@ -334,6 +334,7 @@ from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
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)
|
||||
@@ -840,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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.9.0"
|
||||
version: "0.10.1"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -746,6 +746,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
|
||||
# ==========================================================================
|
||||
@@ -1065,6 +1097,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
|
||||
# ==========================================================================
|
||||
|
||||
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.
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.
@@ -0,0 +1,799 @@
|
||||
{
|
||||
"generated": "2026-09-18T23:52:32.622227+02:00",
|
||||
"generator": "tools/replay_recorded_loop.py --export",
|
||||
"source": "http://192.168.2.158:8123/api/history/period",
|
||||
"timezone": "Europe/Brussels",
|
||||
"entities": {
|
||||
"grid": "sensor.p1_meter_active_power",
|
||||
"batt": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"setpoint": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"soc": "sensor.goodwe_rs485_controller_goodwe_battery_soc"
|
||||
},
|
||||
"format": "{\"t\": <ISO-8601 with offset>, \"v\": <state string>} per line, gzip, mtime 0",
|
||||
"days": [
|
||||
{
|
||||
"date": "2026-09-02",
|
||||
"window": [
|
||||
"2026-09-02T00:00:00+02:00",
|
||||
"2026-09-03T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 16515,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-02-grid.jsonl.gz",
|
||||
"bytes_raw": 922104,
|
||||
"sha256_raw": "8e61b964fd9ebe047dfcc70f32f08ce2bebe7ac3ffe6f97000e3526a5637e9c4"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 3299,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-02-batt.jsonl.gz",
|
||||
"bytes_raw": 187888,
|
||||
"sha256_raw": "0e2f392158a7fd2dc77287d3f94de1edab81c6a9a7e16b0f59f525145fe6a61b"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 4683,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-02-setpoint.jsonl.gz",
|
||||
"bytes_raw": 267407,
|
||||
"sha256_raw": "2f486e27bffdce82f16d20104fcdbbe7057ebd4c025c9ac00eb59531a44d1ea1"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 130,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-02-soc.jsonl.gz",
|
||||
"bytes_raw": 7141,
|
||||
"sha256_raw": "090d14866552b4dc27cf0c0505cfb5c4d7e7223b25a5af504598618aeba317ed"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-03",
|
||||
"window": [
|
||||
"2026-09-03T00:00:00+02:00",
|
||||
"2026-09-04T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 16419,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-03-grid.jsonl.gz",
|
||||
"bytes_raw": 919508,
|
||||
"sha256_raw": "fd4230b864a2f789fcac6da7bbe87f0445fedbc81818d3c4a1c1a41655ed5b4f"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 3434,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-03-batt.jsonl.gz",
|
||||
"bytes_raw": 194783,
|
||||
"sha256_raw": "3a12c37dfe33a571b68f27a68d88b38170f4acd39f4c9574f58de7a9c358fe8e"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 5145,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-03-setpoint.jsonl.gz",
|
||||
"bytes_raw": 292456,
|
||||
"sha256_raw": "c8c70155151c44ef92cd7048dd5642838e2fcd5dfa4989db30d39f91e9031aa2"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 69,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-03-soc.jsonl.gz",
|
||||
"bytes_raw": 3786,
|
||||
"sha256_raw": "a34ccf6a175d0b60b0852a305be69457c0e0a42f3f9a1c777ab100039cc57c24"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-04",
|
||||
"window": [
|
||||
"2026-09-04T00:00:00+02:00",
|
||||
"2026-09-05T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 16098,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-04-grid.jsonl.gz",
|
||||
"bytes_raw": 897558,
|
||||
"sha256_raw": "4c1528faa35778a3fe6a7b613482af4ef854e6b1b92ba8a6c72aa231158cf93f"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 3009,
|
||||
"non_numeric_states": 1,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-04-batt.jsonl.gz",
|
||||
"bytes_raw": 170410,
|
||||
"sha256_raw": "2ee54e9c6670ebc87bb120bb98a572dbe6b129c3a2e3aa7802c5993ecab03500"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 4300,
|
||||
"non_numeric_states": 1,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-04-setpoint.jsonl.gz",
|
||||
"bytes_raw": 244058,
|
||||
"sha256_raw": "7130b266efe61447276183a8da1b79e14e1e2a56b92b9884c5cb3c1d277cf002"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 47,
|
||||
"non_numeric_states": 1,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-04-soc.jsonl.gz",
|
||||
"bytes_raw": 2583,
|
||||
"sha256_raw": "833ed5f48b3fac83beb65cb1c1b00ed66597505bcab37813997c3f3f908b72bd"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-05",
|
||||
"window": [
|
||||
"2026-09-05T00:00:00+02:00",
|
||||
"2026-09-06T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 16448,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-05-grid.jsonl.gz",
|
||||
"bytes_raw": 920613,
|
||||
"sha256_raw": "03cb4e2b3e075b060a47bf5d3cb83d5c55d0055512dcec800a4aaf5bbae91b3e"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 4409,
|
||||
"non_numeric_states": 6,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-05-batt.jsonl.gz",
|
||||
"bytes_raw": 251332,
|
||||
"sha256_raw": "a11c722ff106df1675e57e9d98af37ec22bf61d509fa847c30c31a50165c07a1"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 6339,
|
||||
"non_numeric_states": 6,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-05-setpoint.jsonl.gz",
|
||||
"bytes_raw": 361644,
|
||||
"sha256_raw": "7929161bfaf654dab097d02219b3a1c83d1530e78a0e38776115792afd45fb8d"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 132,
|
||||
"non_numeric_states": 6,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-05-soc.jsonl.gz",
|
||||
"bytes_raw": 7289,
|
||||
"sha256_raw": "139d883516de462655fe34902c30cc09a329ace6fbb248b06f8ffd8b1d6edaa2"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-06",
|
||||
"window": [
|
||||
"2026-09-06T00:00:00+02:00",
|
||||
"2026-09-07T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 16060,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-06-grid.jsonl.gz",
|
||||
"bytes_raw": 899105,
|
||||
"sha256_raw": "7d3e92c36834aeb1992d611cea6d256967585b45c3c9251b00dac676befebbea"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 3569,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-06-batt.jsonl.gz",
|
||||
"bytes_raw": 202858,
|
||||
"sha256_raw": "5030a36a36b9ae6867c582c70fb7cf57b6fe762aa1e52993ab48d4f5c4269ef4"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 5190,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-06-setpoint.jsonl.gz",
|
||||
"bytes_raw": 295576,
|
||||
"sha256_raw": "d361daab3fa61bae75779d574e3619d3cc0908501187f054b16643caa2e0f231"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 83,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-06-soc.jsonl.gz",
|
||||
"bytes_raw": 4556,
|
||||
"sha256_raw": "6ee9813016c7a1bdb62f6f4e892e8c3351491e1c5324c67862c068cebee8c2d1"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"window": [
|
||||
"2026-09-07T00:00:00+02:00",
|
||||
"2026-09-08T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 15512,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-07-grid.jsonl.gz",
|
||||
"bytes_raw": 868060,
|
||||
"sha256_raw": "69ff04ecc0df7faa11824b3a4f2e4bd5a84167aa260386380d842da84da4f152"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 3646,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-07-batt.jsonl.gz",
|
||||
"bytes_raw": 207432,
|
||||
"sha256_raw": "37a68612eadcecf80d6e796f653ae8e4ec9ff98a40a8d560ca18df995b47ad14"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 3521,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-07-setpoint.jsonl.gz",
|
||||
"bytes_raw": 201333,
|
||||
"sha256_raw": "1c0a93b59c46f7338e81a1a8f0609db7e140793bc28c9a66d2087eaf2360eb2b"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 138,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-07-soc.jsonl.gz",
|
||||
"bytes_raw": 7584,
|
||||
"sha256_raw": "bea3599b83e280a11a3a6b83b2626db15acaf04c2d7db16734a1acc76f691bf8"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-08",
|
||||
"window": [
|
||||
"2026-09-08T00:00:00+02:00",
|
||||
"2026-09-09T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 15691,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-08-grid.jsonl.gz",
|
||||
"bytes_raw": 872562,
|
||||
"sha256_raw": "632c58235dde62178358a72c445945829dfbb580cf9dbd1eef32c545ea29154b"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 4451,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-08-batt.jsonl.gz",
|
||||
"bytes_raw": 251748,
|
||||
"sha256_raw": "0d94ce9c4e41e33094fb7a3ffafbec15c9d9ad1b741aaa0b77e39a9ca8b64572"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 5145,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-08-setpoint.jsonl.gz",
|
||||
"bytes_raw": 292178,
|
||||
"sha256_raw": "d29a15925e1a87c57a120828dd6f138b0ee3738f0418f789e6b14451785dcab4"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 118,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-08-soc.jsonl.gz",
|
||||
"bytes_raw": 6481,
|
||||
"sha256_raw": "f07d9cd878763dcda6e8bf58653d260a5d897f4bc65efd446707e0af5a84c813"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-09",
|
||||
"window": [
|
||||
"2026-09-09T00:00:00+02:00",
|
||||
"2026-09-10T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 16017,
|
||||
"non_numeric_states": 1,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-09-grid.jsonl.gz",
|
||||
"bytes_raw": 894526,
|
||||
"sha256_raw": "50924035a087895b89b72f6acf5db24a3b9ed704362968854e1328493e0c7758"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 4124,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-09-batt.jsonl.gz",
|
||||
"bytes_raw": 235314,
|
||||
"sha256_raw": "93e26b5f40b20d1478c457b7cae06f899c2b52f1ce2abba90fe08ca14ad4607e"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 5855,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-09-setpoint.jsonl.gz",
|
||||
"bytes_raw": 334751,
|
||||
"sha256_raw": "d5286bc477b825343804e4fd8eb85e9a35c3a8e98e83abba9df5d2879637bab9"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 123,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-09-soc.jsonl.gz",
|
||||
"bytes_raw": 6757,
|
||||
"sha256_raw": "c4b6b1a3fc3de4e928db2f6ac7520a7556893e1b588d2f57711a4c7ded6646d4"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-10",
|
||||
"window": [
|
||||
"2026-09-10T00:00:00+02:00",
|
||||
"2026-09-11T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 16172,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-10-grid.jsonl.gz",
|
||||
"bytes_raw": 902807,
|
||||
"sha256_raw": "7a388548ce9b10374354afe17ba10567f7a592c38b7614b60e570d12a8c4cc87"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 4322,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-10-batt.jsonl.gz",
|
||||
"bytes_raw": 246291,
|
||||
"sha256_raw": "e01417fb54e163f188feed3aab464cd7e125d1c8c6e2b5ed51066d86f84cc221"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 5450,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-10-setpoint.jsonl.gz",
|
||||
"bytes_raw": 310937,
|
||||
"sha256_raw": "5fd7917fa89459a7f89eb838cc07d09f2dda85964f45df27a16d49a5608af797"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 142,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-10-soc.jsonl.gz",
|
||||
"bytes_raw": 7802,
|
||||
"sha256_raw": "ff94001915949d165377ad6b2ac8dd0f00c84a0cd1c8a83fbddf474543dc1871"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-11",
|
||||
"window": [
|
||||
"2026-09-11T00:00:00+02:00",
|
||||
"2026-09-12T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 15975,
|
||||
"non_numeric_states": 1,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-11-grid.jsonl.gz",
|
||||
"bytes_raw": 889154,
|
||||
"sha256_raw": "335847b7f757a1fa3682be000ef3e77dcf637ccb52b9ad3d739539d1a7fd1384"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 4399,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-11-batt.jsonl.gz",
|
||||
"bytes_raw": 250012,
|
||||
"sha256_raw": "bd91e7a0e33cbec1737fb95019746c44bdf3ff71e71933ff99b45383b3bcef29"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 5519,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-11-setpoint.jsonl.gz",
|
||||
"bytes_raw": 315079,
|
||||
"sha256_raw": "8f538f230ff2076a5a098d27a6d26390c9995e88a14b9b30e7b141bd216b211a"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 124,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-11-soc.jsonl.gz",
|
||||
"bytes_raw": 6812,
|
||||
"sha256_raw": "d090fe60687ce35498afa923089881d1eaec3b50be2604ffe4ac4261c6d7f8b5"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-12",
|
||||
"window": [
|
||||
"2026-09-12T00:00:00+02:00",
|
||||
"2026-09-13T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 16001,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-12-grid.jsonl.gz",
|
||||
"bytes_raw": 893240,
|
||||
"sha256_raw": "3ff17d8fa70133338cd4c54067b7c37d5f33fd971d8037e99b8f829703d42a2d"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 3744,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-12-batt.jsonl.gz",
|
||||
"bytes_raw": 213926,
|
||||
"sha256_raw": "228b3498d915d6b96781dca039899816b4a9c45874af019095cede1128c2d2aa"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 5012,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-12-setpoint.jsonl.gz",
|
||||
"bytes_raw": 287190,
|
||||
"sha256_raw": "bfb3389a021c2b01a005901c961c2c30bb6ca4a42e5e80c00855ea1144f3464a"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 126,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-12-soc.jsonl.gz",
|
||||
"bytes_raw": 6922,
|
||||
"sha256_raw": "ca17b220922550d9d00330f62742edf9b9cdf990cd3647a14a580f3427023092"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-13",
|
||||
"window": [
|
||||
"2026-09-13T00:00:00+02:00",
|
||||
"2026-09-14T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 15912,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-13-grid.jsonl.gz",
|
||||
"bytes_raw": 888912,
|
||||
"sha256_raw": "79a2f5462c396cc26c90c72e2b38390f1665bb41b77fc9120019595228133ca3"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 3432,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-13-batt.jsonl.gz",
|
||||
"bytes_raw": 193080,
|
||||
"sha256_raw": "9410b9b80632c2ad68cd0632a5dd6513077eaecfc7a776986df867f847b19b39"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 3566,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-13-setpoint.jsonl.gz",
|
||||
"bytes_raw": 201353,
|
||||
"sha256_raw": "8173b590ed7a33d441cc0f2fb447ad88ce1c12aef6019155124132f44b11981c"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 80,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-13-soc.jsonl.gz",
|
||||
"bytes_raw": 4391,
|
||||
"sha256_raw": "15a8328cc07964216be4f3a6001fb640937e33f81b13c64e8da0872ab0da8a46"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-14",
|
||||
"window": [
|
||||
"2026-09-14T00:00:00+02:00",
|
||||
"2026-09-15T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 16086,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-14-grid.jsonl.gz",
|
||||
"bytes_raw": 904274,
|
||||
"sha256_raw": "26c255b793432474a45dc6f0fff156f358f8468f2381e8161f4d0812d732e754"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 2679,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-14-batt.jsonl.gz",
|
||||
"bytes_raw": 152706,
|
||||
"sha256_raw": "d2bec4260c3649db865744a93f20c38c8f76b4f09f2e0b2308e61280b415321d"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 4007,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-14-setpoint.jsonl.gz",
|
||||
"bytes_raw": 229164,
|
||||
"sha256_raw": "4b3feb9cf7349bf8d6b3c4b6daa7d8b598f5589e52dfe48c98791d89e5678d79"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 75,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-14-soc.jsonl.gz",
|
||||
"bytes_raw": 4116,
|
||||
"sha256_raw": "31b3d54b0b4ea4e2e5ee5fc0131c05bea5f920996699888ac764b66d19763d89"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-15",
|
||||
"window": [
|
||||
"2026-09-15T00:00:00+02:00",
|
||||
"2026-09-16T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 15947,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-15-grid.jsonl.gz",
|
||||
"bytes_raw": 890223,
|
||||
"sha256_raw": "6a6d39306d47b08dc190d576b3b7a2e486a755ad846fa721262a53002305bb2c"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 3651,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-15-batt.jsonl.gz",
|
||||
"bytes_raw": 208309,
|
||||
"sha256_raw": "4b60ab3ab24b9fc6dbde7262d36c74b0ef164429e382448c48ceea709921ddf4"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 4641,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-15-setpoint.jsonl.gz",
|
||||
"bytes_raw": 265543,
|
||||
"sha256_raw": "308ab0767d0dfd1c78daf313aef292e4383cd78d155656f2e829b88ad8539ece"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 116,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-15-soc.jsonl.gz",
|
||||
"bytes_raw": 6372,
|
||||
"sha256_raw": "5a8d8bdca4c6eccc275edcc13ee1b1ec24092f86d209b605f4405d95f23107d6"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-16",
|
||||
"window": [
|
||||
"2026-09-16T00:00:00+02:00",
|
||||
"2026-09-17T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 15888,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-16-grid.jsonl.gz",
|
||||
"bytes_raw": 886214,
|
||||
"sha256_raw": "cc3a1fc8f892fd22bb27b64f9928dcbbea9849a9c26ef8e7b2221483c82e5c15"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 4444,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-16-batt.jsonl.gz",
|
||||
"bytes_raw": 252502,
|
||||
"sha256_raw": "70189159b45c26f742dca92475d991f70210b5766a711e466b2fbd266968842b"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 5424,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-16-setpoint.jsonl.gz",
|
||||
"bytes_raw": 309293,
|
||||
"sha256_raw": "9f8c00b6bdea1de540f7ade4bc31870f61d972dfcd58d48a1653b1e114320d75"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 155,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-16-soc.jsonl.gz",
|
||||
"bytes_raw": 8517,
|
||||
"sha256_raw": "1bf7f07c5b60eea5cadb9758a40d2b34463ecaf33627b117ea2bed6afa45fb57"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"date": "2026-09-17",
|
||||
"window": [
|
||||
"2026-09-17T00:00:00+02:00",
|
||||
"2026-09-18T00:00:00+02:00"
|
||||
],
|
||||
"files": {
|
||||
"grid": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.p1_meter_active_power",
|
||||
"samples": 15544,
|
||||
"non_numeric_states": 0,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-17-grid.jsonl.gz",
|
||||
"bytes_raw": 867466,
|
||||
"sha256_raw": "340bec5e35fed7a4f0964694bbca294fba21fce77ddd27af616720398c1cbbc4"
|
||||
},
|
||||
"batt": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_power",
|
||||
"samples": 3868,
|
||||
"non_numeric_states": 2,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-17-batt.jsonl.gz",
|
||||
"bytes_raw": 218642,
|
||||
"sha256_raw": "4cb8e89baa89b4919d5e9b578585ab591eac4e94a636fa3aa88d293926f8401b"
|
||||
},
|
||||
"setpoint": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_setpoint",
|
||||
"samples": 4599,
|
||||
"non_numeric_states": 2,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-17-setpoint.jsonl.gz",
|
||||
"bytes_raw": 261144,
|
||||
"sha256_raw": "ddebbc60d2d8b0b8e06d2bef6bc3b61ea7c39becb22f25b7b2219455a7398519"
|
||||
},
|
||||
"soc": {
|
||||
"read": "OK",
|
||||
"entity": "sensor.goodwe_rs485_controller_goodwe_battery_soc",
|
||||
"samples": 97,
|
||||
"non_numeric_states": 2,
|
||||
"attempts": 1,
|
||||
"file": "2026-09-17-soc.jsonl.gz",
|
||||
"bytes_raw": 5336,
|
||||
"sha256_raw": "f1fade3d84a924447659fefa680f5b795266f3281bc2f5d5b6f86a1e434ddd4e"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"generated": "2026-09-21T11:30:00+02:00",
|
||||
"card": "SAFETY-57",
|
||||
"what": "The 2026-09-21 episode: a healthy 0x0518 declared stale, the site parked at 0 W, and no exit reachable from inside the state.",
|
||||
"source": {
|
||||
"host": "sniffer@192.168.2.167 (goodwe-sniff)",
|
||||
"nature": "PASSIVE listener on the RS-485 pair between the ESP32 master and the GW5000S-BP. It has no address on the bus and writes nothing.",
|
||||
"file": "~/bus-a.log",
|
||||
"window_local": [
|
||||
"2026-09-21T10:30:00+02:00",
|
||||
"2026-09-21T11:10:00+02:00"
|
||||
],
|
||||
"timezone": "Europe/Brussels (the capture stamps a time of day and no date; the date is asserted here, not derivable from the file)",
|
||||
"extraction": "lines whose time-of-day field lies in the window, verbatim, in capture order"
|
||||
},
|
||||
"file": {
|
||||
"name": "bus-a-2026-09-21-1030-1110.log.gz",
|
||||
"sha256_gz": "876fdaaf6d087125b184bfb4dd7ef4c3d1dd71ccad0fc271f8d268e8b489d134",
|
||||
"sha256_plain": "fa07e13b60e230626a8c0964b9539783a71b5627a522fc5930fd9fda54438a48",
|
||||
"bytes_gz": 17181,
|
||||
"bytes_plain": 182400,
|
||||
"lines": 3840
|
||||
},
|
||||
"decoder": "tools/decode_bus_capture.py - and it is the ONLY interpretation of these bytes this repository carries. Re-run it to re-derive every number below.",
|
||||
"frame_census": {
|
||||
"lines": 3840,
|
||||
"unparsed": 0,
|
||||
"bad_crc": 0,
|
||||
"requests": 720,
|
||||
"responses": 720,
|
||||
"writes": 1200,
|
||||
"write_echoes": 1200,
|
||||
"unknown": 0
|
||||
},
|
||||
"series": {
|
||||
"setpoint_w": {
|
||||
"frames": 1200,
|
||||
"changes": 224,
|
||||
"first_s": 37801.687,
|
||||
"last_s": 40199.693
|
||||
},
|
||||
"batt_w": {
|
||||
"frames": 240,
|
||||
"changes": 133,
|
||||
"first_s": 37804.716,
|
||||
"last_s": 40194.724
|
||||
},
|
||||
"soc_pct": {
|
||||
"frames": 240,
|
||||
"changes": 4,
|
||||
"first_s": 37806.725,
|
||||
"last_s": 40196.732
|
||||
}
|
||||
},
|
||||
"the_episode_in_the_capture": {
|
||||
"10:51:24.736": "0x0518 reaches -3 W. The pack is at 11 %, the loop has just reversed out of -447 W of charge, and the BMS refuses the discharge.",
|
||||
"10:51:29.695": "the setpoint reaches +1060 W of discharge - 800 W of travel from the command standing when the register last moved.",
|
||||
"10:52:21.695": "the setpoint is written as 0 W and stays there. This is the add-on's own `inputs missing -> command 0 W`, on the wire.",
|
||||
"10:51:24.736..11:05:34.734": "0x0518 does not change value. 850.0 s.",
|
||||
"10:52:21.695..11:05:31.698": "the setpoint does not change value. 790.0 s.",
|
||||
"~11:04": "a human restarts the add-on. NOT VISIBLE IN THIS FILE - it is an action on 192.168.2.158 - and it is the only reason the episode ends.",
|
||||
"11:05:31.698": "the restarted loop commands -400 W of CHARGE, which the pack accepts at 11 %.",
|
||||
"11:05:34.734": "0x0518 answers -137 W. The register was healthy throughout."
|
||||
},
|
||||
"not_in_this_file": [
|
||||
"the grid meter - a P1 telegram on a different wire. The 2.4 kW of export during the episode is from Home Assistant history and is NOT re-derivable here.",
|
||||
"anything the add-on believed: Inverter.batt, inputs_ok, log lines. Those are INFERRED by replaying the shipped modules against these series (test_freshness.py part 10).",
|
||||
"the restart at ~11:04."
|
||||
]
|
||||
}
|
||||
@@ -73,6 +73,7 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -84,10 +85,17 @@ from app.arbiter import (
|
||||
P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim,
|
||||
resolve,
|
||||
)
|
||||
# SAFETY-45 section 3c. The shipped loop, the shipped billed-quarter
|
||||
# accumulator and the shipped projection - nothing here is transcribed.
|
||||
from app.control import Tuning, compute
|
||||
from app.p1 import QUARTER_S, QuarterAverager, make_sample, split_signed
|
||||
from app.tariff import PeakTracker
|
||||
from app.arbitrage import (
|
||||
CHARGE, DISCHARGE, ArbitrageDecision, claim, decide, enabled, soc_bounds,
|
||||
threshold_eur_kwh,
|
||||
)
|
||||
# SAFETY-47 section 3d. The shipped shadow-mode row, not a transcription of it.
|
||||
from app import shadow as _shadow
|
||||
from app.price import (
|
||||
PRICE_OPTION_DEFAULTS, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, WIDEN_FACTOR, brussels, required_spread, widen_factor,
|
||||
@@ -203,10 +211,19 @@ def hour(i, *, start=D0):
|
||||
return start + timedelta(hours=i, minutes=30)
|
||||
|
||||
|
||||
# SAFETY-45. THE HEADROOM EVERY CHECK THAT IS NOT ABOUT THE HEADROOM USES, and
|
||||
# it is deliberately NOT binding: `min(MAX_W, this)` is MAX_W, so every existing
|
||||
# check in this file grades what it always graded and the new rung is decoration
|
||||
# in all of them. Section 3b is where it is made to bind, and 3b asserts the
|
||||
# precondition that it does (MUTATION.md habit 1) rather than hoping.
|
||||
HEADROOM_CLEAR = MAX_W * 10.0
|
||||
|
||||
|
||||
def act(plan, i, *, peak=False, soc=50.0, thresh=0.0, unc=frozenset(),
|
||||
floor=20.0, ceiling=95.0, start=D0):
|
||||
floor=20.0, ceiling=95.0, start=D0, headroom=HEADROOM_CLEAR):
|
||||
"""`decide()` with the fixture's constants filled in. One call shape."""
|
||||
return decide(plan, hour(i, start=start), peak_guard_active=peak, soc=soc,
|
||||
charge_headroom_w=headroom,
|
||||
min_spread_eur_kwh=thresh, uncertainty=unc,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=floor, soc_ceiling_pct=ceiling)
|
||||
@@ -721,6 +738,7 @@ check("a peak floor does NOT cap an arbitrage DISCHARGE - §9.1 bills offtake "
|
||||
# MECHANISM 3, which is neither of the above: the guard that never ran.
|
||||
_blind = decide(PLAN, hour(CHEAP_H), peak_guard_active=None, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
||||
check("a peak guard that NEVER RAN is a third state, and it refuses: `None` is "
|
||||
@@ -750,6 +768,835 @@ check("...and cycle() calls it rather than inlining the conjunction, so a "
|
||||
encoding="utf-8").read(), "cycle")))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3b. SAFETY-45 - the buy leg is SIZED by the guard, not switched by it")
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC3 above is the SWITCH: `peak_guard_active` refuses the whole trade while
|
||||
# the guard holds. That is right and it stays. What was missing is the other
|
||||
# half - the guard measures a CONTINUOUS number, "the watts this billed quarter
|
||||
# can still afford", and until this card nothing consumed it. So a trade sat at
|
||||
# max_w through the whole window in which the guard was silent, blew the
|
||||
# quarter, tripped the switch, was cancelled outright, and came back at max_w
|
||||
# the moment the meter fell far enough to release it. Measured on TEST-23
|
||||
# stage 3's closed rig at peak_cap_w 1500 / max_w 2000 / a 1200 W house:
|
||||
# 28 charge onsets in one hour, mean period 131 s, grid -456 <-> +3200 W.
|
||||
#
|
||||
# `control.maintenance_charge_floor()` has always given a MAINTENANCE charge
|
||||
# exactly this shape - "capped by the headroom left under the site's cap" - and
|
||||
# its own docstring says why: "a maintenance charge is the only thing this
|
||||
# system does that is big enough and long enough to set that peak". OPT-04 is
|
||||
# the second such thing and was given no cap. This section grades the cap.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ⚠️ THE PRECONDITION, AND IT IS THE HABIT-1 ONE. The headroom must be strictly
|
||||
# between 0 and the size the trade would otherwise ask for, or "sized by the
|
||||
# guard" is indistinguishable from "not sized at all" and from "refused".
|
||||
S45_HEADROOM = 300.0
|
||||
check(f"PRECONDITION: the headroom under test ({S45_HEADROOM:.0f} W) is "
|
||||
f"strictly inside the trade's own sizing ({MAX_W:.0f} W) and strictly "
|
||||
f"positive, so a cap that did nothing and a cap that refused everything "
|
||||
f"are both distinguishable from the answer below",
|
||||
0.0 < S45_HEADROOM < MAX_W)
|
||||
_s45_capped = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER,
|
||||
headroom=S45_HEADROOM)
|
||||
_s45_clear = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER)
|
||||
print(f" headroom {S45_HEADROOM:.0f} W -> {_s45_capped.power_w:+.0f} W; "
|
||||
f"headroom clear -> {_s45_clear.power_w:+.0f} W")
|
||||
print(f" {_s45_capped.reason}")
|
||||
check("the trade still HAPPENS - §10.1 ranks peak shaving above arbitrage, it "
|
||||
"does not abolish it - and it is sized to the watts the billed quarter "
|
||||
"can afford rather than to max_w",
|
||||
_s45_capped.action == CHARGE
|
||||
and near(_s45_capped.power_w, -S45_HEADROOM))
|
||||
check("...and the claim the arbiter receives carries the SIZED number, so the "
|
||||
"cap reaches the actuator and is not merely printed",
|
||||
near(claim(_s45_capped).value, -S45_HEADROOM)
|
||||
and claim(_s45_capped).priority == P_ARBITRAGE)
|
||||
check("...and the SAME call with the headroom clear asks for max_w, so the "
|
||||
"check above is graded against a difference this fixture can actually "
|
||||
"produce (habit 7: one thing varied, from an accepted baseline)",
|
||||
_s45_clear.action == CHARGE and near(_s45_clear.power_w, -MAX_W))
|
||||
|
||||
# ⚠️ THE ORDERED PAIR (MUTATION.md habit 14). Two numbers in one sentence can
|
||||
# always be transposed by a ZERO-BYTE edit, and this sentence is what an
|
||||
# operator reads when the battery buys less than the price says it should. So
|
||||
# the check asserts each value inside the literal text that names it, and
|
||||
# asserts that the TRANSPOSED sentence is not what it says.
|
||||
_S45_PAIR = (f"the trade asked for {MAX_W:.0f} W and this billed quarter can "
|
||||
f"afford {S45_HEADROOM:.0f} W")
|
||||
_S45_SWAP = (f"the trade asked for {S45_HEADROOM:.0f} W and this billed "
|
||||
f"quarter can afford {MAX_W:.0f} W")
|
||||
check("the reason names WHAT WAS ASKED FOR and WHAT WAS AFFORDABLE in their "
|
||||
"own slots, so an operator can tell a small trade from a small "
|
||||
"opportunity", _S45_PAIR in _s45_capped.reason)
|
||||
check("...and the transposed sentence is NOT what it says, so the check above "
|
||||
"discriminates rather than decorates - both numbers are in the string "
|
||||
"either way and a substring test would pass under the swap",
|
||||
_S45_SWAP not in _s45_capped.reason)
|
||||
check("...and it names the guard and the two spec sections, so the sentence "
|
||||
"cannot be read as a pack limit or a price limit",
|
||||
"CAPACITY-TARIFF GUARD" in _s45_capped.reason
|
||||
and "§9.1" in _s45_capped.reason and "§10.1" in _s45_capped.reason)
|
||||
check("...and an UNCAPPED trade does not carry that clause at all: a sentence "
|
||||
"that always says 'sized by the guard' tells nobody anything",
|
||||
"CAPACITY-TARIFF GUARD" not in _s45_clear.reason
|
||||
and f"charging {MAX_W:.0f} W" in _s45_clear.reason)
|
||||
|
||||
# --- the two refusals, and they are told apart from the ones that existed ---
|
||||
_s45_none = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=None)
|
||||
_s45_zero = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0)
|
||||
print(f" headroom None -> {_s45_none.action}; headroom 0 W -> "
|
||||
f"{_s45_zero.action}")
|
||||
check("a headroom nothing MEASURED refuses. This is guard_state()'s None "
|
||||
"argument one layer down: charging creates the billed quantity (§9.1) "
|
||||
"and an unmeasured quarter is not a clear one",
|
||||
not _s45_none.trading
|
||||
and _s45_none.reason == _arb.IDLE_HEADROOM_UNMEASURED)
|
||||
check("...and it is DISTINGUISHABLE from the guard never having run at all, so "
|
||||
"the status page does not report a missing battery sensor as a missing "
|
||||
"meter", _s45_none.reason != _arb.IDLE_NOT_COVERED)
|
||||
check("a headroom of ZERO refuses rather than filing a 0 W claim - "
|
||||
"`Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the "
|
||||
"loop, which is the failure claim()'s own note is written against",
|
||||
not _s45_zero.trading and claim(_s45_zero) is None
|
||||
and _s45_zero.reason == _arb.IDLE_NO_HEADROOM)
|
||||
# ⚠️ TEST-16's RULE: TWO RUNGS THAT CAN ANSWER THE SAME STIMULUS MUST BE
|
||||
# SEPARATED. `charge_w = 0` and `headroom = 0` both produce a 0 W sizing, and
|
||||
# grading only `kind == "refused"` would let either absorb the other's work -
|
||||
# after which weakening either one reports nothing. So the REASONS are graded,
|
||||
# and each rung is reached with the other one clear.
|
||||
_s45_nosize = decide(PLAN, hour(CHEAP_H), peak_guard_active=False, soc=50.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=0.0, discharge_w=MAX_W,
|
||||
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
||||
check("...and the PACK being sized at 0 W still reports OPT-09's own sentence, "
|
||||
"not this card's: the two rungs answer the same stimulus and a refusal "
|
||||
"that named the wrong subsystem would send an operator to the wrong page",
|
||||
not _s45_nosize.trading
|
||||
and _s45_nosize.reason == _arb.IDLE_NO_SIZE
|
||||
and _s45_zero.reason != _s45_nosize.reason)
|
||||
|
||||
# --- §9.1: the SELL leg is untouched ---------------------------------------
|
||||
_s45_sell = act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER, headroom=0.0)
|
||||
check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export "
|
||||
"are never capped by the peak guard; only charging and import are' - the "
|
||||
"capacity tariff bills offtake, and a sell creates none",
|
||||
_s45_sell.action == DISCHARGE and near(_s45_sell.power_w, MAX_W))
|
||||
|
||||
# --- and main.py actually passes it, pinned on the AST ----------------------
|
||||
# ⚠️ THE CALL SITE IS THE UNIT OF COVERAGE (MUTATION.md habit 15). Nothing
|
||||
# behavioural in this file can see main.py drop the argument - every check
|
||||
# above calls decide() itself - and a required keyword-only parameter that no
|
||||
# caller fills is a TypeError only on the one path a suite does not run. Same
|
||||
# pin, same reason, as `weights=` in test_packshare.py part 10.
|
||||
_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read()
|
||||
_S45_MAIN_TREE = ast.parse(_S45_MAIN)
|
||||
_s45_calls = [n for n in ast.walk(_S45_MAIN_TREE)
|
||||
if isinstance(n, ast.Call)
|
||||
and ast.unparse(n.func).endswith("arbitrage.decide")]
|
||||
_s45_arg = [ast.unparse(k.value) for c in _s45_calls for k in c.keywords
|
||||
if k.arg == "charge_headroom_w"]
|
||||
|
||||
|
||||
def _s45_assigned(name, clear=("None",)):
|
||||
"""Every expression main.py assigns to `self.<name>` that is not a CLEAR.
|
||||
|
||||
⚠️ SAFETY-47 HOISTED THE HEADROOM OUT OF THE CALL, so pinning the call
|
||||
argument alone stopped covering anything: the literal a mutation would put
|
||||
there now goes one line up instead. Both ends are pinned.
|
||||
|
||||
⚠️ `clear` NAMES THE CONSTANTS THAT MEAN "NOTHING HAS ANSWERED THIS YET" -
|
||||
`__init__`'s value and the per-cycle reset - and they are not a second
|
||||
source for the number. Excluding them does NOT soften the pin: a mutation
|
||||
that replaces the real assignment with one of those constants leaves this
|
||||
list EMPTY, which matches no expected value and is red.
|
||||
"""
|
||||
return [ast.unparse(n.value) for n in ast.walk(_S45_MAIN_TREE)
|
||||
if isinstance(n, ast.Assign)
|
||||
and [ast.unparse(t) for t in n.targets] == [f"self.{name}"]
|
||||
and ast.unparse(n.value) not in clear]
|
||||
|
||||
|
||||
_s45_head_src = _s45_assigned("arb_headroom_w")
|
||||
print(f" main.py has {len(_s45_calls)} `arbitrage.decide(` call site(s); "
|
||||
f"charge_headroom_w= is {_s45_arg}, and self.arb_headroom_w is assigned "
|
||||
f"from {_s45_head_src}")
|
||||
check("main.py passes the guard's OWN measured headroom at its one "
|
||||
"arbitrage.decide() call site - the shipped expression, not a "
|
||||
"transcription of it, so a mutation back to a literal or to max_w is red "
|
||||
"rather than silent",
|
||||
_s45_arg == ["self.arb_headroom_w"]
|
||||
and _s45_head_src == ["peakguard.charge_headroom_w(self.peak_guard)"])
|
||||
# ⚠️ AND THE HOIST DID NOT MOVE IT OUT OF THE CYCLE. An `arb_headroom_w` set
|
||||
# once in `__init__` and never refreshed would satisfy the pin above and would
|
||||
# size every trade from the guard decision of whatever cycle happened to run
|
||||
# last - which is the defect the hoist's own comment warns about, one level up.
|
||||
_s45_cycle = next(n for n in ast.walk(_S45_MAIN_TREE)
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and n.name == "cycle"
|
||||
and any(isinstance(c, ast.Call)
|
||||
and ast.unparse(c.func).endswith("arbitrage.decide")
|
||||
for c in ast.walk(n)))
|
||||
check("...and the assignment is INSIDE the same `cycle()` that makes the call, "
|
||||
"so the headroom the trade is sized from is this cycle's guard decision "
|
||||
"and not one carried from an arbitrary earlier one",
|
||||
any(isinstance(n, ast.Assign)
|
||||
and [ast.unparse(t) for t in n.targets] == ["self.arb_headroom_w"]
|
||||
and ast.unparse(n.value)
|
||||
== "peakguard.charge_headroom_w(self.peak_guard)"
|
||||
for n in ast.walk(_s45_cycle)))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3c. SAFETY-45 - THE REGRESSION: the closed loop SETTLES")
|
||||
# ---------------------------------------------------------------------------
|
||||
# ⚠️ THIS IS THE CHECK THE CARD IS ACTUALLY ABOUT, AND A UNIT TEST ON decide()
|
||||
# IS NOT IT. The defect is an INTERACTION - two strategies, a 900 s integrator
|
||||
# and a plant that answers back - and every individual decision in it was
|
||||
# correct. So the loop is closed here: prices decide, the battery model
|
||||
# responds, the shipped QuarterAverager reports the consequence, and the
|
||||
# shipped guard reacts to that.
|
||||
#
|
||||
# WHAT IS REAL, AND WHAT IS MODELLED - the same discipline TEST-23 stage 3's
|
||||
# harness holds itself to, because a transcription proves what somebody typed:
|
||||
#
|
||||
# REAL peakguard.decide/claim/charge_headroom_w, arbitrage.decide/claim/
|
||||
# guard_state, control.compute + Tuning, arbiter.resolve, and
|
||||
# p1.QuarterAverager - the billed-quarter accumulator itself, so the
|
||||
# quarters reported below are the ones a meter would bill.
|
||||
# MODEL ONE cycle's ORDER (peak block, then arbitrage reading its
|
||||
# conclusion, then the loop claim, then resolve) - twelve lines
|
||||
# transcribed from main.py:2660-2900 and pinned above in 3b; the
|
||||
# PLANT, an 800 W/s ramp into a 10 s first-order lag, which is
|
||||
# plantsim's; and a flat synthetic house.
|
||||
#
|
||||
# ⚠️ AND THE HEADLINE NUMBERS WERE NOT TAKEN HERE. They were taken on the
|
||||
# closed rig at /home/homelab/test23s3 against the shipped container's own
|
||||
# checkout, which is where a divergence between this model and main.py's real
|
||||
# event loop would show up. This section exists so CI can go red.
|
||||
# ---------------------------------------------------------------------------
|
||||
S45_CAP_W, S45_LOAD_W, S45_CYCLE_S, S45_RUN_S = 1500.0, 1200.0, 5.0, 2700.0
|
||||
S45_RAMP_W_S, S45_LAG_S = 800.0, 10.0
|
||||
# ⚠️ THE RUN STARTS ON A QUARTER BOUNDARY AND ENDS INSIDE THE SAME CHEAP HOUR,
|
||||
# and both halves matter. Starting on the boundary makes the three closed
|
||||
# blocks below whole billed quarters. Ending inside the hour keeps TEST-23
|
||||
# finding 1 - the export overshoot at the END of any trade, ~0.37 x max_w, a
|
||||
# separate and already-recorded property - out of a swing figure this card is
|
||||
# grading. The run still crosses TWO quarter boundaries, which is the half of
|
||||
# the defect the card says arbitrage must learn from.
|
||||
S45_T0 = D0 + timedelta(hours=CHEAP_H)
|
||||
|
||||
|
||||
def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
|
||||
seconds=S45_RUN_S, shadow=False):
|
||||
"""One closed hour. `sized=False` is the behaviour this card removes.
|
||||
|
||||
⚠️ THE COUNTERFACTUAL IS ONE ARGUMENT, NOT A SECOND CODE PATH. `sized=False`
|
||||
hands `decide()` a headroom nothing can bind - which is exactly what the
|
||||
tree did before this card, because nothing was passing one - so the two runs
|
||||
below differ in one value and share every line of shipped code. Habit 7.
|
||||
|
||||
⚠️ SAFETY-47's `shadow=True` IS THE SAME DISCIPLINE, ONE ARGUMENT AGAIN. It
|
||||
reproduces the THREE things `Controller._write_unit()` and
|
||||
`_shadow_settle()` do and nothing else: the resolved command is RECORDED,
|
||||
the plant is driven from `shadow.WIRE_W` instead of from it, and the
|
||||
carried command is RE-SEEDED to `shadow.WIRE_W` after the record is taken.
|
||||
Those three lines are a TRANSCRIPTION of main.py's placement and are marked
|
||||
as one - `test_clamp.py` part 8 is the check on the shipped placement, and
|
||||
it drives the real `Controller`. What is NOT transcribed is every number
|
||||
that matters here: the averager, the projection, the guard, the headroom,
|
||||
the strategy, the arbiter and the row's own words are all the shipped ones.
|
||||
"""
|
||||
|
||||
avg = QuarterAverager(phases=1, max_hold_s=900.0)
|
||||
tuning = Tuning(gain=0.6, max_w=MAX_W, slew_w=0.0)
|
||||
t0 = S45_T0
|
||||
target = i_w = 0.0
|
||||
sat = 0
|
||||
loop_w = None
|
||||
batt_cmd = batt = 0.0 # + discharge, as main.py's site_batt_w
|
||||
onsets = blocks = 0
|
||||
rec = []
|
||||
prev_action = "<start>"
|
||||
grid_lo, grid_hi = None, None
|
||||
billed = []
|
||||
acc = 0.0
|
||||
t = 0.0
|
||||
while t <= seconds:
|
||||
grid = load_w - batt
|
||||
imp, exp = split_signed(grid)
|
||||
for b in avg.add(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=t0 + timedelta(seconds=t),
|
||||
ingest_mono=(t0.timestamp() + t))):
|
||||
billed.append(round(float(b.offtake_avg_w), 1))
|
||||
blocks += 1
|
||||
if acc <= 1e-9:
|
||||
acc = S45_CYCLE_S
|
||||
now = t0 + timedelta(seconds=t)
|
||||
claims = []
|
||||
pk = _pg.decide(PeakTracker.project(avg, now), batt_w=batt,
|
||||
cap_w=cap_w, reading=None, achievable_w=MAX_W,
|
||||
soc=50.0, soc_floor=20.0, max_hold_s=900.0,
|
||||
frozen=False)
|
||||
pc = _pg.claim(pk)
|
||||
if pc is not None:
|
||||
claims.append(pc)
|
||||
arb = decide(PLAN, now,
|
||||
peak_guard_active=_arb.guard_state(pk),
|
||||
charge_headroom_w=(_pg.charge_headroom_w(pk) if sized
|
||||
else HEADROOM_CLEAR),
|
||||
soc=50.0, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER, charge_w=MAX_W,
|
||||
discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
ac = claim(arb)
|
||||
if ac is not None:
|
||||
claims.append(ac)
|
||||
if arb.action == CHARGE and prev_action != CHARGE:
|
||||
onsets += 1
|
||||
prev_action = arb.action
|
||||
if loop_w is None or target != loop_w:
|
||||
i_w = target
|
||||
d = compute(prev_w=target, grid_w=grid, actual_w=batt,
|
||||
tuning=tuning, sat_count=sat, i_w=i_w)
|
||||
sat, i_w, loop_w = d.sat_count, d.i_w, d.target_w
|
||||
claims.append(Claim.limit("supervised", P_SAFETY, -MAX_W, MAX_W,
|
||||
"max_w"))
|
||||
claims.append(Claim.set("loop", P_LOOP, d.target_w, d.reason))
|
||||
res = resolve(claims)
|
||||
target = res.target_w
|
||||
head_w = _pg.charge_headroom_w(pk) if sized else HEADROOM_CLEAR
|
||||
rec.append(dict(
|
||||
t_s=t, action=arb.action, power_w=arb.power_w,
|
||||
reason=arb.reason, price=arb.price_now_eur_kwh,
|
||||
spread=arb.spread_eur_kwh, winner=res.winner,
|
||||
target_w=float(res.target_w), headroom_w=head_w,
|
||||
engaged=bool(pk.engaged),
|
||||
sized_from_meter=_arb.sized_from_meter(
|
||||
arb, head_w, winner=res.winner)))
|
||||
if shadow:
|
||||
# --- TRANSCRIBED 1/3: `_shadow_settle()`'s record, taken
|
||||
# BEFORE the re-seed destroys its evidence.
|
||||
rec[-1]["would_w"] = float(res.target_w)
|
||||
rec[-1]["row"] = _shadow.words(
|
||||
{"shadow_mode": "on"}, control_started=False,
|
||||
would_w=float(res.target_w),
|
||||
sized_from_meter=rec[-1]["sized_from_meter"],
|
||||
reason=res.explain())
|
||||
# --- TRANSCRIBED 2/3: `_write_unit()`'s substitution - the
|
||||
# plant below is driven from WIRE_W, not from the decision.
|
||||
# --- TRANSCRIBED 3/3: `_shadow_settle()`'s re-seed of the
|
||||
# carried command, which is what `compute(prev_w=...)`,
|
||||
# `standing_w()` and SAFETY-42 read next cycle.
|
||||
target = _shadow.WIRE_W
|
||||
# THE PLANT, MODELLED: an 800 W/s ramp into a 10 s first-order lag.
|
||||
step = min(1.0, acc)
|
||||
delta = target - batt_cmd
|
||||
batt_cmd += math.copysign(min(abs(delta), S45_RAMP_W_S * step), delta)
|
||||
batt += (batt_cmd - batt) * (step / S45_LAG_S)
|
||||
acc -= step
|
||||
t += step
|
||||
grid_lo = grid if grid_lo is None else min(grid_lo, grid)
|
||||
grid_hi = grid if grid_hi is None else max(grid_hi, grid)
|
||||
return dict(onsets=onsets, grid_lo=round(grid_lo, 1),
|
||||
grid_hi=round(grid_hi, 1), swing=round(grid_hi - grid_lo, 1),
|
||||
billed=billed, blocks=blocks, rec=rec,
|
||||
engaged=sum(1 for r in rec if r["engaged"]),
|
||||
cycles=len(rec))
|
||||
|
||||
S45_BEFORE = derived(lambda: s45_loop(sized=False))
|
||||
S45_AFTER = derived(lambda: s45_loop(sized=True))
|
||||
for _lab, _r in (("unsized (the shipped behaviour before this card)", S45_BEFORE),
|
||||
("sized by the guard", S45_AFTER)):
|
||||
print(f" {_lab}: {_r['onsets']} charge onsets/h, grid "
|
||||
f"{_r['grid_lo']:.0f} .. {_r['grid_hi']:.0f} W (swing "
|
||||
f"{_r['swing']:.0f} W), billed quarters {_r['billed']} W")
|
||||
|
||||
# ⚠️ THE NEGATIVE CONTROL FIRST. If the unsized run did NOT limit-cycle, every
|
||||
# assertion below would be a statement about a loop that was never unstable -
|
||||
# MUTATION.md's "a check whose data cannot reach the failure it names", which
|
||||
# has now appeared in eleven cards in this repository.
|
||||
check("PRECONDITION: with the trade UNSIZED this loop really does run away - "
|
||||
"many charge onsets in one hour and a grid swing of kilowatts. Without "
|
||||
"this, everything below grades a loop that was never unstable",
|
||||
S45_BEFORE["onsets"] >= 10 and S45_BEFORE["swing"] > 2000.0)
|
||||
check("...and it is the bang-bang shape and not a slow drift: the meter "
|
||||
"reaches both a hard import and an EXPORT, so the actuator is being "
|
||||
"handed back and forth rather than converging",
|
||||
S45_BEFORE["grid_hi"] > S45_CAP_W + 1000.0
|
||||
and S45_BEFORE["grid_lo"] < 0.0)
|
||||
check(f"THE REGRESSION: sizing the trade to the guard's measured headroom "
|
||||
f"settles the loop. {S45_BEFORE['onsets']} charge onsets an hour become "
|
||||
f"{S45_AFTER['onsets']}, and the grid swing falls from "
|
||||
f"{S45_BEFORE['swing']:.0f} W to {S45_AFTER['swing']:.0f} W",
|
||||
S45_AFTER["onsets"] <= 2
|
||||
and S45_AFTER["swing"] < 0.25 * S45_BEFORE["swing"])
|
||||
check("...and the battery is still TRADING for the whole hour rather than "
|
||||
"settling by giving up: §10.1 ranks peak shaving above arbitrage, it "
|
||||
"does not cancel it, and a fix that simply stopped buying would pass an "
|
||||
"onset count and fail the card",
|
||||
S45_AFTER["onsets"] == 1)
|
||||
check(f"...and every BILLED quarter comes in at or under the "
|
||||
f"{S45_CAP_W:.0f} W cap, which the unsized run does not manage - the "
|
||||
f"oscillation was not merely ugly, it was losing the money it was "
|
||||
f"oscillating about (§10.2, §9.1)",
|
||||
S45_AFTER["blocks"] >= 3
|
||||
and all(v <= S45_CAP_W + 0.5 for v in S45_AFTER["billed"])
|
||||
and any(v > S45_CAP_W for v in S45_BEFORE["billed"]))
|
||||
check("...and the two runs are the same code on the same fixture: one "
|
||||
"argument differs, and the loop, the guard, the arbiter, the averager "
|
||||
"and the plant are shared line for line",
|
||||
S45_BEFORE["blocks"] == S45_AFTER["blocks"])
|
||||
|
||||
# ⚠️ AND IT IS INERT WHERE THE DEFECT IS INERT, WHICH IS THE OTHER HALF OF A
|
||||
# FIX. The rig's sweep found the bite rule `load + max_w > peak_cap_w`; below
|
||||
# it, the trade never threatens the quarter and this card must change NOTHING.
|
||||
S45_WIDE = derived(lambda: s45_loop(sized=False, cap_w=5000.0, load_w=1200.0,
|
||||
seconds=900.0))
|
||||
S45_WIDE_S = derived(lambda: s45_loop(sized=True, cap_w=5000.0, load_w=1200.0,
|
||||
seconds=900.0))
|
||||
print(f" peak_cap_w 5000 W, 1200 W house (load + max_w = "
|
||||
f"{S45_LOAD_W + MAX_W:.0f} W, under the cap): unsized "
|
||||
f"{S45_WIDE['billed']} W, sized {S45_WIDE_S['billed']} W")
|
||||
|
||||
|
||||
|
||||
def _s45_outcome(r):
|
||||
"""Everything the two runs put on the PLANT and the METER.
|
||||
|
||||
⚠️ `rec` IS EXCLUDED AND THE EXCLUSION IS NAMED. SAFETY-47 added a
|
||||
per-cycle record to this harness, and it carries `headroom_w` and
|
||||
`sized_from_meter` - which are THE ONE ARGUMENT the two runs differ in, by
|
||||
construction. Comparing them would make this check fail for the reason it
|
||||
exists to rule out. The resolved command per cycle IS compared, below, and
|
||||
that is a stronger statement than the six aggregates ever were.
|
||||
"""
|
||||
return {k: v for k, v in r.items() if k != "rec"}
|
||||
|
||||
|
||||
check("on a site where the trade cannot reach the cap the two runs are "
|
||||
"IDENTICAL to the watt - so this card costs nothing on the installs it "
|
||||
"is not about, and the check above is not passing because the cap "
|
||||
"dominates everything",
|
||||
_s45_outcome(S45_WIDE) == _s45_outcome(S45_WIDE_S)
|
||||
and S45_WIDE["onsets"] == 1)
|
||||
check("...and identical CYCLE BY CYCLE, not merely in the six aggregates: "
|
||||
"every resolved command and every traded size matches to the float",
|
||||
[(r["target_w"], r["power_w"]) for r in S45_WIDE["rec"]]
|
||||
== [(r["target_w"], r["power_w"]) for r in S45_WIDE_S["rec"]])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3d. SAFETY-47 - the SHADOW record against a LIVE run, at a BINDING cap")
|
||||
# ---------------------------------------------------------------------------
|
||||
# WHAT THIS PART EXISTS FOR, AND IT IS NOT A SECOND SAFETY-45 CHECK.
|
||||
#
|
||||
# SAFETY-10 shipped shadow mode saying, in three merged documents, that "the
|
||||
# strategies are unaffected - they ask for a fixed power from inputs that do
|
||||
# not depend on what the battery did - so a shadow run does show you the
|
||||
# trades, the peak-guard engagements and the SoC-limit behaviour". That was
|
||||
# true until 3c's own fix merged. SAFETY-45 makes the buy leg
|
||||
# `min(asked, headroom)`, and the headroom is
|
||||
#
|
||||
# (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. The strategy
|
||||
# became closed-loop the day 3c landed, and shadow mode holds the battery at
|
||||
# 0 W, so in a shadow run that accumulator contains THE HOUSE AND NOTHING ELSE.
|
||||
#
|
||||
# ⚠️ AND SAFETY-10 §6's COMMITTED EVIDENCE COULD NEVER HAVE FAILED - TWO
|
||||
# INDEPENDENT REASONS, AND CLOSING EITHER ONE ALONE CLOSES NOTHING.
|
||||
# (1) its fixture is `peak_cap_w: 15000` against a 350 W house, so the guard
|
||||
# never engages and the sizing is inert - MUTATION.md habit 4, inside a
|
||||
# merged card's own proof;
|
||||
# (2) every identity row reads THE FIRST CHARGING SAMPLE, and at `elapsed=0`
|
||||
# the projection IS the held level, so the two runs agree to the float
|
||||
# there even AT a binding cap. 3d-2 drives that and says so.
|
||||
#
|
||||
# So this part is the grader for the class: a shadow run and a live run, the
|
||||
# same hour, the same code, at a cap that BINDS. What is REAL and what is
|
||||
# TRANSCRIBED is in `s45_loop()`'s own docstring - the three lines that stand
|
||||
# in for `Controller._write_unit()` and `_shadow_settle()` are named there, and
|
||||
# `test_clamp.py` part 8 is the check on the shipped placement.
|
||||
# ---------------------------------------------------------------------------
|
||||
S47_LIVE = derived(lambda: s45_loop(sized=True))
|
||||
S47_SHAD = derived(lambda: s45_loop(sized=True, shadow=True))
|
||||
# The SAFETY-45 defect itself, watched from inside shadow mode - which is what
|
||||
# SAFETY-10's header claimed a shadow run would surface.
|
||||
S47_UNSIZED_SHAD = derived(lambda: s45_loop(sized=False, shadow=True))
|
||||
|
||||
_s47_lc = derived(lambda: [r for r in S47_LIVE["rec"] if r["action"] == CHARGE])
|
||||
_s47_sc = derived(lambda: [r for r in S47_SHAD["rec"] if r["action"] == CHARGE])
|
||||
|
||||
# ⚠️ THE SECTION CARRIES ITS OWN PRECONDITION INTO EVERY CHECK, AND THIS IS
|
||||
# FCST-06's SHAPE RATHER THAN TIDINESS. A mutation that stops either run
|
||||
# trading at all leaves these lists EMPTY; the first `min()` below then raises,
|
||||
# the file dies before part 4, and the run reports ZERO reds - which reads
|
||||
# exactly like a survivor. Measured: driving `wanted = batt_w - ex` through
|
||||
# this section did precisely that. So the lists are replaced by a sentinel that
|
||||
# cannot satisfy anything, and `s47check()` fails every check in the section
|
||||
# when the stimulus was not produced.
|
||||
_S47_DEAD = dict(action=None, power_w=0.0, reason="<no charging cycle>",
|
||||
price=None, spread=None, winner=None, target_w=0.0,
|
||||
headroom_w=0.0, engaged=False, sized_from_meter=False,
|
||||
would_w=0.0, row="", t_s=0.0)
|
||||
_S47_OK = bool(_s47_lc) and bool(_s47_sc)
|
||||
if not _S47_OK:
|
||||
_s47_lc = _s47_sc = [dict(_S47_DEAD)]
|
||||
_s47_n = min(len(_s47_lc), len(_s47_sc))
|
||||
|
||||
|
||||
def s47check(name, cond):
|
||||
"""A 3d check, AND the section's own precondition, in one verdict."""
|
||||
check(name, _S47_OK and bool(cond))
|
||||
|
||||
|
||||
check("PRECONDITION: both runs actually reached a charging decision - without "
|
||||
"this the section raises on its first `min()`, the file dies before "
|
||||
"part 4 and the run reports ZERO reds, which reads exactly like a "
|
||||
"survivor (MUTATION.md, FCST-06's shape). Measured, not imagined",
|
||||
_S47_OK)
|
||||
print(f" live : {len(_s47_lc)} charging cycles, traded "
|
||||
f"{min(r['power_w'] for r in _s47_lc):+.1f} .. "
|
||||
f"{max(r['power_w'] for r in _s47_lc):+.1f} W, "
|
||||
f"headroom {min(r['headroom_w'] for r in _s47_lc):.1f} .. "
|
||||
f"{max(r['headroom_w'] for r in _s47_lc):.1f} W, "
|
||||
f"grid {S47_LIVE['grid_lo']:.0f} .. {S47_LIVE['grid_hi']:.0f} W")
|
||||
print(f" shadow: {len(_s47_sc)} charging cycles, RECORDED "
|
||||
f"{min(r['power_w'] for r in _s47_sc):+.1f} .. "
|
||||
f"{max(r['power_w'] for r in _s47_sc):+.1f} W, "
|
||||
f"headroom {min(r['headroom_w'] for r in _s47_sc):.1f} .. "
|
||||
f"{max(r['headroom_w'] for r in _s47_sc):.1f} W, "
|
||||
f"grid {S47_SHAD['grid_lo']:.0f} .. {S47_SHAD['grid_hi']:.0f} W")
|
||||
|
||||
# --- 3d-1. THE PRECONDITION: the cap binds, and the meter proves it --------
|
||||
# ⚠️ WITHOUT THIS EVERY CHECK BELOW IS SAFETY-10 §6's DEFECT IN A NEW FILE. At
|
||||
# a cap the trade cannot reach, live and shadow agree exactly and nothing here
|
||||
# could ever go red - which is precisely what the committed evidence did.
|
||||
s47check("PRECONDITION: the cap BINDS - the live run's trade is sized by the "
|
||||
f"guard on every charging cycle, never reaching the {MAX_W:.0f} W ask",
|
||||
_s47_lc and all(abs(r["power_w"]) < MAX_W - 1.0 for r in _s47_lc))
|
||||
s47check("...and the live METER is what closed the gap: it is held up at the "
|
||||
f"{S45_CAP_W:.0f} W ceiling rather than sitting at the "
|
||||
f"{S45_LOAD_W:.0f} W house, which is the feedback a shadow run does not "
|
||||
f"have",
|
||||
S47_LIVE["grid_hi"] > S45_LOAD_W + 200.0)
|
||||
s47check("...and the SHADOW meter sees the house and nothing else for the whole "
|
||||
"run, which is what makes its billed quarter a different quarter",
|
||||
near(S47_SHAD["grid_lo"], S45_LOAD_W, 0.001)
|
||||
and near(S47_SHAD["grid_hi"], S45_LOAD_W, 0.001))
|
||||
|
||||
# --- 3d-2. WHY THE OLD SHAPE COULD NOT FAIL --------------------------------
|
||||
# Graded as an EXPECTATION rather than left as a footnote, so that nobody
|
||||
# rebuilds a check on `[0]` and believes it covers something.
|
||||
print(f" first charging cycle: live {_s47_lc[0]['power_w']:+.4f} W, "
|
||||
f"shadow {_s47_sc[0]['power_w']:+.4f} W")
|
||||
s47check("⚠️ THE FIRST CHARGING CYCLE IS IDENTICAL IN BOTH RUNS EVEN HERE, so "
|
||||
"widening SAFETY-10 §6's fixture ALONE would not have made its table go "
|
||||
"red: at elapsed=0 the projection IS the held level and neither plant "
|
||||
"has moved yet. Both reasons had to be closed, and this is the one a "
|
||||
"reviewer cannot see from a diff",
|
||||
_s47_lc[0]["power_w"] == _s47_sc[0]["power_w"]
|
||||
and _s47_lc[0]["reason"] == _s47_sc[0]["reason"])
|
||||
|
||||
# --- 3d-3. AND ACROSS THE WHOLE RUN THEY DIVERGE ---------------------------
|
||||
_s47_mis = sum(1 for i in range(_s47_n)
|
||||
if _s47_lc[i]["power_w"] != _s47_sc[i]["power_w"])
|
||||
_s47_worst = min(_s47_sc, key=lambda r: r["power_w"])
|
||||
_s47_live_worst = min(_s47_lc, key=lambda r: r["power_w"])
|
||||
# ⚠️ NaN AND NOT AN EXCEPTION when the sentinel above is in play: `nan > 2.0`
|
||||
# is False, so the check goes RED, while a ZeroDivisionError here would kill
|
||||
# the file and report zero reds. Same defect, one line further down - it was
|
||||
# driven, and it is why this is not a plain division.
|
||||
_s47_over = (abs(_s47_worst["power_w"]) / abs(_s47_live_worst["power_w"])
|
||||
if _s47_live_worst["power_w"] else float("nan"))
|
||||
print(f" size mismatches {_s47_mis}/{_s47_n} charging cycles; the shadow "
|
||||
f"run records {_s47_worst['power_w']:+.0f} W where the live run's "
|
||||
f"hardest trade is {_s47_live_worst['power_w']:+.1f} W - "
|
||||
f"{_s47_over:.2f}x")
|
||||
s47check("ACROSS THE WHOLE RUN the recorded trade size diverges on all but the "
|
||||
"first few cycles - the check SAFETY-10 §6 should have been",
|
||||
_s47_mis > 0.9 * _s47_n)
|
||||
s47check(f"...and the shadow run RECORDS a trade the live run never commanded, "
|
||||
f"over by {_s47_over:.2f}x. `900/remaining` amplifies a "
|
||||
f"(ceiling - projected) gap the battery never closed, and the row "
|
||||
f"carried that number to a commissioning engineer with no caveat",
|
||||
_s47_over > 2.0)
|
||||
s47check("...and the amplification is IN THE HEADROOM, which is where the "
|
||||
f"measurement enters: the shadow guard offers up to "
|
||||
f"{max(r['headroom_w'] for r in _s47_sc):.0f} W of charging headroom "
|
||||
f"against a {S45_CAP_W:.0f} W ceiling and a {S45_LOAD_W:.0f} W house, "
|
||||
f"while the live guard never exceeds "
|
||||
f"{max(r['headroom_w'] for r in _s47_lc):.0f} W",
|
||||
max(r["headroom_w"] for r in _s47_sc) > MAX_W
|
||||
and max(r["headroom_w"] for r in _s47_lc) < MAX_W)
|
||||
|
||||
# --- 3d-3b. THE SENTENCE THE ROW PRINTS, GRADED AS ARITHMETIC --------------
|
||||
# ⚠️ THE ROW CLAIMS THE RECORDED NUMBER IS AN **UPPER BOUND**, and a claim on
|
||||
# an operator-facing surface is a claim this file has to be able to fail.
|
||||
# Charging only ADDS offtake, so a live `projected` is never below a shadow
|
||||
# one and the headroom is never above it. The earlier wording said "smaller",
|
||||
# which is FALSE on every cycle where the two are equal - the start of each
|
||||
# quarter, where `900/remaining` is 1.0 and neither plant has moved.
|
||||
_s47_below = [i for i in range(_s47_n)
|
||||
if abs(_s47_sc[i]["power_w"]) < abs(_s47_lc[i]["power_w"]) - 1e-9]
|
||||
_s47_equal = sum(1 for i in range(_s47_n)
|
||||
if abs(_s47_sc[i]["power_w"]) == abs(_s47_lc[i]["power_w"]))
|
||||
print(f" the shadow record is below the live command on "
|
||||
f"{len(_s47_below)}/{_s47_n} cycles and exactly equal on {_s47_equal}")
|
||||
s47check("the recorded size is an UPPER BOUND on the live one, on every compared "
|
||||
"cycle - which is the sentence `shadow.SIZED_FROM_METER` puts on the "
|
||||
"status row, so the row is making a claim this file can fail",
|
||||
not _s47_below)
|
||||
s47check("...and it is NOT strictly greater: the two are equal at the start of "
|
||||
f"each quarter ({_s47_equal} cycles here), which is why the row says "
|
||||
f"'upper bound' and not 'smaller' - the second would be a false "
|
||||
f"sentence on exactly those cycles",
|
||||
_s47_equal > 0)
|
||||
|
||||
# ⚠️ AND THE MECHANISM BEHIND THE BOUND, AS TWO POINTS OF THE SHIPPED
|
||||
# ARITHMETIC RATHER THAN AS ONE PROPERTY OF A WHOLE RUN. Same ceiling, same
|
||||
# elapsed, same house: one quarter that carried the battery's charge at the
|
||||
# meter (LIVE) and one that did not (SHADOW). The closed-loop version above
|
||||
# cannot be driven red by any mutation that ALSO stops the fixture trading -
|
||||
# measured - so the relation it rests on is graded directly here.
|
||||
def _s47_headroom(*, meter_w, batt_w, elapsed_s=450.0):
|
||||
"""`charge_headroom_w()` for a quarter that banked `meter_w` for
|
||||
`elapsed_s`, with `batt_w` measured at the pack. All shipped."""
|
||||
avg = QuarterAverager(phases=1, max_hold_s=900.0)
|
||||
t = 0.0
|
||||
while t <= elapsed_s:
|
||||
imp, exp = split_signed(meter_w)
|
||||
avg.add(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=S45_T0 + timedelta(seconds=t),
|
||||
ingest_mono=(S45_T0.timestamp() + t)))
|
||||
t += 5.0
|
||||
pk = _pg.decide(
|
||||
PeakTracker.project(avg, S45_T0 + timedelta(seconds=elapsed_s)),
|
||||
batt_w=batt_w, cap_w=S45_CAP_W, reading=None, achievable_w=MAX_W,
|
||||
soc=50.0, soc_floor=20.0, max_hold_s=900.0, frozen=False)
|
||||
return _pg.charge_headroom_w(pk)
|
||||
|
||||
|
||||
_S47_TRADE_W = 300.0
|
||||
_s47_h_shadow = derived(lambda: _s47_headroom(meter_w=S45_LOAD_W, batt_w=0.0))
|
||||
_s47_h_live = derived(lambda: _s47_headroom(
|
||||
meter_w=S45_LOAD_W + _S47_TRADE_W, batt_w=-_S47_TRADE_W))
|
||||
print(f" half-way through one quarter, {S45_LOAD_W:.0f} W house under a "
|
||||
f"{S45_CAP_W:.0f} W ceiling: the SHADOW meter (battery at 0 W) offers "
|
||||
f"{_s47_h_shadow:.0f} W of headroom, the LIVE meter (the same house "
|
||||
f"plus a {_S47_TRADE_W:.0f} W charge) offers {_s47_h_live:.0f} W")
|
||||
s47check("the SHADOW meter offers STRICTLY MORE charging headroom than the "
|
||||
"live one on the same house, same ceiling and same elapsed - which "
|
||||
"is the whole mechanism, in two points of the shipped arithmetic "
|
||||
"rather than as a property of a whole run",
|
||||
_s47_h_shadow > _s47_h_live)
|
||||
s47check("...and the live answer is the one that lands the block ON the "
|
||||
"ceiling, so the shadow answer is the one that is wrong about the "
|
||||
"bill rather than the other way round",
|
||||
near(_s47_h_live, S45_CAP_W - S45_LOAD_W, 1.0))
|
||||
|
||||
# --- 3d-4. WHAT SURVIVES OF THE EXEMPTION, GRADED --------------------------
|
||||
# The narrowed claim the three documents now make, as a check rather than as a
|
||||
# sentence: WHICH hour and WHY are unaffected; HOW HARD is not.
|
||||
s47check("WHICH hour the strategy picked and at WHAT price are identical on "
|
||||
f"every one of the {_s47_n} compared cycles - the clock and the price "
|
||||
f"series are not functions of what the battery did, which is the half "
|
||||
f"of the old exemption that is still true",
|
||||
all(_s47_lc[i]["price"] == _s47_sc[i]["price"] for i in range(_s47_n)))
|
||||
s47check("...and so is the spread it had to clear",
|
||||
all(_s47_lc[i]["spread"] == _s47_sc[i]["spread"]
|
||||
for i in range(_s47_n)))
|
||||
|
||||
# --- 3d-5. THE CLOSED-LOOP DEFECT THE DOCUMENTS SAID WAS VISIBLE -----------
|
||||
# ⚠️ SAFETY-10's HEADER NAMED SAFETY-45's LIMIT CYCLE AS EXAMPLE #1 OF WHAT A
|
||||
# SHADOW RUN SURFACES. 3c's own PRECONDITION establishes that the unsized loop
|
||||
# really does run away; the same stimulus watched from inside shadow mode is
|
||||
# a settled, uneventful hour.
|
||||
print(f" SAFETY-45's own defect, unsized: live {S45_BEFORE['onsets']} "
|
||||
f"onsets/swing {S45_BEFORE['swing']:.0f} W, guard engaged "
|
||||
f"{S45_BEFORE['engaged']}/{S45_BEFORE['cycles']} -> in shadow mode "
|
||||
f"{S47_UNSIZED_SHAD['onsets']} onsets/swing "
|
||||
f"{S47_UNSIZED_SHAD['swing']:.0f} W, guard engaged "
|
||||
f"{S47_UNSIZED_SHAD['engaged']}/{S47_UNSIZED_SHAD['cycles']}")
|
||||
s47check("⚠️ SAFETY-45's LIMIT CYCLE IS NOT VISIBLE FROM A SHADOW RUN, and three "
|
||||
"merged documents said it was. The same hour, the same unsized code: "
|
||||
f"{S45_BEFORE['onsets']} charge onsets and a "
|
||||
f"{S45_BEFORE['swing']:.0f} W grid swing live, "
|
||||
f"{S47_UNSIZED_SHAD['onsets']} onsets and "
|
||||
f"{S47_UNSIZED_SHAD['swing']:.0f} W in shadow",
|
||||
S45_BEFORE["onsets"] >= 10 and S45_BEFORE["swing"] > 2000.0
|
||||
and S47_UNSIZED_SHAD["onsets"] <= 2
|
||||
and S47_UNSIZED_SHAD["swing"] < 1.0)
|
||||
s47check("...and neither are the guard ENGAGEMENTS the same documents promised: "
|
||||
f"{S45_BEFORE['engaged']} engaged cycles live against "
|
||||
f"{S47_UNSIZED_SHAD['engaged']} in shadow. Every term of "
|
||||
f"`wanted = batt_w + excess` is MEASURED, and in shadow mode the "
|
||||
f"battery contributes nothing to either term",
|
||||
S45_BEFORE["engaged"] > 100 and S47_UNSIZED_SHAD["engaged"] == 0)
|
||||
|
||||
# --- 3d-6. THE THREE RUNGS OF `sized_from_meter()`, SEPARATED --------------
|
||||
# TEST-16's rule: three rungs answer one question, so each is reached with the
|
||||
# other two clear. Any two of them left standing would mask the third.
|
||||
_s47_chg = _s47_worst
|
||||
_s47_sell = derived(lambda: act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER,
|
||||
headroom=0.0))
|
||||
s47check("the CHARGE rung: a metered headroom that sized a buy leg answers True, "
|
||||
"which is the state the caveat exists for",
|
||||
_arb.sized_from_meter(
|
||||
ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x",
|
||||
spread_eur_kwh=1.0, required_eur_kwh=0.1,
|
||||
price_now_eur_kwh=0.05),
|
||||
300.0, winner=_arb.SOURCE) is True)
|
||||
s47check("...the SELL rung: a DISCHARGE answers False however the headroom reads. "
|
||||
"§9.1 - discharge and export are never capped by the peak guard - so a "
|
||||
"caveat on a sell would be a FALSE sentence on the status row",
|
||||
_s47_sell.action == DISCHARGE
|
||||
and _arb.sized_from_meter(_s47_sell, 300.0, winner=_arb.SOURCE) is False)
|
||||
s47check("...the UNMEASURED rung: a None headroom answers False. Nothing sized "
|
||||
"anything from a meter, so there is nothing to disclose - and `decide()` "
|
||||
"refuses the trade there anyway",
|
||||
_arb.sized_from_meter(
|
||||
ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x",
|
||||
spread_eur_kwh=1.0, required_eur_kwh=0.1,
|
||||
price_now_eur_kwh=0.05),
|
||||
None, winner=_arb.SOURCE) is False)
|
||||
s47check("...the WINNER rung: the same sized charge answers False once something "
|
||||
"else owns the resolution. `Resolution.target_w` is then somebody "
|
||||
"else's number and a caveat filed against it would be false",
|
||||
_arb.sized_from_meter(
|
||||
ArbitrageDecision(action=CHARGE, power_w=-300.0, reason="x",
|
||||
spread_eur_kwh=1.0, required_eur_kwh=0.1,
|
||||
price_now_eur_kwh=0.05),
|
||||
300.0, winner="loop") is False)
|
||||
|
||||
# --- 3d-7. THE ROW, AT THE WORST CYCLE -------------------------------------
|
||||
_s47_row = _s47_chg["row"]
|
||||
_s47_first_row = _s47_sc[0]["row"]
|
||||
print(f" worst cycle row: {_s47_row[:230]}")
|
||||
s47check("the shipped row carries the caveat at the worst cycle: the operator is "
|
||||
"told the number was sized from a meter with only the house in it",
|
||||
_shadow.SIZED_FROM_METER in _s47_row)
|
||||
s47check("...and it still carries the watts, in their own slot, and the standing "
|
||||
"0 W beside them - the caveat NARROWS the claim rather than deleting "
|
||||
"the record a shadow run exists to produce",
|
||||
"WOULD HAVE COMMANDED" in _s47_row and "inverter held at 0 W" in _s47_row
|
||||
and f"{round(_s47_chg['would_w']):+d}".replace("+", "") != "")
|
||||
s47check("...and the watts are introduced as what it would have commanded ON "
|
||||
"THIS CYCLE, in every shadow state and not only the caveated ones - no "
|
||||
"number a shadow run records is a forecast of a live run",
|
||||
"ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_row
|
||||
and "ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_first_row)
|
||||
|
||||
# --- 3d-7b. THE ACTION LOG, WHICH IS THE RECORD READ AFTERWARDS -----------
|
||||
# ⚠️ THE TWO SURFACES ARE GRADED SEPARATELY BECAUSE THEY FAILED SEPARATELY.
|
||||
# Driven as one mutation, deleting the caveat from the log line alone 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 is `shadow.log_line()` now, so it can be graded.
|
||||
_s47_log = derived(lambda: _shadow.log_line(
|
||||
would_w=_s47_chg["would_w"], reason="arbitrage -> -2000 W",
|
||||
sized_from_meter=_s47_chg["sized_from_meter"]))
|
||||
_s47_log_plain = derived(lambda: _shadow.log_line(
|
||||
would_w=_s47_chg["would_w"], reason="loop -> -300 W",
|
||||
sized_from_meter=False))
|
||||
print(f" action log: {_s47_log[:190]}")
|
||||
s47check("the ACTION LOG line carries the same caveat the row does - a page that "
|
||||
"discloses and a 40-row record that does not is the worse half of the "
|
||||
"two, because the record is what is read after the run",
|
||||
_shadow.SIZED_FROM_METER in _s47_log)
|
||||
s47check("...and it is the SAME constant, not a second wording of it, so the two "
|
||||
"surfaces cannot start disagreeing about which cycles are caveated",
|
||||
_shadow.SIZED_FROM_METER in _s47_row and _shadow.SIZED_FROM_METER
|
||||
in _s47_log)
|
||||
s47check("...and an uncaveated cycle gets the plain sentence, so the caveat is "
|
||||
"not simply printed on everything",
|
||||
_shadow.SIZED_FROM_METER not in _s47_log_plain
|
||||
and "would have commanded" in _s47_log_plain
|
||||
and "ON THIS CYCLE" in _s47_log_plain)
|
||||
|
||||
# --- 3d-8. ⚠️ AND IT IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED ----------
|
||||
# THE TRAP THIS PART EXISTS TO SPRING. `arbitrage`'s own sentence says "SIZED
|
||||
# BY THE CAPACITY-TARIFF GUARD" only when the headroom came out BELOW the ask.
|
||||
# In a shadow run that 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 that sentence would
|
||||
# be quietest exactly where it is needed loudest.
|
||||
_S47_CAPPED = "SIZED BY THE CAPACITY-TARIFF GUARD"
|
||||
print(f" at the worst cycle the shipped trade sentence says "
|
||||
f"{'' if _S47_CAPPED in _s47_chg['reason'] else 'NOT '}capped; at the "
|
||||
f"first it says "
|
||||
f"{'' if _S47_CAPPED in _s47_sc[0]['reason'] else 'NOT '}capped")
|
||||
s47check("PRECONDITION: the shipped 'capped' sentence IS present at the first "
|
||||
"charging cycle, where the shadow number is RIGHT - so the inverse "
|
||||
"below is a real inversion and not an absence everywhere",
|
||||
_S47_CAPPED in _s47_sc[0]["reason"])
|
||||
s47check("⚠️ ...and ABSENT at the worst cycle, where the shadow number is "
|
||||
f"{_s47_over:.2f}x out. A caveat keyed on `capped` would fire only "
|
||||
f"where the record is accurate and go silent where it is multiples "
|
||||
f"wrong; `sized_from_meter()` is keyed on the headroom having been "
|
||||
f"MEASURED, so it fires on both",
|
||||
_S47_CAPPED not in _s47_chg["reason"]
|
||||
and _s47_chg["sized_from_meter"] is True
|
||||
and _s47_sc[0]["sized_from_meter"] is True)
|
||||
|
||||
# --- 3d-9. main.py's CALL SITES, PINNED ------------------------------------
|
||||
# ⚠️ THE CALL SITE IS THE UNIT OF COVERAGE (habit 15), same as 3b's. Nothing
|
||||
# behavioural in this file can see main.py render the row with a hard-coded
|
||||
# False, because every check above calls `shadow.words()` itself.
|
||||
_s47_words = [n for n in ast.walk(_S45_MAIN_TREE)
|
||||
if isinstance(n, ast.Call)
|
||||
and ast.unparse(n.func).endswith("shadow.words")]
|
||||
_s47_flag = [ast.unparse(k.value) for c in _s47_words for k in c.keywords
|
||||
if k.arg == "sized_from_meter"]
|
||||
_s47_flag_src = _s45_assigned("shadow_sized_from_meter",
|
||||
clear=("None", "False"))
|
||||
print(f" main.py: shadow.words(sized_from_meter={_s47_flag}), and "
|
||||
f"self.shadow_sized_from_meter is assigned from {_s47_flag_src}")
|
||||
s47check("main.py renders the row from the flag THE CYCLE RECORDED, at its one "
|
||||
"shadow.words() call site - not from a literal and not re-derived on "
|
||||
"the status task, where `self.peak_guard` is a different cycle's",
|
||||
_s47_flag == ["self.shadow_sized_from_meter"])
|
||||
s47check("...and that flag comes from the SHIPPED predicate with all three of its "
|
||||
"arguments - the decision, the headroom the trade was sized from, and "
|
||||
"the arbiter's winner - so a mutation to `False`, to a literal or to a "
|
||||
"two-argument transcription is red rather than silent",
|
||||
_s47_flag_src == ["arbitrage.sized_from_meter("
|
||||
"self.arb, self.arb_headroom_w, "
|
||||
"winner=resolution.winner)"])
|
||||
_s47_logcalls = [n for n in ast.walk(_S45_MAIN_TREE)
|
||||
if isinstance(n, ast.Call)
|
||||
and ast.unparse(n.func).endswith("shadow.log_line")]
|
||||
_s47_logflag = [ast.unparse(k.value) for c in _s47_logcalls for k in c.keywords
|
||||
if k.arg == "sized_from_meter"]
|
||||
print(f" main.py: shadow.log_line(sized_from_meter={_s47_logflag})")
|
||||
s47check("...and the ACTION LOG line comes from the shipped sentence with the "
|
||||
"same flag, at main.py's one call site - not re-assembled as an "
|
||||
"f-string where no check can reach it, which is how it survived a whole "
|
||||
"mutation battery once already",
|
||||
_s47_logflag == ["self.shadow_sized_from_meter"])
|
||||
# ⚠️ AND THE PARAMETER HAS NO DEFAULT. TEST-14's rule, and this card is the
|
||||
# reason it matters here: a caller that FORGOT the caveat and a caller that
|
||||
# DECIDED it did not apply would render the identical row.
|
||||
_s47_sig = inspect.signature(_shadow.words)
|
||||
_s47_logsig = inspect.signature(_shadow.log_line)
|
||||
s47check("`shadow.words()` and `shadow.log_line()` BOTH require "
|
||||
"`sized_from_meter` - no default on either, so a caller cannot omit the "
|
||||
"caveat by accident and get the un-caveated surface",
|
||||
"sized_from_meter" in _s47_sig.parameters
|
||||
and _s47_sig.parameters["sized_from_meter"].default
|
||||
is inspect.Parameter.empty
|
||||
and "sized_from_meter" in _s47_logsig.parameters
|
||||
and _s47_logsig.parameters["sized_from_meter"].default
|
||||
is inspect.Parameter.empty)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("4. §8.3 - no irreversible action on a provisional price")
|
||||
@@ -822,7 +1669,8 @@ _ticks = []
|
||||
for _t in range(24 * 120): # 24 h at the 30 s control cadence
|
||||
_d = decide(_flap_plan, D0 + timedelta(seconds=30 * _t),
|
||||
peak_guard_active=False, soc=50.0, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W,
|
||||
uncertainty=frozenset(), charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
||||
_ticks.append(_d.action)
|
||||
_transitions = sum(1 for a, b in zip(_ticks, _ticks[1:]) if a != b)
|
||||
@@ -844,7 +1692,8 @@ _no_plan = act(None, CHEAP_H, thresh=_shipped)
|
||||
_no_soc = act(PLAN, CHEAP_H, thresh=_shipped, soc=None)
|
||||
_uncovered = decide(PLAN, D0 + timedelta(days=9), peak_guard_active=False,
|
||||
soc=50.0, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=frozenset(), charge_w=MAX_W, discharge_w=MAX_W,
|
||||
uncertainty=frozenset(), charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
||||
_reasons = [_no_plan.reason, _no_soc.reason, _uncovered.reason, _blind.reason,
|
||||
_held.reason]
|
||||
@@ -1218,7 +2067,8 @@ def replay_real(days, threshold, unc, *, soc0=50.0, floor=20.0, ceiling=95.0):
|
||||
d = decide(plan, p.start + timedelta(minutes=30),
|
||||
peak_guard_active=False, soc=soc,
|
||||
min_spread_eur_kwh=threshold, uncertainty=unc,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=floor, soc_ceiling_pct=ceiling)
|
||||
if d.action is None:
|
||||
continue
|
||||
@@ -1952,10 +2802,12 @@ check("...and the same at the sell probe, against the verdict's dearest",
|
||||
_pa_sell is derived(lambda: _qv.dearest))
|
||||
_qbuy = decide(QPLAN, qprobe(Q_CHEAP_I), peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
_qsell = decide(QPLAN, qprobe(Q_DEAR_I), peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
check("...so decide() CHARGES in the cheapest quarter and DISCHARGES in the "
|
||||
@@ -1980,7 +2832,8 @@ check(f"the off-peak probe's own price differs from its hour's :00 price "
|
||||
and _QOFF_I not in (Q_CHEAP_I, Q_DEAR_I))
|
||||
_qoff = decide(QPLAN, qprobe(_QOFF_I), peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
check("a quarter that is neither extremum still idles, for the reason that "
|
||||
"names the right rule, and reports ITS OWN price - not the price of the "
|
||||
@@ -2007,7 +2860,8 @@ check(f"the negative day's dearest quarter is slot {Q_DEAR_I} and it is BELOW "
|
||||
NO_WEATHER))
|
||||
_qneg_sell = decide(QNEG_PLAN, qprobe(Q_DEAR_I), peak_guard_active=False,
|
||||
soc=50.0, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER, charge_w=MAX_W, discharge_w=MAX_W,
|
||||
uncertainty=NO_WEATHER, charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
||||
check("selling into a negative-price QUARTER is refused, and the reason names "
|
||||
"§17 and THAT QUARTER's price",
|
||||
@@ -2020,7 +2874,8 @@ check("...while the SAME quarter-hourly series with that ONE price's SIGN "
|
||||
"FLIPPED discharges instead",
|
||||
decide(qplan_of(QPOS), qprobe(Q_DEAR_I), peak_guard_active=False,
|
||||
soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == DISCHARGE
|
||||
and sum(1 for a, b in zip(QNEG, QPOS) if a != b) == 1)
|
||||
# §8.3's flag, read off the covering point. `_act`'s own provisional guard
|
||||
@@ -2077,11 +2932,13 @@ check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
|
||||
"their own prices",
|
||||
decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == CHARGE
|
||||
and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == DISCHARGE)
|
||||
# An instant inside a CARRIED hour of the same series. The covering point is
|
||||
# the carried one; the shipped scan answered a point from the FIRM block
|
||||
@@ -2096,7 +2953,8 @@ check("an instant inside a CARRIED hour of the mixed series matches the "
|
||||
and MIX_PTS[MIX_N_H - 1].provisional is True
|
||||
and decide(MIX_PLAN, _mix_carried_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
charge_headroom_w=HEADROOM_CLEAR,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action is None)
|
||||
|
||||
print()
|
||||
|
||||
@@ -74,6 +74,7 @@ Read MUTATION.md before you report a survivor.
|
||||
import asyncio
|
||||
import io
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -87,6 +88,7 @@ from app.arbiter import P_LOOP, Claim, UnitBounds, allocate, resolve
|
||||
from app.control import Tuning, compute
|
||||
from app.inverter import Inverter
|
||||
from app.main import Controller
|
||||
from app import shadow
|
||||
|
||||
fails = []
|
||||
T0 = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
|
||||
@@ -670,8 +672,15 @@ def arm(c, value):
|
||||
|
||||
async def _direct():
|
||||
hass = _Hass(0.0)
|
||||
# ⚠️ SAFETY-10: `shadow_mode: "off"` EXPLICITLY, BECAUSE THIS FIXTURE
|
||||
# NEVER PRESSES START. The shipped default is `auto`, which is SHADOW
|
||||
# MODE while control is stopped - so without this line the probe below
|
||||
# would be graded against a controller that is correctly refusing to
|
||||
# command anything, and the clamp it is here to exercise would never be
|
||||
# reached. The shipped write path is unaffected: a stopped controller
|
||||
# only ever writes 0 W in production, which shadow mode also writes.
|
||||
c = Controller({"setpoint_entity": "number.sp", "max_w": 2000,
|
||||
"meter_source": "off"},
|
||||
"meter_source": "off", "shadow_mode": "off"},
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
# The shape the heartbeat, shutdown() and handle_action() all use - each one
|
||||
@@ -1121,10 +1130,16 @@ A_UNIT, B_HYPO = "inverter-a", "inverter-b"
|
||||
|
||||
|
||||
def fresh(**over):
|
||||
"""A Controller with a stub HA, no cycle run, and therefore NO record."""
|
||||
"""A Controller with a stub HA, no cycle run, and therefore NO record.
|
||||
|
||||
⚠️ SAFETY-10: `shadow_mode: "off"` for `_direct()`'s reason - this fixture
|
||||
never presses Start, and the shipped default is shadow mode while control
|
||||
is stopped. Part 7 grades who is allowed to reach the WIRE; a controller
|
||||
that is correctly commanding nothing cannot answer that question.
|
||||
"""
|
||||
hass = _Hass(0.0)
|
||||
c = Controller(dict({"setpoint_entity": "number.sp", "max_w": 2000,
|
||||
"meter_source": "off"}, **over),
|
||||
"meter_source": "off", "shadow_mode": "off"}, **over),
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
return hass, c
|
||||
@@ -1414,6 +1429,432 @@ check("...and the numbers those cycles put on the wire are the arbiter's own "
|
||||
hass_r.writes and all(abs(w) <= LIVE_MAX_W for w in hass_r.writes))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 8. SAFETY-10 - SHADOW MODE: THE DECISION PATH RUNS AND NOTHING REACHES THE
|
||||
# INVERTER
|
||||
# --------------------------------------------------------------------------- #
|
||||
# This part belongs in THIS file and not in a new one: shadow mode is a
|
||||
# substitution inside `_write_unit()`, one line below SAFETY-03's clamp and two
|
||||
# below SAFETY-28's gate, and the property it claims - "nothing reaches the
|
||||
# inverter" - is the same property parts 5 and 7 already grade from the other
|
||||
# side. The fake `set_number()` in this file IS the wire.
|
||||
#
|
||||
# ⚠️ EVERY CHECK BELOW IS GRADED ON THE WRITES OR ON THE RENDERED SURFACE, NOT
|
||||
# ON `Controller.shadow`. A check that asserted the flag would pass on a build
|
||||
# in which the flag is set and the substitution is deleted, which is precisely
|
||||
# the build this section exists to refuse.
|
||||
#
|
||||
# ⚠️ AND EVERY ONE OF THEM RUNS THE SAME STIMULUS IN BOTH MODES. MUTATION.md's
|
||||
# rule: a per-thing check that has never seen the things DISAGREE is a total.
|
||||
# `_s10_live` and `_s10_shadow` are the same 6000 W import through the same
|
||||
# `Controller.cycle()`; the only difference is one option.
|
||||
print("\n8. SAFETY-10 - shadow mode\n")
|
||||
|
||||
|
||||
def _s10(shadow_mode, *, auto=True, cycles=25, grid=6000.0, dev=None, **over):
|
||||
"""The same wired run as part 3, with `shadow_mode` and the start button.
|
||||
|
||||
⚠️ `auto` IS A PARAMETER BECAUSE `shadow_mode: auto` READS IT. The three
|
||||
values and the two button positions are six states and they are not all
|
||||
the same state; the pure-function table over all six is in
|
||||
`test_validate.py`, and 8f says why it is not here.
|
||||
"""
|
||||
async def go():
|
||||
hass = _Hass(grid, track=True)
|
||||
c = Controller(dict(
|
||||
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
|
||||
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
|
||||
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
|
||||
"shadow_mode": shadow_mode},
|
||||
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = auto
|
||||
# `dev` narrows what the DEVICE says it will accept, which is the
|
||||
# clamp's second stage - the only way to make the clamp engage on a
|
||||
# target the loop has already bounded at `max_w`.
|
||||
c.dev_min, c.dev_max = ((-LIVE_MAX_W, LIVE_MAX_W) if dev is None
|
||||
else (-dev, dev))
|
||||
# ⚠️ CAPTURED PER CYCLE, BECAUSE THE HEARTBEAT OVERWRITES IT.
|
||||
# `clamp_last` is assigned by every write, and the heartbeat below
|
||||
# writes `inv.target` - 0 W in shadow mode - so the clamp's verdict on
|
||||
# the DECISION is gone by the end of the run. Looking for it afterwards
|
||||
# would grade the refresh instead of the cycle.
|
||||
c.clamp_seen = []
|
||||
for n in range(cycles):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
if c.clamp_last is not None:
|
||||
c.clamp_seen.append(c.clamp_last)
|
||||
# ⚠️ THE HEARTBEAT IS DRIVEN HERE AND NOT LEFT OUT, BECAUSE IT IS
|
||||
# THE HALF THAT PROVES SHADOW MODE IS NOT SILENCE. `_write_unit()`
|
||||
# skips an unchanged value unless `force` is set, so a cycles-only
|
||||
# fixture sees exactly ONE 0 W write and could not tell "holds the
|
||||
# inverter at 0 W" from "wrote 0 W once and went quiet" - and the
|
||||
# second of those leaves a real install's ESP32 in a tripped
|
||||
# stale-input watchdog for the whole run. `heartbeat_s=0` makes
|
||||
# every tick due, which is the shipped path with its clock wound
|
||||
# forward rather than a second implementation of it.
|
||||
await c.send_heartbeats(float(n), 0.0)
|
||||
return hass, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
_live_h, _live_c = _s10("off")
|
||||
_shad_h, _shad_c = _s10("on")
|
||||
|
||||
# --- 8a. THE PRECONDITION, WHICH IS THE HALF THAT MAKES THE REST MEAN ANYTHING
|
||||
# MUTATION.md habit 4: a check whose DATA cannot reach the failure it names is
|
||||
# decoration. "No non-zero value was written" is trivially true of a stimulus
|
||||
# that would not have produced one, so the same stimulus is first shown to
|
||||
# produce a large one with the option off.
|
||||
check("PRECONDITION: with shadow_mode off this stimulus really does drive the "
|
||||
f"wire - {len(_live_h.writes)} writes, "
|
||||
f"{len([w for w in _live_h.writes if w != 0.0])} of them non-zero, "
|
||||
f"largest {max((abs(w) for w in _live_h.writes), default=0):.0f} W",
|
||||
len([w for w in _live_h.writes if w != 0.0]) >= 10
|
||||
and max((abs(w) for w in _live_h.writes), default=0) > 1000.0
|
||||
and 0.0 not in _live_h.writes)
|
||||
|
||||
# --- 8b. NOTHING THE DECISION PATH PRODUCED REACHES THE INVERTER -------------
|
||||
check("AC 1: in shadow mode EVERY value that reached set_number() is 0 W. "
|
||||
f"Distinct values written: {sorted(set(_shad_h.writes))}",
|
||||
_shad_h.writes and set(_shad_h.writes) == {0.0})
|
||||
|
||||
# ⚠️ AND IT IS NOT SILENCE. The inverter holds its last command forever and the
|
||||
# ESP32's stale-input watchdog needs a refresh; a shadow mode that stopped
|
||||
# writing would leave a standing command up under a page saying nothing is
|
||||
# commanded. A check that only asserted "no non-zero write" would pass on that
|
||||
# build too - this is the half that separates them.
|
||||
check("...and it WROTE that 0 W rather than going silent, which is what keeps "
|
||||
f"the inverter from holding its last command ({len(_shad_h.writes)} "
|
||||
"writes)",
|
||||
len(_shad_h.writes) >= 2)
|
||||
|
||||
# --- 8c. THE DECISION PATH ACTUALLY RAN ------------------------------------
|
||||
# "Nothing reached the inverter" is also true of a controller that decided
|
||||
# nothing. Both halves, or the feature is an off switch with a long name.
|
||||
check("the full decision path still ran: the loop reached a target and the "
|
||||
f"arbiter resolved one ({_shad_c.loop_w!r} W, "
|
||||
f"would have commanded {_shad_c.shadow_would_w!r} W)",
|
||||
_shad_c.loop_w is not None and abs(_shad_c.loop_w) > 500.0
|
||||
and _shad_c.shadow_would_w is not None
|
||||
and abs(_shad_c.shadow_would_w) > 500.0)
|
||||
check("...and the decision it recorded is a number a live controller would "
|
||||
"have written - it is inside the rail and it is not the 0 W that went "
|
||||
"on the wire",
|
||||
_shad_c.shadow_would_w != 0.0
|
||||
and abs(_shad_c.shadow_would_w) <= LIVE_MAX_W)
|
||||
|
||||
# --- 8d. THE CARRIED COMMAND TRACKS THE WIRE, WHICH IS WHAT MAKES THE EXIT
|
||||
# SAFE
|
||||
check("the carried site command is what is STANDING ON THE WIRE, not the "
|
||||
f"decision ({_shad_c.target!r} W standing, "
|
||||
f"{_shad_c.shadow_would_w!r} W decided)",
|
||||
_shad_c.target == 0.0 and _shad_c.shadow_would_w != 0.0)
|
||||
check("...and every unit's share is too, so SAFETY-42's delivery watchdog is "
|
||||
"not handed a share nothing was told to produce",
|
||||
all(i.target == 0.0 for i in _shad_c.inverters))
|
||||
|
||||
|
||||
def _s10_exit(shadow_first):
|
||||
"""Run twelve cycles, force shadow mode off, take ONE more cycle.
|
||||
|
||||
Answers with `inv.last_write` either side of that boundary - WHAT STANDS ON
|
||||
THE WIRE, updated only on an accepted write, which is the number the
|
||||
inverter is actually latched on. Reading `hass.writes` instead would be
|
||||
blind to the converged case, where the controller correctly re-sends
|
||||
nothing at all.
|
||||
|
||||
⚠️ THE COMPARISON IS AGAINST THE SAME RUN THAT NEVER ENTERED SHADOW MODE,
|
||||
not against a constant. `slew_w` bounds a step from the standing command,
|
||||
so "did it resume from the wire or from a fiction" is a question about a
|
||||
DIFFERENCE, and a hard-coded expectation would be the number the code
|
||||
produces compared with itself.
|
||||
"""
|
||||
async def go():
|
||||
hass = _Hass(6000.0, track=True)
|
||||
c = Controller(
|
||||
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
|
||||
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
|
||||
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
|
||||
"shadow_mode": "on" if shadow_first else "off"},
|
||||
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
for _ in range(12):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
before = c.inverters[0].last_write
|
||||
c.o["shadow_mode"] = "off"
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
# ⚠️ NOT `float()`. `last_write` is None until something is ACCEPTED,
|
||||
# and a build in which shadow mode writes nothing at all would turn
|
||||
# this fixture into a TypeError - which MUTATION.md's FCST-06 section
|
||||
# is about: a traceback grades none of the checks after it AND reads
|
||||
# like a survivor. It is handed back as None and named below instead.
|
||||
return before, c.inverters[0].last_write, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
_exit_before, _exit_after, _exit_c = _s10_exit(True)
|
||||
_never_before, _never_after, _never_c = _s10_exit(False)
|
||||
print(f" shadow run: standing {_exit_before!r} W -> {_exit_after!r} W on "
|
||||
"the first live cycle")
|
||||
print(f" never in shadow: standing {_never_before!r} W -> "
|
||||
f"{_never_after!r} W on the same cycle")
|
||||
check("SOMETHING REACHED THE WIRE IN SHADOW MODE. `last_write` is set only on "
|
||||
"an ACCEPTED write, so None here is the build that stops writing "
|
||||
"altogether and leaves the inverter latched on its last command - the "
|
||||
"failure shadow mode must not be a new way to reach",
|
||||
_exit_before is not None and _exit_after is not None
|
||||
and _never_before is not None and _never_after is not None)
|
||||
_exit_before = -1.0 if _exit_before is None else float(_exit_before)
|
||||
_exit_after = -1.0 if _exit_after is None else float(_exit_after)
|
||||
_never_before = 0.0 if _never_before is None else float(_never_before)
|
||||
_never_after = 0.0 if _never_after is None else float(_never_after)
|
||||
check("PRECONDITION: the two arrangements DISAGREE - the run that was never in "
|
||||
"shadow mode is standing somewhere else entirely at that boundary, so "
|
||||
"the comparison below is not a number against itself",
|
||||
_never_before != _exit_before and abs(_never_before) > 1000.0)
|
||||
check("PRECONDITION: and the shadow run really was standing at 0 W, so the "
|
||||
"step below is measured from the wire and not from a coincidence",
|
||||
_exit_before == 0.0)
|
||||
check("AC 2, leaving: the first live command after shadow mode is one slew "
|
||||
"step from the 0 W that was actually standing on the wire - NOT one step "
|
||||
"from the decision shadow mode reached, which never existed at the "
|
||||
f"battery. |{_exit_after:.0f}| <= slew 1000 W",
|
||||
abs(_exit_after) <= 1000.0)
|
||||
check("...and the run that was never in shadow mode is NOT inside that bound "
|
||||
"at the same cycle, which is what says the bound is a real constraint "
|
||||
"here and not a property of the stimulus",
|
||||
abs(_never_after) > 1000.0)
|
||||
|
||||
|
||||
def _s10_enter():
|
||||
"""Live first, then shadow mode, on a battery standing somewhere else."""
|
||||
async def go():
|
||||
hass = _Hass(6000.0, track=True)
|
||||
c = Controller(
|
||||
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
|
||||
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
|
||||
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
|
||||
"shadow_mode": "off"}, hass, _Store(), _Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||
for _ in range(12):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
standing = hass.writes[-1]
|
||||
c.o["shadow_mode"] = "on"
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
# ⚠️ `inverters[0].last_write`, NOT `hass.writes[-1]`. The question is
|
||||
# WHAT THE INVERTER IS LATCHED ON after the first shadow cycle, and a
|
||||
# build that writes NOTHING leaves it latched on the live value while
|
||||
# `hass.writes[-1]` still reads that same number - the two look
|
||||
# identical from the write list and are opposite at the battery.
|
||||
return standing, float(c.inverters[0].last_write), c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
_stood, _first_shadow, _enter_c = _s10_enter()
|
||||
print(f" standing when shadow mode came on: {_stood:.0f} W")
|
||||
check("PRECONDITION: the battery was standing at a real, non-zero command when "
|
||||
"shadow mode was switched on - otherwise 'it reached 0 W' says nothing",
|
||||
abs(_stood) > 500.0)
|
||||
check("AC 2, entering: after ONE shadow cycle the inverter is latched on 0 W, "
|
||||
"not on what it was doing a cycle earlier. The inverter holds its last "
|
||||
"command FOREVER, so a shadow mode that merely stopped writing would "
|
||||
"leave that command standing under a page saying nothing is commanded "
|
||||
f"({_stood:+.0f} W -> {_first_shadow:+.0f} W)",
|
||||
_first_shadow == 0.0)
|
||||
|
||||
# --- 8e. THE OTHER GUARDS ON THE WRITE PATH STILL OBSERVE --------------------
|
||||
# The substitution is the LAST thing before the wire, below SAFETY-28's gate and
|
||||
# below SAFETY-03's clamp, so both still grade the number the decision path
|
||||
# produced. A shadow run that could not say "it would have hit the rating here"
|
||||
# is answering a different question from the one it is for.
|
||||
_cl_h, _cl_c = _s10("on", cycles=12, grid=9000.0, dev=400.0)
|
||||
_cl_req = [r.requested_w for r in _cl_c.clamp_seen]
|
||||
print(f" decision {_cl_c.shadow_would_w!r} W; what the clamp was asked "
|
||||
f"about at each cycle boundary: {sorted(set(_cl_req))}")
|
||||
check("PRECONDITION: the decision is a real, non-zero number and the wire "
|
||||
"carried only 0 W on this run, so the two are distinguishable",
|
||||
_cl_c.shadow_would_w is not None and _cl_c.shadow_would_w != 0.0
|
||||
and set(_cl_h.writes) == {0.0})
|
||||
check("SAFETY-03 still OBSERVES in shadow mode: the clamp was asked about the "
|
||||
"number THE DECISION PATH PRODUCED, not about the 0 W that went on the "
|
||||
"wire. That is what puts the substitution BELOW the clamp, and it is "
|
||||
"what lets a shadow run report that the rating would have been hit",
|
||||
_cl_req and all(r == _cl_c.shadow_would_w for r in _cl_req))
|
||||
check("...and the clamp's own alarm still ran on it - the winding-loop "
|
||||
"engagement reached the action log on a run where nothing was commanded",
|
||||
any("clamp" in e.lower() or "CLAMP" in e for e in _cl_c.events))
|
||||
check("SAFETY-28's gate still OBSERVES too - it refused nothing on this run, "
|
||||
"which is the same answer it gives live, so shadow mode has not been "
|
||||
"implemented by making every write unauthorised",
|
||||
not [e for e in _cl_c.events if "WRITE REFUSED" in e])
|
||||
|
||||
# ⚠️ AND SAFETY-18 IS GRADED ON WHAT IT WAS HANDED, WHICH IS AN ORDERING
|
||||
# CLAIM. `check_meter_frozen()` takes `self.target` - the arbiter's RESOLVED
|
||||
# INTENT - and `_shadow_settle()` re-seeds that to 0 W at the very end of the
|
||||
# cycle, AFTER it. Re-seed one line earlier and the freeze detector grades a
|
||||
# 0 W that no decision produced: its conjunction needs a command at the rail,
|
||||
# so it goes permanently blind and the whole suite stays green. The detector's
|
||||
# own `evaluate` is wrapped here because the arguments are the only place that
|
||||
# ordering is observable.
|
||||
_fz_seen = []
|
||||
_fz_real = _shad_c.freeze_wd.evaluate
|
||||
|
||||
|
||||
def _fz_spy(unchanged, target, *a, **k):
|
||||
_fz_seen.append(target)
|
||||
return _fz_real(unchanged, target, *a, **k)
|
||||
|
||||
|
||||
_shad_c.freeze_wd.evaluate = _fz_spy
|
||||
|
||||
|
||||
async def _one_more():
|
||||
await _shad_c.read_inputs()
|
||||
await _shad_c.cycle()
|
||||
|
||||
|
||||
asyncio.run(_one_more())
|
||||
_shad_c.freeze_wd.evaluate = _fz_real
|
||||
print(f" SAFETY-18 was handed: {_fz_seen}")
|
||||
check("PRECONDITION: the freeze detector was consulted at all on that cycle",
|
||||
len(_fz_seen) == 1)
|
||||
check("SAFETY-18 still OBSERVES the DECISION: it is handed the arbiter's "
|
||||
"resolved intent, not the 0 W the wire gate substituted. Re-seeding one "
|
||||
"line earlier would hand it a command no decision produced and its "
|
||||
"conjunction - a command at the rail against a meter that has stopped "
|
||||
"moving - could never be satisfied again",
|
||||
_fz_seen and _fz_seen[0] == _shad_c.shadow_would_w
|
||||
and _fz_seen[0] != shadow.WIRE_W)
|
||||
|
||||
# --- 8f. THE MODE TABLE LIVES IN test_validate.py ---------------------------
|
||||
# `shadow.engaged()` is a pure function of the option and the start button, and
|
||||
# the six states it answers for are graded beside the rule that validates the
|
||||
# option - not here, where a defeat of the DEFAULT stops part 3's wired run
|
||||
# reaching the wire at all and takes this whole file down before part 8 runs.
|
||||
# ⚠️ THAT IS MEASURED, NOT ANTICIPATED: the mutation that makes `auto` resolve
|
||||
# to shadow unconditionally aborts part 3 with an IndexError, so its red count
|
||||
# here is a LOWER BOUND and none of part 8 is graded (MUTATION.md, FCST-06).
|
||||
|
||||
# --- 8g. THE OPERATOR STOP, WHICH IS THE ONE FAILSAFE THIS CARD CHANGES ------
|
||||
_stop_live_h, _stop_live_c = _s10("off", auto=False, cycles=6)
|
||||
_stop_shad_h, _stop_shad_c = _s10("auto", auto=False, cycles=6)
|
||||
print(f" stopped, shadow off: {_stop_live_c.reason!r}")
|
||||
print(f" stopped, shadow on : {_stop_shad_c.reason!r}")
|
||||
check("PRECONDITION: with shadow mode off, a stopped controller files the "
|
||||
"P_SAFETY stop claim and the arbiter resolves to THAT source, at 0 W",
|
||||
_stop_live_c.reason.startswith("safety")
|
||||
and _stop_live_c.target == 0.0)
|
||||
check("in shadow mode that claim is NOT filed, so the arbiter resolves to the "
|
||||
"REAL winner and the recorded decision is the real one. A P_SAFETY claim "
|
||||
"out-ranks everything - left in, every shadow run would answer the wrong "
|
||||
f"question ({_stop_shad_c.reason!r})",
|
||||
_stop_shad_c.reason.startswith("loop")
|
||||
and _stop_shad_c.shadow_would_w is not None
|
||||
and abs(_stop_shad_c.shadow_would_w) > 0.0)
|
||||
check("...and the actuator ends at exactly the same place either way, because "
|
||||
"the substitution that replaced the claim is STRICTLY STRONGER: a claim "
|
||||
"can be out-ranked and a wire gate cannot",
|
||||
set(_stop_shad_h.writes) == {0.0} == set(_stop_live_h.writes))
|
||||
|
||||
# --- 8h. THE RENDERED SURFACE (TARIFF-11's pattern) -------------------------
|
||||
# Graded on `status_payload()`, which IS the status page: web.py's `PAGE` is a
|
||||
# static shell whose banner and rows are filled from this JSON. Named slots, not
|
||||
# substring tests, so a transposition moves a slot.
|
||||
_ROW = "Shadow mode"
|
||||
_shad_pay = _shad_c.status_payload()
|
||||
_live_pay = _live_c.status_payload()
|
||||
_shad_rows = dict(_shad_pay["rows"])
|
||||
_live_rows = dict(_live_pay["rows"])
|
||||
print(f" banner: {_shad_pay['banner']!r}")
|
||||
print(f" row : {_shad_rows.get(_ROW)!r}")
|
||||
check("the status page has a Shadow mode row in BOTH states, so its ABSENCE is "
|
||||
"never how an operator learns which one they are in",
|
||||
_ROW in _shad_rows and _ROW in _live_rows)
|
||||
check("AC 3: the banner states plainly that the battery is not being "
|
||||
"controlled",
|
||||
"SHADOW MODE" in _shad_pay["banner"]
|
||||
and "NOT being controlled" in _shad_pay["banner"])
|
||||
check("...and it is not green. A page that reads `ok` while nothing is being "
|
||||
"commanded is the 'looks healthy but is inert' failure itself",
|
||||
_shad_pay["level"] == "warn")
|
||||
check("...and it is FIRST in the cascade: the same controller has other "
|
||||
"conditions to report and none of them displaces this sentence",
|
||||
_live_pay["banner"] != _shad_pay["banner"]
|
||||
and "SHADOW MODE" not in _live_pay["banner"])
|
||||
_slot = re.search(r"WOULD HAVE COMMANDED ([-+][\d ]+) W, inverter held at "
|
||||
r"([-\d]+) W", _shad_rows[_ROW])
|
||||
check("the row answers 'what would it have done' with a NUMBER in its own "
|
||||
"named slot, and says where the inverter actually is in a second one - "
|
||||
"two slots in one sentence, so a transposition moves them",
|
||||
_slot is not None)
|
||||
if _slot is not None:
|
||||
_would = float(_slot.group(1).replace(" ", ""))
|
||||
check("...and that number is the decision the path reached, not the 0 W "
|
||||
f"standing on the wire ({_would:.0f} vs {_shad_c.target:.0f})",
|
||||
_would == round(float(_shad_c.shadow_would_w))
|
||||
and _would != _shad_c.target)
|
||||
check("...and the second slot is the STANDING command, so the two cannot "
|
||||
"be read as one number",
|
||||
float(_slot.group(2)) == 0.0)
|
||||
check("the row answers 'and why' by carrying the arbiter's own sentence, "
|
||||
"rather than a re-derivation of it",
|
||||
_shad_c.reason in _shad_rows[_ROW])
|
||||
check("with shadow mode off the row says so, and says what follows from it - "
|
||||
"which is the arrangement that makes the one above a claim about this "
|
||||
"controller and not about the string",
|
||||
"NOT in shadow mode" in _live_rows[_ROW]
|
||||
and "DOES reach the inverter" in _live_rows[_ROW]
|
||||
and "WOULD HAVE COMMANDED" not in _live_rows[_ROW])
|
||||
|
||||
# ⚠️ A STANDING LATCH IS NAMED RATHER THAN HIDDEN. The shadow banner sits above
|
||||
# the three `bad` branches, so it has to carry what they would have said - a
|
||||
# latch that is up while the controller is inert is up the moment it stops
|
||||
# being inert.
|
||||
_shad_c.inverters[0].sign_wd.tripped = True
|
||||
_latched = _shad_c.status_payload()
|
||||
print(f" banner with a latch up: {_latched['banner']!r}")
|
||||
check("a failsafe latch that is standing is NAMED in the shadow banner and "
|
||||
"turns it red, so nobody turns shadow mode off into a battery that will "
|
||||
"not move and a page that never mentioned why",
|
||||
_latched["level"] == "bad"
|
||||
and "LATCH IS STILL UP" in _latched["banner"]
|
||||
and _shad_c.inverters[0].name in _latched["banner"])
|
||||
check("...and it still says the battery is not being controlled, so the latch "
|
||||
"has not displaced the sentence that may never be displaced",
|
||||
"NOT being controlled" in _latched["banner"])
|
||||
_shad_c.inverters[0].sign_wd.tripped = False
|
||||
|
||||
# --- 8i. THE RECORD A LATER ANALYSIS READS ----------------------------------
|
||||
# A shadow run is only worth running if it answers "what would it have done,
|
||||
# and why" AFTERWARDS, from the 40-row action log, and not only on a page
|
||||
# somebody happened to be looking at.
|
||||
_shadow_lines = [e for e in _shad_c.events if "SHADOW" in e]
|
||||
print(f" action log, shadow lines: {len(_shadow_lines)} of "
|
||||
f"{len(_shad_c.events)}")
|
||||
check("the action log records that shadow mode came on, in words",
|
||||
any("SHADOW MODE ON" in e for e in _shadow_lines))
|
||||
check("...and what it would have commanded, with the arbiter's reason beside "
|
||||
"it",
|
||||
any("would have commanded" in e and "loop" in e for e in _shadow_lines))
|
||||
check("...EDGE-logged, not level-logged: 25 cycles did not flush the 40-row "
|
||||
"log that has to carry the context of what the run found",
|
||||
len(_shadow_lines) < 25 and len(_shad_c.events) <= 40)
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -522,7 +522,20 @@ for _ in range(20):
|
||||
pinned = [r for r in rows if r[3] == "saturated-freeze"]
|
||||
check(f"non-zero prev_w, another writer: pinned at {pinned[0][1]:.0f} W...",
|
||||
pinned and all(r[1] == -800.0 for r in pinned))
|
||||
check(f"...then released, settling at {prev:.0f} W", prev == -2390.0)
|
||||
# ⚠️ SAFETY-48 MOVED THIS NUMBER FROM -2390 W TO -1200 W, DELIBERATELY, AND
|
||||
# THE PROPERTY IT IS HERE FOR IS UNCHANGED. The release lets the command walk
|
||||
# to the FROZEN INTEGRATOR; SAFETY-48 bounds how far that integrator can have
|
||||
# wound ahead of the command before the freeze engaged, so the value it walks
|
||||
# to is smaller. What this run must still show is that the release happens and
|
||||
# that it stops at the integrator - both asserted, the second against `i_w`
|
||||
# rather than against a literal, because the literal is the half this card
|
||||
# moves. The cost is stated rather than smoothed: on a persistent second writer
|
||||
# this run buys back 1200 W of a 2000 W export where release/1.0 bought back
|
||||
# 2390 W. DECISIONS/SAFETY-48 §5 records it as the price of the bound and the
|
||||
# rejected alternative that avoids it.
|
||||
check(f"...then released, settling at {prev:.0f} W, at the frozen integrator "
|
||||
f"({i_w:.0f} W) - release/1.0 settled at -2390 W, at its own unbounded "
|
||||
f"one", prev == -1200.0 and prev == i_w)
|
||||
|
||||
# --- 5. THE CARD'S OWN CASE, DRIVEN END TO END -----------------------------
|
||||
# The live install's shape (max_w 5000, slew_w 400, target_grid_w -10). The site
|
||||
@@ -546,9 +559,14 @@ check(f"the card's case: the charge starts at cycle {started[0]}"
|
||||
# that divergence into windup. It cannot, because releasing the OUTPUT freeze
|
||||
# does not release the INTEGRATOR freeze - so the command can only walk, one
|
||||
# slew step per cycle, to a value the integrator stopped accumulating at.
|
||||
# ⚠️ SAFETY-48 MOVED THIS ONE TOO, -1190 W -> -800 W against an integrator that
|
||||
# stood at -1194 W and now stands at -800 W, for the reason above. AC 4's
|
||||
# property - the release stops at the integrator and not at the rail - is what
|
||||
# is asserted; the literal is the branch's own number and is printed.
|
||||
check(f"the card's case: it settles at {prev:.0f} W, at the frozen integrator "
|
||||
f"({i_w:.0f} W) and not the {TL.max_w:.0f} W rail",
|
||||
prev == -1190.0 and abs(prev) <= abs(i_w) and abs(prev) < TL.max_w)
|
||||
f"({i_w:.0f} W) and not the {TL.max_w:.0f} W rail - release/1.0 settled "
|
||||
f"at -1190 W against an integrator of -1194 W",
|
||||
prev == -800.0 and abs(prev) <= abs(i_w) and abs(prev) < TL.max_w)
|
||||
steps = [abs(cmds[i] - cmds[i - 1]) for i in range(1, len(cmds))]
|
||||
check(f"the card's case: no cycle moves more than the slew limit "
|
||||
f"(worst {max(steps):.0f} W of {slew_limit_w(TL):.0f})",
|
||||
@@ -1115,25 +1133,58 @@ check("⭐ FIXED, ON THE THING THE SLEW ACTUALLY OWNS: following the rail remove
|
||||
_R["live"]["big"] > 0 and _R["rail"]["big"] == 0
|
||||
and _R["interim"]["big"] == 0)
|
||||
|
||||
# ⚠️ AND WHAT IT DOES NOT BUY, MEASURED, ASSERTED, AND SAID BEFORE ANYBODY CAN
|
||||
# READ MORE INTO THIS CARD THAN IT DELIVERED. `slew_w` bounds the OUTPUT; the
|
||||
# integrator accumulates `gain * error` per cycle and is bounded only by
|
||||
# `integrator_max_w`. At gain 0.6 into a 3.4 kW error the integrator moves
|
||||
# ~2000 W per tick and reaches a 5000 W rail in under three ticks WHATEVER
|
||||
# `slew_w` is. So the over-absorption is a GAIN term, and slowing the slew does
|
||||
# not reduce it - on this model it gets slightly WORSE, because the command
|
||||
# spends longer away from where it needs to be while the integrator keeps
|
||||
# winding. admin/goodwe's rig, a different plant model, agrees: over-absorption
|
||||
# 990 W at slew 1000 and 1086 W at slew 250, against 1010 W at gain 0.6 and
|
||||
# 863 W at gain 0.3 (tools/slew_sim.py).
|
||||
# --- SAFETY-48 RE-BASELINED THIS CHECK, AND THE INVERSION IS THE EVIDENCE ----
|
||||
#
|
||||
# `gain` is therefore the next constant to re-derive. SAFETY-39 measured it and
|
||||
# CHANGED NOTHING: it is a live control-law constant on a running house, and two
|
||||
# of them moving in one release makes the next measurement uninterpretable.
|
||||
check("⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION, AND THIS CHECK IS HERE SO "
|
||||
"THAT NOBODY READS SAFETY-39 AS 'THE OVERSHOOT IS FIXED'. Quartering the "
|
||||
"slew from 1000 to 250 does not reduce the over-absorption at all",
|
||||
_R["slower"]["over"] >= _R["live"]["over"])
|
||||
# ⚠️ WHAT THIS LINE USED TO SAY, AND WHY IT IS NOT A SILENT EDIT. SAFETY-39
|
||||
# shipped:
|
||||
#
|
||||
# "⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION ... Quartering the slew
|
||||
# from 1000 to 250 does not reduce the over-absorption at all"
|
||||
# asserted as _R["slower"]["over"] >= _R["live"]["over"]
|
||||
#
|
||||
# and the reasoning under it was right about the mechanism and therefore wrong
|
||||
# about the conclusion: `slew_w` bounds the OUTPUT while the integrator
|
||||
# accumulates `gain * error` and is bounded only by `integrator_max_w`, so
|
||||
# slowing the slew leaves the integrator winding for LONGER and the
|
||||
# over-absorption got WORSE. Measured on this model at release/1.0
|
||||
# (`68c5ede`), worst case over the same 51-step family:
|
||||
#
|
||||
# slew_w 1000 over-absorbed 1005 W
|
||||
# slew_w 0 (rail, 556) 1109 W <- SLOWER IS WORSE
|
||||
# slew_w 400 1285 W
|
||||
# slew_w 250 1330 W
|
||||
#
|
||||
# SAFETY-48 bounds the integrator's LEAD at one slew step, which is exactly the
|
||||
# quantity that sentence describes. The same six runs on this branch:
|
||||
#
|
||||
# slew_w 1000 over-absorbed 729 W
|
||||
# slew_w 0 (rail, 556) 550 W <- AND NOW SLOWER IS BETTER
|
||||
# slew_w 400 424 W
|
||||
# slew_w 250 326 W
|
||||
#
|
||||
# So the claim is inverted rather than weakened, on a plant model this file
|
||||
# writes itself and SAFETY-48's 15.7-day replay of the live install's recorded
|
||||
# disturbances has never seen. The two agree on the sign, which is worth more
|
||||
# than either alone. ⚠️ The old direction is asserted as a NEGATIVE below so
|
||||
# that a revert of SAFETY-48 cannot quietly restore it and leave this section
|
||||
# green on the other law.
|
||||
#
|
||||
# `gain` is still the next constant to re-derive, and SAFETY-48 CHANGED NOTHING
|
||||
# ABOUT IT: it is a live control-law constant on a running house, and two of
|
||||
# them moving in one release makes the next measurement uninterpretable.
|
||||
check("⭐ SAFETY-48: WITH THE INTEGRATOR'S LEAD BOUNDED, A SLOWER SLEW REDUCES "
|
||||
"THE OVER-ABSORPTION - 1000 -> rail -> 400 -> 250 falls monotonically, "
|
||||
f"{_R['live']['over']:.0f} -> {_R['rail']['over']:.0f} -> "
|
||||
f"{_R['interim']['over']:.0f} -> {_R['slower']['over']:.0f} W. On "
|
||||
"release/1.0 the same four runs RISE: 1005 -> 1109 -> 1285 -> 1330 W",
|
||||
_R["rail"]["over"] < _R["live"]["over"]
|
||||
and _R["interim"]["over"] < _R["rail"]["over"]
|
||||
and _R["slower"]["over"] < _R["interim"]["over"])
|
||||
check("...and every member of the family over-absorbs LESS than release/1.0's "
|
||||
"own figure for that member - the recorded four are 1005, 1109, 1285 and "
|
||||
"1330 W, taken on 68c5ede by running this file",
|
||||
_R["live"]["over"] < 1005.0 and _R["rail"]["over"] < 1109.0
|
||||
and _R["interim"]["over"] < 1285.0 and _R["slower"]["over"] < 1330.0)
|
||||
check("...while the GAIN does own it: 0.6 -> 0.3 -> 0.2 falls monotonically, at "
|
||||
"the same slew. That is the measurement behind 'gain is the next constant "
|
||||
"to re-derive', and it is a measurement rather than an opinion",
|
||||
|
||||
@@ -49,10 +49,14 @@ before you report a survivor.
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import glob
|
||||
import gzip
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
@@ -500,11 +504,13 @@ for t in range(40, 50):
|
||||
_after_zero.append(_lat.reading())
|
||||
check("PRECONDITION: it is stale before the site is stopped",
|
||||
_was and _lat.reading() is None)
|
||||
check("⚠️ AND IT STAYS STALE ONCE THE SITE COMMANDS 0 W. The conjunction is no "
|
||||
"longer armed - |standing| is 0 W, below the 250 W term - so an unlatched "
|
||||
"verdict would hand the held -1200 W straight back and the site would "
|
||||
"restart into it, then stop, then restart, once per read",
|
||||
all(v is None for v in _after_zero))
|
||||
check("⚠️ AND IT STAYS STALE ONCE THE SITE COMMANDS 0 W, for RELEASE_S. The "
|
||||
"conjunction is no longer armed - |standing| is 0 W, below the 250 W "
|
||||
"term - so an unlatched verdict would hand the held -1200 W straight "
|
||||
"back and the site would restart into it, then stop, then restart, once "
|
||||
"per read. The ten reads here are inside the 17.0 s SAFETY-57 waits",
|
||||
all(v is None for v in _after_zero) and len(_after_zero) == 10
|
||||
and 10 < F.RELEASE_S)
|
||||
# ⚠️ AND A FAILED READ MUST NOT CLEAR IT EITHER. This check exists because the
|
||||
# mutation that added `self.stale = False` to `submit()`'s None branch SURVIVED
|
||||
# the whole sweep - no fixture had ever injected a read failure while latched.
|
||||
@@ -526,11 +532,26 @@ check("⚠️ A FAILED READ DOES NOT CLEAR THE LATCH - found by a SURVIVING "
|
||||
_after_fail is None and _lat_fail.stale
|
||||
and _lat_fail.reading() is None)
|
||||
|
||||
check("⚠️ AND ONLY MOVEMENT CLEARS IT - a different number arriving, which is "
|
||||
"exactly the evidence the verdict was about. Not a timer, not a restart, "
|
||||
"not the operator's start button",
|
||||
(lambda f: (f.submit(A_FROZEN_W + 1.0, 0.0, 60.0),
|
||||
(not f.stale) and f.reading() == A_FROZEN_W + 1.0)[-1])(_lat))
|
||||
# ⚠️ THIS CHECK USED TO READ "AND ONLY MOVEMENT CLEARS IT ... Not a timer, not
|
||||
# a restart, not the operator's start button", AND SAFETY-57 IS THE CARD THAT
|
||||
# FALSIFIED IT. It was driven on `_lat` - already 20 s into a 0 W rest - so
|
||||
# under the shipped exit it would now pass having never tested movement at all,
|
||||
# which is habit 9: a check that goes on being true for a reason it does not
|
||||
# name. It is rebuilt here on a FRESH object that has never rested, so the only
|
||||
# thing that can clear it is the arrival.
|
||||
_mov = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_mov.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
_mov_was = _mov.stale
|
||||
_mov.submit(A_FROZEN_W + 1.0, 1300.0, 40.0) # the register moves one watt
|
||||
check("⚠️ MOVEMENT CLEARS IT WHILE THE OBLIGATION IS STILL STANDING - a "
|
||||
"different number arriving under a command that has NOT rested, which is "
|
||||
"exactly the evidence the verdict was about. Not a restart, not the "
|
||||
"operator's start button. SAFETY-57's timer cannot be what cleared this "
|
||||
"one: the command was +1300 W on every read, so the release clock never "
|
||||
"started",
|
||||
_mov_was and not _mov.stale and _mov.reading() == A_FROZEN_W + 1.0
|
||||
and _mov.releases == 0 and _mov.rested_since is None)
|
||||
# ⚠️ THE MAGNITUDE TERM OF THE CONJUNCTION, AND THIS CHECK EXISTS BECAUSE A
|
||||
# MUTATION SURVIVED WITHOUT IT. Deleting `abs(standing_w) < move_w` from
|
||||
# `_grade()` left every other check in this file green, because in all of them a
|
||||
@@ -542,12 +563,12 @@ _rest = Freshness(BOUNDS["batt"])
|
||||
# BEFORE the command settles - and then the site is commanded to 0 W and the
|
||||
# register sits at +5 W, correctly, for a minute.
|
||||
_rest.submit(1290.0, 1300.0, 0.0)
|
||||
_rest.submit(5.0, 1300.0, 1.0) # standing_at_change captures 1300 W
|
||||
_rest.submit(5.0, 1300.0, 1.0) # standing_at_grade captures 1300 W
|
||||
for t in range(2, 60):
|
||||
_rest.submit(5.0, 0.0, float(t)) # site at rest; register truthfully +5 W
|
||||
check("⚠️ THE MAGNITUDE TERM IS LOAD-BEARING, and it took a surviving mutation "
|
||||
"to show it. A register that reached +5 W while the old +1300 W command "
|
||||
"was still standing has a `standing_at_change` of 1300 W, so the TRAVEL "
|
||||
"was still standing has a `standing_at_grade` of 1300 W, so the TRAVEL "
|
||||
"term alone reads 1300 W of unfollowed command for the whole minute the "
|
||||
"site rests - and calls a perfectly truthful reading dead. Requiring "
|
||||
"|standing| >= 250 W as well is what says 'a site at rest is not owed "
|
||||
@@ -564,7 +585,7 @@ check("...and the hole that term leaves CANNOT MISLEAD EITHER CONSUMER, which "
|
||||
check("...and one watt is enough, because 0x0518 is a 1 W register that "
|
||||
"dithers +/-1 W while holding (goodwe/NOTES.md §5.11, measured "
|
||||
"2026-08-22) - a working register clears this on its next 10 s publish",
|
||||
_lat.held == A_FROZEN_W + 1.0 and not _lat.stale)
|
||||
_mov.held == A_FROZEN_W + 1.0 and not _mov.stale)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -655,6 +676,7 @@ _unit_min_w = option_default("unit_sign_disagree_w")
|
||||
_ceiling = min(_circ_hold, _unit_hold)
|
||||
print(f" floor {BATT_OBLIGED_S:.1f} s = settle 6.0 + publish 10.0 + read 1.0")
|
||||
print(f" bound {BATT_UNCHANGED_S:.1f} s")
|
||||
print(f" exit {F.RELEASE_S:.1f} s (SAFETY-57, the same obligation window)")
|
||||
print(f" ceil {_ceiling:.1f} s = min(circulation_hold_s {_circ_hold:.0f}, "
|
||||
f"unit_sign_disagree_s {_unit_hold:.0f})")
|
||||
|
||||
@@ -690,6 +712,24 @@ check("⚠️ AND `MOVE_W` IS PINNED TO config.yaml TOO. It is the largest value
|
||||
"raise `unit_sign_disagree_w` and the gate would stop arming in states "
|
||||
"that check still fires in, which this catches",
|
||||
MOVE_W == _unit_min_w)
|
||||
check("⚠️ AND SAFETY-57'S EXIT SITS INSIDE THAT SAME BAND, AT ITS FLOOR - the "
|
||||
"mirror of MOVE_W taking its own band's ceiling. The question a release "
|
||||
"answers is 'has the register had its chance to answer the command "
|
||||
"standing now?', which is the obligation window and nothing else, so the "
|
||||
"exit is exactly the floor and strictly shorter than the bound whose "
|
||||
"verdict it withdraws. An exit at or above the bound would mean the site "
|
||||
"spends longer parked than the evidence that parked it took to gather",
|
||||
F.RELEASE_S == BATT_OBLIGED_S and F.RELEASE_S < BATT_UNCHANGED_S
|
||||
and BOUNDS["batt"].release_s == F.RELEASE_S)
|
||||
check("⚠️ AND THE WHOLE PARKED INTERVAL IS NOW BOUNDED BY TWO SHIPPED NUMBERS "
|
||||
"AND NOTHING ELSE: `stale_input_s` (15 s, config.yaml) then `release_s` "
|
||||
"(17.0 s) = 32 s. The schema caps `stale_input_s` at 120, so even the "
|
||||
"worst accepted configuration parks for 137 s - where the live episode "
|
||||
"parked for 790 s and had no upper bound at all",
|
||||
float(option_default("stale_input_s")) + F.RELEASE_S == 32.0
|
||||
and "stale_input_s: int(5,120)" in io.open(
|
||||
HERE / "config.yaml", encoding="utf-8").read()
|
||||
and 120.0 + F.RELEASE_S == 137.0)
|
||||
check("...and the module's three floor terms are the numbers this repository "
|
||||
"actually carries, not a transcription of them: the ESPHome publish "
|
||||
"interval is read back out of the shipped firmware",
|
||||
@@ -734,7 +774,7 @@ _uses_batt = bounds_users("batt")
|
||||
_uses_soc = bounds_users("soc")
|
||||
for _k, _b in BOUNDS.items():
|
||||
print(f" {_k:5} wired={_b.wired!s:5} unchanged={_b.unchanged_s:>6} "
|
||||
f"move={_b.move_w:>6} {_b.signal}")
|
||||
f"move={_b.move_w:>6} release={_b.release_s:>6} {_b.signal}")
|
||||
print(f" BOUNDS['batt'] subscripted in CODE by: {_uses_batt}")
|
||||
print(f" BOUNDS['soc'] subscripted in CODE by: {_uses_soc or 'nothing'}")
|
||||
|
||||
@@ -752,10 +792,17 @@ check("...and the SoC row says what TEL-08 owes, in the row itself, so the "
|
||||
"energy" in BOUNDS["soc"].derivation
|
||||
and "153.6 Wh" in BOUNDS["soc"].derivation
|
||||
and "TEL-08" in BOUNDS["soc"].derivation)
|
||||
check("...and every wired bound is a real number - an infinite bound is how a "
|
||||
"row silently becomes decorative",
|
||||
check("...and every wired bound is a real number IN ALL THREE COLUMNS - an "
|
||||
"infinite bound is how a row silently becomes decorative, and SAFETY-57's "
|
||||
"`release_s` is the newest place that can happen: at `inf` the module is "
|
||||
"byte-for-byte the one that parked the live site for 790 s",
|
||||
all(b.unchanged_s < float("inf") and b.move_w < float("inf")
|
||||
and b.release_s < float("inf")
|
||||
for b in BOUNDS.values() if b.wired))
|
||||
check("...and the UNWIRED row is infinite in all three, so nothing about "
|
||||
"SoC's absent bound can be read as a policy that exists",
|
||||
all(b.unchanged_s == b.move_w == b.release_s == float("inf")
|
||||
for b in BOUNDS.values() if not b.wired))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -809,6 +856,96 @@ check("⚠️ AND THE LONG RUNS ARE ALL UNDER AN UNMOVING LOAD, which is what th
|
||||
max(_runs)[1] == -899.0)
|
||||
|
||||
|
||||
# --- 8b. AND THE EXIT THE BOUND USED TO HAVE, ON THE SAME HOUSE -------------
|
||||
# ⚠️ THIS IS THE MEASUREMENT SAFETY-57 RESTS ON, and it is about the REGISTER
|
||||
# rather than about any code: before this card the only way out of the verdict
|
||||
# was "an arrival carrying a different number", so the length of the state IS
|
||||
# the time to 0x0518's next change. The committed 16-day loop capture is
|
||||
# replayed at 1 Hz through the SHIPPED conjunction to find the trips, and each
|
||||
# trip is then measured against the register's own next move.
|
||||
#
|
||||
# ⚠️ WHAT THIS 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 only the one that does not depend on the
|
||||
# command at all: how long this register goes without changing value. If
|
||||
# anything 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.
|
||||
_LOOPDIR = HERE / "scenarios" / "loop-2026-09-02_2026-09-17"
|
||||
_loop_days = sorted({Path(f).name.rsplit("-", 1)[0]
|
||||
for f in glob.glob(str(_LOOPDIR / "*-batt.jsonl.gz"))})
|
||||
|
||||
|
||||
def _loop_series(day, sig):
|
||||
out = []
|
||||
with gzip.open(_LOOPDIR / f"{day}-{sig}.jsonl.gz", "rt") as fh:
|
||||
for line in fh:
|
||||
row = json.loads(line)
|
||||
try:
|
||||
v = float(row["v"])
|
||||
except ValueError:
|
||||
continue # 'unavailable' / 'unknown' are not readings
|
||||
out.append((datetime.fromisoformat(row["t"]).timestamp(), v))
|
||||
return out
|
||||
|
||||
|
||||
_t8b = time.monotonic()
|
||||
_waits, _trips8b = [], 0
|
||||
for _day in _loop_days:
|
||||
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
|
||||
if not _b or not _s:
|
||||
continue
|
||||
_moves = [r[0] for i, r in enumerate(_b) if i == 0 or r[1] != _b[i - 1][1]]
|
||||
_f8b, _prev8b = Freshness(BOUNDS["batt"]), False
|
||||
_ib = _is = 0
|
||||
_t = max(_b[0][0], _s[0][0])
|
||||
_end = min(_b[-1][0], _s[-1][0])
|
||||
while _t <= _end:
|
||||
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
|
||||
_ib += 1
|
||||
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
|
||||
_is += 1
|
||||
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
|
||||
if _f8b.stale and not _prev8b:
|
||||
_trips8b += 1
|
||||
_nxt = next((m for m in _moves if m > _t), None)
|
||||
if _nxt is not None:
|
||||
_waits.append(_nxt - _t)
|
||||
_prev8b = _f8b.stale
|
||||
_t += 1.0
|
||||
_waits.sort()
|
||||
_p50 = _waits[len(_waits) // 2]
|
||||
print(f" {len(_loop_days)} days replayed at 1 Hz in "
|
||||
f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips")
|
||||
print(f" wait for the register's next change after a trip: "
|
||||
f"min {_waits[0]:.1f} s median {_p50:.1f} s max {_waits[-1]:.1f} s; "
|
||||
f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's "
|
||||
f"710 s")
|
||||
|
||||
check("PRECONDITION: all sixteen committed days were replayed, not whichever "
|
||||
"files happened to be present",
|
||||
len(_loop_days) == 16 and _loop_days[0] == "2026-09-02"
|
||||
and _loop_days[-1] == "2026-09-17" and len(_waits) == _trips8b)
|
||||
check("⚠️ THE OLD EXIT WAS NOT RARE AND IT WAS NOT SHORT. Ninety-three trips in "
|
||||
"sixteen days of this house, and the only thing that could clear any of "
|
||||
"them - the register carrying a different number - took a MEDIAN of "
|
||||
"143.4 s and as long as 21440.1 s. Seventeen of the ninety-three are at "
|
||||
"or beyond the 710 s the live site was parked for on 2026-09-21",
|
||||
_trips8b == 93 and abs(_p50 - 143.4) < 0.1
|
||||
and abs(_waits[-1] - 21440.1) < 0.1
|
||||
and sum(1 for w in _waits if w >= 710.0) == 17)
|
||||
check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE - "
|
||||
"which is the whole reason the exit could not stay as it was. The short "
|
||||
"waits are the pack's keep-alive pulse twitching 0x0518 and satisfying "
|
||||
"the clearing rule BY ACCIDENT: a quarter of them are under 20 s, and "
|
||||
"nothing in this add-on asked for any of them. After SAFETY-57 the wait "
|
||||
"is `stale_input_s + release_s` = 32 s whatever the register does",
|
||||
sum(1 for w in _waits if w < 20.0) >= 20
|
||||
and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("9. KNOWN SURVIVORS - graded, so they are residuals and not surprises")
|
||||
@@ -871,6 +1008,627 @@ check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
|
||||
"each unit's own command travel and each unit's own number",
|
||||
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
|
||||
|
||||
# S5. SAFETY-57's own, and it is a FALSE POSITIVE that predates this card.
|
||||
# The unchanged clock keeps running while the site rests, so the FIRST read
|
||||
# at which a command travels 250 W after a long quiet already satisfies all
|
||||
# three terms - the register is given no time at all to answer the new
|
||||
# command. Present on the shipped tree; the live capture in part 10 contains
|
||||
# one (10:48:14, cleared one second later).
|
||||
_s5 = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 300): # five minutes of a resting site
|
||||
_s5.submit(-3.0, 0.0, float(t))
|
||||
_s5_quiet = _s5.stale
|
||||
_s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W
|
||||
check("⚠️ SURVIVOR 5, FALSE POSITIVE, AND IT IS OLDER THAN SAFETY-57: after a "
|
||||
"long rest the unchanged clock is already past 35 s, so the very FIRST "
|
||||
"read carrying a travelled command trips the gate - before the 17.0 s in "
|
||||
"which a healthy register could have answered it. The gate accuses on "
|
||||
"read one and the register answers on read eleven",
|
||||
not _s5_quiet and _s5.stale and _s5.graded_s(300.0) == 300.0)
|
||||
check("...and its SIZE is one `stale_input_s` hold, which is why it is recorded "
|
||||
"here rather than fixed here: `cycle()` returns at `inputs missing "
|
||||
"{n}s` for the whole of it, so nothing is commanded differently, and the "
|
||||
"register's next publish clears it. 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",
|
||||
(lambda f: (f.submit(-4.0, 1500.0, 311.0), not f.stale)[-1])(_s5)
|
||||
and float(option_default("stale_input_s")) >= 5.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("10. SAFETY-57: THE EXIT. The state the verdict creates, and the way out")
|
||||
|
||||
# ⚠️ THE ACCEPTANCE CRITERION OF THIS CARD, IN ONE SENTENCE, AND EVERY CHECK
|
||||
# BELOW IS GRADED AGAINST IT: a scenario in which the register is held CONSTANT
|
||||
# and the command is gated must recover WITH NO CHANGE TO THE REGISTER. Parts 1
|
||||
# to 9 grade whether the gate can be ENTERED. A check that only shows a latch
|
||||
# latching is decoration for this card.
|
||||
#
|
||||
# ⚠️ THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS NOT A TRANSCRIPTION.
|
||||
# `_release()` with an infinite bound never fires, `_open_window()` is then only
|
||||
# ever called where the register moves, and `graded_from`/`standing_at_grade`
|
||||
# are `changed_mono`/the command at that change - which is byte-for-byte the
|
||||
# pre-SAFETY-57 conjunction. That equivalence is ASSERTED in 10b against part
|
||||
# 1's own fixtures rather than claimed here, because a counterfactual nobody
|
||||
# graded is a second implementation.
|
||||
_BEFORE = dataclasses.replace(BOUNDS["batt"], release_s=float("inf"))
|
||||
|
||||
|
||||
def _decoder():
|
||||
"""The committed decoder, loaded from `tools/` rather than transcribed."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_s57decode", HERE / "tools" / "decode_bus_capture.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def hms(t):
|
||||
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{int(t % 60):02d}"
|
||||
|
||||
|
||||
def _raises(fn):
|
||||
try:
|
||||
fn()
|
||||
except ValueError:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# `stale_input_s` is READ from the shipped config.yaml, not typed here: the
|
||||
# parked half of every period below is that option, and a check that carried its
|
||||
# own copy would go on being true after an operator moved it.
|
||||
_S57_HOLD = float(option_default("stale_input_s"))
|
||||
|
||||
|
||||
def _dead_site(bound, reads=1201, dead_w=A_FROZEN_W, hold_s=None):
|
||||
"""A register frozen for good, with §11.1 rule 1 driving the command.
|
||||
|
||||
Returns `(freshness, exposure)` where `exposure[i]` is True when read `i`
|
||||
handed the consumers a number. The command rule is the one `cycle()` uses -
|
||||
hold the standing command for `stale_input_s`, then 0 W - and the loop's
|
||||
own ask when telemetry is available; it is written here rather than driven
|
||||
through `cycle()` because that hold runs on the WALL clock and the bound
|
||||
under test runs on the injected one. 10g drives the real path for one
|
||||
period instead.
|
||||
"""
|
||||
hold_s = _S57_HOLD if hold_s is None else hold_s
|
||||
f = Freshness(bound)
|
||||
standing, bad_since, exposure = dead_w, None, []
|
||||
for t in range(0, reads):
|
||||
f.submit(dead_w, standing, float(t))
|
||||
live = f.reading() is not None
|
||||
exposure.append(live)
|
||||
if live:
|
||||
bad_since = None
|
||||
standing = 1300.0 if t >= 1 else dead_w
|
||||
else:
|
||||
bad_since = t if bad_since is None else bad_since
|
||||
standing = standing if t - bad_since < hold_s else 0.0
|
||||
return f, exposure
|
||||
|
||||
|
||||
def _run_lengths(exposure):
|
||||
"""The lengths of the unbroken runs of True in `exposure`."""
|
||||
out, n = [], 0
|
||||
for live in exposure:
|
||||
if live:
|
||||
n += 1
|
||||
elif n:
|
||||
out.append(float(n))
|
||||
n = 0
|
||||
if n:
|
||||
out.append(float(n))
|
||||
return out
|
||||
|
||||
|
||||
def _release_instant(release_s):
|
||||
"""Seconds after the command rests at which the verdict is withdrawn.
|
||||
|
||||
One stimulus, one varied term (habit 7): the same 40 s of unfollowed
|
||||
+1300 W command, then a resting site, under each candidate bound.
|
||||
"""
|
||||
f = Freshness(dataclasses.replace(BOUNDS["batt"], release_s=release_s))
|
||||
for t in range(0, 40):
|
||||
f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(40, 200):
|
||||
f.submit(A_FROZEN_W, 0.0, float(t))
|
||||
if not f.stale:
|
||||
return float(t) - 40.0
|
||||
return None
|
||||
|
||||
|
||||
# --- 10a. the capture, and what it is allowed to be evidence of --------------
|
||||
_D = _decoder()
|
||||
_CAPDIR = HERE / "scenarios" / "safety57-2026-09-21"
|
||||
_CAPFILE = _CAPDIR / "bus-a-2026-09-21-1030-1110.log.gz"
|
||||
_man = json.loads(io.open(_CAPDIR / "manifest.json", encoding="utf-8").read())
|
||||
_events, _census = _D.decode(_CAPFILE)
|
||||
_sp = _D.series(_events, "setpoint_w")
|
||||
_bt = _D.series(_events, "batt_w")
|
||||
_soc = _D.series(_events, "soc_pct")
|
||||
print(f" capture {_CAPFILE.name}: {_census}")
|
||||
print(f" setpoint {len(_sp)} frames batt {len(_bt)} frames "
|
||||
f"soc {len(_soc)} frames, "
|
||||
f"{hms(_sp[0][0])}..{hms(_sp[-1][0])} Europe/Brussels")
|
||||
|
||||
check("PRECONDITION: the committed capture is the file the manifest names, "
|
||||
"byte for byte. A scenario whose bytes can drift from the numbers quoted "
|
||||
"off them is a story",
|
||||
hashlib.sha256(io.open(_CAPFILE, "rb").read()).hexdigest()
|
||||
== _man["file"]["sha256_gz"]
|
||||
and _man["file"]["bytes_gz"] == _CAPFILE.stat().st_size)
|
||||
check("PRECONDITION: every line of it was understood. 3840 frames, and the "
|
||||
"decoder's own census reports ZERO unparsed, ZERO bad CRC and ZERO "
|
||||
"unknown shapes - so nothing in the episode below was dropped on the "
|
||||
"floor by the instrument",
|
||||
_census["lines"] == 3840 and _census["unparsed"] == 0
|
||||
and _census["bad_crc"] == 0 and _census["unknown"] == 0
|
||||
and _census["writes"] == 1200 and _census["responses"] == 720)
|
||||
# ⚠️ THE SIGN IS THE ONE THING THIS DECODER COULD GET EXACTLY BACKWARDS, and a
|
||||
# backwards sign would invert the whole episode - a discharge the BMS refused
|
||||
# would read as a charge it accepted. So the mode/sign correspondence is GRADED
|
||||
# OFF THE CAPTURE rather than trusted: every charge command is followed by a
|
||||
# negative register reading, and there is no reading of the other sign.
|
||||
_after_charge = [
|
||||
_D.held_at(_bt, t + F.BATT_OBLIGED_S)
|
||||
for t, v in _sp if v <= -1000.0]
|
||||
_after_charge = [v for v in _after_charge if v is not None]
|
||||
print(f" readings {F.BATT_OBLIGED_S:.0f} s after a <= -1000 W command: "
|
||||
f"{len(_after_charge)} of them, "
|
||||
f"max {max(_after_charge):+.0f} W, min {min(_after_charge):+.0f} W")
|
||||
check("⚠️ THE DECODER'S SIGN CONVENTION IS GRADED, NOT ASSERTED. EMS mode 2 is "
|
||||
"read as charge and mode 3 as discharge; if that were backwards the "
|
||||
"episode would read as a refused CHARGE. Every command at or under "
|
||||
"-1000 W in this capture is answered, one obligation window later, by a "
|
||||
"NEGATIVE register reading - and not one positive one",
|
||||
len(_after_charge) > 200 and max(_after_charge) < 0.0
|
||||
and _D.signed_setpoint_w(2, 2170) == -2170.0
|
||||
and _D.signed_setpoint_w(3, 1060) == +1060.0)
|
||||
check("...and an EMS mode this decoder has never seen RAISES rather than "
|
||||
"defaulting to a sign. A silent default is how a firmware change becomes "
|
||||
"a mis-signed series nobody re-derives",
|
||||
_raises(lambda: _D.signed_setpoint_w(1, 500))
|
||||
and _raises(lambda: _D.signed_setpoint_w(0, 0)))
|
||||
|
||||
|
||||
# --- 10b. THE LIVE EPISODE, RED BEFORE AND GREEN AFTER -----------------------
|
||||
def _replay_capture(bound):
|
||||
"""The shipped `_read_unit` loop, at 1 Hz, against the capture.
|
||||
|
||||
⚠️ THE COMMAND COLUMN IS NOT A MODEL. The capture was taken off a box
|
||||
running 0.10.0, i.e. running the defect: the 0 W at 10:52:21 IS the site's
|
||||
own `inputs missing -> command 0 W`, recorded on the wire. So this replay is
|
||||
closed-loop for the whole of the parked interval, and the only thing in the
|
||||
window it cannot account for is the human restart at ~11:04.
|
||||
"""
|
||||
f = Freshness(bound)
|
||||
t0 = int(max(_sp[0][0], _bt[0][0])) + 1
|
||||
t1 = int(min(_sp[-1][0], _bt[-1][0]))
|
||||
rows, t = [], float(t0)
|
||||
while t <= t1:
|
||||
f.submit(_D.held_at(_bt, t), _D.held_at(_sp, t), t)
|
||||
rows.append((t, f.stale, f.held, _D.held_at(_sp, t), f.releases))
|
||||
t += 1.0
|
||||
return f, rows
|
||||
|
||||
|
||||
def _edges(rows):
|
||||
return [r for i, r in enumerate(rows) if i == 0 or r[1] != rows[i - 1][1]]
|
||||
|
||||
|
||||
_f_before, _rows_before = _replay_capture(_BEFORE)
|
||||
_f_after, _rows_after = _replay_capture(BOUNDS["batt"])
|
||||
|
||||
# ⚠️ THE EPISODE'S BOUNDARIES ARE READ OFF THE CAPTURE, NOT TYPED. It is
|
||||
# defined by the thing it is about - the register's LONGEST unbroken hold in
|
||||
# the window - and every other instant is derived from that one. A fixture that
|
||||
# named 10:52:00 by hand would go on naming it after the file changed.
|
||||
# ⚠️ CHANGE POINTS, NOT FRAMES. 0x0518 is POLLED every 10 s and answers every
|
||||
# poll, so consecutive frames are always 10 s apart whatever the register is
|
||||
# doing - a gap computed over frames measures the poll period and nothing else,
|
||||
# which is `freshness.py`'s own first paragraph in miniature.
|
||||
_bt_moves = [r for i, r in enumerate(_bt) if i == 0 or r[1] != _bt[i - 1][1]]
|
||||
_holds = [(_bt_moves[i + 1][0] - _bt_moves[i][0], _bt_moves[i][0],
|
||||
_bt_moves[i + 1][0], _bt_moves[i][1])
|
||||
for i in range(len(_bt_moves) - 1)]
|
||||
_HOLD_S, _HOLD_FROM, _REG_MOVES, _HOLD_W = max(_holds)
|
||||
_TRIP = next(t for t, s, _h, _sw, _r in _rows_after if s and t > _HOLD_FROM)
|
||||
_PARK = next(t for t, v in _sp if t > _TRIP and v == 0.0)
|
||||
_HUMAN = next(t for t, v in _sp if t > _PARK and v != 0.0)
|
||||
_blips = [t for t, s, _h, _sw, _r in _edges(_rows_after)
|
||||
if s and t < _HOLD_FROM]
|
||||
print(f" longest register hold {_HOLD_S:.1f} s at {_HOLD_W:+.0f} W, "
|
||||
f"{hms(_HOLD_FROM)}..{hms(_REG_MOVES)}")
|
||||
print(f" trip {hms(_TRIP)} site commands 0 W {hms(_PARK)} "
|
||||
f"human restart's first command {hms(_HUMAN)} "
|
||||
f"register moves {hms(_REG_MOVES)}")
|
||||
print(f" earlier one-read trips in this window (survivor 5): "
|
||||
f"{[hms(t) for t in _blips]}")
|
||||
|
||||
|
||||
def _first_clear_after(rows, t_from):
|
||||
return next((t for t, s, _h, _sw, _r in rows if t > t_from and not s), None)
|
||||
|
||||
|
||||
_clear_before = _first_clear_after(_rows_before, _TRIP)
|
||||
_clear_after = _first_clear_after(_rows_after, _TRIP)
|
||||
print(f" BEFORE (release_s=inf): stale {hms(_TRIP)} -> clear "
|
||||
f"{hms(_clear_before)} {_clear_before - _TRIP:.0f} s")
|
||||
print(f" AFTER (release_s={BOUNDS['batt'].release_s:.0f}): stale "
|
||||
f"{hms(_TRIP)} -> clear {hms(_clear_after)} "
|
||||
f"{_clear_after - _TRIP:.0f} s")
|
||||
|
||||
check("PRECONDITION: `release_s=inf` REPRODUCES the pre-SAFETY-57 module and is "
|
||||
"not a second implementation of it. Part 1's own fresh/stale pair, driven "
|
||||
"through both bounds, gives bit-identical verdict streams - the release "
|
||||
"is the only behaviour the counterfactual removes",
|
||||
drive(_BEFORE, STREAM, lambda t: A_FROZEN_W)[2] == _fresh_read
|
||||
and drive(_BEFORE, STREAM,
|
||||
lambda t: A_FROZEN_W if t < 1 else 1300.0)[2] == _stale_read)
|
||||
check("PRECONDITION: the episode is in the capture where the manifest says it "
|
||||
"is, and it is the window's WORST hold rather than one picked out of "
|
||||
"several. 0x0518 answers -3 W for 850.0 s from 10:51:24, the gate arms "
|
||||
"at 10:52:00, the site's own 0 W reaches the wire at 10:52:21, and "
|
||||
"nothing else moves until a human does at 11:05:31",
|
||||
abs(_HOLD_S - 850.0) < 0.1 and _HOLD_W == -3.0
|
||||
and hms(_HOLD_FROM) == "10:51:24" and hms(_TRIP) == "10:52:00"
|
||||
and hms(_PARK) == "10:52:21" and hms(_HUMAN) == "11:05:31"
|
||||
and hms(_REG_MOVES) == "11:05:34"
|
||||
and sorted(_holds)[-2][0] < 0.5 * _HOLD_S)
|
||||
check("⚠️ RED BEFORE. On the shipped-before module the verdict outlives the "
|
||||
"whole parked interval: from the trip to the first command a HUMAN put "
|
||||
"on the wire, 811 s later, `reading()` is None on every one of the 811 "
|
||||
"reads - and the register never changes value in any of them, so there "
|
||||
"is nothing the module could have cleared on",
|
||||
all(s for t, s, _h, _sw, _r in _rows_before if _TRIP <= t < _HUMAN)
|
||||
and len([1 for t, s, _h, _sw, _r in _rows_before
|
||||
if _TRIP <= t < _HUMAN]) == 812
|
||||
and {v for t, v in _bt if _TRIP <= t < _HUMAN} <= {-3.0}
|
||||
and _clear_before > _HUMAN)
|
||||
check("⚠️ AND WHAT ENDS IT ON THE SHIPPED-BEFORE MODULE IS OUTSIDE THE MODULE. "
|
||||
"The clear lands three seconds after the register moves, and the register "
|
||||
"moves because a person restarted the add-on and the restarted loop "
|
||||
"commanded a CHARGE the pack accepted. Nothing inside this add-on "
|
||||
"produced that command",
|
||||
_clear_before > _REG_MOVES and _clear_before - _REG_MOVES <= 2.0
|
||||
and _HUMAN < _REG_MOVES)
|
||||
check("⚠️ GREEN AFTER, AND THE REGISTER NEVER MOVES - THIS IS THE CARD. The "
|
||||
"verdict is withdrawn 17 s after the site's own 0 W reaches the wire, "
|
||||
"while 0x0518 is still answering the same -3 W it has answered since "
|
||||
"10:51:24, and `reading()` is a number again with no external change of "
|
||||
"any kind",
|
||||
_PARK + BOUNDS["batt"].release_s <= _clear_after
|
||||
< _PARK + BOUNDS["batt"].release_s + 1.0
|
||||
and hms(_clear_after) == "10:52:39" and _f_after.releases >= 1
|
||||
and _D.held_at(_bt, _clear_after) == _D.held_at(_bt, _TRIP) == -3.0
|
||||
and next(r for r in _rows_after if r[0] == _clear_after)[2] == -3.0)
|
||||
check("...and the recovery is worth 776 s of this one episode: the site is "
|
||||
"handed its telemetry back at 10:52:39 instead of at 11:05:35, and 772 "
|
||||
"of those seconds are BEFORE the human touched anything. That is the "
|
||||
"13 minutes it spent parked while the house exported 2.4 kW",
|
||||
_clear_before - _clear_after == 776.0
|
||||
and abs((_HUMAN - _clear_after) - 772.7) < 0.01
|
||||
and all(not s for t, s, _h, _sw, _r in _rows_after
|
||||
if _clear_after <= t < _HUMAN))
|
||||
check("⚠️ AND THE RELEASE STICKS RATHER THAN CHATTERING. From the withdrawal to "
|
||||
"the human's first command the verdict is withdrawn on every one of the "
|
||||
"773 reads, and exactly ONE release was taken in the whole episode - the "
|
||||
"1 Hz oscillation the latch exists to prevent is still prevented, "
|
||||
"because the window a release opens is 35 s wide",
|
||||
len([1 for t, s, _h, _sw, _r in _rows_after
|
||||
if _clear_after <= t < _HUMAN]) == 773
|
||||
and _f_after.releases == 1)
|
||||
|
||||
|
||||
# --- 10c. what a release costs, measured -------------------------------------
|
||||
# ⚠️ THE EXPOSURE IS THE NUMBER THE SAFETY ARGUMENT RESTS ON, so it is counted
|
||||
# rather than reasoned about: how many reads hand a consumer the held number
|
||||
# after a release, and how long the longest unbroken run of them is. The whole
|
||||
# argument is that one run is shorter than either consumer's hold.
|
||||
_dead, _exp57 = _dead_site(BOUNDS["batt"])
|
||||
_deadb, _expb = _dead_site(_BEFORE)
|
||||
_runs57 = _run_lengths(_exp57)
|
||||
print(f" register dead for good, {len(_exp57)} reads, "
|
||||
f"stale_input_s={_S57_HOLD:.0f}")
|
||||
print(f" before : held number reached a consumer on {sum(_expb)} reads "
|
||||
f"({100 * sum(_expb) / len(_expb):.1f} %), runs={_run_lengths(_expb)}")
|
||||
print(f" after : held number reached a consumer on {sum(_exp57)} reads "
|
||||
f"({100 * sum(_exp57) / len(_exp57):.1f} %), releases={_dead.releases}, "
|
||||
f"longest run={max(_runs57):.0f} s")
|
||||
|
||||
check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register "
|
||||
"that is dead for good is re-graded for ever: over 1201 reads the held "
|
||||
"number reaches a consumer on 630 of them against 35 before, and 17 "
|
||||
"releases are taken. This check exists so that cost cannot be quietly "
|
||||
"forgotten, and so a change that makes it worse has to move a number "
|
||||
"somebody reads",
|
||||
sum(_expb) == 35 and sum(_exp57) == 630 and _dead.releases == 17
|
||||
and _deadb.releases == 0)
|
||||
check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER "
|
||||
"CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s "
|
||||
"grading window, and 35 < 75 (circulation_hold_s) < 85 "
|
||||
"(unit_sign_disagree_s) - so NEITHER detector 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",
|
||||
set(_runs57) == {BOUNDS["batt"].unchanged_s}
|
||||
and BOUNDS["batt"].unchanged_s < _circ_hold < _unit_hold)
|
||||
check("...and nothing SAFETY-37 detects is given back by that, which is the "
|
||||
"half that makes the trade a trade. A permanent None clears both windows "
|
||||
"for ever too, so on the shipped-before module the same two detectors are "
|
||||
"equally unable to speak about this unit. The exposure buys the site its "
|
||||
"meter-derived control back and costs no detection that existed",
|
||||
disagrees(1300.0, None, True, _cfg3) is False
|
||||
and circulating_w([1300.0, None]) is None)
|
||||
|
||||
|
||||
# --- 10d. the bound, graded as a family --------------------------------------
|
||||
# ⚠️ ASSERTING `release_s == BATT_OBLIGED_S` WOULD BE COMPARING THE SHIPPED
|
||||
# EXPRESSION AGAINST ITSELF (habit 2, and MAINT-05's whole card). The literal is
|
||||
# asserted, the three terms it is built from are asserted, and then the BAND is
|
||||
# graded behaviourally against its neighbours.
|
||||
print(f" RELEASE_S {F.RELEASE_S:.1f} s = settle {F.PLANT_SETTLE_S:.1f} "
|
||||
f"+ publish {F.PUBLISH_PERIOD_S:.1f} + read {F.READ_PERIOD_S:.1f}")
|
||||
check("⚠️ THE BOUND IS 17.0 s AND IT IS THE PHYSICAL OBLIGATION WINDOW, "
|
||||
"asserted against the literal rather than against the expression that "
|
||||
"produces it. Same three terms as the floor of `BATT_UNCHANGED_S`'s "
|
||||
"band, because it is the same question: has the register had its chance "
|
||||
"to answer the command standing NOW?",
|
||||
BOUNDS["batt"].release_s == 17.0 and F.RELEASE_S == 17.0
|
||||
and F.PLANT_SETTLE_S + F.PUBLISH_PERIOD_S + F.READ_PERIOD_S == 17.0)
|
||||
_family = {r: _release_instant(r) for r in (5.0, 16.0, 17.0, 18.0, 35.0,
|
||||
float("inf"))}
|
||||
for _r, _at in sorted(_family.items(), key=lambda kv: kv[0]):
|
||||
print(f" release_s {_r:>5} -> withdrawn at t+{_at}"
|
||||
if _at is not None else f" release_s {_r:>5} -> never")
|
||||
check("⚠️ THE FAMILY IS COMPUTED AND PRINTED, so a bound no fixture separates "
|
||||
"shows up as a named survivor. Each candidate withdraws the verdict "
|
||||
"exactly its own number of seconds after the command rests, and they are "
|
||||
"six DISTINCT answers - a check that could not tell 17 from 35 could not "
|
||||
"tell this card from a slower version of the bug",
|
||||
_family == {5.0: 5.0, 16.0: 16.0, 17.0: 17.0, 18.0: 18.0, 35.0: 35.0,
|
||||
float("inf"): None})
|
||||
# ⚠️ AND THE FLOOR IS GRADED BEHAVIOURALLY, WHICH IS THE HALF A CONSTANT CHECK
|
||||
# CANNOT DO. Below 17.0 s the timer fires before a healthy register could have
|
||||
# published its answer to the 0 W command - so it takes the expensive exit when
|
||||
# the free one was one read away. The register here answers at the obligation
|
||||
# window's own edge, which is the case the bound is derived from.
|
||||
_free_exit = {}
|
||||
for _r in (5.0, 17.0):
|
||||
_f = Freshness(dataclasses.replace(BOUNDS["batt"], release_s=_r))
|
||||
for t in range(0, 40):
|
||||
_f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(40, 40 + int(F.BATT_OBLIGED_S) - 1):
|
||||
_f.submit(A_FROZEN_W, 0.0, float(t)) # resting, register not yet in
|
||||
_f.submit(-1150.0, 0.0, float(40 + int(F.BATT_OBLIGED_S) - 1))
|
||||
_free_exit[_r] = (_f.releases, _f.stale, _f.held)
|
||||
print(f" register answers the 0 W command at +16 s: {_free_exit}")
|
||||
check("⚠️ THE FLOOR BITES, AND IT BITES ON BEHAVIOUR. A register that answers "
|
||||
"the new 0 W command inside its own obligation window clears the latch "
|
||||
"BY MOVING - the free exit - and at 17.0 s that is what happens, with "
|
||||
"`releases` still 0. At 5.0 s the timer has already fired and the site "
|
||||
"has paid for a re-grading window it did not need",
|
||||
_free_exit[17.0][0] == 0 and _free_exit[5.0][0] == 1
|
||||
and _free_exit[17.0][2] == _free_exit[5.0][2] == -1150.0)
|
||||
|
||||
|
||||
# --- 10e. the release does not rewrite the register's history ----------------
|
||||
_hist = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_hist.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(40, 120):
|
||||
_hist.submit(A_FROZEN_W, 0.0, float(t))
|
||||
print(f" at t=119 after one release: unchanged_s={_hist.unchanged_s(119.0)} "
|
||||
f"graded_s={_hist.graded_s(119.0)} releases={_hist.releases}")
|
||||
check("⚠️ TWO CLOCKS, AND THE ONE THE OPERATOR READS IS THE REGISTER'S. A "
|
||||
"release re-opens the VERDICT's window; it does not make the register "
|
||||
"younger. At t=119 the register has not carried a different number for "
|
||||
"119 s and `unchanged_s` says 119 s, while the grading window opened at "
|
||||
"the release at t=57 and says 62 s. A single clock here would either lie "
|
||||
"to the operator or defeat the re-grading",
|
||||
_hist.unchanged_s(119.0) == 119.0 and _hist.graded_s(119.0) == 62.0
|
||||
and _hist.releases == 1 and _hist.held == A_FROZEN_W
|
||||
and _hist.changed_mono == 0.0 and _hist.graded_from == 57.0)
|
||||
|
||||
|
||||
# --- 10f. the obligation coming back cancels the timer -----------------------
|
||||
_resume = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_resume.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(40, 40 + int(BOUNDS["batt"].release_s) - 1):
|
||||
_resume.submit(A_FROZEN_W, 0.0, float(t)) # 16 s of rest
|
||||
_resume.submit(A_FROZEN_W, 1300.0, 56.0) # the command comes back
|
||||
_resume_mid = (_resume.stale, _resume.rested_since)
|
||||
for t in range(57, 57 + int(BOUNDS["batt"].release_s) - 1):
|
||||
_resume.submit(A_FROZEN_W, 1300.0, float(t)) # and stays
|
||||
check("⚠️ A TIMER THAT DOES NOT RESET IS NOT A TIMER. Sixteen seconds of rest, "
|
||||
"one second short of the bound, and then the site puts the obligation "
|
||||
"back: the clock is cleared, not carried, so the verdict stands. "
|
||||
"Carrying it would withdraw a verdict about a command that is standing "
|
||||
"RIGHT NOW",
|
||||
_resume_mid == (True, None) and _resume.stale
|
||||
and _resume.releases == 0 and _resume.reading() is None)
|
||||
_resume.submit(A_FROZEN_W, 0.0, 80.0)
|
||||
for t in range(81, 81 + int(BOUNDS["batt"].release_s)):
|
||||
_resume.submit(A_FROZEN_W, 0.0, float(t))
|
||||
check("...and it starts again from the SECOND rest, not from the first: the "
|
||||
"release lands 17.0 s after t=80, which is 41 s after the first rest "
|
||||
"began. The total time spent resting is not what the bound is about",
|
||||
_resume.releases == 1 and _resume.released_at == 97.0
|
||||
and not _resume.stale)
|
||||
|
||||
# ⚠️ AND "NO LONGER STANDING" IS `MOVE_W`, NOT SOME OTHER THRESHOLD IN THE SAME
|
||||
# OBJECT. Found by mutating the SIBLING (habit 11): `_release()`'s test against
|
||||
# `bound.move_w` swapped for `bound.unchanged_s` SURVIVED the whole file at
|
||||
# +5 B, because every fixture above rests at exactly 0 W and 0 is below both
|
||||
# 35 and 250. The band between them is where they separate, and a site can sit
|
||||
# in it - a peak-guard floor, a shadow substitution or any claim that wins with
|
||||
# a small non-zero number leaves a command there while the verdict stands.
|
||||
_band = {}
|
||||
for _w in (0.0, 100.0, 300.0):
|
||||
_f = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
_f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(40, 40 + 2 * int(BOUNDS["batt"].release_s)):
|
||||
_f.submit(A_FROZEN_W, _w, float(t))
|
||||
_band[_w] = _f.releases
|
||||
print(f" releases after 34 s of a standing command of 0 / 100 / 300 W: "
|
||||
f"{_band}")
|
||||
check("⚠️ THE OBLIGATION TEST IS THE 250 W ONE, AND THE 100 W CASE IS WHAT "
|
||||
"SAYS SO. A command of 100 W is below `move_w`, so the register is owed "
|
||||
"nothing and the verdict is withdrawn exactly as at 0 W; a command of "
|
||||
"300 W is above it, so the question is still standing and the verdict "
|
||||
"is too. Every other fixture in this file rests at 0 W, where `move_w` "
|
||||
"and `unchanged_s` cannot be told apart",
|
||||
_band == {0.0: 1, 100.0: 1, 300.0: 0}
|
||||
and BOUNDS["batt"].unchanged_s < 100.0 < BOUNDS["batt"].move_w < 300.0)
|
||||
|
||||
# ⚠️ AND THE ONE EQUIVALENT MUTANT THIS CARD FOUND, ARGUED RATHER THAN ROUNDED
|
||||
# OFF. Swapping `_release()` and `_grade()` in `submit()` is ZERO BYTES and
|
||||
# survived the whole battery. It is not a missing fixture and no fixture can
|
||||
# close it: the two act on MUTUALLY EXCLUSIVE predicates - `_grade()` only when
|
||||
# `|standing| >= move_w`, `_release()` only when `|standing| < move_w` - so at
|
||||
# most one of them can do anything on any read. This check pins the disjointness
|
||||
# rather than the order, which is the property that actually holds.
|
||||
_disjoint = []
|
||||
for _w in (0.0, 100.0, 249.0, 250.0, 251.0, 1300.0):
|
||||
_a = Freshness(BOUNDS["batt"])
|
||||
_b = Freshness(BOUNDS["batt"])
|
||||
for t in range(0, 40):
|
||||
for _f in (_a, _b):
|
||||
_f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(40, 80):
|
||||
_a._release(_w, float(t))
|
||||
_a._grade(_w, float(t))
|
||||
_b._grade(_w, float(t))
|
||||
_b._release(_w, float(t))
|
||||
_disjoint.append((_w, _a.stale == _b.stale
|
||||
and _a.releases == _b.releases
|
||||
and _a.graded_from == _b.graded_from))
|
||||
print(f" release-then-grade vs grade-then-release, by standing command: "
|
||||
f"{_disjoint}")
|
||||
check("⚠️ AN ARGUED EQUIVALENT MUTANT, NOT A SURVIVOR NOBODY LOOKED AT. "
|
||||
"Swapping the two calls in `submit()` is zero bytes and changes nothing "
|
||||
"on either side of `move_w`, including AT it - because the two are gated "
|
||||
"on mutually exclusive tests and cannot both act on one read. The "
|
||||
"docstring used to claim the order was load-bearing; it is not, and the "
|
||||
"battery is what said so",
|
||||
all(same for _w, same in _disjoint) and len(_disjoint) == 6)
|
||||
|
||||
|
||||
# --- 10g. through the REAL read path, and the operator is told ---------------
|
||||
_LOGREC = []
|
||||
|
||||
|
||||
class _S57Handler(logging.Handler):
|
||||
def emit(self, record):
|
||||
_LOGREC.append(record)
|
||||
|
||||
|
||||
logging.getLogger("goodwe").addHandler(_S57Handler())
|
||||
logging.getLogger("goodwe").setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
async def _wired_release():
|
||||
"""The whole chain at N=1: real `read_inputs`, real `Inverter`, real log."""
|
||||
c, hass = one_site()
|
||||
seen = []
|
||||
for t in range(0, 40):
|
||||
hass.numbers["sensor.batt_a"] = A_FROZEN_W
|
||||
c.inverters[0].last_write = A_FROZEN_W if t < 1 else 1300.0
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
seen.append((t, c.inverters[0].batt, c.inputs_ok))
|
||||
for t in range(40, 40 + int(BOUNDS["batt"].release_s) + 2):
|
||||
hass.numbers["sensor.batt_a"] = A_FROZEN_W # the register NEVER moves
|
||||
c.inverters[0].last_write = 0.0 # the site's own 0 W
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
seen.append((t, c.inverters[0].batt, c.inputs_ok))
|
||||
return c, seen
|
||||
|
||||
|
||||
_c57, _seen57 = run(_wired_release())
|
||||
_trip57 = next(t for t, b, _ok in _seen57 if b is None)
|
||||
_back57 = next(t for t, b, _ok in _seen57 if t > _trip57 and b is not None)
|
||||
print(f" real read path: batt None from t={_trip57}, a number again at "
|
||||
f"t={_back57}, register held {A_FROZEN_W:+.0f} throughout")
|
||||
check("⚠️ THROUGH THE SHIPPED READ PATH, NOT THE POLICY OBJECT. "
|
||||
"`Inverter.batt` goes None at the bound, `inputs_ok` goes False - and "
|
||||
"17.0 s after the site's own 0 W it is a NUMBER again and `inputs_ok` is "
|
||||
"True, with `sensor.batt_a` serving the identical -1200.0 on every one "
|
||||
"of those reads",
|
||||
_trip57 == 35 and _back57 == 57
|
||||
and all(v == A_FROZEN_W for _e, v in _c57.hass.served
|
||||
if _e == "sensor.batt_a")
|
||||
and _seen57[_trip57][2] is False and _seen57[-1][2] is True
|
||||
and _c57.inverters[0].batt == A_FROZEN_W)
|
||||
check("...and the site stops holding inputs bad, which is what ends the "
|
||||
"`inputs missing -> command 0 W` claim §11.1 rule 1 files",
|
||||
_c57.inputs_bad_since is None and _c57.inputs_ok is True)
|
||||
_ev57 = [e for e in _c57.events if "STALE" in e]
|
||||
_msgs57 = [r.getMessage() for r in _LOGREC]
|
||||
_rel57 = [m for m in _msgs57 if "STALE_WITHDRAWN" in m]
|
||||
print(f" events: {[e.split(' ', 1)[1][:44] for e in _ev57]}")
|
||||
check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATOR "
|
||||
"CAN AUDIT - and this one is withdrawing a SITE STOP. Both edges reach "
|
||||
"the event log and the logger, in order, naming the unit",
|
||||
len(_ev57) == 2 and "STALE inverter-a" in _ev57[1]
|
||||
and "STALE WITHDRAWN inverter-a" in _ev57[0]
|
||||
and len(_rel57) == 1 and "inverter-a" in _rel57[0])
|
||||
check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has "
|
||||
"happened: it says in its own words that nothing has been learned about "
|
||||
"the register, and it prints the value the register is STILL holding",
|
||||
"NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in _rel57[0]
|
||||
and f"still holding {A_FROZEN_W:+.0f}" in _rel57[0])
|
||||
|
||||
|
||||
# --- 10h. the two sentences, as ORDERED PAIRS (habit 14) ---------------------
|
||||
# ⚠️ AN `in` TEST AGAINST A FORMATTED SENTENCE GRADES PRESENCE, NEVER
|
||||
# ARRANGEMENT. Both of the sentences this card writes carry two numbers that
|
||||
# could be transposed at zero bytes - 17.0 s and 250 W in the stale line's new
|
||||
# escape clause, and 250 W and 17.0 s again in the release note - so each is
|
||||
# asserted with its value embedded in its own literal position.
|
||||
_stale57 = [m for m in _msgs57 if "SAFETY-37" in m]
|
||||
print(f" escape clause: ...{_stale57[0][-150:]}")
|
||||
check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDERED "
|
||||
"PAIR. It used to end 'It clears itself the moment the register reports "
|
||||
"a different value.' - which was FALSE, because the 0 W in the sentence "
|
||||
"before it is what stops the register being asked. The new clause names "
|
||||
"17 s in the TIME slot and 250 W in the POWER slot; swapped, this goes "
|
||||
"red",
|
||||
f"failing that {BOUNDS['batt'].release_s:.0f} s after the standing "
|
||||
f"command falls below {BOUNDS['batt'].move_w:.0f} W" in _stale57[0])
|
||||
check("...and the release note names 250 W in the POWER slot and 17 s in the "
|
||||
"TIME slot, in that order, which is the reverse arrangement of the same "
|
||||
"two numbers",
|
||||
f"sat below {BOUNDS['batt'].move_w:.0f} W for "
|
||||
f"{BOUNDS['batt'].release_s:.0f} s" in _rel57[0])
|
||||
check("...and the 35.0 s the register is re-graded over is named in the note "
|
||||
"too, because it is what the operator is being told to wait out",
|
||||
f"re-graded over a fresh {BOUNDS['batt'].unchanged_s:.0f} s window"
|
||||
in _rel57[0])
|
||||
check("⚠️ AND THE DIAGNOSIS HALF OF THE STALE LINE IS UNTOUCHED, WHICH IS "
|
||||
"SAFETY-56'S. That card is rewriting 'is not following its own command' "
|
||||
"- the live episode's register was following perfectly and the pack was "
|
||||
"simply empty - and two cards editing one sentence is how one of them "
|
||||
"silently loses. This check is here so that rewrite is a deliberate act",
|
||||
"battery-power register is not following its own command" in _stale57[0])
|
||||
check("...and a re-trip's reason names the TWO durations separately, each "
|
||||
"beside its own value, because after a release the verdict's window and "
|
||||
"the register's own unchanged time are different facts",
|
||||
(lambda f: (
|
||||
[f.submit(A_FROZEN_W, A_FROZEN_W if t < 1 else 1300.0, float(t))
|
||||
for t in range(0, 40)],
|
||||
[f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)],
|
||||
[f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)],
|
||||
f.stale and f.releases == 1
|
||||
and "unchanged for 92 s" in f.reason
|
||||
and "the 35 s window this verdict rests on opened when the previous "
|
||||
"one was withdrawn" in f.reason
|
||||
and "graded 2 times" in f.reason)[-1])(
|
||||
Freshness(BOUNDS["batt"])))
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -224,9 +224,18 @@ A_RATING = 5000.0
|
||||
# had become so; validate.py rule 10 refused it the day it existed, which is
|
||||
# what a rule about a relationship is for. 0 means "follow the rail"
|
||||
# (`control.SLEW_RAIL_FRAC * max_w`) and is what config.yaml ships.
|
||||
# ⚠️ SAFETY-10: `shadow_mode="off"` EXPLICITLY, BECAUSE NOTHING HERE PRESSES
|
||||
# START. The shipped default is `auto`, which is SHADOW MODE while control is
|
||||
# stopped - and in shadow mode every write is substituted with 0 W at the one
|
||||
# choke point these parts grade. Without this line the per-unit clamp, the
|
||||
# per-unit write budget and the per-unit heartbeat would all be measured on a
|
||||
# controller that is correctly refusing to command anything, which is a
|
||||
# different (and vacuous) claim. The shipped write path is unaffected: a
|
||||
# stopped controller only ever writes 0 W in production anyway.
|
||||
BASE_OPTS = dict(A_ENT, meter_source="off", meter_entity="sensor.grid",
|
||||
max_w=A_RATING, gain=0.6, slew_w=0, deadband_w=15,
|
||||
target_grid_w=0, step_w=10, heartbeat_s=10, stale_input_s=15)
|
||||
target_grid_w=0, step_w=10, heartbeat_s=10, stale_input_s=15,
|
||||
shadow_mode="off")
|
||||
|
||||
|
||||
def built(opts=None, store=None, hass=None, second=None):
|
||||
|
||||
+123
-26
@@ -151,6 +151,47 @@ BASELINE = [
|
||||
('goodwe_ctl/availability', 'online', True),
|
||||
]
|
||||
|
||||
# --- TEL-10's three, and they are DELIBERATELY NOT IN `BASELINE` -------------
|
||||
#
|
||||
# ⚠️ `BASELINE` IS WHAT release/1.0 PUBLISHES AND NOTHING MAY BE ADDED TO IT.
|
||||
# Every strong claim in this file rests on that list being a record of another
|
||||
# tree; append a row this branch invented and "byte-identical to release/1.0"
|
||||
# quietly becomes "identical to itself", which is the defect MUTATION.md's
|
||||
# habit 4 is about - a check whose data cannot reach the failure it names.
|
||||
#
|
||||
# So TEL-10's arrival is TWO claims and gets two checks: the nine release/1.0
|
||||
# messages did not move, and EXACTLY these three arrived after them.
|
||||
#
|
||||
# ⚠️ AND THIS LIST IS A REGRESSION PIN, NOT EVIDENCE OF CORRECTNESS - the same
|
||||
# status as part 3's sha256 over the per-unit payloads, for the same reason:
|
||||
# there is no released version of these bytes to diff against, so all it can
|
||||
# say is "these three payloads have not changed since the PR was reviewed".
|
||||
_TEL10_NUM = ('"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV
|
||||
+ ', "unit_of_measurement": "W", "device_class": "power", '
|
||||
'"state_class": "measurement"}')
|
||||
TEL10 = [
|
||||
('homeassistant/sensor/goodwe_ctl_loop_i/config',
|
||||
'{"name": "Loop integrator", "object_id": "goodwe_loop_integrator", '
|
||||
'"unique_id": "goodwe_ctl_loop_i", "state_topic": "goodwe_ctl/loop_i", '
|
||||
+ _TEL10_NUM, True),
|
||||
('homeassistant/sensor/goodwe_ctl_loop_lead/config',
|
||||
'{"name": "Loop integrator lead", "object_id": "goodwe_loop_integrator_lead", '
|
||||
'"unique_id": "goodwe_ctl_loop_lead", "state_topic": "goodwe_ctl/loop_lead", '
|
||||
+ _TEL10_NUM, True),
|
||||
('homeassistant/sensor/goodwe_ctl_loop_reason/config',
|
||||
'{"name": "Loop reason", "object_id": "goodwe_loop_reason", '
|
||||
'"unique_id": "goodwe_ctl_loop_reason", "state_topic": "goodwe_ctl/loop_reason", '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"icon": "mdi:sigma"}', True),
|
||||
]
|
||||
|
||||
# What THIS branch announces at a roster of one: release/1.0's eight sensor
|
||||
# configs, then TEL-10's three, then the availability message - which stays
|
||||
# LAST, because `_announce()` publishes it after every config and a broker
|
||||
# replaying the retained set must see availability arrive after the entities it
|
||||
# is the availability OF.
|
||||
SITE_NOW = BASELINE[:-1] + TEL10 + BASELINE[-1:]
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Captures what `_announce()` actually hands paho."""
|
||||
@@ -177,7 +218,7 @@ def announce(units=(), omit=(), module=mqtt):
|
||||
return pub.client.msgs
|
||||
|
||||
|
||||
def diff(got, want=BASELINE):
|
||||
def diff(got, want=SITE_NOW):
|
||||
"""`(missing, changed, added)` between two announcement lists, by topic."""
|
||||
g, w = dict((t, (p, r)) for t, p, r in got), dict((t, (p, r)) for t, p, r in want)
|
||||
missing = [t for t in w if t not in g]
|
||||
@@ -186,7 +227,7 @@ def diff(got, want=BASELINE):
|
||||
return sorted(missing), sorted(changed), sorted(added)
|
||||
|
||||
|
||||
def report(label, got, want=BASELINE):
|
||||
def report(label, got, want=SITE_NOW):
|
||||
m, c, a = diff(got, want)
|
||||
print(f" {label}: {len(want)} topics, {len(m)} missing, {len(c)} changed, "
|
||||
f"{len(a)} added")
|
||||
@@ -231,11 +272,33 @@ print("\n1. the recorded baseline, and a roster of one")
|
||||
# entity on a live install.
|
||||
m, c, a = report("roster of one (`inverters:` empty, i.e. every install today)",
|
||||
announce(units=roster_names({})))
|
||||
check("⭐ AT THE SHIPPED ROSTER OF ONE THE ANNOUNCEMENT IS BYTE-IDENTICAL TO "
|
||||
"release/1.0: same topics, same unique_ids, same payload bytes, same "
|
||||
"retain flags, in the same order - no second device, no renamed entity, "
|
||||
"no added topic",
|
||||
(m, c, a) == ([], [], []) and announce(units=roster_names({})) == BASELINE)
|
||||
# ⚠️ THE HEADLINE SAYS EIGHT, NOT NINE, AND THE SLICE UNDER IT IS WHY.
|
||||
# release/1.0 announces NINE messages; the ninth is `goodwe_ctl/availability`,
|
||||
# which `_announce()` publishes LAST and which TEL-10 therefore MOVES - it is
|
||||
# byte-identical but no longer at index 8. So what is asserted here is the eight
|
||||
# entity CONFIGS, `[:len(BASELINE) - 1] == BASELINE[:-1]`, and the word "nine"
|
||||
# above it named an assertion this file does not make. The ninth is not
|
||||
# unguarded: the `== SITE_NOW` on the next check pins it byte-for-byte in its
|
||||
# new last position. Two sentences, two checks.
|
||||
check("⭐ AT THE SHIPPED ROSTER OF ONE EVERY ONE OF release/1.0's EIGHT ENTITY "
|
||||
"CONFIGS IS STILL BYTE-IDENTICAL AND STILL FIRST: same topics, same "
|
||||
"unique_ids, same payload bytes, same retain flags, in the same order - "
|
||||
"no second device, no renamed entity, nothing re-ordered. TEL-10 adds "
|
||||
"rows AFTER them; it may not move one of them. (release/1.0's NINTH "
|
||||
"message, `availability`, is byte-identical too and deliberately moves "
|
||||
"to LAST; the `== SITE_NOW` below is what pins it)",
|
||||
announce(units=roster_names({}))[:len(BASELINE) - 1] == BASELINE[:-1])
|
||||
|
||||
# ⚠️ AND THE ADDITION IS ITS OWN CLAIM, WITH A COUNT. "Nothing moved" and
|
||||
# "exactly three arrived" are different sentences and a single `== SITE_NOW`
|
||||
# would let a fourth row hide inside whichever of the two a reader had in mind.
|
||||
_m1, _c1, _a1 = diff(announce(units=roster_names({})), BASELINE)
|
||||
check("⭐ TEL-10 ADDS EXACTLY THREE TOPICS AND CHANGES NONE: the integrator, "
|
||||
"the lead and the loop's own reason string, all NEW, all on the SITE "
|
||||
"device - nothing of release/1.0's is missing and nothing of it changed",
|
||||
(_m1, _c1) == ([], [])
|
||||
and sorted(_a1) == sorted(t for t, _p, _r in TEL10)
|
||||
and announce(units=roster_names({})) == SITE_NOW)
|
||||
|
||||
check("...and the roster-of-one path is the one every install actually reads: "
|
||||
"an empty `inverters:` names exactly one unit",
|
||||
@@ -244,11 +307,11 @@ check("...and the roster-of-one path is the one every install actually reads: "
|
||||
check("...and a publisher told NOTHING about the roster announces the same "
|
||||
"thing, so a caller that has not been taught about units cannot change "
|
||||
"what a live install sees",
|
||||
announce(units=()) == BASELINE)
|
||||
announce(units=()) == SITE_NOW)
|
||||
|
||||
m, c, a = report("roster of one, P1 off (`omit=('p1_age',)`)",
|
||||
announce(units=roster_names({}), omit=("p1_age",)),
|
||||
[row for row in BASELINE if "p1_age" not in row[0]])
|
||||
[row for row in SITE_NOW if "p1_age" not in row[0]])
|
||||
check("...and the `omit` path is unchanged too: with P1 ingestion off the "
|
||||
"watchdog input is still not announced, and nothing else moved",
|
||||
(m, c, a) == ([], [], []))
|
||||
@@ -280,11 +343,18 @@ check("⭐ AC 1: AT A ROSTER OF TWO, EVERY ONE OF A's NINE MESSAGES IS STILL "
|
||||
(m, c) == ([], []))
|
||||
check("...and the eight additions are all B's and A's own per-unit rows, and "
|
||||
"all of them are NEW topics", len(a) == 8)
|
||||
check("...and A's nine messages are still the FIRST nine, in the same order, "
|
||||
"so a broker replaying retained messages sees them unchanged",
|
||||
check("...and A's eight entity configs are still the FIRST eight, in the same "
|
||||
"order, so a broker replaying retained messages sees them unchanged "
|
||||
"(the ninth, `availability`, is byte-identical and last - the check "
|
||||
"above is the one that covers it, as a diff rather than a prefix)",
|
||||
TWO[:len(BASELINE) - 1] == BASELINE[:-1])
|
||||
|
||||
site_ids = {c_["unique_id"] for c_ in cfgs(BASELINE)}
|
||||
# ⚠️ THE SITE SET IS `SITE_NOW`, NOT `BASELINE`. Read from `BASELINE` it would
|
||||
# be short by TEL-10's three, and those three would then be counted as PER-UNIT
|
||||
# rows by every line below that says "not a site id" - which silently changes
|
||||
# what the sha256 pin, the uniqueness checks and part 7's fed/announced set are
|
||||
# taken over.
|
||||
site_ids = {c_["unique_id"] for c_ in cfgs(SITE_NOW)}
|
||||
unit_cfgs = [c_ for c_ in cfgs(TWO) if c_["unique_id"] not in site_ids]
|
||||
a_cfgs = [c_ for c_ in unit_cfgs if "inverter_a" in c_["unique_id"]]
|
||||
b_cfgs = [c_ for c_ in unit_cfgs if "inverter_b" in c_["unique_id"]]
|
||||
@@ -330,7 +400,7 @@ def field(rows, key):
|
||||
|
||||
for key in ("unique_id", "object_id", "state_topic"):
|
||||
av, bv = set(field(a_cfgs, key)), set(field(b_cfgs, key))
|
||||
sv = set(field(cfgs(BASELINE), key))
|
||||
sv = set(field(cfgs(SITE_NOW), key))
|
||||
check(f"⭐ AC 2: no `{key}` is shared between A's entities and B's, and "
|
||||
f"none of B's collides with a SITE entity that already exists",
|
||||
len(av) == 4 and len(bv) == 4 and not (av & bv) and not (bv & sv)
|
||||
@@ -340,10 +410,10 @@ all_ids = field(cfgs(TWO), "unique_id")
|
||||
check("...and across the WHOLE announcement every unique_id is distinct - an "
|
||||
"HA discovery collision is not an error, it DROPS the second entity, so "
|
||||
"one inverter's telemetry would silently stop existing",
|
||||
len(all_ids) == len(set(all_ids)) == 16)
|
||||
len(all_ids) == len(set(all_ids)) == 19)
|
||||
|
||||
all_topics = [t for t, _, _ in TWO]
|
||||
check("...and so is every topic", len(all_topics) == len(set(all_topics)) == 17)
|
||||
check("...and so is every topic", len(all_topics) == len(set(all_topics)) == 20)
|
||||
|
||||
# ⚠️ THE SLUG COLLISION rule 9(c) DOES NOT REFUSE. `inverter-b` and `Inverter B`
|
||||
# are two different names by the validator's test and one string once slugged.
|
||||
@@ -363,8 +433,14 @@ print("\n5. site-scoped sensors stay site-scoped")
|
||||
# firmware's ingest guard). A second copy of it is a second answer to "is the
|
||||
# meter fresh?", one of which is always the one nobody is looking at. `grid` is
|
||||
# the same argument without the safety edge: there is ONE grid connection.
|
||||
# ⚠️ TEL-10's THREE ARE IN HERE, AND THE ARGUMENT IS `grid`'s. There is ONE
|
||||
# integrator - §6.2's architecture is one integrator on the P1 meter whose
|
||||
# output is then SPLIT - so a per-inverter copy of it would invent N of a thing
|
||||
# there is one of, and two of them would disagree the moment the allocator gave
|
||||
# the units different shares.
|
||||
SITE_ONLY = ("goodwe_ctl_grid", "goodwe_ctl_p1_age", "goodwe_ctl_circulation",
|
||||
"goodwe_ctl_phase", "goodwe_ctl_status")
|
||||
"goodwe_ctl_phase", "goodwe_ctl_status",
|
||||
"goodwe_ctl_loop_i", "goodwe_ctl_loop_lead", "goodwe_ctl_loop_reason")
|
||||
rows = []
|
||||
for n in range(1, 7):
|
||||
names = [f"inverter-{chr(ord('a') + i)}" for i in range(n)]
|
||||
@@ -377,8 +453,8 @@ check("⭐ AC 3: `grid` and `p1_sample_age_s` are announced EXACTLY ONCE at ever
|
||||
"two of a thing there is one of, and would duplicate a safety signal",
|
||||
all(counts == [1] * len(SITE_ONLY) for _n, counts, _t in rows))
|
||||
check("...and the site set does not grow with the roster while the per-unit "
|
||||
"set does: 8 site entities always, 4 more per unit past the first",
|
||||
[t for _n, _c, t in rows] == [8, 16, 20, 24, 28, 32])
|
||||
"set does: 11 site entities always, 4 more per unit past the first",
|
||||
[t for _n, _c, t in rows] == [11, 19, 23, 27, 31, 35])
|
||||
|
||||
site_devices = {json.dumps(c_["device"], sort_keys=True)
|
||||
for c_ in cfgs(announce(units=NAMES2))
|
||||
@@ -420,7 +496,7 @@ for c_, eid in zip(cfgs(TWO), composed):
|
||||
f"name {c_['name']!r}")
|
||||
check("⭐ every composed entity_id at a roster of two is DISTINCT: no per-unit "
|
||||
"entity can land on top of a site entity that already has history",
|
||||
len(composed) == len(set(composed)) == 16)
|
||||
len(composed) == len(set(composed)) == 19)
|
||||
|
||||
base_composed = [compose(c_["device"]["name"], c_["name"]) for c_ in cfgs(BASELINE)]
|
||||
check("...and the eight ids that exist today are unchanged by adding B, which "
|
||||
@@ -620,9 +696,9 @@ def properties(mod):
|
||||
bc = [c_ for c_ in uc if "inverter_b" in c_["unique_id"]]
|
||||
ac = [c_ for c_ in uc if "inverter_a" in c_["unique_id"]]
|
||||
comp = [compose(c_["device"]["name"], c_["name"]) for c_ in tc]
|
||||
out["A byte-identical at N=1"] = base == BASELINE
|
||||
out["A byte-identical at N=1"] = base == SITE_NOW
|
||||
out["A byte-identical at N=2"] = diff(two)[:2] == ([], [])
|
||||
out["N=1 adds no topic"] = len(base) == len(none) == len(BASELINE)
|
||||
out["N=1 adds no topic"] = len(base) == len(none) == len(SITE_NOW)
|
||||
out["unique_ids all distinct"] = (
|
||||
len(set(c_["unique_id"] for c_ in tc)) == len(tc))
|
||||
out["slug-alike names still distinct"] = (
|
||||
@@ -703,6 +779,25 @@ MUTATIONS = [
|
||||
' ("grid", "grid_power", "Grid power", "W", "power", "measurement", None),\n')]),
|
||||
("B's device drops `via_device`",
|
||||
[(' "via_device": DEVICE["identifiers"][0],\n', "")]),
|
||||
# --- TEL-10 ------------------------------------------------------------
|
||||
("TEL-10's integrator row is DROPPED (published to a topic nothing "
|
||||
"announced, which AC 4 forbids in the other direction)",
|
||||
[(' ("loop_i", "goodwe_loop_integrator", "Loop integrator", "W", "power", "measurement", None),\n', "")]),
|
||||
("TEL-10's integrator is announced PER INVERTER - two answers to a "
|
||||
"question §6.2 says there is one integrator for",
|
||||
[(' ("sign_latch", "sign_latch", "Sign latch", None, None, None, "mdi:flash-alert"),\n',
|
||||
' ("sign_latch", "sign_latch", "Sign latch", None, None, None, "mdi:flash-alert"),\n'
|
||||
' ("loop_i", "loop_integrator", "Loop integrator", "W", "power", "measurement", None),\n')]),
|
||||
# ⚠️ THE ONE THAT CHANGES NO BYTE OF ANY PAYLOAD. Every id, name, unit and
|
||||
# topic is identical; only the POSITION of the integrator row moves. A
|
||||
# broker replaying the retained set then hands HA a different sequence, and
|
||||
# part 1's claim is about order as much as about bytes - which is why it is
|
||||
# asserted as a LIST and not as a set.
|
||||
("TEL-10's integrator row is announced BEFORE release/1.0's nine",
|
||||
[(' ("loop_i", "goodwe_loop_integrator", "Loop integrator", "W", "power", "measurement", None),\n', ""),
|
||||
(' ("setpoint", "goodwe_setpoint", "Setpoint", "W", "power", "measurement", None),\n',
|
||||
' ("loop_i", "goodwe_loop_integrator", "Loop integrator", "W", "power", "measurement", None),\n'
|
||||
' ("setpoint", "goodwe_setpoint", "Setpoint", "W", "power", "measurement", None),\n')]),
|
||||
]
|
||||
|
||||
survivors = []
|
||||
@@ -757,11 +852,13 @@ for line in [
|
||||
"resolve it, because it renames no device and no entity, and the byte-diff "
|
||||
"protects both spellings equally. SIZE: unresolved, 8 live entity ids; it "
|
||||
"wants one look at the live registry, which this branch must not take.",
|
||||
"3. THE PER-UNIT PAYLOADS HAVE NO RELEASED BASELINE. A's are diffed against "
|
||||
"release/1.0; B's can only be diffed against this branch (the sha256 pin in "
|
||||
"part 3). A wrong `device_class` or unit on a per-unit sensor is therefore "
|
||||
"caught as a CHANGE from review, not as an error. SIZE: 4 sensors x 4 "
|
||||
"optional fields.",
|
||||
"3. THE PER-UNIT PAYLOADS HAVE NO RELEASED BASELINE, AND NEITHER DO "
|
||||
"TEL-10's THREE. A's are diffed against release/1.0; B's can only be "
|
||||
"diffed against this branch (the sha256 pin in part 3), and `TEL10` above "
|
||||
"is the same kind of pin for the three site rows this branch adds. A wrong "
|
||||
"`device_class` or unit on any of them is therefore caught as a CHANGE "
|
||||
"from review, not as an error. SIZE: 4 per-unit sensors x 4 optional "
|
||||
"fields, plus 3 site sensors x 4.",
|
||||
"4. NO CHECK HERE RUNS A BROKER. `_announce()` is graded through a fake "
|
||||
"paho client, so QoS, retain semantics on a real broker, and what HA does "
|
||||
"with a retained config for a device it has never seen are all out of "
|
||||
|
||||
@@ -1067,14 +1067,19 @@ class _V:
|
||||
|
||||
_here = _Pt(_NOW)
|
||||
_other = _Pt(_NOW - timedelta(hours=3))
|
||||
# SAFETY-45's `headroom_w` is the last positional. It is set to a value that
|
||||
# CANNOT BIND on either call - larger than every sizing below - so this part
|
||||
# goes on grading OPT-09's per-pack sizing and not the capacity-tariff cap that
|
||||
# now sits beside it. test_arbitrage.py section 3b is where it is made to bind.
|
||||
_CLEAR_W = 1e9
|
||||
_sell = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0,
|
||||
0.0, 0.0, 20.0, 90.0)
|
||||
0.0, 0.0, 20.0, 90.0, _CLEAR_W)
|
||||
_sell_ok = arbitrage._act(_Plan(_here), _NOW, _V(_other, _here), 60.0,
|
||||
0.0, 4600.0, 20.0, 90.0)
|
||||
0.0, 4600.0, 20.0, 90.0, _CLEAR_W)
|
||||
_buy = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0,
|
||||
0.0, 0.0, 20.0, 90.0)
|
||||
0.0, 0.0, 20.0, 90.0, _CLEAR_W)
|
||||
_buy_ok = arbitrage._act(_Plan(_here), _NOW, _V(_here, _other), 60.0, 5000.0,
|
||||
0.0, 20.0, 90.0)
|
||||
0.0, 20.0, 90.0, _CLEAR_W)
|
||||
print(f" sized at 0 W: sell -> {_sell.action}, buy -> {_buy.action}; "
|
||||
f"sized at a rating: sell -> {_sell_ok.action}, buy -> {_buy_ok.action}")
|
||||
check("⚠️ A TRADE NOTHING CAN TAKE IS NOT A TRADE. A 0 W sizing IDLES on both "
|
||||
|
||||
@@ -138,8 +138,8 @@ from app.peakguard import (
|
||||
BLIND_COVERAGE, BLIND_FROZEN, BLIND_NO_BLOCK, CEILING_CAP,
|
||||
CEILING_MONTH_PEAK, FLOOR_AT_SOC_FLOOR, FLOOR_SOC_UNREADABLE,
|
||||
PEAK_OPTION_DEFAULTS, Pack, PeakDecision, binding_soc_floor, blindness,
|
||||
decide, discharge_permitted, excess_w, pack_floor_pct, pack_permissions,
|
||||
quarter_ceiling_w, uncovered_s,
|
||||
charge_headroom_w, decide, discharge_permitted, excess_w, pack_floor_pct,
|
||||
pack_permissions, quarter_ceiling_w, uncovered_s,
|
||||
)
|
||||
from app.validate import validate_startup_config
|
||||
from app.tariff import (
|
||||
@@ -379,8 +379,17 @@ check("at the engagement boundary the excess is zero, so the floor is exactly "
|
||||
released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
would_release_on_excess = excess_w(p_edge, CEIL) <= 0.0
|
||||
# ⚠️ `floor_w` IS FORMATTED DEFENSIVELY, AND MUTATION.md's OWN LEDGER IS WHY.
|
||||
# The mutation this check exists for - reverting the `floor > 0` rung to
|
||||
# `excess > 0` - makes `floor_w` None HERE, so a bare `:.0f` raises TypeError
|
||||
# and ABORTS THE WHOLE FILE on the line after the check that just went red.
|
||||
# Parts 2-13 are then ungraded and the recorded coverage of that mutation is
|
||||
# smaller than the truth: exactly what MAINT-05's `delinquent(...)['b']`
|
||||
# subscript did to test_delivery.py, and habit 5 ("a check whose failure mode
|
||||
# is a hang - or a crash - needs a deadline, or a name") in the other costume.
|
||||
print(f" at the boundary: excess {e_edge:.3f} W (an `excess > 0` test would "
|
||||
f"RELEASE: {would_release_on_excess}), floor {released.floor_w:.0f} W "
|
||||
f"RELEASE: {would_release_on_excess}), floor "
|
||||
f"{'(none)' if released.floor_w is None else f'{released.floor_w:.0f} W'} "
|
||||
f"(the shipped `floor > 0` test holds: {released.engaged})")
|
||||
check("...and the claim is held there, not released. `excess` falls to zero as "
|
||||
"soon as the battery is already taking the watts, so thresholding IT is "
|
||||
@@ -5947,6 +5956,281 @@ check("...and the row is green and un-warned, which is what makes the "
|
||||
"operator scrolls past", _rows38[0]["ok"] and not _rows38[0]["warn"]
|
||||
and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0)
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("13. SAFETY-45 - charge_headroom_w(): the number decide() used to throw "
|
||||
"away")
|
||||
# ---------------------------------------------------------------------------
|
||||
# `decide()` computes `wanted = batt_w + excess` - the DISCHARGE that lands the
|
||||
# quarter exactly on the ceiling - and returns `engaged=False, floor_w=None`
|
||||
# the moment that number is not positive. A negative `wanted` is not "nothing
|
||||
# to say": it is a permitted CHARGE of `-wanted` W, and discarding it is what
|
||||
# let an arbitrage charge sit at max_w against a quarter it was blowing.
|
||||
#
|
||||
# ⚠️ NOTHING IN decide() CHANGES IN THIS CARD, AND THAT IS THE POINT. The
|
||||
# `wanted <= 0` branch is 2026-08-20's anti-bang-bang rung and part 6's replay
|
||||
# is what proves it still holds; this part grades a PURE READ of the number it
|
||||
# already carried. 13g asserts the branch is still there rather than trusting
|
||||
# the diff.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _proj_at(net_w, elapsed):
|
||||
return PeakTracker.project(averager_at(net_w, elapsed),
|
||||
B + timedelta(seconds=elapsed))
|
||||
|
||||
|
||||
def _decide_on(projection, *, batt_w, soc=SOC_OK, cap_w=CAP_W,
|
||||
achievable_w=LIVE_MAX_W, reading=None, frozen=False):
|
||||
return decide(projection, batt_w=batt_w, cap_w=cap_w, reading=reading,
|
||||
achievable_w=achievable_w, soc=soc, soc_floor=SOC_FLOOR,
|
||||
max_hold_s=MAX_HOLD_S, frozen=frozen)
|
||||
|
||||
|
||||
def _decide_at(net_w, elapsed, **kw):
|
||||
return _decide_on(_proj_at(net_w, elapsed), **kw)
|
||||
|
||||
|
||||
def _avg_held(base_w, elapsed, held_w, *, start=B, step=5.0):
|
||||
"""`elapsed` seconds BANKED at `base_w`, then `held_w` standing at the edge.
|
||||
|
||||
⚠️ THE HISTORY AND THE HELD VALUE ARE SEPARATE FIXTURE AXES, and 13c is why
|
||||
this helper exists rather than `averager_at(load - batt, ...)`. The averager
|
||||
credits a sample from its own timestamp to the NEXT one, so the last sample
|
||||
contributes ZERO accumulated seconds and only becomes `held`. That is the
|
||||
real shape of a battery whose command changed a moment ago - the quarter's
|
||||
banked seconds do not retroactively change - and a fixture that moved the
|
||||
whole history with it would be grading a different quarter each time.
|
||||
"""
|
||||
a = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
t = 0.0
|
||||
while t < elapsed:
|
||||
a.add(sample(start + timedelta(seconds=t), base_w))
|
||||
t += step
|
||||
a.add(sample(start + timedelta(seconds=elapsed), held_w))
|
||||
return a
|
||||
|
||||
|
||||
# --- 13a. the number, and it is DERIVED rather than read back ---------------
|
||||
# The house is flat at 2600 W under a 3500 W cap, the battery is idle, and 300 s
|
||||
# of the block are banked. 600 s remain, so the block can still absorb
|
||||
# 900 W x 900/600 = 1350 W of extra offtake and finish exactly on the ceiling.
|
||||
_H45_GAP = 900.0
|
||||
_H45_LOAD = CAP_W - _H45_GAP
|
||||
_H45_EL, _H45_REM = 300.0, QUARTER_S - 300.0
|
||||
_h45_idle = _decide_at(_H45_LOAD, _H45_EL, batt_w=0.0)
|
||||
_h45_head = charge_headroom_w(_h45_idle)
|
||||
# The CONSEQUENCE, computed from the fixture's own literals and not from the
|
||||
# module's arithmetic: hold that charge for the rest of the block and the
|
||||
# BILLED AVERAGE is the ceiling. This is the meaning of the number, which is a
|
||||
# different statement from the expression that produces it (habit 2).
|
||||
_H45_FINAL = (_H45_LOAD * _H45_EL
|
||||
+ (_H45_LOAD + _h45_head) * _H45_REM) / QUARTER_S
|
||||
print(f" house {_H45_LOAD:.0f} W under a {CAP_W:.0f} W cap, battery 0 W, "
|
||||
f"{_H45_EL:.0f} s banked: engaged={_h45_idle.engaged}, "
|
||||
f"wanted={_h45_idle.wanted_w:+.0f} W, headroom={_h45_head:.0f} W")
|
||||
print(f" holding that charge for the remaining {_H45_REM:.0f} s bills the "
|
||||
f"block at {_H45_FINAL:.1f} W against a {CAP_W:.0f} W ceiling")
|
||||
check("the guard files NOTHING here - this is the shipped `wanted <= 0` branch, "
|
||||
"untouched, and the state SAFETY-45 is about",
|
||||
not _h45_idle.engaged and _h45_idle.floor_w is None
|
||||
and _h45_idle.wanted_w is not None and _h45_idle.wanted_w < 0.0)
|
||||
check(f"...and yet the quarter can afford {_h45_head:.0f} W of charging, which "
|
||||
f"is the number that was being discarded. Graded by its CONSEQUENCE - "
|
||||
f"the block lands on the ceiling to the watt - rather than by "
|
||||
f"re-deriving -wanted_w with the module's own formula",
|
||||
near(_H45_FINAL, CAP_W, 0.5))
|
||||
check(f"...and it is none of the other numbers this fixture has: not the cap "
|
||||
f"({CAP_W:.0f}), not max_w ({LIVE_MAX_W:.0f}), not the house "
|
||||
f"({_H45_LOAD:.0f}) and not the raw gap under the ceiling "
|
||||
f"({_H45_GAP:.0f}) - the 900/remaining gain is 1.5 here and a check that "
|
||||
f"could not tell those apart would be decoration",
|
||||
not any(near(_h45_head, v, 1.0)
|
||||
for v in (CAP_W, LIVE_MAX_W, _H45_LOAD, _H45_GAP)))
|
||||
|
||||
# --- 13a2. AND THE IDENTITY HOLDS ACROSS THE WHOLE BLOCK --------------------
|
||||
# ⚠️ THE AMPLIFICATION IS REAL AND IT IS REPORTED WITH ITS SIZE (habit 13).
|
||||
# 900/remaining reaches 180 in the last five seconds, so the headroom there is
|
||||
# enormous - and it is bounded in EFFECT, not in magnitude: at every elapsed,
|
||||
# holding it bills the block at exactly the ceiling.
|
||||
H45_ID = []
|
||||
for _el in (0.0, 60.0, 300.0, 600.0, 870.0, 895.0):
|
||||
_rem = QUARTER_S - _el
|
||||
_hd = charge_headroom_w(_decide_at(_H45_LOAD, _el, batt_w=0.0))
|
||||
H45_ID.append((_el, _hd,
|
||||
(_H45_LOAD * _el + (_H45_LOAD + _hd) * _rem) / QUARTER_S))
|
||||
print(" headroom across the block (elapsed, headroom W, billed W): "
|
||||
+ ", ".join(f"({a:.0f}, {b:.0f}, {c:.1f})" for a, b, c in H45_ID))
|
||||
check("at EVERY point in the block, holding the headroom for the rest of it "
|
||||
"bills the block at exactly the ceiling - so the 162 kW answer five "
|
||||
"seconds from the boundary and the 900 W answer at the top of it are the "
|
||||
"same statement about the bill",
|
||||
all(near(c, CAP_W, 0.5) for _, _, c in H45_ID))
|
||||
check("...and the fixture SPANS the amplification rather than sitting in one "
|
||||
f"corner of it: the headroom moves by a factor of "
|
||||
f"{max(b for _, b, _ in H45_ID) / min(b for _, b, _ in H45_ID):.0f} "
|
||||
f"across those six points",
|
||||
max(b for _, b, _ in H45_ID) > 100.0 * min(b for _, b, _ in H45_ID))
|
||||
|
||||
# --- 13b. HOLDING implies ZERO OR UNKNOWN, swept - the family, not one case -
|
||||
# ⚠️ THE PROPERTY THAT MAKES THIS SAFE TO HAND A STRATEGY. Every state that
|
||||
# files a claim has `wanted > 0`, `wanted is None` or `blind`, so this function
|
||||
# can never hand a caller permission the guard's own `lo` is at that instant
|
||||
# refusing. Swept over the same grid part 5's no-negative-floor proof uses,
|
||||
# widened with the two ways a cycle can fail to measure at all.
|
||||
H45_N = H45_BAD = H45_POS = H45_NONE = H45_ZERO = 0
|
||||
for _load in (0.0, CAP_W - 500.0, CAP_W, CAP_W + 300.0, CAP_W + 3000.0,
|
||||
LIVE_MAX_W * 3):
|
||||
for _el in (30.0, 450.0, 870.0):
|
||||
for _bw in (-LIVE_MAX_W, -2000.0, 0.0, 400.0, 4000.0, None):
|
||||
for _sc in (None, BMS_FLOOR, SOC_FLOOR, SOC_FLOOR + 0.5, 100.0):
|
||||
for _fz in (False, True):
|
||||
_d45 = _decide_at(_load, _el, batt_w=_bw, soc=_sc,
|
||||
frozen=_fz)
|
||||
_hr = charge_headroom_w(_d45)
|
||||
H45_N += 1
|
||||
if _hr is None:
|
||||
H45_NONE += 1
|
||||
elif _hr > 0.0:
|
||||
H45_POS += 1
|
||||
else:
|
||||
H45_ZERO += 1
|
||||
if _d45.engaged and _hr not in (None, 0.0):
|
||||
H45_BAD += 1
|
||||
print(f" swept {H45_N} (load x elapsed x battery x SoC x frozen) "
|
||||
f"combinations: {H45_BAD} ENGAGED decisions answering a positive "
|
||||
f"headroom; {H45_POS} permit charging, {H45_ZERO} answer 0 W, "
|
||||
f"{H45_NONE} unmeasured")
|
||||
check(f"NOT ONE of the {H45_N} combinations answers a positive headroom while "
|
||||
f"the guard is ENGAGED. A decision whose `lo` is at that instant "
|
||||
f"forbidding charging can never be read as permission to charge",
|
||||
H45_BAD == 0 and H45_N > 800)
|
||||
check("...and the sweep is vacuous in no direction: it reaches decisions that "
|
||||
f"permit charging ({H45_POS}), decisions that permit none ({H45_ZERO}) "
|
||||
f"and decisions nothing measured ({H45_NONE}), so all three answers are "
|
||||
f"exercised", H45_POS > 0 and H45_ZERO > 0 and H45_NONE > 0)
|
||||
|
||||
# --- 13c. IT 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. The headroom 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.
|
||||
# Without this property the whole fix would be one loop replacing another.
|
||||
H45_INV, H45_EX = [], []
|
||||
for _bw in (-LIVE_MAX_W, -2000.0, -500.0, 0.0, 500.0, 2000.0):
|
||||
# ONE banked history, and the battery's change visible only in the HELD
|
||||
# value - which is what a command given a moment ago actually looks like.
|
||||
_d = _decide_on(PeakTracker.project(
|
||||
_avg_held(_H45_LOAD, _H45_EL, _H45_LOAD - _bw),
|
||||
B + timedelta(seconds=_H45_EL)), batt_w=_bw)
|
||||
H45_INV.append(charge_headroom_w(_d))
|
||||
H45_EX.append(round(_d.excess_w, 1))
|
||||
print(f" same house, same banked quarter, battery {-LIVE_MAX_W:+.0f}.."
|
||||
f"{2000.0:+.0f} W: headroom {[round(v, 3) for v in H45_INV]}")
|
||||
print(f" ...while the EXCESS those runs see is {H45_EX}")
|
||||
check("the headroom does NOT move when the battery moves across the whole "
|
||||
"range of what it can do - it is a property of the HOUSE and the "
|
||||
"CEILING, not of what the battery happens to be doing, which is what "
|
||||
"makes a trade sized from it feed-forward rather than a second loop "
|
||||
"around the plant",
|
||||
max(H45_INV) - min(H45_INV) < 1.0)
|
||||
check(f"...and the fixture SEPARATES it from the two quantities it is built "
|
||||
f"out of: the battery moved {LIVE_MAX_W + 2000.0:.0f} W and the excess "
|
||||
f"with it, over {len(set(H45_EX))} distinct values, so an implementation "
|
||||
f"answering `-batt_w` or `abs(excess)` would give six different answers "
|
||||
f"here (habit 4)",
|
||||
len(set(H45_EX)) == len(H45_EX)
|
||||
and len(set(round(v, 3) for v in H45_INV)) == 1)
|
||||
|
||||
# --- 13d. AND A BOUNDARY HANDS IT BACK AT THE AFFORDABLE RATE, NOT AT max_w --
|
||||
# ⚠️ SAFETY-45's SECOND HALF, AND THE CLAIM IS NOT "IT DOES NOT CHANGE".
|
||||
# `project()` resets to the held level at `elapsed = 0`, and the headroom
|
||||
# collapses with it - from 162 kW to 900 W here. What the card is about is WHAT
|
||||
# IT COLLAPSES TO: the house and the cap, and nothing else. The behaviour being
|
||||
# removed re-armed at `max_w` regardless of either.
|
||||
_H45_END = charge_headroom_w(_decide_at(_H45_LOAD, 895.0, batt_w=0.0))
|
||||
_H45_TOP = charge_headroom_w(_decide_at(_H45_LOAD, 0.0, batt_w=0.0))
|
||||
print(f" across a boundary, same house: {_H45_END:.0f} W with 5 s of the "
|
||||
f"old quarter left -> {_H45_TOP:.0f} W with the new one 0 s old, against "
|
||||
f"a max_w of {LIVE_MAX_W:.0f} W")
|
||||
check(f"at the top of the new quarter the headroom is exactly the ceiling less "
|
||||
f"the house ({CAP_W:.0f} - {_H45_LOAD:.0f} = {_H45_GAP:.0f} W). A trade "
|
||||
f"refused at 14:59 resumes at 15:00 at the rate the new quarter can "
|
||||
f"afford - the projection resets, this does not reset TO max_w",
|
||||
near(_H45_TOP, _H45_GAP, 1.0) and _H45_TOP < LIVE_MAX_W)
|
||||
check("...and the check is not passing because nothing moved: the headroom on "
|
||||
"the other side of that boundary is two orders of magnitude larger, and "
|
||||
"the PROJECTION really did reset",
|
||||
_H45_END > 100.0 * _H45_TOP and _proj_at(_H45_LOAD, 0.0).elapsed_s < 1.0)
|
||||
|
||||
# --- 13e. the unmeasured answers, and None is NOT unlimited -----------------
|
||||
_h45_nobatt = _decide_at(_H45_LOAD, _H45_EL, batt_w=None)
|
||||
_h45_blind = _decide_at(_H45_LOAD, _H45_EL, batt_w=0.0, frozen=True)
|
||||
print(f" no battery reading: {charge_headroom_w(_h45_nobatt)}; "
|
||||
f"BLIND: {charge_headroom_w(_h45_blind)}; no decision at all: "
|
||||
f"{charge_headroom_w(None)}")
|
||||
check("a guard that never ran answers None, not a number - `meter_source: off` "
|
||||
"reaches this with no decision at all and a caller that read it as a "
|
||||
"large headroom would be guard_state()'s None-is-not-False collapse one "
|
||||
"layer down", charge_headroom_w(None) is None)
|
||||
check("...and a cycle with NO BATTERY MEASUREMENT answers None too: there is "
|
||||
"nothing to size a floor from, so there is nothing to size a trade from "
|
||||
"either", charge_headroom_w(_h45_nobatt) is None
|
||||
and _h45_nobatt.wanted_w is None)
|
||||
check("...and BLIND answers 0.0 - a NUMBER, and the number is no charging. "
|
||||
"The claim it files already says exactly that at `floor 0 W`, and this "
|
||||
"agrees with it rather than restating it",
|
||||
charge_headroom_w(_h45_blind) == 0.0 and _h45_blind.blind
|
||||
and _h45_blind.floor_w == 0.0)
|
||||
check("...and the three are TOLD APART: blind is 0.0 and the two unmeasured "
|
||||
"states are None, so a caller can refuse for the reason that is true "
|
||||
"rather than for whichever fired first",
|
||||
charge_headroom_w(_h45_blind) is not None
|
||||
and charge_headroom_w(_h45_nobatt) is None)
|
||||
|
||||
# --- 13f. the SoC floor does not become charging permission -----------------
|
||||
# ⚠️ THE ONE ENGAGED STATE WHERE THE GUARD IS DELIBERATELY NOT DISCHARGING.
|
||||
# SAFETY-24's floor-limited decision files `lo = 0.0` - "charging stays
|
||||
# blocked" - on a quarter that IS over the ceiling. If that read as headroom,
|
||||
# an empty pack in a blown quarter would be told to buy.
|
||||
_h45_lim = _decide_at(CAP_W + 2000.0, _H45_EL, batt_w=0.0, soc=SOC_FLOOR - 1.0)
|
||||
print(f" floor-limited over a blown quarter: engaged={_h45_lim.engaged}, "
|
||||
f"floor_limited={_h45_lim.floor_limited}, wanted="
|
||||
f"{_h45_lim.wanted_w:+.0f} W, headroom={charge_headroom_w(_h45_lim)}")
|
||||
check("a FLOOR-LIMITED decision - the guard wanted a discharge and SoC refused "
|
||||
"to size it - answers 0.0 headroom. The quarter is over the ceiling; the "
|
||||
"pack being empty is not a reason to fill it from the grid in it",
|
||||
_h45_lim.floor_limited and _h45_lim.engaged
|
||||
and charge_headroom_w(_h45_lim) == 0.0)
|
||||
|
||||
# --- 13g. decide() ITSELF IS UNCHANGED, PINNED -----------------------------
|
||||
# ⚠️ MUTATION.md habit 4, in the direction a diff cannot cover. The whole safety
|
||||
# argument of this card is "the 2026-08-20 rung is still there and part 6 still
|
||||
# grades it". That is a claim about the SOURCE, so it is pinned on the source -
|
||||
# and on the BEHAVIOUR, because a shape pin alone would pass a rung that had
|
||||
# been inverted.
|
||||
_H45_SRC = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8")
|
||||
_H45_TREE = ast.parse(_H45_SRC)
|
||||
_h45_fn = next(n for n in ast.walk(_H45_TREE)
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "decide")
|
||||
_H45_RUNG = [n for n in ast.walk(_h45_fn) if isinstance(n, ast.If)
|
||||
and ast.unparse(n.test) == "wanted <= 0.0"]
|
||||
check("the `wanted <= 0.0` rung is still in decide(), spelled exactly that "
|
||||
"way, on the AST rather than as a substring - it is the rung whose "
|
||||
"removal put 2026-08-20's quarter 49 W over the limit after seventeen "
|
||||
"engage/release cycles in 900 s", len(_H45_RUNG) == 1)
|
||||
check("...and it still DISENGAGES rather than merely reporting: the fixture "
|
||||
"13a is built on reaches it and files no claim, so the pin above is not "
|
||||
"grading a rung that has been inverted under it",
|
||||
peakguard.claim(_h45_idle) is None)
|
||||
_h45_head_fn = next(n for n in _H45_TREE.body if isinstance(n, ast.FunctionDef)
|
||||
and n.name == "charge_headroom_w")
|
||||
check("...and charge_headroom_w() is a PURE READ of the decision - one "
|
||||
"positional parameter, no projection, no clock and no options - so it "
|
||||
"cannot become a second answer to the question decide() already answers",
|
||||
[a.arg for a in _h45_head_fn.args.args] == ["decision"]
|
||||
and not _h45_head_fn.args.kwonlyargs)
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
"""Runnable check for TEL-10 and SAFETY-48. `python3 test_telemetry.py`
|
||||
|
||||
TEL-10 publishes the loop's integrator. SAFETY-48 bounds it. They are graded in
|
||||
one file because they are one observable: the number SAFETY-48 bounds is the
|
||||
number TEL-10 added, and a check on either that could not see the other would
|
||||
be grading its own fixture.
|
||||
|
||||
`self.i_w` is carried between cycles of `Controller.cycle()` and, before this
|
||||
card, left the process nowhere at all - no entity, no MQTT attribute, no log
|
||||
line. The last investigation into what it does had to RECONSTRUCT it from the
|
||||
grid trace; the reconstruction reproduced about a third of the engagements it
|
||||
was checked against and was discarded.
|
||||
|
||||
⚠️ WHAT THIS FILE REFUSES TO DO, SAID FIRST. It does not assert that a row
|
||||
EXISTS. `publish()` handing a dict with `loop_i` in it is satisfied by
|
||||
`loop_i: 0.0` forever, by `loop_i: setpoint`, and by a row that reports the
|
||||
command under the integrator's name - which is exactly the confusion the card
|
||||
is about, because on a default install the two are within a quantisation step
|
||||
of each other for most of every day. Every check below therefore drives REAL
|
||||
CYCLES through the real `Controller.cycle()` until the two quantities are
|
||||
MEASURABLY DIFFERENT, and grades the published number at a tick where reading
|
||||
the wrong one gives the wrong answer. MUTATION.md habit 4.
|
||||
|
||||
VERIFIED BY BREAKING IT. Every check below was driven by the mutation that
|
||||
defeats the claim it names, in the CI image, on a tree purged of `__pycache__`
|
||||
before each run, with the file's sha256 taken before, after, and again after
|
||||
the revert (MUTATION.md's reporting rules, and habit 18's unique backup path).
|
||||
|
||||
# mutation bytes reds file
|
||||
-- ---------------------------------------------- ----- ---- -----------
|
||||
M1 `self.loop_i_w = loop_prev` - publish the -3 B 5 this file
|
||||
COMMAND under the integrator's name
|
||||
M2 `decision.i_w - self.target` for the lead +2 B 0 SURVIVOR
|
||||
M2b `decision.i_w - decision.target_w` for the lead +8 B 1 this file
|
||||
M3 `"loop_reason": self.reason` - the ARBITER's -5 B 5 this file
|
||||
sentence in the loop's row
|
||||
M4 delete `self.loop_reason = "not-running"` -45 B 1 this file
|
||||
M5 delete the not-running branch's two zeroes -67 B 1 this file
|
||||
M6 `"loop_i": round(self.target, 1)` -2 B 4 this file
|
||||
M7 the `loop_i` row deleted from `SENSORS` -96 B 10+1 test_mqtt
|
||||
+ this file
|
||||
M8 `loop_i` announced PER INVERTER +89 B 8 test_mqtt
|
||||
M9 `loop_i` announced BEFORE release/1.0's nine +0 B 7 test_mqtt
|
||||
|
||||
⚠️ A RED COUNT IS A PROPERTY OF THE SUITE AS MUCH AS OF THE MUTATION, SO EVERY
|
||||
ONE ABOVE NAMES A TREE - this one, at this commit. A later card that adds a
|
||||
check, re-baselines one or deletes one moves the number with the mutation
|
||||
untouched. Re-measure rather than inherit: `tools/mutation_run.sh` on a `cp -a`
|
||||
of the tree, one file at a time, and say which tree. M7's count was written as
|
||||
8 and is 10 on a re-run, on both trees - the direction was right and the count
|
||||
was decoration, which is the half of MUTATION.md's reporting rule 3 that is
|
||||
easiest to let rot.
|
||||
|
||||
⚠️ AND SAFETY-48 HAS ALREADY MOVED ONE OF THEM. M1 defeats FIVE checks on
|
||||
TEL-10's own tree and FOUR on this one: this card replaced the "...the gap is
|
||||
the slew limit and not an accident" check, because the gap it asserted is now
|
||||
bounded and the assertion is written against the bound instead. The mutation
|
||||
did not change; the suite did. Both counts are right, for different trees, and
|
||||
neither is right without one.
|
||||
|
||||
⚠️ M9 IS ZERO BYTES AND CHANGES NO PAYLOAD. Every id, name, unit and topic is
|
||||
identical; only the ORDER of the retained messages moves. No fingerprint and no
|
||||
byte delta can see it, which is why part 1 asserts the announcement as an
|
||||
ordered LIST and not as a set.
|
||||
|
||||
⚠️ M2 IS A REAL SURVIVOR AND IS REPORTED AS ONE. At the line it edits,
|
||||
`loop_prev` and `self.target` are the same value: `cycle()` does not reassign
|
||||
`self.target` until `self.target = resolution.target_w`, further down. The
|
||||
mutation therefore changes nothing that runs, on a purged tree, which is not
|
||||
the direction stale bytecode fabricates. `loop_prev` is kept anyway - it is the
|
||||
name the `compute()` call is made with, so the lead cannot be measured against
|
||||
a different cycle's command by anybody moving a line - and M2b is the mutation
|
||||
that DOES change the value, aimed at exactly that.
|
||||
|
||||
⚠️ AND THE FIRST DRAFT OF THIS FILE HAD TWO CHECKS OF ITS OWN THAT COULD NOT
|
||||
REACH THE FAILURE THEY NAMED. Both are MUTATION.md habit 4:
|
||||
|
||||
* "the published integrator reaches its bound" was GREEN under M1, because
|
||||
the command reaches the bound too - twelve cycles later. The check now
|
||||
asserts the LEAD TIME, not the value.
|
||||
* "a stopped controller publishes `not-running`" was GREEN under M4 and M5,
|
||||
because the fixture STARTED stopped and `__init__` writes the same values.
|
||||
It stops 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.
|
||||
|
||||
SAFETY-48's OWN BATTERY, same discipline, against `app/control.py`:
|
||||
|
||||
# mutation bytes reds
|
||||
-- ---------------------------------------------- ----- --------------------
|
||||
L1 the whole SAFETY-48 block deleted -195 B 5 here + 4 control
|
||||
L2 `SLEW_LEAD_STEPS = 0` - the velocity form +0 B 4 here + 3 control
|
||||
L3 `SLEW_LEAD_STEPS = 12` - a cap above the rail +1 B 4 here + 3 control
|
||||
L4 the cap applied unconditionally, outside the -92 B 1 here ⚠️ SURVIVOR
|
||||
slew branch of all 30 files
|
||||
until part 4's
|
||||
`integrator_max_w`
|
||||
case existed
|
||||
L5 `min`/`max` swapped - the cap on the wrong +0 B 5 here + 5 control
|
||||
side (or 5 + 4; see below)
|
||||
L6 the lead measured against `prev_w` rather than +0 B 6 here + 3 control
|
||||
the command it emits
|
||||
|
||||
⚠️ L4, L5 AND L6 ARE ALL ZERO- OR NEGATIVE-BYTE EDITS THAT CHANGE NO PAYLOAD
|
||||
AND NO OPTION, AND L4 WAS BIT-IDENTICAL ON THE SHIPPED CONFIGURATION - 0 of
|
||||
256 246 ticks over the 16-day recording. It is separable only where
|
||||
`integrator_max_w` is above `max_w`, which is the stimulus part 4 drives; the
|
||||
COUNT of separable states is a property of whatever grid was swept and three
|
||||
sweeps disagree about it, so DECISIONS/SAFETY-48 §4 carries all three rather
|
||||
than one.
|
||||
|
||||
⚠️ AND L5 IS TWO MUTATIONS WEARING ONE NAME, WHICH IS WHY ITS COUNT NEEDS A
|
||||
SPELLING BESIDE IT. "min/max swapped" can mean the FUNCTION names exchanged
|
||||
(`max(i_w, slewed + cap)` / `min(i_w, slewed - cap)`) - 5 here + 5 in
|
||||
test_control.py, one of the five being SAFETY-13's own "frozen integrator never
|
||||
deadlocks" sweep over 13 041 states - or the whole BRANCH bodies exchanged
|
||||
(`max(i_w, slewed - cap)` / `min(i_w, slewed + cap)`), which is 5 + 4 and does
|
||||
NOT reach the deadlock sweep. Both are red, both are the cap on the wrong side,
|
||||
and a table that writes one number for the pair is reporting a mutation nobody
|
||||
can re-run. The first spelling is the one the row above quotes.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
stops bytecode being WRITTEN, not a stale one being READ, and a size-preserving
|
||||
edit reverted inside one second is invisible to CPython's staleness test. That
|
||||
is TEST-15, and it has already turned one live mutation into a false survivor.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from app import mqtt
|
||||
from app.control import SLEW_LEAD_STEPS, Tuning, compute, slew_limit_w
|
||||
from app.main import Controller
|
||||
|
||||
fails = []
|
||||
|
||||
# The live install's shape, and every number in it is measured rather than
|
||||
# chosen: `max_w` and `slew_w` are read straight off the recorder (the setpoint
|
||||
# sensor's extremes are exactly +-5000 and 11 226 recorded steps are exactly
|
||||
# 400 W), `gain` and `target_grid_w` are what the options carry.
|
||||
LIVE = {"max_w": 5000.0, "gain": 0.6, "slew_w": 400.0, "target_grid_w": -10.0,
|
||||
"deadband_w": 15, "step_w": 10}
|
||||
# The integrator's bound on a default install: `integrator_max_w` is None, which
|
||||
# control.py resolves to `max_w`. Written as a literal on purpose (habit 2) -
|
||||
# `== c.tuning.max_w` would compare the thing under test against itself.
|
||||
BOUND_W = 5000.0
|
||||
# `slew_w / gain`: the error above which the integrator moves further in one
|
||||
# cycle than the command is allowed to. 400 / 0.6.
|
||||
OUTRUN_W = 666.67
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
|
||||
class _Pub:
|
||||
"""Captures what `Controller.publish()` hands the publisher, every cycle."""
|
||||
|
||||
enabled = False
|
||||
|
||||
def __init__(self):
|
||||
self.frames = []
|
||||
|
||||
def publish(self, values):
|
||||
self.frames.append(dict(values))
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
"""A scripted meter and an inverter that follows what it is told.
|
||||
|
||||
⚠️ THE PLANT FOLLOWS EXACTLY AND WITH NO LAG, WHICH IS DELIBERATE AND IS
|
||||
THE HARDER CASE FOR EVERY CHECK HERE. A lagging inverter trips the
|
||||
SATURATION freeze (`|prev_w - actual_w| > saturation_w`), and the
|
||||
saturation freeze already holds the integrator - so a laggy fixture would
|
||||
let SAFETY-13's mechanism pass for SAFETY-48's. Nothing below may be
|
||||
explained by saturation, and part 4 asserts `frozen` is False throughout.
|
||||
"""
|
||||
|
||||
def __init__(self, grid, soc=50.0):
|
||||
self.grid_seq = grid if callable(grid) else (lambda _n: grid)
|
||||
self.soc = soc
|
||||
self.batt = 0.0
|
||||
self.n = 0
|
||||
self.writes = []
|
||||
self.calls = []
|
||||
|
||||
async def limits(self, entity):
|
||||
return (-LIVE["max_w"], LIVE["max_w"], 1.0)
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
return {"sensor.grid": self.grid_seq(self.n), "sensor.soc": self.soc,
|
||||
"sensor.batt": self.batt}.get(entity)
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append(float(value))
|
||||
self.batt = float(value)
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
self.calls.append((domain, service, data))
|
||||
return True
|
||||
|
||||
|
||||
def run(grid, cycles=30, auto=True, stop_after=None, **over):
|
||||
"""`cycles` real `Controller.cycle()`s, and every published frame.
|
||||
|
||||
`stop_after` presses stop part-way through - which is not the same run as
|
||||
starting stopped, and is the only one that can tell a row that was WRITTEN
|
||||
on the not-running branch from a row that was never written at all.
|
||||
"""
|
||||
async def go():
|
||||
hass = _Hass(grid)
|
||||
pub = _Pub()
|
||||
c = Controller(dict(
|
||||
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "heartbeat_s": 10, "stale_input_s": 15},
|
||||
**LIVE, **over),
|
||||
hass, _Store(), pub, stop=asyncio.Event())
|
||||
c.auto = auto
|
||||
c.dev_min, c.dev_max = -LIVE["max_w"], LIVE["max_w"]
|
||||
for k in range(cycles):
|
||||
if stop_after is not None and k == stop_after:
|
||||
c.auto = False
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
c.publish()
|
||||
hass.n += 1
|
||||
return c, hass, pub.frames
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print("1. TEL-10: the integrator is published, and it is NOT the command")
|
||||
# ==============================================================================
|
||||
# A standing 6 kW export against a 5 kW rail and a 400 W slew step.
|
||||
#
|
||||
# ⚠️ THIS FIXTURE WAS BUILT AGAINST THE UNBOUNDED INTEGRATOR AND SAFETY-48 TOOK
|
||||
# ITS SEPARATION AWAY, WHICH IS WORTH SAYING RATHER THAN QUIETLY RE-TUNING. On
|
||||
# the law TEL-10 shipped against, the integrator reached its 5000 W bound on
|
||||
# cycle 1 and the command needed twelve more cycles to arrive, so the two rows
|
||||
# differed by KILOWATTS and any check at all could see it. SAFETY-48 bounds the
|
||||
# lead at one slew step, so the gap is now 800 W and the integrator arrives ONE
|
||||
# cycle early instead of twelve. Both surviving separations are graded: the
|
||||
# ARRIVAL ORDER, and the 800 W plateau that holds for the whole ramp.
|
||||
ctl, hass, frames = run(-6000.0, cycles=30)
|
||||
i_row = [f["loop_i"] for f in frames]
|
||||
sp_row = [f["setpoint"] for f in frames]
|
||||
lead_row = [f["loop_lead"] for f in frames]
|
||||
why_row = [f["loop_reason"] for f in frames]
|
||||
print(" cycle loop_i setpoint loop_lead loop_reason")
|
||||
for k in range(8):
|
||||
print(f" {k:<5d}{i_row[k]:8.0f} {sp_row[k]:9.0f} {lead_row[k]:10.0f}"
|
||||
f" {why_row[k]}")
|
||||
|
||||
first_at_bound = next((k for k, v in enumerate(i_row) if abs(v) >= BOUND_W), None)
|
||||
first_cmd_at_bound = next((k for k, v in enumerate(sp_row) if abs(v) >= BOUND_W),
|
||||
len(sp_row))
|
||||
print(f" the integrator reaches {-BOUND_W:.0f} W at cycle "
|
||||
f"{first_at_bound}; the command reaches it at cycle "
|
||||
f"{first_cmd_at_bound}")
|
||||
|
||||
# ⚠️ THE ARRIVAL ORDER IS THE CLAIM, NOT THE VALUE. "The published number
|
||||
# reaches the bound" is satisfied by publishing the COMMAND too - it gets there
|
||||
# as well, a cycle later - so a check written on the value alone grades nothing
|
||||
# and the mutation that publishes `prev_w` walks straight through it. Measured:
|
||||
# with `self.loop_i_w = loop_prev` the value-only form was GREEN and this one is
|
||||
# red.
|
||||
check("⭐ THE PUBLISHED INTEGRATOR REACHES ITS BOUND, AND REACHES IT BEFORE THE "
|
||||
f"COMMAND DOES. A 6 kW export winds it to exactly {BOUND_W:.0f} W - "
|
||||
"`integrator_max_w` is None, which control.py resolves to `max_w` - while "
|
||||
"the command is still a slew step short",
|
||||
first_at_bound is not None and i_row[first_at_bound] == -BOUND_W
|
||||
and first_cmd_at_bound > first_at_bound)
|
||||
|
||||
# ⚠️ THE PRECONDITION, AS ITS OWN CHECK (habit 1). If the command happened to be
|
||||
# at the bound too, the line above would be satisfied by publishing EITHER
|
||||
# number and would be grading nothing.
|
||||
check("...and the command is NOT at the bound at that tick, so the two are "
|
||||
f"separable: setpoint {sp_row[first_at_bound]:.0f} W against an "
|
||||
f"integrator of {i_row[first_at_bound]:.0f} W",
|
||||
abs(BOUND_W - abs(sp_row[first_at_bound])) >= 200.0)
|
||||
|
||||
# ⚠️ AND THE PLATEAU, WHICH IS THE SEPARATION THAT LASTS. Through the whole
|
||||
# ramp the integrator stands exactly two slew steps ahead of the command the
|
||||
# previous cycle left standing - one step is `SLEW_LEAD_STEPS` against the
|
||||
# command this cycle EMITS, and the emitted command is itself one step ahead of
|
||||
# `prev_w`. It is graded as a RUN of identical values rather than as a maximum,
|
||||
# because a maximum is reached by one tick and a plateau is the shape.
|
||||
_plateau = [k for k in range(1, 13) if lead_row[k] == -800.0]
|
||||
print(f" the lead plateau: {lead_row[1:13]}")
|
||||
check("⭐ AND THE TWO ROWS ARE SEPARATED FOR THE WHOLE RAMP, not at one tick: "
|
||||
f"the lead sits at exactly -800 W (two slew steps) on "
|
||||
f"{len(_plateau)} of cycles 1..12 while both numbers travel 400 W a "
|
||||
"cycle", len(_plateau) >= 8)
|
||||
check("...and the command really is travelling a full slew step a cycle "
|
||||
"through that plateau, so the plateau is not a stalled loop",
|
||||
all(round(abs(sp_row[k] - sp_row[k - 1]), 6)
|
||||
== slew_limit_w(Tuning(**LIVE)) for k in _plateau))
|
||||
|
||||
# The lead is `i_w - prev_w`, and `prev_w` is the command the LAST cycle left
|
||||
# standing - which is the setpoint this file captured one frame earlier. Graded
|
||||
# as that identity rather than as a magnitude, because a magnitude cannot tell
|
||||
# a lead from the integrator itself.
|
||||
ident = [k for k in range(1, 12)
|
||||
if round(lead_row[k], 1) != round(i_row[k] - sp_row[k - 1], 1)]
|
||||
check("⭐ AND `loop_lead` IS THE INTEGRATOR MINUS THE COMMAND IT WAS COMPUTED "
|
||||
"AGAINST - the PREVIOUS cycle's setpoint, not this one's. Checked as an "
|
||||
f"identity over cycles 1..11 ({len(ident)} disagreements)", not ident)
|
||||
check("...and it is never a permanent zero while the loop is working: worst "
|
||||
f"lead {min(lead_row):.0f} W",
|
||||
min(lead_row) <= -800.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("2. TEL-10: the reason row is compute()'s OWN word, not the arbiter's")
|
||||
# ==============================================================================
|
||||
# ⚠️ THE TWO SENTENCES EXIST SIDE BY SIDE AND ONE OF THEM ALREADY HAD A ROW.
|
||||
# `self.reason` is `Resolution.explain()` - "loop -> -800 W" - and it prints the
|
||||
# winning claim's NAME, never the claim's reason. So a publisher wired to
|
||||
# `self.reason` would produce a row that is never wrong-looking and never once
|
||||
# says `slew-limited`, which is the value SAFETY-48 is about.
|
||||
print(f" the arbiter's sentence: {ctl.reason!r}")
|
||||
print(f" the loop's own word : {ctl.loop_reason!r}")
|
||||
check("⭐ THE LOOP'S REASON REACHES THE WIRE, and on a slew-limited cycle it is "
|
||||
"the string `slew-limited` - a value the arbiter's sentence does not "
|
||||
"contain at all",
|
||||
"slew-limited" in why_row and "slew-limited" not in ctl.reason)
|
||||
check("...and the two rows are DIFFERENT STRINGS on the same cycle, so one "
|
||||
"cannot have been published from the other",
|
||||
frames[-1]["loop_reason"] != ctl.reason)
|
||||
check("...and every published reason is one `compute()` can actually produce",
|
||||
set(why_row) <= {"tracking", "deadband", "i-clamped", "clamped",
|
||||
"slew-limited", "saturated-freeze", "freeze-released",
|
||||
"not-running"})
|
||||
|
||||
# A converged site: the error is inside the deadband and the law says so.
|
||||
calm = run(-10.0, cycles=6)[2]
|
||||
check("...and a site resting inside the deadband publishes `deadband`, not the "
|
||||
"last exciting word the loop said", calm[-1]["loop_reason"] == "deadband")
|
||||
|
||||
# ⚠️ THE NOT-RUNNING PATH IS A DIFFERENT BRANCH OF `cycle()` AND IS GRADED AS
|
||||
# ONE (habit 9). That branch zeroes `self.i_w`, so a row that kept repeating the
|
||||
# last integrator there would say the loop was winding while nothing is being
|
||||
# controlled at all.
|
||||
#
|
||||
# ⚠️ AND `auto: False` IS NOT THAT BRANCH, WHICH IS WHY BOTH ARE HERE. 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, the
|
||||
# integrator is real, and a check that assumed otherwise would have been
|
||||
# asserting `not-running` about a loop that was running. It took `auto: False`
|
||||
# AND `shadow_mode: off` to reach the arm that zeroes it.
|
||||
shadowed = run(-6000.0, cycles=6, auto=False)[2]
|
||||
print(f" stopped, shadow_mode auto: status {shadowed[-1]['status']!r}, "
|
||||
f"loop_i {shadowed[-1]['loop_i']:+.0f} W, "
|
||||
f"reason {shadowed[-1]['loop_reason']!r}")
|
||||
check("a STOPPED controller in shadow mode still publishes a live integrator - "
|
||||
"the decision path runs, so the row must report it rather than a zero "
|
||||
"that would read as 'nothing is winding'",
|
||||
shadowed[-1]["status"] == "shadow"
|
||||
and shadowed[-1]["loop_i"] < -100.0
|
||||
and shadowed[-1]["loop_reason"] != "not-running")
|
||||
|
||||
# ⚠️ AND IT IS STOPPED PART-WAY, NOT STARTED STOPPED, WHICH IS THE WHOLE
|
||||
# DIFFERENCE. A controller that never ran carries `__init__`'s values, so a run
|
||||
# that begins stopped is satisfied by a branch that writes NOTHING - measured:
|
||||
# deleting `self.loop_reason = "not-running"` from `cycle()` outright was a
|
||||
# -45 B SURVIVOR of the first draft of this check. The loop has to wind first,
|
||||
# so that a row left standing is a row that says the wrong thing.
|
||||
stopped = run(-6000.0, cycles=12, stop_after=6, shadow_mode="off")[2]
|
||||
print(f" while running : loop_i {stopped[5]['loop_i']:+.0f} W, "
|
||||
f"reason {stopped[5]['loop_reason']!r}, status {stopped[5]['status']!r}")
|
||||
print(f" after stopping : loop_i {stopped[-1]['loop_i']:+.0f} W, "
|
||||
f"reason {stopped[-1]['loop_reason']!r}, status {stopped[-1]['status']!r}")
|
||||
check("...and the precondition: the loop really was winding before stop was "
|
||||
"pressed, so a stale row afterwards would be a WRONG row and not merely "
|
||||
"an unset one",
|
||||
stopped[5]["loop_i"] <= -1000.0
|
||||
and stopped[5]["loop_reason"] != "not-running")
|
||||
check("⭐ AND ONCE THE DECISION PATH IS GENUINELY OFF THE ROWS SAY SO: "
|
||||
"`not-running` and a zero integrator - the same zero `cycle()` writes to "
|
||||
"`self.i_w` on that branch, not the number it was carrying a cycle ago",
|
||||
stopped[-1]["status"] == "stopped"
|
||||
and stopped[-1]["loop_reason"] == "not-running"
|
||||
and stopped[-1]["loop_i"] == 0.0 and stopped[-1]["loop_lead"] == 0.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("3. TEL-10: announced and published are the same three topics")
|
||||
# ==============================================================================
|
||||
# mqtt.py's `omit` note: an entity that exists and is never fed is a worse
|
||||
# signal than one that does not exist. This is that claim for TEL-10's rows,
|
||||
# taken off the SHIPPED announcement and the SHIPPED `publish()`.
|
||||
announced = {t.rsplit("/", 2)[-2].replace(f"{mqtt.BASE}_", "")
|
||||
for t, _ in mqtt.discovery_payloads()}
|
||||
published = set(frames[-1])
|
||||
TEL10 = {"loop_i", "loop_lead", "loop_reason"}
|
||||
print(f" announced keys: {sorted(announced & TEL10)}")
|
||||
print(f" published keys: {sorted(published & TEL10)}")
|
||||
check("all three rows are announced AND published - no entity nothing feeds, "
|
||||
"no topic nothing declared",
|
||||
TEL10 <= announced and TEL10 <= published)
|
||||
check("...and they are on the SITE device, not per unit - there is one "
|
||||
"integrator",
|
||||
all(c["device"] == mqtt.DEVICE
|
||||
for t, c in mqtt.discovery_payloads(["inverter-a", "inverter-b"])
|
||||
if c["unique_id"].replace(f"{mqtt.BASE}_", "") in TEL10))
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("4. SAFETY-48: the slew step ceiling stops the integrator outrunning it")
|
||||
# ==============================================================================
|
||||
# ⚠️ THE ARITHMETIC THE CARD IS ABOUT, RE-DERIVED RATHER THAN QUOTED. 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`. At the live shape that is 667 W, and the recorded
|
||||
# median error at the onset of an episode is 1114 W.
|
||||
T = Tuning(**LIVE)
|
||||
check(f"the outrun threshold is `slew_w / gain` = {OUTRUN_W:.0f} W, and it is "
|
||||
"the shipped pair that produces it, not a constant written here",
|
||||
abs(T.slew_w / T.gain - OUTRUN_W) < 0.5)
|
||||
|
||||
# ⚠️ TWO LEADS, AND CONFUSING THEM IS HOW THIS CHECK WOULD BE WRITTEN WRONG.
|
||||
# The bound is on `i_w - target`, the command this cycle EMITS - that is what
|
||||
# makes the unwind one cycle, because the command can cover one slew step in
|
||||
# one cycle. `loop_lead`, and `Decision.i_w - prev_w`, are measured against the
|
||||
# command the PREVIOUS cycle left standing, and the emitted command is itself
|
||||
# one slew step further on - so the published figure is bounded by TWO steps,
|
||||
# not one. Both are asserted, against their own bound.
|
||||
_SLEW = slew_limit_w(T)
|
||||
d = compute(prev_w=0.0, grid_w=-6000.0, actual_w=0.0, tuning=T, i_w=-100.0)
|
||||
print(f" one cycle at a 5990 W error: command {d.target_w:+.0f} W, "
|
||||
f"integrator {d.i_w:+.0f} W, ahead of the EMITTED command by "
|
||||
f"{d.i_w - d.target_w:+.0f} W and of `prev_w` by {d.i_w:+.0f} W, "
|
||||
f"reason {d.reason!r}")
|
||||
check(f"⭐ THE INTEGRATOR MAY LEAD THE COMMAND IT EMITS BY AT MOST "
|
||||
f"{SLEW_LEAD_STEPS} x slew_w = {SLEW_LEAD_STEPS * _SLEW:.0f} W - so the "
|
||||
"worst-case unwind is ONE cycle, not `integrator_max_w / slew_w` (12.5 "
|
||||
"at this shape)",
|
||||
abs(d.i_w - d.target_w) <= SLEW_LEAD_STEPS * _SLEW + 1e-9)
|
||||
|
||||
# ...and over a real run, which is where release/1.0 walks to the rail.
|
||||
leads = [abs(f["loop_lead"]) for f in frames]
|
||||
print(f" over {len(frames)} driven cycles: worst published lead "
|
||||
f"{max(leads):.0f} W, worst integrator {min(i_row):.0f} W")
|
||||
check(f"⭐ AND IT HOLDS ON EVERY DRIVEN CYCLE: the published lead - measured "
|
||||
f"against the PREVIOUS command - never exceeds "
|
||||
f"{(SLEW_LEAD_STEPS + 1) * _SLEW:.0f} W, one step for the bound and one "
|
||||
"for the step the command took. On release/1.0 this same run reaches "
|
||||
"4600 W",
|
||||
max(leads) <= (SLEW_LEAD_STEPS + 1) * _SLEW + 1e-9)
|
||||
check("...and release/1.0's figure is not merely larger, it is over the bound: "
|
||||
"the run that produced 4600 W here would be red on the line above",
|
||||
4600.0 > (SLEW_LEAD_STEPS + 1) * _SLEW)
|
||||
check("...and nothing here is the SATURATION freeze doing SAFETY-48's work: "
|
||||
"the inverter tracks exactly, so `frozen` is never true",
|
||||
not any(f["loop_reason"] in ("saturated-freeze", "freeze-released")
|
||||
for f in frames))
|
||||
|
||||
# ⚠️ AND THE CAP IS NOT ZERO, WHICH IS ITS OWN CHECK BECAUSE NOTHING ELSE
|
||||
# NOTICES. `i_w = slewed` - a cap of zero - satisfies every bound above and is
|
||||
# what control.py's docstring calls "the exact velocity form it replaced":
|
||||
# the accumulator stops being a separate quantity at all. The claim is
|
||||
# therefore that the integrator is STRICTLY ahead of the command it emits, by
|
||||
# exactly one step, on a slew-limited cycle.
|
||||
check(f"⭐ AND IT IS NOT A BACK-CALCULATION: on a slew-limited cycle the "
|
||||
f"integrator stands EXACTLY {SLEW_LEAD_STEPS} slew step(s) ahead of the "
|
||||
f"command it emits ({d.i_w - d.target_w:+.0f} W), not on top of it - "
|
||||
"a cap of zero would collapse this function to the velocity form it "
|
||||
"replaced",
|
||||
d.reason == "slew-limited"
|
||||
and round(abs(d.i_w - d.target_w), 6) == SLEW_LEAD_STEPS * _SLEW)
|
||||
|
||||
# ⚠️ AND IT BINDS IN BOTH DIRECTIONS OF TRAVEL, WHICH IS NOT OBVIOUS FROM THE
|
||||
# SPELLING. `if target > slewed ... elif target < slewed` reads like two cases
|
||||
# and is one: 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 capped
|
||||
# exactly as a rising one is. A check written only on the rising case would be
|
||||
# green for a gate that had lost half of itself.
|
||||
_dn = compute(prev_w=2400.0, grid_w=-200.0, actual_w=2400.0, tuning=T,
|
||||
sat_count=0, i_w=100.0)
|
||||
print(f" descending: prev +2400 W, integrator +100 W -> command "
|
||||
f"{_dn.target_w:+.0f} W, integrator {_dn.i_w:+.0f} W, "
|
||||
f"reason {_dn.reason!r}")
|
||||
check("⭐ THE CAP BINDS ON A FALLING COMMAND TOO: descending from +2400 W the "
|
||||
f"integrator is pulled to exactly one slew step beyond the command it "
|
||||
f"emits ({_dn.i_w - _dn.target_w:+.0f} W), not left 2300 W below it",
|
||||
_dn.reason == "slew-limited"
|
||||
and round(abs(_dn.i_w - _dn.target_w), 6) == SLEW_LEAD_STEPS * _SLEW)
|
||||
|
||||
# ⚠️ THE CAP IS INSIDE THE SLEW BRANCH, AND THIS IS THE ONLY STIMULUS THAT CAN
|
||||
# TELL THAT FROM AN UNCONDITIONAL ONE. Applying it to every cycle - `i_w =
|
||||
# max(target - cap, min(target + cap, i_w))` after `target = slewed` - is a
|
||||
# -92 B edit that is BIT-IDENTICAL on the shipped configuration, and it was a
|
||||
# SURVIVOR of all 30 files until this check existed. It is measured: 0 of
|
||||
# 256 246 ticks differ over the 16-day recording, and 2050 of 257 400 swept
|
||||
# states do - every one of them at `integrator_max_w` 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 a
|
||||
# cap that fired on every cycle would drag it back to the command and make the
|
||||
# option inert.
|
||||
_HI = Tuning(gain=0.6, max_w=2000.0, slew_w=400.0, target_grid_w=-10.0,
|
||||
integrator_max_w=5000.0)
|
||||
_hi = compute(prev_w=-2400.0, grid_w=-8000.0, actual_w=-6000.0, tuning=_HI,
|
||||
sat_count=0, i_w=-2400.0)
|
||||
print(f" integrator_max_w 5000 above a max_w of 2000: command "
|
||||
f"{_hi.target_w:+.0f} W, integrator {_hi.i_w:+.0f} W, "
|
||||
f"reason {_hi.reason!r}")
|
||||
check("⭐ THE CAP DOES NOT FIRE WHERE THE SLEW DID NOT BIND: with "
|
||||
"`integrator_max_w` deliberately above `max_w` the integrator keeps its "
|
||||
"own bound (-5000 W) instead of being dragged to the command - an "
|
||||
"unconditional cap would put it at -2400 W and make the option inert",
|
||||
_hi.i_w == -5000.0 and _hi.reason == "clamped")
|
||||
|
||||
# ⚠️ THE TWO RECORDED DEADLOCKS, AS TEST CASES. control.py names both at
|
||||
# length; a gate written on the integrator is exactly the edit that brings them
|
||||
# back, so each is driven rather than argued.
|
||||
#
|
||||
# (a) THE `i_w == 0.0` CASE. `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. A gate that files zero under
|
||||
# one sign blocks the first push toward the other one forever.
|
||||
z = compute(prev_w=0.0, grid_w=-6000.0, actual_w=0.0, tuning=T, sat_count=0,
|
||||
i_w=0.0)
|
||||
check("at `i_w == 0.0` the first push toward charging is NOT blocked - the "
|
||||
f"command leaves zero ({z.target_w:+.0f} W) and the integrator moves "
|
||||
f"({z.i_w:+.0f} W)", z.target_w < 0.0 and z.i_w < 0.0)
|
||||
|
||||
# (b) 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. Driven
|
||||
# as the measured case: 0 W held into a 2 kW import.
|
||||
imp = run(2000.0, cycles=12)[2]
|
||||
print(f" 2 kW import from rest: setpoints "
|
||||
f"{[f['setpoint'] for f in imp[:6]]}")
|
||||
check("a 2 kW import from rest recovers rather than sitting at its last value "
|
||||
"- the shrink-only encoding held 0 W indefinitely here",
|
||||
imp[-1]["setpoint"] >= 1000.0)
|
||||
|
||||
# ⚠️ AND THE GATE MUST NOT BE A DELAY. A loop that is slew-limited for a long
|
||||
# ramp has to arrive, or the fix has bought export back with standing error.
|
||||
far = run(-6000.0, cycles=40)[2]
|
||||
print(f" a 6 kW export, 40 cycles: setpoint reaches "
|
||||
f"{far[-1]['setpoint']:.0f} W of the {-BOUND_W:.0f} W rail")
|
||||
check("⭐ THE COMMAND STILL ARRIVES: a railed export drives the command to the "
|
||||
"rail, one slew step a cycle, with the integrator bounded the whole way "
|
||||
"- the gate bounds the LEAD, it does not stop the loop",
|
||||
far[-1]["setpoint"] == -BOUND_W)
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -21,6 +21,7 @@ from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig
|
||||
from app.price import (
|
||||
PRICE_OPTION_DEFAULTS, SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||
from app.control import LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC
|
||||
from app import shadow
|
||||
from app.validate import (
|
||||
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused,
|
||||
actuator_writers, refuse, site_rail_w, validate_startup_config,
|
||||
@@ -1722,6 +1723,90 @@ check("and the 6% asymmetry is reported as an ORDERED PAIR: the transposed "
|
||||
"vat_pct 6" not in _T16_PAIR[0])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\nSAFETY-10 - shadow_mode is validated like every other option")
|
||||
# ⚠️ WHY THIS IS A REFUSAL AND NOT A COERCION. `shadow.mode()` resolves an
|
||||
# unrecognised value 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. A typo is not
|
||||
# a decision, and UX-02 made this validator the one rule surface both editors
|
||||
# read, so the rule has to be here rather than in a quiet fallback.
|
||||
_S10_BAD = ["true", "yes", "1", "shadow", "", "autoo", None, 1]
|
||||
for _v in _S10_BAD:
|
||||
_p = validate_startup_config(base(shadow_mode=_v), -5000, 5000)
|
||||
check(f"shadow_mode={_v!r} is REFUSED, and the refusal SPELLS the key so "
|
||||
"configui.problems_by_field() can file it under the right control",
|
||||
why(_p, "shadow_mode is") and why(_p, "'auto'")
|
||||
and why(_p, "'on'") and why(_p, "'off'"))
|
||||
for _v in ("auto", "on", "off", "AUTO", " on ", "Auto "):
|
||||
check(f"shadow_mode={_v!r} starts - the three values and the tolerant "
|
||||
"spellings of them are accepted, so the rule above refuses garbage "
|
||||
"and not case",
|
||||
validate_startup_config(base(shadow_mode=_v), -5000, 5000) == [])
|
||||
check("PRECONDITION: and the key being ABSENT starts too, which is what every "
|
||||
"install that upgrades into this version has. A rule that refused the "
|
||||
"absent key would refuse every existing install at once",
|
||||
validate_startup_config(base(), -5000, 5000) == []
|
||||
and "shadow_mode" not in base())
|
||||
# The two arrangements have to DISAGREE somewhere, or `why()` is grading a
|
||||
# sentence that is in the list for another reason entirely.
|
||||
check("...and a refused shadow_mode is the ONLY thing wrong with that config, "
|
||||
"so the refusal above is this rule's and not some other rule's",
|
||||
len(validate_startup_config(base(shadow_mode="maybe"), -5000, 5000)) == 1)
|
||||
|
||||
|
||||
# --- THE SIX STATES, AND THE ONE THAT PROTECTS THE EXISTING INSTALL ---------
|
||||
# ⚠️ GRADED HERE RATHER THAN IN test_clamp.py, WHERE IT STARTED, AND THE REASON
|
||||
# IS MEASURED. `shadow.engaged()` is a pure function of the option and the
|
||||
# start button; a mutation that defeats the DEFAULT stops test_clamp.py's
|
||||
# part 3 reaching the wire at all, which aborts that file with an IndexError
|
||||
# before part 8 runs - so the mutation's red count there is a lower bound and
|
||||
# the table itself is never graded (MUTATION.md, FCST-06's shape). Nothing in
|
||||
# this file drives a Controller, so nothing here can be taken down that way.
|
||||
# Six states. The one that matters most is the LAST row: an install that is
|
||||
# controlling today must not be changed by this option arriving with a default.
|
||||
_TABLE = [
|
||||
("off", True, False), ("off", False, False),
|
||||
("on", True, True), ("on", False, True),
|
||||
("auto", False, True), ("auto", True, False),
|
||||
]
|
||||
for _m, _started, _want in _TABLE:
|
||||
check(f"shadow_mode {_m!r} with control "
|
||||
f"{'started' if _started else 'stopped'} -> "
|
||||
f"{'SHADOW' if _want else 'LIVE'}",
|
||||
shadow.engaged({"shadow_mode": _m}, control_started=_started)
|
||||
is _want)
|
||||
check("THE EXISTING INSTALL: the SHIPPED DEFAULT, on an install that is "
|
||||
"controlling, is LIVE. This is the whole reason the option is not a "
|
||||
"boolean defaulting to true - the Supervisor merges a new default into "
|
||||
"every existing install, and a boolean would have stopped a battery that "
|
||||
"is regulating a real house today",
|
||||
shadow.engaged({}, control_started=True) is False)
|
||||
check("...and the SAME shipped default on a FRESH install - `auto_start` is "
|
||||
"false, so nothing has started - is SHADOW. That is what 'default-on' "
|
||||
"means here, delivered where it is safe",
|
||||
shadow.engaged({}, control_started=False) is True)
|
||||
# ⚠️ THE REASON, NOT THE VERDICT (MUTATION.md's masking rule). `on` and
|
||||
# `auto`-while-stopped are the same boolean and want DIFFERENT operator
|
||||
# actions: one is an option, the other is a button. A surface showing only the
|
||||
# verdict would be blind to the two rungs being swapped.
|
||||
check("the two ways of being in shadow mode are told apart in words, so a "
|
||||
"build that swapped the rungs could not render identically",
|
||||
shadow.why({"shadow_mode": "on"}, control_started=False)
|
||||
!= shadow.why({"shadow_mode": "auto"}, control_started=False))
|
||||
|
||||
# ⚠️ AND THE FAIL-SAFE DIRECTION OF AN UNREADABLE VALUE IS GRADED, THOUGH THE
|
||||
# rule above means a running install can never hold one. It survived the first
|
||||
# mutation battery as M9 for exactly that reason: everything else refused the
|
||||
# value before `mode()` ever saw it, so the direction it fails in was ungraded
|
||||
# and could have been flipped to "live" with the whole suite green.
|
||||
check("an UNREADABLE shadow_mode resolves to SHADOW, not to live - the "
|
||||
"direction that does not move energy. It is reachable only if this "
|
||||
"file's rule is ever weakened, which is the case it exists for",
|
||||
shadow.engaged({"shadow_mode": "garbage"}, control_started=True) is True
|
||||
and shadow.engaged({"shadow_mode": ""}, control_started=True) is True
|
||||
and shadow.mode({"shadow_mode": "garbage"}) == "on")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -272,6 +272,136 @@ check("REFUSED is exactly 1, not merely non-zero",
|
||||
print("\nthe shipped tree's own version is readable")
|
||||
check("shipped_version() finds a version", bool(shipped_version(HERE)))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PKG-14 - THE OTHER HALF OF THE NO-OP, AND NOTHING GRADED IT.
|
||||
#
|
||||
# PKG-09 refuses a deploy whose `version:` did not move. It cannot see the
|
||||
# mirror failure, which this repository has now produced twice: the version
|
||||
# DOES move and the changelog's `## Unreleased` block is never closed, so the
|
||||
# operator who opens the file to find out what they are about to install reads
|
||||
# "THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE" about a
|
||||
# tree that is being installed. `## 0.8.0` records one such block that
|
||||
# accumulated across three releases; `## 0.9.0` was cut with a second one left
|
||||
# standing underneath it.
|
||||
#
|
||||
# ⚠️ THE TWO SIDES COME FROM DIFFERENT FILES ON PURPOSE (MUTATION.md habit 4).
|
||||
# config.yaml's `version:` and CHANGELOG.md's newest heading are written by
|
||||
# different hands at different times, and the whole defect is that they drift.
|
||||
# A check that read the number once and compared it with itself would be the
|
||||
# DEV-02 fixture again: a number against itself.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT A SUBSTRING SCAN OVER THE WHOLE FILE. Every id this section
|
||||
# looks for occurs dozens of times lower down, so a scan of the file would pass
|
||||
# on any tree whatever the newest section said - CFG-04's defect, which asserted
|
||||
# against the TEXT of the thing under test. The section is SLICED first, and
|
||||
# part 11a grades the slicer against a sentinel that exists only below it.
|
||||
print("\n11. PKG-14 - the bump and the closed section are one act")
|
||||
|
||||
_CHANGELOG = os.path.join(HERE, "CHANGELOG.md")
|
||||
with open(_CHANGELOG, encoding="utf-8") as fh:
|
||||
_cl_lines = fh.read().splitlines()
|
||||
|
||||
_heads = [(i, l[3:].strip()) for i, l in enumerate(_cl_lines)
|
||||
if l.startswith("## ")]
|
||||
check(f"precondition: the file has {len(_heads)} `## ` headings, so the "
|
||||
f"quantifiers below are not over an empty set", len(_heads) >= 8)
|
||||
|
||||
# 11a. THE SLICE, AND TWO CONTROLS ON THE SLICER.
|
||||
# ARCH-02's fifteenth instance: a splitter that never closes attributes
|
||||
# everything to the last thing it opened, and every per-section check
|
||||
# downstream silently becomes a total over the whole file. Both controls below
|
||||
# exist because 11b/11c would go on passing for a tree in which nothing they
|
||||
# name is true.
|
||||
|
||||
|
||||
def slice_newest(lines):
|
||||
"""(heading text, the lines of the FIRST `## ` section) - closing at the
|
||||
next `## `, which is the half that has to be graded rather than assumed."""
|
||||
heads = [i for i, l in enumerate(lines) if l.startswith("## ")]
|
||||
if not heads:
|
||||
return None, []
|
||||
start = heads[0]
|
||||
end = heads[1] if len(heads) > 1 else len(lines)
|
||||
return lines[start][3:].strip(), lines[start:end]
|
||||
|
||||
|
||||
# CONTROL ONE, run on every invocation rather than attested in a commit
|
||||
# message: a synthetic document whose token appears ONLY below the first
|
||||
# section. A slicer that runs to the end of the file finds it; this one must
|
||||
# not. test_defaults_pin.py part 5 plants a literal for the same reason.
|
||||
_SYNTH = ["# Changelog", "", "## 9.9.9", "", "the newest section.", "",
|
||||
"## 9.9.8", "", "PLANTED-ONLY-BELOW", ""]
|
||||
_syn_name, _syn_body = slice_newest(_SYNTH)
|
||||
check("11a. the slicer CLOSES - on a synthetic document it excludes a token "
|
||||
"that exists only in the section BELOW the newest one",
|
||||
_syn_name == "9.9.9" and "PLANTED-ONLY-BELOW" in "\n".join(_SYNTH)
|
||||
and "PLANTED-ONLY-BELOW" not in "\n".join(_syn_body))
|
||||
|
||||
_top_name, _top_body = slice_newest(_cl_lines)
|
||||
_newest = "\n".join(_top_body)
|
||||
_SENTINEL = "## 0.8.0"
|
||||
check(f"...and on the shipped file too: it does not contain {_SENTINEL!r}, "
|
||||
f"which the file does", _SENTINEL in "\n".join(_cl_lines)
|
||||
and _SENTINEL not in _newest)
|
||||
check(f"...and it is not empty: {len(_top_body)} lines under {_top_name!r}",
|
||||
len(_top_body) > 20)
|
||||
|
||||
# CONTROL TWO - and this is the one that catches a LOST HEADING rather than a
|
||||
# loose slicer. Every version section in this file carries exactly one
|
||||
# "in merge order since" contents table. Delete a released section's `## `
|
||||
# heading and its table is swallowed by the section above, which is a released
|
||||
# record disappearing into an unreleased one - the shape `## 0.7.0` already
|
||||
# records for 0.6.0, and the one PKG-08 is left holding.
|
||||
_tables = [l for l in _top_body if "in merge order since" in l]
|
||||
check(f"...and it carries EXACTLY ONE contents table, not {len(_tables)} - a "
|
||||
f"second one means a released section below lost its `## ` heading and "
|
||||
f"was absorbed into this one", len(_tables) == 1)
|
||||
|
||||
# 11b. THE BLOCK IS CLOSED, AND IT CARRIES THE SHIPPED NUMBER.
|
||||
check("11b. the newest `## ` heading is a VERSION and not `Unreleased` - the "
|
||||
"block was closed by whoever bumped the version",
|
||||
_top_name.lower() != "unreleased")
|
||||
_shipped = shipped_version(HERE)
|
||||
check(f"...and it is exactly config.yaml's `version:` ({_shipped!r} in "
|
||||
f"config.yaml, {_top_name!r} in CHANGELOG.md) - two files, so a bump "
|
||||
f"without a section, or a section without a bump, is red",
|
||||
_top_name == _shipped)
|
||||
|
||||
# 11c. A WITHDRAWN price_provider IS NAMED IN THE SECTION THAT WITHDRAWS IT.
|
||||
# ⚠️ READ OFF app/price.py BY AST, NOT TYPED HERE. A list transcribed into this
|
||||
# file would go on passing after a fourth id was retired and documented
|
||||
# nowhere, which is the exact failure TARIFF-07 exists to prevent: an id the
|
||||
# add-on refuses at startup and no release note mentions.
|
||||
import ast # noqa: E402
|
||||
_price_src = open(os.path.join(HERE, "app", "price.py"),
|
||||
encoding="utf-8").read()
|
||||
_retired = []
|
||||
for _node in ast.walk(ast.parse(_price_src)):
|
||||
if isinstance(_node, ast.AnnAssign) and getattr(
|
||||
_node.target, "id", None) == "RETIRED_PROVIDERS":
|
||||
_retired = [k.value for k in _node.value.keys]
|
||||
elif isinstance(_node, ast.Assign) and any(
|
||||
getattr(t, "id", None) == "RETIRED_PROVIDERS" for t in _node.targets):
|
||||
_retired = [k.value for k in _node.value.keys]
|
||||
check(f"11c. precondition: RETIRED_PROVIDERS was read off app/price.py and is "
|
||||
f"not empty - {sorted(_retired)}", len(_retired) > 0)
|
||||
_unnamed = sorted(p for p in _retired if p not in _newest)
|
||||
check(f"...and every one of the {len(_retired)} withdrawn id(s) is named in "
|
||||
f"`## {_top_name}` - an id the validator refuses at startup that no "
|
||||
f"release note mentions is the TARIFF-07 defect with the sign flipped",
|
||||
_unnamed == [])
|
||||
|
||||
# 11d. THE LEDGER OF BLOCKS STILL MISFILED, FROZEN RATHER THAN FIXED.
|
||||
# PKG-08 owns splitting them; this file owns the promise that the number can
|
||||
# only go DOWN. A release that cuts its section and leaves a second
|
||||
# `## Unreleased` standing is red here on the commit that does it.
|
||||
_stale = [n for _, n in _heads[1:] if n.lower() == "unreleased"]
|
||||
check(f"11d. exactly {len(_stale)} `## Unreleased` block(s) remain BELOW the "
|
||||
f"newest section, and the count is pinned EXACTLY so the ledger can "
|
||||
f"only shrink - splitting them is PKG-08, not this file",
|
||||
len(_stale) == 1)
|
||||
|
||||
for d in tmpdirs:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user