Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6acd9fa2 | ||
|
|
239e120bf6 | ||
|
|
9d593429d3 | ||
|
|
b0b24e5d41 | ||
|
|
eb88646899 | ||
|
|
b9f7bd0506 | ||
|
|
43c144b8d9 | ||
|
|
6f5e8e378a | ||
|
|
8a5736136a | ||
|
|
5aba220046 | ||
|
|
16b1d6c422 | ||
|
|
ca8fd01819 | ||
|
|
d2ad080e45 | ||
|
|
074157c4c2 | ||
|
|
186a044a3a | ||
|
|
660d079bf3 | ||
|
|
a6af332048 | ||
|
|
03ef61fa3b |
+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`.
|
||||
@@ -1,5 +1,173 @@
|
||||
# Changelog
|
||||
|
||||
## 0.10.1
|
||||
|
||||
**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`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `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
|
||||
@@ -87,8 +255,20 @@ check.
|
||||
**and** is refused by name (rule 11), so the reinterpretation is never
|
||||
silent; no blob written before 0.10.0 can carry one.
|
||||
|
||||
⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL IS -
|
||||
THE REFUSAL THAT CAN STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE.** `ha
|
||||
⚠️ **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
|
||||
@@ -109,7 +289,10 @@ fire only on a roster entry that USES `capacity_kwh`, a key that did not exist
|
||||
`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.**
|
||||
✅ **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 \
|
||||
@@ -120,6 +303,21 @@ BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both.
|
||||
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?` -
|
||||
@@ -158,6 +356,41 @@ 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`):
|
||||
|
||||
| | |
|
||||
@@ -180,6 +413,176 @@ Contents of this release, in merge order since 0.9.0 (`86bdf49`):
|
||||
| `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
|
||||
|
||||
|
||||
@@ -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,551 @@
|
||||
# SAFETY-48 — the slew step ceiling reaches the integrator, and what it bounds is the LEAD
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-48`, on top of `TEL-10`.
|
||||
**Card:** SAFETY-48 — *"the integrator goes on winding while the command is
|
||||
slew-limited"*.
|
||||
**Nothing here reached the inverter.** The live install still runs `slew_w: 400`
|
||||
applied by hand on 2026-09-05 12:00 and `release/1.0`'s control law. This branch
|
||||
does not deploy and does not bump the version.
|
||||
|
||||
---
|
||||
|
||||
## 0. The four things a reader should take away
|
||||
|
||||
1. **It is a missing case, not a wrong constant, and the arithmetic says so.**
|
||||
The integrator moves `gain * error` a cycle and the command moves at most
|
||||
`slew_w`, so the integrator outruns the command whenever
|
||||
`|error| > slew_w / gain` — 667 W at the live shape. SAFETY-39's retune made
|
||||
`slew_w` **smaller**, which makes that threshold **lower**. §1.
|
||||
2. ⚠️ **THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, AND THAT IS WHY
|
||||
IT IS NOT WHAT SHIPPED.** Reusing the asymmetric freeze on a carried slew
|
||||
flag cuts export 13 % and moves the worst quarter-hour average **offtake**
|
||||
from 3739 W to **3935 W**, with the mean quarter +2.7 W — and on the billed
|
||||
quarter it is worse at **every plant lag where release/1.0 produces a peak
|
||||
at all** (a tie at lag 0, worse at 2.5 / 5.0 / 7.5 / 10.0 s) and at **every
|
||||
top-N cut of the quarters**, which makes this the least lag-sensitive result
|
||||
in the card. The harm is a tail redistribution inside quarters that already
|
||||
net to offtake, not a weaker answer to import in aggregate. §3, §3a.
|
||||
3. **What ships bounds the LEAD at one slew step, only where the slew is what
|
||||
stopped the command, and only in the leading direction.** The robust result
|
||||
is the **export reduction — 39.6 → 34.1 kWh, and it falls at every plant and
|
||||
tuning tried** — with wasted throughput 4.67 → 3.19 kWh, sign reversals
|
||||
2992 → 2770 and median recovery 232 → 199 s. ⚠️ **The billed quarter is not
|
||||
something this card can claim.** It reads 3739 → **3434 W** at the fitted
|
||||
plant lag of 7.5 s; at every lag ≤ 5.0 s — including the one that best
|
||||
reproduces the recorded export — release/1.0 is *already* at 3434 W and this
|
||||
card moves it by exactly zero. The lag is not identified by the data. §4,
|
||||
§3a.
|
||||
4. **Two committed numbers moved and neither is a silent edit.** SAFETY-13's
|
||||
released command settles lower, because there is less wound integrator to
|
||||
settle onto; and SAFETY-39's *"the slew does not own the over-absorption"* is
|
||||
now **false — inverted, on its own independent plant model.** §5, §6.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect, in the terms the code uses
|
||||
|
||||
`compute()` freezes the integrator with an asymmetric rule, and the comment
|
||||
beside it states the requirement outright:
|
||||
|
||||
> the requirement is that the INTEGRATOR stop accumulating, not only the command
|
||||
|
||||
But `frozen` is computed from **one** signal:
|
||||
|
||||
```python
|
||||
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w # not tracking
|
||||
```
|
||||
|
||||
Slew-limiting never enters it. When the command hits the step ceiling the
|
||||
integrator keeps winding at `gain * error` until it reaches `integrator_max_w`,
|
||||
and the unwind afterwards is `integrator_max_w / slew_w` cycles — 12.5 at the
|
||||
live shape, about a minute at the meter's measured tick.
|
||||
|
||||
⚠️ **AND THE RETUNE MADE IT MORE FREQUENT, NOT LESS.** `|error| > slew_w / gain`
|
||||
is 1667 W at `slew_w: 1000` and **667 W** at `slew_w: 400`. The measured median
|
||||
grid error at the onset of a recorded episode is 1114 W — above the second
|
||||
threshold and below the first. This is why SAFETY-39 did not fix it, and the
|
||||
mechanism is the same one §6 shows inverting.
|
||||
|
||||
## 2. How it was measured, and what the measurement cannot say
|
||||
|
||||
`tools/replay_recorded_loop.py`, over **16.00 days / 256 252 meter instants** of
|
||||
the live install's own recorder, exported read-only.
|
||||
|
||||
**Real:** the disturbance (`D = grid + batt` at every recorded meter instant —
|
||||
the house with this loop's own battery taken back out, which is exogenous and
|
||||
therefore valid to drive a counterfactual with); the tick (the meter's own
|
||||
recorded instants, median **5.01 s**); and the control law, imported out of the
|
||||
checkout. The "before" law is the **same file** with the SAFETY-48 block removed
|
||||
by a named substitution and exec'd into a fresh namespace — never a
|
||||
transcription — and a missing or duplicated anchor is a **refusal** with
|
||||
`NOTHING HAS BEEN MEASURED` on it, because an instrument that quietly compared
|
||||
the shipped law with itself would report the card as changing nothing.
|
||||
|
||||
**Modelled:** the inverter (pure delay 7.5 s + first-order lag 2.0 s +
|
||||
`batt = 0.973 × cmd − 22.4 W`, fitted on this same recording above the SoC
|
||||
floor, residual **375 W RMS**); the pack (SoC integrated, discharge refused at
|
||||
or below the floor); and **everything else the arbiter does** — the recorded
|
||||
setpoint is the arbiter's output after the peak guard, the SoC clamps,
|
||||
maintenance and arbitrage, while the replay runs `compute()` alone.
|
||||
|
||||
⚠️ **THE ABSOLUTE FIGURES ARE PESSIMISTIC BY ABOUT A THIRD AND THE TOOL PRINTS
|
||||
IT ON EVERY RUN.** Replaying the before-law reproduces 39.6 kWh of export where
|
||||
the install actually exported **29.4 kWh** over the same window. The delta is
|
||||
what this instrument is for: both laws run on the same ticks, the same
|
||||
disturbance, the same plant and the same seed.
|
||||
|
||||
⚠️ **THE SoC FILTER ON THE PLANT FIT IS THE WHOLE FIT, AND IT IS A FINDING IN
|
||||
ITS OWN RIGHT.** This install spends **36 % of its ticks at or below a 10 % SoC
|
||||
with the command standing near 2 kW and the battery delivering nothing.** Fitted
|
||||
over all ticks the recording says `batt = 0.50 × cmd − 396 W, r = 0.70` — a
|
||||
plant that follows half the command, which no inverter does. Above the floor the
|
||||
same data says `batt = 1.00 × cmd − 28 W, r = 0.977`. The floor is the PACK and
|
||||
is modelled separately.
|
||||
|
||||
## 3. The form the card asked for, and the number that refused it
|
||||
|
||||
The card asked for the asymmetric rule already in service for saturation, on a
|
||||
flag carried across cycles the way `sat_count` is. It was built — from
|
||||
release/1.0's own source, one substitution per change — and measured on the same
|
||||
16 days through the same pipeline:
|
||||
|
||||
| | release/1.0 | **form A** — carried slew flag + the asymmetric freeze | **SAFETY-48** — the lead cap |
|
||||
|---|---:|---:|---:|
|
||||
| export kWh | 39.61 | 34.43 | **34.07** |
|
||||
| import kWh | 116.07 | 110.62 | 111.20 |
|
||||
| battery sign reversals | 2992 | **2656** | 2770 |
|
||||
| wasted throughput kWh | 4.67 | **2.99** | 3.19 |
|
||||
| **worst 15-min offtake W** | 3739 | ⚠️ **3935** | **3434** |
|
||||
| mean quarter-hour offtake delta | — | ⚠️ **+2.73 W** | **−2.39 W** |
|
||||
| worst quarter-hour delta | — | +992 W | +532 W |
|
||||
| recovery median s | 232 | **184** | 199 |
|
||||
| recovery p99 s | 849 | **781** | 850 |
|
||||
| recovery max s | 901 | 890 | 898 |
|
||||
| unrecovered (> 900 s) | 53 | **26** | 31 |
|
||||
| \|i_w − prev_w\| p99 W | 3361 | 1534 | **800** |
|
||||
| \|i_w − prev_w\| max W | 9200 | 6912 | **800** |
|
||||
|
||||
**Form A wins on four rows and loses on the one that is billed.** The capacity
|
||||
tariff (*capaciteitstarief*) bills the month's worst quarter-hour average
|
||||
offtake, and form A raises it by **196 W** — and raises the MEAN quarter by
|
||||
2.7 W, so this is systematic and not one unlucky quarter. It does so in **five
|
||||
of the six** plant and tuning variants in `--sensitivity`; the sixth is a tie.
|
||||
|
||||
The per-cycle mechanism is that a frozen integrator stops at
|
||||
`prev + gain·error(first cycle)`, which is `(1 − gain)` short of what the site
|
||||
needs — 40 % short at `gain: 0.6`.
|
||||
|
||||
⚠️ **AND THE SENTENCE THAT USED TO FOLLOW THAT ONE WAS TOO BIG FOR THE DATA.**
|
||||
It read *"for an IMPORT it is offtake, so a large import is answered too weakly
|
||||
for as long as the ramp lasts"*. The aggregate does not show a weaker answer to
|
||||
import: form A cuts total import by **5.45 kWh** and export by **5.18 kWh**
|
||||
against release/1.0, and paired tick by tick it delivers **8.56 kWh LESS**
|
||||
offtake energy on the ticks release/1.0 spent importing (lower on 38 229 such
|
||||
ticks, higher on 19 669). What form A moves is the **TAIL, within quarters that
|
||||
already net to offtake** — the worst quarter goes 3739 → 3935 W, the top five
|
||||
3260 → 3300 W, the top fifty 1880.0 → 1882.7 W. The capacity tariff bills the
|
||||
worst quarter and nothing else, so a redistribution that leaves the mean alone
|
||||
and lifts the maximum is exactly the trade that is refused here. The conclusion
|
||||
is unchanged; the causal sentence is now the one the data supports.
|
||||
|
||||
⚠️ **THE CARD SAID "do not invent a second rule", AND THE MEASUREMENT SAYS TO.**
|
||||
That instruction is recorded here rather than quietly dropped. What ships is a
|
||||
different rule; the paragraph above is why.
|
||||
|
||||
## 3a. ⚠️ The plant lag is NOT identified by this recording, and the billed quarter is the claim that depends on it
|
||||
|
||||
`fit_plant()` picks `lag 7.5 s` because it minimises **battery-power RMS**. That
|
||||
objective barely distinguishes the candidates — 375.3 W at 7.5 s against 376.4 W
|
||||
at 2.5 s, **0.3 %** — while the tool's *own* printed fidelity measure, replayed
|
||||
export against the **29.43 kWh this install actually exported**, prefers 2.5 s by
|
||||
the whole of the 35 % gap the header warns about. Two measures on the same data
|
||||
disagree, and nothing here breaks the tie.
|
||||
|
||||
So the sweep has to be read as a sensitivity, not as a confirmation:
|
||||
|
||||
| plant lag | fit RMS W | replayed export kWh (release/1.0) | **worst 15-min offtake W:** release/1.0 | form A | SAFETY-48 | mean quarter ΔW |
|
||||
|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 0.0 | 392.3 | 23.5 | **3434** | 3434 | **3434** | +0.53 |
|
||||
| 2.5 | **376.4** | **28.9** ← nearest 29.43 | **3434** | ⚠️ 3732 | **3434** | −0.59 |
|
||||
| 5.0 | 378.0 | 32.1 | **3434** | ⚠️ 3711 | **3434** | −1.02 |
|
||||
| 7.5 | **375.3** ← chosen | 39.6 | 3739 | ⚠️ 3935 | **3434** | −2.39 |
|
||||
| 10.0 | 384.2 | 75.4 | 3954 | ⚠️ 4326 | **3434** | −4.65 |
|
||||
|
||||
(The RMS column is `fit_plant()`'s own inner sweep pinned to one lag — best `tau`
|
||||
and a re-fitted gain/offset at that lag. The export and quarter columns follow
|
||||
`--sensitivity`'s convention instead: only the lag is substituted into the
|
||||
global fit, everything else held. That is why the lag 5.0 and 10.0 export
|
||||
figures are the ones already printed in §7b.)
|
||||
|
||||
⚠️ **AT EVERY LAG ≤ 5.0 s THE IMPROVEMENT IS NOT AN IMPROVEMENT — IT IS AN
|
||||
ABSENCE.** release/1.0's worst quarter is *already* 3434 W there and SAFETY-48
|
||||
changes it by **exactly zero**. §7b's paragraph used to read the `3434 → 3434`
|
||||
rows as "the direction does not move"; that is the wrong reading. The direction
|
||||
has nothing to move: at those plants release/1.0 never produces a quarter worse
|
||||
than 3434 W in the first place, and the 3739 → 3434 W headline exists only at
|
||||
the single longest lag `fit_plant()` happens to select. **The peak claim is a
|
||||
property of the plant model, not of the card.** What the card can claim on the
|
||||
billed quantity is that it does not make it worse, at any lag.
|
||||
|
||||
⚠️ **AND THE ONE RESULT HERE THAT IS NOT LAG-SENSITIVE IS THE FORM-A
|
||||
REJECTION.** Form A's worst quarter is the worst of the three at **every lag
|
||||
where release/1.0 produces a peak at all** — 3732, 3711, 3935, 4326 against
|
||||
release/1.0's 3434, 3434, 3739, 3954 at lags 2.5 / 5.0 / 7.5 / 10.0, and a tie
|
||||
at lag 0.0 where both sit at 3434. It is worse at every top-N cut of the
|
||||
quarters at the fitted plant (top-1 3934.6 against 3738.7, top-5 3299.7 against
|
||||
3260.5, top-10 2890.6 against 2871.0, top-50 1882.7 against 1880.0, top-100
|
||||
1481.7 against 1479.0), and it raises the mean quarter. §3 understated this: the rejection does not rest on one unlucky
|
||||
quarter at one plant, which is exactly the objection a lag-sensitive result
|
||||
invites.
|
||||
|
||||
⚠️ **WHAT WOULD SETTLE IT IS NOT IN THIS RECORDING.** Both objectives above are
|
||||
computed from the same four recorded entities, and they disagree; nothing else
|
||||
in the capture breaks the tie, and this card did not go looking for a third
|
||||
objective after seeing which lag each of the first two preferred. A driven step
|
||||
response — the command timestamped where it is written rather than where Home
|
||||
Assistant recorded it — is what identifies a lag. That is a rig measurement and
|
||||
it is not this card. Until it exists, the export reduction is what this card
|
||||
delivers and the peak figure is a plant-model artefact.
|
||||
|
||||
## 4. What ships
|
||||
|
||||
```python
|
||||
slew = slew_limit_w(tuning)
|
||||
slewed = max(prev_w - slew, min(prev_w + slew, target))
|
||||
if slewed != target:
|
||||
reason = "slew-limited"
|
||||
_lead_cap = SLEW_LEAD_STEPS * slew
|
||||
if target > slewed:
|
||||
i_w = min(i_w, slewed + _lead_cap)
|
||||
elif target < slewed:
|
||||
i_w = max(i_w, slewed - _lead_cap)
|
||||
target = slewed
|
||||
```
|
||||
|
||||
Four properties, and each of them is a decision:
|
||||
|
||||
* **It is inside the branch**, and ⚠️ **that placement was a SURVIVOR of all
|
||||
thirty files until a check was written for it.** Applying it to every cycle
|
||||
instead — `i_w = max(target - cap, min(target + cap, i_w))` after
|
||||
`target = slewed`, −92 B — is **bit-identical on the shipped configuration**:
|
||||
0 of 256 246 ticks differ over the 16-day recording. The two separate **only**
|
||||
where `integrator_max_w` is set ABOVE `max_w` — the configuration `control.py`
|
||||
says is the only way its `clamped` reason is reachable. There the integrator
|
||||
is *deliberately* allowed above the rail, and an unconditional cap would drag
|
||||
it back to the command every cycle and make the option inert.
|
||||
`test_telemetry.py` part 4 drives that configuration, and the unconditional
|
||||
form is red on it.
|
||||
|
||||
⚠️ **THE COUNT OF SEPARABLE STATES IS DECORATION AND THREE SWEEPS DISAGREE
|
||||
ABOUT IT.** This card's grid gave **2050 of 257 400**; an independent review
|
||||
measured **1220 of 246 064**; a third, smaller grid (four tunings × 7 `prev_w`
|
||||
× 9 `grid_w` × 5 `actual_w` × 8 `i_w` including `None` × 4 `sat_count`) gives
|
||||
**776 of 40 320** — and that third one is spelled out here precisely because
|
||||
the first two are not, which is the whole reason they cannot be reconciled.
|
||||
No count here should be read as exact without the grid beside it. All three
|
||||
agree on the load-bearing part, which is **where**: every separable state sits
|
||||
in the `integrator_max_w > max_w` tuning and nowhere else.
|
||||
|
||||
⚠️ **AND AN EARLIER DRAFT OF THIS BULLET GAVE THE WRONG REASON AND THE WRONG
|
||||
NUMBER.** It said the two forms differ on 118 ticks because an unconditional
|
||||
cap would pull the integrator toward a command the SATURATION freeze is
|
||||
pinning. That 118 was measured against a *different* pair — a band applied
|
||||
after quantisation, at the very end of `compute()` — and the saturation story
|
||||
does not apply to the placement actually being compared, which sits before
|
||||
the freeze. The figures above are the ones that were measured on the pair the
|
||||
sentence names.
|
||||
|
||||
⚠️ **AND THE SATURATION STORY IS NOT MERELY INAPPLICABLE — IT IS BACKWARDS.
|
||||
THE NARROW CAP THAT SHIPPED *DOES* MOVE A SATURATION-FROZEN INTEGRATOR.** It
|
||||
sits before the freeze block and is not gated on `frozen`. Measured on the
|
||||
same 16 days at the live shape: of **79 160** frozen ticks the cap changed
|
||||
`i_w` on **1095**, and on **679** of those it moved it further out on the side
|
||||
it was already on — putting back magnitude the freeze had just let fall, which
|
||||
is the one direction the freeze's rule ("may fall, never rise") exists to
|
||||
forbid. Worst case **2424 W**, at
|
||||
`prev_w −3400 W, grid +4697 W, actual −4144 W, sat_count 2 → 3`: the freeze
|
||||
leaves the integrator at **−176 W**, the slew binds, and the cap drags it to
|
||||
**−2600 W**. It never overrides a *same-cycle* refusal — the freeze clipped
|
||||
`moved` on 75 512 ticks and the slew bound on none of them — but the claim
|
||||
that this placement leaves the frozen path alone is false, and §5 is where
|
||||
that shows up in a committed number.
|
||||
* **It is written one-sided, and that is a CLARITY choice, not a correctness
|
||||
one.** ⚠️ **An earlier draft of this file and of the comment in `control.py`
|
||||
said the two-sided band `max(slewed - cap, min(slewed + cap, i_w))` was a bug
|
||||
that would drag a TRAILING integrator up toward the command. That was
|
||||
invented, and the sweep said so: the two spellings agree on 205 920 of
|
||||
205 920 cases** across four tunings, every sign of `prev_w`, `grid_w`,
|
||||
`actual_w` and `i_w`, and the `i_w=None` path. `target` is monotone in `i_w`,
|
||||
so when the slew binds `i_w` lies on the same side of `slewed` as `target`
|
||||
does and the other clamp cannot reach. The one-sided form stays because it
|
||||
NAMES the side the lead is on; the equivalence is recorded so the next reader
|
||||
finds a measurement instead of an argument.
|
||||
* **It moves `i_w` and not `target`.** This cycle's command is already decided;
|
||||
what is bounded is what gets CARRIED. That is also why the exhaustive
|
||||
`i_w=None` equivalence sweep in `test_control.py` is untouched — it compares
|
||||
`(target_w, sat_count, frozen, reason)`, none of which this moves.
|
||||
* **The cap is in STEPS, not watts**, for SAFETY-39's reason: a watt figure is a
|
||||
constant that outlives the `slew_w` it was derived against. `SLEW_LEAD_STEPS`
|
||||
moves with `slew_limit_w()` and therefore with the rail.
|
||||
|
||||
⚠️ **ONE STEP, AND NOT ZERO.** `i_w = slewed` is full back-calculation, and
|
||||
`control.py`'s own docstring says what that costs: passing `i_w=None` *"reduces
|
||||
this function to the exact velocity form it replaced"*. A zero cap makes `i_w`
|
||||
identically the command on every cycle, which is that collapse written from the
|
||||
other end. Measured, it is **better** — export 32.02 kWh against 34.07, mean
|
||||
quarter −3.69 W against −2.39 — and it is still refused, because it throws away
|
||||
the reason the accumulator was split out at all. That 2 kWh is the price of
|
||||
keeping the bounded form, and it is stated rather than hidden.
|
||||
|
||||
⚠️ **TWO LEADS, AND CONFUSING THEM IS HOW A CHECK GETS WRITTEN WRONG.** The cap
|
||||
bounds `i_w − target`, the command this cycle EMITS, at one step — which is what
|
||||
makes the unwind one cycle. `Decision.i_w − prev_w`, which is what TEL-10's
|
||||
`loop_lead` publishes, is measured against the command the PREVIOUS cycle left
|
||||
standing, and the emitted command is itself one step further on: the published
|
||||
figure is bounded by **two** steps. Both bounds are asserted, each against its
|
||||
own number.
|
||||
|
||||
### The two recorded deadlocks, driven rather than argued
|
||||
|
||||
`control.py` names two encodings that produce a permanently stuck loop, and a
|
||||
gate written on the integrator is exactly the edit that brings them back.
|
||||
Neither is reachable here and both are test cases in `test_telemetry.py` part 4:
|
||||
|
||||
* **the shrink-only encoding** — *"only corrections that shrink |i_w|"* is
|
||||
unsatisfiable for both signs whenever the correction is larger than twice the
|
||||
integrator, and the loop then sits at its last value forever (measured before
|
||||
the fix: 0 W held indefinitely into a 2 kW import). SAFETY-48 is not a shrink
|
||||
rule; it caps the lead beyond one slew step and only while the slew binds.
|
||||
* **`i_w == 0.0`** — `main.py` resets the integrator to exactly 0.0 on every
|
||||
stop and every reseed, and *"may not wind further in the direction it is
|
||||
already pushing"* has no referent there. SAFETY-48 does not touch that gate;
|
||||
the first push from zero is driven and asserted to move.
|
||||
|
||||
And a third, specific to this card: **the cap cannot latch.** The flag is not
|
||||
carried, and after the cap `|i_w − slewed| = cap`, so the next cycle's command
|
||||
covers it in one step and the slew stops binding — after which the integrator
|
||||
accumulates freely again. The driven check asserts the command still walks all
|
||||
the way to the rail on a railed export.
|
||||
|
||||
## 5. What this card COSTS, stated rather than smoothed
|
||||
|
||||
**SAFETY-13's release buys back less.** That card's own scenario — a second
|
||||
writer holding the pack at +700 W, the command pinned, then released — settles
|
||||
at **−800 W** where release/1.0 settled at −1190 W against an integrator of
|
||||
−1194 W; and its sibling run settles at **−1200 W** where release/1.0 settled at
|
||||
−2390 W. The property AC 4 asserts is unchanged: the released command stops at
|
||||
the frozen integrator and not at the rail.
|
||||
|
||||
⚠️ **AND THE TWO MOVED FOR TWO DIFFERENT REASONS, WHICH THE ONE-LINE EXPLANATION
|
||||
"how far that integrator had been allowed to wind" COVERS ONLY HALF OF.** Gating
|
||||
the cap on `not frozen` — one line, `if frozen: _lead_cap = float("inf")`, so
|
||||
the cap becomes a no-op while frozen — puts **part 5 back at release/1.0's
|
||||
−1190 W exactly**. That run moved because the cap **fires on frozen cycles**
|
||||
(§4). **Part 4(b) does not come back**: it is −1200 W gated and −1200 W shipped,
|
||||
against release/1.0's −2390 W, because *that* one moved for the winding reason.
|
||||
Both literals in `test_control.py` were re-baselined **with the reason beside
|
||||
them**, and the second is now asserted against `i_w` rather than against a
|
||||
number.
|
||||
|
||||
**The alternative that avoids it was measured and rejected.** Gating the cap on
|
||||
`not frozen` as above gives export **34.71 kWh** (against 34.07), worst quarter
|
||||
**3434 W** (the same as shipped), wasted **3.49 kWh** (against 3.19) — and a
|
||||
worst observed lead of **4961 W**, i.e. no bound at all on the quantity this
|
||||
card exists to bound. A bound with an exception large enough to drive a truck
|
||||
through is not a bound. ⚠️ These four figures replace an earlier set (34.9,
|
||||
3461 W, 3.5 kWh, 4966 W) that does not reproduce on this tree with the gate
|
||||
spelled as above; the gate spelling is written out here so the next reader can
|
||||
re-run it rather than re-derive it.
|
||||
|
||||
**One quarter-hour in 1534 is 532 W worse.** The billed figure is a MAXIMUM, and
|
||||
at the fitted plant lag the maximum improves (3739 → 3434 W); no quarter under
|
||||
SAFETY-48 reaches release/1.0's own worst. The mean moves the right way. The
|
||||
tail is reported because a maximum over 1534 quarters is decided by one of them
|
||||
— and §3a is the reason that improvement is not claimed as a benefit of the
|
||||
card: at every shorter plant lag release/1.0's own maximum is already 3434 W.
|
||||
|
||||
## 6. The independent confirmation, which arrived as a failing check
|
||||
|
||||
`test_control.py`'s SAFETY-39 section drives a family of 51 synthetic export
|
||||
steps through a **first-order plant this file writes itself** — no relation to
|
||||
the replay, no recorded data in it at all. It carried this assertion:
|
||||
|
||||
> ⚠️ THE SLEW DOES NOT OWN THE OVER-ABSORPTION … Quartering the slew from 1000
|
||||
> to 250 does not reduce the over-absorption at all
|
||||
|
||||
and it was right about the mechanism and therefore wrong about the conclusion:
|
||||
`slew_w` bounds the OUTPUT while the integrator accumulates `gain · error`, so a
|
||||
slower slew left the integrator winding for longer. Worst case over the family:
|
||||
|
||||
| `slew_w` | release/1.0 | this branch |
|
||||
|---|---:|---:|
|
||||
| 1000 | 1005 W | **729 W** |
|
||||
| 0 (the rail, 556) | 1109 W | **550 W** |
|
||||
| 400 (the live interim) | 1285 W | **424 W** |
|
||||
| 250 | 1330 W | **326 W** |
|
||||
|
||||
**The ranking inverts.** Before: slower is worse, monotonically. After: slower is
|
||||
better, monotonically. Two plant models that share nothing agree on the sign,
|
||||
which is worth more than either alone — and it means SAFETY-39's *"gain is the
|
||||
next constant to re-derive"* is now only half true: with the lead bounded, the
|
||||
slew owns a share of the over-absorption it did not own before. `gain` is still
|
||||
not touched here, for SAFETY-39's own reason: two live control-law constants
|
||||
moving in one release makes the next measurement uninterpretable.
|
||||
|
||||
⚠️ **AND THE RIG SAYS THE SAME THING, WHICH MEANS TWO COMMITTED TRANSCRIPTS
|
||||
CHANGED MEANING UNDER THIS CARD.** `tools/slew_sim.py --slew` runs the same
|
||||
derivation on admin/goodwe's `plantsim.Plant`, a third model again. Worst-case
|
||||
over-absorption over its 51-step family, `slew_w` from 1000 down to 250:
|
||||
|
||||
| `slew_w` | 1000 | 800 | 700 | 600 | 556 | 500 | 400 | 300 | 250 |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| release/1.0 | 958 | 1107 | 1133 | 1058 | 987 | 1029 | 1150 | 1143 | 1081 |
|
||||
| **this branch** | 777 | 734 | 663 | 563 | 553 | 507 | 435 | 358 | 321 |
|
||||
|
||||
release/1.0's row is flat and non-monotone and **800 really is better than 556**
|
||||
on that column — SAFETY-39 measured it correctly and its §3 sentence is true of
|
||||
the law it was written against. This branch's row falls monotonically. So
|
||||
SAFETY-39's record is not retracted; it is given a **forward reference**, and
|
||||
`slew_sim.py`'s own narration now reads `SLEW_LEAD_STEPS` out of the control law
|
||||
it imported and says which of the two tables it is printing. MUTATION.md habit 8
|
||||
— a committed transcript is a check, and a transcript whose prose contradicts
|
||||
its own table is a failing one.
|
||||
|
||||
## 7. Verified by breaking it
|
||||
|
||||
**The law.** `test_telemetry.py` part 4 and `test_control.py`'s SAFETY-39
|
||||
section. The mutation table is in `test_telemetry.py`'s header.
|
||||
|
||||
**The instrument** (MUTATION.md habit 8 — anything under `tools/` that a
|
||||
document quotes is subject to the battery):
|
||||
|
||||
| # | mutation to `app/control.py` | what the instrument did |
|
||||
|---|---|---|
|
||||
| I2 | the whole SAFETY-48 block deleted | **REFUSED**, exit 1, `NOTHING HAS BEEN MEASURED` — not a zero delta |
|
||||
| I3 | the anchor line itself edited | **REFUSED**, same message |
|
||||
| I3b | `SLEW_LEAD_STEPS = 1000` — the cap present but inert | **every row +0.00**, which is the honest report that the block is doing nothing |
|
||||
| I4 | `SLEW_LEAD_STEPS = 0` — full back-calculation | every row moves further; this is the rejected form's number in §4 |
|
||||
|
||||
I3b is the one that matters: it proves the delta in §3 is caused by the block
|
||||
and not by the two runs differing for some other reason.
|
||||
|
||||
**The law**, driven in the CI image on a purged tree, fingerprinted before,
|
||||
after and after the revert:
|
||||
|
||||
| # | mutation to `app/control.py` | bytes | reds |
|
||||
|---|---|---:|---|
|
||||
| L1 | the whole SAFETY-48 block deleted | −195 B | 5 in `test_telemetry.py`, 4 in `test_control.py` |
|
||||
| L2 | `SLEW_LEAD_STEPS = 0` — the velocity form | +0 B | 4 + 3 |
|
||||
| L3 | `SLEW_LEAD_STEPS = 12` — a cap above the rail | +1 B | 4 + 3 |
|
||||
| L4 | the cap applied unconditionally, outside the slew branch | −92 B | 1 + 0 — **and it was a SURVIVOR of all thirty files before that check existed** |
|
||||
| L5a | the `min`/`max` FUNCTION names exchanged | +0 B | 5 + 5, one of them `test_control.py`'s own "frozen integrator never deadlocks" over 13 041 states |
|
||||
| L5b | the two BRANCH BODIES exchanged — the other reading of "the cap on the wrong side" | +0 B | 5 + 4, and it does **not** reach the deadlock sweep |
|
||||
| L6 | the lead measured against `prev_w` rather than the command emitted | +0 B | 6 + 3 |
|
||||
|
||||
⚠️ **L5 AND L6 ARE BOTH ZERO BYTES** — `min`/`max` and `slewed`/`prev_w` are
|
||||
each the same length — so no fingerprint and no byte delta can see either, and
|
||||
one of L5a's reds is `test_control.py`'s own *"frozen integrator never
|
||||
deadlocks"* sweep over 13 041 states, written by SAFETY-13 for a different
|
||||
reason.
|
||||
|
||||
⚠️ **THREE OF THESE COUNTS WERE WRONG AND ARE RE-MEASURED ABOVE, WHICH IS
|
||||
MUTATION.md's REPORTING RULE 3 APPLIED TO THIS CARD'S OWN TABLE.** The earlier
|
||||
row gave L5 as 2 + 5 and L6 as 4 + 3; driven again in the CI image on a `cp -a`
|
||||
of this tree, purged, `:ro`, one file at a time, they are **5 + 5** and
|
||||
**6 + 3**. ⚠️ **AND "L5" NAMED TWO DIFFERENT MUTATIONS.** "`min`/`max` swapped"
|
||||
can mean the function names exchanged or the branch bodies exchanged; they are
|
||||
both red, they are both the cap on the wrong side, and they give different
|
||||
counts (5 + 5 against 5 + 4) and reach different checks. A row that writes one
|
||||
number for a mutation a reader cannot re-derive from its own description is a
|
||||
row that cannot be re-run, so both spellings are listed. The directions in the
|
||||
original table were all correct; the counts were decoration, and decoration in
|
||||
a mutation table reads as evidence.
|
||||
|
||||
## 7b. The transcript, quoted rather than summarised
|
||||
|
||||
`python3 tools/replay_recorded_loop.py --sensitivity`, on this branch, 58 s:
|
||||
|
||||
```
|
||||
THE RECORDING: 256252 meter instants over 16.00 days, loop-2026-09-02_2026-09-17
|
||||
tick p5 4.77 s median 5.01 s p95 7.86 s (control.LOOP_TICK_S says 2.0)
|
||||
the recorded command: extremes -5000 / +5000 W -> max_w 5000; most common step above 100 W is 400 W, 9924 times -> slew_w 400
|
||||
the fitted inverter: lag 7.5 s, tau 2.0 s, batt = 0.9731 x cmd -22.4 W, residual 375 W RMS above the SoC floor
|
||||
|
||||
FIDELITY - the BEFORE law replayed against the recording it came from
|
||||
setpoint r=0.887 mean +601 W simulated vs +739 W recorded
|
||||
grid r=0.682 mean +195 W simulated vs +239 W recorded
|
||||
export 39.61 kWh simulated vs 29.43 kWh recorded -> the model is +35 % PESSIMISTIC about the loop's tracking
|
||||
|
||||
THE A/B, same ticks, same disturbance, same plant, same seed
|
||||
release/1.0 SAFETY-48 delta
|
||||
export kWh 39.61 34.07 -5.54 the cost
|
||||
import kWh 116.07 111.20 -4.86
|
||||
battery sign reversals 2992.00 2770.00 -222.00
|
||||
wasted throughput kWh 4.67 3.19 -1.48
|
||||
worst 15-min offtake W 3738.67 3433.94 -304.74 ⚠️ THE BILLED ONE
|
||||
recovery events 549.00 612.00 +63.00
|
||||
recovery median s 232.06 199.04 -33.01
|
||||
recovery p99 s 849.00 849.94 +0.94
|
||||
recovery max s 901.06 898.40 -2.66
|
||||
unrecovered (>900 s) 53.00 31.00 -22.00
|
||||
|i_w - prev_w| p99 W 3361.12 800.00 -2561.12
|
||||
|i_w - prev_w| max W 9200.00 800.00 -8400.00
|
||||
slew-limited ticks 16392.00 11654.00 -4738.00
|
||||
mean quarter delta W -2.39 over 1534 quarters
|
||||
worst quarter delta W +532.27
|
||||
|
||||
SENSITIVITY - the ranking, not the watts
|
||||
variant export kWh worst quarter W mean quarter dW
|
||||
plant lag 5.0 s 32.1 -> 27.2 3434 -> 3434 -1.02
|
||||
plant lag 10.0 s 75.4 -> 68.3 3954 -> 3434 -4.65
|
||||
plant tau 8 s 56.5 -> 49.8 3995 -> 3605 -3.30
|
||||
gain 1.0135, offset -26 46.8 -> 39.3 3713 -> 3434 -3.27
|
||||
slew_w 555.6 (the rail) 36.5 -> 34.4 3434 -> 3434 -0.17
|
||||
gain 0.3 28.1 -> 27.0 3434 -> 3434 +0.16
|
||||
|
||||
(58 s)
|
||||
```
|
||||
|
||||
⚠️ **`--sensitivity` IS THE RANKING AND NOT THE WATTS.** The export figure moves
|
||||
by a factor of two across these plants; what does not move is the direction —
|
||||
**on export**. ⚠️ **THE `worst quarter W` COLUMN IS NOT THE SAME KIND OF
|
||||
EVIDENCE AND THIS PARAGRAPH USED TO TREAT IT AS IF IT WERE.** Three of its six
|
||||
rows read `3434 -> 3434`, and that is not "the direction holds": it is
|
||||
release/1.0 *already sitting at 3434 W* on those plants, with nothing for the
|
||||
card to improve. §3a sweeps the lag properly and finds the same thing at every
|
||||
lag ≤ 5.0 s. The billed-quarter improvement in the A/B table above exists at one
|
||||
plant, the one `fit_plant()` selects by an objective that separates it from its
|
||||
neighbour by 0.3 %.
|
||||
|
||||
Two rows deserve naming rather than rounding: at `slew_w: 555.6` (the rail) and
|
||||
at `gain: 0.3` the mean quarter-hour delta is −0.17 W and **+0.16 W** — ties,
|
||||
not improvements. Both are regimes where `|error| > slew_w / gain` is rarely
|
||||
satisfied (1333 W at `gain: 0.3`, 926 W at the rail against a measured median
|
||||
onset error of 1114 W), so there is little for the card to bite on. That is the
|
||||
mechanism agreeing with itself, and it is also the honest answer to "would this
|
||||
still be worth shipping if the tuning moved": at `gain: 0.3` it would not be.
|
||||
|
||||
## 8. What this card does NOT establish
|
||||
|
||||
* **Nothing here drives an inverter.** The replay is a model with a 375 W
|
||||
residual and no arbiter in it. The closed-loop answer comes from the rig or
|
||||
from a live run — SAFETY-47 records why a shadow run cannot give it.
|
||||
* **The live tick is 5.0 s, not `LOOP_TICK_S`'s 2.0 s**, measured off 9924
|
||||
recorded 400 W steps and off the meter's own cadence. It is wrong in the SAFE
|
||||
direction (a slower meter makes `validate.py` rule 10 conservative) and is NOT
|
||||
changed here — re-deriving `SLEW_RAIL_FRAC` is a refusal boundary on a live
|
||||
install and belongs to its own card. It is recorded in the tool's header and
|
||||
printed on every run.
|
||||
* **The brief's plant figure was close but not exact.** It quoted
|
||||
`delivered = 1.0135 × command − 26 W, r = 0.992` at a 4–6 s publish lag. On
|
||||
this 16-day window, above the SoC floor, the same relation measures
|
||||
`1.00 × command − 28 W, r = 0.977` at a best-fit pure delay of **7.5 s**. The
|
||||
conclusion — the inverter is a pure follower and the plant is not the problem
|
||||
— survives; the coefficients are the ones in §2.
|
||||
* **The brief's cost figure is larger than this measurement.** It quoted ~14 kWh
|
||||
of avoidable export and 65 % of site export inside 7.9 % of the time. Measured
|
||||
here: **5.5 kWh** avoidable of 39.6 kWh modelled (29.4 kWh recorded), with
|
||||
42 % of export inside the 6.3 % of ticks that are slew-limited and 69 % within
|
||||
±60 s of one, over 15.3 % of the time. The concentration claim holds; the
|
||||
avoidable-kWh figure does not, and the difference is not reconcilable from
|
||||
here because the reconstruction it came from was discarded.
|
||||
@@ -0,0 +1,181 @@
|
||||
# SAFETY-56 — what the gate cannot tell apart, said in the sentence
|
||||
|
||||
**Text only. No behaviour changes in this card**, and §5 is the measurement that
|
||||
says so: the same stimuli through the tree before and after produce a
|
||||
**byte-identical** trace of every verdict, every `reading()`, every `inputs_ok`
|
||||
and every readiness row's `(ok, warn)`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect
|
||||
|
||||
0.10.1 is regulating a real GW5000S-BP. Two messages tell the operator that
|
||||
healthy hardware has failed.
|
||||
|
||||
**The log line**, as emitted on the reference site on 2026-09-21:
|
||||
|
||||
> SAFETY-37: inverter-a battery-power register **is not following its own
|
||||
> command** - STALE_READING: -3 unchanged for 36 s while the standing command
|
||||
> moved 800 W to +1600 W - **the reading is not following**, so it is reported
|
||||
> as MISSING rather than as a measurement … Treating it as MISSING, which
|
||||
> commands 0 W.
|
||||
|
||||
**The readiness row**, at the same moment:
|
||||
|
||||
> NOT READY — battery power: `sensor.kelder_goodwe_master_stage1_goodwe_inverter_ac_power`
|
||||
> **is missing or not numeric**
|
||||
|
||||
That entity was present, numeric and updating.
|
||||
|
||||
**SAFETY-37's conjunction is correct and is not touched by this card** —
|
||||
`unchanged > 35 s AND |command| > 250 W AND |travel| > 250 W`. A register
|
||||
genuinely stuck against a real command is exactly what it should catch, and a
|
||||
pack that cannot comply is a state the site must stop in too. What was wrong is
|
||||
what the sentences **concluded** from it.
|
||||
|
||||
## 2. Two worlds satisfy the conjunction
|
||||
|
||||
| | the register | the pack |
|
||||
|---|---|---|
|
||||
| **DEAD** | has stopped reporting reality | is doing what it was told |
|
||||
| **REFUSED** | is reporting reality correctly | cannot comply — the BMS is refusing at its floor |
|
||||
|
||||
A refused pack holds still under any setpoint, so an honest register publishes
|
||||
the same unchanged number a dead one does. **Nothing `freshness.py` is handed
|
||||
separates them.** All three sentences asserted DEAD.
|
||||
|
||||
## 3. The live episode was REFUSED, and it is measured, not argued
|
||||
|
||||
Off the committed capture `scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz`,
|
||||
whose bytes and decoder are already graded by `test_freshness.py` part 10a:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the register | answered **-3 W for 850 s**, 10:51:24 → 11:05:34 |
|
||||
| the commands during that hold | **+260, +660, +1060 W** — discharge only, never a charge |
|
||||
| pack SoC throughout | **11 %** |
|
||||
| first **charge** command after the restart | 11:05:31 at **-400 W** |
|
||||
| the register moves | **11:05:34** — **3 s**, inside one 17.0 s obligation window |
|
||||
|
||||
**A register that answers a reversed command inside one obligation window had
|
||||
not stopped reporting.** What would not move was the pack. The sentence the
|
||||
operator read named the one thing in the loop that was telling the truth.
|
||||
|
||||
⚠️ What this does **not** establish: that no register is ever intermittently
|
||||
wrong. It excludes a register that had simply stopped; it cannot exclude one
|
||||
that was wrong and then right. That is the point — the new sentence claims
|
||||
neither.
|
||||
|
||||
## 4. The fix, and why it is three sentences and not one helper
|
||||
|
||||
Each message states **what was observed** and names **both** explanations.
|
||||
|
||||
| where | before | after |
|
||||
|---|---|---|
|
||||
| `app/freshness.py` `_grade()` | *"…+1600 W - the reading is not following, so it is reported as MISSING…"* | *"…+1600 W. **TWO THINGS LOOK LIKE THIS AND THIS GATE CANNOT TELL THEM APART**: the register may have stopped reporting reality, or the source may be unable to comply with the command - a pack the BMS is refusing holds still under any setpoint, and an honest register then reads exactly like a frozen one. Either way the number cannot be read as this command's effect, so it is reported as MISSING…"* |
|
||||
| `app/main.py` `_read_unit()` | *"SAFETY-37: %s battery-power **register is not following its own command** - %s."* | *"SAFETY-37: %s battery-power reading **did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER** - %s."* |
|
||||
| `app/main.py` `checks()` | *"{label}: {entity} **is missing or not numeric**"* | *"{label}: **no usable value this cycle from** {entity} - **and this row cannot tell which of two things that is**: the entity is missing or not numeric, or it is publishing a number that this controller is withholding (a freshness gate does exactly that, and names itself in a row of its own). Confirm the entity in Developer Tools before replacing it"* |
|
||||
|
||||
**Three sentences, three wordings, no shared helper.** The first two are a log
|
||||
line about a **behaviour**; the third is a banner about a named **entity**, and
|
||||
the two answer different questions ("what did the register do" against "can I
|
||||
trust this entity"). A helper serving both would have to drop whichever half
|
||||
the other caller does not need, and the half it dropped would be the
|
||||
operator's next step. The log line names the pair **once**, inside the `%s` it
|
||||
already interpolates, rather than in both of its own halves.
|
||||
|
||||
**No new input, and SoC is not wired.** `BOUNDS["soc"]` stays *STATED, NOT
|
||||
WIRED*; telling the two worlds apart needs the pack floor (SAFETY-32's number)
|
||||
and SoC (TEL-08's wiring), and a message honest about its own ambiguity needs
|
||||
neither. Part 11b drives the two worlds at **50 %** and **11 %** SoC and
|
||||
asserts the sentence does not move one byte.
|
||||
|
||||
**The readiness row does not branch.** `inv.batt_fresh.stale` is on the same
|
||||
object at the same moment, and the row deliberately does not ask it: the same
|
||||
loop renders the grid and SoC rows, which have no gate, and a branch per row
|
||||
would be a second place where "why is this None" is decided. The gate already
|
||||
has a surface — `stale_note()` rides `liveness_notes()` a few rows below with
|
||||
the whole STALE_READING sentence in it — so the operator's discriminator is
|
||||
*"is that row there?"*, and part 11d asserts that row is **present in REFUSED
|
||||
and absent in ABSENT**.
|
||||
|
||||
## 5. Behaviour is byte-identical, measured
|
||||
|
||||
`tools/safety56_behaviour_trace.py` prints two documents from one stimulus set.
|
||||
The `behaviour` half contains **no sentence**: only `stale`, `reading()`,
|
||||
`releases`, `held`, the window bookkeeping, `inputs_ok`, and every readiness
|
||||
row's `(ok, warn)`. Run in the CI image, purged, `:ro`, against
|
||||
`origin/release/1.0` **archived out of git** and against this branch:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the committed live capture | **2390 reads**, 7 verdict edges |
|
||||
| a swept grid of command × register schedules | **66 cells × 120 reads** — 1060 stale reads, 1309 `MISSING` readings, 6 releases |
|
||||
| six driven controller worlds through the shipped read path | DEAD, REFUSED, ABSENT, HEALTHY, RELEASE, RETRIP — every read's `(batt, inputs_ok, stale)` and every `checks()` row's `(ok, warn)` |
|
||||
| **result** | `6030175ed2ab9a4143e86e89a71d45c8ab041ac1c00121a3e3e023cd2b08996c` **on both trees** |
|
||||
|
||||
⚠️ **AND THE CONTROL IS INSIDE THE TOOL, BECAUSE AN IDENTITY BETWEEN TWO TREES
|
||||
THAT ARE THE SAME TREE IS HABIT 4.** `--compare` refuses to report "behaviour
|
||||
unchanged" unless the `sentences` halves **differ**. This card's first
|
||||
invariance run was exactly that mistake — it archived a branch whose edits were
|
||||
not committed yet, so `before` and `after` were the same code and the identity
|
||||
was perfect and worthless.
|
||||
|
||||
Both of the tool's refusals are **driven**, not asserted:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the same file compared with itself | `REFUSED: the sentences halves are IDENTICAL…`, exit **2** |
|
||||
| `MOVE_W` 250.0 → 240.0 in the after tree | `BEHAVIOUR MOVED at 'sweep_stale_reads' / 'sweep_missing_reads' / 'sweep_trace'`, exit **1** |
|
||||
| this card | `OK: sentences differ, behaviour is byte-identical.`, exit **0** |
|
||||
|
||||
## 6. The mutation battery
|
||||
|
||||
Each in its own `mktemp`'d copy (habit 18), purged on the host, mounted `:ro`,
|
||||
graded by `tools/mutation_run.sh` in the CI image.
|
||||
|
||||
| | mutation | Δ bytes | named reds |
|
||||
|---|---|---|---|
|
||||
| M1 | `_grade()` reverted to *"the reading is not following"* | -402 | **3** |
|
||||
| M2 | the log line reverted to *"register is not following its own command"* | -80 | **2** |
|
||||
| M3 | the row reverted to *"{entity} is missing or not numeric"* | -619 | **2** |
|
||||
| M4 | *register* and *source* transposed between the two explanations | **0** | **1** |
|
||||
| M5 | the second explanation deleted, the first kept | -216 | **1** |
|
||||
| M6 | *"CANNOT TELL THEM APART"* replaced by *"IN ORDER OF LIKELIHOOD"* | -13 | **1** |
|
||||
| M7 | the REFUSED world's ground truth set to the DEAD world's | 0 | **2** |
|
||||
| M8 | the row's second cause deleted | -309 | **1** |
|
||||
| M9 | `label` and `entity` transposed in the row | **0** | **3** |
|
||||
| M10 | M1 + M2 + M3 together, i.e. exactly what 0.10.1 ships | -1101 | **5** |
|
||||
| M11 | the accusation literals replaced by one that matches nothing | 0 | **1** |
|
||||
|
||||
⚠️ **M9's first run was a defect in this card's own check, and it is the one
|
||||
worth reading.** The row was fetched with `[...][0]`. Transposing `label` and
|
||||
`entity` makes the row unfindable, so the run died on an `IndexError` — a red
|
||||
with **no name attached**, with every check after that line **ungraded**. That
|
||||
is FCST-06's shape exactly. `_s56_row()` now returns a named absence instead of
|
||||
raising, and a precondition grades it: **0 named reds before, 3 after.**
|
||||
|
||||
⚠️ **M11 is the other one aimed inward.** `not any(...)` over an empty list is
|
||||
True, and so is one over three mistyped literals. The positive control runs the
|
||||
same predicate against the three sentences **as 0.10.1 shipped them** and
|
||||
requires it to find an accusation in every one, and every literal to match at
|
||||
least one of them (habit 7: vary one thing from an accepted baseline).
|
||||
|
||||
## 7. What part 11 does not grade
|
||||
|
||||
* It does not show the live episode was a BMS refusal beyond doubt — see §3.
|
||||
* It does not grade English. A sentence that named both explanations and then
|
||||
said something useless about them would pass.
|
||||
* The `no entity configured` branch beside the rewritten row is untouched and
|
||||
ungraded: a blank entity really is the only thing it can be.
|
||||
* The **grid-power and battery-SoC rows** go through the same rewritten line and
|
||||
have no freshness gate today, so for them the second cause is a hypothesis
|
||||
about this controller rather than a live possibility. That is weaker than the
|
||||
battery-power case and is the price of not branching per row. A gate on either
|
||||
signal — TEL-08 would be one — makes it exact.
|
||||
* **SAFETY-58 is not touched** (the first read after a long rest trips before a
|
||||
healthy register could answer), and neither is the 710/790 disagreement
|
||||
between `test_freshness.py` and the changelog. Both are separate cards; the
|
||||
second has a **fourth site** at
|
||||
`DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md:274` that a fix written
|
||||
off the PR table alone would miss.
|
||||
@@ -0,0 +1,388 @@
|
||||
# SAFETY-57 — the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-57`, off `origin/master` @ `6f5e8e3`
|
||||
(content-identical to `release/1.0` @ `8a57361`).
|
||||
**Card:** SAFETY-57 — *"the freshness gate has no exit reachable from inside the state it creates"*.
|
||||
**Nothing here reached the inverter.** No contact was made with 192.168.2.158 or
|
||||
192.168.2.221. The one live system touched is `sniffer@192.168.2.167`, a
|
||||
**passive** RS-485 listener with no address on the bus, and it was read only.
|
||||
|
||||
---
|
||||
|
||||
## 0. The five things a reader should take away
|
||||
|
||||
1. **The live episode is reproduced from the wire, not modelled.** The capture
|
||||
is committed, the decoder is committed, and the box that produced it was
|
||||
running the defect — so the `0 W` in the trace *is* the add-on's own
|
||||
`inputs missing → command 0 W`. §2.
|
||||
2. **Option 2 from the card — "do not arm the gate when the command is 0 W" —
|
||||
already holds, and it is not the bug.** `_grade()` has required
|
||||
`|standing| >= MOVE_W` since SAFETY-37. The trip happened at **+1060 W** and
|
||||
the stuck state is at **0 W**; they are different moments. §3.
|
||||
3. **The decision is not "how long", it is "when".** The verdict is withdrawn
|
||||
when the **obligation that produced it is no longer standing** — SAFETY-13's
|
||||
shape one module over. The 17.0 s is the obligation window that was already
|
||||
derived in the file, taken at the **floor** of its band, and which half is
|
||||
safe is the **opposite** of SAFETY-13's. §4.
|
||||
4. **It costs 52 % exposure on a permanently dead register, and that number is
|
||||
in the suite so it cannot be forgotten.** It is payable only because one
|
||||
re-grading window (35 s) is shorter than both consumer holds (75 s, 85 s).
|
||||
**No detection SAFETY-37 had is given back.** §5.
|
||||
5. **The battery had been rescuing this code by accident.** 93 trips in the
|
||||
committed 16-day capture; median wait for the BMS keep-alive pulse that
|
||||
cleared them **143 s**, worst **5 h 57 m**. §6.
|
||||
|
||||
---
|
||||
|
||||
## 1. What was there, in the terms the code uses
|
||||
|
||||
`freshness.py`, `Freshness.submit()` and `_grade()`:
|
||||
|
||||
```python
|
||||
if self.changed_mono is None or value != self.held:
|
||||
...
|
||||
self.stale = False # the only place the latch is cleared
|
||||
def _grade(self, standing_w, now_mono):
|
||||
if self.stale or self.changed_mono is None:
|
||||
return # sets the latch; never clears it
|
||||
```
|
||||
|
||||
and `main.py:1777`, `inv.batt = inv.batt_fresh.reading()`, where `reading()`
|
||||
answers `None` while `stale`. `inputs_ok` sees the `None`, the site holds for
|
||||
`stale_input_s`, then files `Claim.set("safety", P_SAFETY, 0.0, "inputs
|
||||
missing")` and keeps filing it.
|
||||
|
||||
The reason the latch exists is written in the class docstring and it is a good
|
||||
one: without it the verdict oscillates at about 1 Hz, because declaring the
|
||||
reading stale drops the command below `MOVE_W`, which un-arms the conjunction,
|
||||
which hands the held number straight back.
|
||||
|
||||
**The defect is that the only thing that ends it is an arrival carrying a
|
||||
different number — and the state's own output is what stops the register being
|
||||
asked for one.** The log line promised the opposite, in the same sentence that
|
||||
announced the 0 W:
|
||||
|
||||
> *Treating it as MISSING, which commands 0 W. It clears itself the moment the
|
||||
> register reports a different value.*
|
||||
|
||||
---
|
||||
|
||||
## 2. Reproducing it, from the wire
|
||||
|
||||
`scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz` is 3840
|
||||
verbatim frames off the RS-485 pair, 10:30–11:10 Europe/Brussels, decoded by
|
||||
`tools/decode_bus_capture.py`. The decoder reports **0 unparsed, 0 bad CRC,
|
||||
0 unknown shapes**, and its mode→sign convention is **graded off the capture**
|
||||
rather than trusted: 298 commands at or under −1000 W are each answered one
|
||||
obligation window later by a negative register reading, and not one positive.
|
||||
|
||||
| capture time | on the wire |
|
||||
|---|---|
|
||||
| 10:51:14 | `0x0518` = **−447 W**; the loop is charging |
|
||||
| 10:51:19 | the command reverses: EMS **mode 3**, 260 W of discharge |
|
||||
| 10:51:24.7 | `0x0518` = **−3 W**. The pack is at **11 %**, the BMS refuses the discharge, the inverter idles. **The register is healthy and truthful.** |
|
||||
| 10:51:29.7 | the command reaches **+1060 W**. 800 W of travel |
|
||||
| 10:52:21.7 | the setpoint is written **0 W** — this is the add-on's own §11.1 rule 1, recorded on the wire |
|
||||
| …850.0 s | `0x0518` does not change value |
|
||||
| …790.0 s | the setpoint does not change value |
|
||||
| ~11:04 | **a person restarts the add-on** — not visible in this file, and the only thing that ends the episode |
|
||||
| 11:05:31.7 | the restarted loop commands **−400 W of charge**, which the pack accepts at 11 % |
|
||||
| 11:05:34.7 | `0x0518` = **−137 W** |
|
||||
|
||||
⚠️ **THE CAPTURE IS CLOSED-LOOP FOR THE WHOLE PARKED INTERVAL**, which is why
|
||||
this is a reproduction and not a simulation: the box was running 0.10.0, so
|
||||
every command in the trace is the shipped code's own. The only event inside the
|
||||
window the file cannot account for is the human restart.
|
||||
|
||||
Replaying the shipped `_read_unit` loop at 1 Hz against those two series
|
||||
(`test_freshness.py` part 10b):
|
||||
|
||||
| | stale at | clear at | parked |
|
||||
|---|---|---|---|
|
||||
| **before** (`release_s = inf`) | 10:52:00 | 11:05:35 | **815 s**, and `reading()` is `None` on all **812** reads from the trip to the first command a human put on the wire, with the register at −3.0 W in every one of them |
|
||||
| **after** | 10:52:00 | **10:52:39** | **39 s**, with `0x0518` still answering the same **−3 W** |
|
||||
|
||||
⚠️ **THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS GRADED RATHER THAN
|
||||
CLAIMED.** With an infinite bound `_release()` never fires, `_open_window()` is
|
||||
only ever called where the register moves, and the window pair is the
|
||||
change-stamp pair — byte-for-byte the pre-SAFETY-57 conjunction. Part 10b
|
||||
asserts that by driving part 1's own fresh/stale pair through both bounds and
|
||||
comparing the verdict streams.
|
||||
|
||||
The replay also found a **one-read trip at 10:48:14** that clears one second
|
||||
later. That is a pre-existing false positive and it is now recorded as
|
||||
survivor 5 — §7.
|
||||
|
||||
---
|
||||
|
||||
## 3. What the card's option 2 actually buys, which is nothing here
|
||||
|
||||
The card offered *"do not arm the gate when the command being graded is 0 W"*
|
||||
and warned that it might hold already. **It does, and it has since SAFETY-37**:
|
||||
`_grade()` returns at `abs(standing_w) < self.bound.move_w`, and
|
||||
`test_freshness.py` part 4 has a named check on that term, added because a
|
||||
mutation removing it survived.
|
||||
|
||||
The warning in the card is the right one and it is worth writing down: **the
|
||||
trip and the stuck state are different moments.** The gate armed at **+1060 W**,
|
||||
where the conjunction is entirely correct — a command travelled 800 W and the
|
||||
register did not move for 35 s. The site then went to **0 W** *because of the
|
||||
verdict*. An entry-side rule cannot reach a state that is entered legitimately
|
||||
and then persists. **The defect is the exit, and nothing else in the conjunction
|
||||
is touched by this card.**
|
||||
|
||||
Option 3 — probing with a small non-zero command — was not taken and does not
|
||||
need to be. It writes to a real actuator to test an assumption, and §4's rule
|
||||
does not need a probe: the site's *normal* control resuming is the test, and it
|
||||
is gated behind the same `inputs_ok` everything else is.
|
||||
|
||||
---
|
||||
|
||||
## 4. The decision: which verdicts get an exit, and what the bound is
|
||||
|
||||
> The stale verdict holds as before, **unless** the obligation that produced it
|
||||
> is no longer standing — `|standing| < MOVE_W` — **and** it has been gone
|
||||
> longer than the register would have needed to answer it. On a release the
|
||||
> latch clears and a **new grading window** opens at that instant.
|
||||
|
||||
This is [[SAFETY-13]]'s §4 in a different module, and deliberately so: that card
|
||||
established the house answer to "a latch with no exit" and its answer was *not*
|
||||
a plain time bound but a **predicate plus a time bound**, where the predicate
|
||||
says whether the state can end on its own. Here the predicate is that **the
|
||||
question is no longer being asked.** A register under a 0 W command is obliged
|
||||
to be nowhere, so a verdict about whether it is following cannot be tested, and
|
||||
a verdict that cannot be tested may not stand for ever.
|
||||
|
||||
### 4.1 Why the window has to re-open, and what happens if it does not
|
||||
|
||||
`_grade()` compares the register's unchanged time against `unchanged_s` and the
|
||||
command's travel against the command standing when the window opened. If a
|
||||
release cleared the latch **without** re-opening the window, the first read at
|
||||
which the resumed command travels 250 W would re-trip immediately — the
|
||||
register would never get the 17 s in which it could answer, and the live
|
||||
episode's recovery (a charge the pack **accepts**) would be killed inside two
|
||||
seconds. Mutating exactly that (`graded_from` re-anchored to `changed_mono`,
|
||||
+82 B) takes four named checks red.
|
||||
|
||||
The new window is `BATT_UNCHANGED_S` = **35 s**, the same bound as entry,
|
||||
because it is the same question — *does this register follow the commands issued
|
||||
from now on?* — and there is no principled shorter answer to it. It is also the
|
||||
number the exposure argument in §5 rests on, so it is not free to move.
|
||||
|
||||
### 4.2 `RELEASE_S` = `BATT_OBLIGED_S` = 17.0 s, and which half is safe
|
||||
|
||||
The quantity needed is *has the register had its chance to answer the command
|
||||
standing now?*, and `freshness.py` already derives it as the floor of
|
||||
`BATT_UNCHANGED_S`'s band:
|
||||
|
||||
```
|
||||
6.0 s plant goodwe/NOTES.md §5.9, measured 2026-08-22
|
||||
10.0 s publisher firmware/goodwe-master.yaml:181, update_interval: 10s
|
||||
1.0 s this add-on run_control()'s await asyncio.sleep(1)
|
||||
```
|
||||
|
||||
⚠️ **IT IS THE FLOOR OF ITS BAND, NOT A SENSITIVITY PREFERENCE** — the mirror of
|
||||
`MOVE_W` taking the **ceiling** of its own for the mirrored reason.
|
||||
|
||||
| | the question | the safe half | why |
|
||||
|---|---|---|---|
|
||||
| SAFETY-13's `SETTLE_CYCLES` | may the plant still be the **explanation**? | the **longer** half | a shorter bound stops believing the plant too early, and then it accuses |
|
||||
| this one | may the verdict still be **tested**? | the **shorter** half | being wrong *long* is the failure being fixed; being wrong *short* costs one re-grading window, and §5 bounds that |
|
||||
|
||||
Below 17.0 s the release pre-empts the **cheaper exit** — the register answering
|
||||
the new 0 W command by itself, which clears the latch on evidence rather than on
|
||||
a timer. That is graded behaviourally rather than asserted: a register that
|
||||
answers at +16 s clears by **movement** at `release_s = 17.0` with `releases`
|
||||
still 0, and by **timer** at `release_s = 5.0`.
|
||||
|
||||
The family is computed and printed (habit 10): `5, 16, 17, 18, 35, inf` give
|
||||
six distinct release instants, so no check in the file can confuse two of them.
|
||||
|
||||
### 4.3 Two clocks, and the operator reads the register's
|
||||
|
||||
`changed_mono` / `unchanged_s()` remain facts about the **register**: after a
|
||||
release they go on reporting that it has not moved for 850 s, because it has
|
||||
not. `graded_from` / `graded_s()` are the **verdict's** window. They coincide
|
||||
until the first release. Collapsing them either lies to the operator or defeats
|
||||
the re-grading, and both directions are mutated red.
|
||||
|
||||
The stale sentence's `unchanged for N s` slot carries the **register's** number;
|
||||
a re-trip appends a clause naming the window and the grade count, so the two
|
||||
durations cannot be read as one number that moved (habit 14).
|
||||
|
||||
---
|
||||
|
||||
## 5. What it costs, measured
|
||||
|
||||
Driven against a register frozen for good, 1201 reads, shipped defaults
|
||||
(`test_freshness.py` part 10c):
|
||||
|
||||
| | parked | the held number reaches a consumer | releases |
|
||||
|---|---|---|---|
|
||||
| before | 1166 s | **35 reads (2.9 %)** | 0 |
|
||||
| after | 571 s | **630 reads (52.5 %)** | 17 |
|
||||
|
||||
⚠️ **52 % IS PAID EVERY CYCLE, NOT OCCASIONALLY.** The period is
|
||||
`stale_input_s` (15 s) + `RELEASE_S` (17 s) parked, then one 35 s window.
|
||||
|
||||
It is payable because of one inequality, and it is the inequality
|
||||
`test_freshness.py` part 6 has graded against the shipped `config.yaml` since
|
||||
SAFETY-37:
|
||||
|
||||
```
|
||||
35 s (the re-grading window) < 75 s (circulation_hold_s) < 85 s (unit_sign_disagree_s)
|
||||
```
|
||||
|
||||
Every unbroken run of exposure is **exactly one window**, so neither consumer
|
||||
can reach a verdict inside one, and the re-trip that ends each run hands them a
|
||||
`None`, which **clears** their windows rather than accumulating into them.
|
||||
|
||||
**And nothing SAFETY-37 detects is given back**, which is the half that makes
|
||||
this a trade rather than a regression: a permanent `None` clears those same two
|
||||
windows for ever as well, so on the shipped-before module those detectors are
|
||||
*equally* unable to speak about the unit. What the held number does reach for
|
||||
35 s is `site_batt_w`, the anti-windup input of `control.compute()` — which
|
||||
answers a large unexplained divergence by **freezing the output**, the
|
||||
conservative direction and SAFETY-13's business — and the command itself is
|
||||
derived from the **P1 meter**, never from `batt`.
|
||||
|
||||
### 5.1 The narrowing this does NOT take, named so the next card can
|
||||
|
||||
The live episode's register refused a **discharge** into an 11 % pack and then
|
||||
followed a **charge** three seconds later. A refusal in one direction is not
|
||||
evidence about the other, and a gate that knew the pack's floor could re-grade
|
||||
on the first command that crosses it instead of on a timer — which would shrink
|
||||
the 52 % above. That needs SoC, which this module deliberately does not take
|
||||
(see the `soc` row and TEL-08), and the pack floor, which is SAFETY-32's number.
|
||||
It is **not needed to give the state an exit**, and folding it in here would
|
||||
make the exit depend on a second measurement being right.
|
||||
|
||||
---
|
||||
|
||||
## 6. The measurement that says the old exit was luck
|
||||
|
||||
`scenarios/loop-2026-09-02_2026-09-17/` replayed at 1 Hz through the shipped
|
||||
conjunction (`test_freshness.py` part 8b): **93 trips in 16 days.** The only
|
||||
thing that could clear any of them was the register carrying a different number,
|
||||
and that took:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| minimum | 0.6 s |
|
||||
| **median** | **143.4 s** |
|
||||
| maximum | **21440.1 s** (5 h 57 m) |
|
||||
| at or over the live episode's 710 s | **17 of 93** |
|
||||
|
||||
The short waits are the BMS keep-alive pulse — SAFETY-50 measured its ceiling at
|
||||
508 W — twitching `0x0518` and satisfying the clearing rule **by accident**.
|
||||
Over a fifth of the trips are cleared in under 20 s by something nothing in this
|
||||
add-on asked for.
|
||||
|
||||
⚠️ **WHAT THAT REPLAY IS AND IS NOT.** The capture runs 2026-09-02…17 and
|
||||
0.10.0 — the first version carrying this gate — was cut on 2026-09-16, so the
|
||||
setpoint column is an **ungated** controller's. That makes each trip an honest
|
||||
register-versus-command divergence off the real bus, and it makes the post-trip
|
||||
command column something a gated box would **not** have written. The figure
|
||||
taken from it is therefore the only one that does not depend on the command at
|
||||
all — how long this register goes without changing value — and it **under**-states
|
||||
the wait, because a site parked at 0 W gives the register even less reason to
|
||||
move than the one recorded here.
|
||||
|
||||
---
|
||||
|
||||
## 7. Survivors, and the two things the battery found
|
||||
|
||||
The battery is 22 mutations over `app/freshness.py`, `app/main.py` and
|
||||
`tools/decode_bus_capture.py`, plus one over the committed capture itself. All
|
||||
run through `ci_run.sh`, a mirror of `checks.yml`'s own step: a purged copy of
|
||||
the tree, bind-mounted `:ro`, one file at a time, `PYTHONDONTWRITEBYTECODE=1`.
|
||||
Per MUTATION.md habit 18 the harness's scratch file is `mktemp`ed per
|
||||
invocation and the tree is fingerprinted **after the revert** as well as before;
|
||||
every revert restored the recorded sha256 and byte count.
|
||||
|
||||
**21 red, 1 argued equivalent, 0 unexplained survivors.** The table is in the
|
||||
PR body. Two of them are worth keeping here.
|
||||
|
||||
### 7.1 The equivalent mutant, argued rather than rounded off
|
||||
|
||||
Swapping `_release()` and `_grade()` in `submit()` is a **zero-byte** edit and
|
||||
survived the whole battery. **It is not a missing fixture and no fixture can
|
||||
close it**: `_grade()` acts only when `|standing| >= move_w` and `_release()`
|
||||
only when `|standing| < move_w`, so at most one of them can do anything on any
|
||||
read, whichever runs first.
|
||||
|
||||
⚠️ **THE FIRST VERSION OF THE CODE CLAIMED THE ORDER WAS LOAD-BEARING**, in a
|
||||
`⚠️` comment, with a reason that sounded right. It was false, and the battery is
|
||||
what said so — **MUTATION.md habit 22 exactly, one card later: an explanation in
|
||||
a comment is a claim, and this one had not been run.** The comment now states
|
||||
the disjointness, and part 10 pins **that** — at 0, 100, 249, 250, 251 and
|
||||
1300 W — instead of pinning an order that is not observable.
|
||||
|
||||
### 7.2 The sibling that survived, which is habit 11 earning its place
|
||||
|
||||
`_release()`'s obligation test is `abs(standing_w) >= self.bound.move_w`.
|
||||
Swapping `move_w` for `unchanged_s` — comparing **watts against 35** — survived
|
||||
the file at **+5 B**, because **every fixture rested at exactly 0 W**, and 0 is
|
||||
below both 35 and 250. They separate only in the band between them, and a site
|
||||
can sit there: a peak-guard floor, a shadow substitution, any claim that wins
|
||||
with a small non-zero number. Closed by a three-point fixture at 0 / 100 / 300 W
|
||||
that asserts the 100 W case releases and the 300 W case does not.
|
||||
|
||||
### 7.3 ⚠️ And the instrument lied once, which is habit 16's second half
|
||||
|
||||
The first form of the `graded_from` mutation was
|
||||
`float(self.changed_mono or now_mono)`. It reported a **survivor at +21 B**, and
|
||||
the tree fingerprint showed the edit really was on disk and really did run.
|
||||
`changed_mono` is **0.0** in the dead-register fixture, and `0.0 or x` is `x` —
|
||||
so the mutant was a *no-op on the fixture*, for a Python truthiness reason that
|
||||
has nothing to do with the property under test. Rewritten as an explicit
|
||||
`is None` test (+82 B) it is red on four named checks.
|
||||
|
||||
**A mutation is subject to every habit in MUTATION.md, including habit 4.** A
|
||||
purge, a fingerprint and a byte delta all say the edit is present; none of them
|
||||
says the edit *changed the answer on this stimulus*. MUTATION.md gains habit 19
|
||||
for it.
|
||||
|
||||
---
|
||||
|
||||
## 8. What this does NOT fix, said plainly
|
||||
|
||||
**It does not tell a BMS refusal apart from a dead register**, and it does not
|
||||
try to. Both are re-graded on the same timer. §5.1 names what telling them apart
|
||||
would need.
|
||||
|
||||
**It does not make the diagnosis right.** The line the operator reads still says
|
||||
*"battery-power register is not following its own command"*, which is wrong in a
|
||||
different way when the pack is simply empty. That wording is **SAFETY-56's**,
|
||||
together with `main.py`'s NOT READY row, and it is deliberately untouched here —
|
||||
two cards editing one sentence is how one of them silently loses. Only the
|
||||
**escape clause** was rewritten, because this card is what made the old one
|
||||
false, and part 10h has a named check asserting the diagnosis half is still
|
||||
there so that rewrite has to be a deliberate act.
|
||||
|
||||
> ⚠️ **SINCE SUPERSEDED, WHICH IS WHAT THAT CHECK WAS FOR.** [[SAFETY-56]] is
|
||||
> the deliberate act: all three sentences now name **both** explanations and
|
||||
> accuse neither, and part 10h's check is inverted — it requires the accusation
|
||||
> to be **gone**. Nothing else in this section moved; the escape clause above is
|
||||
> still this card's and is untouched by it. See
|
||||
> `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`, and
|
||||
> `test_freshness.py` part 11 for the graded pair of worlds.
|
||||
|
||||
**Survivor 5 is older than this card and is not closed by it.** After a long
|
||||
rest the unchanged clock is already past 35 s, so the **first** read carrying a
|
||||
travelled command trips the gate — before the 17.0 s in which a healthy register
|
||||
could have answered it. The live capture contains one (10:48:14, cleared one
|
||||
second later). Its size is one `stale_input_s` hold, during which `cycle()`
|
||||
returns at `inputs missing {n}s` and commands nothing differently. Fixing it
|
||||
means measuring the unchanged clock from when the **obligation** appeared rather
|
||||
than from the register's last move, which re-derives the 35.0 s bound and is a
|
||||
different card.
|
||||
|
||||
**Nothing was measured on two inverters.** Inverter B does not physically exist
|
||||
(`goodwe/NOTES.md` §1.1b). Every two-unit figure in `test_freshness.py` is two
|
||||
copies of A's behaviour with different numbers passed in.
|
||||
|
||||
**No deploy is proposed.** 0.10.0 is on the reference install and this branch's
|
||||
CHANGELOG row is filed under it only because `test_version_gate.py` part 11b
|
||||
requires the newest heading to be a version. Moving it, and deciding whether a
|
||||
version is cut, is a PKG card's.
|
||||
@@ -371,6 +371,43 @@ 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
|
||||
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
|
||||
@@ -489,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
|
||||
|
||||
@@ -811,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
|
||||
|
||||
@@ -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,98 @@ class Freshness:
|
||||
# consumer today. They are here for TEL-08, whose obligation term is an
|
||||
# energy integral that has nothing to say until it has a window - and
|
||||
# "nothing to say" must withhold the verdict, not manufacture one.
|
||||
if standing_w is None or self.standing_at_change is None:
|
||||
if standing_w is None or self.standing_at_grade is None:
|
||||
return
|
||||
standing_w = float(standing_w)
|
||||
if abs(standing_w) < self.bound.move_w:
|
||||
return
|
||||
moved_w = abs(standing_w - self.standing_at_change)
|
||||
moved_w = abs(standing_w - self.standing_at_grade)
|
||||
if moved_w < self.bound.move_w:
|
||||
return
|
||||
self.stale = True
|
||||
# ⚠️ THE SENTENCE SAYS "unchanged", SO IT GETS THE REGISTER'S OWN
|
||||
# NUMBER, NOT THE WINDOW'S. They are the same until the first release
|
||||
# and different afterwards, and an operator comparing this line against
|
||||
# a history graph is reading the register.
|
||||
shown_s = self.unchanged_s(now_mono)
|
||||
# ⚠️ SAFETY-56. THE OBSERVATION IS A FACT, THE DIAGNOSIS WAS NOT, AND
|
||||
# THE SENTENCE NOW STOPS WHERE THE EVIDENCE DOES. The conjunction above
|
||||
# is satisfied by TWO disjoint worlds, and nothing this object is handed
|
||||
# separates them:
|
||||
# (1) the register has stopped reporting reality - what the gate was
|
||||
# built for - or
|
||||
# (2) reality is not moving, because the SOURCE cannot comply with
|
||||
# the command. A pack the BMS is refusing at its floor holds
|
||||
# still under any setpoint, and a register reporting that
|
||||
# truthfully publishes the same unchanged number a dead one does.
|
||||
# The live 2026-09-21 episode was (2) - the pack read 11 % for the
|
||||
# whole 850 s hold, the commands were discharge-only, and the register
|
||||
# answered the first CHARGE command within one obligation window
|
||||
# (test_freshness.py 11a measures all three off the committed capture)
|
||||
# - and this sentence asserted (1), which sent the operator to look at
|
||||
# healthy hardware.
|
||||
# Naming both is what the module can honestly say. Naming WHICH would
|
||||
# need SoC and the pack floor - deliberately not this module's inputs
|
||||
# (see the `soc` row in BOUNDS and the docstring's last section), and
|
||||
# TEL-08's and SAFETY-32's numbers rather than this sentence's.
|
||||
#
|
||||
# ⚠️ AND IT IS NOT SHARED WITH `main.py`'s NOT READY ROW, WHICH SAYS
|
||||
# THE SAME AMBIGUITY IN DIFFERENT WORDS ON PURPOSE. This sentence is a
|
||||
# LOG LINE about a behaviour, quoted verbatim by `note()` onto the
|
||||
# status page and by `_read_unit()` into the action log; that one is a
|
||||
# BANNER about a named ENTITY, and the two answer different questions
|
||||
# ("what did the register do" against "can I trust this entity"). One
|
||||
# helper serving both would have to drop whichever half the other does
|
||||
# not need, and the half it dropped would be the operator's next step.
|
||||
self.reason = (
|
||||
f"STALE_READING: {self.held:+.0f} unchanged for {held_s:.0f} s "
|
||||
f"STALE_READING: {self.held:+.0f} unchanged for {shown_s:.0f} s "
|
||||
f"while the standing command moved {moved_w:.0f} W to "
|
||||
f"{standing_w:+.0f} W - the reading is not following, so it is "
|
||||
f"{standing_w:+.0f} W. TWO THINGS LOOK LIKE THIS AND THIS GATE "
|
||||
f"CANNOT TELL THEM APART: the register may have stopped reporting "
|
||||
f"reality, or the source may be unable to comply with the command "
|
||||
f"- a pack the BMS is refusing holds still under any setpoint, and "
|
||||
f"an honest register then reads exactly like a frozen one. Either "
|
||||
f"way the number cannot be read as this command's effect, so it is "
|
||||
f"reported as MISSING rather than as a measurement (limits: "
|
||||
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
|
||||
f"{self.bound.move_w:.0f} W and moved by over "
|
||||
f"{self.bound.move_w:.0f} W)")
|
||||
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
|
||||
# in its own slot: the register's unchanged time is in the sentence
|
||||
# above, the WINDOW this verdict actually rests on is here, and so is
|
||||
# how many times this unit has been round the loop. Without the second
|
||||
# one the sentence reads as though 92 s of unchanged register were what
|
||||
# was compared against the 35 s limit, and it was not.
|
||||
if self.releases:
|
||||
self.reason += (
|
||||
f" [re-graded: the {held_s:.0f} s window this verdict rests on "
|
||||
f"opened when the previous one was withdrawn, not when the "
|
||||
f"register last moved - this unit has now been graded "
|
||||
f"{self.releases + 1} times]")
|
||||
|
||||
def unchanged_s(self, now_mono) -> float | None:
|
||||
"""Seconds since an arrival carried a different number. Status page."""
|
||||
"""Seconds since an arrival carried a different number. Status page.
|
||||
|
||||
⚠️ THE REGISTER'S CLOCK, NEVER THE VERDICT'S (SAFETY-57). A release
|
||||
re-opens the grading window; it does not make the register younger, and
|
||||
an operator reading "unchanged for 12 s" off a register that has held
|
||||
one value for 850 s would be reading this module's bookkeeping back as a
|
||||
measurement. `graded_s()` is the other one and is named differently for
|
||||
that reason.
|
||||
"""
|
||||
if self.changed_mono is None:
|
||||
return None
|
||||
return max(0.0, float(now_mono) - self.changed_mono)
|
||||
|
||||
def graded_s(self, now_mono) -> float | None:
|
||||
"""Seconds the CURRENT verdict window has been open (SAFETY-57).
|
||||
|
||||
Equal to `unchanged_s()` until the first release, and shorter after one.
|
||||
"""
|
||||
if self.graded_from is None:
|
||||
return None
|
||||
return max(0.0, float(now_mono) - self.graded_from)
|
||||
|
||||
def reading(self):
|
||||
"""What the consumers get: the value, or None when it cannot be trusted.
|
||||
|
||||
@@ -366,6 +652,13 @@ class Freshness:
|
||||
rather than accumulating it, and `circulation.circulating_w` returns None
|
||||
for a roster with a missing term, which clears the circulation window
|
||||
too. This module is the thing that was missing between them.
|
||||
|
||||
⚠️ AND IT CAN GO BACK TO BEING A NUMBER WITHOUT THE REGISTER MOVING
|
||||
(SAFETY-57). That is the exit, and it is bounded rather than free: the
|
||||
window a release opens is `bound.unchanged_s` = 35 s, shorter than both
|
||||
consumer holds (75 s and 85 s), so neither can reach a verdict inside
|
||||
one - and a re-trip hands them a None, which CLEARS their windows. See
|
||||
the module docstring's last section.
|
||||
"""
|
||||
return None if self.stale else self.last
|
||||
|
||||
@@ -373,6 +666,26 @@ class Freshness:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
return f"{unit_name}: {self.reason}" if self.stale else ""
|
||||
|
||||
def release_note(self, unit_name: str) -> str:
|
||||
"""SAFETY-57's release, for the log. Empty when nothing was withdrawn.
|
||||
|
||||
⚠️ IT NAMES WHAT IS AND IS NOT BEING CLAIMED, because a line reading
|
||||
"recovered" would be a lie: nothing has been learned about the register.
|
||||
The verdict was withdrawn because it can no longer be tested, and the
|
||||
next 35 s are the test.
|
||||
"""
|
||||
if self.released_at is None:
|
||||
return ""
|
||||
return (f"{unit_name}: STALE_WITHDRAWN after the standing command sat "
|
||||
f"below {self.bound.move_w:.0f} W for "
|
||||
f"{self.bound.release_s:.0f} s. NOTHING HAS BEEN LEARNED ABOUT "
|
||||
f"THE REGISTER - it is still holding {self.held:+.0f} - but a "
|
||||
f"verdict about a command that is no longer standing cannot be "
|
||||
f"tested, and the 0 W this one produces is what stopped it "
|
||||
f"being tested. The register is now re-graded over a fresh "
|
||||
f"{self.bound.unchanged_s:.0f} s window; if it still does not "
|
||||
f"follow, this returns")
|
||||
|
||||
|
||||
def monotonic() -> float:
|
||||
"""The clock this module's callers use, in one place so tests can find it.
|
||||
|
||||
@@ -1769,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
|
||||
@@ -1777,11 +1778,52 @@ 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)
|
||||
_LOG.error("SAFETY-37: %s battery-power register is not following "
|
||||
"its own command - %s. Treating it as MISSING, which "
|
||||
# ⚠️ 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.
|
||||
# ⚠️ SAFETY-56 REWROTE THE DIAGNOSIS HALF, WHICH SAFETY-57 LEFT
|
||||
# STANDING ON PURPOSE. It used to open "battery-power register is
|
||||
# not following its own command" - an accusation against a named
|
||||
# piece of hardware, and FALSE on the very episode above. The
|
||||
# pack read 11 % for the whole hold, every command in it was a
|
||||
# DISCHARGE, and the register answered the first CHARGE command
|
||||
# three seconds later - so the BMS was refusing, the register was
|
||||
# publishing that refusal correctly, and the operator was sent to
|
||||
# look at a working sensor. All three numbers are measured off the
|
||||
# committed capture by test_freshness.py part 11a. The gate's
|
||||
# conjunction is right and is NOT touched; only its conclusion was.
|
||||
#
|
||||
# ⚠️ AND THE TWO EXPLANATIONS ARE NAMED ONCE, IN `%s`, NOT TWICE.
|
||||
# This line's own words are the OBSERVATION plus the one thing a
|
||||
# log reader has to be told before acting - that the observation is
|
||||
# not yet a verdict - and `freshness._grade()`'s sentence, which
|
||||
# this line already interpolates, is where the pair is spelled out.
|
||||
# Saying it in both halves of one sentence is how an operator
|
||||
# learns to skim it.
|
||||
_LOG.error("SAFETY-37: %s battery-power reading did not move under "
|
||||
"a command that did, WHICH IS NOT BY ITSELF A VERDICT "
|
||||
"ON THE REGISTER - %s. 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:
|
||||
@@ -4636,8 +4678,38 @@ class Controller:
|
||||
if not entity:
|
||||
out.append({"ok": False, "warn": False, "text": f"{label}: no entity configured"})
|
||||
elif value is None:
|
||||
# ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED
|
||||
# ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO. It read
|
||||
# "<entity> is missing or not numeric" for ANY None, and one of
|
||||
# the ways this value becomes None is SAFETY-37's freshness gate
|
||||
# withholding a reading that is present, numeric and updating -
|
||||
# `Freshness.reading()` returns None BY DESIGN there. On the
|
||||
# live site on 2026-09-21 the operator opened Developer Tools on
|
||||
# the entity this row named, found it working, and had every
|
||||
# reason to conclude the diagnostics were broken.
|
||||
#
|
||||
# ⚠️ AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER. The
|
||||
# row could ask `inv.batt_fresh.stale` - it is on the same
|
||||
# object at the same moment - but this loop also renders the
|
||||
# grid and SoC rows, which have no gate, and a branch per row
|
||||
# would be a second place where "why is this None" is decided.
|
||||
# The gate already has a surface of its own: `stale_note()`
|
||||
# rides `liveness_notes()` a few rows below with the whole
|
||||
# STALE_READING sentence in it, so the operator's discriminator
|
||||
# is "is that row there?" and this row's job is only to stop
|
||||
# pre-empting the answer. Same reason the wording is not shared
|
||||
# with `freshness._grade()`: that one is a log line about a
|
||||
# BEHAVIOUR, this one is a banner about an ENTITY.
|
||||
out.append({"ok": False, "warn": False,
|
||||
"text": f"{label}: {entity} is missing or not numeric"})
|
||||
"text": f"{label}: no usable value this cycle from "
|
||||
f"{entity} - and this row cannot tell which "
|
||||
f"of two things that is: the entity is "
|
||||
f"missing or not numeric, or it is "
|
||||
f"publishing a number that this controller "
|
||||
f"is withholding (a freshness gate does "
|
||||
f"exactly that, and names itself in a row "
|
||||
f"of its own). Confirm the entity in "
|
||||
f"Developer Tools before replacing it"})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "text": f"{label}: {entity} = {value:g}"})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.10.0"
|
||||
version: "0.10.1"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
|
||||
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."
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+1114
-16
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,9 @@
|
||||
"""Runnable check for TEL-10: the loop's integrator, published.
|
||||
"""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
|
||||
@@ -46,6 +51,13 @@ of the tree, one file at a time, and say which tree. M7's count was written as
|
||||
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
|
||||
@@ -71,6 +83,41 @@ REACH THE FAILURE THEY NAMED. Both are MUTATION.md habit 4:
|
||||
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
|
||||
@@ -83,7 +130,7 @@ import asyncio
|
||||
import sys
|
||||
|
||||
from app import mqtt
|
||||
from app.control import Tuning, slew_limit_w
|
||||
from app.control import SLEW_LEAD_STEPS, Tuning, compute, slew_limit_w
|
||||
from app.main import Controller
|
||||
|
||||
fails = []
|
||||
@@ -203,12 +250,16 @@ def run(grid, cycles=30, auto=True, stop_after=None, **over):
|
||||
# ==============================================================================
|
||||
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. The
|
||||
# integrator moves `gain * error` = 3.6 kW on the first cycle and reaches its
|
||||
# 5000 W bound on the second; the command may only move 400 W a cycle, so it is
|
||||
# at 800 W when the integrator is already railed and needs twelve more cycles to
|
||||
# get there. That gap is the fixture: for ten cycles the two numbers differ by
|
||||
# kilowatts, and a row published from the wrong one is wrong by kilowatts.
|
||||
# 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]
|
||||
@@ -226,30 +277,43 @@ 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 LEAD TIME IS PART OF THE CLAIM, NOT A NICETY. "The published number
|
||||
# ⚠️ 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, twelve cycles 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` this line is red and the value-only
|
||||
# form was green.
|
||||
check("⭐ THE PUBLISHED INTEGRATOR REACHES ITS BOUND, AND REACHES IT TEN CYCLES "
|
||||
f"BEFORE THE COMMAND DOES. A 6 kW export winds it to exactly "
|
||||
f"{BOUND_W:.0f} W - `integrator_max_w` is None, which control.py resolves "
|
||||
"to `max_w` - while the command is still travelling 400 W a cycle",
|
||||
# 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 >= 10)
|
||||
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 NOWHERE NEAR it at that tick, so the two are "
|
||||
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(sp_row[first_at_bound]) <= BOUND_W / 2)
|
||||
check("...and the gap is the slew limit and not an accident: the command is "
|
||||
"still travelling 400 W a cycle while the integrator stands still",
|
||||
abs(sp_row[first_at_bound + 1] - sp_row[first_at_bound])
|
||||
== slew_limit_w(Tuning(**LIVE)))
|
||||
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
|
||||
@@ -260,9 +324,9 @@ ident = [k for k in range(1, 12)
|
||||
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 large while the command is catching up, so the row is not "
|
||||
f"a permanent zero: worst lead {min(lead_row):.0f} W",
|
||||
min(lead_row) <= -2000.0)
|
||||
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)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -312,7 +376,7 @@ 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"] < -1000.0
|
||||
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
|
||||
@@ -360,6 +424,147 @@ check("...and they are on the SITE device, not per unit - there is one "
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-57 - turn a passive RS-485 capture into the three series the loop sees.
|
||||
|
||||
`python3 tools/decode_bus_capture.py scenarios/safety57-2026-09-21/bus-a-*.log.gz`
|
||||
|
||||
WHY THE RAW CAPTURE IS WHAT IS COMMITTED, AND NOT A DECODED TABLE. The other
|
||||
scenario directories in this repository hold Home Assistant recorder exports -
|
||||
a number per row, already interpreted. This one holds MODBUS FRAMES off the
|
||||
wire, because the episode it records is one in which the add-on's own view and
|
||||
the wire's disagree, and a decoded table would be this decoder's opinion with
|
||||
the evidence thrown away. MUTATION.md habit 8: a committed transcript is a
|
||||
check, and a check has to be re-derivable from something.
|
||||
|
||||
WHAT THE CAPTURE IS. `sniffer@192.168.2.167` is a passive listener on the
|
||||
RS-485 pair between the ESP32 master and the GW5000S-BP. It writes nothing; it
|
||||
has no address on the bus. Every line is one frame it heard:
|
||||
|
||||
a|10:51:29.695 ok F7 10 05 6E 00 02 04 00 03 04 24 ...
|
||||
^ ^ ^ ^
|
||||
| | | the frame, hex
|
||||
| | CRC verdict as the capture tool computed it
|
||||
| local wall clock, Europe/Brussels, no date - see `decode()`
|
||||
bus label
|
||||
|
||||
THE FOUR FRAME SHAPES THIS FILE UNDERSTANDS, and it deliberately understands no
|
||||
others - an unrecognised frame is COUNTED and dropped, never guessed at:
|
||||
|
||||
F7 03 <addr:2> <count:2> <crc:2> a read REQUEST
|
||||
F7 03 <2*count> <data...> <crc:2> its response, matched to the request
|
||||
immediately preceding it
|
||||
F7 10 05 6E 00 02 04 <data:4> <crc:2> a write of the two EMS registers
|
||||
F7 10 05 6E 00 02 <crc:2> the inverter's echo of that write
|
||||
|
||||
⚠️ THE SETPOINT IS A MODE AND A MAGNITUDE, NOT A SIGNED NUMBER, and getting
|
||||
that backwards is the one way this decoder could quietly invert the whole
|
||||
episode. `0x056E` is the EMS mode and `0x056F` the magnitude in watts,
|
||||
unsigned. Mode 2 is charge and mode 3 is discharge, and that is READ OFF THIS
|
||||
CAPTURE rather than off a datasheet: in the committed window, every
|
||||
`mode 2, 2170 W` write is followed within one 10 s publish by an 0x0518
|
||||
reading near **-2287 W**, and `mode 3, 1060 W` by a POSITIVE-side idle. The
|
||||
add-on's own sign convention (`Inverter.standing_w()`, + = discharge) is
|
||||
therefore `+magnitude` for mode 3 and `-magnitude` for mode 2, and
|
||||
`test_freshness.py` part 10a grades that correspondence on the capture itself
|
||||
rather than trusting this paragraph.
|
||||
|
||||
WHAT THIS DECODER CANNOT SEE, largest hole first (habit 13/16):
|
||||
|
||||
* **THE GRID METER.** It is not on this bus - it is a P1 telegram on a
|
||||
different wire - so nothing here says what the site was importing or
|
||||
exporting. The 2.4 kW export during the episode is from the operator's
|
||||
Home Assistant history and is NOT re-derivable from this file.
|
||||
* **WHAT THE ADD-ON BELIEVED.** The capture is the wire. It shows the
|
||||
commands that arrived and the readings that were answered; it does not show
|
||||
`Inverter.batt`, `inputs_ok`, or any log line. Everything about the add-on's
|
||||
internal state in this episode is INFERRED by replaying the shipped modules
|
||||
against these series, which is what part 10 does.
|
||||
* **DATE AND YEAR.** The capture tool stamps a time of day and nothing else.
|
||||
The date is supplied by the caller and is recorded in the scenario's
|
||||
`manifest.json` beside the sha256 of the file it came from.
|
||||
* **A FRAME THE CAPTURE TOOL SCORED BAD.** Lines whose CRC verdict is not
|
||||
`ok` are counted and dropped. In the committed window there are none.
|
||||
* **ANY OTHER REGISTER.** 0x0511 (a two-register read this master issues) is
|
||||
recognised as a read and its values are returned untouched under its own
|
||||
address; nothing here claims to know what it means.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
|
||||
# The registers this file will name. Anything else is returned by raw address.
|
||||
REG_SETPOINT_MODE = 0x056E # written as a pair with 0x056F
|
||||
REG_BATT_W = 0x0518 # S16, watts, + = discharge (SAFETY-37)
|
||||
REG_SOC_PCT = 0x050E # U16, percent
|
||||
MODE_CHARGE, MODE_DISCHARGE = 2, 3
|
||||
|
||||
_LINE = re.compile(r"^(\w+)\|(\d\d):(\d\d):(\d\d(?:\.\d+)?)\s+(\S+)\s+(.*)$")
|
||||
|
||||
|
||||
def signed_setpoint_w(mode: int, magnitude: int) -> float:
|
||||
"""The EMS pair as `Inverter.standing_w()` spells it: + = discharge.
|
||||
|
||||
⚠️ AN UNKNOWN MODE RAISES rather than defaulting. A default here would turn
|
||||
a firmware that started using a third mode into a silently mis-signed
|
||||
series, which is the failure this whole scenario is about one layer down.
|
||||
"""
|
||||
if mode == MODE_DISCHARGE:
|
||||
return float(magnitude)
|
||||
if mode == MODE_CHARGE:
|
||||
return -float(magnitude)
|
||||
raise ValueError(f"unknown EMS mode {mode} at 0x{REG_SETPOINT_MODE:04X}; "
|
||||
f"this decoder knows {MODE_CHARGE} (charge) and "
|
||||
f"{MODE_DISCHARGE} (discharge) only")
|
||||
|
||||
|
||||
def _s16(v: int) -> int:
|
||||
return v - 65536 if v >= 32768 else v
|
||||
|
||||
|
||||
def decode(path):
|
||||
"""Decode one capture file. Returns `(events, stats)`.
|
||||
|
||||
`events` is a list of `(t, kind, value)` in capture order, where `t` is
|
||||
SECONDS SINCE LOCAL MIDNIGHT (the capture carries no date) and `kind` is
|
||||
one of `"setpoint_w"`, `"batt_w"`, `"soc_pct"`, or `"reg_0xNNNN"`.
|
||||
|
||||
`stats` counts every line by what became of it, so a caller can assert that
|
||||
the frames it cares about were actually present rather than that the parse
|
||||
produced no error.
|
||||
"""
|
||||
opener = gzip.open if str(path).endswith(".gz") else io.open
|
||||
events = []
|
||||
stats = {"lines": 0, "unparsed": 0, "bad_crc": 0, "requests": 0,
|
||||
"responses": 0, "writes": 0, "write_echoes": 0, "unknown": 0}
|
||||
pending = None # the read request awaiting its response
|
||||
with opener(path, "rt") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
stats["lines"] += 1
|
||||
m = _LINE.match(line)
|
||||
if not m:
|
||||
stats["unparsed"] += 1
|
||||
continue
|
||||
_bus, hh, mm, ss, verdict, hexs = m.groups()
|
||||
if verdict != "ok":
|
||||
stats["bad_crc"] += 1
|
||||
continue
|
||||
t = int(hh) * 3600 + int(mm) * 60 + float(ss)
|
||||
try:
|
||||
b = [int(x, 16) for x in hexs.split()]
|
||||
except ValueError:
|
||||
stats["unparsed"] += 1
|
||||
continue
|
||||
# --- a read request: remember it so its response can be addressed
|
||||
if len(b) == 8 and b[1] == 0x03:
|
||||
pending = ((b[2] << 8) | b[3], (b[4] << 8) | b[5])
|
||||
stats["requests"] += 1
|
||||
continue
|
||||
# --- a read response, addressed by the request before it
|
||||
if (b[1] == 0x03 and pending is not None and len(b) >= 5
|
||||
and b[2] == 2 * pending[1] and len(b) == b[2] + 5):
|
||||
data = b[3:3 + b[2]]
|
||||
regs = [(data[i] << 8) | data[i + 1]
|
||||
for i in range(0, len(data), 2)]
|
||||
addr = pending[0]
|
||||
pending = None
|
||||
stats["responses"] += 1
|
||||
if addr == REG_BATT_W:
|
||||
events.append((t, "batt_w", float(_s16(regs[0]))))
|
||||
elif addr == REG_SOC_PCT:
|
||||
events.append((t, "soc_pct", float(regs[0])))
|
||||
else:
|
||||
events.append((t, f"reg_0x{addr:04X}", tuple(regs)))
|
||||
continue
|
||||
# --- a write of the EMS pair
|
||||
if (len(b) > 8 and b[1] == 0x10 and b[2] == 0x05 and b[3] == 0x6E
|
||||
and b[6] == 4):
|
||||
data = b[7:11]
|
||||
mode = (data[0] << 8) | data[1]
|
||||
mag = (data[2] << 8) | data[3]
|
||||
events.append((t, "setpoint_w", signed_setpoint_w(mode, mag)))
|
||||
stats["writes"] += 1
|
||||
continue
|
||||
if len(b) == 8 and b[1] == 0x10:
|
||||
stats["write_echoes"] += 1
|
||||
continue
|
||||
stats["unknown"] += 1
|
||||
return events, stats
|
||||
|
||||
|
||||
def series(events, kind):
|
||||
"""`[(t, value)]` for one kind, in capture order."""
|
||||
return [(t, v) for t, k, v in events if k == kind]
|
||||
|
||||
|
||||
def held_at(rows, t):
|
||||
"""The last value at or before `t`, or None before the first row.
|
||||
|
||||
This is how the add-on sees an entity: Home Assistant answers with whatever
|
||||
the publisher last put there, and a value that has not been re-published is
|
||||
still the value. See `freshness.py` on why that is the whole problem.
|
||||
"""
|
||||
out = None
|
||||
for ts, v in rows:
|
||||
if ts > t:
|
||||
break
|
||||
out = v
|
||||
return out
|
||||
|
||||
|
||||
def _hms(t):
|
||||
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:06.3f}"
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 2:
|
||||
print(__doc__.splitlines()[2].strip())
|
||||
return 2
|
||||
events, stats = decode(argv[1])
|
||||
print(f"== {argv[1]}")
|
||||
for k, v in stats.items():
|
||||
print(f" {k:14} {v}")
|
||||
for kind in ("setpoint_w", "batt_w", "soc_pct"):
|
||||
rows = series(events, kind)
|
||||
if not rows:
|
||||
continue
|
||||
changes = [r for i, r in enumerate(rows)
|
||||
if i == 0 or r[1] != rows[i - 1][1]]
|
||||
print(f"== {kind}: {len(rows)} frames, {len(changes)} changes, "
|
||||
f"{_hms(rows[0][0])} .. {_hms(rows[-1][0])}")
|
||||
for t, v in changes:
|
||||
print(f" {_hms(t)} {v:+10.1f}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,649 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-48's evidence: the live install's OWN recorded disturbances, replayed
|
||||
through the shipped control law with and without the card.
|
||||
|
||||
python3 tools/replay_recorded_loop.py # the A/B table
|
||||
python3 tools/replay_recorded_loop.py --sensitivity # ...and does it survive
|
||||
# a different plant?
|
||||
python3 tools/replay_recorded_loop.py --export --days 16 --token $HASS_TOKEN
|
||||
|
||||
WHY A REPLAY AND NOT A STEP RESPONSE
|
||||
====================================
|
||||
`test_control.py`'s SAFETY-39 section drives a family of synthetic export steps
|
||||
through a first-order plant, and it is a good instrument for the question it
|
||||
asks. It cannot answer this one. The defect this card is about is a MISSING
|
||||
CASE - the integrator outrunning the command whenever `|error| > slew_w / gain`
|
||||
- and whether that case matters is a question about how often the house
|
||||
actually produces such an error, how long it stands, and what else is happening
|
||||
when it does. A step family answers "what would happen if"; only the recording
|
||||
answers "how much did this cost".
|
||||
|
||||
WHAT IS REAL IN HERE
|
||||
====================
|
||||
* THE DISTURBANCE. `D = grid + batt` at every recorded meter instant: the
|
||||
house's own net demand with THIS loop's battery power taken back out. It is
|
||||
exogenous - the house does not know what the battery did - which is what
|
||||
makes it valid to drive a counterfactual with, for a run of any length.
|
||||
* THE TICK. The replay steps on the METER's own recorded sample instants, not
|
||||
on a constant. Median 5.0 s over the window. ⚠️ `control.LOOP_TICK_S` says
|
||||
2.0 s; that was measured on 2026-09-05 and is NOT what this install has been
|
||||
running since. The figure matters to `SLEW_RAIL_FRAC`, and it is wrong in
|
||||
the SAFE direction (a slower meter makes validate.py rule 10 conservative).
|
||||
* THE CONTROL LAW. `app/control.py`, imported out of this 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 - so it is release/1.0's
|
||||
behaviour derived from the shipped bytes, never a transcription. The
|
||||
substitution anchor must occur exactly once or this refuses to run.
|
||||
|
||||
MODELLED, AND THEREFORE NOT EVIDENCE ABOUT THE THING MODELLED
|
||||
=============================================================
|
||||
* THE INVERTER. A pure delay + first-order lag + affine gain, FITTED on this
|
||||
same recording at SoC above the floor. Its residual is printed on every run
|
||||
and is large (~375 W RMS): the replay tracks the meter WORSE than the real
|
||||
install does, so the absolute export figures below are pessimistic by about
|
||||
a third against the recording. ⚠️ THE DELTA IS WHAT THIS TOOL IS FOR - both
|
||||
laws run against the same plant, on the same ticks, from the same seed.
|
||||
* THE PACK. SoC integrated from the simulated battery power off the recorded
|
||||
SoC, discharge refused at or below the floor, charge refused at 100 %.
|
||||
* EVERYTHING ELSE THE ARBITER DOES. The recorded setpoint is the ARBITER's
|
||||
output - the loop's claim after the peak guard, the SoC clamps, maintenance
|
||||
and arbitrage have had their say. This replay runs `compute()` ALONE. That
|
||||
is the largest single reason the fidelity section below is not tighter, and
|
||||
it is why no absolute figure here should be quoted as "what the install
|
||||
did"; the recording itself is that.
|
||||
|
||||
⚠️ WHAT THIS CANNOT SHOW, SAID BEFORE THE RESULTS RATHER THAN AFTER THEM
|
||||
=======================================================================
|
||||
* IT CANNOT SHOW THAT THE FIX IS SAFE ON THE REAL PLANT. Nothing here drives
|
||||
an inverter. The rig and a live run are what answer that, and SAFETY-47
|
||||
records why a shadow run cannot.
|
||||
* IT CANNOT SEPARATE THE FIX FROM THE PLANT MODEL ON ONE RUN. `--sensitivity`
|
||||
re-runs the whole table on five other plants and tunings; the ranking is the
|
||||
claim, not the watt figures.
|
||||
* THE BILLED QUANTITY IS A MAXIMUM OVER ~1500 QUARTERS, so it is a tail
|
||||
statistic and one quarter decides it. The per-quarter MEAN delta is printed
|
||||
beside it for exactly that reason.
|
||||
"""
|
||||
import argparse
|
||||
import bisect
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ADDON = HERE.parent
|
||||
sys.path.insert(0, str(ADDON))
|
||||
|
||||
SCENARIO = ADDON / "scenarios" / "loop-2026-09-02_2026-09-17"
|
||||
DEFAULT_URL = "http://192.168.2.158:8123"
|
||||
TZNAME = "Europe/Brussels"
|
||||
|
||||
# The entities, and what each is for. ⚠️ `setpoint` is NOT an input to the
|
||||
# replay: it is the RECORDING the replay is graded against in §fidelity, and it
|
||||
# is what `slew_w` and `max_w` are read off rather than assumed.
|
||||
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",
|
||||
}
|
||||
|
||||
# The live install's shape. `max_w` and `slew_w` are READ OFF THE RECORDING by
|
||||
# `--export` and re-derived on every run below; `gain` and `target_grid_w` are
|
||||
# the options and are not observable in the trace.
|
||||
LIVE = dict(gain=0.6, max_w=5000.0, slew_w=400.0, target_grid_w=-10.0)
|
||||
SOC_FLOOR = 10.0
|
||||
CAP_WH = 17379.0 # measured: -dE/dSoC over 1604 segments of this record
|
||||
GRID_HOLD_S = 30.0 # `meter_max_age_s`: past this a held meter is no reading
|
||||
TICK_CAP_S = 60.0 # a longer gap is an outage, not a cycle
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the recording
|
||||
|
||||
|
||||
def _rows(key):
|
||||
out = []
|
||||
for fn in sorted(os.listdir(SCENARIO)):
|
||||
if not fn.endswith(f"-{key}.jsonl.gz"):
|
||||
continue
|
||||
with gzip.open(SCENARIO / fn, "rb") as fh:
|
||||
for line in fh:
|
||||
r = json.loads(line)
|
||||
out.append((datetime.fromisoformat(r["t"]).timestamp(), r["v"]))
|
||||
out.sort(key=lambda r: r[0])
|
||||
ded = []
|
||||
for t, v in out:
|
||||
if ded and abs(ded[-1][0] - t) < 1e-9:
|
||||
ded[-1] = (t, v) # the history endpoint's day-boundary repeat
|
||||
continue
|
||||
ded.append((t, v))
|
||||
return ded
|
||||
|
||||
|
||||
def _steps(key):
|
||||
"""[(t, float|None)] - None OPENS A HOLE and nothing is held across it.
|
||||
|
||||
⚠️ A LONG SILENCE IS NOT A HOLE. Home Assistant's recorder stores a row only
|
||||
when a state CHANGES, so eleven hours of `setpoint` silence means the
|
||||
command held that value for eleven hours - which is exactly what this site
|
||||
does overnight at its SoC floor. What IS a hole is a row whose state is
|
||||
`unavailable`/`unknown`, and `grid` additionally stops being believed after
|
||||
GRID_HOLD_S because that is the age the shipped controller stops believing
|
||||
it at.
|
||||
"""
|
||||
out = []
|
||||
for t, s in _rows(key):
|
||||
try:
|
||||
out.append((t, float(s)))
|
||||
except (TypeError, ValueError):
|
||||
out.append((t, None))
|
||||
return out
|
||||
|
||||
|
||||
def stream():
|
||||
"""[(t, grid, batt, setpoint, soc)] at every recorded METER instant."""
|
||||
g = [(t, v) for t, v in _steps("grid") if v is not None]
|
||||
others = {}
|
||||
for k in ("batt", "setpoint", "soc"):
|
||||
s = _steps(k)
|
||||
others[k] = ([r[0] for r in s], [r[1] for r in s])
|
||||
prev_t = None
|
||||
out = []
|
||||
for t, v in g:
|
||||
if prev_t is not None and t - prev_t > GRID_HOLD_S:
|
||||
prev_t = t
|
||||
continue
|
||||
prev_t = t
|
||||
row = [t, v]
|
||||
for k in ("batt", "setpoint", "soc"):
|
||||
tt, vv = others[k]
|
||||
j = bisect.bisect_right(tt, t) - 1
|
||||
row.append(None if j < 0 else vv[j])
|
||||
if any(x is None for x in row):
|
||||
continue
|
||||
out.append(tuple(row))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the plant, fitted on this same recording
|
||||
|
||||
|
||||
def _lsq(xs, ys):
|
||||
n = len(xs)
|
||||
mx, my = sum(xs) / n, sum(ys) / n
|
||||
sxy = sum((a - mx) * (b - my) for a, b in zip(xs, ys))
|
||||
sxx = sum((a - mx) ** 2 for a in xs)
|
||||
syy = sum((b - my) ** 2 for b in ys)
|
||||
return sxy / sxx, my - (sxy / sxx) * mx, sxy / math.sqrt(sxx * syy)
|
||||
|
||||
|
||||
def fit_plant(rows, stride=1):
|
||||
"""(lag_s, tau_s, gain, offset, rms_W), fitted where the pack can MOVE.
|
||||
|
||||
⚠️ THE SoC FILTER IS THE WHOLE FIT. 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, and a fit taken over all ticks reads
|
||||
`batt = 0.50 x 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 x cmd - 28 W, r = 0.977`. The floor is the PACK and is modelled
|
||||
separately.
|
||||
"""
|
||||
t = [r[0] for r in rows]
|
||||
ba = [r[2] for r in rows]
|
||||
sp = [r[3] for r in rows]
|
||||
live = [r[4] > SOC_FLOOR + 2 for r in rows]
|
||||
best = None
|
||||
for lag in (0.0, 2.5, 5.0, 7.5, 10.0, 12.5):
|
||||
j, u = 0, []
|
||||
for i, ti in enumerate(t):
|
||||
while j + 1 < len(t) and t[j + 1] <= ti - lag:
|
||||
j += 1
|
||||
u.append(sp[j])
|
||||
idx = [i for i in range(0, len(t), stride)
|
||||
if live[i] and abs(u[i]) > 50]
|
||||
g, o, _ = _lsq([u[i] for i in idx], [ba[i] for i in idx])
|
||||
for tau in (0.5, 1.0, 2.0, 4.0, 8.0, 16.0):
|
||||
b, err, cnt = 0.0, 0.0, 0
|
||||
for i in range(1, len(t)):
|
||||
dt = min(t[i] - t[i - 1], TICK_CAP_S)
|
||||
b += (1.0 - math.exp(-dt / tau)) * (g * u[i] + o - b)
|
||||
if live[i]:
|
||||
err += (b - ba[i]) ** 2
|
||||
cnt += 1
|
||||
rms = math.sqrt(err / cnt)
|
||||
if best is None or rms < best[-1]:
|
||||
best = (lag, tau, g, o, rms)
|
||||
return best
|
||||
|
||||
|
||||
class Plant:
|
||||
def __init__(self, lag_s, tau_s, g, o, soc0):
|
||||
self.lag_s, self.tau_s, self.g, self.o = lag_s, tau_s, g, o
|
||||
self.b, self.soc, self.q = 0.0, soc0, []
|
||||
|
||||
def seed(self, t, b, cmd):
|
||||
self.b, self.q = b, [(t - self.lag_s - 1e-6, cmd)]
|
||||
|
||||
def step(self, t, dt, cmd):
|
||||
self.q.append((t, cmd))
|
||||
want = self.q[0][1]
|
||||
while len(self.q) > 1 and self.q[1][0] <= t - self.lag_s:
|
||||
self.q.pop(0)
|
||||
want = self.q[0][1]
|
||||
tgt = self.g * want + self.o
|
||||
if self.soc <= SOC_FLOOR:
|
||||
tgt = min(tgt, 0.0) # an empty pack cannot discharge
|
||||
if self.soc >= 100.0:
|
||||
tgt = max(tgt, 0.0) # a full one cannot charge
|
||||
self.b += (1.0 - math.exp(-dt / self.tau_s)) * (tgt - self.b)
|
||||
self.soc = max(0.0, min(100.0, self.soc - self.b * dt / 36.0 / CAP_WH))
|
||||
return self.b
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the two laws
|
||||
|
||||
|
||||
SAFETY_48_BLOCK_ANCHOR = " _lead_cap = SLEW_LEAD_STEPS * slew\n"
|
||||
|
||||
|
||||
def laws():
|
||||
"""(before, after) - the shipped module, and the same SOURCE without the card.
|
||||
|
||||
⚠️ NOT A TRANSCRIPTION, AND NOT A FLAG. `test_mqtt.py` part 8's technique:
|
||||
the shipped file's bytes with one named substitution, exec'd into a fresh
|
||||
namespace. An anchor that does not occur exactly once is a refusal, because
|
||||
a substitution that applied to nothing would be reported as "the card
|
||||
changes nothing" - MUTATION.md's survivor, wearing an instrument's clothes.
|
||||
"""
|
||||
import app.control as after
|
||||
src = (ADDON / "app" / "control.py").read_text()
|
||||
if src.count(SAFETY_48_BLOCK_ANCHOR) != 1:
|
||||
raise SystemExit(
|
||||
"replay_recorded_loop: app/control.py does not contain exactly one\n"
|
||||
f" {SAFETY_48_BLOCK_ANCHOR.strip()!r}\n"
|
||||
"NOTHING HAS BEEN MEASURED. This is a REFUSAL, not a result: with no\n"
|
||||
"anchor there is no `before` law to compare against, and a run that\n"
|
||||
"quietly compared the shipped law with ITSELF would report the card\n"
|
||||
"as changing nothing - which is the survivor MUTATION.md is about,\n"
|
||||
"wearing an instrument's clothes.")
|
||||
start = src.index(SAFETY_48_BLOCK_ANCHOR)
|
||||
end = src.index(" target = slewed\n", start)
|
||||
block = src[start:end]
|
||||
stripped = src.replace(block, "")
|
||||
if "SLEW_LEAD_STEPS * slew" in stripped:
|
||||
raise SystemExit("replay_recorded_loop: the block was not removed - "
|
||||
"NOTHING HAS BEEN MEASURED")
|
||||
before = type(sys)("control_before")
|
||||
exec(compile(stripped, "<release/1.0 law>", "exec"), before.__dict__)
|
||||
return before, after
|
||||
|
||||
|
||||
def replay(rows, law, fit, tuning):
|
||||
"""[(t, dt, grid, batt, cmd, i_w, lead, reason, soc)] over the whole record."""
|
||||
t0, g0, b0, sp0, soc0 = rows[0]
|
||||
pl = Plant(*fit[:4], soc0)
|
||||
pl.seed(t0, b0, sp0)
|
||||
prev, i_w, sat = sp0, sp0, 0
|
||||
out = []
|
||||
for i in range(1, len(rows)):
|
||||
t, gr, ba, sp, soc = rows[i]
|
||||
dt = t - rows[i - 1][0]
|
||||
if dt > TICK_CAP_S:
|
||||
# An outage. Re-seed from the recording rather than inventing a
|
||||
# cycle: nothing this loop did across it is knowable.
|
||||
pl = Plant(*fit[:4], soc)
|
||||
pl.seed(t, ba, sp)
|
||||
prev, i_w, sat = sp, sp, 0
|
||||
continue
|
||||
D = gr + ba # the exogenous half of the meter
|
||||
b = pl.step(t, dt, prev)
|
||||
grid = D - b
|
||||
d = law.compute(prev, grid, b, tuning, sat, i_w)
|
||||
out.append((t, dt, grid, b, d.target_w, d.i_w, d.i_w - prev, d.reason,
|
||||
pl.soc))
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the five reported quantities - ONE implementation, run on both traces
|
||||
|
||||
|
||||
def export_kwh(tr):
|
||||
return sum(max(0.0, -r[2]) * r[1] for r in tr) / 3.6e6
|
||||
|
||||
|
||||
def import_kwh(tr):
|
||||
return sum(max(0.0, r[2]) * r[1] for r in tr) / 3.6e6
|
||||
|
||||
|
||||
def reversals(tr, dead_w=50.0):
|
||||
n, last = 0, 0
|
||||
for r in tr:
|
||||
s = 1 if r[3] > dead_w else (-1 if r[3] < -dead_w else 0)
|
||||
if s and last and s != last:
|
||||
n += 1
|
||||
if s:
|
||||
last = s
|
||||
return n
|
||||
|
||||
|
||||
def wasted_kwh(tr, bin_s=60.0):
|
||||
"""Throughput that went round rather than anywhere, in FIXED clock bins.
|
||||
|
||||
Per bin, `sum |b|dt - |sum b dt|`. A bin spent charging, or discharging, or
|
||||
idle contributes ZERO; only a bin that does both contributes. ⚠️ The bins
|
||||
are wall-clock and identical for both laws on purpose: an episode-scoped
|
||||
definition would MOVE with the fix and compare two different questions.
|
||||
"""
|
||||
tot, cur, absE, net = 0.0, None, 0.0, 0.0
|
||||
for r in tr:
|
||||
k = math.floor(r[0] / bin_s)
|
||||
if cur is None:
|
||||
cur = k
|
||||
if k != cur:
|
||||
tot += abs(absE) - abs(net)
|
||||
cur, absE, net = k, 0.0, 0.0
|
||||
absE += abs(r[3]) * r[1]
|
||||
net += r[3] * r[1]
|
||||
return (tot + abs(absE) - abs(net)) / 3.6e6
|
||||
|
||||
|
||||
def quarters(tr):
|
||||
"""{quarter -> mean OFFTAKE in W}. The billed quantity.
|
||||
|
||||
Calendar quarter-hours, the average of NET power, floored at 0 - a quarter
|
||||
that nets to export is not offtake and is not billed. A quarter with less
|
||||
than 80 % coverage is not a quarter and is dropped.
|
||||
"""
|
||||
acc = {}
|
||||
for r in tr:
|
||||
k = math.floor(r[0] / 900.0)
|
||||
e, s = acc.get(k, (0.0, 0.0))
|
||||
acc[k] = (e + r[2] * r[1], s + r[1])
|
||||
return {k: max(0.0, e / s) for k, (e, s) in acc.items() if s > 720.0}
|
||||
|
||||
|
||||
def recoveries(tr, bias, onset_w, cap_s=900.0):
|
||||
"""Seconds from the error leaving `onset_w` to it back inside the deadband.
|
||||
|
||||
⚠️ AN EXCURSION THE PACK CANNOT ANSWER IS NOT A RECOVERY TIME. This install
|
||||
spends 36 % of its ticks at the SoC floor with the command standing near
|
||||
2 kW and the battery delivering nothing; the error there is not the loop's
|
||||
to clear and every law scores the same non-answer. Leaving those in gave a
|
||||
p99 of eight hours. An excursion counts only if the pack had headroom in the
|
||||
needed direction for its whole length, and one still standing after `cap_s`
|
||||
is counted as UNRECOVERED rather than as a large number.
|
||||
"""
|
||||
out, unrec, t_on, ok = [], 0, None, True
|
||||
for r in tr:
|
||||
err = r[2] - bias
|
||||
head = (r[8] > SOC_FLOOR + 1) if err > 0 else (r[8] < 99.0)
|
||||
if t_on is None:
|
||||
if abs(err) > onset_w:
|
||||
t_on, ok = r[0], head
|
||||
else:
|
||||
ok = ok and head
|
||||
if abs(err) < 15.0:
|
||||
if ok:
|
||||
out.append(r[0] - t_on)
|
||||
t_on = None
|
||||
elif r[0] - t_on > cap_s:
|
||||
unrec += 1 if ok else 0
|
||||
t_on = None
|
||||
return sorted(out), unrec
|
||||
|
||||
|
||||
def pct(xs, p):
|
||||
return xs[min(len(xs) - 1, int(round(p / 100.0 * (len(xs) - 1))))] if xs else float("nan")
|
||||
|
||||
|
||||
def table(tr, tuning):
|
||||
rec, unrec = recoveries(tr, tuning.target_grid_w, tuning.slew_w / tuning.gain)
|
||||
leads = sorted(abs(r[6]) for r in tr)
|
||||
q = quarters(tr)
|
||||
return dict(export_kwh=export_kwh(tr), import_kwh=import_kwh(tr),
|
||||
reversals=reversals(tr), wasted_kwh=wasted_kwh(tr),
|
||||
worst_quarter_w=max(q.values()), rec_n=len(rec),
|
||||
rec_med=pct(rec, 50), rec_p99=pct(rec, 99),
|
||||
rec_max=rec[-1] if rec else float("nan"),
|
||||
rec_unrecovered=unrec, lead_p99=pct(leads, 99),
|
||||
lead_max=leads[-1], slew_ticks=sum(1 for r in tr if r[7] == "slew-limited"),
|
||||
ticks=len(tr), _q=q)
|
||||
|
||||
|
||||
ROWS = [("export_kwh", "export kWh", "the cost"),
|
||||
("import_kwh", "import kWh", ""),
|
||||
("reversals", "battery sign reversals", ""),
|
||||
("wasted_kwh", "wasted throughput kWh", ""),
|
||||
("worst_quarter_w", "worst 15-min offtake W", "⚠️ THE BILLED ONE"),
|
||||
("rec_n", "recovery events", ""),
|
||||
("rec_med", "recovery median s", ""),
|
||||
("rec_p99", "recovery p99 s", ""),
|
||||
("rec_max", "recovery max s", ""),
|
||||
("rec_unrecovered", "unrecovered (>900 s)", ""),
|
||||
("lead_p99", "|i_w - prev_w| p99 W", ""),
|
||||
("lead_max", "|i_w - prev_w| max W", ""),
|
||||
("slew_ticks", "slew-limited ticks", "")]
|
||||
|
||||
|
||||
def show(a, b, name_a="release/1.0", name_b="SAFETY-48"):
|
||||
print(f" {'':26} {name_a:>13} {name_b:>13} {'delta':>11}")
|
||||
for k, lab, note in ROWS:
|
||||
print(f" {lab:<26} {a[k]:13.2f} {b[k]:13.2f} {b[k] - a[k]:+11.2f}"
|
||||
+ (f" {note}" if note else ""))
|
||||
d = [b["_q"][k] - a["_q"][k] for k in a["_q"] if k in b["_q"]]
|
||||
print(f" {'mean quarter delta W':<26} {'':13} {'':13} "
|
||||
f"{sum(d) / len(d):+11.2f} over {len(d)} quarters")
|
||||
print(f" {'worst quarter delta W':<26} {'':13} {'':13} {max(d):+11.2f}")
|
||||
|
||||
|
||||
def fidelity(tr, rows):
|
||||
"""What the BEFORE run gets wrong against the recording it came from.
|
||||
|
||||
⚠️ PRINTED BEFORE THE A/B TABLE IS READ, NOT AFTER. This is the number that
|
||||
says how far any ABSOLUTE figure above may be trusted, and it is large.
|
||||
"""
|
||||
n = len(tr)
|
||||
rec_g = [rows[i + 1][1] for i in range(n)]
|
||||
rec_sp = [rows[i + 1][3] for i in range(n)]
|
||||
sim_g = [r[2] for r in tr]
|
||||
sim_c = [r[4] for r in tr]
|
||||
dt = [r[1] for r in tr]
|
||||
_, _, r_sp = _lsq(sim_c, rec_sp)
|
||||
_, _, r_g = _lsq(sim_g, rec_g)
|
||||
ex_sim = sum(max(0.0, -g) * d for g, d in zip(sim_g, dt)) / 3.6e6
|
||||
ex_rec = sum(max(0.0, -g) * d for g, d in zip(rec_g, dt)) / 3.6e6
|
||||
print(f" setpoint r={r_sp:.3f} mean {sum(sim_c) / n:+8.0f} W simulated "
|
||||
f"vs {sum(rec_sp) / n:+8.0f} W recorded")
|
||||
print(f" grid r={r_g:.3f} mean {sum(sim_g) / n:+8.0f} W simulated "
|
||||
f"vs {sum(rec_g) / n:+8.0f} W recorded")
|
||||
print(f" export {ex_sim:8.2f} kWh simulated vs {ex_rec:8.2f} kWh "
|
||||
f"recorded -> the model is {100 * (ex_sim / ex_rec - 1):+.0f} % "
|
||||
f"PESSIMISTIC about the loop's tracking")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# --export: the read-only capture
|
||||
|
||||
|
||||
def _get(url, token, timeout=180.0):
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
|
||||
last = None
|
||||
for attempt in range(1, 5):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"HTTP {resp.status}")
|
||||
return json.loads(resp.read().decode()), attempt
|
||||
except Exception as exc:
|
||||
last = f"{type(exc).__name__}: {exc}"
|
||||
if attempt < 4:
|
||||
time.sleep(3 * attempt)
|
||||
raise RuntimeError(last)
|
||||
|
||||
|
||||
def export(base, token, days, out_dir):
|
||||
"""One local day per request, per entity. Read-only: GET only, never a write.
|
||||
|
||||
⚠️ URL-ENCODE THE TIMESTAMPS. The history endpoint answers `Invalid
|
||||
end_time` otherwise, and it answers it with a 400 that reads like a bad
|
||||
entity id.
|
||||
"""
|
||||
from zoneinfo import ZoneInfo
|
||||
tz = ZoneInfo(TZNAME)
|
||||
out_dir = pathlib.Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
today = datetime.now(tz).date()
|
||||
man = {"generated": datetime.now(tz).isoformat(),
|
||||
"generator": "tools/replay_recorded_loop.py --export",
|
||||
"source": f"{base}/api/history/period", "timezone": TZNAME,
|
||||
"entities": ENTITIES,
|
||||
"format": '{"t": <ISO-8601 with offset>, "v": <state string>} per '
|
||||
'line, gzip, mtime 0',
|
||||
"days": []}
|
||||
rc = 0
|
||||
for d in range(days, 0, -1):
|
||||
day = today - timedelta(days=d)
|
||||
s = datetime(day.year, day.month, day.day, tzinfo=tz)
|
||||
e = s + timedelta(days=1)
|
||||
rec = {"date": str(day), "window": [s.isoformat(), e.isoformat()],
|
||||
"files": {}}
|
||||
for key, ent in ENTITIES.items():
|
||||
q = urllib.parse.urlencode({"filter_entity_id": ent,
|
||||
"end_time": e.isoformat(),
|
||||
"minimal_response": ""})
|
||||
url = (f"{base}/api/history/period/"
|
||||
f"{urllib.parse.quote(s.isoformat())}?{q}")
|
||||
try:
|
||||
doc, attempts = _get(url, token)
|
||||
except Exception as exc:
|
||||
rec["files"][key] = {"read": "FAILED", "error": str(exc)}
|
||||
print(f" {day} {key:9s} FAILED {exc}", flush=True)
|
||||
rc = 1
|
||||
continue
|
||||
series = doc[0] if doc else []
|
||||
body = b""
|
||||
n_bad = 0
|
||||
for row in series:
|
||||
t = row.get("last_changed") or row.get("last_updated")
|
||||
v = row.get("state")
|
||||
if t is None or v is None:
|
||||
continue
|
||||
try:
|
||||
float(v)
|
||||
except (TypeError, ValueError):
|
||||
n_bad += 1
|
||||
body += (json.dumps({"t": t, "v": v}) + "\n").encode()
|
||||
fn = f"{day}-{key}.jsonl.gz"
|
||||
with gzip.GzipFile(out_dir / fn, "wb", mtime=0) as fh:
|
||||
fh.write(body)
|
||||
rec["files"][key] = {
|
||||
"read": "OK", "entity": ent, "samples": len(series),
|
||||
"non_numeric_states": n_bad, "attempts": attempts, "file": fn,
|
||||
"bytes_raw": len(body),
|
||||
"sha256_raw": hashlib.sha256(body).hexdigest()}
|
||||
print(f" {day} {key:9s} {len(series):7d} rows, {n_bad} non-numeric",
|
||||
flush=True)
|
||||
man["days"].append(rec)
|
||||
(out_dir / "manifest.json").write_text(json.dumps(man, indent=1) + "\n")
|
||||
return rc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--export", action="store_true")
|
||||
ap.add_argument("--days", type=int, default=16)
|
||||
ap.add_argument("--url", default=DEFAULT_URL)
|
||||
ap.add_argument("--token", default=os.environ.get("HASS_TOKEN"))
|
||||
ap.add_argument("--out-dir", default=str(SCENARIO))
|
||||
ap.add_argument("--sensitivity", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.export:
|
||||
if not args.token:
|
||||
raise SystemExit("--export needs --token or $HASS_TOKEN")
|
||||
return export(args.url, args.token, args.days, args.out_dir)
|
||||
|
||||
from app.control import Tuning
|
||||
t0 = time.time()
|
||||
rows = stream()
|
||||
span = (rows[-1][0] - rows[0][0]) / 86400.0
|
||||
dts = sorted(rows[i + 1][0] - rows[i][0] for i in range(len(rows) - 1))
|
||||
print(f"\nTHE RECORDING: {len(rows)} meter instants over {span:.2f} days, "
|
||||
f"{SCENARIO.name}")
|
||||
print(f" tick p5 {pct(dts, 5):.2f} s median {pct(dts, 50):.2f} s "
|
||||
f"p95 {pct(dts, 95):.2f} s (control.LOOP_TICK_S says 2.0)")
|
||||
|
||||
# ⚠️ READ OFF THE RECORDING, NOT ASSUMED. `max_w` is the setpoint's own
|
||||
# extreme and `slew_w` is the step the recorded command takes over and over.
|
||||
sp = [r[3] for r in rows]
|
||||
steps = {}
|
||||
for i in range(1, len(rows)):
|
||||
if rows[i][0] - rows[i - 1][0] < 8.0:
|
||||
steps[round(abs(sp[i] - sp[i - 1]))] = \
|
||||
steps.get(round(abs(sp[i] - sp[i - 1])), 0) + 1
|
||||
top = max((c, w) for w, c in steps.items() if w > 100)
|
||||
print(f" the recorded command: extremes {min(sp):+.0f} / {max(sp):+.0f} W"
|
||||
f" -> max_w {LIVE['max_w']:.0f}; most common step above 100 W is "
|
||||
f"{top[1]:.0f} W, {top[0]} times -> slew_w {LIVE['slew_w']:.0f}")
|
||||
|
||||
fit = fit_plant(rows, stride=7)
|
||||
print(f" the fitted inverter: lag {fit[0]:.1f} s, tau {fit[1]:.1f} s, "
|
||||
f"batt = {fit[2]:.4f} x cmd {fit[3]:+.1f} W, residual {fit[4]:.0f} W "
|
||||
f"RMS above the SoC floor")
|
||||
|
||||
before, after = laws()
|
||||
T = Tuning(**LIVE)
|
||||
tr_b = replay(rows, before, fit, T)
|
||||
tr_a = replay(rows, after, fit, T)
|
||||
tab_b, tab_a = table(tr_b, T), table(tr_a, T)
|
||||
|
||||
print("\nFIDELITY - the BEFORE law replayed against the recording it came from")
|
||||
fidelity(tr_b, rows)
|
||||
|
||||
print("\nTHE A/B, same ticks, same disturbance, same plant, same seed")
|
||||
show(tab_b, tab_a)
|
||||
|
||||
if args.sensitivity:
|
||||
print("\nSENSITIVITY - the ranking, not the watts")
|
||||
variants = [
|
||||
("plant lag 5.0 s", (5.0, fit[1], fit[2], fit[3], 0.0), LIVE),
|
||||
("plant lag 10.0 s", (10.0, fit[1], fit[2], fit[3], 0.0), LIVE),
|
||||
("plant tau 8 s", (fit[0], 8.0, fit[2], fit[3], 0.0), LIVE),
|
||||
("gain 1.0135, offset -26", (fit[0], fit[1], 1.0135, -26.0, 0.0), LIVE),
|
||||
("slew_w 555.6 (the rail)", fit, dict(LIVE, slew_w=555.6)),
|
||||
("gain 0.3", fit, dict(LIVE, gain=0.3)),
|
||||
]
|
||||
print(f" {'variant':<26}{'export kWh':>22}{'worst quarter W':>26}"
|
||||
f"{'mean quarter dW':>18}")
|
||||
for name, f2, tune in variants:
|
||||
T2 = Tuning(**tune)
|
||||
b = table(replay(rows, before, f2, T2), T2)
|
||||
a = table(replay(rows, after, f2, T2), T2)
|
||||
d = [a["_q"][k] - b["_q"][k] for k in b["_q"] if k in a["_q"]]
|
||||
print(f" {name:<26}{b['export_kwh']:9.1f} ->{a['export_kwh']:9.1f}"
|
||||
f"{b['worst_quarter_w']:13.0f} ->{a['worst_quarter_w']:9.0f}"
|
||||
f"{sum(d) / len(d):+18.2f}")
|
||||
|
||||
print(f"\n ({time.time() - t0:.0f} s)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,300 @@
|
||||
"""SAFETY-56's behaviour-invariance instrument. `tools/safety56_behaviour_trace.py`
|
||||
|
||||
SAFETY-56 rewrote three operator-facing sentences and nothing else. This is what
|
||||
says so: it drives a fixed stimulus set through the SHIPPED modules and prints
|
||||
two documents -
|
||||
|
||||
`behaviour` every verdict, every `reading()`, every `releases`, every
|
||||
`inputs_ok` and every readiness row's (ok, warn). NO SENTENCE
|
||||
REACHES IT, so a tree whose only change is wording must produce
|
||||
this document byte for byte.
|
||||
`sentences` the four operator-facing surfaces, from the same stimulus.
|
||||
|
||||
Run it in two trees and compare:
|
||||
|
||||
python3 tools/safety56_behaviour_trace.py > /tmp/after.json
|
||||
python3 tools/safety56_behaviour_trace.py --compare /tmp/before.json /tmp/after.json
|
||||
|
||||
⚠️ THE CONTROL IS PART OF THE TOOL AND NOT A SEPARATE STEP, WHICH IS MUTATION.md
|
||||
HABIT 4 IN AN INSTRUMENT. `--compare` REFUSES to report "behaviour unchanged"
|
||||
unless the `sentences` halves DIFFER. Without that, comparing a tree with itself
|
||||
- which this card's first run did, by archiving a branch whose edits were not
|
||||
committed yet - gives a perfect result that means nothing. An identity is
|
||||
evidence only when the two sides could have differed, and ARCH-02's rehearsal
|
||||
harness is the worked example of what happens when nobody checks.
|
||||
|
||||
⚠️ IT GRADES BEHAVIOUR, NEVER WORDING. Two trees that agree here can still print
|
||||
different sentences - that is the point. `test_freshness.py` part 11 grades the
|
||||
sentences themselves.
|
||||
|
||||
⚠️ WHAT IT CANNOT SEE, with shapes (habit 13): anything outside `freshness.py`'s
|
||||
verdict, `_read_unit()`'s gating and `checks()`'s rows; a roster of more than
|
||||
one; the MQTT and ingress surfaces; and wall-clock behaviour, since every clock
|
||||
here is the caller's monotonic argument. It is a pin on the three things this
|
||||
card is forbidden to move, not a full behavioural equivalence.
|
||||
|
||||
⚠️ PURGE BEFORE USE on a tree you have just edited. `tools/mutation_run.sh`
|
||||
does, and MUTATION.md says why `python3 -B` alone does not.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent.parent # the add-on directory
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
BOUNDS = Freshness = Controller = DEFAULTS = None
|
||||
|
||||
|
||||
def _load():
|
||||
"""The add-on's modules, imported LAZILY so that `--compare` runs anywhere.
|
||||
|
||||
⚠️ NOT A STYLE CHOICE. `--compare` is the half a reviewer runs on a laptop,
|
||||
against two JSON files, in a directory that has no `app/` in it - and a
|
||||
top-level import would make the comparison impossible exactly where it is
|
||||
most useful.
|
||||
"""
|
||||
global BOUNDS, Freshness, Controller, DEFAULTS
|
||||
from app.freshness import BOUNDS, Freshness # noqa: E402,F401
|
||||
from app.main import Controller # noqa: E402,F401
|
||||
from app.store import DEFAULTS # noqa: E402,F401
|
||||
|
||||
A = 5000.0
|
||||
ONE = dict(meter_source="off", meter_entity="sensor.grid",
|
||||
setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||
batt_entity="sensor.batt_a", max_w=A, gain=0.6, slew_w=0,
|
||||
deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10,
|
||||
stale_input_s=15, inverters=[], bms_soc_floor=10)
|
||||
|
||||
|
||||
class MemStore:
|
||||
def __init__(self, d=None):
|
||||
self.data = dict(DEFAULTS, **(d or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
def set_time(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Hass:
|
||||
def __init__(self, numbers=None, limits=None):
|
||||
self.numbers = dict(numbers or {})
|
||||
self.limits_by = dict(limits or {})
|
||||
self.served = []
|
||||
self.writes = []
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
v = self.numbers.get(entity)
|
||||
self.served.append((entity, v))
|
||||
return None if v is None else (-v if invert else v)
|
||||
|
||||
async def limits(self, entity):
|
||||
return self.limits_by.get(entity, (None, None, None))
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append((entity, value))
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
return True
|
||||
|
||||
|
||||
def one_site(soc=50.0):
|
||||
hass = Hass({"sensor.grid": 0.0, "sensor.soc_a": soc,
|
||||
"sensor.batt_a": -1200.0},
|
||||
{"number.sp_a": (-A, A, 1.0)})
|
||||
c = Controller(dict(ONE), hass, MemStore(), Pub(), stop=asyncio.Event())
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -A, A
|
||||
return c, hass
|
||||
|
||||
|
||||
async def _world(name, soc, reg_of, cmd_of, n):
|
||||
c, hass = one_site(soc)
|
||||
seen = []
|
||||
for t in range(n):
|
||||
hass.numbers["sensor.batt_a"] = reg_of(t)
|
||||
c.inverters[0].last_write = cmd_of(t)
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
seen.append([c.inverters[0].batt, c.inputs_ok,
|
||||
c.inverters[0].batt_fresh.stale])
|
||||
return {"name": name, "reads": seen,
|
||||
"rows": [[r["ok"], r["warn"]] for r in c.checks()],
|
||||
"n_rows": len(c.checks()), "events": len(c.events),
|
||||
"inputs_bad": c.inputs_bad_since is not None,
|
||||
"releases": [i.batt_fresh.releases for i in c.inverters]}
|
||||
|
||||
|
||||
# The command and register schedules. Each of the conjunction's three terms is
|
||||
# exercised alone and with the others, and the last two schedules take a verdict
|
||||
# all the way through SAFETY-57's release and back into a re-trip.
|
||||
CMDS = [lambda t: 0.0, lambda t: 1300.0, lambda t: -1200.0,
|
||||
lambda t: 1300.0 if t >= 1 else -1200.0,
|
||||
lambda t: 249.0 if t >= 1 else 0.0,
|
||||
lambda t: 251.0 if t >= 1 else 0.0,
|
||||
lambda t: 1300.0 if t < 40 else 0.0,
|
||||
lambda t: 5.0 if t >= 1 else 1300.0,
|
||||
lambda t: (t % 7) * 200.0 - 600.0,
|
||||
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 45 else (
|
||||
0.0 if t < 90 else 1300.0),
|
||||
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 45 else 100.0]
|
||||
REGS = [lambda t: -3.0, lambda t: -1200.0, lambda t: -1200.0 + (t % 3),
|
||||
lambda t: None if t % 5 == 0 else -1200.0,
|
||||
lambda t: 0.0, lambda t: float((t * 37) % 11) - 5.0]
|
||||
|
||||
WORLDS = [
|
||||
("DEAD", 50.0, lambda t: -3.0, lambda t: 800.0 if t < 1 else 1600.0, 40),
|
||||
("REFUSED", 11.0, lambda t: -3.0, lambda t: 800.0 if t < 1 else 1600.0, 40),
|
||||
("ABSENT", 50.0, lambda t: None, lambda t: 0.0, 3),
|
||||
("HEALTHY", 50.0, lambda t: -1200.0 + (t % 3),
|
||||
lambda t: 1300.0 if t % 2 else -1200.0, 60),
|
||||
("RELEASE", 50.0, lambda t: -1200.0,
|
||||
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 40 else 0.0, 60),
|
||||
("RETRIP", 50.0, lambda t: -1200.0,
|
||||
lambda t: (-1200.0 if t < 1 else 1300.0) if t < 40 else
|
||||
(0.0 if t < 80 else 1300.0), 130),
|
||||
]
|
||||
|
||||
|
||||
def behaviour():
|
||||
"""Everything this card is forbidden to move. No sentence may enter it."""
|
||||
_load()
|
||||
logging.disable(logging.CRITICAL)
|
||||
out = {}
|
||||
|
||||
# 1. the committed live capture, read by read.
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_s56decode", HERE / "tools" / "decode_bus_capture.py")
|
||||
dec = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(dec)
|
||||
cap_file = (HERE / "scenarios" / "safety57-2026-09-21"
|
||||
/ "bus-a-2026-09-21-1030-1110.log.gz")
|
||||
events, _census = dec.decode(cap_file)
|
||||
sp, bt = dec.series(events, "setpoint_w"), dec.series(events, "batt_w")
|
||||
f = Freshness(BOUNDS["batt"])
|
||||
t = float(int(max(sp[0][0], bt[0][0])) + 1)
|
||||
t1 = int(min(sp[-1][0], bt[-1][0]))
|
||||
cap = []
|
||||
while t <= t1:
|
||||
f.submit(dec.held_at(bt, t), dec.held_at(sp, t), t)
|
||||
cap.append([t, f.stale, f.reading(), f.releases, f.held,
|
||||
f.rested_since, f.graded_from, f.standing_at_grade,
|
||||
f.released_at])
|
||||
t += 1.0
|
||||
out["capture_reads"] = len(cap)
|
||||
out["capture_trace"] = hashlib.sha256(
|
||||
json.dumps(cap, sort_keys=True).encode()).hexdigest()
|
||||
out["capture_edges"] = [r[:4] for i, r in enumerate(cap)
|
||||
if i == 0 or r[1] != cap[i - 1][1]]
|
||||
|
||||
# 2. the swept grid over the policy object.
|
||||
sweep = []
|
||||
for ci, cf in enumerate(CMDS):
|
||||
for ri, rf in enumerate(REGS):
|
||||
g = Freshness(BOUNDS["batt"])
|
||||
row = []
|
||||
for n in range(120):
|
||||
g.submit(rf(n), cf(n), float(n))
|
||||
row.append([g.stale, g.reading(), g.releases])
|
||||
sweep.append([ci, ri, row])
|
||||
out["sweep_cells"] = len(sweep)
|
||||
out["sweep_trace"] = hashlib.sha256(
|
||||
json.dumps(sweep, sort_keys=True).encode()).hexdigest()
|
||||
out["sweep_stale_reads"] = sum(1 for _c, _r, row in sweep
|
||||
for s, _v, _n in row if s)
|
||||
out["sweep_missing_reads"] = sum(1 for _c, _r, row in sweep
|
||||
for _s, v, _n in row if v is None)
|
||||
out["sweep_releases"] = sum(row[-1][2] for _c, _r, row in sweep)
|
||||
|
||||
# 3. the shipped read path and every readiness row's verdict.
|
||||
loop = asyncio.new_event_loop()
|
||||
out["worlds"] = [loop.run_until_complete(_world(*w)) for w in WORLDS]
|
||||
loop.close()
|
||||
return out
|
||||
|
||||
|
||||
def sentences():
|
||||
"""The four operator-facing surfaces, from the REFUSED world."""
|
||||
_load()
|
||||
recs = []
|
||||
|
||||
class _H(logging.Handler):
|
||||
def emit(self, record):
|
||||
recs.append(record.getMessage())
|
||||
|
||||
logging.disable(logging.NOTSET)
|
||||
log = logging.getLogger("goodwe")
|
||||
log.addHandler(_H())
|
||||
log.setLevel(logging.DEBUG)
|
||||
|
||||
async def go():
|
||||
c, hass = one_site(11.0)
|
||||
for t in range(40):
|
||||
hass.numbers["sensor.batt_a"] = -3.0
|
||||
c.inverters[0].last_write = 800.0 if t < 1 else 1600.0
|
||||
await c.read_inputs(now_mono=float(t))
|
||||
return c
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
c = loop.run_until_complete(go())
|
||||
loop.close()
|
||||
return {
|
||||
"reason": c.inverters[0].batt_fresh.reason,
|
||||
"log": [m for m in recs if "SAFETY-37" in m],
|
||||
"stale_note": c.inverters[0].stale_note(),
|
||||
"not_ready_row": [r["text"] for r in c.checks()
|
||||
if r["text"].startswith("battery power")],
|
||||
}
|
||||
|
||||
|
||||
def compare(a_path, b_path):
|
||||
a = json.loads(Path(a_path).read_text(encoding="utf-8"))
|
||||
b = json.loads(Path(b_path).read_text(encoding="utf-8"))
|
||||
beh_same = a["behaviour"] == b["behaviour"]
|
||||
sent_same = a["sentences"] == b["sentences"]
|
||||
ha = hashlib.sha256(json.dumps(a["behaviour"], sort_keys=True).encode())
|
||||
hb = hashlib.sha256(json.dumps(b["behaviour"], sort_keys=True).encode())
|
||||
print(f"behaviour A {ha.hexdigest()}")
|
||||
print(f"behaviour B {hb.hexdigest()}")
|
||||
if sent_same:
|
||||
# ⚠️ THE REFUSAL, AND IT IS THE POINT OF THE TOOL. Two runs whose
|
||||
# sentences agree are either the same tree twice or a card that changed
|
||||
# nothing an operator reads. Either way "behaviour unchanged" is not a
|
||||
# result, and reporting it would be an identity measured against itself.
|
||||
print("REFUSED: the `sentences` halves are IDENTICAL, so these two runs "
|
||||
"cannot be evidence that behaviour survived a WORDING change - "
|
||||
"they may be the same tree twice. Compare a tree against one "
|
||||
"whose sentences differ.")
|
||||
return 2
|
||||
if not beh_same:
|
||||
for k in sorted(set(a["behaviour"]) | set(b["behaviour"])):
|
||||
if a["behaviour"].get(k) != b["behaviour"].get(k):
|
||||
print(f"BEHAVIOUR MOVED at {k!r}")
|
||||
return 1
|
||||
print("OK: sentences differ, behaviour is byte-identical.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 4 and sys.argv[1] == "--compare":
|
||||
sys.exit(compare(sys.argv[2], sys.argv[3]))
|
||||
print(json.dumps({"behaviour": behaviour(), "sentences": sentences()},
|
||||
sort_keys=True, indent=1))
|
||||
@@ -220,6 +220,7 @@ def slew_table(ps, ct, tick_s, rail):
|
||||
f"faster than the")
|
||||
print(f" plant settles. {rail:.0f} W / ({ct.PLANT_SETTLE_S:.0f} s / "
|
||||
f"{tick_s:.0f} s per tick) = {frac * rail:.0f} W per tick.\n")
|
||||
overs = []
|
||||
for slew in (1000, 800, 700, 600, int(frac * rail), 500, 400, 300, 250):
|
||||
w = family(ps, ct, gain=0.6, slew_w=float(slew), rail_w=rail,
|
||||
tick_s=tick_s)
|
||||
@@ -227,13 +228,52 @@ def slew_table(ps, ct, tick_s, rail):
|
||||
" <- the 2026-09-05 live pair" if slew == 1000 else (
|
||||
" <- the live interim" if slew == 400 else ""))
|
||||
row(f"slew_w {slew}{tag}", w)
|
||||
overs.append(w["over"])
|
||||
print("\n READ IT THIS WAY. The >= 900 W step - the live symptom, which")
|
||||
print(" escalated 2 -> 1 -> 13 -> 43 per 30 min that morning - is gone from")
|
||||
print(" every run below 900 W of slew, and that is what the slew owns.")
|
||||
print(" The OVER-ABSORPTION does not move, and at the slowest slews it is")
|
||||
print(" slightly WORSE. The rig cannot pick the derived value out of that")
|
||||
print(" column, and it is not asked to: the CONSTRAINT picks it, and this")
|
||||
print(" table is what says what the constraint does and does not buy.")
|
||||
|
||||
# ⚠️ THE NARRATION BELOW IS DERIVED FROM THE COLUMN JUST PRINTED, NOT
|
||||
# WRITTEN ABOUT IT. SAFETY-39 wrote "the over-absorption does not move, and
|
||||
# at the slowest slews it is slightly WORSE" - true of release/1.0's law,
|
||||
# which is what it was measured against, and FALSE under SAFETY-48's lead
|
||||
# cap, where the same script on the same plant prints a monotonically
|
||||
# FALLING column. A transcript whose prose contradicts its own table is a
|
||||
# failing check (MUTATION.md habit 8), and a sentence that is right under
|
||||
# one law and wrong under another has to be computed rather than typed. So:
|
||||
# which law is imported is read off `control.py` itself, and whether the
|
||||
# column falls is read off the column.
|
||||
cap = getattr(ct, "SLEW_LEAD_STEPS", None)
|
||||
falling = all(b < a for a, b in zip(overs, overs[1:]))
|
||||
law = ("release/1.0's law - the integrator's lead over the command is "
|
||||
"UNBOUNDED" if cap is None else
|
||||
f"SAFETY-48's law - the integrator may lead the command by "
|
||||
f"{cap} slew step(s)")
|
||||
print(f"\n THE LAW THIS RAN AGAINST: {law}.")
|
||||
if cap is None and not falling:
|
||||
print(" The OVER-ABSORPTION does not move with the slew, and at the")
|
||||
print(" slowest slews it is slightly WORSE - `slew_w` bounds the")
|
||||
print(" OUTPUT while the integrator goes on winding, so a slower slew")
|
||||
print(" leaves it winding for longer. SAFETY-48 bounds that lead and")
|
||||
print(" INVERTS this column; see DECISIONS/SAFETY-48 §6.")
|
||||
elif cap is not None and falling:
|
||||
print(" The OVER-ABSORPTION FALLS MONOTONICALLY with the slew, which")
|
||||
print(" is the OPPOSITE of what SAFETY-39 measured on release/1.0")
|
||||
print(" (958/1107/1133/1058/987/1029/1150/1143/1081 W, flat and")
|
||||
print(" non-monotone). Bounding the integrator's lead is what changed")
|
||||
print(" it: the slew now owns a share of the over-absorption it did")
|
||||
print(" not own before. DECISIONS/SAFETY-48 §6 has both tables and an")
|
||||
print(" independent confirmation on test_control.py's own plant.")
|
||||
else:
|
||||
print(" ⚠️ NEITHER NARRATION FITS. The law above and the shape of the")
|
||||
print(f" column ({'falling' if falling else 'not falling'}) are the")
|
||||
print(" combination this script has no sentence for - which means the")
|
||||
print(" measurement has moved and DECISIONS/SAFETY-39 §3 and")
|
||||
print(" DECISIONS/SAFETY-48 §6 both need re-reading before this table")
|
||||
print(" is quoted anywhere.")
|
||||
print("\n EITHER WAY THE RIG DOES NOT PICK THE NUMBER, and it is not asked")
|
||||
print(" to: the CONSTRAINT picks it, and this table is what says what the")
|
||||
print(" constraint does and does not buy.")
|
||||
|
||||
|
||||
def gain_table(ps, ct, tick_s, rail):
|
||||
|
||||
Reference in New Issue
Block a user