Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0f7b30098 | ||
|
|
e158174bd6 | ||
|
|
653874d6ee |
@@ -1,125 +0,0 @@
|
||||
#!/bin/sh
|
||||
# CI-15: run the add-on's test_*.py files, in parallel, inside the CI container.
|
||||
# Called by the step "the add-on's own tests" of .gitea/workflows/checks.yml as
|
||||
# sh -eu /w/goodwe-addon/.gitea/ci/run-tests.sh
|
||||
# and runnable the same way from a clone (see TESTS_DIR below).
|
||||
#
|
||||
# WHY: the serial loop ran past the runner's 60-minute limit (SAFETY-125: 85 files,
|
||||
# 3 557 s of test time, run 1430 cut off at file 84). The host has 12 cores.
|
||||
#
|
||||
# SHAPE
|
||||
# 1. A parallel batch: JOBS files at a time (xargs -P), longest first.
|
||||
# 2. THEN the serial group, one at a time, after the batch has finished, so
|
||||
# each runs on a machine with nothing else of ours on it.
|
||||
# Every file writes to its own $RUN_DIR/<file>.out (there is no shared output
|
||||
# file) and prints its result as ONE block under a lock, so the PASS/FAIL
|
||||
# lines and the `CI: ` / tail -40 text of two files never interleave.
|
||||
#
|
||||
# THE FILE LIST IS STILL DERIVED FROM THE TREE (see checks.yml). The two typed
|
||||
# lists below only SORT or SET ASIDE files that exist; a new test_*.py that is
|
||||
# in neither list is run, in the parallel batch. A name that no longer exists
|
||||
# in a list is harmless. Nothing can make a file silently not run.
|
||||
#
|
||||
# SERIAL GROUP: files whose assertions depend on the wall clock, so CPU load
|
||||
# from siblings could flip them. Name and reason, one each:
|
||||
# test_loop.py drives the real run_control() against a real wall-clock
|
||||
# floor (~93 s on purpose); worst inter-cycle gap 3.0 s
|
||||
# against a 4.5 s bound (TEST-30, CI-06).
|
||||
# test_freeze.py part 3 runs the real sim/hwsim.py over a transport with a
|
||||
# 16 s window, a 10 s hold and an alarm measured at 11.0 s:
|
||||
# one poll of margin, and "failed 1 run in 3" on the rig
|
||||
# transport (TEST-30, CI-06).
|
||||
# test_safety87.py builds a controller (CH65) whose meter reading is stamped
|
||||
# on the REAL clock, and section 8 asks its status page,
|
||||
# which says "Not ready - Meter: no reading for N seconds,
|
||||
# limit 30 seconds" once N passes 30. Measured by CI-15: N is
|
||||
# about half the file's run time (N = 45 s in a 94 s run, 48 s
|
||||
# in a 103 s run). The file takes ~25 s idle and took 45 s in
|
||||
# CI's first parallel run, so N was ~20 s of the 30. It FAILED
|
||||
# alone under --cpus 0.15 (94 s) and in a loaded parallel run
|
||||
# (103 s). No test file is changed here, so it runs alone.
|
||||
# Add a file here only with a measured reason, and say it in this list.
|
||||
SERIAL="test_loop.py test_freeze.py test_safety87.py"
|
||||
#
|
||||
# LONGEST FIRST (SAFETY-125's CI run, job 2580; then a local parallel run, seconds:
|
||||
# modes_p4 758, safety126 634 (SAFETY-126, local, one core), safety125 508 on hotfix-0.19.2 only, modes_p3 364, safety119 328,
|
||||
# price_run_fixes 184, safety90 160, safety103 134, safety94 114, peakguard 113,
|
||||
# opt20 102, safety97 101, safety81 100). With JOBS workers pulling from one
|
||||
# queue, starting the longest files first keeps the tail short: the batch takes
|
||||
# about as long as the longest file. A sort order, not a selection.
|
||||
HEAVY="test_modes_p4.py test_safety126.py test_safety125.py test_modes_p3.py test_safety119.py test_price_run_fixes.py test_safety90.py test_safety103.py test_safety94.py test_peakguard.py test_opt20.py test_safety97.py test_safety81.py"
|
||||
#
|
||||
JOBS=${CI_JOBS:-6}
|
||||
cd "${TESTS_DIR:-/w/goodwe-addon/goodwe_controller}"
|
||||
|
||||
# one FILE: run it, keep its output in its own file, print its block whole.
|
||||
if [ "${1:-}" = "--one" ]; then
|
||||
f=$2
|
||||
o="$RUN_DIR/$f.out"
|
||||
start=$(date +%s)
|
||||
if python3 "$f" > "$o" 2>&1; then rc=0; else rc=1; fi
|
||||
secs=$(( $(date +%s) - start ))
|
||||
{
|
||||
if [ $rc = 0 ]; then
|
||||
echo "PASS $f (${secs}s)"
|
||||
grep "^CI: " "$o" || true
|
||||
else
|
||||
echo "FAIL $f (${secs}s)"
|
||||
tail -40 "$o"
|
||||
fi
|
||||
} > "$RUN_DIR/$f.blk"
|
||||
echo $rc > "$RUN_DIR/$f.rc"
|
||||
flock "$RUN_DIR/print.lock" cat "$RUN_DIR/$f.blk"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -- test_*.py
|
||||
if [ "$1" = "test_*.py" ]; then
|
||||
echo "NO test_*.py FOUND - the mount is empty or the layout moved. NOTHING has been checked."
|
||||
exit 1
|
||||
fi
|
||||
echo "$# test file(s) to run: $JOBS at a time, then the serial group"
|
||||
|
||||
RUN_DIR=$(mktemp -d /tmp/ci-tests.XXXXXX)
|
||||
export RUN_DIR
|
||||
: > "$RUN_DIR/par"
|
||||
: > "$RUN_DIR/ser"
|
||||
for f in $HEAVY; do
|
||||
case " $SERIAL " in *" $f "*) continue ;; esac
|
||||
if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/par"; fi
|
||||
done
|
||||
for f in "$@"; do
|
||||
case " $SERIAL $HEAVY " in *" $f "*) continue ;; esac
|
||||
echo "$f" >> "$RUN_DIR/par"
|
||||
done
|
||||
for f in $SERIAL; do
|
||||
if [ -f "$f" ]; then echo "$f" >> "$RUN_DIR/ser"; fi
|
||||
done
|
||||
echo "parallel: $(wc -l < "$RUN_DIR/par") file(s); serial: $(wc -l < "$RUN_DIR/ser") file(s)"
|
||||
|
||||
t0=$(date +%s)
|
||||
rc=0
|
||||
# `exit 0` in the worker: a failing FILE is recorded in its .rc, so a non-zero
|
||||
# from xargs itself means the machinery broke, which is a failure too.
|
||||
xargs -n 1 -P "$JOBS" sh -eu "$0" --one < "$RUN_DIR/par" || rc=1
|
||||
echo "parallel batch done in $(( $(date +%s) - t0 ))s; serial group next"
|
||||
while read -r f; do
|
||||
sh -eu "$0" --one "$f" || rc=1
|
||||
done < "$RUN_DIR/ser"
|
||||
|
||||
# Every file that was meant to run has a verdict, and every verdict is PASS. A
|
||||
# worker that died before writing its .rc leaves no file, which counts as a fail.
|
||||
ran=0
|
||||
bad=0
|
||||
for f in "$@"; do
|
||||
ran=$((ran + 1))
|
||||
if [ ! -f "$RUN_DIR/$f.rc" ]; then
|
||||
echo "FAIL $f (no result: the run never reported)"
|
||||
bad=$((bad + 1))
|
||||
elif [ "$(cat "$RUN_DIR/$f.rc")" != 0 ]; then
|
||||
bad=$((bad + 1))
|
||||
fi
|
||||
done
|
||||
echo "$ran file(s), $bad failed, $(( $(date +%s) - t0 ))s wall"
|
||||
if [ "$bad" = 0 ] && [ "$rc" = 0 ]; then exit 0; fi
|
||||
exit 1
|
||||
@@ -1,2 +0,0 @@
|
||||
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
|
||||
3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847
|
||||
@@ -3,35 +3,12 @@
|
||||
# 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 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
|
||||
# ⚠️ NO `paths:` FILTER, AND ITS ABSENCE IS LOAD-BEARING. Branch protection on
|
||||
# release/1.0 requires the context `checks / unit (pull_request)`. Gitea leaves a
|
||||
# required context it never receives PENDING forever, so the moment this workflow
|
||||
# learns to sit a PR out, that PR becomes unmergeable. Condition a STEP if you
|
||||
# must; never the workflow.
|
||||
#
|
||||
# ⚠️ 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
|
||||
@@ -46,116 +23,9 @@
|
||||
# that step; a green from this workflow is a claim about content that can land.
|
||||
name: checks
|
||||
|
||||
# ⚠️ CI-9: `pull_request:` TAKES NO `branches:` LIST, ON PURPOSE. It used to
|
||||
# read `branches: [release/1.0, master]`, which meant a STACKED pull request -
|
||||
# one whose base is another feature branch rather than a release branch - did
|
||||
# not match, and so was skipped, laundered to `success`, and mergeable untested
|
||||
# by the mechanism described at the top of this file.
|
||||
#
|
||||
# ⚠️ AND RETARGETING DID NOT REPAIR IT, WHICH IS WHAT MADE THIS P1. The skip is
|
||||
# a COMMIT STATUS ON THE HEAD SHA, not a property of the pull request, so moving
|
||||
# the base afterwards leaves it exactly where it was. Observed live on PR #93
|
||||
# (2026-09-19): retargeted TEL-10 -> release/1.0, and the 10:05:00 `skipped`
|
||||
# stayed attached to head a6af332 with the rollup still reading success. It was
|
||||
# caught by a human who forced a real execution by reopening the pull request
|
||||
# (run 799, 10:56). Reproduced deliberately on throwaway PR #94: retargeted onto
|
||||
# a branch carrying a byte-identical copy of release/1.0's protection rule, and
|
||||
# the single `skipped` status survived the retarget unchanged - no new run, no
|
||||
# new status - and still cleared the gate.
|
||||
#
|
||||
# ⚠️ WHY NOT FIX THIS AT THE GATE INSTEAD, which would be the better place. It
|
||||
# cannot be done. Gitea's branch protection cannot express "this context must be
|
||||
# SUCCESS" as opposed to "this context must be PRESENT AND NOT FAILING". The
|
||||
# whole of its status-check vocabulary is two fields - `enable_status_check`
|
||||
# (bool) and `status_check_contexts` (a list of globs MATCHING CONTEXT NAMES) -
|
||||
# confirmed against this server's own schema at /swagger.v1.json, definitions
|
||||
# EditBranchProtectionOption and CreateBranchProtectionOption. There is no field
|
||||
# for the required STATE anywhere in either. The success/skip comparison lives
|
||||
# in Gitea's compiled code, so the only lever left is upstream of it: make sure
|
||||
# the context is never a skip in the first place. Hence this block.
|
||||
#
|
||||
# ⚠️ WHAT THIS COSTS, stated honestly, because it is a real trade. Every pull
|
||||
# request now runs the full suite, including the intermediate steps of a stack
|
||||
# that nobody intends to merge on its own. A run is ~8 minutes wall clock
|
||||
# (run 799: 7m55s) on a runner that is `capacity: 1` and shared with the other
|
||||
# repo's rig, so each extra run is ~8 minutes of slot that a rig campaign then
|
||||
# queues behind.
|
||||
#
|
||||
# SCALE OF THE NEW WORK, re-derived from the API and stated with its denominator,
|
||||
# because the first version of this paragraph had a made-up one. Counting the 93
|
||||
# pull requests that predate this ticket (#1..#93; CI-9's own #94..#98 excluded,
|
||||
# and note /pulls caps a page at 50, so this needs paging to see at all): 92 were
|
||||
# opened against release/1.0 and would have run anyway, and exactly ONE was
|
||||
# stacked - #2, TEL-01 -> SAFETY-04, closed unmerged, 2026-08-24. No pull request
|
||||
# has ever targeted `master`, so half the old filter was dead from the start.
|
||||
#
|
||||
# ⚠️ THAT 92 IS AN UPPER BOUND, NOT A CENSUS, and the reason is this very bug.
|
||||
# The API reports a pull request's CURRENT base, so one that was opened stacked
|
||||
# and later retargeted is recorded against its final base and is invisible here.
|
||||
# #93 is precisely that case - opened 2026-09-19T10:05:00 on TEL-10, retargeted,
|
||||
# merged into release/1.0 at 11:05:26 the same morning, under three hours before
|
||||
# CI-9 was opened - and it counts in the 92. So the true historical stacking rate
|
||||
# is somewhere above 1/93, and unmeasurable from here.
|
||||
#
|
||||
# The bill is therefore small today and grows with however much stacking the team
|
||||
# adopts. It is worth paying: the alternative is that the cheap case stays
|
||||
# untested and indistinguishable from the tested one.
|
||||
#
|
||||
# ⚠️ THIS DOES NOT CHANGE GITEA'S ACCEPTANCE OF A `skipped` REQUIRED CONTEXT -
|
||||
# that half is in its compiled code and is not reachable from this repository.
|
||||
# The CONSEQUENCE, though, is very largely closeable from here, and is closed:
|
||||
# after this edit there is nothing in this repository that produces a skip -
|
||||
# there is exactly one workflow file, it now has no event filter of any kind,
|
||||
# and the other candidate cause was MEASURED and is not one. The `guard the
|
||||
# trigger` step below then keeps it that way mechanically rather than by asking
|
||||
# the next editor to read this comment.
|
||||
#
|
||||
# ⚠️ READ THE BLIND SPOTS ON THAT STEP BEFORE TRUSTING THE SENTENCE ABOVE.
|
||||
# "Mechanically" holds only for a reintroduced filter that still names the
|
||||
# PR's own base. For blind spots 1 and 3 the defence IS this comment, because
|
||||
# the guard lives inside the workflow that a filter makes Gitea skip. Blind
|
||||
# spot 3 is the one that matters: a filter naming a branch THIS pull request
|
||||
# does not target is itself skipped, lands green and untested, and then every
|
||||
# subsequent pull request the filter DOES NOT NAME is skipped too. That is the
|
||||
# gate off, silently.
|
||||
#
|
||||
# ⚠️ THAT DIRECTION IS THE WHOLE POINT AND IT IS EASY TO WRITE BACKWARDS. A
|
||||
# pull request that MATCHES the filter still runs; it is the one the filter
|
||||
# excludes that Gitea sits out, reports `skipped`, and the rollup launders
|
||||
# into `success`. An earlier draft of this very clause said "matching it is
|
||||
# skipped", which is the inverse of the measurement and contradicted the
|
||||
# paragraph directly below.
|
||||
#
|
||||
# ⚠️ AND THE DANGEROUS ONE IS NOT `branches: [main]`, WHICH AT LEAST LOOKS
|
||||
# WRONG. It is `branches: [release/1.0]`, which looks like tightening onto the
|
||||
# protected branch and is what a careful person would write. Every day-to-day
|
||||
# pull request still runs and still goes green, so nothing appears broken -
|
||||
# and the only thing switched off is the `release/1.0` -> `master` release
|
||||
# PR, i.e. the one whose content reaches an installed system. SEC-2 made that
|
||||
# PR the mandated release path (docs/RELEASE.md), so this is now a live route
|
||||
# rather than a hypothetical one.
|
||||
#
|
||||
# Closing it needs a SECOND permanently unfiltered workflow posting its own
|
||||
# context, with that context added to the `status_check_contexts` of BOTH
|
||||
# `release/1.0` AND `master` - both carry the rule since SEC-2, and adding it
|
||||
# to one alone leaves the hole open on the other. A forge change, with the
|
||||
# owner.
|
||||
#
|
||||
# THE MEASUREMENT, because the obvious guess is wrong and was nearly written
|
||||
# here as fact. A skip-ci keyword in a commit message (`[skip ci]`, `[ci skip]`,
|
||||
# `[no ci]`, `[skip actions]`, `[actions skip]`) does NOT behave like a filter
|
||||
# miss. On throwaway PR #94, head b36effb, commit subject ending `[skip ci]`:
|
||||
#
|
||||
# .../commits/b36effb/statuses -> [] <- NO status, not a skip
|
||||
# .../commits/b36effb/status -> {"state":"pending"} <- so the rollup is pending
|
||||
# POST .../pulls/94/merge -> 405 Not all required status checks successful
|
||||
#
|
||||
# i.e. skip-ci is FAIL-CLOSED - it withholds the context entirely, which is the
|
||||
# pending-forever behaviour CI-01's header wrongly attributed to a filter miss.
|
||||
# It blocks the merge rather than laundering it, so it needs no fix and no
|
||||
# app.ini change. Only the filter miss ever posted a `skipped`.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [release/1.0, master]
|
||||
workflow_dispatch:
|
||||
|
||||
# ⚠️ NO `concurrency:` BLOCK, AND ITS ABSENCE IS AS LOAD-BEARING AS THE MISSING
|
||||
@@ -209,7 +79,7 @@ on:
|
||||
jobs:
|
||||
unit:
|
||||
runs-on: [self-hosted, goodwe-rig]
|
||||
timeout-minutes: 40 # ~15 to run (CI-15: 6 files at a time; the serial suite was ~60 and hit the runner cap); the rest is margin for a busy host
|
||||
timeout-minutes: 70 # ~4 to run; the rest is queueing for the single slot
|
||||
env:
|
||||
# ⚠️ UNDER /home/homelab, NOT $GITHUB_WORKSPACE (ENV-01, and re-measured
|
||||
# during CI-01). act's workspace is `/root/.cache/act/<id>/hostexecutor`, a
|
||||
@@ -286,9 +156,8 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
rm -rf "$WS" && mkdir -p "$WS/goodwe-addon" && cd "$WS/goodwe-addon"
|
||||
git init -q . && git remote add origin "$URL"
|
||||
# Token as an env-set header: not in argv or .git/config (TOOL-9).
|
||||
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN"
|
||||
git init -q .
|
||||
git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:${TOKEN}@#")"
|
||||
if [ -n "$PRNUM" ]; then
|
||||
# Two fetches, not one, so that a failure to reach either ref fails
|
||||
# HERE with the ref in the message, rather than inside the merge
|
||||
@@ -355,165 +224,6 @@ 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."
|
||||
|
||||
# CI-12: the infra repo at the ONE pinned commit in .gitea/infra-pin (read
|
||||
# from the merged tree), as the sibling test_freeze.py looks for. Any
|
||||
# failure here fails the job; the tests step below refuses the stub.
|
||||
- name: check out admin/goodwe at the pin in .gitea/infra-pin
|
||||
env:
|
||||
URL: ${{ github.server_url }}/admin/goodwe.git
|
||||
# Read access to admin/goodwe only; this job's own token cannot see it.
|
||||
TOKEN: ${{ secrets.INFRA_READ_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
# `|| true`: under pipefail a missing or comment-only pin would skip the FAIL line (TOOL-9).
|
||||
PIN=$(grep -v '^#' "$WS/goodwe-addon/.gitea/infra-pin" | tr -d '[:space:]') || true
|
||||
echo "$PIN" | grep -Eq '^[0-9a-f]{40}$' || { echo "FAIL: .gitea/infra-pin holds '$PIN', not a full 40-hex SHA."; exit 1; }
|
||||
test -n "$TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
|
||||
rm -rf "$WS/goodwe" && mkdir -p "$WS/goodwe" && cd "$WS/goodwe"
|
||||
git init -q .
|
||||
# The token goes in a header via env: not in argv, and not in .git/config,
|
||||
# which is mounted into the tests' container and outlives a failed job.
|
||||
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
|
||||
git fetch -q "$URL" "$PIN" || { echo "FAIL: could not fetch admin/goodwe at $PIN."; exit 1; }
|
||||
git -c advice.detachedHead=false checkout -qf FETCH_HEAD
|
||||
test "$(git rev-parse HEAD)" = "$PIN"
|
||||
test -f sim/hwsim.py || { echo "FAIL: admin/goodwe@$PIN has no sim/hwsim.py."; exit 1; }
|
||||
echo "infra: admin/goodwe @ $(git log --oneline -1)"
|
||||
|
||||
- name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile)
|
||||
run: |
|
||||
set -eu
|
||||
@@ -534,22 +244,16 @@ jobs:
|
||||
# shorter timeout - a 60 s limit was tried during CI-01 and reported
|
||||
# exactly the false hang the docstring predicts.
|
||||
#
|
||||
# ⚠️ CI-15: THE FILES RUN SIX AT A TIME, NOT ONE AFTER ANOTHER. The serial
|
||||
# loop summed 3 557 s on SAFETY-125's run (job 2580) and ran past the
|
||||
# runner's 60-minute cap. The loop now lives in .gitea/ci/run-tests.sh (it
|
||||
# comes from the merged tree, like the Dockerfile), so the same script runs
|
||||
# locally and here. It runs the longest files first, then the files whose
|
||||
# assertions depend on the wall clock - test_loop.py and test_freeze.py
|
||||
# (TEST-30 / CI-06) and test_safety87.py (CI-15) - ALONE, after the
|
||||
# parallel batch, so no sibling can steal their CPU. The reasons are in
|
||||
# the script. Its output stays one
|
||||
# PASS/FAIL block per file, in a file per file (never one shared /tmp/out).
|
||||
# A file that is not in the script's two typed lists still runs, in the
|
||||
# parallel batch: the list is a sort order, not a selection.
|
||||
#
|
||||
# test_freeze.py part 3 runs against the real `sim/hwsim.py` from the
|
||||
# checkout above; GOODWE_REQUIRE_HWSIM=1 makes it fail rather than use its
|
||||
# stub (CI-12). Lines a test prints with a `CI: ` prefix are echoed on PASS.
|
||||
# ⚠️ WHAT THIS RUN DOES NOT COVER, stated because a pass here would
|
||||
# otherwise over-claim. test_freeze.py's third section wants the infra
|
||||
# repo's `sim/hwsim.py` to drive a real HTTP meter through the real
|
||||
# transport. admin/goodwe is PRIVATE (checked via the API) and this
|
||||
# workflow's GITHUB_TOKEN is minted for THIS repository only, so hwsim is
|
||||
# absent here and `find_hwsim()` returns None. The rest of the file runs
|
||||
# and passes. It does so SILENTLY - the file prints nothing to say the
|
||||
# hwsim section was skipped - so "test_freeze.py: PASS" below is a weaker
|
||||
# claim than it looks. That belongs to whoever owns test_freeze.py next;
|
||||
# naming it here is the least this gate can do about it.
|
||||
# ⚠️ PYTHONDONTWRITEBYTECODE=1 IS BELT AND BRACES, NOT A FIX FOR A LIVE
|
||||
# HOLE. TEST-15 measured this runner's actual exposure and found none: the
|
||||
# checkout below is bind mounted `:ro`, so the interpreter physically
|
||||
@@ -565,7 +269,27 @@ jobs:
|
||||
- name: the add-on's own tests
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -e GOODWE_REQUIRE_HWSIM=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu /w/goodwe-addon/.gitea/ci/run-tests.sh
|
||||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
|
||||
cd /w/goodwe-addon/goodwe_controller
|
||||
set -- test_*.py
|
||||
if [ "$1" = "test_*.py" ]; then
|
||||
echo "NO test_*.py FOUND - the mount is empty or the layout moved. NOTHING has been checked."
|
||||
exit 1
|
||||
fi
|
||||
echo "$# test file(s) to run"
|
||||
rc=0
|
||||
for f in "$@"; do
|
||||
start=$(date +%s)
|
||||
if python3 "$f" > /tmp/out 2>&1; then
|
||||
echo "PASS $f ($(( $(date +%s) - start ))s)"
|
||||
else
|
||||
echo "FAIL $f ($(( $(date +%s) - start ))s)"
|
||||
tail -40 /tmp/out
|
||||
rc=1
|
||||
fi
|
||||
done
|
||||
exit $rc
|
||||
'
|
||||
|
||||
- name: clean up
|
||||
if: always()
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# CI-12: goes red when admin/goodwe release/1.0 has moved past .gitea/infra-pin.
|
||||
# It runs on add-on activity only; admin/goodwe's own `addon-pin` workflow
|
||||
# covers a move of release/1.0 there. Deliberately NOT a required check: it
|
||||
# reports; `checks / unit` stays the only gate. How to bump: README.md,
|
||||
# "CI and the infra pin".
|
||||
name: infra-pin
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [release/1.0]
|
||||
|
||||
jobs:
|
||||
fresh:
|
||||
runs-on: [self-hosted, goodwe-rig]
|
||||
timeout-minutes: 30 # seconds of work; the rest is queueing for the shared slot
|
||||
steps:
|
||||
- name: compare .gitea/infra-pin with admin/goodwe release/1.0
|
||||
env:
|
||||
SELF: ${{ github.server_url }}/${{ github.repository }}.git
|
||||
INFRA: ${{ github.server_url }}/admin/goodwe.git
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INFRA_TOKEN: ${{ secrets.INFRA_READ_TOKEN }} # admin/goodwe is private
|
||||
run: |
|
||||
set -eu
|
||||
D=$(mktemp -d) && trap 'cd / && rm -rf "$D"' EXIT && cd "$D"
|
||||
git init -q .
|
||||
# The pin of the commit this event is about (the PR head, or the push).
|
||||
# Token as an env-set header: not in argv or .git/config (TOOL-9).
|
||||
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
|
||||
git fetch -q --depth 1 "$SELF" "$GITHUB_SHA" || { echo "FAIL: could not fetch $GITHUB_SHA here."; exit 1; }
|
||||
# `|| true`: under the runner's pipefail a missing or comment-only pin
|
||||
# would stop here on git's error instead of the FAIL line below (TOOL-9).
|
||||
PIN=$(git show FETCH_HEAD:.gitea/infra-pin | grep -v '^#' | tr -d '[:space:]') || true
|
||||
test -n "$PIN" || { echo "FAIL: .gitea/infra-pin is missing at $GITHUB_SHA, or holds only comments."; exit 1; }
|
||||
test -n "$INFRA_TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
|
||||
# Header via env, as in checks.yml: the token stays out of argv.
|
||||
HEAD=$(GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $INFRA_TOKEN" \
|
||||
git ls-remote "$INFRA" refs/heads/release/1.0 | cut -f1) || true
|
||||
test -n "$HEAD" || { echo "FAIL: could not read admin/goodwe release/1.0."; exit 1; }
|
||||
if [ "$PIN" != "$HEAD" ]; then
|
||||
echo "STALE: .gitea/infra-pin is $PIN but admin/goodwe release/1.0 is $HEAD."
|
||||
echo " Test against $HEAD and bump the pin (README.md, \"CI and the infra pin\")."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: .gitea/infra-pin $PIN is admin/goodwe release/1.0 HEAD."
|
||||
@@ -4,10 +4,6 @@ The guide technicians carry is the PDF:
|
||||
|
||||
**`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided.
|
||||
|
||||
⚠️ **Outdated: the content is still v0.1.0.** The PDF was rebuilt on 2026-10-17
|
||||
for the EMS-port wording and the `slew_w` row only. Where the guide disagrees with
|
||||
`goodwe_controller/DOCS.md`, DOCS.md wins.
|
||||
|
||||
It covers, in the order the work is done: pre-visit checks, hardware, firmware,
|
||||
add-on install and configuration, how to read the Web UI, the six commissioning
|
||||
gates, the e-stop (and the statement a site without one must sign), proving a
|
||||
@@ -22,8 +18,7 @@ copy cannot drift from the PDF someone is holding in a cellar.
|
||||
python3 docs/build_guide.py # regenerates field-guide.html and the PDF
|
||||
```
|
||||
|
||||
Requires Chrome or Chromium (used headless for print-to-PDF); `CHROME` in the
|
||||
script is a Windows path, so edit it on any other machine. Screenshots live
|
||||
Requires Chrome or Chromium (used headless for print-to-PDF). Screenshots live
|
||||
in `docs/img/` and are base64-embedded, so `field-guide.html` is standalone.
|
||||
|
||||
⚠️ **Every section must stay under the A4 printable height** (1039 px at 96 dpi
|
||||
|
||||
@@ -156,7 +156,7 @@ that looks hung gets deleted.
|
||||
**A GoodWe "-20" / G2 inverter cannot be driven by this.**
|
||||
|
||||
Platform comes from characters 6–8 of the serial number. Only
|
||||
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era generation — are
|
||||
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era meter bus — are
|
||||
supported. SBP G2 (`SPB`/`SPN`) and ES G2 (`ESN`/`ESC`) are platform 745, a
|
||||
different protocol. The reference unit is `BPS`.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# GoodWe RS485 Controller — Home Assistant add-on repository
|
||||
|
||||
Replaces a GoodWe battery inverter's vendor controller with a local one: holds
|
||||
net grid exchange at zero by sending the inverter a grid reading over its RS485
|
||||
EMS port, and runs the monthly battery maintenance cycle the vendor box was doing.
|
||||
net grid exchange at zero by emulating the inverter's smart meter over RS485,
|
||||
and runs the monthly battery maintenance cycle the vendor box was doing.
|
||||
|
||||
```
|
||||
repository.yaml add-on repository metadata
|
||||
@@ -30,11 +30,9 @@ goodwe_controller/ the add-on
|
||||
returns, run against its own release image
|
||||
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
|
||||
estop/rs485_log.py optional RS485 e-stop / bus witness
|
||||
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; rebuilt 2026-10-17, content still v0.1.0 and outdated)
|
||||
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,
|
||||
@@ -50,9 +48,6 @@ https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
|
||||
|
||||
then install **GoodWe RS485 Controller**.
|
||||
|
||||
Then turn on **Watchdog** on the add-on's Info tab — see
|
||||
[Before you start](goodwe_controller/DOCS.md#before-you-start).
|
||||
|
||||
⚠️ **The repository must be PUBLIC for this to work.** Home Assistant clones it
|
||||
anonymously; against a private repo the store shows nothing and gives no useful
|
||||
error. If it must stay private, install locally instead: copy
|
||||
@@ -82,8 +77,8 @@ machine itself dying:
|
||||
2. **Wind-down before firmware updates** — 0 W written before the update starts.
|
||||
3. **RS485 e-stop (optional)** — writes 0 W after 30 s of total bus silence.
|
||||
|
||||
Sites sold without the e-stop must have the client acknowledgement in the field
|
||||
guide (Step 7b) signed.
|
||||
Sites sold without the e-stop must have the acknowledgement in `FIELD-GUIDE.md`
|
||||
§10 signed.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -105,12 +100,6 @@ has the mechanism, the measurement, and what the flags do not do. They are not u
|
||||
each assertion corresponds to a rule whose absence produced an observed failure
|
||||
on real hardware, and the comments in `control.py` say which.
|
||||
|
||||
### CI and the infra pin
|
||||
|
||||
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py` (six at a time, the wall-clock ones alone afterwards: `.gitea/ci/run-tests.sh`), with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
|
||||
The pin tracks `admin/goodwe` **release/1.0**, not master. Two non-required checks go red when release/1.0 has moved past the pin: `infra-pin / fresh` here, on any add-on PR or push to release/1.0, and `addon-pin / fresh` in `admin/goodwe`, on every push to its release/1.0. Nothing re-runs `addon-pin` after a bump, so it stays red on that admin/goodwe commit; `infra-pin / fresh` green on the bump is the all-clear.
|
||||
**Bump the pin** whenever either is red. Set `.gitea/infra-pin` to the new release/1.0 SHA (`git ls-remote <gitea>/admin/goodwe.git refs/heads/release/1.0`) in a PR, and let `checks / unit` grade the tests against it. CI reads `admin/goodwe` with the Actions secret `INFRA_READ_TOKEN`.
|
||||
|
||||
## Compatibility
|
||||
|
||||
**Check the serial number, not the model name.** Characters 6-8 of the serial are
|
||||
@@ -118,17 +107,17 @@ the platform tag:
|
||||
|
||||
| tag | platform | verdict |
|
||||
|---|---|---|
|
||||
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era | **supported** |
|
||||
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era meter bus | **supported** |
|
||||
| `SPB SPN` | 745 — SBP **G2** (`GW…-SBP-20`) | no |
|
||||
| `ESN ESC` | 745 — ES **G2** (`GW…-ES-20`) | no |
|
||||
| `ETU EHU BTU …` | 205/745/753 — ET/EH/BT hybrids | no |
|
||||
| `SDT DST MSU NSU …` | PV-only inverters — no battery to control | no |
|
||||
|
||||
A "-20" or "G2" suffix means a different platform with a different
|
||||
A "-20" or "G2" suffix means a different platform with a different meter
|
||||
protocol. The tags come from the `goodwe` library's `PLATFORM_105_MODELS`, which
|
||||
is also what Home Assistant's own integration uses to pick a protocol.
|
||||
|
||||
- GoodWe **ES / BP family** inverters (AA55 generation)
|
||||
- GoodWe **ES / BP family** inverters (AA55 / RS485 meter-bus generation)
|
||||
- The protocol is **reverse-engineered**. There is no vendor contract, and a
|
||||
firmware change on GoodWe's side could break every installation at once. Say
|
||||
so when you sell it.
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# STYLE.md - how user-facing text is written (UX-08)
|
||||
|
||||
This applies to everything a home owner or installer reads: notifications, phone pushes, the add-on page, refusals, add-on log lines at WARNING and above, and the names and descriptions of options. It is enforced by `goodwe_controller/test_messages.py`. Its allowlist is empty since phase 2d and its cap is 0: a new string that breaks the rule fails the test.
|
||||
|
||||
## 1. Shape
|
||||
- **Title**: at most 5 everyday words. It says what is wrong ("Meter reading may be stuck"). No "GoodWe:" prefix, no full stop.
|
||||
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
|
||||
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
|
||||
- **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
|
||||
- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words in `app/ui_text/<lang>.json` under `home_words`, built by `app/homeview.py`, checked by `test_messages.py` part 8. The page's own words, English and Dutch, are checked by `test_ui_text.py`.
|
||||
- **Log line** (WARNING/ERROR): one line of at most 25 words **as logged**, with any built text filled in. Log lines stay in English and are not translated. A line that reports a safety reason gives one plain sentence and the reason's code (`STALE_READING`, `OUTPUT_CLAMP`), the evidence an installer searches for. The reason itself, with its figures and limits, goes on a DEBUG line right after it and into the action log, never onto the WARNING/ERROR line. It keeps the shape of a red alarm: at most 3 sentences of at most 30 words. The line as logged has no semicolon, banned word, capitals, "1 times" or "(s)". `test_messages.py` part 6 builds every such text with the real code and checks it, and lists the few non-safety families still over 25 words. A line that repeats every cycle is DEBUG. INFO lines carry no section numbers, ISO timestamps or capitals.
|
||||
- **Refusal length is counted as shown**: a setting placeholder counts as its label and key, and the long cases are driven through the validator (`test_messages.py` part 3).
|
||||
- **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown on the page. Installers read it as `events` in the `status` JSON.
|
||||
- **Sentences**: one idea each, active voice, present tense, with nothing nested in brackets and no semicolons (a semicolon is a second sentence in disguise). These translate well into Dutch and French.
|
||||
|
||||
## 2. Numbers
|
||||
Use a number only if it helps the reader act, for example the peak limit, or what was asked and what was sent in a fault report. Write it the way `app/messages.py` does: `5 000 W`, `20 %`, `10 minutes`, `14:05`.
|
||||
|
||||
## 3. Severity
|
||||
Severity is shown by the title and the colour, never by CAPITALS or by words like "BUG SIGNAL".
|
||||
|
||||
| Level | Colour | When |
|
||||
|---|---|---|
|
||||
| alarm | red | Control stopped, money or energy lost now, a fault, or a person must act now. Up to 3 sentences. |
|
||||
| warning | orange | Control is still running, but something needs a look. 1 sentence. |
|
||||
| info | grey | No action needed. 1 sentence. |
|
||||
| resolved | green | An earlier alarm or warning has cleared. 1 sentence. |
|
||||
|
||||
A rewrite never changes an alarm's severity, `notification_id` or cadence.
|
||||
|
||||
## 4. Words - the same word for the same thing everywhere
|
||||
A setting is named by its label in `translations/en.yaml`, in quotes when it sits mid-sentence (“Inverter rating”). The words below match those labels. If a label changes, change it here and in `app/messages.py` in the same change.
|
||||
|
||||
| Say | Not |
|
||||
|---|---|
|
||||
| the add-on (who decides), control (what it does: "start control", "Control: last ran …") | controller, loop, planner |
|
||||
| battery | pack, BMS pack |
|
||||
| inverter, or its name | unit, device, actuator |
|
||||
| meter | P1 feed, telegram |
|
||||
| peak limit / this month's peak | ceiling, capaciteitstarief, billed quarter |
|
||||
| 15-minute period | quarter |
|
||||
| battery level (setting: “Battery level entity”) | SoC, state of charge, charge level |
|
||||
| battery cut-out level, or cut-out level next to "battery" (setting: “Battery cut-out level”) | BMS floor, `bms_soc_floor` |
|
||||
| peak reserve (setting: “Peak reserve”) | `peak_soc_floor`, EMS floor, "peak guard will not discharge below" |
|
||||
| “Inverter rating” (the setting) | `max_w`, rail, inverter rating as a fact about the hardware |
|
||||
| solar production, solar entity (settings: “Solar production entity”, “Solar production entities, per inverter”, “Reversed-sign solar entities”) | PV, reversed-sign list, single sensor setting |
|
||||
| “Where the meter reading comes from” | meter source |
|
||||
| “Grid power entity”; entity for any Home Assistant sensor a setting points at | grid power sensor, sensor setting |
|
||||
| draw the battery down, give power | discharge, discharging |
|
||||
| control is stopped / every inverter is set to 0 W | failsafe, trip, latch, parked |
|
||||
| safety stop (a stop that only starting control clears) | failsafe latch |
|
||||
| software fault | BUG SIGNAL |
|
||||
| control board | master, ESP32 |
|
||||
| power request, what the add-on asks | command, setpoint (on the page) |
|
||||
| the power went the wrong way | sign disagreement, command and meter point opposite ways |
|
||||
| check for inverters working against each other | circulating power check |
|
||||
| month peak, 15-minute periods measured | billed quarter, banked, soft limit, open block |
|
||||
| shadow mode | a mode that makes every decision but commands 0 W. The name is kept. In shadow mode a text says what the add-on **would** send, never that it sent it. |
|
||||
|
||||
**Banned in titles and bodies** (the test fails on them): SAFETY-/TARIFF-/TEL-/CFG-/FW-/HW-nn and other card ids, integrator, anti-windup, windup, clamp, clamped, saturated, BUG SIGNAL, latch, latched, arbiter, allocator, register, `0x…`, layer 1/2/3, heartbeat, watchdog, telegram, ACK, CRC, OTA, Modbus, planner, `§n`, file names, IP addresses, option keys in snake_case, **HA entity ids**, and runs of 2 or more CAPITALISED words.
|
||||
|
||||
HA entity ids are allowed only in refusals and validator messages, where the installer has to find that entity. They are never allowed in alarms or status rows.
|
||||
|
||||
**Glossary.** DOCS.md "Words used on this page" explains, once, the words a home owner meets without context: control board, isolator, Developer Tools, shadow mode, safety stop, peak limit, peak guard, peak reserve and cut-out level. A home-owner text may use them without explaining them again. Card ids stay out of DOCS.md headings: they go on a *For installers* line under the heading.
|
||||
@@ -1,116 +0,0 @@
|
||||
# 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`.
|
||||
@@ -95,7 +95,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<div class="page" id="p1">
|
||||
<div class="hdr">
|
||||
<div><h1>GoodWe RS485 Controller</h1>
|
||||
<div class="sub">Field installation & commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
|
||||
<div class="sub">Field installation & commissioning guide — v0.1.0</div></div>
|
||||
<small>Installer copy</small>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<table style="margin-bottom:2mm">
|
||||
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
|
||||
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
|
||||
<td><b>SUPPORTED</b></td></tr>
|
||||
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
|
||||
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
|
||||
@@ -162,7 +162,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
|
||||
</table>
|
||||
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
|
||||
A “-20” or “G2” suffix in the model name means a different platform with a different
|
||||
A “-20” or “G2” suffix in the model name means a different platform with a different meter
|
||||
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
|
||||
</div>
|
||||
|
||||
@@ -208,13 +208,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<!-- ============ PAGE 3 ============ -->
|
||||
<div class="page" id="p3">
|
||||
<h2>Steps 1–3 — Hardware</h2>
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
|
||||
is low-voltage but sits inside an energised installation.</div>
|
||||
|
||||
<div class="step">
|
||||
<h3><span class="num">1</span> Survey and record</h3>
|
||||
<ol class="do">
|
||||
<li><b>Photograph</b> the EMS-port wiring before touching it.</li>
|
||||
<li><b>Photograph</b> the meter-port wiring before touching it.</li>
|
||||
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
|
||||
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
|
||||
</ol>
|
||||
@@ -226,7 +226,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<ol class="do">
|
||||
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
|
||||
inverter display).</li>
|
||||
<li>Disconnect the vendor controller from the inverter's EMS port.</li>
|
||||
<li>Disconnect the vendor controller from the meter port.</li>
|
||||
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
|
||||
you a rollback story.</li>
|
||||
</ol>
|
||||
@@ -237,7 +237,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<div class="step">
|
||||
<h3><span class="num">3</span> Fit and power the T-CAN485</h3>
|
||||
<ol class="do">
|
||||
<li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
|
||||
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li>
|
||||
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li>
|
||||
<li>Power the T-CAN485 <b>last</b>.</li>
|
||||
</ol>
|
||||
@@ -503,7 +503,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<table>
|
||||
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
|
||||
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
|
||||
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code> / 9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
|
||||
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
|
||||
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
|
||||
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
|
||||
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
|
||||
|
||||
@@ -60,7 +60,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<div class="page" id="p1">
|
||||
<div class="hdr">
|
||||
<div><h1>GoodWe RS485 Controller</h1>
|
||||
<div class="sub">Field installation & commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
|
||||
<div class="sub">Field installation & commissioning guide — v0.1.0</div></div>
|
||||
<small>Installer copy</small>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +119,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<table style="margin-bottom:2mm">
|
||||
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
|
||||
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
|
||||
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
|
||||
<td><b>SUPPORTED</b></td></tr>
|
||||
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
|
||||
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
|
||||
@@ -127,7 +127,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
|
||||
</table>
|
||||
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
|
||||
A “-20” or “G2” suffix in the model name means a different platform with a different
|
||||
A “-20” or “G2” suffix in the model name means a different platform with a different meter
|
||||
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
|
||||
</div>
|
||||
|
||||
@@ -173,13 +173,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<!-- ============ PAGE 3 ============ -->
|
||||
<div class="page" id="p3">
|
||||
<h2>Steps 1–3 — Hardware</h2>
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
|
||||
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
|
||||
is low-voltage but sits inside an energised installation.</div>
|
||||
|
||||
<div class="step">
|
||||
<h3><span class="num">1</span> Survey and record</h3>
|
||||
<ol class="do">
|
||||
<li><b>Photograph</b> the EMS-port wiring before touching it.</li>
|
||||
<li><b>Photograph</b> the meter-port wiring before touching it.</li>
|
||||
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
|
||||
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
|
||||
</ol>
|
||||
@@ -191,7 +191,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<ol class="do">
|
||||
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
|
||||
inverter display).</li>
|
||||
<li>Disconnect the vendor controller from the inverter's EMS port.</li>
|
||||
<li>Disconnect the vendor controller from the meter port.</li>
|
||||
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
|
||||
you a rollback story.</li>
|
||||
</ol>
|
||||
@@ -202,7 +202,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<div class="step">
|
||||
<h3><span class="num">3</span> Fit and power the T-CAN485</h3>
|
||||
<ol class="do">
|
||||
<li>Wire <b>A→A, B→B</b> to the EMS port, plus its own supply.</li>
|
||||
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li>
|
||||
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li>
|
||||
<li>Power the T-CAN485 <b>last</b>.</li>
|
||||
</ol>
|
||||
@@ -468,7 +468,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<table>
|
||||
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
|
||||
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
|
||||
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code> / 9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
|
||||
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
|
||||
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
|
||||
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
|
||||
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# UX-13 screenshots
|
||||
|
||||
Made from the real page by `goodwe_controller/tools/ux13_screenshots.sh`. Do not
|
||||
edit them by hand: run the script again.
|
||||
|
||||
- `home-*.png`: Home, running, in Dutch, in shadow mode, at phone width, and in setup.
|
||||
- `settings-*.png`: Settings, Simple and Installer, and the time-window editor with an overlap.
|
||||
- `batteries*.png`: the Batteries screen with modes on, in Dutch, an Off battery helping the peak limit, a refusal at phone width, and modes off.
|
||||
- `setup-step-1*.png`: Setup, step 1.
|
||||
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 138 KiB |
@@ -1,109 +0,0 @@
|
||||
# FCST-03 - the hours the feed has not published, and what may not rest on them
|
||||
|
||||
**Status: SHIPPED.** Tests: `test_price_forecast.py`, `test_price_history.py`.
|
||||
Written down by MAINT-20 from the long module comment of `app/price_forecast.py`.
|
||||
Follows specs.md 8.3: "Day-ahead prices arrive once daily. Until they do, unknown
|
||||
hours use yesterday's profile and are flagged `provisional: true`, and no
|
||||
irreversible action may be taken on a provisional price. Real-time / imbalance
|
||||
products are treated as high-volatility: arbitrage thresholds widen." This module
|
||||
owns the first two; the widening is in price.py (`Uncertainty`, `widen_factor`,
|
||||
`required_spread`), spent by `static_tariff.TariffPlan.arbitrage()` as one more
|
||||
refusal on its existing ladder. Persistence of yesterday: [[FCST-04]].
|
||||
|
||||
## The flag is never passed through
|
||||
|
||||
A carried point and a published one are the same three floats and a bool.
|
||||
`PricePoint(..., provisional=y.provisional)` copies yesterday's `False` onto
|
||||
today's guess: stale data presented as current (SAFETY-18's defect one layer up).
|
||||
So:
|
||||
|
||||
- `_carried()` has no parameter for the flag; `provisional=True` is a literal in
|
||||
one place.
|
||||
- A published point is passed through by identity (`filled.points[i] is
|
||||
fetched.points[j]`, checked by the test), never copied.
|
||||
- `_audit()` re-derives the expected flag for every emitted point from the hours
|
||||
the feed published and raises on a mismatch. It never corrects (a module that
|
||||
quietly fixes its own provenance cannot be trusted).
|
||||
- test_price_forecast.py walks this file's AST and fails if any `PricePoint(...)`
|
||||
passes anything but a literal for `provisional`.
|
||||
|
||||
"Published" does not mean "firm": 9.3 carries `provisional` per point because a
|
||||
real-time product's current-interval print is published AND provisional, so the
|
||||
audit's rule for a published point is "whatever the feed said". No shipped
|
||||
plugin emits one since [[TARIFF-07]]; the rule is a property of the format and
|
||||
weakening it would let the first imbalance feed arrive mispriced and flagged firm.
|
||||
|
||||
## The audit is a provenance check, not a completeness check (FCST-05)
|
||||
|
||||
Rules: a PUBLISHED point must be one of `published[u]` by identity and be
|
||||
emitted once, with the feed's own flag; a CARRIED point's hour must not be in
|
||||
`published` at all, flag exactly True, and no second point in that hour. This
|
||||
replaced FCST-03's "at most one point per hour" count, which cannot express a
|
||||
quarter-hourly feed. FCST-04 claimed two strengthenings; both were false: a
|
||||
differential over every published mask on 3 hours crossed with every output of
|
||||
length 0-3 found zero divergence either way (OLD raised/NEW accepted 0, OLD
|
||||
accepted/NEW raised 0). The one real change is the intended relaxation (several
|
||||
distinct published points per hour).
|
||||
|
||||
The audit does not check completeness: fed the decimation defect (four points in
|
||||
an hour, only the last emitted), two of four, or an empty output, it accepts.
|
||||
What stops decimation is the point COUNT and IDENTITY checks in the two test
|
||||
files. It cannot be made complete from its arguments: `published` is over the
|
||||
whole unwindowed read while the fill emits `[since, until)`, so a published point
|
||||
outside the window is legitimately absent. FCST-05 built "every published point
|
||||
is emitted" (+162 B) and it took the 1 h, 12 h and 48 h horizon checks red on a
|
||||
healthy feed (a real change: the differential scored it 49 cases stronger),
|
||||
so the rule as stated is wrong, and
|
||||
asserting a completeness the instrument lacks is MUTATION.md habit 16's defect.
|
||||
|
||||
## The fill keys on the wall clock
|
||||
|
||||
For each whole hour of the horizon the feed has not published, take yesterday's
|
||||
value for the SAME BRUSSELS WALL-CLOCK HOUR, not `t - 86400 s`: a 23-hour day has
|
||||
no 02:00 and a 25-hour day has two, so a fixed 24 h shifts every carried hour
|
||||
across a transition. On a 25-hour day both 02:00s are filled from yesterday's
|
||||
single 02:00 (both guesses, both flagged); if yesterday was a 25-hour day, the
|
||||
first of its two 02:00 points is used. An hour with no published price and no
|
||||
yesterday value is left out, not filled with a neighbour, mean or zero: 11.4
|
||||
applies (`Absence`, then the static profile). If nothing can be produced the
|
||||
result is `no_data`, not an empty series.
|
||||
|
||||
A sub-hourly feed is not decimated: the hourly walk decides which hours need
|
||||
filling, never what a published hour may hold. Belgian day-ahead settles per
|
||||
quarter since 2025-10-01 (96 points a day), so every published point in the
|
||||
window is passed through and a carried one is added only for an hour with no
|
||||
published coverage. `{_utc_hour(p.start): p for p in r.points}` reads ordinary
|
||||
and keeps the last point of each hour; FCST-04's first caller shipped it, the
|
||||
series came out 15 points long, the spread fell from 0.143 to 0.014 EUR/kWh and
|
||||
the cheapest "hour" became a `:45` point. `_published_by_hour()` holds a tuple
|
||||
per hour for that reason.
|
||||
|
||||
The window is whole hours at both ends: `since` floored (the current hour is
|
||||
still tradeable) and `until` floored too; with `until` compared raw a 48 h horizon
|
||||
asked at 08:17 emitted 49 hours. `yesterday` has no default (a missing yesterday
|
||||
and a useless one must not look alike). The fill is idempotent and re-runnable
|
||||
(5.4 late publication, 14-16 h CET): published hours win, and the series is
|
||||
rebuilt, not patched. TARIFF-11 log line: carried HOURS, total hours and
|
||||
published POINTS are three different quantities (a quarter-hourly feed made it
|
||||
read "33 of 141 hours" for 48 h).
|
||||
|
||||
## AC2: the irreversible-action gate
|
||||
|
||||
An irreversible action is a value, `Irreversible`, carrying the hours it commits.
|
||||
`price_permits()` looks at exactly those hours and answers with a `Gate` carrying
|
||||
evidence on the permitting arm and a reason on the blocking one (the `_Outcome`
|
||||
shape). It blocks when the read observed nothing (11.4), an hour has no price, or
|
||||
an hour is provisional, and names the hour. The maintenance drain's window is
|
||||
derived from `maintenance.DRAIN_TIMEOUT_MIN` / `CHARGE_TIMEOUT_MIN`, so the gate
|
||||
and the phase machine cannot drift. `_window_hours()` is separate so the gate and
|
||||
its caller agree on "the hours it commits".
|
||||
|
||||
What is NOT gated matters more: the maintenance cycle is a calendar obligation
|
||||
(12.2: a cycle that silently never fires looks like one not yet due), and a site
|
||||
with `price_provider: off` (the default) has no series. Routing every start
|
||||
through a gate that blocks on `no_data` would stop the monthly cycle on every
|
||||
default install. So `begin_phase()` is the entry point for a start JUSTIFIED BY A
|
||||
PRICE; main.py's calendar path is untouched, and OPT-04 / MAINT-05 own which a
|
||||
site runs. Reversible actions (self-consumption modulation, the loop) are not
|
||||
gated: 6 says absent machinery must not disable the loop. Read this before wiring
|
||||
`begin_phase()` in.
|
||||
@@ -1,251 +0,0 @@
|
||||
# FCST-04 — what "yesterday" is, and what the fill is allowed to reach
|
||||
|
||||
Four decisions, recorded because each had a defensible alternative that a later
|
||||
reader would otherwise re-open from scratch.
|
||||
|
||||
---
|
||||
|
||||
## 1. The fill runs on a `series` read only. A dead feed still goes to §11.4.
|
||||
|
||||
`fill_from_yesterday()` accepts a `no_data` read and will happily build a
|
||||
100 %-carried series from yesterday alone. `resolve_tariff()` does not let it:
|
||||
a feed that published nothing still falls to `_degrade()` and §11.4's own row —
|
||||
*"No price data → fall back to the static profile, mark the plan provisional,
|
||||
no arbitrage."*
|
||||
|
||||
**Why not prefer yesterday's real prices to a coarse day/night profile?**
|
||||
Because it buys nothing the optimiser can use, and it silently replaces a
|
||||
fallback the operator configured.
|
||||
|
||||
Measured, not assumed — and stated at the reach it actually has:
|
||||
|
||||
- **This one reaches control.** A fully-carried series is 100 % provisional, so
|
||||
`TariffPlan.arbitrage()` finds fewer than two firm hours and refuses on
|
||||
§8.3's firm-pair rung. That is the verdict the optimiser reads: `arbitrage.py`
|
||||
calls `plan.arbitrage(...)` on `fresh_tariff_plan()`, and it is the only path
|
||||
by which a provider plan moves energy. The §11.4 static fallback refuses on
|
||||
the same rung, for the same reason.
|
||||
- **This one does not, and saying so is the point.** `TariffPlan.cheap_now()`
|
||||
also returns False on a fully-carried series, because `firm_points()` is
|
||||
empty — but it has **no production caller on the provider plan**.
|
||||
`self.cheap` comes from `static_status()`, which builds its *own* static plan
|
||||
and calls `cheap_now()` on that. So it is a property of the type, checked in
|
||||
`test_static_tariff.py`, and it is not evidence about the shipped control
|
||||
path. The conclusion below holds on the first bullet alone.
|
||||
|
||||
The two paths therefore differ only in which numbers the status page shows.
|
||||
|
||||
So the choice is between two identical control behaviours, and the tie-breaker
|
||||
is that `static_profile` is a documented option an operator fills in **for this
|
||||
case**. Overriding it for a cosmetic difference is a behaviour change nobody
|
||||
asked for.
|
||||
|
||||
⚠️ **The consequence, stated so it is not discovered later as a bug:**
|
||||
`fill_from_yesterday()`'s `no_data` branch is not reached from production.
|
||||
It stays checked in `test_price_forecast.py`. Turning it on is a product
|
||||
decision — it would want a rule about how old a carried profile may be before it
|
||||
stops being offered at all — and not a wiring one.
|
||||
|
||||
---
|
||||
|
||||
## 2. "Yesterday" is a date, not the newest record.
|
||||
|
||||
`newest()` is the obvious accessor and it is the one that decays. An add-on down
|
||||
from Friday to Monday would answer Friday's prices to a fill whose only
|
||||
vocabulary is `provisional: true` — a flag that says "this is a guess", never
|
||||
"this guess is three days old". Nothing downstream can tell the difference and
|
||||
nothing reports it.
|
||||
|
||||
So `yesterday(stamp)` computes the Brussels date before `stamp`'s Brussels date
|
||||
and looks up **that** record. Anything else is `no_data` with a reason naming
|
||||
both the date wanted and the dates held.
|
||||
|
||||
The cost is real and accepted: after an outage of more than a day, the first
|
||||
morning back has no fill. That is the honest state — there is no yesterday —
|
||||
and the horizon is short for one morning rather than wrong for several.
|
||||
|
||||
---
|
||||
|
||||
## 3. What is recorded is the provider's read, and the mechanism is ordering.
|
||||
|
||||
Feeding the filled series back into the store is how this rots: day 2 carries
|
||||
day 1's guesses, day 3 carries day 2's carry of them, and after a week of a dead
|
||||
feed the "yesterday" being carried is a fossil of the last real publication.
|
||||
Every point still carries `provisional=True`, so the **flag never lies** — what
|
||||
decays is the number, behind a flag that is telling the truth.
|
||||
|
||||
⚠️ **No value-level check can catch that**, and claiming one would be worse than
|
||||
claiming none. A carried point and a published-provisional point (a real-time
|
||||
product's current interval, §9.2) are the same three fields carrying the same
|
||||
flag. The
|
||||
difference is provenance, and provenance is not in the value.
|
||||
|
||||
The defence is therefore an **ordering**, not a validation:
|
||||
|
||||
- `carry()` is the only public entry point. It records its argument, then
|
||||
returns the fill.
|
||||
- The filled series exists only as that return value. The object never sees it
|
||||
again.
|
||||
- `_remember()` is private and called from exactly one place.
|
||||
- `test_price_history.py` parses every shipped module under `app/`, `tools/`,
|
||||
`scenarios/` and `estop/` and fails if any of them **uses the name**
|
||||
`_remember` — an attribute, a bare name, a `def`, or the string constant a
|
||||
`getattr` would need. By name rather than by substring, so a comment that
|
||||
merely mentions the rule does not fail it and a call in `tools/` cannot pass
|
||||
unseen.
|
||||
- and it pins the order two ways: the **line numbers** of the two calls in
|
||||
`carry()`, and — the one that matters — the **behaviour**. A feed carrying
|
||||
yesterday alongside today, into an empty store, must fill the whole horizon;
|
||||
reversed, the lookup finds nothing and the plan is only the hours the feed
|
||||
published. Driven, so a reversal is red.
|
||||
|
||||
⚠️ **The first version of that ordering check graded nothing, and this file
|
||||
said it did.** It compared positions in `ast.walk`, which is breadth-first:
|
||||
`self._remember(...)` is a statement and `self.yesterday(...)` is an *argument*
|
||||
of `fill_from_yesterday(...)`, one level deeper, so `_remember` came out first
|
||||
whichever statement was written first. Measured — shipped and reversed both
|
||||
scored the same. A decorative check that a document cites as a guarantee is
|
||||
worse than no check, which is why the correction is recorded here rather than
|
||||
quietly made.
|
||||
|
||||
That ordering has a second benefit worth keeping: a feed that returns yesterday
|
||||
alongside today refreshes yesterday's record from live data *before* it is
|
||||
consumed as the profile.
|
||||
|
||||
---
|
||||
|
||||
## What this ticket does not claim
|
||||
|
||||
It does not make a trade possible that yesterday's prices justify. Carried hours
|
||||
are dropped from the trade set by §8.3, and that is not a limitation to be fixed
|
||||
later — it is the rule.
|
||||
|
||||
The one change that **does** move energy came from the windowing, not the
|
||||
carrying: the filled series covers `[since, until)`, so hours that have already
|
||||
elapsed are no longer in the spread. A cheap night that has gone used to be the
|
||||
series' cheapest hour, and a cycle cannot buy in an hour that is past — so the
|
||||
trade was silently never taken. `test_price_history.py` section 2 grades that by
|
||||
finding the cheapest firm hour with and without the fill and showing it moves.
|
||||
|
||||
⚠️ **And section 1's identity check now grades identity.** It used to compare
|
||||
`eur_per_kwh` between `_before` and `_after`, which come from two *separate*
|
||||
`resolve_tariff()` calls — each parsing the feed body into its own objects, so
|
||||
`is` could never have held between them however the fill were written. FCST-03's
|
||||
whole anti-copy argument is `filled.points[i] is fetched.points[j]`, and floats
|
||||
cannot grade it. The value comparison is kept and now says what it is (the fill
|
||||
moved the window, it did not touch a price); identity is graded on a read the
|
||||
file constructs and still holds a reference to.
|
||||
|
||||
---
|
||||
|
||||
## 4. A sub-hourly feed passes through whole; the fill only decides which hours are missing.
|
||||
|
||||
⚠️ **This section originally said `eneco_eddy` is "the Belgian imbalance
|
||||
product". That was false and [[TARIFF-07]] withdrew the plugin: Eneco's dynamic
|
||||
product is "Zon & Wind Dynamisch" and is indexed to Belpex/EPEX **day-ahead**,
|
||||
not to imbalance, and `api.eneco.be/eddy/prices` never existed. The mechanism
|
||||
below is unaffected, because the real quarter-hourly feed is the one still
|
||||
shipped.**
|
||||
|
||||
Belgian **day-ahead** settles per **quarter** — the 15-minute MTU took effect
|
||||
on 2025-10-01, so `generic_dayahead` reads 96 points a day rather than 24 — and
|
||||
`price.py`'s `_validate_raw()` accepts any strictly-increasing spacing, so a
|
||||
published hour may legitimately hold four points.
|
||||
|
||||
FCST-04's first caller keyed the published set as `{_utc_hour(p.start): p for p
|
||||
in r.points}`. It reads perfectly ordinary and it keeps the **last** point of
|
||||
each hour. Measured on a 96-point quarter-hourly feed — all firm, fully
|
||||
published, nothing to carry: the plan reached the optimiser as **15 points**,
|
||||
the arbitrage spread fell from **0.143 to 0.014 EUR/kWh**, and the cheapest
|
||||
"hour" became a `:45` point whose `at()` window runs into the next hour. That is
|
||||
not the windowing effect above; it is a feed losing 84 % of its own prints, and
|
||||
no `provisional` flag would ever have reported it.
|
||||
|
||||
**The alternative was rejected:** refusing sub-hourly feeds at `_validate_raw()`
|
||||
would have made the count correct by breaking the one supported product that
|
||||
legitimately publishes per quarter.
|
||||
|
||||
**What was done instead** keeps FCST-03's existing contract rather than weakening
|
||||
it: *every* published point inside the window passes through **by identity**,
|
||||
and a carried point is added only for a whole hour with **no** published
|
||||
coverage at all. A carried hour is still a single hourly point whatever the
|
||||
feed's own spacing is, because yesterday's profile is only known per wall-clock
|
||||
hour — and carried hours are provisional, so they are out of the trade set
|
||||
anyway.
|
||||
|
||||
⚠️ **`_audit()` got stricter, not looser, and that was the condition on doing
|
||||
this at all.** The old post-condition refused any repeated `_utc_hour` — a count
|
||||
that cannot express four points in one hour. It is replaced by two rules that
|
||||
**subsume** it:
|
||||
|
||||
- a **published** point must *be* one of that hour's published points, by
|
||||
identity, and may be emitted only once;
|
||||
- a **carried** point may stand only in an hour that does not appear in the
|
||||
published set at all, must be flagged `True`, and may not repeat.
|
||||
|
||||
So a duplicated carried hour — the DST mispricing the count existed to catch —
|
||||
still raises; so does the same published point emitted twice; and a carried
|
||||
point sharing an hour with a real print now raises too, which the count never
|
||||
looked at. Each of those three is driven by its own mutation in
|
||||
`test_price_forecast.py`.
|
||||
|
||||
## 5. Store details (moved here from `app/price_history.py` by MAINT-20)
|
||||
|
||||
**Why a file, and which one.** FCST-03 shipped the fill with no caller, so no
|
||||
series ever contained a carried point. Without a yesterday that survives a
|
||||
restart, the mornings after an operator touched something spend the whole
|
||||
window on the truncated series. The cache is `/data/price_history.json`, not
|
||||
`/data/state.json`: `Store.set()` rewrites the whole of state.json on each
|
||||
write, and the refresh runs every `TARIFF_REFRESH_S` (1800 s), so 48 extra
|
||||
rewrites a day would go through the file whose corruption reads as "no
|
||||
maintenance ever ran". Separate file, separate blast radius. The directory is
|
||||
not created (`tariff.PeakStore`'s rule): `/data` is always mounted, so a
|
||||
`makedirs` could only succeed where the durable volume is missing and build a
|
||||
cache on the ephemeral layer. "Cannot persist" is one question asked after the
|
||||
attempt: see [[FCST-06]]; `self._days` never holds a point the file does not.
|
||||
|
||||
**Brussels date, not UTC.** 23:30 UTC in July is already tomorrow in Belgium.
|
||||
`_yesterday_of()` subtracts on the local `date`, not the instant: the old
|
||||
`brussels(stamp) - timedelta(days=1)` took 24 absolute hours and, at
|
||||
2026-03-30T00:30+02:00, returned 2026-03-28 (a two-day-old profile flagged as
|
||||
yesterday's). Rows are keyed by instant, not local hour, so the two 02:00s of
|
||||
the 25-hour October day both survive and `fill_from_yesterday()`'s own rule is
|
||||
the only collapsing rule. The merge key is the validated instant; the old
|
||||
string key merged by spelling and sorted the `+01:00` 02:00 before the `+02:00`.
|
||||
`KEEP_DAYS` is a sort-order cap, not a horizon: nothing is deleted by arithmetic
|
||||
against the clock (a jumped clock, WITNESS-03, would otherwise delete everything
|
||||
on the one morning the fill is for), and the two dates `yesterday()` can be
|
||||
asked for are pinned on top of the cap, which can only retain.
|
||||
|
||||
**One validator (FCST-05, MEDIUM).** The read path was defensive about every
|
||||
field and the write path trusted `q["start"]` to be hashable and sortable. An
|
||||
int, null, dict or list `start` raised `TypeError` out of `carry()` on the day
|
||||
being written; `run_tariff_refresh` caught it, so the add-on stayed up and every
|
||||
refresh failed for good (the plan retires after `TARIFF_MAX_AGE_S`, arbitrage and
|
||||
the cheap window go off, and `_remember()` raised before `_save()`, so no
|
||||
self-heal). `_point_of()` is now the one standard for both paths and returns
|
||||
`None` rather than raising.
|
||||
|
||||
**A broken cache is a short horizon, never a controller that will not start**
|
||||
(6: absent machinery must not disable the loop). `PriceHistory(...)` and `.words`
|
||||
are unguarded in `Controller.__init__`. Shipped defects, all fixed:
|
||||
|
||||
- `_load()` caught `json.JSONDecodeError` and `OSError` only; one non-UTF-8 byte
|
||||
raised `UnicodeDecodeError` (a `ValueError`, not an `OSError`) and the add-on
|
||||
did not start (FCST-05, HIGH). The handler names the `ValueError` family. The
|
||||
schema version is compared with its type (`1.0 == 1` in Python).
|
||||
- `{"days": {"2026-01-14": "oops"}}` loaded as healthy and raised
|
||||
`AttributeError` out of `words`, `yesterday()` and `resolve_tariff()`.
|
||||
`_usable_days()` drops such records, and counts (not drops) malformed rows
|
||||
inside a kept record so the store stops reporting health it lacks.
|
||||
- `words` printed `f"{d} ({_n_points(rec)} h)"`: points under the hour's name
|
||||
(`'2026-01-13 (96 h)'` for a 24-hour quarter-hourly day, FCST-05 LOW). Both
|
||||
numbers are printed now; only rows that are points are counted. `words` must
|
||||
never raise, `_held()` is total.
|
||||
- A failed merge is undone from a deep copy (a one-level copy shares the day
|
||||
records the merge rebinds).
|
||||
|
||||
`NO_HISTORY` is a name, not `None`, so `history=` can be required: `None`
|
||||
meaning "skip the fill" is how a caller silently gets the truncated horizon.
|
||||
`carry()` records first, then fills, so the filled series exists only as a
|
||||
return value that the store never sees again (rule 1).
|
||||
@@ -1,125 +0,0 @@
|
||||
# FCST-06 — "cannot persist" is one question, and it is asked about the outcome
|
||||
|
||||
Two decisions. The first is the one the ticket put up for judgement; the second
|
||||
is the one that decides what a store answers *after* it has failed to write.
|
||||
|
||||
---
|
||||
|
||||
## 1. One derived predicate, not two places testing two symptoms — yes
|
||||
|
||||
`price_history.py`'s module docstring states, unconditionally:
|
||||
|
||||
> ⚠️ AND THAT RULE IS CARRIED OUT IN `_remember()`, NOT ONLY IN `_save()`. …
|
||||
> A store that cannot persist now records nothing, and both of its answers say
|
||||
> the same thing.
|
||||
|
||||
It was enforced against `not self.ok`, and `ok` is **about the directory**: it
|
||||
says `/data` was there when the store opened. A read-only remount and a full
|
||||
volume both leave it `True`. So the second way to be a store that cannot
|
||||
persist — `ok=True`, the directory present, `_save()` failing — was outside the
|
||||
one condition the rule named, and in that state `words` printed two full days
|
||||
beside the write error while `yesterday()` answered 24 real points, from memory,
|
||||
until a restart dropped them without a word.
|
||||
|
||||
**The ticket's question was whether "cannot persist" should be one derived
|
||||
predicate rather than two places each testing a different symptom. It should,
|
||||
and the reason is not tidiness — it is that the enumeration cannot be
|
||||
completed.** FCST-04's M4 enforced the rule on the symptom that was in front of
|
||||
it. FCST-05's HIGH was the same shape — an `except` clause naming the one
|
||||
content-failure member somebody had met (`json.JSONDecodeError`) instead of the
|
||||
`ValueError` family — and its fix was to name the family. This is the third instance in
|
||||
one module, and it is the last place in this file where the rule can be spelt
|
||||
twice. A fourth cause — an `OSError` subclass, a filesystem that accepts the
|
||||
`mkstemp` and refuses the `os.replace`, a container whose volume is unmounted
|
||||
mid-refresh — needs no fourth branch now, because none of them is asked about.
|
||||
|
||||
### Where the one question can be asked, which is the whole design
|
||||
|
||||
**"Can this store persist?" cannot be answered before the attempt.** A
|
||||
directory that exists and was writable a moment ago is not a write that
|
||||
completed; there is no pre-check that is not itself a guess, and adding one
|
||||
(`os.access`, a probe write) would be a *third* symptom test — precisely what
|
||||
the ticket rules out. So the predicate is an **outcome**:
|
||||
|
||||
_save() -> bool # True only if the file now holds what memory holds
|
||||
|
||||
Both ways of not persisting are the same answer from the same call: no durable
|
||||
volume returns `False` without touching the disk, an `OSError` out of the write
|
||||
returns `False` after recording why. `_remember()` reads that one answer and
|
||||
keeps only what it says was kept. **The `not self.ok` test in `_remember()` is
|
||||
gone** — not moved, gone — because there is nothing left for a second test to
|
||||
add, and a check in `test_price_history.py` 5d asserts its absence from the
|
||||
source.
|
||||
|
||||
⚠️ **That last check is deliberately a source check, and habit 4 is why it is
|
||||
allowed to be.** The claim "there are not two places testing two symptoms" is a
|
||||
claim about the *shape of the code*, not about a behaviour — the behaviour is
|
||||
identical whether the rule is enforced once or twice. Every other check in 5d
|
||||
is driven.
|
||||
|
||||
### What was NOT done, and why
|
||||
|
||||
- **Not a `can_persist()` helper.** It would have to answer before the attempt,
|
||||
which is the thing that cannot be done honestly, and it would be a second
|
||||
place to keep in step with the first.
|
||||
- **Not a probe write at `_open()`.** It would make `ok` mean something new, it
|
||||
would write to `/data` on every start, and a volume that is writable at 08:00
|
||||
and full at 14:00 would still defeat it. A predicate that can go stale is a
|
||||
symptom test wearing a predicate's name.
|
||||
- **Not making a failed write fatal.** §6, and this module's own opening: a
|
||||
broken cache must not be a broken controller. An add-on that will not start
|
||||
because `/data` filled up is a worse defect than the one being fixed. The
|
||||
store degrades; it does not raise. `_save()` reports and returns.
|
||||
|
||||
---
|
||||
|
||||
## 2. The MERGE is undone, not the store
|
||||
|
||||
When `_save()` answers no, `_remember()` restores the state the read started
|
||||
from — **not** an empty one.
|
||||
|
||||
The alternative considered was to clear `self._days` outright, on the argument
|
||||
that a store which cannot write is a store that should stop answering. It is
|
||||
wrong, and measurably so: the state being restored is the last one that **is on
|
||||
disk**, so answering it is not a promise the store cannot keep. A site whose
|
||||
volume fills at 14:00 goes on answering the yesterday it wrote at 13:30 —
|
||||
through the restart, because that record is in the file. What it stops doing is
|
||||
*adding* to it.
|
||||
|
||||
Clearing would throw away a good yesterday that survives a restart in order to
|
||||
punish a write that failed, and it would make the morning fill worse on exactly
|
||||
the day the operator is already dealing with a full disk.
|
||||
|
||||
That gives the file the invariant its rule was always describing, stated once
|
||||
in the class docstring:
|
||||
|
||||
> `self._days` **never holds a point the file does not.**
|
||||
|
||||
⚠️ Not *"mirrors the file"* — `_usable_days()` drops records the file still
|
||||
holds, and that asymmetry is deliberate. The only direction that can **invent a
|
||||
yesterday** is the one named.
|
||||
|
||||
⚠️ **And the snapshot is `copy.deepcopy`, not `dict(self._days)`.** A one-level
|
||||
copy shares the day *records*, and the merge rebinds `points` on the ones it
|
||||
touches — so the "restored" store would answer the merge that never reached the
|
||||
file. Measured: with a shallow copy, a store holding 12 persisted points
|
||||
answered **24** after a failed merge. It would also be correct only for as long
|
||||
as nobody makes the merge mutate a `points` list or a row in place, which is the
|
||||
same "correct for the conditions someone thought of" this ticket is about. The
|
||||
store holds at most `KEEP_DAYS + 2` records and writes 48 times a day; a copy
|
||||
that is right by construction is worth more than the allocations.
|
||||
|
||||
---
|
||||
|
||||
## What this does not change
|
||||
|
||||
- **No shipped path reaches the defect.** `/data` is the Supervisor's durable
|
||||
volume and this module is its only writer, so on a real install `_save()`
|
||||
succeeds and nothing above is on the hot path. The fix is that a guarantee the
|
||||
module states about itself now holds in the condition it did not.
|
||||
- **The failure was already visible and still is** — `_LOG.error("could not
|
||||
persist price history: %s")` and `last_error`, reported by both `words` and
|
||||
`yesterday()`'s reason. What changed is that it is now *acted on*.
|
||||
- **Nothing about the prices.** The series served is the feed's own, every
|
||||
carried point is still flagged provisional, and a broken cache is still a
|
||||
short horizon rather than a §11.4 degradation.
|
||||
@@ -285,11 +285,32 @@ cross-inverter `lo > hi` contradiction at `arbiter.py:114`. The only line this
|
||||
card changed there is **which** roster entry the claim is filed on: the one whose
|
||||
state machine produced the result, rather than `inverters[0]`.
|
||||
|
||||
## 6. FIXED BY SAFETY-78: the site loop contradicted a drain at N>=2
|
||||
## 6. FOUND AND NOT FIXED: the site loop still contradicts a drain at N>=2
|
||||
|
||||
Found here and pre-existing: below the pin the other pack had to charge, the
|
||||
allocator refused it and the site sat at 0 W. Fixed by a site `drain` floor;
|
||||
see [[SAFETY-78]] (`DECISIONS/SAFETY-78-drain-at-two.md`).
|
||||
Measured while wiring this card, and **pre-existing on `release/1.0` at
|
||||
`2d025ee`** — the same drain filed on `inverters[0]` produces the identical
|
||||
refusal with the two unit names swapped.
|
||||
|
||||
The maintenance SET **pins** its pack. The loop's target is a **site** claim.
|
||||
When the site target is *below* the pin — a 2000 W drain while the house imports
|
||||
less than that, which is most of a night — the only split that sums to the site
|
||||
target asks the sibling to **charge** while the maintained pack **discharges**,
|
||||
and `allocator.plan()` refuses it by name as circulating power. The refusal is
|
||||
correct and fails safe: the whole site goes to 0 W with an `ARBITER: REFUSED`
|
||||
line in the action log.
|
||||
|
||||
**The consequence an operator would see is a misdiagnosis.** The pack is held at
|
||||
0 W while its state machine believes it is draining, so after 12 h the DRAIN
|
||||
timeout fires and reports *"drain stuck"* — §12's words for a battery that is not
|
||||
following commands. The battery is fine; the site refused to allocate.
|
||||
|
||||
**Why it is not fixed here.** The fix is to change what the site loop's target
|
||||
*means* while a unit is pinned — regulate the meter using only the unpinned
|
||||
packs — which is loop and allocator territory and changes the integrator's
|
||||
subject. MAINT-05 moves which pack is pinned and nothing else about it. It is
|
||||
pinned as a running check (`test_maintenance.py` part 12) rather than left as a
|
||||
comment, so it cannot be forgotten or quietly tuned away, and it wants a card of
|
||||
its own.
|
||||
|
||||
## 7. FOUND AND NOT FIXED: `migrate_unit_keys()` has the same hole, and it is a LATCH
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# MAINT-10 — the maintenance start hour is local time, default 19:00
|
||||
|
||||
Owner decision 2026-09-29 (option A). `maintenance_start_hour` was compared
|
||||
with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and
|
||||
11:00 in winter. It is now read on the site's local clock, and the default moves
|
||||
to 19 so the drain runs into the evening and night rather than the solar day.
|
||||
|
||||
Evening drains meet higher house loads; SAFETY-83 (merged first) lets the
|
||||
peak guard outrank the drain.
|
||||
|
||||
## 1. Which zone
|
||||
|
||||
Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup,
|
||||
else the container's `TZ`, else UTC. One log line says which (WARNING only for
|
||||
UTC). Resolved once per start: a zone change in HA takes effect on the next
|
||||
add-on restart. HA can still be starting when the add-on starts (502 from the
|
||||
proxy, 2026-10-05); then `TZ` holds. HA pushes its zone to the Supervisor
|
||||
(hassio `push_config`), which stores it and sets it as the add-on's `TZ`
|
||||
(`docker/app.py`, Supervisor 2026.08.0), so the hour stays right. Only a `TZ` of
|
||||
UTC there logs a WARNING asking for a restart. No later re-resolve: the fallback
|
||||
already is HA's zone, so the rare UTC case gets a warning rather than more code.
|
||||
The add-on image has tzdata (checked in `amd64-base:3.20`); the CI image does
|
||||
not, so the tests grade DST on `tariff._EUTimezone` too.
|
||||
|
||||
## 2. DST
|
||||
|
||||
`maintenance.in_start_slot()` works from UTC instants only, so it does not
|
||||
depend on how a tzinfo resolves a missing or doubled wall time:
|
||||
|
||||
- normally the slot is the local hour `start_hour`;
|
||||
- clocks forward and the hour does not exist: the first hour after the jump;
|
||||
- clocks back and the hour happens twice: the first pass only. Without this an
|
||||
aborted start (90 min cooldown) would start again in the 120 min repeated hour.
|
||||
|
||||
Every day has exactly one 60 min slot for every hour 0-23: checked against
|
||||
zoneinfo for Europe/Brussels, America/New_York, America/Santiago (midnight
|
||||
transitions), Asia/Kathmandu (+05:45) and Australia/Lord_Howe (30 min DST)
|
||||
over all of 2026.
|
||||
|
||||
## 3. Existing installs
|
||||
|
||||
The Supervisor merges the shipped defaults under the STORED options, and saving
|
||||
the settings stores every key (the reference install's blob has all 76). So
|
||||
almost every existing install keeps its stored 10, which now means 10:00 local
|
||||
instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19.
|
||||
The add-on does not rewrite a stored option: a silent migration would override
|
||||
an operator's choice with no way to tell it apart from the old default. A site
|
||||
that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it
|
||||
back to 19 after the upgrade, or it starts two hours early. DOCS and the release
|
||||
note (drafted on the PR, for the version bump) say this.
|
||||
`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0.
|
||||
|
||||
## 4. Two inverters
|
||||
|
||||
`Controller` hands the same zone to every pack's `Maintenance`, so every pack's
|
||||
`wants_start()` reads the local hour. `arbitrate()` is unchanged: one pack
|
||||
cycles at a time, and the one that lost the slot starts in the first start
|
||||
slot after the other is done (a later day: a cycle outlasts its start hour). On the day the clocks go back, a pack freed during the second pass of
|
||||
a repeated hour does not start there. `test_maint10.py` part 10.
|
||||
|
||||
## 5. What does not change
|
||||
|
||||
Only the start decision (`Maintenance.wants_start`) and the Home screen's shown
|
||||
start time read the zone. `test_maint10.py` parts 9 and 10 run the real
|
||||
Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the
|
||||
same instant): every command, claim, reason and log line is identical. A
|
||||
`Maintenance` built without a zone reads UTC, as before.
|
||||
@@ -1,37 +0,0 @@
|
||||
# MAINT-11 - start or stop the maintenance cycle per battery
|
||||
|
||||
**Status: for review.** Tests: `test_maint11.py`. No option added.
|
||||
|
||||
**The fault.** At two or more batteries the page had one "start a battery cycle"
|
||||
button, and `_maint_button()` chose the battery. An operator could not cycle a
|
||||
chosen one (inverter B, new after storage, needed its first cycle).
|
||||
|
||||
## Rule
|
||||
|
||||
1. `maint_start` and `maint_abort` take an optional `unit`. With no unit they
|
||||
run the old code unchanged. The Batteries screen sends the unit from a
|
||||
per-battery Start cycle and Stop cycle button, at two or more batteries only.
|
||||
2. **A start while any battery holds the maintenance slot is refused, not
|
||||
queued.** The message names the battery that is cycling. One slot per site
|
||||
stays the rule, and the scheduler is untouched.
|
||||
3. Also refused, with a plain message: an unknown unit, a start on a battery
|
||||
that is switched off (battery modes on), and a stop on a battery that is not
|
||||
cycling. Nothing changes on a refusal.
|
||||
4. A stop on the cycling battery is today's abort: the cycle ends, then every
|
||||
inverter goes to 0 W.
|
||||
|
||||
## Why refuse and not queue
|
||||
|
||||
A queued start is a cycle that begins hours later, unseen, after the operator
|
||||
has moved on. A refusal is immediate and says why. The scheduler already
|
||||
queues by waiting time, so nothing is lost.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- Today's bare button never refused a battery that is refusing to give power
|
||||
(SAFETY-73); its drain waits. The per-battery start does the same.
|
||||
- A start on the battery that is already cycling is refused as "busy", where
|
||||
the bare button restarts its drain. The bare button is unchanged.
|
||||
- At one battery, a request that names the battery uses the new path, so a
|
||||
second start is refused instead of restarting the drain. Nothing sends a name
|
||||
at one battery, and the bare request is unchanged.
|
||||
@@ -1,109 +0,0 @@
|
||||
# MAINT-13 - a full pack that stops at 98-99 % is not a failed charge
|
||||
|
||||
**Status: for review.** Tests: `test_maint13.py`. No option added; every number
|
||||
is a constant in `app/maintenance.py`.
|
||||
|
||||
**What happened (live, B).** The drain ended 2026-10-04 18:24 UTC and the charge
|
||||
began. It ran in pieces behind the price gate, reached 99 % at 2026-10-06 04:00,
|
||||
fell back to 98 % at 04:15 while the loop still asked about -1370 W, and never
|
||||
reached `maintenance_soc_target` 100. At 06:24 (2160 min = 36 h of wall clock
|
||||
after the charge began) it aborted "charge stuck at 98 %". `last_completed` was
|
||||
not set, so B was still due and would have drained again from 98 % to 11 % at the
|
||||
next start hour, with no notice. Three faults: a pack may never report 100 %
|
||||
while the BMS balances; a failed cycle came back the next day; and the 36 h
|
||||
counted hours the price gate kept the pack waiting.
|
||||
|
||||
## 1. The charge is complete when
|
||||
|
||||
1. SoC >= target (as before), or
|
||||
2. SoC >= target - 3 (`CHARGE_NEAR_FULL_PCT`) and the SoC has not beaten its
|
||||
best for 60 min (`CHARGE_STALL_MIN`) of allowed charging, or
|
||||
3. the charging time (`CHARGE_TIMEOUT_MIN`, 2160 min) runs out at SoC >= target - 3.
|
||||
|
||||
Then the usual HOLD follows and `last_completed` is set after it. Each of 2 and
|
||||
3 logs one plain line ("the battery takes no more charge at 98 %, so the charge
|
||||
counts as done", "charge time is up at 98 %, close enough to full ...").
|
||||
|
||||
**Why "SoC has not beaten its best", and not asked/delivered watts.** SoC is the
|
||||
one signal the live record gives us (99 at 04:00, 98 at 04:15); the asked power
|
||||
was -1370 W but no delivered figure was kept, so a watt threshold (X, Y) would be
|
||||
invented. SoC also covers the case where power flows but nothing is stored.
|
||||
**Why 3 points.** The card's band; at target 100 it is 97, and the live pack sat
|
||||
at 98-99. **Why 60 min.** A judgement, not a measurement: we have no per-minute record of
|
||||
the pack taking charge near full. The one live fact is 2 h 24 min (04:00 to
|
||||
06:24) without a new high, so 60 min is well inside it and long enough that a
|
||||
pack still creeping up (one new high within the hour) keeps resetting the wait.
|
||||
On the live run the rule fires after 60 gate-open minutes past the 99 %: 05:00
|
||||
UTC at the earliest, before 06:24 if the gate was open. If the pack turns out to
|
||||
step up more slowly than that, raise the constant. Falling back by one point
|
||||
does not reset the wait; only a new best does.
|
||||
|
||||
## 2. A failed cycle backs off 7 days
|
||||
|
||||
A drain stuck 12 h or a charge that times out below target - 3 is a failure
|
||||
(`FAIL_BACKOFF_DAYS` = 7). The machine goes IDLE as before, `last_failed` is
|
||||
stored, and `wants_start()` is false until 7 days later. The operator's forced
|
||||
start ignores it; a cycle that completes after the failure ends it. An operator
|
||||
abort, an interlock stop and a switched-off pack are not failures.
|
||||
|
||||
**Why 7 days, not the 28 day interval.** A judgement: a failure may have a
|
||||
short cause (several dull days with no cheap hours, a pack switched off), and
|
||||
waiting the full interval leaves the coulomb counter uncalibrated for a month
|
||||
more. A week is long enough that a bad pack costs one drain a week, with one
|
||||
notice a week, not one a day. **Cost.** A truly faulty pack still drains once a week until someone looks;
|
||||
the notice says so each time.
|
||||
|
||||
**Notice, once.** The tick that fails carries `failed`; the Controller sends one
|
||||
persistent notification "Battery cycle did not finish" (EN, with the Dutch
|
||||
below it; per pack at N=2). It is not repeated, and Home Assistant restarts
|
||||
wipe it, so the status page shows a "Maintenance paused" row with the date for
|
||||
as long as the wait lasts. The operator may also see the log lines.
|
||||
|
||||
## 3. The timeout counts only allowed time
|
||||
|
||||
`Controller._charge_gate_open()` says whether the pack may charge now. It is
|
||||
true when no price gate is set up (no price entities, no fixed tariff: nothing
|
||||
holds the charge back, and `cheap` is always false there), in a cheap window, or
|
||||
while the pack is really taking charge (battery power at or below -50 W, which is
|
||||
sun outside cheap hours). It is false only when a gate exists, is shut, and the
|
||||
pack is not charging; a pack asked for charge that delivers nothing then does not
|
||||
count, which is the case the clock cannot judge. A site with a price provider
|
||||
(TARIFF-11) and no fixed tariff counts as having no gate: the provider's plan
|
||||
reaches only the Tariff row, never `cheap`, so no cheap window ever opens a grid
|
||||
top-up there and only the sun charges the pack, as on a site with no tariff. Its
|
||||
time counts in full. When a provider plan starts to drive `cheap`,
|
||||
`_price_gated` must become true for it. Whether a fixed tariff is set up is
|
||||
worked out once an hour, not on every tick. Only open ticks add to `open_min`; one tick
|
||||
adds at most 5 min, so a gap (add-on down) adds nothing. The stall wait pauses
|
||||
while the gate is closed. The accounting is written to the state file at most
|
||||
every 5 min (`charge_track`); a restart loses at most that. A state file from
|
||||
before this change, or with no record for the current charge, starts from the
|
||||
wall-clock minutes so far, which is the old behaviour.
|
||||
|
||||
**Backstop.** A gated site whose gate never opens (no cheap hours, no sun) would never time
|
||||
out, so a charge also ends after 7 days of wall clock (`CHARGE_WALL_TIMEOUT_MIN`):
|
||||
complete at >= target - 3, a failure below.
|
||||
|
||||
**MAINT-12.** This is the smallest change that makes the timeout honest: one
|
||||
boolean in, one counter. MAINT-12's block plan can replace the boolean (the
|
||||
minutes it plans are the allowed ones) without touching the rest. Two things it
|
||||
must keep: the back-off is in `wants_start()`, so any new entry point (such as
|
||||
`price_forecast.begin_phase`, which is not wired today) must go through it or
|
||||
check `backoff_until()`; and `gate_open` must stay "the pack is allowed to
|
||||
charge now". The peak guard pausing the charge shaping is not seen as a closed
|
||||
gate: that time still counts.
|
||||
|
||||
## 4. What does not change, and what this does not cover
|
||||
|
||||
- The drain, the HOLD, the held-battery rules and `arbitrate()` are unchanged. A
|
||||
backed-off pack does not want a start, so at N=2 the other pack may take the
|
||||
slot. One pack at a time at N=1 and N=2.
|
||||
- `last_failed` is a maintenance key and moves with the others when a second
|
||||
inverter is added (`MAINT_UNIT_KEYS`). The charge record is not moved; it
|
||||
restarts from the wall-clock minutes.
|
||||
- It does not find out why a pack stopped. A pack that stops at 96 % and never
|
||||
gets near is still a failure.
|
||||
- The Dutch text is only in the notification. The status row and the log lines
|
||||
are English, like the rest of the status page.
|
||||
- It does not change the target: a site that wants a different stop point sets
|
||||
`maintenance_soc_target`.
|
||||
@@ -1,68 +0,0 @@
|
||||
# MAINT-14 - no SoC reading in a maintenance cycle
|
||||
|
||||
**Status: for review.** Tests: `test_maint14.py`. No option added; the one new
|
||||
number is a constant in `app/maintenance.py`.
|
||||
|
||||
**The fault.** `Maintenance.tick()` returned early when the SoC was `None`,
|
||||
before any timeout check. (1) A charge on a pack whose SoC reading was lost
|
||||
never ended: no timeout, no back-off, no notice, and the 7-day backstop of
|
||||
MAINT-13 sat behind the same return. (2) A drain kept commanding `discharge_w`
|
||||
with an unknown level, which breaks the TEL-08 rule that an unknown level never
|
||||
allows discharge.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Charge, no SoC.** The charge timeout (`CHARGE_TIMEOUT_MIN`, gate-open
|
||||
minutes) and the 7-day wall backstop still run. When one fires: if the last
|
||||
SoC seen was >= target - 3 the charge counts as done (the MAINT-13 "close
|
||||
enough to full" rule, HOLD follows); otherwise it fails: MAINT-13 back-off
|
||||
and its one notification. If no SoC was ever seen, it fails.
|
||||
2. **Drain, no SoC.** DRAIN holds at 0 W and owns the setpoint, exactly as HOLD
|
||||
does. The 12 h drain timeout still runs and fails as before.
|
||||
3. **SoC comes back.** Nothing is recorded; the drain continues at the drain
|
||||
rate, the charge carries on, and the stall wait of MAINT-13 restarts from
|
||||
what it was: it paused while the reading was missing, so a gap never counts
|
||||
as "no more charge".
|
||||
4. **HOLD** is unchanged (0 W).
|
||||
5. **Log.** One line when a drain starts holding for this reason ("the battery
|
||||
level is unknown, so the drain holds at 0 W until it is back"), and one line
|
||||
when the level has been missing for `NOSOC_NOTE_MIN` = 15 min. Both are said
|
||||
once per episode; the old line that repeated every tick is gone.
|
||||
|
||||
## Why
|
||||
|
||||
- **"Last seen", not "best seen".** A pack that was at 99 and then dropped to 50
|
||||
before the reading went is not close to full. Only the last reading counts.
|
||||
It is saved with the charge record (`charge_track.last`), and saved at once
|
||||
when the reading first goes missing, so a restart mid-gap still knows it
|
||||
(within the usual 5 min save interval otherwise).
|
||||
- **Failure when nothing was seen.** With no level we cannot call a pack full.
|
||||
The failure costs a 7-day wait and one notice; calling it done costs a
|
||||
missed balance and a month-long false "complete". The first is the honest one.
|
||||
- **Timeouts count the same minutes as with a reading.** Gate-open charging
|
||||
minutes only; the wall backstop for a gate that never opens.
|
||||
- **15 min.** A judgement. A poll glitch of a few minutes should not write a
|
||||
line; a reading lost for a quarter of an hour is worth one. Raise it if the
|
||||
action log shows noise.
|
||||
- **0 W, not "stop the phase".** Aborting a drain on a short gap would throw
|
||||
away the part already drained. Holding at 0 W loses nothing and the 12 h
|
||||
timeout is the limit.
|
||||
|
||||
## Cost
|
||||
|
||||
- A drain that loses its reading stops draining, and the 12 h clock keeps
|
||||
running. A gap of many hours can therefore fail a drain that was working. The
|
||||
back-off then applies (7 days).
|
||||
- A charge whose reading is lost early (last SoC below target - 3) fails at the
|
||||
timeout, even if the pack was in fact filling. The same 7-day wait applies.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- **HOLD with no SoC**: closed by MAINT-15 (`MAINT-15-hold-ends-without-soc.md`).
|
||||
- It does not tell the operator why the reading is missing (sensor, register,
|
||||
link). It does not reuse the stale-input gate; it is the cycle's own rule.
|
||||
- The charge itself is still driven by the control loop, which has its own
|
||||
handling of a missing SoC (TEL-08 for the loop is a separate card).
|
||||
- A restart loses at most 5 min of accounting, as in MAINT-13. The "unknown for
|
||||
long" line may be said again after a restart: the episode start is in memory.
|
||||
- Text added is two log lines, English only, like the other log lines.
|
||||
@@ -1,47 +0,0 @@
|
||||
# MAINT-15 - the balancing hold ends on time with no SoC reading
|
||||
|
||||
**Status: for review.** Tests: `test_maint15.py`. No option added.
|
||||
|
||||
**The fault.** `step()` sent every tick with no SoC to `_no_soc()`, and for
|
||||
HOLD that returned 0 W without the time check. A hold that lost its reading
|
||||
never ended: `last_completed` was never written and the next due date never
|
||||
moved. Harmless for power (0 W), but the cycle was stuck. This was left open
|
||||
in MAINT-14.
|
||||
|
||||
## Rule
|
||||
|
||||
1. **HOLD ends at `hold_min` whether or not there is a reading.** One helper,
|
||||
`Maintenance._hold()`, holds the time check and is used by both paths. When
|
||||
the time is up it writes `last_completed`, goes IDLE and logs the same
|
||||
"cycle COMPLETE" line. Before the time is up it commands 0 W as before.
|
||||
2. **Text.** The 15-min "no battery level" line in HOLD now reads "The hold
|
||||
still ends at its normal time." Still once per episode.
|
||||
|
||||
## Why
|
||||
|
||||
HOLD is reached only after the charge counted as done. Every entry into HOLD
|
||||
is in `step()` or `_no_soc()` and follows a charge that reached the target,
|
||||
stalled near full (MAINT-13) or timed out near full (MAINT-13/14). Nothing else
|
||||
sets the phase to HOLD (`_enter(HOLD` has four call sites, all after a done
|
||||
charge). The hold is a wait for the BMS to balance; it never needed the level.
|
||||
So a hold that ends with no reading is a completed cycle. The "cycle COMPLETE"
|
||||
line is true without a reading: it names minutes of hold, not a level.
|
||||
|
||||
## Cost
|
||||
|
||||
- If the level was lost during the hold, the cycle is recorded as complete
|
||||
without anyone seeing the final level. The charge had counted as done, so this
|
||||
is the same trust MAINT-14 already gives a charge that ends with no reading.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- It does not check that the pack is still full at the end of the hold.
|
||||
- It does not say why the reading was lost.
|
||||
- With a reading, nothing changes. `test_maint15.py` section 7 compares the new
|
||||
HOLD tick by tick with a frozen copy of the release/1.0 HOLD branch (phase,
|
||||
setpoint, owner, events, `last_completed`) at four `hold_min` values. A wider
|
||||
trace of the whole cycle (800 seeded runs against db9e231) was run outside the
|
||||
repo; its script and log are kept under `scratch/MAINT-15/` of the dev
|
||||
workspace, not in this repository.
|
||||
- A state file written by an older version with `phase: hold` loads and ends on
|
||||
time; the file is not changed in format.
|
||||
@@ -1,334 +0,0 @@
|
||||
# OPT-09 - the weights parameter, and what it is honestly for
|
||||
|
||||
**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `3d29993` (rebased onto the UX-02 merge; it read `3368231`, and before that `464d398` - each rebase moved it and review caught it each time, which is why the counts in `test_translations.py` are now computed rather than typed)
|
||||
(`git merge-base HEAD 3368231`; the branch was rebased and this line used to name
|
||||
the pre-rebase base, `464d398`).
|
||||
|
||||
## 0. What was actually wrong
|
||||
|
||||
`allocator.plan()` has taken a `weights` argument since ALLOC-01, `_fill()`
|
||||
consumes it in two passes, and **nothing had ever passed one**. Confirmed on the
|
||||
base commit:
|
||||
|
||||
arbiter.py:535 def resolve(claims, *, units=None, weights=None, live=None, ...)
|
||||
main.py:2422 resolve(claims, units=self.units, live=self.live_units(), ...)
|
||||
|
||||
So `weights` was None on every tick of every install, `plan()` turned that into
|
||||
1.0 per unit, and the site target was split **50/50 regardless of what either
|
||||
pack could do**. The card was filed as "per-pack arbitrage sizing"; the architect
|
||||
pass found the sharper content, and it is the sharper content that this branch is
|
||||
mostly about.
|
||||
|
||||
## 1. Two instruments, and they are not interchangeable
|
||||
|
||||
`allocator.py` states the rule this card is built on, and it is mechanical rather
|
||||
than advisory:
|
||||
|
||||
> WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is
|
||||
> "please do not use this unit", and it is honoured while any other unit has
|
||||
> headroom - but it is NOT a constraint.
|
||||
|
||||
`_fill()` runs a deliberate **last-resort pass** over the zero-weight units when
|
||||
the preferred ones saturate, so that a preference can never manufacture a
|
||||
standing error at the meter. A pack at its own cut-out that is only *weighted*
|
||||
to zero is therefore still commanded to discharge the moment its sibling fills
|
||||
up. So this branch ships both:
|
||||
|
||||
| | question | mechanism |
|
||||
|---|---|---|
|
||||
| `packshare.envelope()` | MAY this pack be asked, at all, in this direction? | a `pack` UNIT claim at `P_SAFETY`, `hi = 0` at the cut-out, `lo = 0` at 100 % |
|
||||
| `packshare.weights()` | GIVEN that both may, which should carry more? | energy headroom in kWh, handed to `resolve()` |
|
||||
|
||||
## 2. ⚠️ What the weights CANNOT do
|
||||
|
||||
ARCH-01's convex-combination argument - the gain at the meter is
|
||||
`sum(a_i * g_i)`, so it lies in `[min g_i, max g_i]` for **any** split -
|
||||
guarantees that a bad split cannot **destabilise** the site. It says nothing
|
||||
about whether the site can **regulate**. A unit at its energy limit has
|
||||
`g_i = 0`; the combination is still inside the interval and the load it was
|
||||
asked to carry simply does not move.
|
||||
|
||||
Measured, `test_packshare.py` part 8, two units rated 2000 W each, B sitting on
|
||||
its own floor and therefore answering 0 W to any discharge command. The plant is
|
||||
modelled in that file (`lag_s = 0`, obeys within one cycle, bounded by its
|
||||
rating) and the loop is the real `compute()`:
|
||||
|
||||
| load | arm | site target | shares | grid | unmet |
|
||||
|---|---|---|---|---|---|
|
||||
| 1500 W | shipped | +3000 W | A +1500, B +1500 | 0 W | 0 W |
|
||||
| 1500 W | weights | +1500 W | A +1500, B +0 | 0 W | 0 W |
|
||||
| 1500 W | envelope | +1500 W | A +1500, B +0 | 0 W | 0 W |
|
||||
| 3000 W | shipped | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
|
||||
| 3000 W | weights | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
|
||||
| 3000 W | envelope | +4000 W | A +2000, B **+0** | +1000 W | **+2000 W** |
|
||||
|
||||
Three things are worth reading off that table, and the second and third are the
|
||||
uncomfortable ones:
|
||||
|
||||
1. At a load the healthy pack can cover, the weights alone deliver the whole
|
||||
improvement. The shipped arm reaches the same meter reading only by winding
|
||||
the site target to **twice the load** and standing a +1500 W command on a
|
||||
battery that answers nothing, permanently saturation-frozen. That is not a
|
||||
working site, it is a site with no headroom left for the next load step.
|
||||
2. At a load it cannot cover, **the weights stop working entirely**. The
|
||||
weighted arm and the shipped arm command the identical +2000 W into the empty
|
||||
pack, because `_fill()`'s last-resort pass hands the remainder to the
|
||||
zero-weighted unit. Same shares, same watts, same error.
|
||||
3. **And the envelope does not rescue the delivery either - it names the
|
||||
shortfall.** Same 2000 W delivered, same 1000 W standing at the meter. What
|
||||
changes is that the empty pack is commanded 0 W instead of 2000 W, and 2000 W
|
||||
of site target is *reported* as unplaceable instead of appearing to have been
|
||||
placed.
|
||||
|
||||
> **Weights are a comfort choice for STABILITY and NOT for DELIVERY.** Nothing in
|
||||
> this add-on shifts a unit's load to its sibling once the sibling is at its
|
||||
> rating, and weighting a failing unit down is not a remedy for it.
|
||||
|
||||
## 2b. ⚠️ The card's own premise is only half true
|
||||
|
||||
The card opens with *"a unit that stops delivering its share (empty, full,
|
||||
curtailed, wedged) parks the site with a standing grid error and no alarm."*
|
||||
After this branch: the **ask** stops, and while the sibling has headroom the site
|
||||
regulates to zero error instead of winding to twice the load. The **standing grid
|
||||
error does not stop** once the sibling is at its rating - §2's table measures
|
||||
1000 W standing in all three arms - and there is **still no alarm** for it, only
|
||||
a report. SAFETY-42 will not alarm on a pack at its own floor; that is one of its
|
||||
acceptance criteria and its evidence is that the benign and the wedged case are
|
||||
indistinguishable in the power domain.
|
||||
|
||||
So the two cards together mean: an empty pack limiting the site is now REPORTED
|
||||
(`unmet_w`, an action-log line) and still, deliberately, NOT ALARMED. Whether it
|
||||
should be alarmed is a decision - and the alarm it would need is about a site's
|
||||
stored ENERGY, not about an inverter's delivery, so it is not this card's and not
|
||||
SAFETY-42's. Recorded rather than quietly closed against a premise this card only
|
||||
half meets.
|
||||
|
||||
## 3. Which floor, and which ceiling - the two places this could have become policy
|
||||
|
||||
**The floor is `bms_soc_floor` alone, never `peakguard.pack_floor_pct()`.** That
|
||||
function's conjunction with `peak_soc_floor` is right for the peak guard and
|
||||
wrong here: SAFETY-25 decided (2026-09-02, Option A) that `peak_soc_floor` bounds
|
||||
the peak guard **only**, ordinary self-consumption discharges straight past it,
|
||||
and closing an envelope at it would turn an operator preference into a hard stop
|
||||
on the grid-following loop - the memo's **deferred Option B**, built by accident.
|
||||
`delivery.py`'s `account()` made the same choice one step out, for the same
|
||||
reason. Graded: a pack at 15 % with a 10 % cut-out is not closed, though a 20 %
|
||||
EMS floor would have closed it.
|
||||
|
||||
**The ceiling is 100 %, and the coarseness is the honest answer.** There is no
|
||||
per-pack charge ceiling anywhere in this add-on - `delivery.py` says so at length
|
||||
and calls it a card, and it is why SAFETY-42 is armed on discharge shares only.
|
||||
The only other number available is `arbitrage_soc_ceiling`, which is one
|
||||
site-wide **trade policy**; filing it as a `P_SAFETY` envelope on the loop would
|
||||
be the mistake above in the charge direction. So the envelope closes only where
|
||||
the ask is physically impossible, and everything between is left to the weights -
|
||||
which is the right instrument for a graded preference and is honest about being
|
||||
only a preference.
|
||||
|
||||
## 4. N=1, and the tripwire this had to argue past
|
||||
|
||||
`actuator_for()` routes **every** source to the site at one inverter, so a `pack`
|
||||
interval filed there would bound the SITE TARGET rather than a share. `main.py`
|
||||
therefore does not file it at all below a roster of two - the same guard, for the
|
||||
same reason, that SAFETY-30's `sign_site` carries. AC 4 ("at N=1 allocation is
|
||||
byte-identical") is that `if`.
|
||||
|
||||
`test_peakguard.py` part 10f pins the claim-source set against `main.py`'s own
|
||||
AST precisely so that a state-of-charge-conditioned claim cannot arrive quietly.
|
||||
`pack` is state-of-charge-conditioned, so the argument is written at that set
|
||||
rather than the count edited: the number is not an option, the claim is
|
||||
unit-scoped so it cannot bound the site target, it is not filed at N=1, and part
|
||||
10f's own note names this as the revisit trigger - *"under ALLOC-01 the reserve
|
||||
becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a ninth claim
|
||||
here"*. Stage 1 is how an interval reaches the allocator and stage 1's input is
|
||||
claims, so a unit-scoped claim is the shape that sentence asks for.
|
||||
|
||||
**What N=1 still does not get, said plainly:** nothing stops the loop discharging
|
||||
into the BMS cut-out at one inverter. That is unchanged, it is SAFETY-25's
|
||||
deferred option, and the BMS is what stops it.
|
||||
|
||||
## 5. SAFETY-42 - the same hazard from the opposite side
|
||||
|
||||
The seam is **the state of charge** - and they overlap in one direction, which
|
||||
is what the rest of this section is about:
|
||||
|
||||
* **OPT-09 stops the site ASKING** a pack whose own SoC says it cannot deliver.
|
||||
* **SAFETY-42 alarms** when a pack that said it could does not.
|
||||
|
||||
A wedged unit reads a healthy SoC, so **no envelope closes** - that half is
|
||||
absolute, and it is why this card cannot stop the ask on a wedged box. A pack at
|
||||
its floor is `account()`'s explained case, so SAFETY-42 stays silent - and after
|
||||
this card that ask does not arrive in the first place, which removes the benign
|
||||
arm from the detector's input rather than teaching it a new discriminator.
|
||||
|
||||
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWED SAFETY-42's DETECTION WINDOW.
|
||||
THAT WAS A DETECTION REGRESSION AT N=2, IT WAS NOT FIXED HERE, AND SAFETY-43
|
||||
CLOSED IT - see the block at the end of this section.** An earlier draft of
|
||||
this section said "no envelope closes and no weight moves - OPT-09 cannot see it
|
||||
at all". The second clause was **false**, and it was load-bearing for the claim
|
||||
that the two cards do not overlap. A weight is a function of the pack's ENERGY
|
||||
HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever
|
||||
its headroom differs from its sibling's - which is the ordinary case this card
|
||||
exists to exploit. And `delivery.delinquent()`
|
||||
named a unit only while its `short_by()` gap exceeded `min_w =
|
||||
Tuning.saturation_w`, and that was **500 W in absolute watts** with nothing
|
||||
else beside it. Shrinking a unit's share shrinks its gap. Shrink the
|
||||
share far enough and the gap drops below 500 W while the unit is still delivering
|
||||
nothing, and the alarm stops firing.
|
||||
|
||||
Measured on the closed loop - real `compute()`, real `plan()`, real
|
||||
`delinquent()`, modelled plant, B wedged at 15 % against its own 10 % floor so
|
||||
nothing closes and `account()` explains nothing, i.e. a SAFETY-42 **true
|
||||
positive** by that card's own definition:
|
||||
|
||||
load 1500 W equal weights (shipped) named on 199/200 cycles
|
||||
shares {a:1500, b:1500} grid +0 W named=['inverter-b']
|
||||
load 1500 W OPT-09 weights named on 0/200 cycles
|
||||
shares {a:1505, b: 125} grid -5 W named=(nobody)
|
||||
|
||||
⚠️ That plant is modelled in `test_packshare.py`, and **inverter B does not
|
||||
exist** (expected ~2026-09-12): it is two copies of A driven with different
|
||||
numbers, and B's 4600 W / 20.48 kWh / 10 % floor are from the datasheet and the
|
||||
serial. The claim is about the arithmetic of the split and the threshold, which
|
||||
needs no hardware, and not about a GW5000S-BP.
|
||||
|
||||
At a load the healthy pack can cover - the regime where nothing else tells the
|
||||
operator anything, `unmet_w` is 0, the meter is satisfied and no action-log line
|
||||
fires - a wedged inverter goes from named on essentially every cycle to **never
|
||||
named**. The suppression window widens from `site target < 2 * min_w` = 1000 W to
|
||||
`site target < min_w * (w_A + w_B) / w_B`, which on that fixture is **6500 W** of
|
||||
a 9600 W site; above it - once the healthy pack is at its own rating and
|
||||
`_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm
|
||||
returns. ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
|
||||
|
||||
`test_packshare.py` part 8b graded all of it and **pinned the unflattering
|
||||
behaviour**, so that the day it changed a named check would move. It has moved.
|
||||
|
||||
### ⚠️ CLOSED BY SAFETY-43 - THIS IS NO LONGER A DISCLOSED RESIDUAL
|
||||
|
||||
This section used to end *"until then this is a disclosed residual [...] at two
|
||||
inverters, a wedged unit with materially less energy headroom than its sibling
|
||||
can go unreported at ordinary house loads"*. That sentence is **retired, not
|
||||
softened**, and what replaces it is a measurement rather than an intention.
|
||||
|
||||
`delivery.delinquent()` is now relative to the ask **as well as** absolute:
|
||||
|
||||
named if short_by() > min_w OR short_by() > frac * share
|
||||
|
||||
with `frac = delivery.DELIVERY_SHARE_FRACTION`. A unit delivering 0 of 125 W is
|
||||
100 % delinquent and is named; a unit delivering 0 of 1500 W was already named
|
||||
and still is. On the **same fixture, under the same weights**, part 8b now
|
||||
grades both rules in one run - `share_frac=None` is SAFETY-42's absolute arm,
|
||||
exactly:
|
||||
|
||||
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
|
||||
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
|
||||
|
||||
⚠️ **`min_w` WAS NOT LOWERED, AND THAT IS THE HALF THAT MATTERS HERE.** The
|
||||
absolute arm's suppression window is still exactly the arithmetic above - 1000 W
|
||||
at equal weights, 6500 W at these - and part 8b still asserts both numbers.
|
||||
Lowering `saturation_w` would have traded a missed alarm for a false one on
|
||||
every unit at once, which is SAFETY-41's four-attempt regime. SAFETY-43 added a
|
||||
**second** way to be named and left the first where it was.
|
||||
|
||||
⚠️ **AND WHAT OPT-09 STILL DOES NOT DO IS UNCHANGED.** This card stops the site
|
||||
ASKING a pack whose own SoC says it cannot deliver; it does not re-allocate a
|
||||
wedged unit's watts, and SAFETY-43 did not make it. The alarm got louder; the
|
||||
loop did not move. The derivation of `frac` - swept over the same 2640 no-fault
|
||||
runs that produced `delivery_hold_s`'s floor - is in
|
||||
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md`.
|
||||
|
||||
So the seam, stated accurately **as of SAFETY-43**: OPT-09 stops the ask, and it
|
||||
still narrows the ABSOLUTE arm's window for a low-headroom unit exactly as
|
||||
described above - but it no longer narrows the DETECTOR, because the detector no
|
||||
longer reads only that arm.
|
||||
|
||||
⚠️ **And OPT-09 supplies the mechanism SAFETY-42 said was missing, without
|
||||
connecting it.** That module's rejection of re-allocation rests on *"the only
|
||||
re-allocation this add-on can express is ALL OR NOTHING [...] a partial
|
||||
re-allocation is what would make acting safe, and the allocator has no way to
|
||||
express it today"*. Passing weights **is** that expression. Wiring delinquency
|
||||
into them is deliberately **not** done here: it would be acting on the alarm,
|
||||
which SAFETY-42 refused with rig evidence (a unit derated to 50 % is 400 W worse
|
||||
dropped than kept, and no instrument this add-on has can tell it from a wedged
|
||||
one), and - see §2 - a weight cannot stop a unit being commanded anyway. That is
|
||||
a card, and this paragraph is its input.
|
||||
|
||||
## 6. Per-pack arbitrage sizing, and the half that is deliberately not moved
|
||||
|
||||
`arbitrage.decide()` was handed `site_max_w` on **both** sides, so a two-pack
|
||||
site would buy at the sum of the ratings whatever either pack's state of charge
|
||||
was. It is now sized to the packs whose own SoC permits that direction
|
||||
(`Controller.trade_power_w()`), using **arbitrage's own** bounds rather than the
|
||||
BMS cut-out - sizing a trade is the trade's business; permitting a command is the
|
||||
pack's.
|
||||
|
||||
Two honest limits:
|
||||
|
||||
* **The sell side is a no-op on every roster today.** `decide()` is handed
|
||||
`pack_floor_soc` - the emptiest pack - so a sell is refused outright whenever
|
||||
*any* pack is at or under the floor, i.e. in exactly the states where the sum
|
||||
could be smaller than the site rating. The arithmetic is graded directly
|
||||
(`test_packshare.py` part 9); it becomes reachable the day that gate is
|
||||
per-pack.
|
||||
* **Moving that gate is not this card's.** Sizing a sell off the *fullest* pack
|
||||
would let the trade proceed while the emptiest pack was below
|
||||
`arbitrage_soc_floor`, and the only per-pack instrument that could keep it out
|
||||
of that pack is the weights - which are a preference, and `_fill()` will hand
|
||||
it watts anyway. Doing it safely needs an envelope that depends on WHICH CLAIM
|
||||
WON the tick, which the arbiter's partition does not express. §2 is why that is
|
||||
a real objection and not a hedge.
|
||||
|
||||
`arbitrage.decide()` also now refuses a **0 W sizing** by name (`IDLE_NO_SIZE`),
|
||||
because a `Claim.set(..., 0.0)` at `P_ARBITRAGE` is a park order that outranks
|
||||
the loop - the failure the note on `claim()` is already written against. The
|
||||
sizing can never be zero on a tick that trades today; that is a coincidence of
|
||||
the existing gate, and this makes it structural instead.
|
||||
|
||||
## 7. The fixture, which is the hard part
|
||||
|
||||
Two packs that differ in only ONE of SoC and capacity cannot separate a
|
||||
SoC-aware weighting from a capacity-aware one; two identical packs cannot
|
||||
separate ANY weighting from any other. `test_packshare.py` part 1 grades the
|
||||
fixture itself before anything is asserted with it - four candidate schemes
|
||||
(equal, power rating, SoC only, energy), four distinct splits of the same
|
||||
1000 W:
|
||||
|
||||
equal A +500, B +500
|
||||
power A +521, B +479
|
||||
soc only A +125, B +875
|
||||
energy A +97, B +903
|
||||
|
||||
and the counterfactuals: on two identical packs all four collapse to one answer;
|
||||
at the same SoC, `soc only` collapses onto `equal`; at the same capacity,
|
||||
`energy` collapses onto `soc only`.
|
||||
|
||||
## 8. Provenance of every number
|
||||
|
||||
* A: **5000 W, 15.36 kWh, 10 % floor** - measured (GW5000S-BP, register 0x0560).
|
||||
* B: **4600 W, 20.48 kWh, 10 % floor** - **FROM DATASHEET AND SERIAL**
|
||||
(`goodwe/NOTES.md` §1.1b, GW5048D-ES `55048ESU22AW0992`). ⚠️ **Inverter B does
|
||||
not exist** (expected ~2026-09-12).
|
||||
* Every two-unit run in `test_packshare.py` is arithmetic over intervals and
|
||||
weights, or a plant modelled in that file. `sim/plantsim.py:216`'s `INVERTER_B`
|
||||
is **inverter A copied** with `measured=False`, so any two-inverter simulation
|
||||
result anywhere in this project is **two copies of A**.
|
||||
* No rig was taken, no hardware was touched, and nothing here is evidence about
|
||||
a second GoodWe inverter.
|
||||
|
||||
## 9. Config
|
||||
|
||||
`capacity_kwh` joins the `inverters` roster as an **optional** per-entry key
|
||||
(`float(0.1,200)?`) - the opposite choice to SAFETY-32's required
|
||||
`bms_soc_floor`, and for that card's own reason: a missing cut-out would have to
|
||||
be *defaulted*, and one default for two batteries protects the shallower pack
|
||||
with the deeper pack's number, whereas a missing capacity has an honest answer -
|
||||
no opinion about the split, every unit weighs 1.0, which is exactly what shipped.
|
||||
|
||||
It is **all or nothing across the roster** and rule 9 refuses a partial one: a
|
||||
pack measured in kWh beside a sibling weighing a dimensionless 1.0 is not a
|
||||
comparison, `weights()` answers "no opinion" for the whole roster in that case,
|
||||
and a key that silently changes nothing is worse than one that refuses to load.
|
||||
|
||||
There is deliberately **no flat `capacity_kwh`**: at one inverter there is no
|
||||
split to weight, so it would be an option an operator can set and nothing reads.
|
||||
@@ -1,203 +0,0 @@
|
||||
# OPT-11 - battery modes, per battery
|
||||
|
||||
A battery's mode (on/off, Solar first, Leftover solar) is a soft unit limit at
|
||||
`P_MODE` = 35 that always holds 0 W. A SET above 35 (a safety stop) overrides
|
||||
it; a limit above 35 (the peak guard) makes it give way only as far as that
|
||||
limit needs, and a battery its mode holds back then carries only what the
|
||||
others cannot (`arbiter._unit_modes()`, `_site_mode()`). Design and owner answers: `goodwe-session/OPT-11-design.md` (2026-09-27);
|
||||
P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
|
||||
- Off by default (`battery_modes_enabled`); off, commands are byte-identical to
|
||||
a5dddad (`test_modes.py` part 1).
|
||||
- Off is a mode range, so the peak guard can still use an Off battery when the
|
||||
others cannot cover the floor (design sections 2-3). Whether Off should be
|
||||
absolute is an open owner question.
|
||||
- Review (PR #149): releasing only when the peak floor bound S let a trade won
|
||||
above the floor keep the modes and place less than the floor, and let a
|
||||
capped grid charge release an Off battery. Both are graded in
|
||||
`test_arbiter.py` part 11. The loop bound leaves a side that reaches the
|
||||
rail to the rail, so SAFETY-03 still flags a loop winding into it.
|
||||
- P2 (HA entities): a select and a switch per battery, only while modes are
|
||||
on; commands go through `handle_action()`, the page's path. A retained
|
||||
command is dropped, not applied: the store already holds the last choice,
|
||||
and a replay after a restart could otherwise undo a newer one. The config
|
||||
topics announced are kept in the store (`mqtt_mode_configs`), so turning
|
||||
modes off removes exactly those, and an install that never turned them on
|
||||
publishes what ed739d4 did (`test_mqtt.py` part 10). A removed topic stays
|
||||
in that list and its empty config is re-sent on each start, so a start with
|
||||
no broker cannot lose it; re-sending is harmless. The HA options are the
|
||||
page's English names, pinned by a test, and are not renamed with the page:
|
||||
automations send them.
|
||||
- In P1 only Off closes a direction; Leftover solar differs by charging last.
|
||||
- P3 (price runs, `priceruns.py`, `modetrade.py`): each battery's range is
|
||||
narrowed by its role this tick (hold, give, charge, rest, sell); main.py
|
||||
files the intersection with the switch, so it still holds 0 W. With modes on
|
||||
the site trade is filed by the modes, sized from the loop bound (so no
|
||||
battery a mode or a refusal holds is asked for watts, P1's unmet share) and
|
||||
the guard's headroom; `arbitrage.decide()` runs only with modes off, which
|
||||
stays byte-identical to 7a00f12 (`test_modes_p3.py` part 1).
|
||||
- SAFETY-46 item 2 is decided as the owner answered (2026-09-25): a trade is
|
||||
a run of consecutive firm slots. It applies with battery modes on; the
|
||||
modes-off trade keeps its one-period shape, because that path must not move.
|
||||
- Choices that were measured, not assumed (tools/opt11p3_replay.py):
|
||||
- a sale needs the guard to have measured the quarter, not to be clear: a
|
||||
5 kW discharge alone reads "engaged" on export and flipped the sale every
|
||||
5 s;
|
||||
- a grid charge makes the others rest, so it starts only at 500 W and, once
|
||||
the guard or the headroom stops it, stays off for that 15-minute period
|
||||
(without: 13 flips in 4 minutes under a 3.5 kW house);
|
||||
- stored energy is valued at the last cheap run for 12 h, and a charge
|
||||
needs its own run to clear the spread (without: a night charge was spent
|
||||
on flat morning prices, then charging went on at them);
|
||||
- a run edge reseeds the loop at the house's level (a 3 kW import spike
|
||||
removed) and, since OPT-15, holds it there until the meter shows the step.
|
||||
- Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX hourly prices a
|
||||
year earlier, modelled batteries), net EUR, lower is better: P2 19.33, P3
|
||||
Solar first + Leftover solar 19.57, with a sale 19.60, the old one-period
|
||||
trade 18.14. Review of #153 (same model, with a per-action split):
|
||||
- the cheapest hours were at midday on 15 of 16 days, so Leftover solar (acting
|
||||
as Cheap hours until P4) bought 12.2 kWh there that the sun would have
|
||||
given: 7 kWh more exported, 12 kWh less solar stored. Holding for the
|
||||
expensive hours saved 4 kWh of import, not enough. With no grid charge on
|
||||
Leftover solar: 19.44;
|
||||
- the old trade charged one hour, so the afternoon sun still fit;
|
||||
- a sale earned the wholesale price (2.27 EUR for 11.4 kWh) and the house
|
||||
then bought the same energy back (2.29 EUR): a wash before wear. It pays
|
||||
only when the sale hour's wholesale price beats the later import price,
|
||||
levies included (about 0.13 EUR/kWh above that hour's wholesale here);
|
||||
- valuing the charge left at the end, wear and real Flemish levies make
|
||||
selling worse and do not change the order.
|
||||
- Owner decision 2026-09-29 (option A): selling is off until it can be judged
|
||||
on the price the grid pays (`packmode.SELLING_ENABLED`, code kept), and
|
||||
Leftover solar takes solar only until P4's forecast
|
||||
(`packmode.GRID_CHARGE_MODES`); Cheap hours only still charges from the grid. Replayed
|
||||
(16 days, as above): Solar first + Leftover solar 19.44 EUR net against
|
||||
P2's 19.33; Solar first + Cheap hours only 19.57.
|
||||
- Not done in P3: the forecast (P4), and a headroom too short for the run
|
||||
moving the rest to the next cheap periods.
|
||||
- OPT-15 (TEST-23 findings on 5a4cdc2, merged up to e3e4ba6,
|
||||
`test_price_run_fixes.py`):
|
||||
- F1, a charge stopped at a quarter edge and latched for the quarter. The
|
||||
P1 averager opens a quarter only when a sample arrives, and the HA source
|
||||
builds one 0.35 s after the reading, so the cycle on the boundary still
|
||||
saw the old block: "no seconds left", headroom None, "no trade
|
||||
(headroom)", latched for the new quarter. With modes on the guard now
|
||||
projects the quarter `now` is in (`PeakTracker.project(roll=True)`,
|
||||
crediting the held sample as the averager will), and only a measured
|
||||
stop latches. Modes off still reads the old block (identity).
|
||||
Since SAFETY-91, `next_quarter()` does the same for the guard at two
|
||||
inverters (no stop there without the roll, same banked quarters); the
|
||||
roll is what covers one inverter, where the old projection stops the
|
||||
charge at every edge. With modes on at one inverter the roll also removes
|
||||
the 1 s 0 W command at each quarter edge that SAFETY-81's sim showed, so
|
||||
its modes-on digests are re-pinned (modes off equal to e3e4ba6). A
|
||||
rolled projection is not graded by SAFETY-81's alarm (the old edge tick
|
||||
read "no seconds left"), so a standing overload is still notified once
|
||||
per quarter.
|
||||
- F2, -1120 W export at a charge end against -257 W modelled. The replay
|
||||
acted every 5 s on readings of that instant, and by then the lagged
|
||||
battery had nearly arrived, so it never acted on a reading that still
|
||||
showed the trade. The add-on cycles on each meter update (1 s on the rig)
|
||||
on readings 1-2 s old: the two cycles after the reseed saw the trade's
|
||||
+3300 W and slewed E up 555 W each. The loop now holds E until the meter
|
||||
reaches the target (or `EDGE_HOLD_S`, 18 s; to 36 s while the battery
|
||||
still moves). The replay gained `Lag` (dead time, reading age, the P1
|
||||
debounce, 1 s cycles). Charge end, 800 W house: -10 to -51 W over eight
|
||||
lag variants; without the hold -1370 W (meter 2 s). On e3e4ba6 the same
|
||||
numbers hold. The hold gives the loop the target as its grid, so it
|
||||
yields to SAFETY-95's drain tail, and any other claim that moves the
|
||||
command ends it.
|
||||
- F3, a charge refusal in a cheap run was never probed: the SAFETY-73 pin
|
||||
stands only while the site asks that way, and nothing asked to charge.
|
||||
Probing was chosen over changing the card: the probe is 200 W for 34 s
|
||||
each 15 min under the same peak gates, and it wins back the run. In its
|
||||
probe window the refusing battery is a charger, pinned at 200 W by its
|
||||
own claim, and the others rest. A monthly drain (SAFETY-78) still comes
|
||||
first, and with a day plan a probing Leftover solar battery goes only to
|
||||
its planned level. At one inverter SAFETY-52 already ends a charge
|
||||
refusal as soon as the house asks for power.
|
||||
- Not fixed, same on e3e4ba6: with lagged readings a headroom-sized quarter
|
||||
banks up to 1.4 W over the aim (48.6 W under the cap), and a battery
|
||||
reading older than the meter can make a charge start look like a house
|
||||
over the aim, a real guard stop.
|
||||
- Review: an unmeasured stop that never latched let a headroom missing
|
||||
every other cycle flip the charge 1799 times in 30 min, so the second one
|
||||
in a quarter latches (at most 4 changes a quarter). A house that rises
|
||||
1, 2 or 3 kW as the charge ends, from 30 s into the quarter to 15 s
|
||||
before its end (two inverters, lagged readings): the hold adds at most
|
||||
+33 W to that quarter against the loop without it, and the most any
|
||||
quarter banks is 3431 W with the charge sized to the aim (3450 W; the cap
|
||||
is 3500 W), 3456 W with a slow 8 s battery (the lag's own, the same on
|
||||
e3e4ba6). The import during the hold is the rise, below the charge's
|
||||
own, and a rise past that is the peak floor's, which ends the hold.
|
||||
A maintenance drain, hold or charge that starts during the hold ends it
|
||||
on that tick (replayed at edge + 3 s).
|
||||
- OPT-20 (TEST-23 OPT-15 g7, `test_opt20.py`): a probe asks only PROBE_W.
|
||||
The trade was sized at the probing battery's rating (2500 W), B's pin held
|
||||
200 W of it, and the allocator put the other 2300 W on A, in its
|
||||
maintenance charge phase (no mode range there, so nothing kept it out):
|
||||
grid 3100 W for 34 s. Now the ask is PROBE_W for the probing battery plus
|
||||
what the other chargers would have had without the probe (none: 200 W
|
||||
alone), and it carries the pin only on the ticks the pin is filed (the
|
||||
window's first and last cycle moved another battery's share by 200 W).
|
||||
- Chosen: the probe sits on top of a headroom-sized charge, so beside a
|
||||
normal charger its command is the same and the grid rises 200 W (about
|
||||
7 W on the quarter) rather than that charger giving 200 W up. A probe
|
||||
that the headroom or the bound cannot hold at 200 W is not filed.
|
||||
- Unchanged, same on bf9019e: with a charge ask the other batteries cannot
|
||||
give (one sign), so an idle-looking A that gives the house stops for the
|
||||
window and the grid rises by what it gave. The trade line now says 200 W
|
||||
for a probe alone; its words are UX-38's. The base P4 F4 path (a normal
|
||||
trade on a battery in maintenance charge) is not touched.
|
||||
|
||||
## P4: the solar forecast and the day plan (2026-09-29)
|
||||
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
|
||||
today; `pv_forecast_tomorrow_entity` optional), read by `app/solarfc.py`.
|
||||
Supported shapes: Forecast.Solar (state only), Solcast (`detailedForecast`)
|
||||
and Open-Meteo Solar Forecast (`wh_period`). The unit is read, not guessed;
|
||||
older than 3 h is stale (one missed hourly poll must not flag it).
|
||||
- Trust: `solar_trust_pct`, default 70 (owner answer 5).
|
||||
- House use: nothing the add-on recorded fitted. `tariff.db` holds grid
|
||||
import per quarter, which the batteries shape. So `dayplan.HouseProfile`
|
||||
keeps grid + battery per local hour for 7 days in the store
|
||||
(`house_profile`), median per hour. It is net of the solar the house used,
|
||||
so the solar sum never takes it below the night median. Solar readings are
|
||||
not used (TEL-11). The first plan needs one day of it.
|
||||
- Verdict (`dayplan.plan()`): trust x sum max(0, forecast - house) x 0.95
|
||||
until the expensive run the cheap run is for. Solar first takes it first.
|
||||
- Leftover solar may then charge from the grid up to min(ceiling, floor +
|
||||
the house's use in the expensive runs / 0.95), less its sun share: only
|
||||
the shortfall.
|
||||
- Cheap hours only ignores the plan and fills to the ceiling (design
|
||||
section 1; review of #156, see the replay below).
|
||||
- Solar first never charges from the grid (owner, 2026-09-29).
|
||||
- The level is kept unless it moves by min(1 kWh, 10 %). With no plan
|
||||
(no, stale or failed forecast, no house profile, no capacity) every mode
|
||||
is P3 option A exactly: `test_modes_p4.py` part 1 pins 12 digests from
|
||||
5a4cdc2 (modes on, with and without an unavailable forecast).
|
||||
- The plan is made with the runs (every 15 min, or at once when the forecast
|
||||
comes or goes). A lost forecast drops the levels at the next cycle.
|
||||
- Not done: the design's scaling by measured solar from 10:00 (it needs the
|
||||
solar readings TEL-11 keeps out), the HA `goodwe_<inv>_plan` sensor, and
|
||||
FCST-01.
|
||||
- Replay (`tools/opt11p4_replay.py`, 16 recorded days, Solar first + Leftover
|
||||
solar, net EUR, lower is better; site solar rebuilt from the recording,
|
||||
forecast errors from Elia's day-ahead Belgian solar forecast):
|
||||
- year-earlier hourly prices: P2 19.33, P3 option A 19.44, old one-period
|
||||
trade 18.14, P4 19.15 (perfect forecast 19.14, 1.5 x too high 19.16,
|
||||
missing 19.44);
|
||||
- the days' real 2026 quarter-hourly prices (held out): P2 32.26, option A
|
||||
32.01, old trade 31.40, P4 29.19 (perfect 29.21, too high 29.28, missing
|
||||
32.01).
|
||||
- Solar first + Cheap hours only, skipping its sun share (the first
|
||||
build): 19.57 (same as P3) and 29.72 against P3's 29.41. The shorter,
|
||||
moved runs left B lower, so later midday top-ups rested A while the sun
|
||||
shone. Cheap hours now ignores the plan, which is P3 exactly.
|
||||
- A forecast of no sun at all gives 19.14 and 29.33. So nearly all of the
|
||||
gain is Leftover solar buying the evening's shortfall in the cheap run;
|
||||
the forecast itself was worth 0.14 EUR in 16 September days.
|
||||
- Bias: the site's solar is rebuilt from the same net use the replay
|
||||
plays, and "recorded" keeps its hourly shape and adds only the national
|
||||
daily error. Both favour P4; "national" (no house leakage) gives 29.25.
|
||||
No threshold was tuned to these days. The 2026-09-24 PV recording could not
|
||||
be replayed: it is only in the live Home Assistant.
|
||||
@@ -1,79 +0,0 @@
|
||||
# OPT-17 - the solar forecast from several entities, summed
|
||||
|
||||
**Status: for review.** Tests: `test_opt17.py`. Two options added.
|
||||
|
||||
A site whose forecast comes as several Home Assistant sensors (one per
|
||||
inverter or roof side) had to build a template sensor to sum them. Now each
|
||||
day can list them.
|
||||
|
||||
## Options
|
||||
|
||||
- `pv_forecast_entities` and `pv_forecast_tomorrow_entities`: lists of
|
||||
sensors, default `[]`, the same shape as `pv_entities`.
|
||||
- `pv_forecast_entity` and `pv_forecast_tomorrow_entity` are unchanged. An
|
||||
install that uses them needs no edit.
|
||||
- **Why new keys, not "string or list" in the old ones.** Measured on
|
||||
Supervisor 2026.08.0 by `tools/supervisor_shape_probe.py` §J, as TEL-12's
|
||||
§G did for `pv_entity`. Declared as a list, each forecast key refuses the
|
||||
string an install already stores (`Invalid list for option
|
||||
'pv_forecast_entity'`), and a `[]` default does not rescue it. The one
|
||||
spelling that takes both, `match(.*)?`, also takes the integer 417 and
|
||||
shows a plain text box, so a second sensor cannot be added in the UI.
|
||||
- §J also shows: a 0.17.0 blob validates unchanged with the lists at `[]`;
|
||||
the lists render as repeatable fields; a downgrade drops them and keeps
|
||||
the single keys.
|
||||
- `[]` in `options:` is load-bearing, as for `pv_entities`.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **One key per day.** Setting the single key and the list for the same day
|
||||
is refused at start, with one message: keep the list, clear the single one.
|
||||
The list can hold that entity too. (TEL-12 lets `pv_entities` replace
|
||||
`pv_entity` with a caution row. Here the brief asked for a refusal: a
|
||||
doubled or dropped forecast changes what a battery buys, and no install
|
||||
has both keys yet, so the refusal breaks nothing.)
|
||||
2. **Each member in its own unit.** Every member is read as one forecast
|
||||
sensor is today (`solarfc._day()`): Wh, kWh or MWh, never guessed. Then the
|
||||
day's kWh are added up. A repeated id counts once.
|
||||
3. **One member not usable means the day is not usable.** Missing,
|
||||
unavailable, unknown, stale, not an energy unit, or a bad number: that
|
||||
day's sum is not used, exactly as when the single sensor reads that way.
|
||||
No today means no forecast (P3 option A). No tomorrow means today only.
|
||||
4. **Hourly split.** If every member has a split, the splits are joined
|
||||
(overlapping periods add up). If any member has none, the sum is spread
|
||||
over the bell, as for a state-only sensor.
|
||||
5. **Time.** The forecast is as old as its oldest member.
|
||||
6. **One read.** All members of both days are read in one gather, inside the
|
||||
gather that reads the inverters, so a slow sensor does not delay control.
|
||||
7. **Shown and logged once.** With two or more members, each one not usable
|
||||
is named on the status page by its place in the list (STYLE.md: no entity
|
||||
ids on the page), as a caution row titled "Solar forecast" on Home. The
|
||||
action log names the entity id, once when the set of failing members
|
||||
changes.
|
||||
8. **One member is the single key.** A one-member list reads, plans, shows
|
||||
and logs exactly as the single key with that entity.
|
||||
|
||||
## Why
|
||||
|
||||
- A partial sum is a smaller number that looks real. Two of three inverters'
|
||||
forecasts read as a duller day, and Leftover solar would then buy grid
|
||||
energy the sun was going to give. TEL-12 took the same rule for the same
|
||||
reason. No forecast is a known state with a tested fallback.
|
||||
- The bell for a mixed list: joining one member's hours with another's total
|
||||
has no honest shape, and the bell is the existing state-only rule.
|
||||
|
||||
## Cost
|
||||
|
||||
- One flaky member costs the whole day's forecast. A site with three
|
||||
sensors loses the plan more often than one with a template sum that hides
|
||||
gaps.
|
||||
- Two more options (85).
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- No weighting or calibration per member; FCST-01 is still the own forecast.
|
||||
- Members on different integrations with different refresh times: each must
|
||||
be fresh (3 h) on its own; there is no wait for a lagging one.
|
||||
- The day plan itself, the trust and the house profile are unchanged.
|
||||
- The Batteries screen still says only "no solar forecast now". The member
|
||||
is named on the status page.
|
||||
@@ -1,12 +0,0 @@
|
||||
# OPT-23 and OPT-28 - the day plan counts the sun right, and the night charge covers the expensive-hour shortfall
|
||||
|
||||
**Status: for review.** Tests: `test_opt23_28.py`.
|
||||
|
||||
`dayplan.plan()` now buys, for Leftover solar batteries, what the house uses in the expensive hours less the sun counted for each hour, shared by capacity, and reads a state-only forecast from now for every span (OPT-23).
|
||||
|
||||
- **Shortfall.** `sum_h max(0, house_h - trust x sun_h)` over every expensive hour, `/ EFF`, from the floor. It replaces the use over the run with no sun counted, which on a day and night tariff bought for the whole day (G1, `reports/UX-69-design-report.md`). The sun before the run is unchanged. A run with its dear hours after sunset gives the old number.
|
||||
- **House use.** An hour with sun is read at no less than the night base, as the sun sum reads it (the profile is net of the sun). An hour without is read as measured. An hour with no forecast counts no sun.
|
||||
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by the grid (OPT-36 counts the other batteries first), as the screen says.
|
||||
- **Cheap hours** still fills up; **Solar first** still never charges from the grid. The mode help says so.
|
||||
- **OPT-36 (0.20.1):** Cheap hours and Solar first batteries give first; Leftover solar shares only the rest of the shortfall (`dayplan._cheap_give_kwh()`, `_solar_first_give_kwh()`). A Cheap hours battery counts the level its charge reaches: the cheap run, then later slots still worth charging in and no dearer than any slot from the expensive hours on, under the peak aim, x EFF stored. A Solar first battery counts what is left after the house's use until the hours start, each hour at no less than the night base. Live, 2026-11-02: A at 60 % went to 95 % while B at 94 % covered the hours. Owner, 2026-11-02: Solar first counts too. Test: `test_opt36.py`.
|
||||
- **Not done:** the run length (OPT-25). On a dynamic tariff the Cheap hours count guesses where control will charge; a counted slot can still go unused, and that gap comes from the grid.
|
||||
@@ -1,48 +0,0 @@
|
||||
# OPT-27 - the night plan keeps the last fresh forecast
|
||||
|
||||
**Status: done on `release/1.0`, not released.** Tests: `test_opt27.py`.
|
||||
|
||||
**The defect.** A today sensor is stale after 3 h (`FRESH_S`) or when it is from
|
||||
before local midnight. Forecast.Solar stops updating in the evening, so at night
|
||||
every Forecast.Solar site had no forecast: a price mode's night cheap run needed
|
||||
1.5x the cycle cost (TARIFF-24 round 2) and Leftover solar planned nothing.
|
||||
Separately, one missed poll (`unavailable`) dropped the forecast at once.
|
||||
|
||||
**Where: `solarfc`, not the plan.** The last fresh reading of each sensor is
|
||||
kept in `solarfc.Hold` and `read_sum()` uses it when a sensor has none. A hold
|
||||
at the plan (`_wx_track`/`weather_held`) can only keep the 1.5x gap off. The
|
||||
day plan, Leftover solar's levels, Home and the Forecast tab all read
|
||||
`Controller.solar_fc`, so one kept forecast makes control and every screen agree
|
||||
(UX-53) with no second rule to keep in step. `weather_held()` is unchanged: it
|
||||
still keeps a run under way when a read is lost.
|
||||
|
||||
**The rule** (`Hold.stand_in()`). A kept reading is for one day. It covers a
|
||||
sensor that is missing or stale, never one with a bad value or unit, and:
|
||||
- for up to `FRESH_S` when the sensor is missing: a short outage, any time of day;
|
||||
- from the end of the bell (20:00 local, `BELL_TO_H`) until local midnight, if the
|
||||
reading is at most `KEPT_MAX_AGE_S` (12 h) old when used; after midnight the
|
||||
tomorrow figure is today's until the bell starts (06:00), up to 15 h old
|
||||
(OPT-34 changed this from midnight + 3 h);
|
||||
- the day it is for: a today figure only on that day, a tomorrow figure as
|
||||
tomorrow until midnight and, if the today sensors are still stale, as today's
|
||||
until 06:00 (all sensors of the sum are needed, as for any sum);
|
||||
- until the next fresh reading, which replaces it at once.
|
||||
|
||||
So a figure is never used for another day and never older than 12 hours (`KEPT_MAX_AGE_S`), except the morning case above: a 17:05 reading serves until 06:00 (12 h 55 min), a 09:05 reading only until 21:05. Until OPT-34 the figure ended at 03:00, and a charge under way lost its level there; OPT-34 closes that limit.
|
||||
Daytime is as before: a sensor that stopped is stale after 3 h, and no forecast
|
||||
all day is still no forecast (the night run still needs 1.5x).
|
||||
|
||||
**Where it is stored.** The controller's store, key `solar_fc_hold`, so an
|
||||
evening restart keeps it. It is written when a reading's figure changes or
|
||||
every 15 min (`SAVE_EVERY_S`), not on every poll. A damaged entry is dropped.
|
||||
|
||||
**The screens.** `Forecast.kept` is true when a reading older than `FRESH_S` is
|
||||
in use; the forecast line then reads "Solar forecast read 17:05, kept for
|
||||
tonight." (`home_words.solar.kept`, EN and NL). The event log says
|
||||
"read, kept from 17:05".
|
||||
|
||||
**Not changed.** Control with modes off: it does not use the forecast. The
|
||||
screens still show the line, so it says "kept", not "used for the plan". A
|
||||
forecast that stays fresh. The hold does not look at the weather, only at the clock and the day.
|
||||
|
||||
**OPT-38 (2026-11-07).** A kept TOMORROW reading (at most `KEPT_MAX_AGE_S` old, same `day0`) now stands in at any hour, not only from `BELL_TO_H`: one sensor that stopped at 14:54 had dropped the whole sum (OPT-17) from 17:54 to 20:00. Today readings and the OPT-34 morning rules are unchanged; `test_opt38.py`.
|
||||
@@ -1,69 +0,0 @@
|
||||
# OPT-34 - the night charge keeps its level when the kept forecast ends
|
||||
|
||||
**Status: done on `release/1.0`, not released.** Tests: `test_opt34.py`.
|
||||
|
||||
**The defect** (OPT-27 rig test, F-1). The kept evening figure ended at midnight + 3 h.
|
||||
With the sensors silent from 17:05 the Leftover solar battery's level went with
|
||||
it in the middle of the charge: A ended at 49.8 % against 79.9 % with a fresh
|
||||
figure. Whether Forecast.Solar refreshes after midnight on a live site is not
|
||||
known, so the add-on does not depend on it.
|
||||
|
||||
**The rule.**
|
||||
1. *A charge under way keeps its level* (`Controller._latch_charge()`). While
|
||||
there is a forecast (fresh or kept), each battery that charges from the grid in the
|
||||
cheap run takes its planned level into a latch (store key `night_charge_latch`).
|
||||
With no forecast, `plan_targets()` gives the latched level, and the wide
|
||||
no-forecast gap does not refuse the next slice of the run (`weather_held()`,
|
||||
as TARIFF-24 does for the run under way). While a
|
||||
forecast is there the latch follows the plan, up or down as a fresh figure moves
|
||||
it. It lasts through every cheap run on and one price slot after the last, and it
|
||||
ends at once when its end time passes, whatever the run state (it is also
|
||||
ignored at a restart if it has ended). A battery that is switched off or
|
||||
changed to another mode leaves it.
|
||||
*Night bound (manager's call, 2026-10-31):* a run carries the latch on only if
|
||||
it starts by the bell start (06:00 local) after the latch began; that run
|
||||
finishes, plus one slot. Chosen over a fixed 12 h because the night tariff
|
||||
(22:00-07:00) needs the 06:00-07:00 slice and 12 h from a late start would
|
||||
reach into the day. With dynamic prices a chain of cheap slots cannot carry a
|
||||
night's level into the next day.
|
||||
2. *The evening's tomorrow figure is today's until the bell starts*, 06:00 local
|
||||
(`BELL_FROM_H`, `solarfc._bell_start()`), not until midnight + 3 h.
|
||||
Nothing has been produced yet, so it is still the whole day.
|
||||
3. *Up to 15 h old for that case only* (`KEPT_MORNING_MAX_AGE_S`): a 17:05
|
||||
reading is 12 h 55 min old at 06:00. It must have been at most 12 h old at
|
||||
midnight, so a 09:05 reading, unusable since 21:05, does not come back for five
|
||||
minutes after midnight. Same-day figures and the daytime rule keep 12 h and 3 h.
|
||||
4. A fresh figure replaces the kept one at once, as before.
|
||||
*Rig F-1 fix:* once every today sensor has given a fresh reading after
|
||||
midnight, the evening figure is no stand-in for that day any more
|
||||
(`Hold.replaced()`). When that reading goes stale (3 h), it is kept or not
|
||||
as any today reading (OPT-27), so it is "no forecast" at night and the latch
|
||||
keeps the level it set; the older 17:05 figure never comes back. Rig: A
|
||||
stopped at 69.5 % after a brighter 02:00 figure, then charged back to 94.4 %
|
||||
from 05:15 when the 17:05 figure returned. Test: `test_opt34.py` F-1.
|
||||
|
||||
**Step 1 alone is enough for the owner's shape, steps 2-3 are still needed.**
|
||||
With the window put back to midnight + 3 h the owner's night still ends as a
|
||||
fresh figure ends it (A 86.9 %, B 95.0 %), because the charge had started at
|
||||
22:00. But a charge that has not started by 03:00 (the price dips at 04:00, or
|
||||
the headroom is taken) has no level to latch: A ends at 38 % against 78 %. The
|
||||
plan, the screens and a restart also need the figure itself.
|
||||
|
||||
**Screens.** "Solar forecast read {at}, kept for tonight." is still true until
|
||||
06:00 and unchanged. After the figure ends, with a charge still going on: "No
|
||||
solar forecast now. The charge under way goes on to its planned level." /
|
||||
"Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau."
|
||||
(`home_words.solar.latched`, only while a cheap run is on and a latched battery is below its level).
|
||||
|
||||
**Not used.** A figure over 15 h old; a morning figure at night (12 h); a figure
|
||||
for another day; a bad value or unit; and no latch for a charge that has not
|
||||
started, for a battery that is not charging, or after the cheap runs have ended
|
||||
(plus one slot).
|
||||
|
||||
**Not changed.** Charge sizing, the peak guard, Cheap hours, modes off, arbitrage.
|
||||
A fresh figure that LOWERS the level during a charge still lowers it, as before.
|
||||
|
||||
**Mutants** (all red in `test_opt34.py`): no latch; window back to midnight + 3 h;
|
||||
15 h without a limit; no "usable at midnight" rule; the latch without the narrow
|
||||
gap, without its save, or not kept through the next run; the window 6 h after
|
||||
midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off or shown without a cheap run; no expiry check; no pruning of a changed mode; no night bound; a left-the-plan battery keeping its level; no `replaced()` check (F-1); `replaced()` with one of several today sensors (any, not all).
|
||||
@@ -1,117 +0,0 @@
|
||||
# PRICE-01 - the owner's price server as the price source, with fallbacks
|
||||
|
||||
**Status: for review.** Tests: `test_price_service.py`. One option added,
|
||||
`price_service_token` (password). Follows [[TARIFF-10]], whose section 4 this
|
||||
card answers for one provider. Design: the owner's price service note of
|
||||
2026-10-05. Owner go 2026-10-05 and 2026-10-14.
|
||||
|
||||
## What it is
|
||||
|
||||
`price_provider: price_service` reads `GET https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`
|
||||
with `Authorization: Bearer <token>`. The answer is the energy-charts layout
|
||||
plus `covers`, so the existing columnar parser reads it. The price is wholesale
|
||||
energy only; the framework adds markup and taxes, as for `generic_dayahead`.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **The host is a constant in the plugin**, declared as its only egress host,
|
||||
HTTPS only. The operator's `price_feed_url` is not read by this provider.
|
||||
*Cost:* every client talks to the one host, and moving the server is a new
|
||||
add-on version. *What replaces the per-site setting:* the egress allow-list,
|
||||
TLS, and a token per client that the server can revoke.
|
||||
2. **The chain is server, then energy-charts, then the saved prices**
|
||||
(`app/price_chain.py`). A step counts only if it has a price for *now*: a
|
||||
server that answers with old prices is skipped like one that is down. A step
|
||||
down and a step up are each one log line, written when the step changes. The
|
||||
operator's `price_feed_url` is cleared for the energy-charts step, which is
|
||||
asked for its own feed.
|
||||
3. **The saved prices are `/data/price_history.json`**, the file `PriceHistory`
|
||||
already keeps (the last three Brussels days of what was published). They are
|
||||
the all-in prices as stored; a change of markup shows from the next fetch
|
||||
that works. A read from the store is not filed again.
|
||||
4. **No price for now** means no published price covers the present moment. Not
|
||||
"none for the coming hours": before about 13:00 tomorrow's prices are not
|
||||
published on any healthy day, and carried hours are already marked estimated.
|
||||
It sets one status row (*Prices*), one Home plan line (English and Dutch),
|
||||
and one notification (*No prices*, English and Dutch), taken down when a
|
||||
price returns. A start-up before the first refresh finished is not a gap.
|
||||
The notification is not re-asserted after a Home Assistant core restart
|
||||
(SAFETY-34 does that for latched safety alarms); the status row stays.
|
||||
*Open for the owner:* if "coming hours" should mean a look-ahead of N hours,
|
||||
that is a change to `gate_for()` and `PriceWatch`, not to the chain.
|
||||
5. **The maintenance charge gate** (owner's decision, 2026-10-14): the charge may
|
||||
run when the *current 15-minute price is at or below today's average*, today
|
||||
being the Brussels day, computed from the provider's prices. This is the rule
|
||||
the Home Assistant pair gave (`price_service.yaml`), so a site that moves from
|
||||
the pair to the provider does not change when the maintenance charge runs.
|
||||
It is not `TariffPlan.cheap_now()` (strictly below the mean of the firm
|
||||
hours of the whole plan). This replaces, for this provider only,
|
||||
TARIFF-10 section 4: its three reasons are answered as follows. (1) Two
|
||||
writers: the pair beside a provider stays refused; `static_profile` beside
|
||||
`price_service` is accepted and no longer writes `cheap`. (2) An expired or
|
||||
absent plan shuts the gate (the charge waits), as an unavailable HA sensor
|
||||
did. (3) The basis is the one above, not `cheap_now()`.
|
||||
6. **The token is a secret.** `password?` in the schema; hidden on the settings
|
||||
page (`uilayout.HIDDEN`), which can keep `price_service` but not choose it;
|
||||
never put in a message by the plugin; taken out of any reason text by
|
||||
`price.scrub()`; the real transport reports only a status or an exception's
|
||||
name. validate.py refuses `price_service` with no token, a token with a
|
||||
space or line break, and one under 16 characters (so `scrub()`, which skips
|
||||
values under 4 characters, always has something to take out). Round 2: the
|
||||
token is in no page and no JSON answer of the web app. Every `password`
|
||||
option of the schema is shown as `********` (set) or empty, in
|
||||
`/config/model` (`options`, `running`, `ui`) and in a stale refusal; the
|
||||
classic page draws it as a password field; a save or a validate that sends
|
||||
`********` or nothing keeps the stored value, any other value replaces it.
|
||||
A token can therefore be changed but not cleared from the page.
|
||||
7. **A fixed tariff beside `price_service` is allowed and quietly noted.** It is
|
||||
still the plan's last fallback (`resolve_tariff` -> `_degrade`); only the
|
||||
cheap window and the gate ignore it, because they read the provider's
|
||||
prices. Keeping it set is the recommended setup, and clearing it triggers the
|
||||
"static_profile is empty" warning, so no notification (round 2's *Fixed
|
||||
tariff not used* was false and fired on every sound start; round 3 removed
|
||||
it). Instead one INFO line at start (English, as every log line) and an info
|
||||
note beside the setting (EN and NL, "With the price service, the fixed
|
||||
tariff is only used if no prices are available."), both
|
||||
only with a non-blank `static_profile` (whitespace counts as blank). At
|
||||
start the add-on dismisses the old notification id, in case a pre-release
|
||||
build raised it.
|
||||
8. **The no-price words show only with the maintenance charge on** (owner,
|
||||
2026-10-14): the status row, the Home line and the notification say
|
||||
'maintenance waits', so with `maintenance_enabled` off none is shown.
|
||||
|
||||
## Where the gate equals the Home Assistant pair, and where it does not
|
||||
|
||||
Equal: `test_price_service.py` section 8 feeds the same day both ways, to the
|
||||
rules in `price_service.yaml` (price at the quarter, average of today's
|
||||
quarters rounded to 0.00001 EUR/kWh, `<=`), over 6 days including both
|
||||
clock-change days, at three instants in every period, and through a real
|
||||
`Controller` for a whole day. Every answer is the same.
|
||||
|
||||
Not equal, by design or by arithmetic:
|
||||
|
||||
- The comparison is made on all-in prices. With a flat taxes figure and a
|
||||
markup and taxes of at most five decimals the offset cancels and the answer
|
||||
is the same. With `price_taxes_profile` (an hour-varying levy) the all-in
|
||||
prices differ by hour, so the answer can differ from the wholesale comparison
|
||||
the HA sensors made. A site on that profile should check before moving.
|
||||
- A quarter within 0.00001 EUR/kWh of the average can differ if markup or taxes
|
||||
have more than five decimals.
|
||||
- The plan is retired when no refresh has succeeded for 90 minutes
|
||||
(`TARIFF_MAX_AGE_S`) and the gate shuts then. How long the HA pair kept an
|
||||
answer after its REST sensor stopped updating was not measured here.
|
||||
- The average is over the firm prices the provider holds for today. After a
|
||||
restart in the middle of a day with only the saved part of today, it is the
|
||||
average of that part.
|
||||
|
||||
## Not covered
|
||||
|
||||
- **No real answer of the server was seen.** No token was available. The
|
||||
layout is the design's; `test_price_service.py` uses a mock and, for the
|
||||
parser, the real energy-charts capture reshaped with the design's extra keys.
|
||||
The first capture from the live server (rig test) should replace the mock.
|
||||
- The 429 limit (about 60 calls an hour) is not approached: one fetch per
|
||||
30 minutes, and at most two requests when the server fails.
|
||||
- Battery modes read the plan as for `generic_dayahead` and were not changed.
|
||||
- The `price_service` REST and template sensors in Home Assistant are not
|
||||
touched; they stop being read when the pair is cleared.
|
||||
@@ -1,89 +0,0 @@
|
||||
# PRICE-02 - the trading verdict skips price periods that have ended
|
||||
|
||||
**Status: for review.** Tests: `test_price02.py`. Found by AUDIT-01 (F1).
|
||||
|
||||
The refresh floors the plan to the top of the hour, so on the 15-minute feed
|
||||
it holds the periods of this hour that are already over. The verdict took the
|
||||
cheapest and dearest over every point. With modes off it could aim at a period
|
||||
that had passed and then do nothing, for up to 45 min plus the refresh lag.
|
||||
With modes on the `allowed()` gate could be open only because of such a period.
|
||||
|
||||
Decision: `TariffPlan.arbitrage(now=...)` drops the points whose period has
|
||||
ended (`end <= now`, the span `price._cover_table()` gives, as
|
||||
`priceruns.firm_slots()` does). Its three callers pass their own `now`:
|
||||
`arbitrage.decide()`, `Controller._mode_roles()` and `homeview.trade_verdict()`.
|
||||
|
||||
- Taken in the verdict, not when the plan is built: a plan built at 03:00 is
|
||||
still used at 03:20, and the Home chart keeps the day's earlier bars.
|
||||
- An hourly point that is still current stays in. But an hourly or static plan
|
||||
is rebuilt every 30 min counted from add-on start, so it is usually off the
|
||||
hour and, until the next refresh, holds the hour that has just ended. That
|
||||
hour is dropped too (round 3, owner-accepted 2026-10-22). On a day/night
|
||||
tariff all day hours tie and the old verdict took the first, the ended one,
|
||||
so it paused the trade every hour until the refresh: 91 on/off changes over
|
||||
2 days with the refresh at :10, 4 now (4 before as well, with the refresh on
|
||||
the hour). With the refresh on the hour nothing changes.
|
||||
- `now` is required: keyword-only, no default, like the other three arguments. A
|
||||
caller that could leave it out would get the elapsed periods back and the
|
||||
defect with them. A test that wants the whole series passes an instant before it.
|
||||
- The sentences of this verdict in `arbitrage.py` (status row and action log,
|
||||
English only) say "price period" where they said "hour": the idle one now
|
||||
reads "now is not the cheapest or the dearest price period". The UI texts
|
||||
that say "cheapest hour" (AUDIT-01 T1-T5) are not touched here.
|
||||
- Not changed here: F2 (static profile sampled at HH:00) and F3 (carried value).
|
||||
|
||||
Measured: with price off, every command over the modes-off replay is
|
||||
byte-identical. On a static tariff rebuilt on the hour it is too; rebuilt 20
|
||||
min off the hour the commands differ (digest 79f06d6381ff3358 on release/1.0,
|
||||
18de19d9fe919fe5 here), for the reason above. On the dynamic replay every command is
|
||||
the same too; only a sale's sentence quotes a smaller "above the cheapest"
|
||||
figure once the first cheap period is over. The identity pins in
|
||||
`test_modes_p3.py` and `test_price_run_fixes.py` hide just that figure and the
|
||||
word change (`opt11p3_identity.trade_digest()`).
|
||||
|
||||
## Ties (round 2)
|
||||
|
||||
The verdict takes `min`/`max` over the points still ahead and the first one wins
|
||||
a tie, so with equal prices it is the first period still ahead, which is the
|
||||
current one. Before, it was the first of the whole plan, often one that had
|
||||
ended. Two effects follow, and both are the card's intent, not a side effect:
|
||||
|
||||
- Equal quarters (hour-constant data, or a feed that prints the same price in
|
||||
several quarters) now act in every quarter of the cheap (or dear) block,
|
||||
not in the first only. Test: `test_price02.py` part 6.
|
||||
- After the cheapest period has passed, the next cheapest still ahead is the
|
||||
target, if its spread still clears the cost. A hourly plan did this at every
|
||||
refresh already (it started at the current hour); a 15-minute plan did it
|
||||
only from the next hour on, because it kept the elapsed quarters of this one.
|
||||
|
||||
Measured with `scratch/PRICE-02/dev/tiesim.py` (refresh every 30 min from the
|
||||
floored hour, verdict at 7 min into each period, threshold 0.05, old = the
|
||||
verdict over the whole plan, new = this card):
|
||||
|
||||
| data | days | acting periods old -> new | action changes old -> new |
|
||||
|---|---|---|---|
|
||||
| real 15-minute day, energy-charts 2026-09-09 | 1 | 4 -> 5 | 6 -> 6 |
|
||||
| real 15-minute days 2026-09-09 and -10 | 2 | 14 -> 26 | 24 -> 24 |
|
||||
| EPEX 2025-26 hourly prices as four equal quarters | 12 | 18 -> 72 | 36 -> 20 |
|
||||
| the same with random +-0.002 noise per quarter (no ties) | 12 | 18 -> 33 | 36 -> 48 |
|
||||
|
||||
So a real 15-minute day does not switch more often (24 -> 24), and a day of
|
||||
equal quarters switches less. A synthetic day of noisy, tie-free quarters does
|
||||
(3.0 -> 4.0 changes a day): each period that passes hands the target to the
|
||||
next cheapest one ahead. Every change is still at a period edge, never within
|
||||
one. The replay in `test_arbitrage.py` (synthetic year, hourly) gives the same
|
||||
ledger on the old and the new code once its ties are broken (280 round trips,
|
||||
85 held, both): it printed one maximum twice per day, and the old fixture
|
||||
tested the first of them only because its plan began at midnight.
|
||||
|
||||
## Battery throughput (round 3)
|
||||
|
||||
On real 15-minute days the trade runs longer: about 1.5 to 2.5 times the
|
||||
minutes spent charging or discharging, with fewer on/off switches (for example
|
||||
78 -> 68, 92 -> 72), over 16 days (2026-09-02 to 09-17, a decision every
|
||||
minute, refresh at :00 or :10, tomorrow's prices known always or from 13:00;
|
||||
reviewer's sim). Through a modelled 10 kWh battery that is 27.5 -> 42.6 kWh
|
||||
(refresh at :00, tomorrow always known); 19.6-31.9 -> 43.4-48.7 kWh in the
|
||||
other three variants. Charge to
|
||||
discharge reversals are unchanged (10 -> 10, 13 -> 13). More cycling is the
|
||||
price of not pausing.
|
||||
@@ -1,166 +0,0 @@
|
||||
# SAFETY-07 - the quarter-peak projection guard (with SAFETY-24 and CFG-03)
|
||||
|
||||
**Status: SHIPPED.** Tests: `test_peakguard.py`. Written down by MAINT-20 from
|
||||
the long module comment in `app/peakguard.py`, so the module can stay short.
|
||||
Later changes have their own notes: [[SAFETY-25]], [[SAFETY-32]], [[SAFETY-45]],
|
||||
[[SAFETY-79]], [[SAFETY-81]], [[SAFETY-87]], [[SAFETY-90]], [[SAFETY-125]],
|
||||
[[SAFETY-126]].
|
||||
|
||||
## The hazard
|
||||
|
||||
The capacity tariff bills the month's single worst 15-minute average offtake,
|
||||
with no averaging and no forgiveness. specs.md:583 records one 4998 W quarter
|
||||
that moved a monthly peak from 4832 W to 5013 W and billed the whole month at
|
||||
it. SAFETY-18 alarms on a frozen meter (`meter_freeze_s`, 300 s) but a frozen
|
||||
meter can rail the command in 75 s (freeze.py's table, `max_w: 5000`, meter at
|
||||
3000 W or more), against a 900 s quarter. The guard is the cap on that. It
|
||||
reads freeze.py's `looks_frozen()`; it is not a second freeze detector.
|
||||
|
||||
## The mechanism: one `lo` limit at `P_PEAK`
|
||||
|
||||
specs.md 10.1 row 2 wants pre-emptive discharge near the limit and a hard clamp
|
||||
on charging above it; 9.1 says discharge and export are never capped by the
|
||||
peak guard. A lower bound on the setpoint is the only shape that does both: a
|
||||
floor cannot stop the loop discharging harder (so 9.1 holds by structure), it
|
||||
clamps charging when above the standing charge, and it forces discharge when
|
||||
above zero.
|
||||
|
||||
- `P_PEAK` (50) is above `P_LOOP` (10) and below `P_MAINTENANCE` (60) and
|
||||
`P_SAFETY` (100). arbiter clause 2 applies only limits at or above the
|
||||
winning set's priority, so a P_SAFETY 0 W stop is not pulled off zero.
|
||||
- A limit does not out-vote another limit: clause 2 takes the most restrictive
|
||||
with no priority between limits. The floor (`lo >= 0`) and the maintenance
|
||||
charge-only shaping (`hi <= 0`) therefore resolve to `lo > hi`, a
|
||||
contradiction, which the arbiter answers with 0 W (battery parked in the
|
||||
worst quarter). The rule for two crossing limits is suppression, not
|
||||
priority: main.py drops the shaping while the guard is engaged
|
||||
(`shaping_suspended()`). The floor is clamped to `achievable_w` to close the
|
||||
only other crossing pair (the P_SAFETY rating bounds).
|
||||
- Arbitrage arrives as a SET below `P_PEAK`, so clause 2 binds it with this
|
||||
floor without any edit here (9.1: peak shaving outranks arbitrage).
|
||||
|
||||
## The floor is derived
|
||||
|
||||
With `projected = (acc + held * remaining) / 900`, the watts that must come off
|
||||
the meter are `excess_w = 900 * (projected - ceiling) / remaining`, and the
|
||||
floor is `floor_w = batt_w + excess_w`.
|
||||
|
||||
1. The claim is filed while the floor is positive, not while the excess is.
|
||||
`excess` falls to zero as soon as the battery is already removing it, and a
|
||||
guard that released there handed the actuator back and re-engaged next
|
||||
cycle. The first cut did that on 2026-08-20's billed quarter: 17
|
||||
engage/release cycles in 900 s, and the quarter still ended 49 W over.
|
||||
Thresholding `batt_w + excess` (the offtake with the battery at zero, less
|
||||
the level the quarter can afford) decays continuously, so there is no
|
||||
hysteresis constant. 10.2: an EMS that oscillates is worse than none.
|
||||
2. The gain 900/remaining is 1.0 at the start of a quarter and grows. The guard
|
||||
is gentlest when its evidence is weakest. No minimum-evidence threshold was
|
||||
added: it would delay the guard on a sustained rail from second zero.
|
||||
3. `batt_w` is the measured battery power, not the command. The bill is what
|
||||
flowed, and a floor built on the command winds up to the rail when the
|
||||
inverter cannot follow; one built on the measurement stops rising when
|
||||
delivery does.
|
||||
|
||||
## The ceiling
|
||||
|
||||
`ceiling_w = max(peak_cap_w, the month's banked peak)`. Once a month's peak
|
||||
stands at 5013 W, holding later quarters to 3500 W buys nothing. Only an
|
||||
`OBSERVED` reading may raise it; `NO_RECORD` and `NOT_OBSERVED` leave it at
|
||||
`peak_cap_w`, so a peak store that cannot be read makes the guard stricter,
|
||||
never looser. That is also why the guard does not use `headroom_w()`: a peak
|
||||
read as 0 W returns full headroom, which is a guard that is not there.
|
||||
SAFETY-79 aims `peak_margin_w` under the ceiling; the ceiling stays the real
|
||||
limit for reports and the bill.
|
||||
|
||||
## Blindness comes first
|
||||
|
||||
The projection is graded only if the quantity under it was measured. Three
|
||||
ways it was not, checked before grading:
|
||||
|
||||
- No open block: a fresh averager answers an all-zero `Projection`, which sits
|
||||
under every ceiling. That is "no quarter", not "the quarter is fine".
|
||||
- Coverage: a meter gap longer than `max_hold_s` leaves seconds the averager
|
||||
does not accumulate (holding a dead meter's value forward is how a fabricated
|
||||
peak gets onto a permanent record), so the projection reads low. The
|
||||
threshold is `max_hold_s`, not a new number. SAFETY-90 fills gaps once
|
||||
readings return.
|
||||
- Frozen: samples keep arriving, coverage is perfect and the projection is a
|
||||
confident zero while the real meter is at the rail.
|
||||
|
||||
The blind action is `floor_w = 0.0`: no importing while the billed quantity
|
||||
cannot be observed. It is one-directional (forbidding charge only lowers
|
||||
offtake), so a false blindness costs a charge and never pack energy.
|
||||
|
||||
## Why it trips where SAFETY-18 only notifies
|
||||
|
||||
The healthy-day false-engagement rate is zero (2026-08-23, 95 quarters;
|
||||
test_peakguard.py re-measures it from the committed capture). The action is
|
||||
level-triggered and unlatched: it is re-derived each cycle and releases when the
|
||||
projection drops, so a false engagement costs one cycle, not a parked battery.
|
||||
It is a limit, so every safety stop outranks it. It is the only guard whose
|
||||
trigger is the billed quantity itself.
|
||||
|
||||
## Saturation is reported, not alarmed as a bug
|
||||
|
||||
10.3 treats an out-of-range setpoint as a bug (SAFETY-03). A peak floor above
|
||||
the inverter rating is not: it is a 5900 W house on a 5000 W inverter (2026-08-20
|
||||
15:45). The floor is clamped to `achievable_w`, the decision carries
|
||||
`saturated`, and main.py raises one notification.
|
||||
|
||||
## SAFETY-24: the energy backstop the guard shipped without
|
||||
|
||||
The graded branch can force discharge (878 Wh in a day on the 2026-08-20 replay)
|
||||
and nothing below it consulted the state of charge; the BMS was the only
|
||||
backstop, which protects the pack and not the operator. `decide()` now takes
|
||||
`soc` and `soc_floor` and `discharge_permitted()` answers.
|
||||
|
||||
1. Not a trip. When it binds, the guard stops asking for more discharge and
|
||||
commands nothing; every stop in 11.2 still outranks it.
|
||||
2. The action is `floor_w = 0.0` (forbids charging, forbids nothing else). A
|
||||
`lo` limit cannot cap discharge, and a negative floor would destroy that.
|
||||
The loop is not bounded: self-consumption may still drain the pack past the
|
||||
floor. [[SAFETY-25]] measured it (2026-08-20, 19:00-22:00 UTC, 830 W mean:
|
||||
the pack ends at the BMS floor, 10.0 %, whatever `peak_soc_floor` is) and the
|
||||
owner decided on 2026-09-02 that the BMS stays the only backstop for
|
||||
demand-driven discharge, a loop-side reserve being deferred to ALLOC-01.
|
||||
3. Graded only after `wanted > 0`, where a forced discharge is asked for.
|
||||
4. An unreadable SoC is not a full battery. `soc=None` and `soc_floor=None` are
|
||||
floor-limited. Both arguments are required keyword-only, no default (TEST-14).
|
||||
5. Failing to shave is not silent: `floor_limited=True` and one notification.
|
||||
It excludes `saturated`, since it asks for nothing.
|
||||
|
||||
The action log says "the peak reserve", not "the SoC floor": `peak_soc_floor`
|
||||
(“Peak reserve”) bounds this guard only, and the wider words would contradict
|
||||
a loop that is draining the pack at that moment.
|
||||
|
||||
## CFG-03: two options, mirrored once
|
||||
|
||||
- `peak_soc_floor` is the EMS floor, not `maintenance_soc_floor` (the monthly
|
||||
recalibration's drain target); sharing a key would couple features whose
|
||||
right values differ. Default 20: the reference pack is 300 Ah at 51.2 V,
|
||||
DoD 90, 13.8 kWh usable; the replay's 878 Wh is 6.4 %; a floor 10 points above
|
||||
the 10 % cut-out leaves about 1.6 worst days of room.
|
||||
- `bms_soc_floor` is the pack's own cut-out (register 0x0560, 10 on the
|
||||
reference unit). It exists so validate.py rule 8 can refuse a
|
||||
`peak_soc_floor` at or below it.
|
||||
- Both defaults live once in `PEAK_OPTION_DEFAULTS` (TARIFF-02's pattern),
|
||||
imported by validate.py and main.py and pinned to config.yaml by the test.
|
||||
The module reads no option itself.
|
||||
- `maintenance_soc_floor` is the ambiguous name of the two. Decision: keep it,
|
||||
carry the distinction in DOCS.md; revisit if a third floor appears.
|
||||
- SAFETY-07 itself added no option: `peak_cap_w` was already validated
|
||||
`int(500,15000)`, and 14's `capacity_peak_limit_kw` stays declined (a kW key
|
||||
beside a W key is the mismatch trap).
|
||||
|
||||
## Per-pack floors (SAFETY-32) and phases
|
||||
|
||||
`bms_soc_floor` became a roster key; `pack_floor_pct()` conjoins it with the
|
||||
site's EMS floor, and rule 8 names the unit it refuses. `peak_soc_floor` stayed
|
||||
one number for the site, so with cut-outs of 10 and 18 the operator sets it
|
||||
above 18; the per-pack EMS floor is the open question in [[SAFETY-32]]. The
|
||||
`min()` over measured SoC (`Controller.pack_floor_soc`) is still right for the
|
||||
site verdict; `pack_permissions()` is the per-pack answer.
|
||||
|
||||
Nothing here reads a per-phase quantity: the sum/max/net choice is an open
|
||||
owner question, defaulted to NET inside `billed_w()`, so this file does not
|
||||
change whichever way it is answered.
|
||||
@@ -1,406 +0,0 @@
|
||||
# SAFETY-10 — shadow mode, and why "default-on" is not `true`
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-10`.
|
||||
⚠️ **AMENDED BY SAFETY-47, and three of the claims below were false when this
|
||||
was merged.** §5's "the strategies are unaffected" exemption, §4's peak-guard
|
||||
row and §6's evidence table are corrected in place and each correction is
|
||||
marked. The amendments are marked rather than rewritten silently, because what
|
||||
this card believed is part of why the defect shipped. See
|
||||
`DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md`.
|
||||
**Card:** SAFETY-10 — *"Shadow mode (default-on, log-only decisions)"*.
|
||||
**Nothing here reached the inverter.** No deployment, no `ha addons update`, no
|
||||
contact with 192.168.2.158 or 192.168.2.221. Everything below was measured in
|
||||
the CI image and on TEST-23 stage 3's closed-loop rig.
|
||||
|
||||
---
|
||||
|
||||
## 0. The four things a reader should take away
|
||||
|
||||
1. **"Default-on" is delivered as `auto`, not as `true`, and the difference is
|
||||
an install that is regulating a real battery today.** The Supervisor
|
||||
composes an add-on's options as *its defaults, overlaid with the stored user
|
||||
options*, so a new key with a new default reaches **every existing install**
|
||||
on the next update. A boolean `shadow_mode: true` would silently stop the
|
||||
reference site. §1.
|
||||
2. **Shadow mode WRITES. It writes 0 W, on the ordinary heartbeat, through the
|
||||
ordinary single writer.** "Commands nothing" implemented as "stops writing"
|
||||
would leave the inverter latched on whatever was standing — it has no meter
|
||||
timeout — and would put the ESP32 into a tripped stale-input watchdog for
|
||||
the whole run. §2.
|
||||
3. **The sign watchdogs are inert, and that was the decision that had to be got
|
||||
right.** Fed the shadow decision they would trip within `sign_disagree_s`
|
||||
and **latch a trip that survives into live operation and a restart**. §4.
|
||||
4. **The loop leg's shadow decision is one slew step, not a trajectory, and
|
||||
that is a real limitation of this feature.** §5.
|
||||
5. ⚠️ **SAFETY-47: and neither is anything else that depends on the plant
|
||||
answering back. A shadow run cannot surface a closed-loop defect at all**,
|
||||
by the same construction that makes it safe — the battery is held at 0 W, so
|
||||
every measured input describes a plant that did not respond. Of the three
|
||||
defects §"WHAT DEFECT THIS PREVENTS" was written for, exactly one —
|
||||
TARIFF-14's wrong quarter-hour — is visible from a shadow run. §5, §6.
|
||||
|
||||
---
|
||||
|
||||
## 1. The default
|
||||
|
||||
### The literal reading, and what it costs
|
||||
|
||||
`config.yaml`'s `options:` block **is** the default. There is exactly one of it,
|
||||
and HA's Supervisor resolves an add-on's effective options as
|
||||
|
||||
```
|
||||
{**config.yaml options, **the stored user options}
|
||||
```
|
||||
|
||||
A key that has never been stored therefore takes the file's value — on a fresh
|
||||
install and on an install that has been running for months, identically. There
|
||||
is **no mechanism in the add-on options system** by which one key can default
|
||||
differently for the two. So the card's title, taken at face value, is:
|
||||
|
||||
> the next `ha addons update` stops the battery at the reference site, and the
|
||||
> only symptom is a battery that has stopped doing anything.
|
||||
|
||||
That is strictly worse than every defect shadow mode exists to catch. It is
|
||||
refused.
|
||||
|
||||
### What was shipped instead
|
||||
|
||||
`shadow_mode` is a three-valued `list(auto|on|off)` defaulting to `"auto"`:
|
||||
|
||||
| value | resolves to |
|
||||
|---|---|
|
||||
| `off` | live |
|
||||
| `on` | shadow, always |
|
||||
| `auto` | shadow **while control is stopped**; live once it is started |
|
||||
|
||||
`auto` reads `Controller.auto` — the persisted start/stop state, which a fresh
|
||||
install takes from `auto_start` and which the *Start / stop control* button
|
||||
moves. No new persisted state, no migration, no guess:
|
||||
|
||||
* a **fresh** install ships `auto_start: false`, has no stored `auto` key, and
|
||||
therefore comes up **in shadow mode**. It observes a day of decisions before
|
||||
it is ever allowed to command anything. That is the card's intent, delivered
|
||||
where it is safe;
|
||||
* an install that is **controlling** is started, so `auto` resolves to live and
|
||||
**not one watt changes**;
|
||||
* an install that is **stopped** keeps its actuator at 0 W exactly as before —
|
||||
the same number, from the same writer, on the same heartbeat. What changes is
|
||||
what the status page and the action log **say**, and that the decision path
|
||||
now runs and is recorded.
|
||||
|
||||
⚠️ **Under `auto`, pressing Start leaves shadow mode.** That is the point of
|
||||
`auto`: the button means "control the battery". An operator who wants the
|
||||
decisions recorded *while the loop runs* — the configuration this card exists
|
||||
to make possible — sets `on` explicitly, and cannot reach it by accident.
|
||||
|
||||
⚠️ **An unrecognised value resolves to SHADOW, and is REFUSED at startup.**
|
||||
`shadow.mode()` fails toward inaction; `validate.py` rule 11 refuses the value
|
||||
by name so the reinterpretation is never silent. A typo is not a decision, and
|
||||
an install silently left in shadow mode commands nothing indefinitely while its
|
||||
status page calmly explains that this is intentional.
|
||||
|
||||
---
|
||||
|
||||
## 2. "Commands nothing" is a number, not a silence
|
||||
|
||||
The inverter holds its last command **forever** — `HANDOVER.md` §6 says so, and
|
||||
`firmware/goodwe-master.yaml`'s header records what it cost when a controller
|
||||
stopped refreshing: *"Illusmart went silent mid-command and the inverter held
|
||||
+5000 W for 113 s"*. `firmware/goodwe-master.yaml` states the contract that follows — *"HA
|
||||
must now REWRITE the setpoint every ~10 s even when the value has not
|
||||
changed"* — because without a refresh, **"stale" and "steady" are the same
|
||||
thing on the wire**.
|
||||
|
||||
So shadow mode holds the actuator at `shadow.WIRE_W` = **0.0 W**, refreshed on
|
||||
the ordinary heartbeat. Two consequences were weighed and are recorded:
|
||||
|
||||
* **it is not silence.** A shadow mode that stopped writing would leave the
|
||||
standing command up until the ESP32's 30 s watchdog noticed — and at a site
|
||||
with no such board, forever;
|
||||
* **it does not use an alarm as a mode.** Letting that watchdog trip for the
|
||||
duration of a shadow run teaches the operator that `wd_tripped` is normal,
|
||||
which is exactly the training that makes it useless when the add-on has
|
||||
actually died.
|
||||
|
||||
### It is not a second writer
|
||||
|
||||
SAFETY-28's rule is one actuator, one writer. Shadow mode files **no claim**,
|
||||
owns **no transport**, and calls nothing in `hass.py`. It is a **substitution**
|
||||
inside `Controller._write_unit()` — the single choke point every write in this
|
||||
add-on already reaches, including the heartbeat, the stop button, `shutdown()`
|
||||
and `maint_abort()` — in exactly the form of the three substitutions above it
|
||||
(non-finite → 0 W, unauthorised → 0 W, clamped → the rating).
|
||||
|
||||
It is the **last** of them. Below SAFETY-28's gate and below SAFETY-03's clamp,
|
||||
so both still grade the number the decision path actually produced, and a
|
||||
shadow run can report *"it would have hit the rating here"*.
|
||||
|
||||
---
|
||||
|
||||
## 3. What the run records
|
||||
|
||||
| surface | what it carries |
|
||||
|---|---|
|
||||
| status banner | `SHADOW MODE - the battery is NOT being controlled...`, **first in the cascade**, amber — red, with the latch named, if one is standing |
|
||||
| status row `Shadow mode` | which rung decided, the watts it **would have commanded on this cycle**, where the inverter actually is, and `Resolution.explain()` — plus, when a measured billed quarter sized those watts, SAFETY-47's caveat saying so (`shadow.SIZED_FROM_METER`) |
|
||||
| status row `Commanded` | `0 W` — the truth, which the row above reconciles with the `Why` row below it |
|
||||
| action log | one line when shadow mode comes on; one line per **change of decision** (winner, rounded watts, or SAFETY-47's caveat state), carrying the arbiter's own sentence and the same caveat the row carries — `shadow.log_line()` is the one sentence both read |
|
||||
| MQTT | `status: shadow`, its own value, below `failsafe` |
|
||||
|
||||
The action log is **edge**-logged, not level-logged: the log holds 40 rows, and
|
||||
a line per cycle would flush the context of the run's own findings inside four
|
||||
minutes at the 15 s cycle floor.
|
||||
|
||||
---
|
||||
|
||||
## 4. The failsafes, one by one
|
||||
|
||||
| failsafe | in shadow mode | why |
|
||||
|---|---|---|
|
||||
| SAFETY-02/30 sign watchdogs | **INERT** | They grade `Inverter.standing_w()` — what this controller has standing on the wire — and shadow mode holds that at 0 W, so they cannot arm. ⚠️ Fed the shadow decision instead they would disagree with a meter that never saw it and **latch a trip that survives into live operation and a restart**: shadow mode would manufacture the failsafe it exists to help debug. ⚠️ **Two rungs hold it there** — `last_write` (the wire gate) and `inv.target` (the re-seed) — which is MUTATION.md's masking shape, so the mutation that reproduces the hazard defeats both as a pair (M6). Driven singly, dropping the re-seed does not reproduce it at all — measured |
|
||||
| SAFETY-03 clamp | **OBSERVES** | Runs on the decision, sets `clamp_last`, logs every engagement. Changes no command |
|
||||
| SAFETY-28 write gate | **OBSERVES** | Same placement, same reason: the question is who chose the number |
|
||||
| SAFETY-18 meter freeze | **OBSERVES**, unchanged | It takes no action live either; both halves of its conjunction are as true in shadow |
|
||||
| SAFETY-07/24/25 peak guard | **OBSERVES, AND SEES ONLY THE HOUSE** | Level-triggered, unlatched, files its claim as usual, so the recorded decision is the real one *for this meter*. ⚠️ **AMENDED BY SAFETY-47.** This row used to end "SAFETY-45's limit cycle is visible from a shadow run, which is the point". It is not: every term of `wanted = batt_w + excess` is measured, and in shadow mode the battery contributes nothing to either. Measured on the rig, same hour, same unsized code — **410 of 720 cycles engaged live, 0 of 720 in shadow** |
|
||||
| SAFETY-31 circulation | **OBSERVES AND MAY LATCH** | Its evidence is *measured* power. Packs pushing against each other while all are told 0 W is a real fault |
|
||||
| SAFETY-42 delivery | **OBSERVES AND MAY LATCH** | Same, against a share that is 0 W |
|
||||
| operator stop | **SUPPRESSED AS A CLAIM, ENFORCED AT THE WIRE** | The one deliberate change. Its `P_SAFETY` 0 W claim out-ranks everything, so leaving it in makes the recorded decision `"stopped"` on every cycle and the run answers the wrong question. The actuator ends at the same number either way, and the wire substitution is **strictly stronger**: a claim can be out-ranked, and it cannot |
|
||||
| inputs missing, non-finite, allocator refusal, arbiter contradiction | **UNCHANGED** | All resolve to 0 W, which is what the wire gets anyway |
|
||||
|
||||
⚠️ **A latch that is already up stays up.** Nothing here clears one. The shadow
|
||||
banner **names** any standing latch and goes red for it, because a latch that is
|
||||
up while the controller is inert is up the moment it stops being inert.
|
||||
|
||||
⚠️ **Maintenance is not in that table because shadow mode does not touch it.**
|
||||
The state machines are ticked on every cycle whether control is started or not,
|
||||
so a stopped install already runs them against a battery that does not move.
|
||||
Shadow mode inherits that, unchanged and unimproved. Recorded here rather than
|
||||
quietly fixed under another card's name.
|
||||
|
||||
---
|
||||
|
||||
## 5. The limitation, stated rather than discovered later
|
||||
|
||||
`Controller._shadow_settle()` re-seeds the carried command (`self.target` and
|
||||
each `inv.target`) to `WIRE_W` at the **end** of every shadow cycle — after
|
||||
every in-cycle guard has read the decision. Three machines read that value as a
|
||||
statement about the **hardware**: the loop's `prev_w` and integrator,
|
||||
`standing_w()`, and SAFETY-42's share comparison. Re-seeded, they track reality,
|
||||
and **the exit is safe by construction** — the carried command already equals
|
||||
the standing one, so the first live cycle slews from 0 W like any other.
|
||||
|
||||
⚠️ **The cost is real.** The loop's `prev_w` is therefore 0 W on every shadow
|
||||
cycle, so the loop leg's recorded decision is **one slew step from the standing
|
||||
command** and stays there. A shadow run answers *"what would this controller
|
||||
command NOW, from where the plant really is"* — a true statement about the
|
||||
present — and **not** *"what trajectory would it have followed"*. The trajectory
|
||||
is a closed-loop property, it did not happen, and reporting it would be a
|
||||
simulation of a plant that does not exist; three cycles in, such a number
|
||||
describes nothing.
|
||||
|
||||
⚠️ **AMENDED BY SAFETY-47 — THE PARAGRAPH THAT STOOD HERE WAS THE DEFECT.**
|
||||
It read:
|
||||
|
||||
> The strategies are unaffected: arbitrage, the peak guard and maintenance all
|
||||
> file `SET` claims at a fixed power from level-triggered inputs. That is why
|
||||
> the three defects this card was written for — SAFETY-45's 28 onsets an hour,
|
||||
> TARIFF-14's wrong quarter-hour, SAFETY-46's ceiling churn — are all visible
|
||||
> from a shadow run, and the loop's ramp is not.
|
||||
|
||||
It was true until **SAFETY-45 merged, one commit before this card**. That
|
||||
change makes the buy leg `min(asked, headroom)` with
|
||||
|
||||
```
|
||||
headroom = (900 / remaining) x (ceiling - projected)
|
||||
```
|
||||
|
||||
and `projected` reads the **open quarter's accumulator** — the meter — which is
|
||||
a function of what the battery did earlier in that same quarter. The strategy
|
||||
is closed-loop, and the loop's one-slew-step limitation above is not a special
|
||||
case of anything: **it is the general case**, and the limitation is the whole
|
||||
feature's, not the loop leg's.
|
||||
|
||||
**The correct statement, and it is now graded rather than asserted:**
|
||||
|
||||
> A shadow run cannot surface a **closed-loop** defect — one whose stimulus is
|
||||
> the plant's response to the controller — because the battery is held at 0 W
|
||||
> and there is no response to be the stimulus. **Which** hour a strategy picks
|
||||
> and **why** are unaffected, because the clock and the price series are not
|
||||
> functions of the battery. **How hard** it trades is not.
|
||||
|
||||
Of the three defects this card was written for:
|
||||
|
||||
| | visible from a shadow run? | measured |
|
||||
|---|---|---|
|
||||
| SAFETY-45's limit cycle | **no** | 28 charge onsets live, **1** in shadow; grid −455.5 … +3200.0 W live, **flat at the 1200 W house** in shadow; guard engaged 410/720 live, **0/720** in shadow |
|
||||
| SAFETY-46's SoC churn | **no** | the SoC moves **0.000000000 pp** in a shadow run. No SoC limit can be *reached* in one |
|
||||
| TARIFF-14's wrong quarter-hour | **yes**, and it is the one | which hour, at what price, clearing what spread: identical live and shadow on **720 of 720** charging cycles, at a binding cap |
|
||||
|
||||
Graded in `test_arbitrage.py` part **3d** — the same closed loop part 3c uses,
|
||||
run once live and once in shadow — and the loop leg's own limitation stays
|
||||
named in `test_watchdog.py` part 10 as `RECORDED LIMITATION`.
|
||||
|
||||
---
|
||||
|
||||
## 6. How "nothing reaches the inverter" is graded
|
||||
|
||||
**Not on a flag.** A check that asserted `Controller.shadow` would pass on a
|
||||
build in which the flag is set and the substitution is deleted — which is
|
||||
exactly mutation **M1**.
|
||||
|
||||
`test_clamp.py` part 8 drives the **shipped** `Controller.cycle()` and the
|
||||
shipped heartbeat against a recording Home Assistant stub, and asserts on what
|
||||
reached `set_number()`:
|
||||
|
||||
* **the precondition first** — the same stimulus with `shadow_mode: off` puts
|
||||
30 non-zero writes on the wire, the largest 5000 W. MUTATION.md habit 4: a
|
||||
check whose data cannot reach the failure it names is decoration;
|
||||
* then `set(writes) == {0.0}`, **and** that there were 26 of them — a check
|
||||
that only said "no non-zero write" would pass on the silent build too;
|
||||
* entry and exit are measured on `inv.last_write` — what the inverter is
|
||||
latched on, updated only on an **accepted** write — either side of the
|
||||
boundary, against the same run that never entered shadow mode.
|
||||
|
||||
The closed-loop half is `/home/homelab/s10-mut/shadowloop.py` on TEST-23 stage
|
||||
3's rig: the same hour, twice, against `plantsim.Plant` through the firmware's
|
||||
own frame encoder.
|
||||
|
||||
⚠️ **SUPERSEDED BY SAFETY-47 — THE TABLE THAT STOOD HERE COULD NEVER HAVE GONE
|
||||
RED, FOR TWO INDEPENDENT REASONS.** It read `arb_power_w` −2000 W *"identical"*,
|
||||
`arb_reason` *"identical, verbatim"*, arbiter winner *"identical"*, and
|
||||
concluded *"the decision happened, at the same instant, for the same reason, at
|
||||
the same signed power — and the battery did not move."* The battery half is
|
||||
true and is unchanged. The **decision** half was decoration:
|
||||
|
||||
1. **the fixture is `peak_cap_w: 15000` against a 350 W house**, so the
|
||||
capacity guard never engages and SAFETY-45's sizing is in the run and inert.
|
||||
The harness's own comment says so — *"at this scenario's `peak_cap_w: 15000`
|
||||
it is not binding anyway"* — which is MUTATION.md habit 4 written down
|
||||
beside the check it disables;
|
||||
2. **every identity row reads the FIRST charging sample.** At `elapsed = 0`
|
||||
the projection *is* the held level, so the two runs agree to the float there
|
||||
**even at a cap that does bind**. Measured: closing reason (1) alone leaves
|
||||
that comparison passing.
|
||||
|
||||
Both had to be closed. Re-taken below at **`peak_cap_w` 1500 / house 1200 /
|
||||
`max_w` 2000**, one hour at a 5 s cycle, **across the whole run** — harness
|
||||
`/home/homelab/s47-mut/shadowloop47.py`, same rig, same three transcribed
|
||||
lines.
|
||||
|
||||
| | live | shadow |
|
||||
|---|---|---|
|
||||
| grid | 1224.1 … 1506.0 W — **held up at the ceiling** | **1200.0 W flat = the house, exactly** |
|
||||
| SoC | 50.000019 → **51.856789 %** | 50.000000 → **50.000000 %** |
|
||||
| `ARBITRAGE_CHARGE` cycles | 720 / 720 | **720 / 720** |
|
||||
| first charging cycle, `arb_power_w` | −300.0000 W | **identical — and this is why the old table passed** |
|
||||
| `arb_power_w`, **whole run** | −307.799 … −299.930 W | **−2000.000 … −300.000 W** |
|
||||
| …cycles where the two differ | — | **716 of 720** |
|
||||
| `arb_reason`, whole run | the strategy's own sentence | **differs on 719 of 720** |
|
||||
| measured charging headroom | 299.93 … 307.80 W | **300.0 … 54 000.0 W** |
|
||||
| final recorded command | **−302.4 W** | **−2000.0 W — 6.61× the live figure** |
|
||||
| price / spread / which hour | | **identical on 720 of 720** |
|
||||
| arbiter winner | `arbitrage` | **identical** |
|
||||
|
||||
**What the re-taken table says.** The decision still *happens*, at the same
|
||||
instant, on the same hour, at the same price, for the same *kind* of reason,
|
||||
and the battery still does not move — those were the card's real claims and
|
||||
they hold. What does **not** hold is that the decision is the *same decision*:
|
||||
its **size** is a function of a meter the battery never moved, and at this cap
|
||||
the recorded figure is 6.61× what a live controller commands. 54 000 W of
|
||||
"headroom" under a 1500 W ceiling is `900/remaining` multiplying a gap the
|
||||
battery never closed.
|
||||
|
||||
⚠️ **And the SoC row is the second half of it.** 0.000000000 pp of movement is
|
||||
not a curiosity: it is the proof that **no SoC-limit behaviour is reachable
|
||||
from a shadow run at all**, which §5 used to promise.
|
||||
|
||||
SAFETY-47 puts the caveat on both surfaces that carry the number and narrows
|
||||
the three documents' claims; `test_arbitrage.py` part 3d is the CI-side grader
|
||||
so this class cannot recur.
|
||||
|
||||
⚠️ **Three lines of each harness are a TRANSCRIPTION and are marked as one.**
|
||||
`priceloop.Rig` has no `_write_unit()`, no clamp and no failsafe machine
|
||||
(divergence 2 of its README), so the substitution's **placement** is
|
||||
transcribed there. The shipped placement is graded by `test_clamp.py` part 8,
|
||||
which drives the real `Controller`. ⚠️ **And the rig's transcribed cycle
|
||||
predates SAFETY-45**: `arbitrage.decide()` gained a required
|
||||
`charge_headroom_w` after `478ba93`, and the harness supplies it from the
|
||||
**shipped** `peakguard.charge_headroom_w()` reading the **shipped** peak
|
||||
decision rather than from a constant, so SAFETY-45 stays in the run.
|
||||
|
||||
---
|
||||
|
||||
## 7. The mutation battery — 22 mutations, no survivors
|
||||
|
||||
Harness: `/home/homelab/s10-mut/run.py`, backups beside it. Each run restores
|
||||
from backup, purges `__pycache__` **on the host**, and grades through the CI
|
||||
image with the tree bind-mounted `:ro`, so the interpreter cannot write a cache
|
||||
into the tree under test (TEST-15). The full table is in the branch report;
|
||||
one per new behaviour, including **M1** (the substitution deleted — a write
|
||||
escapes), **M2** (it returns instead of substituting — the inverter is left
|
||||
latched), **M3** (it moves above SAFETY-28 and SAFETY-03 — the observers go
|
||||
blind), **M8** (`auto` resolves to shadow unconditionally — the literal
|
||||
default-on that stops the live install), **M12** (the stop claim is filed in
|
||||
shadow — every recorded decision reads `stopped`), **M21** (the substitution
|
||||
and the re-seed together) and **M22** (the re-seed moved one line earlier —
|
||||
SAFETY-18 grades a 0 W no decision produced and goes permanently blind).
|
||||
|
||||
⚠️ **Two findings from driving it, both of the kind MUTATION.md says to expect.**
|
||||
|
||||
* **M2 first came back a SURVIVOR and was not one.** The "stop writing
|
||||
altogether" build left `inv.last_write` as `None`, the check did `float()` on
|
||||
it, and the section died with a `TypeError` — grading none of the checks
|
||||
after it and reporting **zero reds**, which reads exactly like a survivor.
|
||||
That is FCST-06's shape. The fixture now hands `None` back and names it as
|
||||
its own check; M2 is red on three.
|
||||
* **M6 had to become a PAIR, and only the battery said so.** `standing_w()`
|
||||
prefers `last_write` — the 0.0 W the wire gate accepted — and falls back to
|
||||
`inv.target`, which the re-seed also holds at 0.0. Either rung alone keeps
|
||||
the detector inert, so dropping the re-seed **singly** came back red on the
|
||||
limitation check and on **nothing about the latch**: the hazard it names was
|
||||
not reproduced. TEST-16's rule with a new name on it.
|
||||
* **M7/M8/M9/M19 had to MOVE SUITES.** They defeat `shadow.engaged()`, and a
|
||||
defeated default stops `test_clamp.py` part 3's wired run reaching the wire
|
||||
at all — aborting the file with an `IndexError` before part 8 is graded. The
|
||||
red count there was a lower bound naming the saturation freeze. The mode
|
||||
table now lives in `test_validate.py`, which drives no Controller.
|
||||
* **`shadow_would_w = float(self.target)` is an EQUIVALENT mutation and was
|
||||
measured as one.** `_shadow_settle()` runs before the re-seed, so at that
|
||||
line `self.target` **is** `resolution.target_w`. It is recorded as equivalent
|
||||
rather than reported as a survivor, and the non-equivalent form of the same
|
||||
defect — the record becoming the **wire** — is driven instead.
|
||||
|
||||
### The table
|
||||
|
||||
Each row: the mutation, the file, the byte delta, the suite it was graded in,
|
||||
and how many **named** checks went red. `⚠️` marks a row whose run did not
|
||||
reach the end of its file, so its count is a **lower bound** (MUTATION.md).
|
||||
|
||||
| # | mutation | file | Δ | suite | reds |
|
||||
|---|---|---|---|---|---|
|
||||
| M1 | **a write escapes** — the substitution deleted | `main.py` | −6 B | clamp | 5 |
|
||||
| M2 | **silence instead of 0 W** — it returns rather than substituting | `main.py` | −15 B | clamp | 3 |
|
||||
| M3 | **the observers go blind** — it moves above SAFETY-28 and SAFETY-03 | `main.py` | +58 B | clamp | 1 |
|
||||
| M4 | the site re-seed dropped — the exit resumes from a fiction | `main.py` | −36 B | clamp | 3 |
|
||||
| M5 | the per-unit re-seed dropped | `main.py` | −22 B | clamp | 1 |
|
||||
| M6 | **PAIRED**: `standing_w()` reads the target **and** the re-seed dropped — the sign latch that survives into live operation | `inverter.py` + `main.py` | −170 B | watchdog | 3 |
|
||||
| M7 | `auto` inverted — shadow while started, live while stopped | `shadow.py` | +2 B | validate | 4 |
|
||||
| M8 | **default-on for everyone** — `auto` always shadow; the literal reading that stops the live install | `shadow.py` | −15 B | validate | 2 |
|
||||
| M9 | an unreadable value resolves to LIVE instead of shadow | `shadow.py` | +1 B | validate | 1 |
|
||||
| M10 | `validate.py` stops refusing an unknown value | `shadow.py` | −9 B | validate | 9 |
|
||||
| M11 | the loop leg gated on `auto` alone again — the decision path stops running | `main.py` | −17 B | clamp | 1 |
|
||||
| M12 | the stop claim filed in shadow — every decision reads `stopped` | `main.py` | −20 B | clamp | 1 |
|
||||
| M13 | the banner goes green while nothing is commanded | `shadow.py` | −2 B | clamp | 1 |
|
||||
| M14 | the banner loses the standing latch | `shadow.py` | −2 B | clamp | 1 |
|
||||
| M15 | the row renders the standing command instead of the decision | `shadow.py` | −8 B | clamp | 1 |
|
||||
| M16 ⚠️ | the row disappears when live | `main.py` | +43 B | clamp | 1 |
|
||||
| M17 | the record becomes the wire | `main.py` | −6 B | clamp | 7 |
|
||||
| M18 | level-logged — the 40-row action log floods | `main.py` | −23 B | clamp | 1 |
|
||||
| M19 | the two rungs render identically (masking) | `shadow.py` | +22 B | validate | 1 |
|
||||
| M20 | the shipped default moves to `off` in `config.yaml` alone | `config.yaml` | −1 B | defaults_pin | 1 |
|
||||
| M21 | **PAIRED**: the substitution **and** the re-seed deleted together | `main.py` | −103 B | clamp | 10 |
|
||||
| M22 | the re-seed moved one line earlier — SAFETY-18 grades a 0 W no decision produced and goes permanently blind | `main.py` | +40 B | clamp | 1 |
|
||||
|
||||
**Survivors: none.** ⚠️ **M17's `+0 B`-adjacent sibling** —
|
||||
`shadow_would_w = float(self.target)` — is recorded in §7 as **equivalent**
|
||||
rather than counted here.
|
||||
@@ -1,80 +0,0 @@
|
||||
# SAFETY-103 - no swing on old battery readings after a drain ends
|
||||
|
||||
**Status: implemented 2026-10-08, for review.** Tests: `test_safety103.py`.
|
||||
|
||||
**The defect** (SAFETY-95 rig test, check 5, F1). While a battery settles
|
||||
from its ended drain, SAFETY-95 steers on the house's point: grid + every
|
||||
battery's reading - aim. The meter is 1 s old; a battery register can be
|
||||
10 s old (the SAFETY-90 register model). At E+13 A's reading said 1238 W
|
||||
(6.8 s old) while A was at 616 W, so the house looked ~620 W bigger: B went
|
||||
860 -> 1650 -> 870 W. B's own old reading does the same the other way.
|
||||
|
||||
**The rule** (`main.py`, `_track_reads()`, `_drain_grid_w()`, N>=2, only
|
||||
while a drain's tail settles):
|
||||
|
||||
1. Each cycle, per battery: the range its power can be in. That is its last
|
||||
changed reading, widened by every command on its wire since (a lagging
|
||||
battery moves from one toward the others, never beyond). A new reading
|
||||
starts the range again.
|
||||
2. A reading that changed this cycle is taken as it reads. So is the side a
|
||||
battery refuses (SAFETY-73): it is not heading that way.
|
||||
3. The house point is held (first cycle: the loop's start from SAFETY-95).
|
||||
It moves only as far as the meter proves: to the nearest value of grid +
|
||||
the batteries' ranges - aim.
|
||||
|
||||
So the loop no longer moves on a reading that has not caught up. A real
|
||||
house step still moves it, as soon as the meter shows more than the
|
||||
batteries can explain.
|
||||
|
||||
**Why not the tester's rule** ("a battery whose reading is older than its
|
||||
last command counts at its command"). B's command changes almost every
|
||||
cycle, so B is almost always at its command. That is the plain meter, which
|
||||
chases A's tail: B swings 1420 W. A lag model needs the plant's time
|
||||
constant, which is not known for a client's inverter. Measured on the plant
|
||||
below (N=2, 1200 W, A Off; swing = a move of the others later undone):
|
||||
|
||||
| rule | 10 s lag | 5 s lag | 20 s lag | surplus, 5 s lag |
|
||||
|---|---|---|---|---|
|
||||
| release/1.0 (readings) | 760 | 840 | 150 | 800 |
|
||||
| tester's: at the command | 1420 | 1020 | 1600 | 800 |
|
||||
| lag model, tau 10 s | 70 | 140 | 80 | 580 |
|
||||
| lag model, tau 5 s, plant 20 s | | | 600 | |
|
||||
| hold all window | 0 | 0 | 0 | 0 |
|
||||
| **this rule** | **0** | **0** | **0** | **130** |
|
||||
|
||||
Holding all window ignores a house step: a 2 kW kettle at E+5 s imported
|
||||
14.4 Wh over the next 30 s (release 11.5, this rule 11.3); 2 kW more sun
|
||||
exported 16.9 Wh (release 8.5, this rule 8.9).
|
||||
|
||||
**Before -> after** (`test_safety103.py`, `tools/safety103_plant.py`: 800
|
||||
W/s ramp, 10 s lag, meter 1 s, registers 10 s at the rig's phases, B idles
|
||||
at -100 W; summed swing of the others in the 23 s after the end):
|
||||
|
||||
| case | release/1.0 | after |
|
||||
|---|---|---|
|
||||
| rig check 5: N=2, 1200 W, A Off | B 1140 -> 850 -> 1630 -> 870, swing 760 W | B holds 1140 W |
|
||||
| N=3, 1200 W / 4000 W, A Off | 680 / 630 W | 0 / 0 W |
|
||||
| solar surplus, N=2 / N=3 | 890 / 740 W | 130 / 130 W |
|
||||
| lag 5 or 20 s, registers 5 or 10 s, N=2 and N=3 | | at most 10 W |
|
||||
| registers as fresh as the meter (1 s) | | rows byte-identical |
|
||||
| refusal end | | rows byte-identical |
|
||||
| N=1, every end | | rows byte-identical (also against the release/1.0 tree) |
|
||||
|
||||
**Cost.** In the window, a house change smaller than what the batteries
|
||||
could still be doing is not followed until a reading catches up (at most a
|
||||
register period) or the window ends (23 s). Under a surplus the others
|
||||
still swing up to 130 W, a third of a slew step.
|
||||
|
||||
**Not covered.**
|
||||
- Outside the drain tail the loop steers on the meter; readings only reach
|
||||
the saturation freeze (SAFETY-96), unchanged.
|
||||
- A lost link or a refusal ends the tail at once (A is out of the split, or
|
||||
reads ~0 W). The swing after them is the loop's own on a lagging plant,
|
||||
the same with 1 s registers and on release/1.0: under a surplus up to
|
||||
1.5 kW at N=2 and 3.9 kW summed at N=3; after a lost link about 1 kW
|
||||
even without one. Not this card.
|
||||
- A battery that overshoots its command, or a reading offset beyond the
|
||||
range, can put the true power outside the range; the meter then moves the
|
||||
point, as before.
|
||||
- The end cycle's start (SAFETY-95 step 2) still uses the readings; the
|
||||
batteries were steady during the drain, so they are current there.
|
||||
@@ -1,96 +0,0 @@
|
||||
# SAFETY-107 - a refusal held under another writer is learned once
|
||||
|
||||
**Status: for review.** Tests: `test_safety107.py`. One inverter only; nothing
|
||||
changes at N>1 (SAFETY-73's unit refusal has no "another writer" end).
|
||||
|
||||
**The defect** (MAINT-13 F5, MAINT-14 "seen again"). A full pack under a
|
||||
maintenance charge in a cheap window takes no charge. At N=1 the action log then
|
||||
showed "BATTERY REFUSES asked -2500 W ..." and "BATTERY REFUSES ENDED: another
|
||||
writer" every cycle: 548 + 548 lines in 15 min on the MAINT-13 rig head,
|
||||
652 + 651 in 12 min on its base, 33 + 32 in 60 s in MAINT-14's c2b. The 40-line
|
||||
log flooded and the hint under the buttons changed every second, so the
|
||||
MAINT-13 notice was gone at once.
|
||||
|
||||
**Cause** (`main.py`, the SAFETY-52 block before `compute()`). The end tested
|
||||
`self.target != self.loop_w`: the wire differs from the loop's ask. A held
|
||||
refusal makes the loop ask 0 W, so any claim that binds (the maintenance
|
||||
charge's `hi`, the peak guard's `lo`, a trade's SET) differs from it on every
|
||||
cycle. The refusal was ended against the same writer it had been learned
|
||||
against. `sat_count` is not reset by that end, so `compute()` learned the same
|
||||
refusal again in the same cycle. Nothing about the battery or the wire had
|
||||
changed; only the log and the hint did.
|
||||
|
||||
**Other writers.** The same flap comes from any writer that holds the wire
|
||||
while the loop is refused, as long as the evidence lasts: a trade (a SET at
|
||||
P_ARBITRAGE) into a full pack, and the peak guard over an empty pack: 335
|
||||
refusal lines in 15 min each at a 5 s meter (`test_safety107.py` part 2, run on
|
||||
release/1.0).
|
||||
A maintenance drain or hold does not flap: its SET resets `sat_count` each cycle.
|
||||
|
||||
**The rule** (`_refusal_update()`, `rejudged`). When another writer held the
|
||||
wire, the held refusal is judged afresh: `compute()` gets `refused=0` and no
|
||||
probe, exactly as before the change. Then:
|
||||
|
||||
1. it finds the same refusal against that writer's ask: the same episode
|
||||
continues, with no line and no hint. The probe clock restarts, as it did on
|
||||
the old end and start: a probe is the loop's ask, and it is not on the wire
|
||||
while another writer is;
|
||||
2. it does not: the refusal ends, logged "another writer", or "the battery
|
||||
gives power" when the reading shows it (it was "another writer" before);
|
||||
3. it finds the other direction: end, then a new start, as before. In
|
||||
practice 2 comes first: SAFETY-94's evidence restarts when the ask changes
|
||||
sign, so a writer that turns round ends the refusal at once and the other
|
||||
way is learned ~12 cycles later (`test_safety107.py` part 3e).
|
||||
|
||||
**Why this rule.** The end now means what SAFETY-52 meant by it: the refusal no
|
||||
longer describes what is on the wire, judged by the same test that learns it.
|
||||
`compute()` sees the same inputs as before and the state after each cycle is
|
||||
the same (`batt_refused`, `batt_refused_at`), so every command is unchanged.
|
||||
Keeping the held refusal into `compute()` instead (mutation M5) would also
|
||||
stop the lines, but it holds the loop at 0 W where the old law let it ask past
|
||||
a writer's limit, which changes commands; it also removes SAFETY-52's
|
||||
owner-decided "another writer" exit.
|
||||
|
||||
**Evidence.**
|
||||
- Differential trace (`goodwe/scratch/SAFETY-107/probe/trace.py`, not in this
|
||||
repo; the real Controller in the CI image, release/1.0 bf3ac79 against this
|
||||
tree): 300 seeds, N=1 (153) and
|
||||
N=2 (147), 1/2/5 s meter, full/empty/dead/healthy battery windows, a stepping
|
||||
house, maintenance charge with the cheap window opening and closing, P1 and
|
||||
the peak guard, trade windows, stop/start; 427 680 cycles, 294 seeds with a
|
||||
refusal held. Every command, site target, integrator, `sat_count`, refusal
|
||||
state and reason is identical. Refusal lines: 100 748 before, 2 188 after.
|
||||
- The card's case: 1 line in 15 min at N=1 and N=2, 1/2/5 s meter, house
|
||||
importing or exporting (release/1.0: 335-1 753 at N=1; N=2 was already 1).
|
||||
|
||||
**Mutations** (`tools/mutation_run.sh` on purged copies; reds in
|
||||
test_safety107 / test_refusal / test_refusal_units; trace = seeds of 300 whose
|
||||
commands differ from release/1.0):
|
||||
|
||||
| | mutation | reds | trace |
|
||||
|---|---|---|---|
|
||||
| M1 | `now == rejudged` -> `False` (every such cycle ends: the old flap) | 26 / 0 / 0 | 0 |
|
||||
| M2 | `rejudged` always 0 (another writer never ends it) | 5 / 1 / 2 | 29 |
|
||||
| M3 | the probe clock is not restarted on a continued episode | 1 / 0 / 0 | 2 |
|
||||
| M4 | a probe may open while rejudged | 0 / 0 / 0 | 0 |
|
||||
| M5 | the held refusal goes into `compute()` while rejudged | 2 / 1 / 2 | 27 |
|
||||
| M6 | an end while rejudged always says "another writer" | 1 / 0 / 0 | 0 |
|
||||
|
||||
M4 is equivalent: a probe matters only if `compute()` engages in that call,
|
||||
which needs `SETTLE_CYCLES` saturated against the writer, and each of those
|
||||
cycles restarted the clock. The line stays because it states the rule.
|
||||
|
||||
**Cost.** None in power. A takeover by a different writer while the battery
|
||||
still refuses is logged as an end and, ~12 cycles later, a new start against
|
||||
that writer (3 lines for the episode, `test_safety107.py` part 3a).
|
||||
|
||||
**Not covered.**
|
||||
- A perfectly flat reading under a writer. The stale-reading gate trips, stops
|
||||
the site and seeds the refusal (SAFETY-52 decision 3). After the stop the
|
||||
writer's ask returns fresh, `compute()` has no saturation history against it,
|
||||
so the refusal ends at the first cycle and the next trip seeds it again: 2
|
||||
lines per gate cycle (29 in 15 min, house exporting), the same as release/1.0.
|
||||
The gate cycle itself (0 W, then the writer's ask) is the cause; stopping it
|
||||
changes commands. None of the rig runs showed it: real readings move.
|
||||
- At N=1 a refusal shapes only the loop. A writer above it still asks the
|
||||
battery; at N>1 SAFETY-73's P_SAFETY claim binds every writer.
|
||||
@@ -1,98 +0,0 @@
|
||||
# SAFETY-112 - control waits for Home Assistant to finish starting
|
||||
|
||||
**Status: for review.** Tests: `test_safety112.py`, `test_messages.py`.
|
||||
Control is unchanged. The add-on now says plainly that it is waiting.
|
||||
|
||||
## Cause
|
||||
|
||||
The Supervisor's proxy refuses every add-on request with 502 until Core's
|
||||
state is `RUNNING`. The add-on reads every battery reading and writes every
|
||||
power request through that proxy, so it cannot control until then. Readings
|
||||
that are already live inside Home Assistant do not help.
|
||||
|
||||
Source, Supervisor 2026.08.0 (the image the live site ran at CFG-04):
|
||||
|
||||
- `supervisor/api/proxy.py`, `APIProxy.api()` (REST, used for reads and
|
||||
`number.set_value`) and `APIProxy.websocket()` both start with
|
||||
`if not await self.sys_homeassistant.api.check_api_state(): raise HTTPBadGateway`.
|
||||
- `supervisor/homeassistant/api.py`, `check_api_state()` returns
|
||||
`state.core_state == "RUNNING" or state.offline_db_migration`. The state
|
||||
comes from Core's `/api/core/state`.
|
||||
- Upstream `main` has the same gate in both handlers (read 2026-10-13).
|
||||
- A Core restart restarts only the Core container
|
||||
(`homeassistant/core.py`, `restart()`). The add-on keeps running.
|
||||
|
||||
The add-on itself has no wait. Nothing in `app/` reads Core's state or waits
|
||||
for an event. The loop reads its inputs every second, a 502 gives `None`,
|
||||
and missing inputs give 0 W. P1 backoff is not involved: the live site reads
|
||||
the meter straight from the HomeWizard (`homewizard_local`), not through Home
|
||||
Assistant.
|
||||
|
||||
## Measured
|
||||
|
||||
Repro stack `safety112-*`, all removed afterwards:
|
||||
|
||||
- Home Assistant 2026.9.4, as live.
|
||||
- A test integration whose `async_setup` sleeps, so Core stays `NOT_RUNNING`.
|
||||
- A fake meter and two fake inverters that update every 2 s from load.
|
||||
- The Supervisor's own `APIProxy` and `HomeAssistantAPI` from the 2026.08.0
|
||||
image. Only the add-on token lookup, Core's access token and the container
|
||||
check are stubbed.
|
||||
- The add-on on `meter_source: homewizard_local` against a fake HomeWizard.
|
||||
|
||||
Times are seconds after `docker restart` of Home Assistant:
|
||||
|
||||
| run | Core `NOT_RUNNING` | entities live in HA | Core `RUNNING` | proxy 502 | first write | wait from live readings |
|
||||
|---|---|---|---|---|---|---|
|
||||
| base 3050fa5, N=2 | 13.1 | 17.1 | 253.1 | 0.9-252.7 (1 132 requests) | 254.7 | 237.6 |
|
||||
| base 3050fa5, N=1 | 13.1 | 18.1 | 256.1 | 1.5-255.1 (573 requests) | 257.2 | 239.1 |
|
||||
| this change, N=2 | 12.2 | 17.2 | 313.2 | 1.4-312.5 (1 406 requests) | 317.6 | 300.4 |
|
||||
| this change, N=1 | 12.2 | 17.2 | 255.2 | 1.2-254.1 (571 requests) | 257.1 | 240.0 |
|
||||
|
||||
The first write comes 1.0-4.4 s after `RUNNING`, so the whole wait is the
|
||||
proxy's. With a 400 s sleep Core moved on at 300.88 s on its own (bootstrap's
|
||||
stage timeout), so one slow integration holds a start stage for about 5
|
||||
minutes. The whole start can take longer: the live one took 414 s.
|
||||
|
||||
Before this change the page said "Not ready — Battery level: no usable
|
||||
reading", which sends the reader to Developer Tools for an entity that is
|
||||
fine.
|
||||
|
||||
## Rule
|
||||
|
||||
1. No way around the Supervisor. The add-on still talks to Home Assistant
|
||||
only through the proxy.
|
||||
2. While the proxy does not answer on the API (`hass.OUTAGE.waiting()`), the
|
||||
first status row is red: "Home Assistant is not answering, perhaps it is
|
||||
still starting. Control resumes by itself when it answers." The banner
|
||||
reads "Not ready — Waiting for Home Assistant to finish starting". The Home
|
||||
pill reads "Waiting for Home Assistant", in English and Dutch.
|
||||
3. A safety stop, a stalled control loop or shadow mode keeps its own banner
|
||||
and state over the wait, because none of them clears by itself.
|
||||
4. The log has one WARNING when Home Assistant stops answering, an INFO line
|
||||
every 5 minutes while it is down, and an INFO line with the length of the
|
||||
wait when it answers again.
|
||||
5. Only the API counts. The meter websocket alone does not, because the meter
|
||||
has its own row.
|
||||
|
||||
## Why it is safe
|
||||
|
||||
The change is text only. `cycle()`, the writes, the heartbeat, the stand-down
|
||||
on shutdown and every watchdog are untouched. `test_safety112.py` part 3 shows
|
||||
the same targets and writes with the flag on and off, for N=1 and N=2.
|
||||
|
||||
## Open, for the owner
|
||||
|
||||
The add-on cannot control while Home Assistant starts. These ways around it
|
||||
were not taken:
|
||||
|
||||
- **Talk to Core directly with a long-lived token** (`http://homeassistant:8123`).
|
||||
Core's own API answers while `NOT_RUNNING`. In the repro `/api/core/state`
|
||||
answered from 12-13 s, and a direct `number.set_value` 10 s into
|
||||
`NOT_RUNNING` returned 200 and set the value. The cost is a secret stored
|
||||
at every client site, which `hass.py` exists to avoid.
|
||||
- **Talk to the control board directly** (ESPHome native API), so control
|
||||
does not need Home Assistant at all. This is a large change.
|
||||
- **Remove the slow start at the site.** On 2026-10-12 the Solis
|
||||
integration's discovery held the start. Fixing or disabling it shortens the
|
||||
wait there, but not on a client site with another slow integration.
|
||||
@@ -1,18 +0,0 @@
|
||||
# SAFETY-118 - the peak forecast is read in its own unit
|
||||
|
||||
**Status: for review.** Tests: `test_safety118.py`.
|
||||
|
||||
`peak_forecast_entity` was read as a bare number, and anything under 50 was
|
||||
multiplied by 1000 ("accept kW or W"). A W sensor reading 17 to 45 W, which a
|
||||
real site did 430 times in one morning, would be taken as 17 to 45 kW. The
|
||||
validator also called a kW sensor wrong, which was false for this entity.
|
||||
|
||||
Decision: the size rule is gone. The entity is read through `pv.watts()`, the
|
||||
same unit table and reader as the solar entities, so W, kW, MW and the rest
|
||||
convert by the sensor's own `unit_of_measurement`. No unit, or a unit that is
|
||||
not power (kWh), means the forecast is not used, exactly as if the option were
|
||||
empty: the loop cap and every non-forecast peak step still apply. One WARNING
|
||||
per cause, and a status row and a settings note say the same.
|
||||
|
||||
No size rule can be right in both units: 30 W and 30 kW are both ordinary.
|
||||
The read is still one `GET /api/states/<id>` per cycle, as `hass.number()` made.
|
||||
@@ -1,99 +0,0 @@
|
||||
# SAFETY-119 - no "not keeping up" when the loop takes the wire back
|
||||
|
||||
**Status: merged (bee7431); the settle and the budget changed in SAFETY-121**
|
||||
(DECISIONS/SAFETY-121-bound-every-quarter.md: 4 cycles and 22 s, one budget
|
||||
of 11). The rolling-budget sections below describe bee7431. Tests:
|
||||
`test_safety119.py`.
|
||||
|
||||
**Cause.** When another owner (the peak guard, a maintenance charge) has set
|
||||
the wire and the loop wins again, `cycle()` re-seeds the integrator from that
|
||||
owner's command, and `compute()` adds `gain * error` from a meter that does
|
||||
not show that command yet. On the TEST-23 rig (SAFETY-118 I-2) the guard
|
||||
stepped from -2000 W to +429 W with 5 s left. A cycle later the loop's ask
|
||||
crossed the +2500 W rail and beat the guard's floor, so SAFETY-85 rule 1
|
||||
counted it as the loop's own bound and raised `goodwe_integrator_bound`. The
|
||||
battery gave what it was told, and the cap held.
|
||||
|
||||
**Decision.** After a hand-back, the loop's integrator bound is not counted
|
||||
until the plant's settle has passed (`SHORT_CYCLES` cycles and
|
||||
`SHORT_SPAN_S`, the window SAFETY-85 grades a battery on). After that it
|
||||
counts as before. An over-rating (`clamped`, a bug) on a cycle the loop
|
||||
decided always counts. No command changes, and no text was added or changed.
|
||||
|
||||
**A rolling budget (round 3).** The settle above is per hand-back, and
|
||||
losing the wire restarts it. Across all hand-backs the bound is hidden on at
|
||||
most a budget of cycles in any 15 minutes (`ROLLING_BUDGET`, `ROLLING_S`); a
|
||||
cycle over the budget counts. Round 1 restarted the settle at every
|
||||
hand-back, so a guard and a charge taking turns hid the bound for good (0
|
||||
warnings in 2 h where 28 were raised before). Round 2 gave each run of
|
||||
hand-backs its own budget; at 10 s and 15 s meters each quarter then started
|
||||
a fresh one and the loop's bound was never counted. A quarter-edge kick is a
|
||||
few bound cycles in one quarter, a real bound that stays is many: a budget
|
||||
per 15 minutes tells them apart.
|
||||
|
||||
**The budget follows the cycle length.** 10 cycles up to a 3.5 s cycle, 8 up
|
||||
to 11 s, 6 up to 16 s, 4 beyond. The cycle length is the time between two
|
||||
decided cycles (a stop, or a gap over two minutes, is not one); until it is
|
||||
known, and without a monotonic clock, the budget is 8. Without a monotonic
|
||||
clock the budget never refills.
|
||||
|
||||
**Why not one number.** Measured over 2 h (`goodwe-addon-checks:1`), the
|
||||
quarter-edge case (house 800 W, cap 2500 W, no forecast, 30 seeds) and the
|
||||
case below. No single budget held both at 1 to 15 s: 3 to 6 let the false
|
||||
alarm back at 2 s; 7 on 10 of 30 seeds at 2 s; 8 on 1 of 30 (seed 25) at
|
||||
2 s, and warned in only 1 to 3 of 7 quarters at 15 s; 9 and 10 miss quarters
|
||||
at 10 s. A slow cycle needs a small budget to warn, a fast one a large budget
|
||||
to stay quiet. Per band, the largest budget that still warns: up to 3.5 s,
|
||||
10 (9 and 10 had no false alarm at 1 and 2 s; above 10 was not swept); up to
|
||||
11 s, 8 (9 misses quarters at 10 s); up to 16 s, 6 (7 leaves two quiet
|
||||
quarters in a row at 15 s, 4 false-alarms at 12 s on 7 of 30 seeds); beyond,
|
||||
4 (3 false-alarms at 20 and 30 s on 14 of 30, 5 leaves five quiet quarters at
|
||||
20 s).
|
||||
|
||||
**What holds.** Quarter-edge case, 30 seeds each: 0 notifications at 1, 2,
|
||||
3, 4, 5, 10, 12, 15, 20 and 30 s. Repeated hand-backs (2 x 2500 W,
|
||||
cheap-window charge, no usable forecast, cap 5000 W, house 5050 and 5100 W,
|
||||
constant and with noise, 8 runs per meter):
|
||||
|
||||
| meter | warnings in 2 h | quarters warned (of 7 after the first) | without the fix |
|
||||
|---|---|---|---|
|
||||
| 1 s | 56 to 63 | 7 | 56 to 63 |
|
||||
| 2 s | 49 to 56 | 7 | 54 to 56 |
|
||||
| 5 s | 19 to 23 | 7 | 28 |
|
||||
| 10 s | 7 to 10 | 7 | 14 |
|
||||
| 15 s | 4 to 10 | 4 to 7, never two quiet in a row | 14 |
|
||||
| 30 s | 3 to 7 | 3 to 5, up to two quiet in a row | 7 to 11 |
|
||||
|
||||
At 30 s no budget does better: 4 leaves two quiet quarters, 3 brings back
|
||||
the false alarm. Single hand-back, the loop at its rail, first warning after
|
||||
the hand-back compared with no fix: 10 s at 1 s, 20 s at 2 s, 40 s at 5 s
|
||||
(default), 80 s at 10 s, 90 s at 15 s, 80 s at 20 s, 120 s at 30 s, 240 s at
|
||||
60 s. At 60 s the rig is a different regime (the house is not over the cap
|
||||
in the case above), so nothing more is claimed there. The cap held at 1 to
|
||||
10 s in the quarter-edge case and at 1 to 5 s in the case above; at slower
|
||||
meters the meter's lag puts quarters over it, the same with and without the
|
||||
fix (commands are identical).
|
||||
|
||||
**Limits.** These runs have one cycle per meter reading, at a steady
|
||||
interval. A 20 or 30 s cycle needs `cycle_floor_s` raised; at the default
|
||||
15 s, a slower meter adds cycles on an old reading. Measured that way (11
|
||||
seeds): a 15 s meter that varies by up to 30 % false-alarmed on 5 seeds, a
|
||||
30 s meter on 10 (0 to 4 in 2 h, 8 to 15 without the fix); a 20 s meter on
|
||||
none. The case above still warned every quarter there.
|
||||
|
||||
**Kept.** The loop in charge at its rail warns on the same cycle as before.
|
||||
The start and a restart after a stop are not hand-backs. The quarter-edge
|
||||
kick of `test_safety119.py` part 1 stays hidden (see above).
|
||||
|
||||
**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`,
|
||||
`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ; commands and
|
||||
claims are the same (`test_safety119.py` part 6).
|
||||
|
||||
**Not hidden.** A battery that gives less than another owner told it still
|
||||
alarms through SAFETY-85 rule 2. "Always counts" for an over-rating holds on
|
||||
cycles the loop decided; on others SAFETY-85 rule 1 already dropped it.
|
||||
|
||||
**Not changed.** The kick itself is control behaviour: for a few seconds at
|
||||
a quarter's edge the loop asks up to its rail. In `test_safety119.py` part 1
|
||||
that is more discharge than the guard asked at a quarter's end, and a few
|
||||
seconds of up to 500 W more charge at the next quarter's start.
|
||||
@@ -1,95 +0,0 @@
|
||||
# SAFETY-121 - a recurring overload warns every quarter
|
||||
|
||||
**Status: for review.** Tests: `test_safety119.py` (parts 2, 4, 5, 6).
|
||||
Follows DECISIONS/SAFETY-119-bound-after-hand-back.md.
|
||||
|
||||
**The defect (TEST-23, SAFETY-119 rig test F-1).** 5 s meter, 2 x 2500 W,
|
||||
cap 5000 W, maintenance charge, no forecast, house 5100 W. bee7431 warned
|
||||
once in 70 min (04:31:01), then not at 04:45, 05:00 or 05:15. Base 75f0804
|
||||
warned at every quarter start. The loop held the wire once a quarter, at its
|
||||
start, and was at its bound on 4 or 5 cycles. SAFETY-119's settle (12
|
||||
cycles, 60 s at 5 s) covered all of them, and 5 cycles a quarter fit in its
|
||||
budget of 8 per 900 s.
|
||||
|
||||
**Why the simulation said "every quarter".** `test_safety119.py` fed the
|
||||
meter one cycle behind the battery reading (`delay` counts cycles: 5 s at a
|
||||
5 s meter). Just after the loop leaves its rail, the guard's charge
|
||||
headroom (house = meter + battery) is then off by up to 5000 W, the charge
|
||||
pulls 2000 W, the guard steps in again, and the two chase each other: 8 to
|
||||
10 hand-backs and about 20 bound cycles a quarter at 5 s (rig: 1 and 4-5).
|
||||
With the meter read in the same cycle (`delay` 0, the rig's shape) the sim
|
||||
gives the rig's numbers: 3 to 5 bound cycles a quarter at 5 s, all inside
|
||||
SAFETY-119's settle, and bee7431 warns in 0 to 2 of 7 quarters. The test
|
||||
now runs both shapes.
|
||||
|
||||
**Decision.** After a hand-back the loop's bound is hidden until 4 cycles
|
||||
(`HANDBACK_CYCLES`) and 22 s (`SHORT_SPAN_S`) have passed; across all
|
||||
hand-backs on at most 11 cycles (`ROLLING_BOUND_CYCLES`) in any 900 s. The
|
||||
budget no longer follows the cycle length, and the cycle length is not
|
||||
tracked. A stop ends the settle and keeps the budget. An over-rating always
|
||||
counts. Commands are unchanged.
|
||||
|
||||
**Why it holds for any number of bound cycles a quarter.** The settle is
|
||||
fixed: at most 3 cycles or 22 s after a hand-back, whichever is longer. A
|
||||
bound that lasts past it counts on its first cycle after it. So one
|
||||
hand-back a quarter, with the loop at its rail past the settle, warns in
|
||||
that quarter, whether it is 1 bound cycle or 100. A bound that never lasts
|
||||
past a settle counts once more than 11 are hidden within 900 s. Hidden
|
||||
are only shapes with at most 11 bound cycles in 900 s, each within 3 cycles
|
||||
or 22 s of a hand-back. That is the quarter-edge kick: in every steady-meter
|
||||
run below it is on the 1st to 3rd cycle after its hand-back (up to 30 s at
|
||||
a 30 s meter). In the rig's shape at 5 s the bound comes 30 to 55 s after
|
||||
the hand-back.
|
||||
|
||||
**Sweep** (`goodwe-addon-checks:1`, 2 h runs, scratch/SAFETY-121/dev/).
|
||||
Quarter edge = test_safety119 part 1 (house 800 W, cap 2500 W, no
|
||||
forecast). Overload = part 4 (2 x 2500 W, cap 5000 W, house 5050 or
|
||||
5100 W, seeds 0-3). "Delay 1" is the old sim, "delay 0" the rig's shape.
|
||||
|
||||
| meter | quarter edge, runs with a warning (new / bee7431 / no fix) | overload delay 1, quarters warned of 7 (new / bee7431 / no fix) | overload delay 0, 5100 W (new / bee7431 / no fix) |
|
||||
|---|---|---|---|
|
||||
| 1 s | 0/70 / 0/70 / 68/70 | 7 / 7 / 7 | 7 / 7 / 7 |
|
||||
| 2 s | 0/70 / 0/70 / 39/70 | 7 / 7 / 7 | 7 / 7 / 7 |
|
||||
| 3 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 7 / 7 (5050 W: 7 / 1-7 / 7) |
|
||||
| 5 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0-2 / 7 |
|
||||
| 10 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0 / 7 |
|
||||
| 15 s | 0/70 / 0/70 / 40/70 | 7 / 4-7 / 7 | no fix warns 0-1 (5300 W: 7 / 0 / 7) |
|
||||
| 20 s | 0/60 / 0/60 / 30/60 | 7 / 5-7 / 7 | no fix warns 0 (5300 W: 7 / 0 / 7) |
|
||||
| 30 s | 0/60 / 0/60 / 30/60 | 7 / 3-5 / 7 | no fix warns 0 (5300 W: 6-7 / 0 / 6-7) |
|
||||
|
||||
Quarter-edge seeds: 1-30 with the meter in the same cycle, and 1-40 (1-30
|
||||
at 20 and 30 s) with the test's 1-2 cycle delay. 5300 W: seeds 1-5.
|
||||
Commands identical to no fix and to bee7431 in all 1013 runs (these and
|
||||
the limits below). Choosing the numbers: the runs above were recorded
|
||||
without the fix and replayed through candidate rules; with 3, 4 or 5
|
||||
cycles, budgets 9 to 13 met both cases at 1-30 s. 8 warns at the quarter
|
||||
edge (2 s, seed 25) and 14 misses quarters in the rig's shape (1 s,
|
||||
5050 W), both checked in part 4. 11 is the middle, and 4 the middle of 3
|
||||
to 5; with a jittery meter (below) 3 gave more false alarms, 5 more quiet
|
||||
quarters (replay).
|
||||
|
||||
**First warning after one hand-back** (the loop at its rail; later than
|
||||
no fix by): 11 s at 1 s, 22 s at 2 s, 24 s at 3 s, 25 s at 5 s (default),
|
||||
30 s at 10 s, 45 s at 15 s, 60 s at 20 s, 90 s at 30 s, 180 s at 60 s.
|
||||
bee7431: 10, 20, 30, 40, 80, 90, 80, 120, 240 s. In the overload runs at
|
||||
1 to 30 s, the first warning was at most 90 s later than without the fix.
|
||||
|
||||
**Limits (measured, not fixed).**
|
||||
- A meter that jitters by 30 % with `cycle_floor_s` 15 (11 seeds): with
|
||||
the test's 1-2 cycle meter delay the quarter edge warns on 2 of 11 seeds
|
||||
at 15 s, 2 at 20 s and 5 at 30 s (bee7431: 4, 0, 10; no fix: 11 each);
|
||||
with the meter in the same cycle on none (no fix: 10, 9, 11). The overload at 5100 W leaves up
|
||||
to 4 quiet quarters in a row at 15 and 20 s (bee7431: no warning at all
|
||||
on some seeds); at 5200 W at most 1. Its first warning came up to 61 min
|
||||
later than without the fix. The numbers depend on the jitter draw: the
|
||||
review's draw gave 4, 4 and 5 of 11 at the quarter edge (bee7431: 4, 1,
|
||||
9) and up to 2 and 4 quiet quarters at 15 and 20 s.
|
||||
- With cycles over about 40 s, a bound that is never past a settle uses up
|
||||
11 in 900 s only if the wire changes hands every few cycles; otherwise
|
||||
it is never counted (part 5 prints it at 60 s).
|
||||
- Hidden as designed: a real bound with the kick's shape (at most 11 cycles
|
||||
in 900 s, each within 3 cycles or 22 s of a hand-back).
|
||||
|
||||
**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`,
|
||||
`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ from
|
||||
bee7431 and from no fix; commands, claims, guard and phases are the same.
|
||||
@@ -1,35 +0,0 @@
|
||||
# SAFETY-123 - the day plan's hour loop steps in UTC
|
||||
|
||||
**Status: for review.** Tests: `test_safety123.py`. No option, no text change.
|
||||
|
||||
`dayplan._hour_slices()` stepped to the next local hour by adding an hour to
|
||||
the local wall clock and converting back. Where the zone has no tzdata
|
||||
(`tariff._EUTimezone`: the CI image, a dev host, an image that loses tzdata)
|
||||
the missing 02:00 of the spring change reads as summer time, which is
|
||||
00:00 UTC again. At 28 March 2027 00:00 UTC `t` did not move and the list
|
||||
grew until the process died. Control and the web page share one event loop,
|
||||
so the whole add-on froze. `dayplan.plan()` calls it on every plan, before it
|
||||
looks at a battery's mode, so a site with all batteries on Solar first reaches
|
||||
it as well, if the prices give a plan.
|
||||
|
||||
The shipped image has tzdata (`amd64-base:3.20`), where this zone gives the
|
||||
right answer, so a running add-on is not known to be hit. The fix is for any
|
||||
host without it. (AUDIT-01 F4, card SAFETY-120, is the same defect.)
|
||||
|
||||
**Decision.** Each step adds the time left in the local hour to the UTC
|
||||
instant. It is at least a microsecond, so `t` always advances and the loop
|
||||
ends, in any zone, with or without tzdata. A day of 23 hours gives 23 slices
|
||||
and a day of 25 hours gives 25, each at most an hour, none across a local
|
||||
hour. On ordinary days the slices are the old ones.
|
||||
|
||||
**One change on the autumn day.** The old function (with tzdata) made one
|
||||
two-hour slice for the doubled hour 02:00-03:00; it now makes two of an hour,
|
||||
both labelled 2. `plan()` sums per slice, so only a forecast with sun in that
|
||||
hour could tell the difference. There is none.
|
||||
|
||||
**Other loops.** The other code that walks local hours or days steps in UTC
|
||||
or on the calendar date (`static_tariff`, `price_forecast`, `price_history`,
|
||||
`price`, `maintenance`, `homeview.local_day`, `solarfc._bounds`), or has a
|
||||
fixed bound. None can stall. One known inexactness stays, outside this fix:
|
||||
`solarfc` spreads a state-only forecast over hours since local midnight, so on
|
||||
the two change days its sun curve is an hour off the clock.
|
||||
@@ -1,10 +0,0 @@
|
||||
# SAFETY-125 - a grid charge under the month peak
|
||||
|
||||
**Status: FIXED in 0.19.2 (hotfix from 0.19.1).** Tests: `test_safety125.py`.
|
||||
|
||||
A grid charge is sized by `peakguard.charge_room_w()`, the guard's headroom
|
||||
with the seconds since a late meter reading counted at that reading (the
|
||||
projection leaves them out, SAFETY-99, the bill does not), and the guard's own
|
||||
floor is unchanged. At N=1 the maintenance charge is now sized too (owner
|
||||
2026-10-29; SAFETY-84 had left it out). Evidence (repro on 0.19.1, sweep,
|
||||
digests): the SAFETY-125 pull request.
|
||||
@@ -1,47 +0,0 @@
|
||||
# SAFETY-126 - a landing error on the aim is not an overrun
|
||||
|
||||
**Status: FIXED on release/1.0 (after 0.19.2).** Tests: `test_safety126.py`.
|
||||
|
||||
**Cause.** A grid charge under the peak limit is sized to land the 15-minute
|
||||
period on the aim (SAFETY-125), so the period ends 0-7 W over it. In the last
|
||||
second `peakguard.excess_w()` multiplies that by 900 / remaining (3 000 at
|
||||
0.3 s left, 90 000 at 0.01 s), so `decide()` wants tens of kW
|
||||
(`wanted > 0`, peakguard.py `decide()`). With every battery under the peak
|
||||
reserve that is floor-limited at 0 W, and `check_peak_floor_limited()`
|
||||
alarmed on it at once: SAFETY-87 graded only the case where another battery
|
||||
could give. With one battery able, the guard asked it for its rating for that
|
||||
cycle and the Batteries "Why" said "The peak limit needs power". The
|
||||
add-on's own charge is not the cause: `wanted` already counts it as
|
||||
stoppable.
|
||||
|
||||
**Rule.** Each cycle the guard wants power, `Controller._peak_landing()`
|
||||
asks `peakguard.misses_aim()`: with the batteries at 0 W from now on, does
|
||||
the period end more than `ALARM_MISS_W` (10 W) over the aim? This is
|
||||
`load_too_high()` with nothing to give, without `decide()`'s gain. If not, and
|
||||
no real miss was seen since the guard took hold, the decision is
|
||||
`within_aim`. Such a decision raises no reserve alarm (ERROR,
|
||||
notification, red row), no red "Load too high" row, no "Why" peak and
|
||||
no "Battery stopped limiting the peak" event hint. The claim, the floor and the
|
||||
commands are unchanged.
|
||||
|
||||
**Still alarms.** House load the batteries cannot cover under the reserve:
|
||||
the period would end over the aim with the batteries at 0 W. A step load
|
||||
alarms on the same cycle as before. SAFETY-87's case and SAFETY-81's 'Load too
|
||||
high' notification are unchanged.
|
||||
|
||||
**What the 10 W tolerance costs** (review, owner accepted 2026-10-31):
|
||||
- A steadily rising load alarms a little later, once the projected miss passes
|
||||
10 W (3 000 -> 4 000 W over 45 min: 56 s later). With the default 50 W
|
||||
margin the alarm still comes before the limit.
|
||||
- With `peak_margin_w` 0, a period can end up to 10 W over the limit without
|
||||
this alarm, the tolerance 'Load too high' already accepts (SAFETY-81).
|
||||
|
||||
`misses_aim()` takes the house as meter plus batteries, so an export nets
|
||||
off (`decide()`'s projection counts offtake only). A re-alarm while the loop's
|
||||
draw on held batteries covers the house is gone too (test_safety126.py part 2,
|
||||
modes off: 2 notifications, was 4).
|
||||
|
||||
**Not changed.** The one-cycle floor itself: at a period's last instant the
|
||||
guard still asks an able battery for its rating, or stops a charge, for a
|
||||
landing error. That is control, outside the alarm path. Raise a card if it
|
||||
should go.
|
||||
@@ -1,92 +0,0 @@
|
||||
# SAFETY-128 - a reading that does not answer the last command is not current
|
||||
|
||||
**Status: FIXED on release/1.0 (after 0.20.0).** Code: `app/lateness.py`,
|
||||
`control.compute(hold=, hold_w=)`, `peakguard.cap_floor()`,
|
||||
`Controller._readings_late()`, `Controller.read_inputs()` (feeds the fits).
|
||||
Tests: `test_safety128.py` (`tools/safety128_rig.py`, TEST-23's plant).
|
||||
|
||||
**Cause** (SAFETY-127 report). Home Assistant can deliver readings late but
|
||||
still changing. Every gate judges arrival (SAFETY-01) or a value that stops
|
||||
changing (SAFETY-18, SAFETY-37), so a late reading passes them all. The loop
|
||||
then corrects, each cycle, an error its last command has already fixed: a
|
||||
loop with a dead time far longer than its cycle, which swings and grows. The
|
||||
peak guard projects the quarter from the same late figures and pushes to the
|
||||
inverter limit in its last seconds (OPT-34 rig F-2).
|
||||
|
||||
**Rule.**
|
||||
1. *Find late readings* (`Answer.watch()`, every 2 s, from the 1 s reads).
|
||||
- **Batteries** (`Delay`): fit one `d` so that each battery's reading minus
|
||||
its own request at `t - d` (averaged over 2 s for the battery's lag and
|
||||
the read) is flattest. The house does not move that reading, so a
|
||||
repeating load cannot line up with it (review round 1, B1). Needs 5 new
|
||||
readings in 60 s and the request and the reading each to move 300 W.
|
||||
Late from 9.5 s, after 2 late fits in a row. A late fit must explain 75 %
|
||||
and beat every fit up to 6.5 s by 2x; the shortest delay that fits about
|
||||
as well (within 2x, or within 2 500 W^2) is taken instead, since a swing's
|
||||
period fits again. A slow battery (3-10 s lag) answering at once gives no
|
||||
late fit; at a 15-20 s lag some fits read late (see "What it costs").
|
||||
No late battery fit counts for 60 s
|
||||
after a battery gave nothing under its ask: an emptied battery lined its
|
||||
old movement up as 85 s late.
|
||||
- **Meter** (`MeterFit`, round 1's fit): `meter(t) + request(t - d)` is the
|
||||
house alone at the right `d`. Quick, but confounded by the house. Needs
|
||||
16 new values in 60 s, so only a meter of about 3.5 s or faster: at 5 s
|
||||
it gave false delays. Late over 8 s. At a slower meter (a 5 s P1) the
|
||||
same fit runs over 120 s and calls only a delay over 16 s late, on 2 fits
|
||||
in a row, and never ends a verdict (SAFETY-129). Alone it reads 35-90 s on healthy
|
||||
runs, a few times in a row; only the slow-start and battery-fit check
|
||||
below keeps those from a verdict. 10 s late is not found: it cannot be
|
||||
told from a 10 s slow battery, and it does not swing at 5 s.
|
||||
- A meter fit says late only with evidence from the batteries' side too:
|
||||
a request more than 100 W from the battery reading took 3 s or more to
|
||||
move a reading towards it (`SLOW_START_S`; on time they start within
|
||||
0-1 s in the recorded house at 1 and 2 s ticks), and no battery fit in
|
||||
the last 60 s said on time (under 9.5 s). An ask during which a battery
|
||||
gave nothing under its ask is no slow evidence. A repeating load fools
|
||||
the meter fit but not these. No meter verdict in the first 150 s.
|
||||
- On time again on a battery fit under 6.5 s, or a meter fit within 8 s
|
||||
unless a battery fit said late (hysteresis). With no fit for 120 s the
|
||||
verdict is dropped.
|
||||
2. *Hold.* While late, a request more than 100 W from the battery reading is
|
||||
pending until that reading comes within 100 W of it, or half way. Meanwhile
|
||||
the loop may not lead the reading by more than 100 W that way, and the peak
|
||||
guard's floor is capped at the request that stands. The loop is not held
|
||||
while a battery gives nothing under its ask or refuses (SAFETY-52/94).
|
||||
3. *Late verdict.* Pending for `LATE_S` (23 s) with no reading moving 100 W
|
||||
towards it: control holds, then after `stale_input_s` goes to 0 W
|
||||
("readings late"), as for a missing input.
|
||||
4. *Seen.* While late an amber row says "Home Assistant readings show requests
|
||||
about N seconds late, so control holds the batteries." (NL too). N is the
|
||||
batteries' fitted delay: Home Assistant's lag plus the battery's own
|
||||
answer. While only the meter has shown it the row gives no figure. One
|
||||
WARNING (`READINGS_LATE`) per episode and one line when it ends.
|
||||
|
||||
**With readings on time** no verdict was reached in any reviewer scenario
|
||||
(owner-shaped house, ovens 20/40 to 60/60 s, clouds, a 300 W/s battery, 6-8 s
|
||||
dead times, HA jitter 0-4 s, an EV following the meter, capped batteries, HA
|
||||
restarts, meter pauses; 1, 2 and 5 s meters) nor in 2 days of the recorded
|
||||
house at 1, 2 and 5 s ticks, so the commands are the same as without the gate.
|
||||
The fits run all the time; the pull request has the counts.
|
||||
|
||||
**What it costs.**
|
||||
- Found 36-68 s after a lag starts at 1-2 s meters; at a 5 s meter 30 s late
|
||||
is found within 100 s after it starts (88 s measured) (the loop first moves 100 W after 60 s,
|
||||
SAFETY-129) with the export above -200 W, and 10 s late is not found. Until
|
||||
then control is as before. In a quiet house the "readings late" row stays
|
||||
about 2 minutes longer after the lag ends (126 s against 32 s on base); no
|
||||
harm measured, and it clears as early as on base when the load changes.
|
||||
- A battery that answers slowly is read as late readings and held: a dead
|
||||
time of 10 s or more (8 s reads 7-8; 10 s reads 10 at 1-2 s meters, 9 at
|
||||
5 s), or a 15-20 s lag (review round 2). The GoodWe measured settles in 6 s
|
||||
(`freshness.PLANT_SETTLE_S`); the row then blames Home Assistant wrongly.
|
||||
- The row can stay up to 180 s after the readings are on time on a steady
|
||||
site. While only the meter has shown the delay it gives no figure.
|
||||
- A battery that cannot follow while the readings are late (at a limit, not
|
||||
reading 0 W) is held, then set to 0 W after 38 s.
|
||||
- The fits cost a few ms every 2 s (x86, measured after 3 idle days); the
|
||||
history they read is bounded (60 s of readings, 150 s of requests).
|
||||
|
||||
**Left out.** The setpoint -> Home Assistant round trip: the fitted delay gives
|
||||
the figure from readings the add-on already has. The hold's step-and-pull-back
|
||||
at extreme lag and the arbitrage charge's sizing on late readings
|
||||
(`charge_room_w()`) are follow-up cards.
|
||||
@@ -1,21 +0,0 @@
|
||||
# SAFETY-137 - the shutdown 0 W is the last write every unit hears
|
||||
|
||||
SMA-10 made a hand-back release wait for a write already on its way
|
||||
(`_release_last`, `Inverter.inflight`). A GoodWe unit skipped that, and rig 5b
|
||||
saw a cycle write (-2 823 W) land after the shutdown 0 W and stay until relaunch.
|
||||
|
||||
Now the 0 W of shutdown and of the Stop button is a *last* write
|
||||
(`_write_unit(last=True)`), for every driver:
|
||||
|
||||
- It first waits, at most `SETTLE_BUDGET_S` (2 s), for the writes already on
|
||||
its way to that unit (`_await_inflight`, shared with `_release_last`). A hung
|
||||
write is not waited out and not cancelled; the 0 W goes anyway.
|
||||
- Once shutdown has begun (`stopping`), every other write (cycle, heartbeat,
|
||||
safety stop) is dropped at the same place, right before the write is created.
|
||||
That is the gate that keeps the control cycle quiet; the cycle itself needs
|
||||
no cancelling.
|
||||
- Worst case for a GoodWe unit is 2 s + `WRITE_BUDGET_S` (4 s) = 6 s, the same
|
||||
as `HAND_BACK_WORST_S`; the units are handled together.
|
||||
|
||||
Not touched: the heartbeat's own timing, safety-stop paths, the maintenance
|
||||
abort (still a plain 0 W).
|
||||
@@ -149,70 +149,8 @@ each swept across the **whole range `config.yaml` accepts** for the two settings
|
||||
that decide how fast the site can turn around: `gain` `float(0.05,1.0)` and
|
||||
`slew_w` `int(50,5000)`.
|
||||
|
||||
### ⚠️ THE HARNESS BEHIND EVERY FIGURE ABOVE IS NOT COMMITTED, AND UNTIL 2026-09-07 THAT MADE THEM UNCHECKABLE
|
||||
|
||||
**Read this before quoting any number in this section.** The paragraph below
|
||||
said the campaign harness "is not committed here" and treated that as a
|
||||
packaging detail. It is not: a figure produced by a script that exists in
|
||||
nobody's tree cannot be re-run, cannot be reviewed, and cannot be defended when
|
||||
it is contradicted. It has now been contradicted twice.
|
||||
|
||||
| sweep | worst peak | worst continuous | trips |
|
||||
|---|---:|---:|---:|
|
||||
| the 924-run campaign below — **harness uncommitted** | 1700 W | 505.0 s at 100 W; 5.0 s above 700 W | 0 / 924 |
|
||||
| independent reviewer sweep, 2026-09-07, 48 honest reversals | **2750 W** | **15.0 s above 700 W** | 0 / 48 |
|
||||
| `admin/goodwe` `two_master_circulation`, 840 runs — **committed** | 1048 W | 10.0 s at 100 W; 5.0 s above 700 W | **0 / 840** |
|
||||
|
||||
**This is not proof that anything was fabricated, and it should not be reported
|
||||
as one.** The three swept different grids. What the third one adds is a
|
||||
measurement of *why* the spread is that wide — on one grid, changing nothing but
|
||||
the model:
|
||||
|
||||
| plant / stagger | worst legitimate peak |
|
||||
|---|---:|
|
||||
| modelled plant (lag 10 s, ramp 800 W/s), staggers up to a whole cycle | 1048 W |
|
||||
| `INSTANT` (lag 0, unbounded ramp), staggers up to a whole cycle | 2250 W |
|
||||
| `INSTANT`, staggers strictly under a whole cycle | **0 W** |
|
||||
|
||||
A factor of 2.1×, and a factor of infinity, between three defensible
|
||||
descriptions of the same rig. **The plant model and the write-stagger set are
|
||||
terms of the number**, and a worst case quoted without them is not a
|
||||
measurement. That is enough to explain the whole spread above without anybody
|
||||
having invented anything — and it is exactly what cannot be established when the
|
||||
instrument is missing.
|
||||
|
||||
**What survives, and what does not.**
|
||||
|
||||
* **The window argument survives, and is now committed.** The claim that
|
||||
decides `circulation_hold_s: 75` is *"the worst continuous legitimate episode
|
||||
above 700 W is about one control cycle"*. All three sweeps agree on the shape
|
||||
of that (5.0 s, 15.0 s, 5.0 s — one to three cycles against a fifteen-cycle
|
||||
window), and **all three trip zero times**. `two_master_circulation` re-runs
|
||||
it on every pull request in `admin/goodwe`.
|
||||
* **The threshold curve does NOT survive as stated.** `505.0 s at 100 W` is the
|
||||
number the "knee at 400 W" argument for `circulation_w: 700` rests on, and the
|
||||
committed sweep measures **10.0 s** at 100 W on its grid — 50× smaller. The
|
||||
curve still *falls* with the threshold (126 of 840 runs cross 100 W against 1
|
||||
that crosses 700 W), so the knee is real in direction; its depth is not
|
||||
established by anything anybody can run. **Do not quote 505.0 s.**
|
||||
* **Nothing about the guard is compromised.** 0 trips in 924 + 48 + 840
|
||||
non-faulty runs, from three independent grids.
|
||||
|
||||
**What would settle it:** the original harness, committed. Until then the
|
||||
committed sweep is the only figure in this section anybody can check, and it is
|
||||
labelled as its own grid's rather than as a reproduction.
|
||||
|
||||
### Reproducing it
|
||||
|
||||
⚠️ **THE COMMITTED VERSION IS `admin/goodwe`'s `sim/inject.py
|
||||
two_master_circulation`** (`sim/sitesim.py:circulation_findings`, runnable on its
|
||||
own as `python3 sim/sitesim.py --circulation`, `norig`, ~36 s, graded by
|
||||
`checks.yml` on every pull request there). It produces the fault with
|
||||
`plantsim.Inverter.wedge()` — a unit answering its bus and no longer steering its
|
||||
power stage, which is HW-01 — and sweeps the legitimate family with the
|
||||
serialised write path staggered up to a whole control cycle. The paragraph below
|
||||
describes the ORIGINAL campaign, which is the one that is not committed.
|
||||
|
||||
The rig lives in `admin/goodwe` (`sim/`) and was used **read-only**; the harness
|
||||
is not committed here because it imports that repo's `plantsim`/`frame_check`,
|
||||
which are not present in this one or in CI. What it does is small enough to
|
||||
@@ -268,11 +206,7 @@ margin above the knee is what absorbs that without re-opening this card.
|
||||
**Revisit when SPIKE-04 lands, not before.**
|
||||
|
||||
The **schema floor is the knee itself**, 400 W, so a legally configured install
|
||||
cannot land in the 445–505 s regime — ⚠️ and those two figures are from the
|
||||
uncommitted campaign; the committed sweep measures 10.0 s at 100 W on its own
|
||||
grid. See the disclosure at the head of this section. The floor is still the
|
||||
right shape (fewer runs cross a higher threshold, measured 126/840 at 100 W
|
||||
against 1/840 at 700 W); its DEPTH is not established by anything re-runnable. What the threshold costs: a circulating
|
||||
cannot land in the 445–505 s regime. What the threshold costs: a circulating
|
||||
loop smaller than 350 W per unit is not reported, which is deliberate — at that
|
||||
scale the round-trip loss is a few watts.
|
||||
|
||||
@@ -355,32 +289,6 @@ SAFETY-25 — so no stale `.pyc` could exist to be read. Byte deltas recorded;
|
||||
| M17 | `circulation.py` | default `700` → `500` | **+0 B** | **RED** — the CFG-03 pin and the not-a-round-number check |
|
||||
| M18 | `main.py` | `await self.check_circulating()` removed | −39 B | **RED** — all four notification checks |
|
||||
| M19 | `main.py` | publish `round(x or 0.0)` instead of `None` | −68 B | **survivor on the first battery — GAP CLOSED**, then RED |
|
||||
| **M20** | `main.py` | **`pos, neg = opposing(...)` → `([], [])`** — the attribution clause deleted from the alarm | −102 B | **SURVIVED ALL 26 FILES on the shipped battery — GAP CLOSED**, then RED on §6a |
|
||||
| M21 | `main.py` | `pos` and `neg` exchanged — the alarm names both packs, both the wrong way round | **+0 B** | **RED** — §6a ×3 (the mirrored run, and "only its own sentence") |
|
||||
| M22 | `main.py` | `opposing(..., min_w=cfg.min_w / 2.0)` → `min_w=0.0` | −25 B | **RED** — §6a's resting-pack check |
|
||||
|
||||
**And four in `admin/goodwe`**, graded through `python3 sim/sitesim.py
|
||||
--circulation` rather than through the injector — deliberately, because
|
||||
`inject.py` is the file that takes the rig lock, and "run the injector to grade a
|
||||
mutation" is not a sentence anybody should have to type. Byte deltas over
|
||||
`plantsim.py` + `sitesim.py` together.
|
||||
|
||||
| # | file | mutation | Δ | result |
|
||||
|---|---|---|---|---|
|
||||
| S1 | `plantsim.py` | `wedge()` no longer stops the latch reaching the power stage | −20 B | **RED** — the opposing-signs precondition **and** "the watchdog did not trip" |
|
||||
| S2 | `sitesim.py` | the sensor computed over the **allocation record** instead of register 0x0518 | **+0 B** | **RED** ×3 — this is M10 restated on the rig |
|
||||
| S3 | `sitesim.py` | the campaign run on `plant=INSTANT` | −51 B | **RED** — the plant is a *term* of the figures; it was a SURVIVOR until that check was added, and finding out why produced §3's 1048/2250/0 W table |
|
||||
| S4 | `sitesim.py` | the write stagger removed from the legitimate campaign | −19 B | **RED** — "the legitimate campaign produced no opposition at all" |
|
||||
|
||||
**S3 is recorded as a survivor because it was one, and it is the most useful
|
||||
result in this table.** The scenario first shipped with a vacuity check whose
|
||||
text claimed `plant=INSTANT` would make the sweep measure 0 W. S3 was run and
|
||||
**passed**: `INSTANT` with a stagger of exactly one control cycle samples the
|
||||
plant before the second unit has stepped, so it produces opposition anyway — and
|
||||
a *larger* worst peak (2250 W) than the modelled plant does (1048 W). The claim
|
||||
in the check's own text was wrong. It was corrected against the measurement, the
|
||||
plant was pinned as an explicit term, and S3 is red now for a stated reason
|
||||
rather than by accident. §3's spread table is that measurement.
|
||||
|
||||
**M19 is recorded as a survivor because it was one.** The first battery found no
|
||||
check grading what `publish()` does when a per-unit reading is missing, so
|
||||
@@ -389,30 +297,6 @@ watches went unnoticed — the sensor's own `None` rule, defeated one layer out.
|
||||
A check was added and the mutation re-run; it is red now. The run that found it
|
||||
was purged, so it is a result and not an artefact.
|
||||
|
||||
**M20 IS RECORDED AS A SURVIVOR BECAUSE IT WAS ONE, AND IT IS THE MORE
|
||||
UNCOMFORTABLE OF THE TWO.** The commit message for this card claimed *"18
|
||||
mutations, all RED"*, and M1–M19 above have no entry for the notification's
|
||||
attribution clause. Verification on 2026-09-07 ran it and it **survived the
|
||||
whole 26-file suite**: the alarm still fires, still stops the site, still says
|
||||
the meter cannot see it — and no longer says *which two packs* or *which way
|
||||
round*. `circulation.py`'s own docstring calls that sentence the first thing
|
||||
somebody woken by this alarm needs and *"impossible to reconstruct from the
|
||||
number alone"*.
|
||||
|
||||
It survived for one reason, and it is not reachability: **M18 (the whole call
|
||||
removed) is red**, so `check_circulating()` is exercised. The three checks on
|
||||
that notification asserted `METER CANNOT SEE`, `CONTROL HAS BEEN STOPPED` and
|
||||
`isolator` — and **never an inverter name**. Nothing graded the attribution.
|
||||
|
||||
`test_watchdog.py` §6a closes it, and closes it on a fixture that can tell the
|
||||
three failures apart. It runs a roster of **`["garage", "shed", "cellar"]`** —
|
||||
operator names, none of them `UNIT_A`, for test_maintenance.py §11's reason:
|
||||
on a fixture whose names are the module's own constants a check cannot tell the
|
||||
roster from the constant. It runs the site **twice, mirrored**, so the sentence
|
||||
is not symmetric and a swap (M21, `+0 B`) is a different red from a deletion.
|
||||
And it carries a **third pack resting at 5 W**, so the floor that stops a pack
|
||||
in standby being named as a participant in a 6 kW loop (M22) is graded too.
|
||||
|
||||
## 7. What this card does NOT close
|
||||
|
||||
* **Inverter B's ramp and lag are placeholders.** Every transient number in §3
|
||||
@@ -421,9 +305,7 @@ in standby being named as a participant in a 6 kW loop (M22) is graded too.
|
||||
margin. **Do not present any figure here as a measurement of inverter B.**
|
||||
* **A wedged inverter still cannot be stopped by this add-on.** §1 says so; the
|
||||
notification says so; there is no software fix and this card does not pretend
|
||||
to one. It *can* now be produced on demand: `plantsim.Inverter.wedge()`.
|
||||
* **The depth of the threshold curve is not established by anything committed.**
|
||||
§3's disclosure. The window is; the knee's magnitude is not.
|
||||
to one.
|
||||
* **A loop below ~350 W per unit is not reported.** Deliberate, §3.
|
||||
* **The saturation freeze fires under a wedged unit and leaves a standing grid
|
||||
error.** Observed on the rig while producing §2's numbers: with B wedged, the
|
||||
|
||||
@@ -69,8 +69,6 @@ which does not run the startup gate) the absence costs a shave and never a pack.
|
||||
|
||||
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
|
||||
|
||||
*At N>=2 one pack at its floor no longer stops the others: [[SAFETY-87]].*
|
||||
|
||||
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
|
||||
**is unchanged**, exactly as the card instructed. What changed is the floor it is
|
||||
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
|
||||
|
||||
@@ -187,23 +187,6 @@ 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
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# SAFETY-40: the integrator bound is not a bug
|
||||
|
||||
**Decision.** `i_clamped` (the integrator reaching `integrator_max_w`, i.e.
|
||||
anti-windup) keeps alarming through the same `ClampWatchdog`: it is counted,
|
||||
notified on the same cadence, and escalates on a sustained run
|
||||
(SAFETY-41). It gets its own text, a `WARNING` and an amber status row. Only
|
||||
`clamped` (a command over `max_w`) and write-path clamps keep the BUG SIGNAL,
|
||||
the `ERROR` and the red row.
|
||||
|
||||
**Why it still notifies.** On a default install `clamped` is unreachable
|
||||
(`control.py`, at the integrator bound), so the integrator bound is the only
|
||||
loop-side signal of a runaway such as the 14 768 W one. Silencing it would
|
||||
remove that. The card says the same: "the fix is to say the true thing, not to
|
||||
say less."
|
||||
|
||||
**Why a bug has its own budget.** A genuine over-rating must not wait up to
|
||||
`REASSERT_S` behind anti-windup notices. `ClampWatchdog` tracks a separate
|
||||
`last_bug_alarm`, and a bug's text is never overwritten by later anti-windup
|
||||
text within an episode. Across episodes the WARNING uses its own
|
||||
notification id (`goodwe_integrator_bound`), so it cannot replace a BUG SIGNAL
|
||||
notice still on screen (review of #130). With only bug engagements, the cadence is the same as
|
||||
before (`test_clamp.py` part 9).
|
||||
|
||||
**Numbers.** They come from the `Decision` of the clamping tick (`i_bound_w`,
|
||||
`requested_w`, `clamped_w`, `target_w`), not from `i_w` or `self.target`.
|
||||
|
||||
**Open for the owner.** Whether scattered one-cycle anti-windup episodes should
|
||||
notify at all, or only log and count. They notify now, as before.
|
||||
@@ -68,102 +68,22 @@ deliberately *not* in the model). **Every two-inverter figure in this document
|
||||
is two copies of inverter A.** §3.3 is the sensitivity sweep that sizes the
|
||||
margin that costs.
|
||||
|
||||
### Reproducing it — ⚠️ THE HARNESS IS COMMITTED NOW (TEST-21, 2026-09-07)
|
||||
|
||||
⚠️ **THIS SECTION USED TO SAY THE HARNESS WAS NOT COMMITTED, AND THAT WAS THE
|
||||
DEFECT TEST-21 FOUND.** A shipped safety constant whose floor the next reviewer
|
||||
cannot re-run is PROC-01's shape, and PROC-01 had eleven instances in three
|
||||
days. The paragraph this replaces was true — what the harness does *is* small
|
||||
enough to restate — and it was not enough: a restatement in prose leaves every
|
||||
axis it does not name to the next person's judgement, and §3.3 below now records
|
||||
how far that moves the number. **It moved. See §3.3's second table.**
|
||||
|
||||
It is committed in the **other** repository, `admin/goodwe`, as
|
||||
`sim/delivery_window.py`, beside `sitesim.py` — the container-free two-master
|
||||
harness it is built on. From clean checkouts of both repos:
|
||||
|
||||
```
|
||||
cd goodwe/sim
|
||||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --selftest
|
||||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --campaign --blag
|
||||
```
|
||||
|
||||
stdlib only. No container, no HA, no esphome, **no rig and no rig lock** — it is
|
||||
a `plantsim` campaign, not a rig job. About fifteen minutes for the whole thing.
|
||||
|
||||
⚠️ **AND IT IS IN `admin/goodwe` RATHER THAN HERE, WHICH IS CI-08's RULE
|
||||
APPLIED RATHER THAN IGNORED.** It imports `plantsim` (the plant, the per-unit
|
||||
wire, `Site`) and `frame_check` (the firmware's own encoder), both **owned by
|
||||
that repo** and neither present in this one's CI image. Committed here it had
|
||||
two possible fates and both are the coupling CI-08 has been broken by three
|
||||
times: skipped for want of its imports — a green that establishes nothing — or
|
||||
the plant and the encoder **restated in this tree**, which is a second copy of
|
||||
something the other repo owns, free to drift, and drift in a fixture is
|
||||
invisible until the number it defends is wrong. There the dependency runs one
|
||||
way and stays one-directional: **this** repo's `control`, `arbiter` and
|
||||
`delivery` are imported live out of whichever checkout is under test
|
||||
(`$GOODWE_ADDON_REPO`), never copied — so a change to `delinquent()` moves the
|
||||
number on the next run instead of silently disagreeing with a fixture. That is
|
||||
the seam `sitesim.py` already stands on, which is the other reason the campaign
|
||||
is its neighbour and not a third arrangement.
|
||||
|
||||
⚠️ **THE SELFTEST IS CI-GATED; THE CAMPAIGN IS NOT, AND THE SPLIT IS
|
||||
DELIBERATE.** `admin/goodwe`'s `.gitea/workflows/checks.yml` gained one step —
|
||||
`python3 -B delivery_window.py --selftest`, seconds, beside the existing
|
||||
`norig, no lock` TEST-19 and TEST-20 steps, in the workflow that **already**
|
||||
checks out this repo for `two_writers` and accepts that boundary in writing. So
|
||||
a change **here** that moved `saturation_w`, `max_w`, `delivery_hold_s` or the
|
||||
shape of `delinquent()` turns that step red rather than leaving a stale number
|
||||
in this record. The **campaign** (`--campaign --blag`, ~15 minutes) is not
|
||||
gated: it is a campaign, not a gate, and `failure-injection.yml`'s jobs run on
|
||||
the **rig** runner, which a container-free simulation has no business holding.
|
||||
What TEST-21 asked for is a command a reviewer can run from a clean checkout —
|
||||
that is the block above.
|
||||
|
||||
⚠️ **`--selftest` IS A FALSIFIABILITY ARM, NOT A SMOKE TEST, AND IT RUNS
|
||||
FIRST.** A campaign whose N=1 column and whose flapping column are 0.0 s
|
||||
everywhere is indistinguishable from a campaign whose detector was never wired
|
||||
up — `sitesim.broadcast_onset` exists for the same reason. So the selftest
|
||||
drives a genuinely **wedged** unit through the same loop and refuses to proceed
|
||||
unless the shipped `delinquent()` names it for longer than the shipped hold
|
||||
(it names it for 275.0 s). Every 0.0 s in §3.3 is only worth reading because
|
||||
that arm passes.
|
||||
|
||||
What it does, unchanged from the restatement it replaces: build two
|
||||
`plantsim.Inverter`s from `INVERTER_A` / `INVERTER_B`; close the loop with the
|
||||
add-on's own `compute()` on the SITE sum (`Controller.site_batt_w`) and
|
||||
`arbiter.allocate_record()` over per-unit envelopes of `±min(max_w, rating)`;
|
||||
put each share on its own wire through `Inverter.command()`, honouring a
|
||||
configurable **write stagger** so B's number can land up to four cycles after
|
||||
A's (the serialised write path); advance `Site.settle()` one control cycle; and
|
||||
record, per unit, the command standing *before* the write and that unit's 0x0518
|
||||
power. Then grade each cycle with the **shipped** `delivery.delinquent()` and
|
||||
take the **longest run of consecutive cycles** on which it names anybody — not a
|
||||
count of naming samples, because the window is cleared by any cycle that does
|
||||
not.
|
||||
|
||||
⚠️ **SINCE [[SAFETY-43]] THE ROWS OF §3.3's TABLE ARE GRADED WITH
|
||||
`share_frac=None`** — `delinquent()`'s ABSOLUTE arm alone, which is this card's
|
||||
rule exactly. Said here because it is the difference between §3.3 still meaning
|
||||
what it is labelled as and quietly becoming a table about a different predicate:
|
||||
SAFETY-43 added a SECOND way to be named, the harness grades it in its own rows
|
||||
(`--fractions`), and the numbers below are unchanged and were re-measured to
|
||||
confirm it. `DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` §3 carries
|
||||
those rows and what they cost — the worst legitimate episode goes from 35.0 s to
|
||||
40.0 s at the shipped fraction, which does not move `delivery_hold_s`.
|
||||
|
||||
⚠️ **ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING.** "The
|
||||
command standing before the write" is the **controller's** standing command —
|
||||
`Inverter.standing_w()`, i.e. `last_write` — and **not** the far end of the
|
||||
wire. A write that has been issued but has not landed yet is standing as far as
|
||||
this add-on is concerned, which is the whole of the write-stagger axis. Grading
|
||||
against the plant's latch instead reads a number no add-on has, and it makes
|
||||
both the stagger *and a wedged unit* invisible: a unit whose writes never arrive
|
||||
shows a standing command of 0 W, is never `asked`, and is never named. That is
|
||||
not a conservative harness, it is a harness that cannot fail — and the rebuild
|
||||
did exactly this on its first run, which is how the falsifiability arm earned
|
||||
its place above.
|
||||
### Reproducing it
|
||||
|
||||
The harness is **not committed here**, for the reason SAFETY-30's and
|
||||
SAFETY-31's were not: it imports `plantsim` and `frame_check`, which live in the
|
||||
other repository and are not present in this one's CI image. What it does is
|
||||
small enough to restate: build two `plantsim.Inverter`s from `INVERTER_A` /
|
||||
`INVERTER_B`; close the loop with the add-on's own `compute()` on the SITE sum
|
||||
(`Controller.site_batt_w`) and `arbiter.allocate_record()` over per-unit
|
||||
envelopes of `±min(max_w, rating)`; put each share on its own wire through
|
||||
`Inverter.command()`, honouring a configurable **write stagger** so B's number
|
||||
can land up to four cycles after A's (the serialised write path); advance
|
||||
`Site.settle()` one control cycle; and record, per unit, the command standing
|
||||
*before* the write and that unit's 0x0518 power. Then grade each cycle with the
|
||||
**shipped** `delivery.delinquent()` and take the **longest run of consecutive
|
||||
cycles** on which it names anybody — not a count of naming samples, because the
|
||||
window is cleared by any cycle that does not.
|
||||
⚠️ MUTATION.md's note applies: this harness writes `__pycache__` into **both**
|
||||
trees. Run it with `python3 -B` and purge.
|
||||
|
||||
@@ -293,9 +213,7 @@ Every run is executed a second time at a **roster of one** on the same stimulus.
|
||||
check"* from *"a property of the second inverter"*.
|
||||
|
||||
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
|
||||
threshold. **ORIGINAL CAMPAIGN, 2026-09-06, from the harness that was not
|
||||
committed — SUPERSEDED, and kept here because a record that quietly replaced its
|
||||
own numbers would be worth less than one that shows both:**
|
||||
threshold:
|
||||
|
||||
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
@@ -307,100 +225,7 @@ own numbers would be worth less than one that shows both:**
|
||||
| 1500 W | 10.0 s (51) | 0.0 s (0) | 10.0 s (50) | **0.0 s (0)** |
|
||||
| 2000 W | 10.0 s (24) | 0.0 s (0) | 5.0 s (23) | **0.0 s (0)** |
|
||||
|
||||
#### ⚠️ TEST-21, 2026-09-07 — THE RE-RUN, AND 30.0 s DID NOT REPRODUCE
|
||||
|
||||
`admin/goodwe:sim/delivery_window.py --campaign`, same three families, same
|
||||
counts, same thresholds, the shipped `delinquent()`:
|
||||
|
||||
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640 runs)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 100 W | 120.0 s (770) | 0.0 s (0) | 165.0 s (685) | **0.0 s (0)** |
|
||||
| 250 W | 65.0 s (687) | 0.0 s (0) | 100.0 s (590) | **0.0 s (0)** |
|
||||
| **500 W** | **30.0 s (460)** | **0.0 s (0)** | **35.0 s (403)** | **0.0 s (0)** |
|
||||
| 700 W | 25.0 s (342) | 0.0 s (0) | 30.0 s (318) | **0.0 s (0)** |
|
||||
| 1000 W | 15.0 s (4) | 0.0 s (0) | 25.0 s (131) | **0.0 s (0)** |
|
||||
| 1500 W | 0.0 s (0) | 0.0 s (0) | 20.0 s (43) | **0.0 s (0)** |
|
||||
| 2000 W | 0.0 s (0) | 0.0 s (0) | 0.0 s (0) | **0.0 s (0)** |
|
||||
|
||||
**The floor is 35.0 s, not 30.0 s** — weighted family, and the worst single run
|
||||
is `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W reversal. The
|
||||
argmax is printed with the table on purpose (see below): a worst case sitting on
|
||||
an edge of the grid would be a number about the grid.
|
||||
|
||||
⚠️ **AND BY THAT STANDARD, TWO OF ITS FOUR AXES ARE ON AN EDGE. Measured, at the
|
||||
argmax's other axes held fixed:**
|
||||
|
||||
stagger 0 -> 1 -> 2 -> 3 -> 4 0.0 15.0 25.0 30.0 35.0 s
|
||||
weights (A) at stagger 4 0.5 -> 20.0 0.7/0.9/0.99 -> 0.0
|
||||
0.10 -> 35.0 s
|
||||
|
||||
Strictly increasing in stagger and maximal at 4, the largest modelled stagger;
|
||||
and maximal at the most asymmetric split. It is interior only in `gain` (2nd of
|
||||
6) and `slew_w` (3rd of 5).
|
||||
|
||||
⚠️ **SO THE HEADLINE IS 0.0 s AT stagger=0, AND 35.0 s IS ENTIRELY A PRODUCT OF
|
||||
THE MODELLED SERIALISED-WRITE STAGGER.** That stagger is declared MODELLED at
|
||||
`sim/delivery_window.py:60-64` — it is a term of this figure, not a detail of
|
||||
it. A site whose two writes land in the same cycle does not produce this episode
|
||||
at all.
|
||||
|
||||
This does not move `delivery_hold_s`: 185 s must cover the worst case the
|
||||
modelled site can produce, and a stagger of four cycles is inside what a real
|
||||
two-inverter write path can do. But a reader entitled to ask "is 35.0 s about
|
||||
the plant or about the grid?" deserves the answer, and the answer is: **partly
|
||||
about the grid, and the grid's shape is the record's own, fixed by §3.3 before
|
||||
this rebuild ran.** Found in review of TEST-21; the PR body had claimed the
|
||||
argmax was interior, and it is not.
|
||||
|
||||
**WHAT REPRODUCED, EXACTLY:**
|
||||
|
||||
* the **N=1 control column is 0.0 s at every threshold**, in every run. That is
|
||||
§3.2's algebra measured, and it is the claim the compatibility promise rests
|
||||
on. (This rebuild ran the control arm over all three families — 2640 runs —
|
||||
where the original ran 1890, i.e. the two families whose stimulus is not
|
||||
weighted. The weighted stimulus at a roster of one is the same stimulus with
|
||||
inert weights, and it too reports nothing at any threshold.)
|
||||
* the **flapping family is 0.0 s at every threshold**, in all 840 runs. The
|
||||
proper-subset rule really does defeat the family that defeated SAFETY-30's
|
||||
site window.
|
||||
* **45.0 s at twice A's lag**, to the cycle. See the B-lag table below.
|
||||
|
||||
**WHAT DID NOT:**
|
||||
|
||||
* the headline. **35.0 s against a recorded 30.0 s** — one control cycle worse,
|
||||
and 20.0 s → 30.0 s on the reversal family, two cycles worse.
|
||||
* **the non-monotonicity.** §3.3 finding 3 read the original 55.0 s at 250 W
|
||||
against 40.0 s at 100 W as a property of the reduction. **This rebuild's
|
||||
curve is monotone decreasing on both N=2 families.** The *mechanism* finding 3
|
||||
describes is real and is still the reason not to read either table as a
|
||||
sensitivity curve — lowering the threshold really can turn a proper subset
|
||||
into the whole roster and break the run — but **this grid does not exhibit
|
||||
it**, so the claim now rests on the mechanism alone and no longer on a
|
||||
measurement. That is a weaker claim than the one this record made, and it is
|
||||
written down as weaker.
|
||||
* the low-threshold rows, badly: 120.0 s / 165.0 s at 100 W against 40.0 s /
|
||||
75.0 s. Nothing ships at 100 W, but the size of that gap is the honest measure
|
||||
of how much of the original table was a property of axes nobody wrote down.
|
||||
|
||||
⚠️ **WHY THE NUMBERS MOVED, AS FAR AS IT CAN BE ESTABLISHED.** §3.3 fixes the
|
||||
*counts* of every axis (6 gains × 5 slews; 7 reversal sizes; 7 flap periods × 4
|
||||
amplitudes; 5 staggers; and the 5 splits, which it lists) and the *ranges*
|
||||
(`config.yaml`'s `gain` `float(0.05,1.0)` and `slew_w` `int(0,5000)`). It does
|
||||
**not** fix the individual values, the run length, or the per-unit cap. The
|
||||
rebuild spread them over the admissible ranges and **prints every one of them
|
||||
above its own table**, so the caveat travels with the number instead of living
|
||||
in somebody's memory. The residual disagreement — one to two control cycles at
|
||||
the shipped threshold — is inside the sensitivity to that choice; the
|
||||
low-threshold rows are not, and are reported as not.
|
||||
|
||||
⚠️ **AND THE HARNESS WAS NOT ADJUSTED UNTIL IT AGREED.** The one substantive
|
||||
correction made during the rebuild was made *against* the doc's number, not
|
||||
towards it, and it is recorded in §1: grading on the plant's latch rather than
|
||||
the controller's standing command produced a harness that could not name even a
|
||||
wedged unit. Fixing that made the numbers **larger**. Nothing else was tuned.
|
||||
|
||||
Findings 1-3 below are the original campaign's, kept with finding 3 now
|
||||
qualified by the re-run above:
|
||||
Three findings worth stating rather than leaving in the table:
|
||||
|
||||
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
|
||||
threshold.** That is the compatibility promise measured, not argued.
|
||||
@@ -443,77 +268,37 @@ would be a preference, and all the margin this check needs is in the window.
|
||||
|
||||
Four bounds, and the value clears all four.
|
||||
|
||||
* **lower, from the rig — 35.0 s** (TEST-21, 2026-09-07; **the original record
|
||||
said 30.0 s and it did not reproduce**). The worst continuous legitimate
|
||||
episode at the shipped threshold across all 2640 runs — weighted family, worst
|
||||
single run `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W
|
||||
reversal; 30.0 s on reversals, 0.0 s on flapping. Re-run it with
|
||||
`admin/goodwe:sim/delivery_window.py --campaign` (§1).
|
||||
⚠️ **THIS IS NOW STRICTLY THE BINDING LOWER BOUND, WHICH IT WAS NOT BEFORE.**
|
||||
At 30.0 s it coincided with the firmware's veto below and a reader could not
|
||||
tell which of the two was doing the work. At 35.0 s the rig's number is on its
|
||||
own, above the firmware's — so **the rig is what stops `delivery_hold_s` going
|
||||
low**, and the schema's `int(30,900)` floor no longer sits above the worst
|
||||
legitimate episode. An operator who sets it to the schema minimum of 30 s is
|
||||
now **below** a measured legitimate episode and will get false reports; the
|
||||
option is marked *derived, do not change* and this is what that marking is
|
||||
for. Raising the schema floor to 35 is a change to a shipped option's
|
||||
contract and is left to its own card rather than smuggled in here.
|
||||
* **lower, from the rig — 30.0 s.** The worst continuous legitimate episode at
|
||||
the shipped threshold across all 2640 runs (weighted family; 20.0 s on
|
||||
reversals, 0.0 s on flapping).
|
||||
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
|
||||
executing its share for less than the firmware's own veto window is a *link*
|
||||
fault the add-on already answers by dropping it from the split. Reporting
|
||||
inside that window would be a second, louder alarm about a state already
|
||||
being handled — the SAFETY-15/SAFETY-19 failure exactly.
|
||||
* **margin, from inverter B not existing.** ⚠️ **THIS ROW IS A SENSITIVITY
|
||||
STUDY ON A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, AND THE ORIGINAL
|
||||
WORDING ("MEASURED, not felt") OVERSTATED IT.** Inverter B is not installed
|
||||
(~2026-09-12; NOTES.md §1.1b). The campaign's B is **A copied**, `measured=
|
||||
False`, so the 35.0 s above is **two identical machines** and every row below
|
||||
is a *what-if*: what a slower second unit would cost this window. **No row
|
||||
here is B's lag.** What it is genuinely evidence for is the SHAPE of the
|
||||
margin — which term the window has to be sized against — and that is what it
|
||||
is used for. Sweeping a *different* second unit through the weighted family
|
||||
(750 runs each). Second column is the original record, third is TEST-21's
|
||||
re-run with the committed harness (`--blag`):
|
||||
* **margin, from inverter B not existing — and it is MEASURED, not felt.** The
|
||||
campaign's B is A copied, so 30.0 s is two identical machines. Sweeping a
|
||||
*different* second unit through the weighted family (750 runs each):
|
||||
|
||||
| B lag | B ramp | 2026-09-06 (uncommitted) | **TEST-21 re-run** | runs naming |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| 10.0 s (A) | 800 W/s (A) | 30.0 s | **35.0 s** | 403/750 |
|
||||
| 20.0 s | 800 W/s | 45.0 s | **45.0 s** | 454/750 |
|
||||
| 10.0 s | 400 W/s | 30.0 s | **35.0 s** | 411/750 |
|
||||
| 20.0 s | 400 W/s | 45.0 s | **50.0 s** | 454/750 |
|
||||
| 40.0 s | 200 W/s | 90.0 s | **75.0 s** | 478/750 |
|
||||
| B lag | B ramp | worst legitimate | runs naming |
|
||||
|---:|---:|---:|---:|
|
||||
| 10.0 s (A) | 800 W/s (A) | **30.0 s** | 368/750 |
|
||||
| 20.0 s | 800 W/s | 45.0 s | 469/750 |
|
||||
| 10.0 s | 400 W/s | **30.0 s** | 370/750 |
|
||||
| 20.0 s | 400 W/s | 45.0 s | 469/750 |
|
||||
| 40.0 s | 200 W/s | **90.0 s** | 500/750 |
|
||||
|
||||
**The LAG moves it roughly linearly and the RAMP barely moves it**, which is
|
||||
the original finding and it survives — with one correction: *"the RAMP does
|
||||
not move it at all"* is too strong. Halving the ramp is worth 0 s at A's lag
|
||||
and **+5 s at twice it** (45.0 → 50.0), so the ramp is a second-order term
|
||||
rather than no term. The shortfall lasts as long as the unit's first-order lag
|
||||
holds it more than `saturation_w` below its share; at these step sizes the
|
||||
ramp is rarely the binding one. So the number to carry margin against is still
|
||||
B's lag — and a hypothetical unit **four times slower than A** produces
|
||||
**75.0 s**, where the original record said 90.0 s. ⚠️ Note the direction: this
|
||||
row moved **in the margin's favour**, while the floor above moved against it.
|
||||
Neither was adjusted to suit the other.
|
||||
**The RAMP does not move it at all and the LAG moves it roughly linearly.**
|
||||
The shortfall lasts as long as the unit's first-order lag holds it more than
|
||||
`saturation_w` below its share; at these step sizes the ramp is never the
|
||||
binding term. So the number to carry margin against is B's lag, and a unit
|
||||
**four times slower than A** produces 90.0 s.
|
||||
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
|
||||
|
||||
**185 s** is thirty-seven control cycles. Against the TEST-21 re-run it is
|
||||
**5.29×** the measured worst legitimate episode (35.0 s), **2.47×** the
|
||||
hypothetical four-times-slower second unit (75.0 s), **6.2×** the firmware veto
|
||||
(30 s) and **4.9×** inside the quarter (900 s). ⚠️ Deliberately **not 180** —
|
||||
that is SAFETY-02's site hold, and a reader must not be able to think this was
|
||||
carried across — and not 150 or 200.
|
||||
|
||||
⚠️ **185 STANDS, AND HERE IS THE ARITHMETIC RATHER THAN THE ASSURANCE.** The
|
||||
record claimed 6.2× the measured worst; the re-run makes that **5.29×**. All
|
||||
four bounds still clear, none of them narrowly, and the *ordering* of the
|
||||
argument is unchanged: the rig's floor is the binding one, the quarter is the
|
||||
ceiling, and 185 sits between them with margin at both ends. So the value is
|
||||
**not changed** — a shipped safety constant is not moved because its margin
|
||||
went from 6.2× to 5.3× while every bound still holds, and moving it would
|
||||
invalidate SAFETY-41's four-attempt false-trip regime for nothing. What IS
|
||||
changed is the claim: this record no longer says 6.2×, and no longer says 30.0
|
||||
s, because neither is what the harness produces.
|
||||
**185 s** is thirty-seven control cycles: **6.2×** the measured worst, **2.06×**
|
||||
the four-times-slower case, **6.2×** the firmware veto and **4.9×** inside the
|
||||
quarter. ⚠️ Deliberately **not 180** — that is SAFETY-02's site hold, and a
|
||||
reader must not be able to think this was carried across — and not 150 or 200.
|
||||
|
||||
**What it costs, stated rather than hidden:** up to 185 s of a site running
|
||||
short by one unit's share before anybody is told. Against a fault that ran
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
# SAFETY-43 — delinquency measured against the ask, not against a flat 500 W
|
||||
|
||||
**Status: closed.** `delivery.delinquent()` names a unit whose `short_by()` gap
|
||||
exceeds `min_w` **or** exceeds a fraction of that unit's own share. The fraction
|
||||
is `delivery.DELIVERY_SHARE_FRACTION = 0.85`, derived on the campaign in §3.
|
||||
|
||||
⚠️ **INVERTER B DOES NOT PHYSICALLY EXIST** (expected ~2026-09-12; NOTES.md
|
||||
§1.1b). `plantsim.INVERTER_B` is inverter A **copied**, `measured=False`, and
|
||||
`test_packshare.py`'s plant is modelled in that file. **Every two-inverter
|
||||
figure in this document is two copies of A.** Nothing here is a measurement of
|
||||
inverter B.
|
||||
|
||||
## 0. The defect, and why it is the quiet regime that matters
|
||||
|
||||
[[OPT-09]] passes SoC- and capacity-aware weights to the allocator. A weight is
|
||||
a function of a pack's **energy headroom**, not of what the pack delivers, so a
|
||||
**wedged** unit — one that accepts its share and produces nothing — is weighted
|
||||
down exactly like a legitimately low-headroom one. `delinquent()` read an
|
||||
**absolute** gap, `min_w = Tuning.saturation_w = 500 W`. Shrinking the share
|
||||
shrinks the gap. Measured end to end on the real loop (`test_packshare.py`
|
||||
part 8b: real `compute()`, real `plan()`, real `packshare.weights()`, real
|
||||
`delivery.account()`, real `delinquent()`; B wedged at a healthy 15 % against its
|
||||
own 10 % cut-out, so no envelope closes and `account()` explains nothing — a
|
||||
SAFETY-42 **true positive** by that card's own definition):
|
||||
|
||||
load 1500 W equal weights shares {a:1500, b:1500} named on 199/200
|
||||
load 1500 W OPT-09 weights shares {a:1505, b: 125} named on 0/200
|
||||
|
||||
A unit delivering **0 of 125 W** is 100 % delinquent and 125 W short. Only the
|
||||
absolute threshold disagreed.
|
||||
|
||||
⚠️ **THE SUPPRESSED REGIME IS THE DANGEROUS ONE.** At a load the healthy sibling
|
||||
covers, the meter is satisfied (grid within 5 W of zero on that fixture),
|
||||
`unmet_w` is 0 because both units *accepted* their shares, no envelope closes and
|
||||
no action-log line fires. The delinquency alarm is the **only** instrument that
|
||||
would have told the operator the site was quietly running one inverter.
|
||||
|
||||
## 1. What was NOT done, and it is the larger half of this card
|
||||
|
||||
⚠️ **`min_w` WAS NOT LOWERED.** It is `Tuning.saturation_w` — the gap at which
|
||||
the control law itself concludes the inverter is not following — and it exists to
|
||||
stop **normal saturation** being reported as delinquency. Lowering it trades a
|
||||
missed alarm for a false one on every unit at once. [[SAFETY-41]] spent **four
|
||||
attempts** on notification cadence in this system and the owner's words were
|
||||
*"i'm getting really annoyed by the notifications in my HA"*.
|
||||
|
||||
So the change is an **`or`**, never a looser first arm:
|
||||
|
||||
named if short_by() > min_w OR short_by() > frac * share
|
||||
|
||||
Everything `min_w` named before is still named. Nothing `min_w` refused becomes
|
||||
noisy **unless the unit is delivering almost none of its ask**, which is not
|
||||
saturation-shaped at all. `test_delivery.py` part 1 runs all seven of its
|
||||
existing `delinquent()` fixtures under **both** rules — `share_frac=None` is
|
||||
SAFETY-42's rule exactly — and requires identical answers on every one.
|
||||
|
||||
The absolute arm's suppression window is therefore **unchanged**: still
|
||||
`site target < 2·min_w` = 1000 W at equal weights, still
|
||||
`site target < min_w·(w_A+w_B)/w_B` = 6500 W under OPT-09's weights on part 8b's
|
||||
fixture. Part 8b still asserts both numbers, now as evidence that the first arm
|
||||
did not move.
|
||||
|
||||
## 2. The evidence is a diff between two rules on ONE run
|
||||
|
||||
`delinquent()` takes `share_frac`, and **`None` means the absolute arm alone**.
|
||||
That parameter exists for one reason: so the red and the green come out of one
|
||||
run over one tree instead of out of two commits that a reader has to trust.
|
||||
`test_packshare.py` part 8b grades every cycle twice:
|
||||
|
||||
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
|
||||
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
|
||||
|
||||
load 1500 W equal weights absolute arm alone named on 199/200 cycles
|
||||
load 1500 W equal weights the shipped rule named on 200/200 cycles
|
||||
|
||||
The equal-weights arm gains **exactly one cycle** — the first, where the slew
|
||||
limit holds the split at 500/500, a 500 W gap is not *over* a 500 W threshold
|
||||
(the absolute bound is strict) and the relative arm names a box delivering none
|
||||
of its 500 W. That single cycle is the whole of what this card costs an arm that
|
||||
was already firing.
|
||||
|
||||
⚠️ **THE PIN THAT INVERTED.** Part 8b was written by OPT-09 to pin the
|
||||
**suppressed** behaviour, and its comment said: *"if it ever goes red because the
|
||||
alarm fired, read the card that made it fire before 'fixing' this line."* This is
|
||||
that card. The pin now asserts the opposite answer and keeps the old one beside
|
||||
it, measured rather than remembered.
|
||||
|
||||
## 3. ⭐ Deriving the fraction
|
||||
|
||||
⚠️ **DERIVED ON THE SAME CAMPAIGN AS `delivery_hold_s`'s FLOOR**, not chosen.
|
||||
`admin/goodwe:sim/delivery_window.py` — the harness [[TEST-21]] committed —
|
||||
gained a second grading axis: the same **2640 no-fault runs at N=2** (three
|
||||
families × 6 gains × 5 slews × write staggers; every unit executes the share the
|
||||
allocator gave it) and the same **2640 N=1 control runs**, graded at the shipped
|
||||
`min_w = 500 W` for each candidate fraction, on every cycle of every run — so a
|
||||
pass grades every fraction it is given, and the absolute arm beside them, over
|
||||
one tree and one set of runs. Every row in a pass is comparable to every other
|
||||
row in it, run for run.
|
||||
|
||||
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
||||
python3 -B delivery_window.py --selftest --campaign --fractions
|
||||
|
||||
⚠️ **THE TABLE BELOW WAS PRODUCED BY TWO PASSES; `FRACTIONS` NOW HOLDS ALL
|
||||
TWENTY SO IT TAKES ONE.** The derivation ran the shipped ten
|
||||
`(0.25 … 0.99)` and then the ten that resolve the low end
|
||||
`(0.05 … 0.65)` with only that tuple edited between them. **The absolute-arm
|
||||
control row came out identical in both** (30.0 / 0.0 / 35.0 / 0.0, same argmax),
|
||||
which is what says the two passes are one campaign. A table a reviewer cannot
|
||||
reproduce without knowing to edit a line nobody wrote down is PROC-01's shape in
|
||||
miniature, so the committed `FRACTIONS` was widened to all twenty in review: the
|
||||
command above now reproduces every row below.
|
||||
|
||||
### 3.1 The curve
|
||||
|
||||
Worst **continuous** legitimate episode (the measure `DeliveryWatchdog` actually
|
||||
sees — its window is cleared by any cycle that does not name), runs naming in
|
||||
brackets:
|
||||
|
||||
| `share_frac` | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640)** |
|
||||
|---:|---:|---:|---:|---:|
|
||||
| absolute only | 30.0 s (460) | 0.0 s (0) | 35.0 s (403) | **0.0 s (0)** |
|
||||
| 0.05 | 85.0 s (826) | 0.0 s (0) | 90.0 s (616) | **0.0 s (0)** |
|
||||
| 0.10 | 75.0 s (824) | 0.0 s (0) | 90.0 s (607) | **0.0 s (0)** |
|
||||
| 0.15 | 70.0 s (836) | 0.0 s (0) | 70.0 s (609) | **0.0 s (0)** |
|
||||
| 0.20 | 70.0 s (839) | 0.0 s (0) | 60.0 s (629) | **0.0 s (0)** |
|
||||
| 0.25 | 55.0 s (832) | 0.0 s (0) | 60.0 s (611) | **0.0 s (0)** |
|
||||
| 0.30 | 55.0 s (840) | 0.0 s (0) | 50.0 s (614) | **0.0 s (0)** |
|
||||
| 0.35 | 55.0 s (840) | 0.0 s (0) | 55.0 s (618) | **0.0 s (0)** |
|
||||
| 0.40 | 50.0 s (840) | 0.0 s (0) | 60.0 s (658) | **0.0 s (0)** |
|
||||
| 0.45 | 45.0 s (840) | 0.0 s (0) | 45.0 s (627) | **0.0 s (0)** |
|
||||
| 0.50 | 45.0 s (840) | 0.0 s (0) | 45.0 s (636) | **0.0 s (0)** |
|
||||
| 0.55 | 40.0 s (840) | 0.0 s (0) | 40.0 s (636) | **0.0 s (0)** |
|
||||
| 0.60 | 40.0 s (840) | 0.0 s (0) | 40.0 s (645) | **0.0 s (0)** |
|
||||
| 0.65 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| 0.70 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| 0.75 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| 0.80 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
|
||||
| **0.85** | **35.0 s (840)** | **0.0 s (0)** | **40.0 s (635)** | **0.0 s (0)** |
|
||||
| 0.90 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||
| 0.95 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||
| 0.99 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
|
||||
|
||||
Worst of the three families, which is what `delivery_hold_s` has to cover:
|
||||
|
||||
frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
|
||||
worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
|
||||
|
||||
frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
|
||||
worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
|
||||
|
||||
⚠️ **THE FIRST ROW IS THE CONTROL AND IT REPRODUCED SAFETY-42 §3.3 EXACTLY** —
|
||||
30.0 s / 0.0 s / 35.0 s, and the same argmax (`gain=0.2 slew_w=1000 stagger=4
|
||||
split=0.10/0.90`, ±2200 W reversal). It is measured **in the same run** as every
|
||||
row below it, which is what makes those rows a comparison rather than a claim.
|
||||
|
||||
### 3.2 What the curve says
|
||||
|
||||
* ⚠️ **THERE IS NO CLIFF ANYWHERE IN THE CURVE, AND THAT IS THE FINDING.** From
|
||||
**0.45 to 0.99** every figure is 40.0 or 45.0 s — within **one control cycle
|
||||
(5.0 s)** of every other — and below 0.45 it rises **gradually**: 60.0 s at
|
||||
0.25, 70.0 s at 0.15, 90.0 s at 0.05. A reviewer asking "why 0.85 and not
|
||||
0.90?" is asking about a difference this campaign cannot resolve, and the
|
||||
honest answer is that it cannot.
|
||||
* ⚠️ **IT IS NOT MONOTONE** — 0.40 reports 60.0 s where 0.35 reports 55.0, and
|
||||
0.65–0.80 report 45.0 s where 0.55/0.60 and 0.85–0.99 report 40.0 — and that is
|
||||
SAFETY-42 §3.3's own mechanism (c) rather than noise in the plant: **a more
|
||||
sensitive rule puts MORE units into the short set, which can turn a proper
|
||||
subset into the WHOLE roster and END the episode.** The reduction is not
|
||||
monotone in its own threshold. Do not read this column as a sensitivity curve
|
||||
in the ordinary sense.
|
||||
* **The shoulder is at about 0.45**, and it is a shoulder rather than an edge:
|
||||
the reversal family's naming count saturates at 840 of 1050 runs from 0.30
|
||||
upward, so below the shoulder what grows is the LENGTH of an episode, not the
|
||||
number of runs that have one.
|
||||
* **The N=1 column is 0.0 s at every fraction, in all 2640 one-inverter runs.**
|
||||
The compatibility promise is measured for the new arm, not argued.
|
||||
* **The flapping family is 0.0 s at every fraction, in all 840 runs.** A common
|
||||
stimulus makes both units short at once and the subset stops being proper —
|
||||
the property that defeated SAFETY-30's site window still holds.
|
||||
|
||||
### 3.3 ⚠️ "Zero false namings" is not available at ANY fraction
|
||||
|
||||
The card asked for *the largest fraction that yields zero false namings across
|
||||
the whole no-fault campaign*. **There is none.** Every fraction adds naming
|
||||
cycles: the reversal family goes from 460 runs naming to 840, the weighted family
|
||||
from 403 to 635. A relative arm that never fired on a legitimate transient would
|
||||
be a relative arm that never fired at all — the write stagger lands B's number up
|
||||
to four cycles late, and a unit whose new command has not arrived is, for those
|
||||
cycles, delivering a small fraction of what is standing on its wire.
|
||||
|
||||
What **is** zero at every fraction is **false alarms**:
|
||||
|
||||
> the longest continuous legitimate episode anywhere in the 20-point sweep is
|
||||
> **90.0 s** — at 0.05, the most sensitive fraction measured — against a
|
||||
> `delivery_hold_s` of **185 s**. Nothing in 2640 no-fault runs comes within
|
||||
> **95 s** of notifying an operator, **at any fraction measured**. At the
|
||||
> shipped 0.85 it is 40.0 s, i.e. 145 s of slack.
|
||||
|
||||
That is the quantity SAFETY-41 cares about and it is the one this card had to
|
||||
protect. Stated as margin, at the shipped fraction: **185 s is 4.62×** the worst
|
||||
legitimate episode, where the absolute arm alone gave 5.29×.
|
||||
|
||||
### 3.4 The bounds 0.85 answers to
|
||||
|
||||
* **LOWER, from the campaign** — the shoulder at about **0.45**, below which the
|
||||
added episode starts to grow (60.0 s at 0.25, 90.0 s at 0.05). 0.85 is 0.40
|
||||
above it.
|
||||
* **UPPER, from the card's own case** — strictly **below 1.0**. At 1.0 the arm
|
||||
can only fire on a unit that is *importing* while asked to discharge, which is
|
||||
SAFETY-30's fault and not this one, so a unit delivering **none** of its share
|
||||
(gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
|
||||
wedged box reporting a small standby figure instead of a clean zero.
|
||||
* **UPPER, from the hold** — every fraction in the flat range leaves
|
||||
`delivery_hold_s` between **4.11×** (45.0 s) and **4.62×** (40.0 s) the worst
|
||||
legitimate episode, well inside the four bounds SAFETY-42 §3.3 requires of it.
|
||||
**`delivery_hold_s` IS NOT CHANGED BY THIS CARD.**
|
||||
* ⚠️ **NOT DERIVED, AND SAID SO: the exact value inside [0.45, 1.0).** The
|
||||
campaign cannot separate them. 0.85 is the midpoint of the argument above —
|
||||
0.40 of margin above the shoulder, 0.15 below the ceiling — and a
|
||||
re-derivation that moved it to 0.90 would be moving a number this campaign
|
||||
never resolved. It is not moved for that reason.
|
||||
|
||||
## 4. The positive control — what the relative arm must still NOT say
|
||||
|
||||
A card that adds a way to raise an alarm has to show what it did **not** add.
|
||||
`test_packshare.py` part 8b runs the same fixture, the same weights and the same
|
||||
125 W share with the one fact that makes the shortfall legitimate changed:
|
||||
|
||||
| fixture | B's share | named | why it is silent |
|
||||
|---|---|---|---|
|
||||
| B wedged at a healthy 15 % (the fault) | 77–154 W | **200 / 200** | — it is the fault |
|
||||
| B at its own 10 % cut-out | **0 W, all 200 cycles** | **0 / 200** | it is never **asked** |
|
||||
| B delivering **half** its share | 120 W | **0 / 200** | short by 50 %, inside both arms |
|
||||
|
||||
### ⚠️ The cut-out row does NOT grade `account()`, and this record said it did
|
||||
|
||||
**Corrected in review.** The first draft of this section claimed the wedged and
|
||||
cut-out arms had *"identical power traces — 0 W against a positive share, 100 %
|
||||
of the ask missing"* and that *"what separates them is `account()`"*. **Every
|
||||
part of that was false**, and the check that carried it was **vacuous**:
|
||||
|
||||
* at its own cut-out the pack's usable energy **above that cut-out is zero**, so
|
||||
`packshare.weights()` answers **0.0** for it, `_fill()`'s preferred pass gives
|
||||
it nothing, the healthy sibling covers the load alone so the last-resort pass
|
||||
never runs, and its share is **0 W on all 200 cycles**;
|
||||
* a unit with a 0 W share is not in `asked`, so `len(asked) < 2` returns `{}`
|
||||
**before either arm is evaluated** and `account()` is **never consulted**;
|
||||
* the traces are not identical — the cut-out arm's share is **0 W on every
|
||||
cycle** (asserted in part 8b and printed on its line) while the wedged arm's
|
||||
is **125 W** at the last cycle (asserted in the GREEN check) and takes four
|
||||
distinct values 77/125/133/154 W across the 200, measured by re-running the
|
||||
same loop with per-cycle recording during review — and "100 % of the ask" is
|
||||
0/0.
|
||||
|
||||
Measured, by deleting the gate the claim rested on:
|
||||
|
||||
cut-out arm, account() live named 0/200, distinct B shares: [0]
|
||||
cut-out arm, account() DELETED named 0/200, distinct B shares: [0]
|
||||
wedged arm, account() live named 200/200, shares [77,125,133,154]
|
||||
|
||||
⚠️ **The fact that replaces it is better, not weaker.** At its own cut-out
|
||||
**OPT-09 stops asking the pack at all**, so SAFETY-43's relative arm is never
|
||||
reached — a **second, independent protection sitting upstream of `account()`**:
|
||||
the allocator declines to ask before the detector has to decline to alarm. Part
|
||||
8b now asserts the 0 W share and prints it, and runs the arm a second time with
|
||||
`account()` deleted to show which mechanism is doing the work.
|
||||
|
||||
⚠️ **It was invisible for one reason worth keeping**: that line was the only one
|
||||
in part 8b that did not print its shares. `inverter-b+0` would have been on
|
||||
screen from the first run. Every line in that part prints them now, and the
|
||||
derated control's vacuity guard now asserts **its own** unit is asked, rather
|
||||
than re-asserting a different run's 200/200 — which is exactly why the old guard
|
||||
could not see this.
|
||||
|
||||
**The account gate is graded where it is real**, on a positive 125 W share, in
|
||||
`test_delivery.py` part 1 (*"THE ACCOUNT GATE IS UNTOUCHED BY IT"* — the same
|
||||
0 of 125 W with the shortfall explained names nobody). That part also carries
|
||||
that the relative bound is **strict** (a gap of exactly `frac · share` is not
|
||||
over it) and that the proper-subset rule still refuses a site-wide cause (both
|
||||
units delivering none of a 125 W share names nobody).
|
||||
|
||||
## 5. N=1 is byte-identical, and it is asserted rather than argued
|
||||
|
||||
The live install is **N=1**. Two independent statements, each graded against the
|
||||
thing that would break it (`test_packshare.py` part 8b):
|
||||
|
||||
1. **`allocator.plan()` returns before `weights` is read at all.** Graded by
|
||||
handing it a `weights` mapping that **raises on any read**: at a roster of one
|
||||
it answers identically to the same call with no weights; at a roster of two
|
||||
the same mapping is read and raises, so the tripwire is a real one.
|
||||
2. **`delinquent()` answers `{}` at a roster of one for every input** — 84
|
||||
combinations of (share, measured, rule), including **0 of 125 W**, the exact
|
||||
input this card added an arm for. `len(asked) < 2` returns before either
|
||||
threshold is compared, so the fraction is never even multiplied.
|
||||
|
||||
Nothing in this card is reachable at one inverter. It must land before inverter B
|
||||
is commissioned (~2026-09-12), not before the next deploy.
|
||||
|
||||
## 5b. Mutation — the fraction is pinned from BOTH directions
|
||||
|
||||
⚠️ **RUN, NOT REASONED ABOUT.** Each mutation was applied to a tree **copied
|
||||
fresh** from this branch and mounted `:ro` inside the checks image, so no `.pyc`
|
||||
could go stale — MUTATION.md's defect, and all three of these are **reds**, the
|
||||
direction that defect cannot fabricate. Recorded in `test_packshare.py` part 11
|
||||
as M12–M14.
|
||||
|
||||
| mutation | named checks | where |
|
||||
|---|---:|---|
|
||||
| **M12** drop the relative arm, keep `gap > min_w` — *the card itself reverted* | **7** | `test_delivery.py` part 1 (3: the 0-of-125 W naming, the proportion in the sentence, the strict relative bound) + `test_packshare.py` 8b (4: GREEN back to 0/200, the equal arm's 200 back to 199, the 6000 W arm back to 195/199, the derated control's guard). `test_alloc.py` / `test_control.py` stay green — nothing outside this check moves. |
|
||||
| **M13** `DELIVERY_SHARE_FRACTION` → **1.0** — *too loose* | **7** | part 1 (2) + 8b (5), including the PRECONDITION that the fraction is strictly below 1.0. At 1.0 the card is a no-op: gap/share of exactly 1.0 is not *over* 1.0. |
|
||||
| **M14** `DELIVERY_SHARE_FRACTION` → **0.05** — *too sensitive* | **6** | part 1 (2: the strict bound, and *"IT TOOK NOTHING AWAY"* stops being true) + part **4b** (3: the `saturation_w` pin goes **vacuous**, the relative arm naming B at every threshold) + 8b's derated control, where a unit delivering half its share starts being named. |
|
||||
|
||||
M13 and M14 are the pair that matters: the constant cannot be moved in **either**
|
||||
direction without a named check moving with it.
|
||||
|
||||
### ⚠️ Two of these counts were wrong, and the reason is worth keeping
|
||||
|
||||
**Corrected in review.**
|
||||
|
||||
* **M12 and M13 read 1 and 1 in `test_delivery.py`, not 3 and 2.** Part 1 printed
|
||||
the alarm sentence with a bare subscript, `delinquent(_tiny, 500)["b"]`. On any
|
||||
tree where the relative arm is broken or absent that raises `KeyError` and
|
||||
**aborts the file at part 1**, leaving parts 2–8 ungraded — so the recorded
|
||||
coverage of a mutation run read *smaller* than the truth. The direction is safe
|
||||
(an abort is a red, and MUTATION.md's defect can only fabricate a green), but a
|
||||
figure that does not reproduce is a figure that does not reproduce. It is a
|
||||
`.get` now, with the reason written beside it, and the counts above are from
|
||||
re-runs with the guard in place.
|
||||
* **M14 was recorded as 4 named checks in `test_delivery.py` and it is 5** — part
|
||||
1's pre-existing *"a shortfall AT the threshold is not one"* also goes red. That
|
||||
one under-claimed, so it never inflated the evidence; it is still a recorded
|
||||
number that did not reproduce.
|
||||
|
||||
## 6. ⚠️ [[CFG-07]] is adjacent and is NOT absorbed here
|
||||
|
||||
CFG-07 is that `delivery_hold_s` is declared `int(30,900)` in `config.yaml`'s
|
||||
schema while the measured worst legitimate episode at the shipped threshold is
|
||||
**35.0 s** — so the schema offers an operator a value (30) that is **below a
|
||||
measured legitimate episode** and would report on ordinary saturation.
|
||||
|
||||
**This card makes it worse, and by exactly one control cycle.** The relative arm
|
||||
takes the worst legitimate episode from **35.0 s to 40.0 s** at the shipped
|
||||
fraction, so the band of schema-legal but false-tripping values widens from
|
||||
`[30, 35]` to `[30, 40]`. The floor the schema should have is now **40 s**, not
|
||||
35 s. It is not fixed here: raising a schema floor is an option-surface change
|
||||
with its own compatibility argument (an existing config carrying `35` would start
|
||||
being refused), and folding it into a safety fix would hide it. **CFG-07 should
|
||||
be re-derived against 40.0 s, not 35.0 s.**
|
||||
|
||||
⚠️ **WHEN IT HAS TO CLOSE: BEFORE INVERTER B IS COMMISSIONED (~2026-09-12).**
|
||||
Not before the next deploy. The band is **unreachable at N=1** — `delinquent()`
|
||||
returns `{}` for every input at a roster of one, so no value of
|
||||
`delivery_hold_s` can false-trip on the install that is running today — and the
|
||||
live install is N=1 until B exists. Commissioning B is the moment the band
|
||||
becomes reachable, and that is the deadline this card hands it.
|
||||
|
||||
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE.** It exists as a
|
||||
`[[CFG-07]]` mention in `CHANGELOG.md` and in this section, and nowhere else —
|
||||
so there is no file for the 40.0 s to be recorded in, and a reader who goes
|
||||
looking will find a card reference with no record behind it. Whoever takes
|
||||
CFG-07 writes `DECISIONS/CFG-07-*.md` first; the number it has to answer to is
|
||||
**40.0 s**, measured here, and the harness that produces it is
|
||||
`admin/goodwe:sim/delivery_window.py --campaign --fractions`.
|
||||
|
||||
## 7. Provenance of every number in this document
|
||||
|
||||
| number | where it came from |
|
||||
|---|---|
|
||||
| 199/200, 0/200, 200/200, shares 1505/125 | `test_packshare.py` part 8b, run |
|
||||
| 35.0 s absolute-arm floor, and its argmax | `delivery_window.py --campaign`, re-run here, reproduces SAFETY-42 §3.3 |
|
||||
| every row of §3.1 | `delivery_window.py --campaign --fractions`, two passes over the same campaign with `FRACTIONS` changed between them |
|
||||
| 1000 W / 6500 W suppression windows | part 8b's arithmetic, asserted there |
|
||||
| 185 s, 5.29× | SAFETY-42 §3.3 / TEST-21, unchanged by this card |
|
||||
| 4.62× | 185 / 40.0, printed by the campaign |
|
||||
| 0/200 on both positive controls, and B's 0 W share at the cut-out | part 8b, run and asserted |
|
||||
| 0 W cut-out share / 125 W wedged share | part 8b, asserted and printed |
|
||||
| 77/125/133/154 W (the wedged arm's four distinct shares) | the same loop re-run in review with per-cycle recording — NOT part 8b's printed line, which shows the last cycle only |
|
||||
| M12/M13/M14 named-check counts (7 / 7 / 6) | re-run in review on fresh `:ro` trees, after the `KeyError` abort in part 1 was guarded |
|
||||
| 84 single-unit rosters | part 8b, run |
|
||||
|
||||
⚠️ Every two-inverter figure above is **two copies of inverter A**.
|
||||
@@ -1,221 +0,0 @@
|
||||
# SAFETY-45 — the trade is SIZED by the capacity guard, not switched by it
|
||||
|
||||
**Status: closed.** `peakguard.charge_headroom_w()` publishes the watts the open
|
||||
billed quarter can still afford; `arbitrage.decide()` takes it as a required
|
||||
keyword-only argument and sizes the BUY leg to it; `main.py` passes the measured
|
||||
guard's own number at its one call site. `peakguard.decide()` is **not changed** —
|
||||
byte-identical to `release/1.0` at `478ba93`, `sha256(decide) = 200090d0835a3a55`,
|
||||
7252 B on both trees.
|
||||
|
||||
⚠️ **THE HOUSE LOAD IN EVERY FIGURE BELOW IS SYNTHETIC.** TEST-23 stage 3's
|
||||
`priceloop.py` drives a flat modelled house against a modelled pack
|
||||
(`plantsim.Plant`, 800 W/s ramp into a 10 s lag, 15 360 Wh). The PRICES are real,
|
||||
the decision code is the shipped code, the billed-quarter accumulator is
|
||||
`app/p1.py`'s own — the load is not. Nothing here is a measurement of the live
|
||||
site at 192.168.2.158, which this work did not touch.
|
||||
|
||||
## 0. The defect, measured
|
||||
|
||||
TEST-23 stage 3 found a sustained limit cycle with arbitrage armed and a charge
|
||||
decision active. Re-measured here on the same rig against the same checkout,
|
||||
metrics recomputed rather than copied — a **charge onset** is a cycle at which
|
||||
`arbitrage.decide()` transitions into `ARBITRAGE_CHARGE`, which is the edge the
|
||||
rig already logs:
|
||||
|
||||
| `peak_cap_w` | house | onsets/h | mean period | grid swing | billed quarters (W) |
|
||||
|---|---|---|---|---|---|
|
||||
| 1500 | 1200 W | **28** | 131.1 s | **−455.5 … +3200.0 W** | 1490.1 / 1496.5 / **1508.8** |
|
||||
| 3500 *(shipped)* | 1800 W | 8 | 509.3 s | +127.5 … +3800.0 W | **3511.3 / 3536.0 / 3559.1** |
|
||||
| 3500 *(shipped)* | 3000 W | 32 | 114.8 s | +1202.0 … +5000.0 W | **3518.9 / 3542.2 / 3556.7** |
|
||||
| 5000 | 1200 W | 1 | — | +1240.6 … +3200.0 W | 3169.5 / 3200.0 / 3200.0 |
|
||||
|
||||
The last row is the **negative control**: `load + max_w = 3200 W` never reaches
|
||||
the cap, so there is no cycle to remove. The bite rule is `load + max_w >
|
||||
peak_cap_w`, which at the shipped `peak_cap_w: 3500` / `max_w: 2000` is any house
|
||||
drawing over 1500 W in the cheapest quarter.
|
||||
|
||||
§10.1's ordering did win on the money — the guard kept the quarters within tens
|
||||
of watts of the cap — but **every billed quarter in the three biting rows
|
||||
finished OVER it**, which is the thing §9.1 bills. §10.2: *"an EMS that
|
||||
oscillates is worse than no EMS, both for hardware wear and — per §9.1 — for the
|
||||
peak it is trying to avoid."*
|
||||
|
||||
## 1. ⚠️ The mechanism is NOT mainly the quarter boundary, and the card says it is
|
||||
|
||||
The card, and `.cache/s45.md` before it, describe the loop as *"at each quarter
|
||||
boundary the projection resets to 0, the guard disengages, and arbitrage re-arms
|
||||
at full power."* **That is not what the 28 onsets are.** Their timestamps, off
|
||||
the run above:
|
||||
|
||||
1, 231, 351, 471, 581, 696, 806, 896, 1136, 1256, 1371, 1486, 1596,
|
||||
1711, 1811, 2061, 2176, 2296, 2411, 2521, 2626, 2716, 2971, 3091,
|
||||
3206, 3321, 3431, 3541 (s, quarter boundaries at 900/1800/2700)
|
||||
|
||||
**23 of the 27 gaps are 90–120 s, mean 111.5 s, entirely inside one quarter.**
|
||||
The four long gaps — 230, 240, 250, 255 s — are precisely the four that SPAN a
|
||||
boundary, so the boundary **lengthens** the interval rather than causing an
|
||||
onset. Only three onsets land within 15 s of a boundary and one of those is
|
||||
`t = 1`, the start of the run.
|
||||
|
||||
The real cycle, read off the recorded fields at t = 1000…1180 s:
|
||||
|
||||
1. the trade charges at `−2000 W`; the meter sits at 3200 W against a 1500 W
|
||||
ceiling; `wanted = batt_w + excess` is **negative** (−132 → −8 W), so
|
||||
`decide()` files **nothing** and arbitrage is not constrained at all;
|
||||
2. `wanted` drifts up as the quarter's own history worsens and the `900 /
|
||||
remaining` gain grows, and crosses zero at t = 1055 with **`floor_w = 5.3 W`**;
|
||||
3. that 5 W floor flips `arbitrage.guard_state()` to `True`, which refuses the
|
||||
**whole trade** — so the setpoint goes from −2000 W to +5 W and the
|
||||
grid-following loop, which targets grid = 0 and not grid = ceiling, takes the
|
||||
actuator and discharges to **+1870 W**, exporting 420 W;
|
||||
4. the projection collapses, `wanted` goes negative, the guard releases, and
|
||||
arbitrage re-arms at **`max_w`**. 115 s later, again.
|
||||
|
||||
So the defect is **a binary interlock keyed on a continuous quantity**, and the
|
||||
quarter boundary is one of the ways it is crossed rather than the cause. The
|
||||
card's other claim — *"projection 3092 W against a 1509 W ceiling, `engaged=False`,
|
||||
for 170 consecutive seconds"* — reproduces, but it is the **first** such window
|
||||
(`first_engaged_t_s = 151.0`); the steady-state silent window is ~40–65 s.
|
||||
|
||||
None of this changes what the fix has to do. It is recorded because a card that
|
||||
says "the boundary is the trigger" invites a fix in the boundary handling, and
|
||||
such a fix would have removed four of twenty-eight onsets.
|
||||
|
||||
## 2. What was rejected, and why
|
||||
|
||||
### 2a. Reverting `decide()`'s `wanted <= 0.0` rung — rejected, and it is a trap
|
||||
That rung is 2026-08-20's anti-bang-bang. Its own comment records the cost of not
|
||||
having it: 17 engage/release cycles in 900 s and a quarter finishing 49 W over
|
||||
the limit it had spent a quarter of an hour holding. It is untouched, pinned on
|
||||
the AST in `test_peakguard.py` 13g, and `tools/replay_ac3.py` reproduces the same
|
||||
190/190, 110 engaged cycles, 5 blind, 878 Wh on this branch as on `478ba93`.
|
||||
|
||||
### 2b. Filing the affordable charge as a NEGATIVE `lo` — rejected
|
||||
Arithmetically this is the prettiest answer: `lo = wanted` with no sign gate is
|
||||
continuous, is exactly the control law `decide()` already computes, and would
|
||||
have put the constraint on the arbiter where it binds every claimant rather than
|
||||
only arbitrage. It was rejected on two grounds, in this order:
|
||||
|
||||
1. ⚠️ **IT CREATES A NEW `lo > hi` CONTRADICTION CLASS AGAINST THE MAINTENANCE
|
||||
SHAPING.** `main.py`'s cheap-window maintenance charge files
|
||||
`Claim.limit("maintenance", P_MAINT_SHAPE, hi=-floor)` — a NEGATIVE `hi`.
|
||||
`arbiter.py` clause 2 applies every limit at or above the winner's priority
|
||||
and takes the most restrictive, with **no notion of one limit out-ranking
|
||||
another**; a peak `lo = −300 W` against a shaping `hi = −1000 W` is `lo > hi`,
|
||||
which resolves to **0 W — the battery parked, in the one quarter of the month
|
||||
that sets the bill**. That is the exact failure `peakguard.shaping_suspended()`
|
||||
exists to prevent, and the only way to close it would be to key that
|
||||
suppression on "a claim exists" — which, on every site this card is about,
|
||||
means the maintenance charge-only shaping is suspended **permanently**. A
|
||||
silent, large change to a different subsystem is not a price this card should
|
||||
pay.
|
||||
2. It changes the meaning of `PeakDecision.engaged` at five call sites outside
|
||||
this module — `arbitrage.guard_state()`, `shaping_suspended()`,
|
||||
`claim()`, `main.py`'s edge-logged action line and `main.py`'s status row —
|
||||
each of which today means "the guard is HOLDING" and would silently start
|
||||
meaning "a claim exists".
|
||||
|
||||
`test_peakguard.py` part 5's sweep asserts **no negative floor, ever**, and the
|
||||
reason it gives ("a `lo` limit is incapable of capping discharge or export only
|
||||
while it is non-negative") is, strictly, not the true reason — a `lo` of any sign
|
||||
caps neither. The invariant is still right and is still worth holding; §2b above
|
||||
is the argument that actually supports it, and it is recorded here rather than
|
||||
left for the next person to re-derive.
|
||||
|
||||
### 2c. A backoff, cooldown or hysteresis constant in `arbitrage.py` — rejected
|
||||
The card asks whether the second half of the fix belongs in arbitrage: *"a trade
|
||||
refused at 14:59 and re-armed at 15:00 has learned nothing from the refusal."*
|
||||
It does belong there, but as a **size**, not as a memory. `arbitrage.py`'s
|
||||
hysteresis note is explicit that the switching decision is "which hour is it" and
|
||||
that **no timer is needed and none is invented**; `peakguard.py`'s design note
|
||||
spends a paragraph on why it carries no hysteresis constant. A cooldown would be
|
||||
a second control loop compensating for a limit the first one declined to file,
|
||||
and its constant would outlive whatever it was tuned against. The trade now
|
||||
re-arms at `ceiling − load` instead of at `max_w`, which is what "learning from
|
||||
the refusal" means when the refusal is a measurement.
|
||||
|
||||
## 3. What was done, and the precedent it follows
|
||||
|
||||
`control.maintenance_charge_floor()` has capped MAINTENANCE charging at *"the
|
||||
headroom left under the site's cap"* since the loop was written, and its
|
||||
docstring says why:
|
||||
|
||||
> "A maintenance charge is the only thing this system does that is big enough and
|
||||
> long enough to set that peak, so it is capped by the headroom left under the
|
||||
> site's cap."
|
||||
|
||||
⚠️ **THAT SENTENCE STOPPED BEING TRUE WHEN OPT-04 LANDED.** Price arbitrage is a
|
||||
second thing that is big enough and long enough — it commands `max_w` for a whole
|
||||
tariff interval, deliberately, against the meter — and it was given no such cap.
|
||||
This card gives it the same shape, with one difference that matters:
|
||||
`maintenance_charge_floor()` sizes itself from `peak_forecast_entity`, which is
|
||||
OPTIONAL and empty on a default install — the exact hole SAFETY-25 had to close
|
||||
in `shaping_suspended()`. `charge_headroom_w()` comes off `decide()`, which runs
|
||||
wherever P1 does.
|
||||
|
||||
**The number is `wanted_w` read in the other direction.** `decide()` already
|
||||
computes `wanted = batt_w + excess` as the DISCHARGE that lands the quarter on the
|
||||
ceiling; a negative `wanted` is a permitted CHARGE of `−wanted`. No new
|
||||
arithmetic, no new constant.
|
||||
|
||||
Two properties make it safe to hand a strategy, and both are swept rather than
|
||||
argued (`test_peakguard.py` part 13):
|
||||
|
||||
* ⚠️ **IT IS INVARIANT IN THE BATTERY.** Raise the charge by dW and the meter
|
||||
rises by dW, so `excess` rises by exactly dW while `batt_w` falls by dW:
|
||||
`wanted` does not move. Measured across `batt_w` from −5000 W to +2000 W on one
|
||||
banked quarter — the headroom is 1350.0 W at every one of the six points while
|
||||
the `excess` those runs see moves through six distinct values. It is a
|
||||
**feed-forward** number, so consuming it adds no feedback path around the plant.
|
||||
The fixed point is `charge = ceiling − load`, with the meter ON the ceiling and
|
||||
`wanted` parked a whole `ceiling − load` clear of the engagement boundary.
|
||||
* **HOLDING IMPLIES ZERO OR UNKNOWN.** Across 1080 swept combinations, not one
|
||||
ENGAGED decision answers a positive headroom, so the function can never hand a
|
||||
caller permission the guard's own `lo` is at that instant refusing.
|
||||
|
||||
## 4. After, on the same rig, same command, same checkout
|
||||
|
||||
| `peak_cap_w` | house | onsets/h | grid swing | energy bought | billed quarters (W) |
|
||||
|---|---|---|---|---|---|
|
||||
| 1500 | 1200 W | 28 → **1** | 3655.5 → **282.0 W** | 180.0 → **285.2 Wh** | **1500.0 / 1500.0 / 1500.0** |
|
||||
| 3500 | 1800 W | 8 → **1** | 3672.5 → 1687.6 W | 1652.9 → 1616.1 Wh | **3500.0 / 3500.0 / 3500.0** |
|
||||
| 3500 | 3000 W | 32 → **1** | 3798.0 → **474.7 W** | 482.9 → 475.3 Wh | **3500.0 / 3500.0 / 3500.0** |
|
||||
| 5000 | 1200 W | 1 → 1 | **identical** | **identical** | **identical** |
|
||||
|
||||
* **One onset** means the trade starts once and runs for the whole window. The
|
||||
battery is trading 100 % of the hour where it used to trade 43 %, 92 % and 54 %.
|
||||
* **Every billed quarter lands exactly on the cap and none over it**, where every
|
||||
quarter in the three biting rows used to finish over.
|
||||
* **The money.** At 1500/1200 the site buys **58 % MORE** energy, because a
|
||||
continuous 300 W trade beats a 2000 W trade that is cancelled for 57 % of the
|
||||
hour. At the two shipped-default rows it buys **2.2 % and 1.6 % less** — and in
|
||||
exchange the worst quarter drops from 59 W over the cap to 0 W over it. The
|
||||
capacity tariff bills the month's single worst quarter with no averaging and no
|
||||
forgiveness (specs.md:583), so that is not a trade-off, it is the ordering
|
||||
§10.1 asks for.
|
||||
* The remaining 1687.6 W at 3500/1800 is the opening **ramp**, not a cycle: the
|
||||
pack goes from 0 to 1700 W of charge in about ten seconds and the meter with
|
||||
it. One onset, no export, no reversal.
|
||||
* The last row is bit-identical before and after. **This card costs nothing on
|
||||
the installs it is not about.**
|
||||
|
||||
## 5. What this does NOT fix, with sizes
|
||||
|
||||
* **TEST-23 finding 1 — the export overshoot at the END of a trade** (356–938 W,
|
||||
≈0.37 × `max_w`) is untouched and is a different card. It happens when the
|
||||
cheap interval ends and the grid-following loop takes back an actuator that was
|
||||
holding a charge; nothing here changes the handover.
|
||||
* **A house whose load ALONE exceeds the cap** (`load > peak_cap_w`) still puts
|
||||
`wanted > 0`, still engages the guard, and still refuses the trade outright
|
||||
through the interlock — correctly, since you cannot buy in a quarter you are
|
||||
already over on. The loop then drives to grid = 0 rather than to grid =
|
||||
ceiling, which is more discharge than the guard asked for. Not swept here: all
|
||||
four (cap, load) pairs on the record have `load < cap`.
|
||||
* **`arbitrage.claim()` still files the SIZED number, but the ARBITER can clamp
|
||||
it further** and the strategy is not told. The reason line names the guard's
|
||||
headroom, not the arbiter's `bound_by`. Pre-existing; not widened.
|
||||
* **The container leg was not re-run.** `hadrive.py` needs `test23s3-addon`
|
||||
started, and that container is deliberately stopped because it is a second
|
||||
writer on the setpoint entity under measurement. The in-process rig's twelve
|
||||
transcribed lines are pinned by `test_arbitrage.py` 3b's AST check on
|
||||
`main.py`'s call site, which is the line this card adds to them.
|
||||
@@ -1,319 +0,0 @@
|
||||
# SAFETY-47 — what a shadow run can show, and what it cannot
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-47`.
|
||||
**Card:** SAFETY-47 — *"shadow mode's claims, and the number on the status
|
||||
row"*.
|
||||
**Nothing here reached the inverter.** No deployment, no `ha addons update`, no
|
||||
contact with 192.168.2.158 or 192.168.2.221. Everything below was measured in
|
||||
the CI image and on TEST-23 stage 3's closed-loop rig.
|
||||
|
||||
⚠️ **This is not a write-path defect and nothing in the write path was
|
||||
touched.** SAFETY-10's substitution, re-seed, authorisation gate and clamp are
|
||||
unchanged. What was wrong is what three merged documents **say** a shadow run
|
||||
can show you, and a number on the status row that can be **6.61×** what a live
|
||||
controller would command.
|
||||
|
||||
---
|
||||
|
||||
## 0. The four things a reader should take away
|
||||
|
||||
1. **Shadow mode cannot surface a closed-loop defect. Not partially — not at
|
||||
all**, and by the same construction that makes it safe. §1.
|
||||
2. **The "strategies are unaffected" exemption was true until SAFETY-45 merged,
|
||||
one commit before SAFETY-10.** Sizing the buy leg to the billed-quarter
|
||||
headroom made the strategy closed-loop. §2.
|
||||
3. **The recorded watts are the right answer to a question nobody asked, and
|
||||
they are CAVEATED rather than re-computed.** Re-computing would mean the
|
||||
recorded decision was no longer the decision the shipped path reached, which
|
||||
is the one thing shadow mode has to be able to say. §3 records what was
|
||||
rejected.
|
||||
4. **SAFETY-10 §6's committed evidence could never have failed, for two
|
||||
independent reasons, and closing either one alone closes nothing.** §4.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why the answer is "not at all", and not "not very well"
|
||||
|
||||
Shadow mode holds every actuator at `shadow.WIRE_W` and re-seeds the carried
|
||||
command to it at the end of each cycle. So every **measured** input — the
|
||||
meter, the billed-quarter accumulator behind it, the battery power, the state
|
||||
of charge — describes a plant that **did not respond**.
|
||||
|
||||
A closed-loop defect is, by definition, one whose stimulus **is** the plant's
|
||||
response to the controller. No response, no stimulus, nothing to see. This is
|
||||
not an implementation gap:
|
||||
|
||||
* feeding the guards a **simulated** plant instead is exactly what SAFETY-10 §4
|
||||
refuses for the sign watchdogs, and the reason generalises — a controller
|
||||
reacting to a plant that does not exist drifts further from reality every
|
||||
tick, and three cycles in its numbers describe nothing;
|
||||
* and SAFETY-10 §5 already conceded the same fact for the grid-following loop
|
||||
("one slew step, not a trajectory"). That was never a property of the loop.
|
||||
**It is the general case**, and the loop leg was simply where it had already
|
||||
been noticed.
|
||||
|
||||
**So the closed-loop answer comes from a closed-loop rig (TEST-23 stage 3) or
|
||||
from a live run. There is no third place, and saying that plainly is worth more
|
||||
than the three sentences it replaces.**
|
||||
|
||||
### Measured, on the rig, at a cap that binds
|
||||
|
||||
`peak_cap_w` 1500 / house 1200 / `max_w` 2000, one hour at a 5 s cycle. Of the
|
||||
three defects SAFETY-10 was written for:
|
||||
|
||||
| | visible? | measured |
|
||||
|---|---|---|
|
||||
| SAFETY-45's limit cycle | **no** | 28 charge onsets live, **1** in shadow. Grid −455.5 … +3200.0 W live, **flat at the 1200 W house** in shadow. Guard engaged 410/720 cycles live, **0/720** in shadow. (Taken with SAFETY-45 reverted — that is the defect being looked for.) |
|
||||
| SAFETY-46's SoC churn | **no** | the SoC moves **0.000000000 pp** in a shadow run. The SoC is the pack's integral of what the pack was commanded, and it was commanded 0 W. No SoC limit can be *reached* in a shadow run at all |
|
||||
| TARIFF-14's wrong quarter-hour | **yes**, and it is the only one | the price lookup's inputs are the clock and the price series, neither a function of the battery. Which hour, at what price, clearing what spread: **identical live and shadow on 720 of 720 charging cycles, at a binding cap** |
|
||||
|
||||
### What a shadow run genuinely is for
|
||||
|
||||
*Which* strategy wins, on *which* hour, at *which* price, for *which* reason,
|
||||
against which limits — the wrong-quarter-hour class, a trade armed on a day it
|
||||
should not be, a refusal whose sentence names the wrong subsystem, a clamp that
|
||||
would have engaged, a standing failsafe latch. That is a real and useful set,
|
||||
and it is the set the documents now claim.
|
||||
|
||||
---
|
||||
|
||||
## 2. The exemption, and exactly how much of it survives
|
||||
|
||||
All three documents carried a version of:
|
||||
|
||||
> The **strategies** are unaffected — price arbitrage, the capacity guard and
|
||||
> maintenance all ask for a fixed power **from inputs that do not depend on
|
||||
> what the battery did**.
|
||||
|
||||
**True before SAFETY-45, false after it.** SAFETY-45 makes the buy leg
|
||||
`min(asked, headroom)`, and
|
||||
|
||||
```
|
||||
headroom = (900 / remaining) x (ceiling - projected)
|
||||
```
|
||||
|
||||
where `projected` reads the **open quarter's accumulator** — the meter — which
|
||||
is a function of what the battery did earlier in that same quarter. SAFETY-45
|
||||
merged one commit before SAFETY-10, so the exemption was stale on the day it
|
||||
was written.
|
||||
|
||||
**What survives, and it is now graded rather than asserted:**
|
||||
|
||||
> **Which** hour a strategy picks, and **why**, are unaffected — the clock and
|
||||
> the price series are not functions of the battery.
|
||||
> **How hard** it trades is not.
|
||||
|
||||
⚠️ **The peak guard is not in the surviving half either.** Every term of
|
||||
`wanted = batt_w + excess` is measured, so in a shadow run the guard engages
|
||||
only if the *house alone* would put the quarter over the ceiling. Its
|
||||
**ceiling** and its **reasons** are worth reading from a shadow run; its
|
||||
**engagements** are not evidence of anything.
|
||||
|
||||
---
|
||||
|
||||
## 3. The number on the status row — decided, and what was rejected
|
||||
|
||||
At `peak_cap_w` 1500 under a 1200 W house the status row read **"WOULD HAVE
|
||||
COMMANDED −2 000 W"**. A live controller commands **−302.4 W**. Measured on the
|
||||
shipped build:
|
||||
|
||||
| cap / house | live commanded | shadow recorded | over by |
|
||||
|---|---|---|---|
|
||||
| 1500 / 1200 | −302.4 W | **−2000.0 W** | **6.61×** |
|
||||
| 3500 / 3000 | −502.6 W | −2000.0 W | 3.98× |
|
||||
| 3500 / 2000 *(shipped cap)* | −1502.6 W | −2000.0 W | 1.33× |
|
||||
| 3500 / 1800 | −1702.6 W | −2000.0 W | 1.17× |
|
||||
| 15000 / 350 | −2000.0 W | −2000.0 W | 1.00× |
|
||||
|
||||
⚠️ **The arithmetic is not wrong.** `900/remaining` is correct for a meter that
|
||||
is genuinely under the ceiling, and in a shadow run the meter genuinely *is*
|
||||
under it, because the battery is not charging. **The recorded number is the
|
||||
right answer to a question nobody asked** — and the last row of that table is
|
||||
the fixture SAFETY-10's own evidence was taken on, which is why nothing
|
||||
reported it.
|
||||
|
||||
### What shipped
|
||||
|
||||
1. **Unconditionally**, the row and the log line stop forecasting: the watts
|
||||
are introduced as what would have been commanded **ON THIS CYCLE**. No
|
||||
number a shadow run records is a prediction of a live run, so no state
|
||||
should read like one.
|
||||
2. **Conditionally**, whenever a **measured** billed quarter sized the number
|
||||
being recorded, both surfaces carry `shadow.SIZED_FROM_METER`:
|
||||
|
||||
> ⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE
|
||||
> HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same
|
||||
> trade against a meter the battery is moving, so this is an UPPER BOUND and
|
||||
> not a prediction; how far under it a live run lands is a closed-loop
|
||||
> question and a shadow run cannot answer it.
|
||||
|
||||
⚠️ **"Upper bound", and not "smaller", and the difference was measured.**
|
||||
Charging only *adds* offtake, so a live `projected` is never below a shadow one
|
||||
and the headroom never above it — with **equality at the start of every
|
||||
quarter**, where `900/remaining` is 1.0 and neither plant has moved. Measured
|
||||
pairwise over 3600 charging cycles in five configurations: **0 cycles below,
|
||||
730 exactly equal.** A sentence reading "smaller" would have been false on
|
||||
every one of those 730, and the first draft of this card said "smaller".
|
||||
|
||||
⚠️ **And it names no factor.** How far under the bound a live run lands is a
|
||||
property of the site. A number invented for the row would be a second helping
|
||||
of the defect the sentence exists to disclose.
|
||||
|
||||
⚠️ **The predicate is NOT keyed on whether the trade was capped, and that is
|
||||
the trap this card nearly walked into.** `arbitrage`'s own sentence says *"SIZED
|
||||
BY THE CAPACITY-TARIFF GUARD"* only when the headroom came out **below** the
|
||||
ask — which in a shadow run is the **start** of the quarter, where the two runs
|
||||
still agree to the float. By the end, `900/remaining` has multiplied the
|
||||
headroom **past** the ask, the trade is **not** capped, the sentence is silent,
|
||||
and the recorded number is at its **most** wrong. A caveat keyed on it would be
|
||||
quietest exactly where it is needed loudest. `arbitrage.sized_from_meter()` is
|
||||
keyed on the headroom having been **measured**, so it fires on both.
|
||||
`test_arbitrage.py` part 3d-8 drives the inversion.
|
||||
|
||||
### Rejected, and why
|
||||
|
||||
* **Re-compute the number so it does not amplify** — e.g. record
|
||||
`ceiling − projected` (gain 1.0) instead of the sized figure. It matches the
|
||||
live answer almost exactly at this cap, and it is the **worst** of the
|
||||
options. It would be a **shadow-only sizing**: the recorded decision would no
|
||||
longer be the decision the shipped path reached, while the arbiter, the
|
||||
clamp, SAFETY-28's gate, the action log and MQTT all ran on the real one. A
|
||||
second copy of the sizing arithmetic living in the display layer is
|
||||
MUTATION.md's transcribed-predicate defect on an operator-facing surface, and
|
||||
it generalises to no other strategy. SAFETY-10's whole safety argument is
|
||||
that shadow mode is a **substitution at the wire**, not a second controller;
|
||||
this would have made it a second controller everywhere except the wire.
|
||||
* **Record nothing where the sizing depends on measured state.** Tempting —
|
||||
"no number beats a number 6.61× out". Rejected on three counts: it blanks the
|
||||
row exactly when arbitrage is trading, which is when a commissioning engineer
|
||||
most wants it; the action log's edge key is the rounded watts, so blanking
|
||||
them either floods or silences the log; and a surface that flickers between a
|
||||
number and a dash is harder to read than a number with a standing caveat.
|
||||
The narrow version of this — dropping the watts only — was the closest
|
||||
runner-up.
|
||||
* **Record the unsized ask alongside the sized one.** It adds a number without
|
||||
adding a warning. The unsized ask is `max_w`, a constant the operator
|
||||
configured; on the shipped 3500/2000 row both figures are 2000 W, so the pair
|
||||
is exactly as misleading as the single figure was.
|
||||
* **A caveat on every cycle in every state.** Rejected as the *whole* answer,
|
||||
accepted as half of it: the unconditional part is a two-word narrowing of the
|
||||
verb ("on this cycle"), not a warning, and the warning proper is conditional
|
||||
so that it still means something when it appears.
|
||||
|
||||
---
|
||||
|
||||
## 4. SAFETY-10 §6's evidence, and why it could not fail
|
||||
|
||||
§6 tabulated `arb_power_w` −2000 W *"identical"*, `arb_reason` *"identical,
|
||||
verbatim"*, arbiter winner *"identical"*. Two independent reasons it could
|
||||
never have gone red:
|
||||
|
||||
1. **the fixture is `peak_cap_w: 15000` against a 350 W house**, so the guard
|
||||
never engages and SAFETY-45's sizing is in the run and inert. ⚠️ **The
|
||||
harness's own comment says so** — *"at this scenario's `peak_cap_w: 15000`
|
||||
it is not binding anyway"* — which is MUTATION.md habit 4 written down
|
||||
beside the check it disables;
|
||||
2. **every identity row reads the FIRST charging sample.** At `elapsed = 0` the
|
||||
projection *is* the held level, so the two runs agree to the float there
|
||||
**even at a cap that does bind**.
|
||||
|
||||
⚠️ **AND THAT SECOND REASON IS THE ONE A REVIEWER CANNOT SEE FROM A DIFF.**
|
||||
Widening the fixture alone — the obvious fix — leaves the `[0]` comparison
|
||||
passing. Measured, not reasoned: at 1500/1200 the first charging cycle is
|
||||
−300.0000 W in **both** runs while 716 of the 720 cycles differ. Part 3d-2
|
||||
grades that agreement **as an expectation**, so nobody rebuilds a check on
|
||||
`[0]` believing it covers something.
|
||||
|
||||
This is habit 4's **twelfth** instance in this repository, its **second**
|
||||
inside a committed evidence artifact, and — like the seventh and the eleventh —
|
||||
it was in a **measuring instrument** rather than in a check.
|
||||
|
||||
§6 is re-taken at `peak_cap_w` 1500 / house 1200 / `max_w` 2000, across the
|
||||
whole run. Harness `/home/homelab/s47-mut/shadowloop47.py`.
|
||||
|
||||
---
|
||||
|
||||
## 5. The grader, so this class cannot recur
|
||||
|
||||
`test_arbitrage.py` part **3d** — placed beside part 3c, which is SAFETY-45's
|
||||
own closed-loop regression, and built on the **same** `s45_loop()` harness so
|
||||
the two runs share every line of shipped code and differ in **one argument**:
|
||||
|
||||
```
|
||||
s45_loop(sized=True) the live hour
|
||||
s45_loop(sized=True, shadow=True) the same hour, battery held at WIRE_W
|
||||
s45_loop(sized=False, shadow=True) SAFETY-45's own defect, watched from
|
||||
inside shadow mode
|
||||
```
|
||||
|
||||
Three lines of `s45_loop()` stand in for `Controller._write_unit()`'s
|
||||
substitution and `_shadow_settle()`'s record and re-seed. They are marked as a
|
||||
**transcription**; `test_clamp.py` part 8 is the check on the shipped
|
||||
placement, and it drives the real `Controller`. Everything that produces a
|
||||
number — the averager, the projection, the guard, the headroom, the strategy,
|
||||
the arbiter, the row and the log sentence — is the shipped one.
|
||||
|
||||
What 3d grades, in order: that the cap **binds** (the precondition the old
|
||||
evidence lacked); that the first charging cycle agrees **anyway**; that the
|
||||
whole run diverges, and by how much; that the record is an **upper bound**, and
|
||||
the two-point arithmetic that makes it one; what survives of the exemption;
|
||||
that SAFETY-45's limit cycle and the guard's engagements are **not** visible;
|
||||
the four rungs of `sized_from_meter()`, each reached with the other three clear
|
||||
(TEST-16); both operator surfaces; the `capped`-keying trap; and main.py's
|
||||
three call sites, pinned on the AST.
|
||||
|
||||
⚠️ **The section carries its own precondition into every check.** A mutation
|
||||
that stops either run trading left the lists empty, the first `min()` raised,
|
||||
the file died before part 4 and the run reported **zero reds — which reads
|
||||
exactly like a survivor**. That is FCST-06's shape and it was **driven**, not
|
||||
imagined. `s47check()` now fails every check in the section when the stimulus
|
||||
was not produced, and the one division in it answers `nan` rather than raising.
|
||||
|
||||
---
|
||||
|
||||
## 6. The mutation battery — 14 mutations, no survivors
|
||||
|
||||
Harness `/home/homelab/s47-mut/run.py`, backups inside it. Each run restores
|
||||
from backup, purges `__pycache__` **on the host**, and grades through the CI
|
||||
image with the tree bind-mounted `:ro`, so the interpreter cannot write a cache
|
||||
into the tree under test (TEST-15). Fingerprints are printed before the first
|
||||
run and re-checked after the last restore.
|
||||
|
||||
| # | mutation | file | Δ | reds |
|
||||
|---|---|---|---|---|
|
||||
| M1 | the row drops the caveat branch | `shadow.py` | −63 B | 2 |
|
||||
| M2 | **the trap**: `sized_from_meter()` keyed on the trade being CAPPED — silent where the record is multiples out | `arbitrage.py` | +33 B | 5 |
|
||||
| M3 | the CHARGE rung dropped — a sell carries a caveat §9.1 makes false | `arbitrage.py` | −30 B | 1 |
|
||||
| M4 | the measured-headroom rung dropped | `arbitrage.py` | −18 B | 1 |
|
||||
| M5 | the arbiter-winner rung dropped — a caveat on somebody else's number | `arbitrage.py` | −33 B | 1 |
|
||||
| M6 | `main.py` renders the row with `sized_from_meter=False` | `main.py` | −23 B | 1 |
|
||||
| M7 | `main.py` drops `winner=` — the rung exists and is never reached | `main.py` | −1 B | 1 |
|
||||
| M8 | `main.py` sizes the trade from a literal — SAFETY-45 out of the shipped path, which the hoist put at risk | `main.py` | −41 B | 2 |
|
||||
| M9 | the headroom assignment moves out of `cycle()` | `main.py` | −39 B | 1 |
|
||||
| M10 | `sized_from_meter` gains a `False` default (TEST-14) | `shadow.py` | +8 B | 1 |
|
||||
| M11 | `ON THIS CYCLE` deleted — the row forecasts again | `shadow.py` | −14 B | 1 |
|
||||
| M12 | `log_line()` drops the caveat — the page discloses, the record does not | `shadow.py` | −63 B | 2 |
|
||||
| M13 ⚠️ | `main.py` re-assembles the log sentence at the call site | `main.py` | **+0 B** | 1 |
|
||||
| M14 ⚠️ | `wanted = batt_w − excess` — the headroom stops being monotone in the battery and the row's UPPER BOUND sentence becomes false | `peakguard.py` | **+0 B** | 38 |
|
||||
|
||||
**Survivors: none.**
|
||||
|
||||
⚠️ **M13 and M14 are `+0 B`, which MUTATION.md names as the risky delta.** Both
|
||||
were confirmed on disk by `sha256` before the run started — the harness prints
|
||||
the fingerprint of every graded file and the hash moved for each — and every
|
||||
run is purged, so neither is at risk of the stale-`.pyc` failure.
|
||||
|
||||
⚠️ **Two findings from driving it, both reported rather than tidied away.**
|
||||
|
||||
* **M14's first form was aimed at the wrong direction and correctly survived.**
|
||||
`min` → `max` in the buy leg's sizing makes the shadow record *larger*, which
|
||||
is still an upper bound, so 3d-3b was right not to fail. The mutation that
|
||||
actually inverts the bound is the sign of the excess term in `wanted`. A
|
||||
survivor that is right is worth more than a red aimed at the wrong claim.
|
||||
* **M12 was a survivor before the fix it forced.** Deleting the caveat from the
|
||||
action-log line left **all 29 files green**: the only check that could see
|
||||
that line drives a fixture with `meter_source: off`, where no arbitrage
|
||||
decision — and therefore no caveat — is reachable. The sentence was an
|
||||
f-string inside `Controller._shadow_settle()`, i.e. a surface no check could
|
||||
grade. It is `shadow.log_line()` now, for the reason `words()` is a function,
|
||||
and M13 is the mutation that puts it back at the call site.
|
||||
@@ -1,551 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,110 +0,0 @@
|
||||
# SAFETY-52 - a battery that refuses is asked for 0 W
|
||||
|
||||
**Decided 2026-09-27 by the owner, on the phase A plan (option D3). All four
|
||||
questions answered yes.**
|
||||
|
||||
## The defect
|
||||
|
||||
At the BMS cut-out the battery gives ~0 W whatever it is asked. The saturation
|
||||
freeze (`control.py`, `frozen`) then holds the request where it stood, and its
|
||||
SAFETY-13 exit cannot fire, because a reading of ~0 against a large ask lies
|
||||
inside the interval a limit can produce. The request stood at +5000 W for
|
||||
11.87 h on 2026-09-06 and at +1940 W for 3 h 55 m on the bus on 2026-09-19. It
|
||||
moved again only when the grid error changed sign. SAFETY-48's lead cap only
|
||||
changes where `i_w` parks (one slew step ahead instead of at the rail). When
|
||||
the request moved after the reading went flat, the stale-reading gate won
|
||||
instead and ran SAFETY-59's ~70 s trip cycle.
|
||||
|
||||
## What was decided
|
||||
|
||||
1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages
|
||||
in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is
|
||||
below `REFUSED_BELOW_W` (100 W, the probe's own answer) against an ask beyond
|
||||
`saturation_w`. The drop to 0 skips
|
||||
the slew limit (it only removes an ask nobody meets). The direction is
|
||||
carried like `sat_count` (`refused=`, `Decision.refused`).
|
||||
2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`,
|
||||
`PROBE_S`; twice `BATT_OBLIGED_S`). A reading of at least 100 W in the held
|
||||
direction ends the refusal, during a probe or not. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm
|
||||
the gate. The clock is main.py's `now_mono`; `compute()` stays clockless.
|
||||
3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for
|
||||
control (`Controller._refusal_from_stale`). This is what stops the ~70 s
|
||||
cycle. The log keeps SAFETY-56's two-explanation wording.
|
||||
4. **No SoC in the loop.** SAFETY-25 Option A stands. Nothing reads SoC.
|
||||
|
||||
Other exits: the loop wants the other direction (charge is accepted at the
|
||||
floor), control stopped or started, another writer on the wire (the arbiter
|
||||
reseed). A gap in the readings keeps the refusal, because the gate's own stop is
|
||||
what seeds it.
|
||||
|
||||
**One inverter only.** At N>1, OPT-09's `pack` limit already closes an empty
|
||||
battery's discharge end and moves its share. There, a one-unit gate trip must
|
||||
not refuse the site. `compute()` is passed `refused=None` there, and that path
|
||||
is release/1.0's law unchanged (the `i_w=None` equivalence sweep in
|
||||
`test_control.py` still holds).
|
||||
|
||||
## Evidence
|
||||
|
||||
`test_refusal.py` drives the real Controller and gate (the 09-06 and 09-19
|
||||
shapes, recovery with no sign change, the probe cadence, every exit).
|
||||
`tools/replay_refusal.py` replays the 16-day capture, same model as
|
||||
`replay_recorded_loop.py`, and should be read for deltas:
|
||||
|
||||
| plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min |
|
||||
|---|---|---|---|---|---|---|
|
||||
| fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W |
|
||||
| fitted | new | 130 (133 after review) | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W |
|
||||
| offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W |
|
||||
| offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W |
|
||||
|
||||
**Traced in review (#141).** The +22 W worst quarter is the old law's worst
|
||||
quarter too. There the old law stands at +2060 W on a pack giving 0 W, and the
|
||||
new law asks 0 W, which the fitted plant answers with its -22 W offset: 22 W
|
||||
charged from the grid. The fit above the floor reads `batt = cmd - 28 W`, so a
|
||||
real inverter asked 0 W may do the same. Of the 71 new-law trips with no
|
||||
old-law trip within 2 min, 59 come 85-97 s after an answered probe. The offset has lifted the modelled pack
|
||||
just above its floor, the probe is answered, control ramps, the pack empties
|
||||
again mid-ramp, and the gate trips, which re-seeds the hold. The other 12
|
||||
follow an other-direction exit the same way. On a site this is one
|
||||
`STALE_READING` ERROR line per answered probe at most, and only on a battery
|
||||
that answers a probe and then cuts out again. Catching the re-cut before the
|
||||
gate needs a faster engage than `SETTLE_CYCLES`, which accuses a healthy slow
|
||||
battery, so it is left to SAFETY-58/59. After the review changes below the
|
||||
fitted-plant row reads 133 trips (the delivery exit now also catches answers
|
||||
that arrive after the probe window); the other figures are unchanged. Rejected alternatives and their figures are in
|
||||
the phase A plan: bounding the integrator by demand made the worst quarter
|
||||
+541 W worse or cycled the gate 3368 times, and an SoC envelope needs the
|
||||
configured floor to be right and needs SoC.
|
||||
|
||||
## Review changes (#141)
|
||||
|
||||
- **"Gives nothing" is below 100 W, not below 500 W.** At `saturation_w` a
|
||||
battery derated to 100-499 W (cold, a tapering battery) was held at 0 W for
|
||||
~93 % of the time: 300 W derated gave 86 Wh in 4 h against 1 200 Wh on the
|
||||
old law. The capture has 0.11 h in that band, so the replay does not move.
|
||||
- **Any delivery ends the hold, not only an answered probe.** With
|
||||
`meter_poll_s: 1` the 12 cycles last 12 s, less than a healthy battery's
|
||||
reading lag (up to `BATT_OBLIGED_S`, 17 s). That engaged on a healthy step and
|
||||
held it for 15 min. Now the late reading ends it within seconds (2 s held in
|
||||
`test_refusal.py` part 5b).
|
||||
- **N>1 is stated as a limit in DOCS.** The pack limit there covers an empty
|
||||
battery only when its cut-out level is configured right.
|
||||
|
||||
## Not covered
|
||||
|
||||
A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a
|
||||
genuine limit (SAFETY-13 case a) and still holds its request.
|
||||
|
||||
## What SAFETY-58 and SAFETY-59 must take into account
|
||||
|
||||
- **SAFETY-58** (the first read after a rest trips at once): a probe never arms
|
||||
the gate (200 < 250 W), but the refusal seeding reads the trip. If 58 delays
|
||||
the first verdict, the seed arrives later. The 09-19 shape then needs the
|
||||
compute-side engagement (12 cycles) to win, which it does only while the
|
||||
request stands still. Keep `test_refusal.py` part 4 green.
|
||||
- **SAFETY-59** (DOCS says a ~70 s pattern proves a failed register): with this
|
||||
change a refusing battery no longer produces the pattern at one inverter. The
|
||||
DOCS text should say so, and that the `battery refuses` reason is the
|
||||
discriminator the add-on now has. At N>1 the old pattern can still occur.
|
||||
- If either changes `MOVE_W`, `PROBE_W` must stay below it.
|
||||
`test_refusal.py` part 2 checks the inequality.
|
||||
@@ -1,181 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,388 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,106 +0,0 @@
|
||||
# SAFETY-61 — the control task's death ends the process
|
||||
|
||||
## 0. The defect
|
||||
|
||||
`amain()` started `run_control()` as a bare task and waited on `stop`. Nothing
|
||||
watched the task. An uncaught exception in `read_inputs()`, `cycle()` or
|
||||
`publish()` ended the task silently, and `stop` was never set. The web UI, P1
|
||||
and tariff tasks kept running, and the Supervisor saw a healthy container. The
|
||||
traceback appeared only at shutdown. The ESP32's layer-1 veto zeroed the
|
||||
inverter about 30 s later, so the hardware was safe. The pack then sat at 0 W
|
||||
indefinitely while the add-on showed green.
|
||||
|
||||
`test_control_task.py` reproduces this on the pre-fix tree. The child process
|
||||
reaches its 40 s deadline, has written no 0 W, and has logged nothing.
|
||||
|
||||
## 1. Decision: exit with status 70 after the ordinary wind-down
|
||||
|
||||
A done-callback on the control task (`control_task_done()`) handles the death.
|
||||
It logs the traceback at CRITICAL at the moment of death, then sets `stop`.
|
||||
`amain()` then runs the same wind-down that SIGTERM runs (`shutdown()` →
|
||||
`command_site_zero()`, then cancel, then clean up). After that it raises
|
||||
`ControlTaskDied`, and `main()` turns that into `SystemExit(70)` (sysexits
|
||||
EX_SOFTWARE). A normal stop and `run_control()`'s own refusal path both set
|
||||
`stop` before the task ends, so the callback ignores them. The refusal still
|
||||
exits with 78.
|
||||
|
||||
**Why not restart the loop in the same process:**
|
||||
|
||||
* `cycle()` writes controller state before its awaits: `cycle_at`,
|
||||
`peak_guard`, `allocation`, then `target`/`reason`, then the writes. An
|
||||
exception between those steps leaves the controller half-updated. A loop
|
||||
restarted in the same process would continue from a state that no boot ever
|
||||
produces. A process restart rebuilds the controller from the Store, which is
|
||||
the path every boot already runs.
|
||||
* An unbounded retry is the invisible spin the card forbids. A bounded retry
|
||||
would reimplement the Supervisor watchdog's own throttle: 10 restarts per
|
||||
30 min, and 5 attempts with backoff (`apps/const.py`).
|
||||
|
||||
## 2. What happens after the exit, verified rather than assumed
|
||||
|
||||
* **Exit status reaches Docker.** The add-on image (s6-overlay, `init: false`,
|
||||
`CMD /run.sh`) was run with `exec python3 -c 'raise SystemExit(70)'`.
|
||||
`docker inspect` gave ExitCode 70. The same test with exit 0 gave 0, and an
|
||||
uncaught exception gave 1.
|
||||
* **Supervisor 2026.08.0** (`ghcr.io/home-assistant/amd64-hassio-supervisor`,
|
||||
source read from the image):
|
||||
* A non-zero container exit is recorded as `ContainerState.FAILED`
|
||||
(`docker/monitor.py`). The Supervisor logs *"App … exited with non-zero
|
||||
exit code 70"* at ERROR (`apps/app.py container_state_changed`).
|
||||
* The Supervisor restarts the add-on **only if the per-install Watchdog
|
||||
toggle is on**. `watchdog_container()` returns when `not self.watchdog`.
|
||||
The toggle defaults to **off** (`apps/validate.py`:
|
||||
`vol.Optional(ATTR_WATCHDOG, default=False)`).
|
||||
* Add-on containers get **no Docker restart policy**. The only
|
||||
`restart_policy=` in the Supervisor belongs to the observer.
|
||||
* `boot: auto` in `config.yaml` only controls starting at host boot. The
|
||||
`watchdog:` key in `config.yaml` is a health-check URL, not a restart
|
||||
policy, and this add-on does not set it.
|
||||
* **So on a default install the add-on stays stopped after this exit.** The UI
|
||||
shows it as stopped, its log holds the traceback, and the Supervisor log holds
|
||||
an ERROR line. The pack is at 0 W, which is the same physical state as before
|
||||
this card. The difference is that 0 W is written immediately instead of after
|
||||
the ~30 s veto, and the add-on no longer shows green. With the Watchdog toggle
|
||||
on, the Supervisor restarts the add-on.
|
||||
* **Not verified:** an actual restart by a running Supervisor. That would need a
|
||||
live Supervisor, and the only one available is the live house.
|
||||
|
||||
## 3. Left open for the owner
|
||||
|
||||
* Whether `DOCS.md` should tell installers to enable Watchdog. DOCS.md is not
|
||||
this card's file.
|
||||
* Setting the Watchdog toggle from inside the add-on (`/addons/self/options`)
|
||||
was rejected here, because it would change a user setting without the user
|
||||
knowing.
|
||||
|
||||
## 4. Scope: the siblings, and why they are not supervised
|
||||
|
||||
* `run_tariff_refresh()` and the P1 HTTP and HA transports already catch
|
||||
`Exception` inside their loops by design, and the values they feed are
|
||||
age-bounded downstream.
|
||||
* The P1 MQTT source *returns* on purpose when it is misconfigured. Making every
|
||||
long-lived task's end exit the process would turn that deliberate fail-safe
|
||||
into a restart loop.
|
||||
* Only the control task is supervised.
|
||||
|
||||
## 5. `hass.py`: an undecodable body is an absence
|
||||
|
||||
A non-JSON `200` served as `text/html` was **already** treated as an absence,
|
||||
because aiohttp's `ContentTypeError` is a `ClientError`. The reads that actually
|
||||
escaped were these three:
|
||||
|
||||
* a malformed body labelled `application/json`, which raises `JSONDecodeError`
|
||||
(a `ValueError`);
|
||||
* a JSON body that is not an object, which raises `AttributeError` in
|
||||
`number()` and `limits()`;
|
||||
* an undecodable error body in `call()`, which raises `UnicodeDecodeError`.
|
||||
|
||||
All three were measured against aiohttp 3.13.5. The first two now read as
|
||||
`None`, and the third is now a failed write (`False`).
|
||||
|
||||
## 6. Status row
|
||||
|
||||
`checks()` now starts with a *control loop* row showing how long ago the loop
|
||||
last completed a pass. The row turns red once that age exceeds
|
||||
`FIRMWARE_INGEST_VETO_S`. The heartbeat is sent from this loop, so past that age
|
||||
layer 1 has zeroed the inverter.
|
||||
@@ -1,104 +0,0 @@
|
||||
# SAFETY-73 - a battery that refuses, with two or more inverters
|
||||
|
||||
**Decided 2026-09-27 by the owner, on the phase A plan (option R). All four
|
||||
new questions answered yes. SAFETY-52's four decisions carry over.**
|
||||
|
||||
## The defect
|
||||
|
||||
SAFETY-52 held a refusing battery at 0 W at one inverter only. At N>1
|
||||
(`main.py` passed `refused=None`) nothing closed a unit that gives ~0 W while
|
||||
its state of charge reads above its configured cut-out, so the allocator kept
|
||||
handing it its weighted half. Measured on the real `Controller`, two units,
|
||||
house 3000 W, A cut out:
|
||||
|
||||
| register | law | B gives | grid | A's gate trips / h |
|
||||
|---|---|---|---|---|
|
||||
| flat -4 W | old | 533 W | +2471 W | 46 (each stops the whole site) |
|
||||
| flat -4 W | new | 2000 W (its rating) | +1004 W | 1 |
|
||||
| jitter | old | 1910 W | +1094 W | 0 |
|
||||
| jitter | new | 2000 W | +1004 W | 0 |
|
||||
|
||||
House 1200 W, which B alone carries: old +671 W (flat) and +284 W (jitter)
|
||||
standing import, new -16 W. Both cut out: old keeps asking two empty batteries
|
||||
(+1910 W each on the jitter shape), new holds the site at 0 W.
|
||||
|
||||
## What was decided
|
||||
|
||||
1. **A refusing unit is closed in that direction** by its own P_SAFETY unit
|
||||
claim, source `refused`. It engages when the unit gives under
|
||||
`REFUSED_BELOW_W` (100 W) for `SETTLE_CYCLES` (12) cycles, spanning at least
|
||||
`REFUSED_SPAN_S` (22 s), in one direction while asked more than `PROBE_W`
|
||||
(200 W), or when its stale-reading gate
|
||||
trips on a held reading under 100 W. The allocator then hands its share to
|
||||
the others within their own limits (owner decision 1). It is a hard unit
|
||||
limit that always intersects, so OPT-11's P_MODE layer fits inside it.
|
||||
2. **It binds every strategy's ask on that unit** (decision 2): peak guard,
|
||||
arbitrage and maintenance are placed around it like the loop.
|
||||
3. **A refusal to give power above the configured cut-out** logs INFO plus one WARNING ending
|
||||
in `REFUSES_ABOVE_CUT_OUT` (decision 3). SAFETY-42/43 no longer report such a
|
||||
unit: its share is 0 W, and `account()` treats it as parked.
|
||||
4. **All units refusing the same way** pass that sign to `compute()`, so the
|
||||
site holds 0 W with SAFETY-52's law (decision 4). Probes go one unit at a
|
||||
time.
|
||||
5. **Probe:** every 15 min for 34 s the claim becomes a pin at +-200 W and the
|
||||
others carry the rest. The pin stands only while the resolved site target
|
||||
asks at least 200 W its way; otherwise it would make a sibling take the
|
||||
opposite direction, so it falls back to the closed limit for that cycle.
|
||||
6. **Exits:** a reading of 100 W or more in the held direction (probe or not);
|
||||
100 W or more taken the other way for 12 cycles (charge at the floor; one
|
||||
cycle of the loop overshooting through zero is not enough); stop or start.
|
||||
On every exit the site target and integrator restart from the watts on the
|
||||
wire, or the reopened unit is handed a parked remainder at once.
|
||||
|
||||
**Why 200 W and not `saturation_w`.** A unit's share is often small: a 400 W
|
||||
night load split two ways asks 200 W of each. With 500 W the empty battery
|
||||
kept a phantom share that SAFETY-43's relative arm then reported as not
|
||||
delivering, the ERROR decision 3 replaces. Shares of 200 W or less that give
|
||||
under 100 W are "too small to call" in `account()`: the sibling covers the
|
||||
meter and nothing is asked of the empty battery that matters.
|
||||
|
||||
**Why one direction throughout.** On TEST-19's flapping family a command that
|
||||
swaps sign each cycle reads ~0 through the plant's lag: 42 engagements in 21
|
||||
no-fault runs without the rule, 0 with it.
|
||||
|
||||
**N=1 is unchanged.** Every SAFETY-73 path is gated on `len(inverters) > 1`.
|
||||
`test_refusal_units.py` part 6 runs SAFETY-52's whole suite with every
|
||||
SAFETY-73 method made to raise.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `test_refusal_units.py`: cases a, b and c on both register shapes against
|
||||
the old behaviour, probes and their spacing, one-at-a-time probing, the
|
||||
recovery reseed, each exit, the small-ask and small-site-target cases, the
|
||||
WARNING, the status row and shadow mode. Mutations U1-U13 in its docstring.
|
||||
- **No false engagements.** TEST-19's no-fault campaign (admin/goodwe
|
||||
`sim/delivery_window.py` families, 2640 runs at N=2 and 2640 at N=1, every
|
||||
unit executing its share) graded with the engage clause: 0 engagements, the
|
||||
longest qualifying streak 5 cycles against 12. The grader is
|
||||
`goodwe-session/scratch_s73/s73_nofault.py`.
|
||||
- **Recovery.** Case a, A recovering into a house that fell below the parked
|
||||
target: worst grid +0 W with the reseed. Phase A measured -1059 W without the
|
||||
reseed and the site hold.
|
||||
|
||||
## Review changes
|
||||
|
||||
- **The evidence must span 22 s, not only 12 cycles.** At a 1 s meter the 12
|
||||
cycles were 11 s, shorter than a healthy reading's lag (dead time plus a
|
||||
10-17 s publish). The reviewer's harness (real `Controller`, both units with
|
||||
6 s dead time and a 13-17 s publish, load steps 300/3000 W every 2 min) then
|
||||
refused both healthy units 10-16 times an hour, and each time the site held
|
||||
0 W: +50-85 Wh import an hour and a +4.6 kW grid peak against +3.7 kW without
|
||||
the refusal. At a 2 s tick there were none. `REFUSED_SPAN_S` is
|
||||
`(SETTLE_CYCLES - 1) * LOOP_TICK_S`, so 2 s and slower ticks are unchanged.
|
||||
- **The cut-out WARNING is for a refusal to give power only.** A full battery
|
||||
that stops taking charge is always above its cut-out level, and the WARNING
|
||||
told the installer to check a setting that has nothing to do with it.
|
||||
|
||||
## Not covered
|
||||
|
||||
- A unit that gives a steady part of its ask (100 W or more) is a limit, as at
|
||||
N=1. SAFETY-42/43 still report it.
|
||||
- `pack_permissions` stays SoC-based, so the peak guard may size a shave with a
|
||||
refusing battery counted. The shortfall shows as `unmet_w`.
|
||||
- A wedged unit (HW-01) reading ~0 W is now shown as a refusal, not reported as
|
||||
not delivering. Its share still moves, which is the gain SAFETY-42 measured.
|
||||
@@ -1,43 +0,0 @@
|
||||
# SAFETY-78 - a maintenance drain at two inverters
|
||||
|
||||
**Status: DECIDED 2026-09-29.** Fixes MAINT-05 §6 (OPT-11 P1 F1, UX-13 P3
|
||||
check 5, OPT-11 P3 F4). Tests: `test_safety78.py`.
|
||||
|
||||
**The defect.** At N>=2 the drain pins its pack (a unit SET at
|
||||
`P_MAINTENANCE`) and the site target is split around the pin. Whenever the
|
||||
site target was below the pin, the other pack had to charge. The allocator
|
||||
refuses opposite directions (SAFETY-25 §0a, SAFETY-31), so the site went to
|
||||
0 W and logged `ARBITER: REFUSED` every cycle. That covers a house below the
|
||||
drain rate, a grid-charge trade, and a house above it too: after a refusal the
|
||||
loop restarts from 0 W and its 400 W slew step is below the pin again.
|
||||
|
||||
**The fix.** While a pack drains at N>=2, main.py files a site limit
|
||||
`drain`, `lo` = what the pin really places (`allocator.pin_w()`: the drain
|
||||
rate inside that pack's own hard limits), at `P_MAINTENANCE`. So the site
|
||||
target is never below the pin and the other pack only adds power in the same
|
||||
direction. Opposite splits stay forbidden.
|
||||
|
||||
- The order is the arbiter's own: a safety SET (100) outranks the limit and
|
||||
the pin, so everything parks; the peak floor (50) and the drain floor are
|
||||
both applied, so the higher wins; a trade or the loop below the drain is
|
||||
lifted to it.
|
||||
- Below the drain rate the drained pack exports the rest, as the drain does at
|
||||
N=1 (`maintenance.py`: "forced discharge, exports the surplus"). Holding it
|
||||
to the house load would make N=2 drain differently from N=1; that is an
|
||||
owner question, not this fix.
|
||||
- A draining pack that can place nothing (a refusal, a stale link) adds no
|
||||
floor, and the wait is logged once per episode.
|
||||
- With battery modes on, no pack is a grid charger while another drains
|
||||
(`modetrade.decide(draining=)`, why `cycle`), and the other battery's card
|
||||
says why (`other_cycle`). Not while the drain waits: no floor is filed
|
||||
then, so the other battery may charge from solar.
|
||||
- Not filed at N=1, where the drain is the site SET itself: byte-identical
|
||||
(`tools/safety78_identity.py`, which hashes the claims too).
|
||||
|
||||
**Not changed (measured in the PR #154 review, follow-ups).**
|
||||
- At N=1 a drain (60) outranked the peak floor (50): a 5000 W house imported
|
||||
3000 W against a 2500 W limit. Fixed by [[SAFETY-83]].
|
||||
- At N=2 a maintenance CHARGE in a cheap window has the same stall: the
|
||||
charging pack's `hi = -floor` against the loop asking the other to give is
|
||||
refused on 141 of 261 cycles. The mirror fix is a site `hi`, which can meet
|
||||
the peak floor's `lo` (0 W), so it is not this card. Fixed by [[SAFETY-84]].
|
||||
@@ -1,41 +0,0 @@
|
||||
# SAFETY-79 — the peak guard keeps a margin below the peak limit
|
||||
|
||||
**Status: DECIDED 2026-09-28 (owner decision).**
|
||||
|
||||
## Decision
|
||||
|
||||
The peak guard aims `peak_margin_w` under the limit it holds: under
|
||||
`peak_cap_w`, or under a month peak already banked above it
|
||||
(`quarter_target_w()`). `peak_margin_w` is a setting: default 50 W, range
|
||||
0-500 W, whole watts. An install without the key gets 50.
|
||||
|
||||
**Why (owner):** the guard aimed exactly at the cap, and in the OPT-11 P1 test
|
||||
a quarter banked 3501.9 W against a 3500 W limit (OPT-11-P1-test-report.md,
|
||||
check 4). A peak even 1 W over still sets the billed peak. It is a setting and
|
||||
not a constant because the add-on is sold to several sites.
|
||||
|
||||
**Banked peak (review, 2026-09-29):** the PR first aimed at a banked month
|
||||
peak as it stood. That can bank a few W over it, which raises the bill the
|
||||
same way; and a peak banked between cap - margin and the cap (common in the
|
||||
month an install upgrades, since the old guard held quarters at the cap) was
|
||||
aimed at exactly, so one overshoot went over the cap. The margin now applies
|
||||
under it too. Cost: up to `peak_margin_w` of a peak already paid for goes
|
||||
unused (more discharge, less charge headroom); no bill cost. Undo is one line
|
||||
in `quarter_target_w()`.
|
||||
|
||||
## What uses which number
|
||||
|
||||
- **the aim:** `peakguard.decide()` (the claim's floor, and so arbitrage's
|
||||
`charge_headroom_w()` and OPT-11's give-way, which read it), and, as cap -
|
||||
margin, `shaping_suspended()` and `maintenance_charge_floor()` (the forecast
|
||||
checks, via `Controller.peak_aim_w()`).
|
||||
- **the real cap (reporting and billing):** `PeakTracker` (month peak,
|
||||
headroom, 80 % warning), the decision's `ceiling_w` and every notice and
|
||||
status row built from it, Home's limit, and `validate.config_notes()`.
|
||||
|
||||
## Checks
|
||||
|
||||
`peak_margin_w: 0` is byte-identical to ed739d4 (test_peak_margin.py part 1,
|
||||
digests from tools/safety79_identity.py, which also runs a maintenance charge
|
||||
with a forecast). validate.py rule 12 refuses a value outside 0-500, not
|
||||
whole, or not below `peak_cap_w`.
|
||||
@@ -1,63 +0,0 @@
|
||||
# SAFETY-81 — no false "Load too high for battery" alarms
|
||||
|
||||
**Status: 2026-09-29, owner go for the card. Review kept the 10 W tolerance
|
||||
as a constant and added the 10 s hold.**
|
||||
|
||||
## Defect
|
||||
|
||||
The SAFETY-79 test (SAFETY-79-test-report.md, F1/F2) saw 6 false alarms in 7.
|
||||
The alarm fired whenever `decide()` clamped its floor to the rating
|
||||
(`saturated`). Three things set that while the quarter landed on target:
|
||||
|
||||
- **Double count.** `decide()` sizes from the held meter reading plus the
|
||||
battery reading. When the meter sample predates the battery's last increase,
|
||||
the increase is counted twice. It happens under noise, and at each quarter
|
||||
start, where the battery is back while the held sample is still the spike.
|
||||
- **The aim, not the cap.** `saturated` is graded against cap − margin, so at
|
||||
margin 50 noise needs 50 W less to trip it (F2).
|
||||
- **Last seconds.** The gain 900 / remaining turns a few W over the aim into
|
||||
thousands of W wanted, with the battery far from its rating.
|
||||
|
||||
## Decision
|
||||
|
||||
The alarm needs `saturated` and `peakguard.load_too_high()`: with the battery
|
||||
at full power from now on, the quarter still ends more than `ALARM_MISS_W`
|
||||
(10 W) over the real limit (`ceiling_w`). That is the miss itself, without the
|
||||
gain. The battery reading it uses is the lower of this cycle's and the last
|
||||
one's. It must hold for `ALARM_HOLD_S` (10 s). At most one notification per
|
||||
quarter, because near the threshold the test flickers with the meter; the
|
||||
ERROR log line is written for every episode.
|
||||
|
||||
**Why 10 W:** at margin 0 the guard itself lands up to +5.7 W over (SAFETY-79
|
||||
test, check 5), and those misses are real but tiny; the alarm would fire in
|
||||
the last second of most guarded quarters. The margin (default 50) is the fix
|
||||
for them. Cost: a real miss of 10 W or less is not alarmed; at a capacity
|
||||
tariff of the order of EUR 50 per kW a year, 10 W in one month is cents.
|
||||
Review measured +13 W on a 10 kW two-unit site at margin 0, not saturated, so
|
||||
no alarm: the tolerance only matters when the battery is at its rating. A
|
||||
constant, not an option: `peak_margin_w` is the owner's knob.
|
||||
|
||||
**Why the hold (review):** the test assumes the load now lasts the rest of
|
||||
the quarter. A load over cap + battery for ~5 s every 30 s (`sine4500_fast`,
|
||||
a cycling hob or heater) alarmed at each quarter start while the quarter
|
||||
landed under the aim. The hold stops that.
|
||||
|
||||
**Cost:** a real alarm comes 10-12 s later than on 5a4cdc2 (`step6500` at
|
||||
1211 s, not 1201 s); a slow ramp 20 s later (`ramp6500`), as it waits for the
|
||||
real cap. While the battery refuses or its reading is lost, the alarm waits
|
||||
until the quarter is lost even at full battery (27-60 s later in review runs);
|
||||
those faults have their own alarms. A load over cap + battery for more than
|
||||
10 s still alarms even if the quarter later lands under. `ALARM_HOLD_S = 0`
|
||||
removes the hold.
|
||||
|
||||
## Not changed: the 1 s spike itself
|
||||
|
||||
The cycle after a quarter boundary and before the new quarter's first meter
|
||||
sample still sees 0 s left, files no claim, and an Off battery drops to 0 W for
|
||||
one cycle. That is the same edge tick as OPT-15 F1, so it is left to OPT-15.
|
||||
Control is unchanged here: every command in `tools/safety81_sim.py` is
|
||||
byte-identical to 5a4cdc2 (test_safety81.py part 1).
|
||||
|
||||
The double count also moves control, not only the alarm: on a late sample the
|
||||
guard asks for the full rating for one cycle (edge4600 at 902 s: 2000 W where
|
||||
1150 W holds the aim). Left to a follow-up card.
|
||||
@@ -1,34 +0,0 @@
|
||||
# SAFETY-83 - the peak guard outranks the maintenance drain at N=1
|
||||
|
||||
**Status: DECIDED 2026-09-29 (owner go, P1).** Follows SAFETY-78's "Not
|
||||
changed" list. Tests: `test_safety83.py`.
|
||||
|
||||
**The defect.** At N=1 the drain is the site SET at `P_MAINTENANCE` (60). The
|
||||
peak floor is a limit at `P_PEAK` (50), and clause 2 applies only limits at or
|
||||
above the winner, so the floor was ignored and the house imported what the
|
||||
drain left. A 5000 W house under a 2500 W limit and a 2000 W drain banked
|
||||
3000 W quarters; on the owner's settings (limit 3500, drain 2500, margin 50) a
|
||||
breach starts at about 5950 W.
|
||||
|
||||
**The fix.** While a drain owns the setpoint at N=1 and the peak floor asks
|
||||
for more than the drain rate, `main.py` (`_peak_over_drain()`) refiles that
|
||||
peak claim at `P_MAINTENANCE`. The arbiter then applies it: the target is the
|
||||
floor, still inside the safety limits, and a safety SET (100) still parks
|
||||
everything. Where the floor is at or below the drain rate nothing changes.
|
||||
|
||||
- The order in effect is now the same at N=1 and N=2: safety limits and the
|
||||
SAFETY-73 refusal > peak guard (with the SAFETY-79 margin) > maintenance >
|
||||
modes > trade > loop (`arbiter.py`, beside the priorities).
|
||||
- The drain keeps counting: the battery discharges at least the drain rate on
|
||||
every cycle, so the cycle is not reset or stalled; it only ends sooner.
|
||||
- Below the drain rate the rest is still exported (owner, 2026-09-29).
|
||||
- Identity: `tools/safety78_identity.py --diff` against 35cdffe changes only
|
||||
cycles where the guard asked for more than the drain; after such an episode
|
||||
the commands are identical again and only the log and the idle loop's
|
||||
integrator differ. N=2 is unchanged.
|
||||
|
||||
**Not changed.**
|
||||
- Between the drain rate and the peak aim, the house still imports what the
|
||||
drain leaves (N=1 behaviour kept by the identity). At N=2 the loop covers it.
|
||||
- A maintenance HOLD (0 W, 120 min, battery full) still outranked the peak
|
||||
guard at N=1. Fixed by [[SAFETY-86]].
|
||||
@@ -1,47 +0,0 @@
|
||||
# SAFETY-84 - a maintenance charge at two inverters
|
||||
|
||||
**Status: DECIDED 2026-09-30.** Follows SAFETY-78's "Not changed" list.
|
||||
Tests: `test_safety84.py`; identity: `tools/safety84_identity.py`.
|
||||
|
||||
**The defect.** In a cheap window the charging pack files its own
|
||||
`hi = -charge` (P_MAINT_SHAPE). At N>=2 the loop's site target sat above it
|
||||
(a house to cover, or its 400 W slew step from 0 W), so the other pack had to
|
||||
give while this one charged. The allocator refuses opposite directions
|
||||
(SAFETY-31), so the site sat at 0 W whatever the house
|
||||
(`tools/safety84_identity.py` on 9d5b7d1: 436 of 480 cycles refused). N=1
|
||||
was fine.
|
||||
|
||||
**The fix.**
|
||||
- While a pack's own limits make it charge at N>=2, `main.py` files a site
|
||||
limit `charge`, `hi` = what that pack's own `hi` places
|
||||
(`allocator.hi_w()`, whole W as the allocator rounds). The site target is
|
||||
never above it, so the other pack only adds power in the same direction.
|
||||
Opposite splits stay forbidden. Mirror of SAFETY-78's `drain` floor.
|
||||
- Peak guard first (owner order, SAFETY-83). The charge is sized to the
|
||||
quarter's headroom under the aim (`charge_headroom_w()`, cap or banked peak
|
||||
minus `peak_margin_w`), so it charges less instead of stopping: the meter
|
||||
sits on the aim and the guard does not need to step in. Where the house
|
||||
alone is over the aim the guard engages, the shaping is suspended as before
|
||||
(`shaping_suspended()`) and no `charge` is filed, so the guard's `lo` and
|
||||
this `hi` never meet in one cycle (no `lo > hi`, no park at 0 W).
|
||||
Without the headroom sizing the aim still held (the suspension did it), but
|
||||
a battery gave power back on 285 of 540 cycles at a 3000 W house
|
||||
(test_safety84.py part 2).
|
||||
- A site stop (a safety SET above P_MAINT_SHAPE) sizes the charge to 0 W:
|
||||
the unit `hi` is a hard limit, so a charge under a 0 W stop needs the other
|
||||
pack to give. It was refused, with an ARBITER line, on most cycles of the
|
||||
stop (test_safety84.py part 4). The stop still parked the site at 0 W.
|
||||
- A pack that cannot take the charge (a refusal, a full pack, a stale link)
|
||||
files no ceiling; the other pack follows the loop.
|
||||
- Not filed at N=1: byte-identical (`tools/safety84_identity.py`, which hashes
|
||||
every claim, in and out of a cheap window and under a banked peak).
|
||||
|
||||
**Not changed.**
|
||||
- At N=1 the charge is not sized to the headroom: the guard still suspends it
|
||||
and the battery gives until the quarter is safe. The same sizing would fit
|
||||
there; that is an owner question, not this fix. (SAFETY-125, 0.19.2: the
|
||||
owner said yes, and N=1 is sized the same way.)
|
||||
- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N.
|
||||
- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up"
|
||||
alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line
|
||||
comes as often at N=2 as at N=1 (2 in 480 cycles).
|
||||
@@ -1,91 +0,0 @@
|
||||
# SAFETY-85 - no false "not keeping up" while another owner sets the battery
|
||||
|
||||
**Status: implemented 2026-10-01, for review.** Tests: `test_safety85.py`.
|
||||
|
||||
**The defect.** "Battery is not keeping up" (SAFETY-40, `goodwe_integrator_bound`)
|
||||
fired about every 2.5 min during maintenance (drain, charge, HOLD) and under the
|
||||
peak guard, while the battery gave exactly what it was told. Each warning
|
||||
re-created the HA notification. Rig counts: 24 in an hour (SAFETY-83 F1), 43 in
|
||||
7 episodes (SAFETY-86), 35 in 16 (SAFETY-87), 12 in 6 (SAFETY-84). The page
|
||||
counter counted cycles (861), not episodes.
|
||||
|
||||
**Cause, measured on the real Controller.**
|
||||
- N=1: when a higher claim decides, `cycle()` re-seeds the integrator from the
|
||||
target each cycle, then `compute()` adds `gain * error` for a grid error the
|
||||
loop does not own. Target + kick crosses the rail and sets `i_clamped`. The
|
||||
carried integrator is not wound (SAFETY-48 caps the lead at one slew step),
|
||||
so control comes back one slew step at a time.
|
||||
- N>=2: a maintenance pin is a unit claim. The loop stays the site winner and
|
||||
winds to its rail for watts the pinned battery may not give. When the pin
|
||||
lifts, the site jumps: 2500 W in one cycle when a HOLD ends under a 4500 W
|
||||
house, 1390 W when a charge pin lifts (`s84 n2_charge_dear`, t=1800 s).
|
||||
|
||||
**Decision.** Three rules; the alarm, its text and its id are unchanged.
|
||||
1. **The loop's clamp counts only on a cycle the loop decided.** The winner is
|
||||
the loop. Its number is on the wire, or only a rating (`device`,
|
||||
`supervised`) cut it. No bound held it, and nothing was unmet.
|
||||
(`Controller._loop_alarm_input`.)
|
||||
2. **A battery another owner set to discharge alarms when it gives less than
|
||||
it was told.** This covers every battery on a cycle the loop did not
|
||||
decide, and a battery with its own pin otherwise. The test is more than
|
||||
`saturation_w` short of the least it stood at over `SHORT_CYCLES` cycles
|
||||
and `SHORT_SPAN_S` (the plant's measured settle, SAFETY-13/73), for
|
||||
`saturation_cycles` cycles in a row (`clamp.delivery_short`). It covers a
|
||||
real shortfall during maintenance or under the peak guard. Before, that
|
||||
shortfall was silent unless the kick happened to reach the rail (house
|
||||
1000 W, drain 2000 W, battery gives nothing: 0 warnings before, 4 in 10 min
|
||||
now).
|
||||
3. **At N>=2, while maintenance holds a battery, the loop is bounded** by
|
||||
OPT-11's `loop_bound()` (what the batteries may place). It does not wind,
|
||||
and control comes back one slew step a cycle. Why maintenance only: that is
|
||||
where the jump was measured and where the owner order puts maintenance
|
||||
above the loop. Other N>=2 limits (pack cut-out, SAFETY-73 refusal) keep
|
||||
the old law. Rule 1 still silences them (the target is unmet), so they no
|
||||
longer warn either.
|
||||
|
||||
Fixing the windup was chosen over only ignoring the wound part, because at
|
||||
N>=2 the wound integrator moves real watts at hand-back. At N=1 there was
|
||||
nothing to fix: re-seeding already prevents windup.
|
||||
|
||||
**What SAFETY-03/40 protect is kept.** With the loop in charge, the integrator
|
||||
bound still warns (a runaway, a house above the rating), and a saturated
|
||||
inverter under the loop's own command still does not alarm (SAFETY-03 AC 2):
|
||||
rule 2 grades only batteries another owner set.
|
||||
|
||||
**The counter.** `ClampWatchdog.bound_episodes` counts episodes that had a
|
||||
bound engagement. The status row shows it ("N times since start").
|
||||
`bound` (cycles) stays for the log and the tests.
|
||||
|
||||
**Re-pinned digests.** Every change is intended; nothing at N=1 moves a watt.
|
||||
|
||||
| digest | what changed |
|
||||
|---|---|
|
||||
| s78 `n1_drain_owner` | the false clamp rows are gone from the log; commands identical |
|
||||
| s78 `n2_drain`, s84 `n2_charge`, `n2_charge_banked`, s79 `n2_maint` (test_peak_margin) | loop reason (`mode-bound`), integrator and loop claim while bounded; commands identical |
|
||||
| opt11 `n2_maint` (test_modes) | A drops out at t=1655 s: B follows the house at once, 8 cycles differ (max 920 W) |
|
||||
| s84 `n2_charge_dear` | A's pin lifts at t=1800 s: 400 W a cycle, not a 1390 W jump; the surplus step after it; 98 cycles |
|
||||
|
||||
test_safety86's "without the ceiling the log floods" now needs the ceiling
|
||||
and the bound both removed: either one alone keeps the loop inside what the
|
||||
batteries can give. The `alarmed()` helpers in test_safety84/86 also read
|
||||
SAFETY-81's quarter stamp, because ALLOCATOR rows can push its row out of
|
||||
the 40-row log.
|
||||
|
||||
**Review (PR #163).** The first draft graded each battery against last
|
||||
cycle's command and graded charges too. On plantsim's battery (10 s lag) at
|
||||
the live 2 s meter, every drain or charge start warned once, and an N=2 drain
|
||||
or HOLD under the peak guard with a stepping house warned 15 times an hour. A
|
||||
maintenance charge tapering near full warned 11 times in 50 min, which
|
||||
SAFETY-42 section 7 had ruled out. With the settle window and discharge only:
|
||||
0 in all of those cases. A battery that gives nothing still warns, lagging
|
||||
or not. At a hypothetical 20 s lag, it warns at most twice an hour. The cost:
|
||||
a battery that does not take a charge another owner set is not named by this
|
||||
alarm (the same gap SAFETY-42 states; the charge has its 36 h timeout).
|
||||
`test_safety85.py` part 7.
|
||||
|
||||
**Not changed, open for the owner.**
|
||||
- With the loop in charge and the house above the batteries' total rating,
|
||||
it still warns (SAFETY-40). On the rig's 2 x 2000 W N=2, most of SAFETY-84's
|
||||
count came from houses of 4500 and 6500 W.
|
||||
- TEL-10's `Loop reason` reads `mode-bound` while the maintenance bound holds
|
||||
the loop, with modes off. DOCS lists the value with both meanings.
|
||||
@@ -1,47 +0,0 @@
|
||||
# SAFETY-86 - the peak guard outranks the maintenance HOLD
|
||||
|
||||
**Status: DECIDED 2026-09-30 (owner).** Follows SAFETY-83's "Not changed"
|
||||
list. Tests: `test_safety86.py`.
|
||||
|
||||
**The defect.** The HOLD (battery resting full after the maintenance charge)
|
||||
is a SET of 0 W at `P_MAINTENANCE` (60), above the peak floor (`P_PEAK`, 50),
|
||||
so the floor did not bind. At N=1 a 6500 W house banked 6500 W quarters and
|
||||
'Load too high' fired (SAFETY-83 rig test, check 4). At N=2 only the other
|
||||
battery gave: 4500 W against a 3950 W aim (SAFETY-84 rig test, check 6).
|
||||
|
||||
**The fix** (`main.py`, `_peak_over_hold()`).
|
||||
- N=1: during a HOLD a peak floor above 0 W is refiled at `P_MAINTENANCE`, as
|
||||
for a drain (SAFETY-83). The battery gives the floor; the Why reads "raised
|
||||
for the peak limit".
|
||||
- N>=2: the holding battery's pin is raised to the floor minus what the other
|
||||
live batteries may place (their hard `hi`, or their pin). The others give
|
||||
first, the holding one only the shortfall. Why this split and not the
|
||||
allocator's shares: the hold is maintenance and outranks the others' use
|
||||
(owner order), and the owner rule for an Off battery is the same, "give way
|
||||
only for the shortfall". A battery Off by its mode (P_MODE, below
|
||||
maintenance) therefore still gives before the holding one.
|
||||
- N>=2, while the holding battery gives: a site `hold` ceiling at the floor
|
||||
(P_MAINTENANCE), so the loop does not ask for watts the hold keeps back.
|
||||
Without it the moving pin re-logged the ALLOCATOR "could not be placed"
|
||||
row each cycle and filled the action log (review, PR #161).
|
||||
- Below the aim nothing changes: the floor is 0 W, the hold is 0 W, the rows
|
||||
are identical to before (test_safety86.py part 2).
|
||||
- A site stop (a SET above `P_PEAK`) decides alone: nothing is raised at N>=2,
|
||||
and the refiled floor at N=1 stays under `P_SAFETY`. If another battery is
|
||||
pinned to charge (a SAFETY-73 probe) the pin is not raised, so the site is
|
||||
never split both ways.
|
||||
- The hold resumes by itself: the rule is evaluated each cycle, so when the
|
||||
guard lets go the command is 0 W again.
|
||||
|
||||
**The level dips below the target: the hold finishes anyway.** Owner
|
||||
decision 2026-10-01: the HOLD ends on its clock (`maintenance_hold_min`) even
|
||||
when the peak guard pulls the level down; no top-up. A top-up would re-enter
|
||||
CHARGE (up to 36 h, `CHARGE_TIMEOUT_MIN`) and keep the site's one maintenance
|
||||
slot meanwhile.
|
||||
|
||||
**Not changed.**
|
||||
- The peak guard still grades the emptiest battery (SAFETY-32): at N=2 with
|
||||
B under `peak_soc_floor` the floor is limited and A holds even when it is
|
||||
full (SAFETY-87).
|
||||
- "Battery is not keeping up" during maintenance (SAFETY-85), the N=2 charge
|
||||
flapping (SAFETY-88) and the N=1 charge headroom sizing are untouched.
|
||||
@@ -1,83 +0,0 @@
|
||||
# SAFETY-87 - the peak guard uses every battery that can still give
|
||||
|
||||
**Status: DECIDED 2026-10-01 (owner card).** Follows SAFETY-86's "Not
|
||||
changed" list. Tests: `test_safety87.py`; identity:
|
||||
`tools/safety87_identity.py`.
|
||||
|
||||
**The defect.** At N>=2 the guard graded the emptiest battery against the
|
||||
highest floor ([[SAFETY-32]] §3). One battery under `peak_soc_floor` made the
|
||||
whole site floor-limited: the claim asked for 0 W and the reserve alarm fired,
|
||||
even with the other battery full. Measured: a maintenance charge (A at 11 %)
|
||||
raised the alarm 10 times while both batteries gave 2000 W and the quarter
|
||||
landed on the aim (SAFETY-84 rig test, check 3); a HOLD (A at 99 %, B at
|
||||
15 %) left A at 0 W and banked 4051 W, raising the month peak (SAFETY-86
|
||||
review; rig case 7a: 4563.8 W).
|
||||
|
||||
**The fix.**
|
||||
- `decide()` and its call are unchanged. At N>=2 `Controller._peak_on_able()`
|
||||
then grades each battery on its own floor (`peakguard.reserve_split()`).
|
||||
One at or under it is *held*. The others that are live and not refusing
|
||||
(SAFETY-73) are *able*.
|
||||
- A floor-limited decision is re-taken on the able batteries
|
||||
(`peakguard.place_on_able()`): their own permission (min SoC against max
|
||||
floor, among them), and a floor of `min(need, able_w)`. `able_w` is their
|
||||
rating plus the drain a held battery gives anyway (its maintenance pin).
|
||||
- The held batteries weigh 0 in the split while the site gives
|
||||
(`arbiter.resolve(reserve=)`), so the floor lands on the able ones. During a
|
||||
HOLD, a held battery is not counted among the "others" (`_peak_over_hold()`),
|
||||
so the holding battery covers the need.
|
||||
- Floor-limited now means: some battery is held and the able ones cannot hold
|
||||
the aim. The claim still asks them for `able_w`. The alarm fires only if
|
||||
they miss the real limit at full power for `ALARM_HOLD_S`, the same test as
|
||||
'Load too high' (`_reserve_short()`), so a one-cycle miss at a quarter's end
|
||||
is not an alarm. With nothing able (all held, or the rest refusing or
|
||||
dropped) it is the old state: 0 W, immediate alarm.
|
||||
- The alarm and the row name `held`, each battery graded on its own floor. The
|
||||
old code named batteries against the site's highest floor.
|
||||
- Page: the two peak rows carry `runs`. A red `runs` row gives the banner its
|
||||
title without "Not ready", and the Home page is not shown as held at 0 W.
|
||||
Rows that do stop control still outrank it.
|
||||
|
||||
**How SAFETY-32's concern is still covered.** SAFETY-32 took min(SoC) against
|
||||
max(floor) so the guard never sizes a forced discharge a battery's own BMS
|
||||
stops. Now no battery at or under its own floor is asked for the peak: it is
|
||||
graded on its own floor, and an unread SoC or unknown cut-out counts as held.
|
||||
The min/max pairing still decides permission, over the able batteries. The
|
||||
roster verdict `decide()` echoes (`soc`, `soc_floor`) is kept, so SAFETY-32's
|
||||
call-site and reduction checks still bite. What changed is only that one held
|
||||
battery no longer stops the others. One exception, with battery modes on: see
|
||||
Open.
|
||||
|
||||
**Maintenance pins beside a held battery** (review). A draining battery that
|
||||
may still give is raised from its drain rate towards its rating for the peak
|
||||
floor, after the others (`_drain_over_reserve()`; peak > drain), else the
|
||||
floor's rest landed on the held battery. In a HOLD, a held battery counts its
|
||||
own pin (a drain) among the others, and a held battery pinned to charge stops
|
||||
the raise (never split both ways).
|
||||
|
||||
**Before -> after** (test_safety87.py, rig of 2 x 2500 W, aim 3450 W):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| charge, A 11 %, house 4500 | 3 reserve alarms, quarter 3450 | 0 alarms, 3450 |
|
||||
| HOLD, A 99 % / B 15 %, house 5500 | A 0 W, B 2488 W, 3 alarms | A 2054 W, B 0 W, 0 alarms, 3450 |
|
||||
| HOLD, house 6500 | A 0 W, B 2488 W, 4051 W | A 2500 W, B 0 W, 4014 W, 1 alarm (A alone is short) |
|
||||
| HOLD, month peak 4000, house 6000 | A 0 W, B 2488 W, 3551 W, alarms | A 2054 W, B 0 W, 3950 (peak - margin), 0 alarms |
|
||||
| both under the reserve | alarm | alarm (unchanged) |
|
||||
| N=1, every scenario | | byte-identical |
|
||||
|
||||
**Open, for the owner.**
|
||||
- The loop still draws a held battery (SAFETY-25 option A: the reserve binds
|
||||
the peak guard only). It gives last, after the able ones. So with house 6500
|
||||
in the charge case, B alone cannot hold the aim, the alarm fires as
|
||||
specified, and the loop then lands the quarter on the aim from A. The text
|
||||
says "cannot keep … alone", not "will go over", so it stays true.
|
||||
- Battery modes on, A Off and B held: when B alone can place the floor, OPT-11
|
||||
keeps A resting and B gives it. When B cannot, A gives first. Making Off give
|
||||
before a held battery in the first case needs a third allocator tier. The
|
||||
same at N=3: A Off, B held, C able, floor 4000 W gives C 2500, B 1500, A 0.
|
||||
Until then, with modes on, a held battery can give for the peak.
|
||||
- The notification and row text live in `messages.py`, which has no NL layer
|
||||
yet (UX-19). The Home page text, which has NL, is unchanged.
|
||||
|
||||
**Not changed.** N=1. 'Load too high' (SAFETY-81). SAFETY-85, -88, -89, -90.
|
||||
@@ -1,81 +0,0 @@
|
||||
# SAFETY-90 - the peak guard goes on after a P1 meter gap
|
||||
|
||||
**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`.
|
||||
|
||||
**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the
|
||||
quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must
|
||||
not reach the bill record). `peakguard.blindness()` then called the projection
|
||||
blind for the rest of the quarter, so the floor was 0 W. During a HOLD the
|
||||
battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2,
|
||||
3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review).
|
||||
The page still read "Keeping the grid near zero". At start-up mid-quarter the
|
||||
same rule left the start-up quarter blind, and a held battery was drawn as last
|
||||
resort (SAFETY-91 test, O2).
|
||||
|
||||
**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`,
|
||||
`Controller._fill_peak_gaps()`).
|
||||
- Once readings are back, every second of a silence longer than
|
||||
`meter_max_age_s` counts at `gap_w = max(the limit, the import just before
|
||||
the gap, the import just after it)`, where "import" is the grid plus the
|
||||
batteries' discharge, i.e. with the batteries at 0 W. The gap's first
|
||||
`meter_max_age_s`, which the averager credits at the last reading, is topped
|
||||
up to `gap_w` too. The guard then grades the quarter as usual, so it is not
|
||||
blind and gives what keeps the quarter at the aim.
|
||||
- **The bound.** The count is at or above the true import as long as, during
|
||||
the gap, the house load (less solar) stayed at or under the higher of the loads seen just
|
||||
before and just after it, or under the limit, and the batteries did not
|
||||
start charging (during a gap they hold their last command, then go to 0 W).
|
||||
It never counts the gap below the limit. A load that rises above all three
|
||||
only inside the gap is the one case it can miss.
|
||||
- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s
|
||||
to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in
|
||||
battery energy.
|
||||
- **While the gap lasts** nothing changes: the reading is too old to steer by,
|
||||
the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as
|
||||
before.
|
||||
- **Before the first reading** (start-up or restart mid-quarter) the
|
||||
add-on was down, so the batteries were at 0 W (it sets 0 W when it stops,
|
||||
the ESP32 watchdog after 30 s). Those seconds count like a gap, at
|
||||
`gap_w(the limit, the import seen first)`; after a restart the restored
|
||||
reading's held `meter_max_age_s` is topped up the same way. The bound is
|
||||
the gap's, with only the "after" side known. A load the batteries cannot
|
||||
make up then raises "Load too high", which is true: that quarter is going
|
||||
over. The page says so: the status row "Peak guard: part of this
|
||||
15-minute period was not measured, counted as high use" and the home line
|
||||
"Part of this 15-minute period was not measured." / "Een deel van dit
|
||||
kwartier is niet gemeten." until the quarter ends.
|
||||
*Review:* the first version counted them at the aim, with "nothing is
|
||||
known" as the reason. The first reading does show the load, and with the
|
||||
batteries at 0 W the import was that load, not the aim: a 60 s restart
|
||||
during a 6500 W HOLD landed 3653 W against 3450 W.
|
||||
- The bill record (`quarter_blocks`) is unchanged: it still banks only what
|
||||
was measured. The estimate steers the guard and is never banked.
|
||||
- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and
|
||||
the watts it was counted at.
|
||||
- No option. The threshold is `meter_max_age_s`, the limit and the aim are
|
||||
`peak_cap_w` and `peak_margin_w`.
|
||||
|
||||
**Before -> after** (`test_safety90.py`, quarter as the meter really averages
|
||||
it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 |
|
||||
| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 |
|
||||
| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 |
|
||||
| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 |
|
||||
| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 W |
|
||||
| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 |
|
||||
| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 |
|
||||
| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 |
|
||||
| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 |
|
||||
| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 |
|
||||
| meter never drops, N=1 and N=2 | | rows identical |
|
||||
|
||||
**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by
|
||||
less than `meter_max_age_s` is not a gap, and the seconds since it are not
|
||||
counted in the projection (on any quarter, not only after a gap). Near a
|
||||
quarter's end the 900/remaining gain turns that into a large negative excess
|
||||
and the floor drops for a cycle or more. Counting those seconds at the held
|
||||
reading would fix it, but it changes every quarter's last seconds on a
|
||||
healthy meter; it is handed back as its own card.
|
||||
@@ -1,74 +0,0 @@
|
||||
# SAFETY-91 - a held battery is drawn only after the others are full
|
||||
|
||||
**Status: DECIDED 2026-10-01 (owner: held battery last, SAFETY-25 option A
|
||||
kept).** Follows SAFETY-87. Tests: `test_safety91.py`.
|
||||
|
||||
**The defect** (SAFETY-87 rig test, F1 and F2). At N>=2 a battery at or under
|
||||
its own peak reserve was held back (weight 0) only on cycles where the peak
|
||||
guard asked for power.
|
||||
- F1: B draining at 1000 W, A held at 15 %, house 5500. Once the grid was
|
||||
near zero the loop set the target, A had its normal weight and B's drain
|
||||
pin bound, so the loop drew A at 2500 W for the whole quarter (15.0 ->
|
||||
10.7 %) while B stayed at 1000 W. In the next quarter A reached its
|
||||
cut-out, and B stayed pinned at the peak floor (~2422 W), not its 2500 W.
|
||||
- F2: in a HOLD (A holding, B held), at a quarter's edge the guard saw a block
|
||||
with no seconds left and asked for nothing for one cycle. The HOLD's
|
||||
ceiling lapsed, A went back to 0 W and the loop's target went to B: 2500 W
|
||||
for 1 s.
|
||||
|
||||
**The fix** (`main.py`, N>=2 only).
|
||||
- `_held_back()`: the batteries at or under their own reserve while another
|
||||
can still give (live, not refusing), graded as in `_peak_on_able()`. They
|
||||
weigh 0 in every split where the site gives, not only for the peak. With
|
||||
nobody able (all held, or the rest refusing or dropped) nobody is held back
|
||||
and the split is as before.
|
||||
Review: once held back, a battery stays so until its SoC reads more than 1
|
||||
point above its reserve. A reading flipping 20/21 % otherwise swapped
|
||||
~1000 W between the batteries on each flip (grid +-500 W with A 10 s and
|
||||
B 3 s lag). The guard's own permission (SAFETY-87) is unchanged.
|
||||
- `_drain_rise()`: while a battery is held back, a draining battery's pin
|
||||
becomes a floor at its drain rate (`lo`), so it rises towards its rating
|
||||
before the held one gives. Not for a held battery's own drain, not under a
|
||||
site stop, not for a battery that is not live.
|
||||
- `tariff.next_quarter()`: past a block's end, before the next block's first
|
||||
telegram, the guard grades the next quarter, projected from the held
|
||||
reading (the averager credits it there). Not when that reading is too old
|
||||
to be credited (`max_hold_s`).
|
||||
|
||||
**What stays.** SAFETY-25 option A: once every able or draining battery is
|
||||
at full power, the loop still draws a held battery (`allocator._fill()`'s
|
||||
last-resort pass). With house 5500 and 2 x 2500 W, A still gives 2500 W
|
||||
after B is full. Nothing is placed on a battery at its cut-out: OPT-09's
|
||||
pack limit closes it (`hi = 0`), and the drain rise lets B take the rest.
|
||||
|
||||
**Before -> after** (`test_safety91.py`, 2 x 2500 W, lagging battery 10 s):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| B drain 1000, A held, house 4000 | A 2500, B 1000 | A 1500, B 2500 |
|
||||
| same, house 5500 | A 2500, B 1000 | A 2500, B 2500 (option A) |
|
||||
| cycles A asked while B not full | 612 of 900 | 0 |
|
||||
| A at its cut-out, house 5500: B | 1572 W mean | 2500 W, A 0 W |
|
||||
| HOLD, late telegram at the edge | A 0, B 2470 for 1 cycle | A 2050, B 0 |
|
||||
| idle, A held, house 2000 | A 1005, B 1005 | A 0, B 2020 |
|
||||
| N=1, every scenario | | byte-identical |
|
||||
|
||||
**Why N>=2 only.** At N=1 there is no other battery to give first. The edge
|
||||
roll would also fix the N=1 one-cycle dip at a quarter's edge (SAFETY-86
|
||||
rig, "A dropped to 0 W for 1 s"), but this ticket keeps N=1 byte-identical;
|
||||
extending it is a one-line change for a later ticket.
|
||||
|
||||
**Re-pinned.** `test_modes.py` `n2` and `n2_off`: from t=1600 battery A is
|
||||
at its 20 % reserve, so B carries the house and A gives last (100 of 540
|
||||
cycles differ). With SAFETY-87's held rule both are a5dddad's again, and the
|
||||
test grades that.
|
||||
Two fixtures changed with it: `test_safety87.py` part 10's "without the
|
||||
raise" run also turns off `_drain_rise()` (else B rises anyway), and
|
||||
`test_peakguard.py` 10g and `test_watchdog.py`'s two-inverter rig give
|
||||
their second battery a cut-out, as rule 8 requires (an unknown cut-out is held, so A alone would carry the cycle).
|
||||
|
||||
**Not changed.** The peak guard's own rules (SAFETY-87). A drain with
|
||||
nobody held stays a pin (SAFETY-78). An Off battery beside a held one
|
||||
(SAFETY-87 Open). The first cycle after start, before any quarter is
|
||||
measured, can still put the loop's first step on a held battery in a HOLD.
|
||||
SAFETY-88, -89, -90, -92, -93, -94.
|
||||
@@ -1,60 +0,0 @@
|
||||
# SAFETY-93 - the maintenance charge end hands back smoothly
|
||||
|
||||
**Status: implemented 2026-10-02, for review.** Tests: `test_safety93.py`.
|
||||
|
||||
**The defect** (SAFETY-85 rig test, check 4; also on 9358781). At N=2, when
|
||||
A's maintenance charge ended, B went 0 -> -1600 W in one cycle and charged
|
||||
from the grid with the house at 6000 W. The HOLD end was already smooth
|
||||
(SAFETY-85).
|
||||
|
||||
**Cause.** During the charge the site target holds A's charge (SAFETY-84's
|
||||
`charge` ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the
|
||||
loop starts its next step from last cycle's site target. The slew limit
|
||||
moves the site total by 400 W (-2000 -> -1600), and the only battery left
|
||||
to place it on is B. The same happens when the charge times out or the
|
||||
battery is switched Off (A free, its -2000 W shared out). With the charge
|
||||
suspended by the guard and A giving under the loop, B took A's share in
|
||||
one step (675 W).
|
||||
|
||||
**The fix** (`main.py`, `_reseed_charge_end()`, N>=2 only). On the cycle a
|
||||
battery leaves CHARGE, the loop's start point (`self.target`, and the
|
||||
integrator with it) is what the other batteries stood at plus that
|
||||
battery's new command: its pin (HOLD or abort, 0 W), or, if it is free, its
|
||||
last discharge (a charge stops). The loop then moves from there one slew
|
||||
step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's
|
||||
pin, as before; it lowers the grid import. The end is kept until the loop
|
||||
next runs, so a missed poll on that cycle (the input hold) does not lose it.
|
||||
|
||||
Review: a charge also ends, for the site, when the battery is still in
|
||||
CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73)
|
||||
drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a
|
||||
1200 W house). So the end is noted after the unit claims are filed: a
|
||||
battery leaves the charge when it leaves CHARGE, is not live, or its own
|
||||
claims contradict. The guard suspending the charge (SAFETY-84) is no end.
|
||||
|
||||
Why a start-point reseed, and not a per-battery slew in the allocator: the
|
||||
jump comes from one stale number at one edge, the kind of edge SAFETY-73
|
||||
(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed.
|
||||
The change stays at that edge; nothing else at N>=2 moves.
|
||||
|
||||
**Before -> after** (`test_safety93.py`, 2 x 2500 W, 10 s lag, 2 s meter):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| rig case: house 6000, A charges 2000 to the end | B 0 -> -1600, charges 4 cycles | B 0 -> 400 -> 800 ..., never charges |
|
||||
| house 1200 | B 0 -> -1600 (2800 W from the grid) | B steps of 400, never charges |
|
||||
| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 |
|
||||
| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W |
|
||||
| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 |
|
||||
| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 |
|
||||
| N=1, both charge ends above | | byte-identical |
|
||||
|
||||
No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and
|
||||
N=2) are byte-identical to f3e409c; none of their scenarios reaches a
|
||||
charge end.
|
||||
|
||||
**Not changed.** The peak guard's floor still moves the site in one step
|
||||
when it engages (a house step, a quarter edge): the guard outranks the slew
|
||||
(owner order). A HOLD pin still drops the holding battery to 0 W at once
|
||||
and SAFETY-86 gives way to the others first. SAFETY-88 (charge flapping
|
||||
above the aim) is untouched.
|
||||
@@ -1,102 +0,0 @@
|
||||
# SAFETY-94 - a battery that lags or reverses is not frozen or refusing
|
||||
|
||||
**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py`
|
||||
(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open.
|
||||
|
||||
## The defects
|
||||
|
||||
1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last
|
||||
change. After a rest that clock was already past 35 s, so the first read
|
||||
after a new command tripped it, before the register could answer. Live
|
||||
2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a
|
||||
rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was
|
||||
1 s after its command; each register answered within 11 s. This is
|
||||
SAFETY-58's shape.
|
||||
2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES`
|
||||
frozen cycles if the reading was under 100 W at that moment. The frozen
|
||||
cycles include a reversal through the battery's lag. At a 1 s meter the
|
||||
12th cycle comes as a battery reversing out of a charge passes 0 W
|
||||
(SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held
|
||||
0 W, the battery settled at 0 W, never gave 100 W, and waited for the
|
||||
15-minute probe. N>1 already required 22 s in the band, so it was safe
|
||||
at a 10 s lag but not at 20 s.
|
||||
|
||||
## What changed
|
||||
|
||||
- **The gate counts only time the register was asked to move**
|
||||
(`freshness.Freshness._oblige`). A read counts the time since the last
|
||||
read when, at that read, the command is 250 W or more and has moved 250 W
|
||||
from where it stood at the register's last change. A register change
|
||||
(or a SAFETY-57 release) restarts the count. A command flapping in and
|
||||
out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip
|
||||
at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged.
|
||||
- **The bound is unchanged at 35 s and now means what its derivation
|
||||
says.** The floor was always "how long a healthy register takes to answer
|
||||
a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band
|
||||
`[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3,
|
||||
6 and 11 s after the command.
|
||||
- **Refusal evidence is the same at every roster size**
|
||||
(`main.Controller._gave_nothing`, SAFETY-73's count moved into a method).
|
||||
The reading must stay under 100 W, one way, while asked over 200 W, for 12
|
||||
cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and
|
||||
needs it on top of its own freeze test. `None` (compute alone) keeps the
|
||||
old law.
|
||||
- **The evidence restarts when the reading moves 50 W towards the ask**
|
||||
(`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live
|
||||
readings wander by under 25 W at rest. A first-order lag crossing the band
|
||||
gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s.
|
||||
This also covers B, whose reading is 100 W below its power.
|
||||
|
||||
## Bounds and worst-case catch
|
||||
|
||||
| fault | before | after | budget |
|
||||
|---|---|---|---|
|
||||
| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds |
|
||||
| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same |
|
||||
| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) |
|
||||
| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged |
|
||||
| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s |
|
||||
|
||||
Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes
|
||||
the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
|
||||
|
||||
## Before -> after
|
||||
|
||||
- Live replay (part 1): 4 trips -> 0.
|
||||
- Refusing B (its -100 W idle reading): see SAFETY-97.
|
||||
- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs
|
||||
(part 2): before 27 stale trips and 1 refusal, after 0 and 0.
|
||||
- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3
|
||||
refused, N=2 6 / 1; after 0 / 0.
|
||||
- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips
|
||||
answered within 20 s: 21 -> 1. Two of the removed trips were not quick:
|
||||
a register held 351 s under a command hovering at the 250 W travel edge
|
||||
(under 35 s of asking before it moved; survivor 2's shape), and a
|
||||
refusing battery the gate had caught only on a passing ramp (SAFETY-52's
|
||||
refusal covers it).
|
||||
|
||||
## SAFETY-58 and SAFETY-74
|
||||
|
||||
- **SAFETY-58 is fixed** by this change: its card asks for the clock to
|
||||
start with the obligation and for the bound to be re-derived. Both are
|
||||
done above. `test_freshness.py` survivor 5 is now graded as closed.
|
||||
- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly
|
||||
flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2
|
||||
before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a
|
||||
command that moved is exactly what the gate is for; telling "limited"
|
||||
from "frozen" needs a second signal (the meter, or SoC).
|
||||
|
||||
## Known gaps
|
||||
|
||||
- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
|
||||
100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2
|
||||
as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred
|
||||
on each unit's learned idle reading. `SAFETY-97-idle-offset.md`.
|
||||
- A command that asks only part of the time (a fraction d) is caught after
|
||||
35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s).
|
||||
SAFETY-30's per-unit check restarts when its command stops opposing the
|
||||
held reading, which a lapsing ask normally does, so 35 < 85 holds there.
|
||||
SAFETY-31's circulation check (75 s) ignores the command, so its margin
|
||||
holds only while d >= 0.47.
|
||||
- A register frozen under a command that stays near the 250 W travel edge
|
||||
is caught late or not at all (survivor 2).
|
||||
@@ -1,69 +0,0 @@
|
||||
# SAFETY-95 - a maintenance drain that ends hands back smoothly
|
||||
|
||||
**Status: implemented 2026-10-06, for review.** Tests: `test_safety95.py`.
|
||||
|
||||
**The defect** (SAFETY-93 review, O1). At N=2, when A's drain ended (A
|
||||
switched Off, say) with the house at 1200 W, B went 0 -> +1600 W in one
|
||||
cycle and exported. It is the mirror of SAFETY-93 on the drain end.
|
||||
|
||||
**Cause.** During a drain the site target carries A's drain floor
|
||||
(SAFETY-78, `drain`, 2000 W) above what the house needs. At the end A's
|
||||
command drops (pin, Off, abort, stale link, refusal), but the loop slews
|
||||
from last cycle's target (2000 -> 1600) and only B is left to place it.
|
||||
Under a solar surplus B gave 1600 W into the export.
|
||||
|
||||
**The rule** (`main.py`, `_reseed_drain_end()`, `_drain_grid_w()`, N>=2
|
||||
only). On the cycle a drain ends:
|
||||
|
||||
1. The start point is what the others stood at plus A's new command (its
|
||||
pin, else 0 W; a dropped battery is sent 0 W), as SAFETY-93 does.
|
||||
2. If the house needs more, the start is raised to the house's own point
|
||||
(live batteries' readings + grid - target, as `_reseed_run_edge()`),
|
||||
but never above last cycle's start. So the others cover the house at
|
||||
once and never take over the drain's export.
|
||||
3. While A settles (SAFETY-96's window: within the deadband, at most
|
||||
`PLANT_SETTLE_DOWN_S`), the loop's error is the house's point minus its
|
||||
start, not the meter. The meter still shows A's tail and the others'
|
||||
climb; chasing it sent B down and then up again.
|
||||
4. Then the loop moves one slew step a cycle.
|
||||
|
||||
A drain ends when its battery leaves DRAIN, is not live, its claims
|
||||
contradict, or it will not give (SAFETY-73's limit outranks the pin
|
||||
without a contradiction, so the refusal is read from the battery; +1 only,
|
||||
as `_held_back()`). Today a contradiction on a drain only arises through
|
||||
a refusal; the clause stays because a contradiction sends A 0 W, which is
|
||||
an end (tested directly). The end is kept
|
||||
until the loop runs (a missed poll). A dropped draining battery is this
|
||||
rule's, not `_reseed_drop()`'s. Order in one cycle: drop/rejoin
|
||||
(SAFETY-96), charge end (SAFETY-93), drain end (this) last; each moves the
|
||||
start by its own battery, and the drain end's raise reads the start the
|
||||
others left.
|
||||
|
||||
**Why not SAFETY-93's rule alone** (step 1, then slew). Measured on the
|
||||
same plant: at 1200 W the grid imports 849 W and B goes to -465 W
|
||||
(charging) while the loop chases A's tail; at N=3 and 4000 W it imports
|
||||
784 W where base imports 42 W, as the others give up A's share of the house.
|
||||
The house point keeps the grid at its aim; the cap at last cycle's start
|
||||
keeps it from exporting.
|
||||
|
||||
**Before -> after** (`test_safety95.py`, 2500 W batteries, 10 s lag, 2 s
|
||||
meter; base = the same tree with the change taken out):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| drain done / Off / timeout, N=2, 1200 W | B 0 -> 1600, +390 W commanded export, grid then +559 W | B 0 -> 1210, grid within 40 W of its aim |
|
||||
| the same, N=3, 1200 W | B, C 0 -> 800, grid +695 W | 0 -> 605, grid +39 W |
|
||||
| the same, solar surplus | B gives 1600 W into the surplus | B never gives; site moves 400 W a cycle |
|
||||
| refusal, N=3, 1200 W | B, C step 1020 W, +2000 W commanded export | step 20 W, +70 W |
|
||||
| N=3, 4000 W | others take A's share at once | the same (grid +1 W, base +42 W) |
|
||||
| abort (site zeroed by the operator) | climbs from 0, grid +669 W | climbs from 0, grid +16 W |
|
||||
| stale link (board watchdog 25 s) | | as base, but N=3 4000 W: grid +676 W (base +786) |
|
||||
| N=1, every drain end | | byte-identical |
|
||||
|
||||
`tools/opt11_identity.py` n2_maint moved on purpose (A's drain refused at
|
||||
1655 s; B stays at the house's 1420 W instead of 2500 W). Re-pinned in
|
||||
`test_modes.py`. The other 45 digests are byte-identical.
|
||||
|
||||
**Not changed.** The peak guard's floor still binds (arbiter, after the
|
||||
loop). Held batteries still give last; the idle clamp and the freeze test
|
||||
are as they were. A charge end (SAFETY-93) and a trade end are unchanged.
|
||||
@@ -1,59 +0,0 @@
|
||||
# SAFETY-96 - a battery the loop cannot move does not freeze it
|
||||
|
||||
**Status: implemented 2026-10-06, for review.** Tests: `test_safety96.py`.
|
||||
|
||||
**The defect** (SAFETY-93 rig test). At N=2 the saturation freeze compares
|
||||
the site target with the site sum of the readings. After A's link went
|
||||
stale, A was out of the split but its reading (-2000 W, still charging) was
|
||||
in the sum: "saturation freeze (800 W vs -1903 W)", B held at 1200 W for
|
||||
~236 cycles until the circulation trip. After a charge timeout or Off at
|
||||
6000 W the loop froze ~14 s at 800 W while A came out of its charge. At a
|
||||
drop, B also took A's whole share in one step.
|
||||
|
||||
**The rule** (`main.py`, `_held_out_w()`, `_reseed_drop()`, N>=2 only).
|
||||
A battery is held out of the freeze test, counted at its command instead of
|
||||
its reading, while:
|
||||
|
||||
- it is out of the split (writes refused for `LINK_STALE_S`);
|
||||
- its last write was refused (its board may have gone to 0 W on its own
|
||||
watchdog);
|
||||
- it settles after a step the loop did not make: a charge end (SAFETY-93:
|
||||
HOLD, timeout, Off, refusal, stale link) or a rejoin (then every battery,
|
||||
as the split is made again). Settling ends when its reading is within the
|
||||
deadband of its command, or after `PLANT_SETTLE_DOWN_S` (23 s, the
|
||||
measured plant settle).
|
||||
|
||||
Its real power is not lost and not counted twice: the meter sees it, and
|
||||
the loop follows the meter. Only the freeze stops reading it as the loop's.
|
||||
The freeze still fires on the other batteries' own lag and on unplaced
|
||||
watts.
|
||||
|
||||
At a drop the battery's share leaves the loop's start point, so the others
|
||||
take over one slew step a cycle. At a rejoin it enters at its reading.
|
||||
|
||||
**Not a drop.** A missing reading or a stale register stops the site
|
||||
(`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum:
|
||||
it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30)
|
||||
is pinned at 0 W and stays in the sum. New: no refusal is learned on a
|
||||
battery whose writes are refused; its board going to 0 W is no refusal. Its
|
||||
count is kept, not reset, so a link that refuses 1 write in 3 does not hide
|
||||
a battery that really refuses (review).
|
||||
|
||||
**Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not
|
||||
about the remaining battery's own lag):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| A drops while giving, A holds 1200 W | 128, B steps 1210 W | 0, B stays |
|
||||
| A drops while charging, A holds -2000 W | 78 | 0 (circulation trip stays: 4 kW goes round) |
|
||||
| A drops while charging, board to 0 W | 5 | 0 |
|
||||
| A drops then comes back, A holds 1200 W | 46, grid +1.1 kW | 0, grid within 250 W |
|
||||
| charge timeout at 6000 W | 7, B held at 400 W | 0, B climbs |
|
||||
| A switched Off at 6000 W | 6, B held at 800 W | 0, B climbs |
|
||||
| N=1, N=2 with no drop | | byte-identical |
|
||||
|
||||
**Not changed.** At a rejoin the allocator splits again at once (A 0 ->
|
||||
1205, B 2410 -> 1205 W); the site total does not step. A battery that keeps
|
||||
charging on its own after its link drops still trips the circulation check
|
||||
(it is real circulation). A trade end (`_reseed_run_edge`) still starts
|
||||
from the full site sum.
|
||||
@@ -1,118 +0,0 @@
|
||||
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading
|
||||
|
||||
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
|
||||
review.** Owner decision 2026-10-04: the band's centre is clamped to
|
||||
+-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`).
|
||||
Closes SAFETY-94 review finding F1.
|
||||
|
||||
## The defect
|
||||
|
||||
B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W
|
||||
at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W.
|
||||
Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing
|
||||
B sat on the band's edge and never latched:
|
||||
|
||||
- N=2: never caught, before or after SAFETY-94.
|
||||
- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared
|
||||
rule (12 cycles in the band in a row) never did (review probe, 1200 s).
|
||||
|
||||
## Design
|
||||
|
||||
- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No
|
||||
new option: a required one is a setup step every client gets wrong, and an
|
||||
optional one is a second source to reconcile. An override can be added
|
||||
later if a site needs one.
|
||||
- **When it learns:** control runs; the unit is not refused (unit or site),
|
||||
stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for
|
||||
120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more
|
||||
than 50 W, and while refused it does not learn, so its refusal cannot be
|
||||
learned as idle. A capped battery under a big ask is not sampled either.
|
||||
- **How:** the first value is the mean of 120 s of samples; then it follows
|
||||
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`)
|
||||
is dropped (a register held at a giving value). The band is centred on
|
||||
the value clamped to 75 W (`IDLE_MAX_W`; owner decision below).
|
||||
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
|
||||
5 W move) and restored, clamped to 250 W. Without it a battery refusing from the
|
||||
start is never quiet, so never learned, so never caught.
|
||||
- **Until learned the idle reading is 0 W**: the rule as it was.
|
||||
|
||||
## Not chosen: the GoodWe LAN integration's DC battery power
|
||||
|
||||
Both inverters also appear through HA's GoodWe integration (B:
|
||||
`sensor.kelder_dyness_battery_power`, 0 W at idle; found read-only on the
|
||||
live HA 2026-10-04). Pointing `batt_entity` at it removes B's offset by
|
||||
configuration, but:
|
||||
|
||||
- the band must be safe without it: other installs may not have it;
|
||||
- it is polled over UDP on the inverter's Wi-Fi, a second channel that can
|
||||
drop while the board keeps reading (HW-01 lost both once); a missing
|
||||
reading stops the site;
|
||||
- SAFETY-37's 35 s bound is derived from the board's 10 s publish; the
|
||||
integration's poll interval (believed 10 s, not checked here) and latency
|
||||
are not measured;
|
||||
- DC power is not 0x0518, which SAFETY-30/31/42 were measured against.
|
||||
|
||||
It stays an install choice to test on the rig, not a code change here. With
|
||||
it, the learned idle reading is simply ~0 W.
|
||||
|
||||
## Where it applies
|
||||
|
||||
Every test of "gives nothing / gives / takes" now uses `reading - idle`:
|
||||
|
||||
| check | change |
|
||||
|---|---|
|
||||
| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred |
|
||||
| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred |
|
||||
| N=1 end in `_refusal_update` | centred |
|
||||
| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once |
|
||||
| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred |
|
||||
| SAFETY-42 "parked" (small ask, gives nothing) | centred |
|
||||
| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level |
|
||||
| SAFETY-57 release | unchanged: it reads the command only |
|
||||
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
|
||||
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
|
||||
|
||||
## Bounds: owner decision 2026-10-04 (review R1)
|
||||
|
||||
Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W.
|
||||
|
||||
- **Why a clamp under the band.** A wrong idle reading (taught during a
|
||||
rest while the reading shows something else, e.g. a grid top-up of a unit
|
||||
held at 0 W, or restored onto a swapped inverter) more than about 100 W off
|
||||
hid every refusal on that battery, at N=1 and N=2; with the first +-250 W
|
||||
clamp the rig missed a refusing A whenever the error was 110-250 W. At
|
||||
+-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W
|
||||
while refusing is caught whatever was learned or restored, and a wrong idle
|
||||
cannot refuse a healthy 0 W battery (its ask is over 200 W).
|
||||
- **The trade-off.** Options: keep +-250 W (covers idle readings to about
|
||||
+-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen);
|
||||
"old band or centred band" (rejected: a correct B would need to give 200 W
|
||||
to answer its 200 W probe).
|
||||
- **Cost.** A battery whose idle reading is beyond about +-170 W is centred
|
||||
at most 75 W out, so its refusal falls outside the band: it behaves as in
|
||||
0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its
|
||||
clamped centre. B is caught across -150..+90 W of error in its idle
|
||||
reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2.
|
||||
- A frozen register is still caught by the stale gate at 35 s of asking.
|
||||
- Recovery from a wrong value needs quiet time: tau 30 min.
|
||||
|
||||
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
|
||||
|
||||
| refusing | meter 1 s | 2 s | 5 s |
|
||||
|---|---|---|---|
|
||||
| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) |
|
||||
| A, N=2 | 28 (28) | 28 (28) | 110 (110) |
|
||||
| B, N=1 | 28 (never) | 28 (never) | 65 (never) |
|
||||
| B, N=2 | 24 (never) | 24 (never) | 60 (never) |
|
||||
|
||||
A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once
|
||||
A is cut, so the loop cycles on the 15 s floor; the same run on the PR before
|
||||
SAFETY-97 gives 110 s. B restored after a restart, refusing from the start:
|
||||
24 s; with nothing saved: never.
|
||||
|
||||
## Before -> after
|
||||
|
||||
- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s
|
||||
meters, both register shapes, idle learned or restored (96 runs): 0 stale,
|
||||
0 refused. Reversals, lag 10/20 s, idle restored: 0.
|
||||
- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched).
|
||||
@@ -1,62 +0,0 @@
|
||||
# SMA-10 - two idle states: hold 0 W, or hand the inverter back
|
||||
|
||||
**Status: for review.** Tests: `test_sma10_two_idle_states.py`. Source: SMA-9
|
||||
report sections 1.4, 3.2; owner decision 2026-10-20 (Q1, "as proposed").
|
||||
|
||||
A unit is idle in one of two ways, and the driver says which:
|
||||
`Driver.hands_back`. **False (GoodWe):** the add-on holds 0 W, `release()` is
|
||||
`write(0)` and the Controller never calls it, so Stop keeps today's 0 W write and
|
||||
today's 0 W heartbeat. **True:** `release()` gives the unit back to whatever
|
||||
steers it by itself (SMA energy management), once, and then nothing is written
|
||||
to it until control starts again.
|
||||
|
||||
**Why two.** When the add-on goes away (Stop, shutdown, SIGTERM, restart, task
|
||||
death, a config refusal) nobody is left to hold a number, so the unit goes back
|
||||
to its own controller. When the add-on itself decides to stop (sign trip, site
|
||||
sign, circulating power, a refused or non-finite write, missing readings,
|
||||
another writer, an inverter fault) it has just said it cannot trust that
|
||||
battery, and handing it to something else would let that something do what it
|
||||
likes with it. Those hold 0 W through `write(0)` for every unit, whatever its
|
||||
driver. Shadow mode on a unit that hands back uses `release()`: it must leave
|
||||
the unit alone, and 0 W would be a command.
|
||||
|
||||
**Where.** `Controller.command_site_release()` sits beside `command_site_zero()`
|
||||
(unchanged). Stop and `shutdown()` call it; the two maintenance-abort paths keep
|
||||
zero (the add-on runs on, the sentence says 0 W for a moment). With no
|
||||
hand-back unit it is `command_site_zero()`. `_write_unit()` also refuses to write
|
||||
a hand-back unit while the add-on is idle (stopping, stopped, or shadow mode): it
|
||||
releases once and returns, so the heartbeat and the cycle's own "stopped" 0 W
|
||||
cannot reach it. A safety stop runs while control runs, so it falls through to
|
||||
the write. Start re-arms: `released` drops and `last_write` clears.
|
||||
|
||||
**Bounds.** `RELEASE_BUDGET_S` equals `WRITE_BUDGET_S` (4 s): units are released
|
||||
together, so one slow unit costs the kill window no more than a hung GoodWe
|
||||
write does. A release at the bound is abandoned, not awaited. A release that
|
||||
raises, hangs or returns false leaves that unit un-handed (the next idle write
|
||||
tries again) and does not touch the others. Exit 78 before the Controller
|
||||
exists has no driver to release and keeps today's path.
|
||||
|
||||
**Words.** Every sentence that said "set to 0 W" for the add-on going away or
|
||||
for shadow mode is built from the roster in `app/handback.py` (EN and NL): a
|
||||
roster with no hand-back unit gets None and keeps its old literal, so a GoodWe
|
||||
site sees no changed byte. A hand-back unit's words (`Driver.words`: `back`,
|
||||
`back_nl`) come from its driver; one without them gets "its own control" /
|
||||
"de eigen sturing" (the article as in the generic clause; PR #239 review
|
||||
comment 2495, not the brief). MQTT `status` is unchanged; the Home battery
|
||||
row of a handed-back unit says "Back under SMA control" and carries
|
||||
`handed_back`.
|
||||
|
||||
**Not done here (SMA-4, SMA-5, SMA-11):** any SMA/FoxESS driver, the read-back
|
||||
after `release()`, per-driver timing, DOCS.md's safety model and the
|
||||
"control stalled" sentence, the site-level "Held at 0 W" tiles in shadow mode on
|
||||
a mixed roster, and the MQTT per-unit setpoint of a handed-back unit (it still
|
||||
publishes 0).
|
||||
|
||||
**After a failed release** (returns False, raises or hangs past the bound) the
|
||||
unit gets one `write(0)` through `Driver.release_fallback()` (default
|
||||
`write(0)`; a driver where 0 W is not always safe overrides it), then nothing:
|
||||
a repeated 0 W would keep resetting the inverter's own fallback timer, so after
|
||||
that the unit is left to its own fallback. The release is retried once per
|
||||
heartbeat; one error line and one action-log event per failure streak, one line
|
||||
on recovery. Worst case at shutdown is `RELEASE_BUDGET_S` + `FALLBACK_BUDGET_S`
|
||||
(6 s).
|
||||
@@ -1,55 +0,0 @@
|
||||
# SMA-2 - each inverter is reached through a driver
|
||||
|
||||
**Status: for review.** Tests: `test_sma2_driver.py`. Source: SMA-9 report
|
||||
section 1.1. A pure refactor: no behaviour change for GoodWe, no change to a
|
||||
command, a log line or a sentence the add-on shows.
|
||||
|
||||
Every place that read or steered an inverter used the GoodWe ESPHome entities
|
||||
directly, so a second make meant touching all of them. `app/drivers.py` now
|
||||
holds one `Driver` per roster entry (`Inverter.driver`): `configured()`,
|
||||
`actuator_id()`, `limits()`, `read()`, `write()`, `release()`, `probe()`, and
|
||||
`link_stale_s`, `batt_bound`, `settle_s`, `brand`, `words`. `GoodWeEsphomeDriver`
|
||||
makes the same calls in the same order as the code it replaced; its
|
||||
`release()` is `write(0)`.
|
||||
|
||||
**Moved to the driver:** the SoC and power read (`_read_unit`), the write
|
||||
(`_write_unit`, still under the SAFETY-28 gate, the clamp, shadow mode and the
|
||||
write budget), the device range (`run_control`, `amain`, the settings panel),
|
||||
liveness and its window (`has_writer`, `is_live`, `link_note`), the freshness
|
||||
bound, the status rows' "is a source set" test, rule 2's per-unit range check
|
||||
and SAFETY-28's census. **Not moved:** the clamp, the gate, the heartbeat and
|
||||
`command_site_zero()`; stop is still 0 W for every unit, and nothing calls
|
||||
`release()` yet (SMA-10). `settle_s` and the control constants are not read
|
||||
from the driver yet either: with only GoodWe they would change nothing.
|
||||
|
||||
**The seam is not TCP-shaped.** The census asks each entry's driver for its
|
||||
actuator id: the setpoint entity for GoodWe, `host:port:unit_id` for a driver
|
||||
reached over a socket, so two units behind one gateway are two actuators and
|
||||
one unit named twice is the SAFETY-28 refusal. The census still counts every
|
||||
`setpoint_entity` key as before and adds the driver's own actuator to it (parity
|
||||
with 23ff9d0 on 349 option shapes is a test). The driver binds its Home
|
||||
Assistant on first use by the Controller, so a unit added later is wired too.
|
||||
|
||||
**Schema, and why the new keys are not in it.** The brief asks for
|
||||
`driver`, `host`, `port`, `unit_id`, `maintenance` per entry and for
|
||||
`setpoint_entity` to become optional. Owner decision 2026-11-07: clients see
|
||||
nothing new until SMA-4/SMA-7 use the keys. The Supervisor form shows every
|
||||
schema key (and marks required ones), and a nested entry with a key the schema
|
||||
lacks has that key silently dropped (measured, shape probe section K), so the
|
||||
only way to hide them is to keep them out of config.yaml. So for now config.yaml, the
|
||||
translations and the labels are exactly 23ff9d0's, and `setpoint_entity` stays
|
||||
required there: until a driver without one is selectable, no entry can lack it.
|
||||
The code underneath is the brief's: the driver defaults to `goodwe_esphome`,
|
||||
rule 9(d) is per driver (a GoodWe entry without a setpoint entity is refused,
|
||||
same sentence), and rule 9(h) checks `driver`, `host`, `port`, `unit_id`,
|
||||
`maintenance` when a caller that skips the schema (the panel's save path,
|
||||
sim/inject, the checks) supplies them; those sentences name the key, having no
|
||||
label. When SMA-4 needs them: add them to the schema as optional with
|
||||
translations, make `setpoint_entity` `str?`, and add them to `uilayout` as card
|
||||
fields or a hidden list.
|
||||
|
||||
**Checked against 23ff9d0:** replay digests and a log of every Home Assistant
|
||||
call are byte-identical at N=1 and N=2; the TEL-07 announce diff is 0/0/0; a
|
||||
fake driver beside a GoodWe unit allocates, grades, drops and publishes per
|
||||
unit; a call site that goes round the seam is caught.
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
# TARIFF-02 - price provider plugins, and the canonical shape (with later additions)
|
||||
|
||||
**Status: SHIPPED.** Tests: `test_price.py`, `test_price_service.py`. Written
|
||||
down by MAINT-20 from the long comments of `app/price.py` so the module can stay
|
||||
short. Related: [[TARIFF-07]], [[TARIFF-09]], [[TARIFF-10]], [[TARIFF-14]],
|
||||
[[TARIFF-15]], [[PRICE-01]].
|
||||
|
||||
## Where it lives
|
||||
|
||||
specs.md 2.2 puts this at `ems/ingest/prices/`; that layout is not being built.
|
||||
Owner decision 2026-08-30: everything ships in `goodwe_controller/app/`. The
|
||||
behaviour in specs.md 9.3 / 8.3 / 11.4 is unchanged and authoritative.
|
||||
|
||||
## The canonical format (9.3)
|
||||
|
||||
`{"prices": [{"start": "2026-01-15T00:00:00+01:00", "eur_per_kwh": 0.1834,
|
||||
"provisional": false}]}`. `start` is tz-aware at the Brussels offset for that
|
||||
instant, the price is EUR/kWh and all-in, `provisional` is per point (8.3: no
|
||||
irreversible action on a provisional price). Three fields; a fourth would change
|
||||
the wire format every store, fixture and captured body is written against.
|
||||
|
||||
## Three rules the module is shaped by
|
||||
|
||||
1. **Double-counting markup is refused, given a correct declaration.** A plugin
|
||||
returns `RawPoint`s with the figure its source published and declares in
|
||||
`capabilities().sources` which of {ENERGY, MARKUP, TAXES} it already holds.
|
||||
`assemble()` (framework only) adds the complement from `price_markup_eur_kwh`
|
||||
/ `price_taxes_eur_kwh`. A plugin cannot reach those two numbers by any route:
|
||||
`PluginOptions` raises `FrameworkOption`, and review of PR #16 wrote a
|
||||
double-counting plugin three more ways (`opts._o`, `price_option()`, and the
|
||||
public `PRICE_OPTION_DEFAULTS`), so `register()` also refuses any plugin
|
||||
whose own code mentions a framework key (`code_leaks()`). The guarantee is
|
||||
exactly this: the complement is added once *given a correct `sources`*. A
|
||||
plugin declaring `{ENERGY}` for an all-in feed comes out one markup high and
|
||||
no code can catch that. test_price.py checks that a plugin's docstring lines
|
||||
(`SOURCES:` / `The framework ADDS:`) agree with its declaration, not that
|
||||
either is true. `code_leaks()` strips docstrings before searching (they must
|
||||
name the components) and fails open on missing source (a frozen build must
|
||||
still start; CI asserts source is found for every shipped plugin).
|
||||
2. **The product type is declared, not inferred from the id.** `capabilities().
|
||||
product` is a `PriceProduct`, rides on the series, and `volatility_regime()`
|
||||
reads it (8.3: real-time products widen thresholds). A plugin id appears only
|
||||
in `PROVIDERS`, `RETIRED_PROVIDERS` and its own class (AC2). No shipped plugin
|
||||
declares `REALTIME` since [[TARIFF-07]]; `_WIDE`, `Uncertainty.REALTIME_PRODUCT`
|
||||
and `WIDEN_FACTOR`'s 2.5 are kept, and test_price.py section 10 declares
|
||||
REALTIME from a class outside this file to keep them live.
|
||||
3. **"No price data" is a type, not an empty list.** 11.4: no data means static
|
||||
profile, provisional plan, no arbitrage; all-zero prices mean arbitrage is
|
||||
merely pointless. `PriceRead.series()` refuses an empty list;
|
||||
`PriceRead.no_data(reason)` carries `.reason` and nothing else, so
|
||||
`min(read.points)`, `for p in read.points` and `.get("points", [])` raise
|
||||
`Absence`; `.match(*, series, no_data)` takes both handlers as required
|
||||
keywords. Same shape as `sim/riglock.py` after TEST-04 (read its `Absence`
|
||||
block before changing this). Two ways past it survive and are not worth code:
|
||||
`vars(read).get("points", [])`, and `bool(read)` being True for both
|
||||
variants (a falsy `no_data` would turn `if read:` into a guard by accident;
|
||||
use `.match`).
|
||||
|
||||
## Timezone without `zoneinfo`
|
||||
|
||||
The Alpine image has no `tzdata`, so `ZoneInfo("Europe/Brussels")` would raise
|
||||
on the client's Pi. Every instant is UTC and rendered at its fixed offset, so
|
||||
the October 02:00-03:00 hour that happens twice is two instants (`02:00+02:00`
|
||||
and `02:00+01:00`); a naive-local-hour key or a 24-slot array is wrong on both
|
||||
transition days (test_price.py counts points on both 2026 transitions). The EU
|
||||
rule: CEST from 01:00 UTC on the last Sunday of March to 01:00 UTC on the last
|
||||
Sunday of October. Review of PR #16 matched it against `zoneinfo` over 149 040
|
||||
hourly instants 2020-2036 with zero mismatches. Residual risk is political (EU
|
||||
abolishes the change): one edit in `brussels_offset()`.
|
||||
|
||||
## The feed range (TARIFF-09)
|
||||
|
||||
`generic_dayahead.fetch()` used to ignore `since`/`until` and request the bare
|
||||
URL, so the feed answered ONE Brussels day and 8.3 dropped everything past today
|
||||
as provisional: at 09:00 over a 48 h horizon 15 firm and 33 carried, at 23:00 1
|
||||
firm and 47 carried. `feed_days()` returns the Brussels calendar days (first,
|
||||
last, both inclusive; the API takes dates) that the half-open `[since, until)`
|
||||
touches: Brussels, never UTC (22:30 UTC in summer is already tomorrow); `until`
|
||||
exclusive, so local midnight belongs to the day before; an empty or inverted
|
||||
window collapses to one day. `api.energy-charts.info` capture:
|
||||
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`.
|
||||
`dated_feed_url()` refuses a base that already has a query string (appending
|
||||
would give `?bzn=BE?bzn=BE&start=...`). An operator's `price_feed_url` override
|
||||
is used verbatim and loses the range, on purpose: rewriting an operator's URL
|
||||
reorders and re-escapes it, and both providers declare a second host
|
||||
(`web-api.tp.entsoe.eu`) whose parameters are not `start`/`end`. The cost is
|
||||
logged at fetch time, naming the horizon consequence. The URL rule is shared by
|
||||
`generic_dayahead` and `custom_formula` (TARIFF-15), taking `plugin_id` as an
|
||||
argument so AC2 holds. The "range is used" promise is not mechanically enforced
|
||||
(a rule would be satisfied by an invented parameter name); test_price.py pins
|
||||
which plugins consume the range as a literal roster.
|
||||
|
||||
## Absence and the covering window
|
||||
|
||||
See [[TARIFF-14]] for the cover table: a point covers from its own start to the
|
||||
next point's start, never more than an hour; the last point gets the span of the
|
||||
one before; sorted at construction. `covers()` delegates to `at()` (a second copy
|
||||
of the rule is a second place to be wrong); `window_points()` grades only the
|
||||
`start` against `[since, until)` and is not a coverage query. `at()` is a linear
|
||||
scan over about 192 entries on the 30 s path; a bisect is not worth a second
|
||||
way to be wrong. A duplicated start (not reachable through `load_prices()`)
|
||||
gives the earlier entry a zero-width window.
|
||||
|
||||
## Taxes components (TARIFF-18)
|
||||
|
||||
`price_taxes_eur_kwh`'s default (0.06 before) is the sum of
|
||||
`FLEMISH_TAXES_COMPONENTS_EUR_KWH`, each row one line of the Eneco residential
|
||||
card for Flanders, September 2026, including 6 % VAT, in EUR/kWh;
|
||||
test_price.py section 1b re-adds them. It is per DSO: the distribution row runs
|
||||
5,28 c (Midden-Vlaanderen) to 6,70 c (West), so the all-in Flemish total runs
|
||||
about 0.117 to 0.131; a site outside Antwerpen types its own. The default sits
|
||||
near the low end on purpose: the scalar cancels in `max - min`, and its one
|
||||
decision is the 17 negative-price guard in arbitrage.py, which tests the
|
||||
absolute all-in sell price. Too high carries a genuinely negative hour above
|
||||
zero (export into paid-for disposal); too low only declines a trade that would
|
||||
have paid. `energiebijdrage` is a row of zero: from 1 August 2026 it is merged
|
||||
into the bijzondere accijns, and the zero row makes the double-count a visible
|
||||
decision. Moving a shipped default changes which configs start: TARIFF-17's rung
|
||||
in validate.py refuses `price_taxes_profile` together with a non-default scalar,
|
||||
comparing against the default (the Supervisor merges every schema key into the
|
||||
stored blob, 70 of 71 keys on the reference install, CHANGELOG 0.9.0), so a
|
||||
superseded shipped default (0.06) must still read as untouched, and the rung's
|
||||
sentence names both figures.
|
||||
|
||||
## One in-code mirror of option defaults (CFG-03)
|
||||
|
||||
Option defaults live in config.yaml's `options:` and `schema:` and main.py's
|
||||
`.get()` fallbacks. `PRICE_OPTION_DEFAULTS` is the single in-code mirror for the
|
||||
price options: validate.py imports it, main.py has no `o.get("price_...", X)`
|
||||
fallbacks, no dataclass carries a copy, and test_price.py section 1 reads
|
||||
config.yaml and asserts equality key for key. `static_profile` (TARIFF-03),
|
||||
`price_taxes_profile` (TARIFF-17), `price_formula` (TARIFF-15),
|
||||
`price_export_formula` (TARIFF-16) and the OPT-04 keys live there for the same
|
||||
reason, not in their own modules (a second dict would be a fourth location).
|
||||
Details:
|
||||
|
||||
- The tables / formulas are in `FRAMEWORK_KEYS`: a plugin reading one could add
|
||||
taxes a second time per hour, or fold an injection curve into a consumption
|
||||
price. `BANNED_IN_PLUGIN_CODE` derives from that set.
|
||||
- `arbitrage_soc_floor` / `arbitrage_soc_ceiling` are not the maintenance keys
|
||||
(a balancing cycle is not a trading reserve).
|
||||
- `cycle_cost_threshold_eur` is 0.10 because 10.1's name says so, not because it
|
||||
makes anything trade: on Belgian day/night numbers the static spread is about
|
||||
0.08, so a static-tariff site does not trade at this default (test_arbitrage.py
|
||||
section 7). Whether 0.10, or FCST-03's 2.5 / 1.5, is right is an owner
|
||||
decision.
|
||||
- `price_service_token` is a secret (`SECRET_OPTIONS`); `scrub()` removes secret
|
||||
values from any text built from an error.
|
||||
|
||||
## Widening (FCST-03)
|
||||
|
||||
One path, a set of active uncertainty sources (real-time product 8.3, no weather
|
||||
data 11.4). The combination rule is `max`, not a product (2.5 x 1.5 would give a
|
||||
threshold nothing on a Belgian tariff clears). The factors are policy defaults,
|
||||
not derived (no arbitrage ever ran); they differ so `max`, `sum` and `product`
|
||||
are distinguishable in a check. `widen_factor()` refuses a string (a silent 1.0
|
||||
would report a widening that is not applied).
|
||||
|
||||
## Injection price (AC4, TARIFF-16)
|
||||
|
||||
`export_price_eur_kwh()` is a separate scalar. It was said to be so because
|
||||
nothing dynamic exists to route; that was false (Eneco publishes `0,1 x BELPEX-H
|
||||
- 1,188`, ENGIE `(B x eSpot_15) - A`). The true reason: an injection curve has
|
||||
its own coefficients and VAT treatment (injection is VAT-exempt, consumption is
|
||||
6 %). TARIFF-16 added `price_export_formula`, read by `export_formula()` through
|
||||
the same parser as the consumption leg (`parse_export_formula()` is
|
||||
`parse_price_formula()` with its option name moved). `export_price_at()` falls
|
||||
through to the scalar and ignores the index, correctly. A negative answer is a
|
||||
real answer (Eneco's formula is below zero for any index under 11,88 EUR/MWh);
|
||||
nothing floors it. Nothing under `app/` calls either reader to take a decision:
|
||||
arbitrage prices the sell side as displaced import. A malformed leg is refused at
|
||||
startup by validate.py; `taxes_profile()` and `export_formula()` still handle it
|
||||
loudly at run time. `static_tariff` is imported lazily in `taxes_profile()`
|
||||
because it imports this module at module scope.
|
||||
|
||||
## Taxes at each point's own instant (TARIFF-17)
|
||||
|
||||
`taxes_at()` takes a scalar or a period table (duck-typed on `price_at`, because
|
||||
price.py must not import static_tariff.py). A non-flat component matters: ORES
|
||||
bills `dag 13,27 / nacht 7,39` c, a 5,88 c gap coinciding with the cheap spot
|
||||
hours, and flattening it understates the spread. `assemble()` must evaluate it
|
||||
per point; hoisting the lookup out of the loop collapses the feature back into a
|
||||
level (test_price.py section 24 grades which hour is cheapest, since a
|
||||
spread-magnitude assertion passes on the hoisted version). On a scalar, `0.0 +
|
||||
markup` is exactly `markup` in IEEE 754, so scalar sites get identical bytes.
|
||||
The table wins over the scalar; validate.py refuses configs where both speak.
|
||||
|
||||
## Retired providers (TARIFF-07)
|
||||
|
||||
`RETIRED_PROVIDERS` exists so `eneco_eddy` and friends are not refused as typos:
|
||||
they were shipped, the operator chose them, and every failed fetch fell back
|
||||
silently to `static_profile`. No alias to `generic_dayahead`: they published
|
||||
retail prices (`B x spot + A`) and it reads wholesale; an alias would invent the
|
||||
site's two coefficients (behind the klantenzone login; CREG tariff-card pages
|
||||
404, VREG unreachable). Each value is the id-specific half of the refusal (the
|
||||
endpoint, what it answered on 2026-09-13, why no replacement can be built);
|
||||
validate.py owns the framing. `register()` refuses at import to register a
|
||||
retired id again, because `pid in PROVIDERS` going true silences the named
|
||||
refusal; re-shipping is a decision recorded by removing it from
|
||||
`RETIRED_PROVIDERS` in the same commit. See [[TARIFF-07]].
|
||||
|
||||
## Parsers (TARIFF-08)
|
||||
|
||||
The only parser is columnar: `https://api.energy-charts.info/price?bzn=BE` gives
|
||||
parallel `unix_seconds` / `price` arrays plus `unit`. The earlier row parser read
|
||||
`doc["data"]`, a shape no feed ever published, graded by fixtures written from
|
||||
the same guess, so every fetch ended `SourceUnavailable` and 11.4 fell back to
|
||||
static silently. Two shapes, two functions: a parser that sniffs can decide
|
||||
wrong, and a wrong-but-parseable response becomes a price.
|
||||
`_points_from_columnar_json()` refuses:
|
||||
|
||||
- unequal arrays (`zip()` would truncate the evening peak, and a series missing
|
||||
the expensive hours is traded on while no-data switches arbitrage off);
|
||||
- a unit other than the declared one (EUR/kWh under the same keys would be 1000
|
||||
times too small and `MAX_PLAUSIBLE_EUR_KWH` bounds only the large side);
|
||||
- naive time: `unix_seconds` are UTC (`fromtimestamp(t, timezone.utc)`).
|
||||
Rendering at the Brussels offset is `assemble()`'s, once, for every plugin.
|
||||
|
||||
A `null` price is dropped and logged (the pairing is reliable, the source said
|
||||
it published none; `_Series.at()` answers the hole with `Absence`);
|
||||
`scenarios/fetch_epex_be.py` dropped 0 nulls in 12 months to 2026-08-31. The
|
||||
feed is quarter-hourly since Belgium's 15-minute MTU (2025-10-01); nothing
|
||||
assumes either granularity (test_price.py section 2b replays both).
|
||||
`_validate_raw()` accepts negative prices (windy-Sunday hours are the year's best
|
||||
charging; 17 keeps the regression) and rejects NaN, infinity, an implausible
|
||||
magnitude, naive datetimes, and duplicate or out-of-order starts.
|
||||
|
||||
## Transport and plugin details
|
||||
|
||||
- `check_host()` splits the scheme before parsing the host. The first version
|
||||
stripped `len("https://")`, which on `http://` ate two characters of the host
|
||||
(`pi.energy-charts.info`), so a non-TLS feed failed the allow-list and the check
|
||||
"plain http is refused" passed even with the TLS rule deleted. Found by
|
||||
mutation in review of PR #16.
|
||||
- The default transport imports aiohttp lazily so `manual_csv` works with no HTTP
|
||||
stack. With `headers` (price_service only) it answers in the safe words of
|
||||
`status_words()` and follows no redirects (the host is fixed; a redirect would
|
||||
take the token somewhere the allow-list never saw).
|
||||
- `manual_csv`: the CSV is all-in (the plugin cannot know what a typed number
|
||||
contains); `load_prices()` warns when markup / taxes are set non-zero under a
|
||||
plugin that sources them. No transport, aiohttp, network or tz database: it is
|
||||
the offline escape hatch and the TEST-01 replay ingest path.
|
||||
- `custom_formula` (TARIFF-15) declares `{ENERGY, MARKUP}`, not `ALL_IN`: a
|
||||
card's `Tariefformule` is the energy component only (Eneco tabulates network
|
||||
tariffs separately; ENGIE excludes network, transport, levies, excise), so
|
||||
`ALL_IN` would drop about 12 c/kWh. The risk left is an operator folding
|
||||
network charges into `offset`; the card self-check in custom_formula.py refuses
|
||||
that, and grades the coefficients against the card, not the card against
|
||||
reality. No expression evaluation: the string is settable over HTTP through
|
||||
the ingress config page, so `eval` would be RCE in a root container holding
|
||||
`SUPERVISOR_TOKEN`. It reuses `generic_dayahead`'s feed, URL and parser. It
|
||||
keeps `scale=1.0`, so its `RawPoint`s carry EUR/MWh in a field called
|
||||
`eur_per_kwh`: contained (destructured on the next line, never leaves the
|
||||
method; `_validate_raw()` would refuse an escape).
|
||||
- `startup_preview()` is a hook (TARIFF-15), not a line in `load_prices()`: only
|
||||
the plugin knows what went into its arithmetic, and the index beside the
|
||||
all-in figure makes a factor of 1000 obvious. It must not raise into the control
|
||||
path; the id is recorded before the hook runs so a failing preview is not
|
||||
retried every 30 minutes.
|
||||
- `load_prices()` returns, never raises, except `FrameworkOption` (a plugin trying
|
||||
to read the markup; turning it into "no price data" would hide the defect).
|
||||
- `price_provider_id()` is one spelling of the selected id because the same
|
||||
expression was written at three call sites (TARIFF-10). validate.py keeps its
|
||||
own reader on purpose: it validates the raw blob.
|
||||
- `price_provider_selected()` (TARIFF-10): the background refresh used to start
|
||||
only when `arbitrage_enabled` (ships false) was on, so a configured site
|
||||
fetched nothing (40 h soak: zero feed requests, `present: false` on all 802
|
||||
samples). It is not a trading switch: `arbitrage.enabled()` answers that.
|
||||
- `price_service` (PRICE-01): `FALLBACKS` and `KEEP_LAST` are empty/False for
|
||||
every earlier plugin, so `price_chain.py` is not used for them.
|
||||
`TOKEN_REFUSED` is the one phrase the fallback chain reads to tell "the owner
|
||||
must act" from "the server is down". `covers` lists complete days only; an hour
|
||||
missing inside a day is a hole, not a free hour.
|
||||
@@ -1,230 +0,0 @@
|
||||
# TARIFF-07 — three plugins that never fetched a price, and the one mechanism they leave behind
|
||||
|
||||
**Status: DECIDED 2026-09-13 — withdraw `engie_dynamic`,
|
||||
`totalenergies_dynamic` and `eneco_eddy`. Keep `PriceProduct.REALTIME` and the
|
||||
widening machinery. Refuse a retired id by name rather than aliasing it.**
|
||||
|
||||
Filed with TARIFF-07. Follows [[TARIFF-08]], which found the same class of
|
||||
defect one layer down — a parser graded against its own guess — and left the
|
||||
question of these three open.
|
||||
|
||||
---
|
||||
|
||||
## 1. The endpoints, verified rather than taken on report
|
||||
|
||||
The card said the three endpoints were dead. They were re-checked by hand on
|
||||
**2026-09-13** before anything was deleted, because "the endpoint is dead" is
|
||||
exactly the kind of claim this repository keeps finding to be second-hand:
|
||||
|
||||
| id | endpoint | observed |
|
||||
|---|---|---|
|
||||
| `engie_dynamic` | `https://api.engie.be/dynamic/be` | **HTTP 404**, body `{"message":"no Route matched with those values","request_id":"..."}` — a Kong gateway with no route configured |
|
||||
| `totalenergies_dynamic` | `https://api.totalenergies.be/prices/dynamic` | **TLS failure**. The handshake completes and the server presents `CN=pge-apigee-dev.direct-energie.com`; `api.totalenergies.be` is not in the SAN list, so the connection is closed before any request is sent |
|
||||
| `eneco_eddy` | `https://api.eneco.be/eddy/prices` | **HTTP 404**, `content-length: 1`, `server: Microsoft-IIS/10.0` — an empty body from a live host that does not serve this path |
|
||||
|
||||
All three failed. Had any one of them answered, this card would have stopped
|
||||
and asked, because an endpoint that answers changes the decision from
|
||||
"withdraw" to "fix the parser".
|
||||
|
||||
## 2. Why this is not "unfinished", and why no replacement can be written
|
||||
|
||||
An unfinished plugin has a right endpoint and a wrong parser. These have
|
||||
neither, and the reason is structural:
|
||||
|
||||
* **ENGIE's dynamic price is `(B × eSpot_15) − A`, and `B` and `A` are
|
||||
PER-CONTRACT.** They live behind the customer's klantenzone login. No
|
||||
unauthenticated public URL can return *a given customer's* price, so the
|
||||
endpoint this plugin wanted cannot exist for anybody to build.
|
||||
* **There is no regulator index to read the coefficients from.** CREG's
|
||||
tariff-card pages 404; VREG was unreachable. Every coefficient found in
|
||||
research came from a supplier's own PDF, which is a document, not a feed.
|
||||
* **"Eddy" is not an Eneco product at all.** Eneco's dynamic product is
|
||||
**"Zon & Wind Dynamisch"**, and it is indexed to **Belpex/EPEX day-ahead** —
|
||||
not to imbalance. The plugin was wrong about the product, the product type
|
||||
and the URL simultaneously.
|
||||
|
||||
So the three were not withdrawn for being broken. They were withdrawn for
|
||||
describing tariffs that no public endpoint can serve.
|
||||
|
||||
## 3. What a site configured on one of these has actually been running
|
||||
|
||||
⚠️ **This is the half that decides what the migration is.** §11.4 turns a
|
||||
failed fetch into a silent fall back: static profile, plan provisional, no
|
||||
arbitrage. That is the *correct* degradation, and it says nothing at all about
|
||||
the cause. Every fetch these three ever made raised `SourceUnavailable`.
|
||||
|
||||
Therefore a site configured on one of them has been running on
|
||||
`static_profile` — **or on nothing at all, where no profile was set** — since
|
||||
the day the id was entered, and **nothing on its status page has ever differed
|
||||
from a working provider that simply never found a cheap hour**.
|
||||
|
||||
The migration is therefore **not** "keep their prices working". There were no
|
||||
prices. It is *"tell them their prices never worked"*, which is why the answer
|
||||
is a refusal and not a fallback.
|
||||
|
||||
### Why not a silent alias to `generic_dayahead`
|
||||
|
||||
It is the friendlier-looking option and it is wrong. These published **retail**
|
||||
prices; `generic_dayahead` reads **wholesale**. Aliasing needs the site's own
|
||||
`B` and `A`, which §2 has just established nobody has. An alias would have to
|
||||
**invent the numbers a battery is priced against** — the same defect as a
|
||||
parser graded against its own guess, one layer further from anything that
|
||||
reports. The scalar `price_markup_eur_kwh` cannot stand in for `B` either: it
|
||||
is an addend, not a multiplier, and it is `float(0,1)` so it cannot even hold a
|
||||
negative `A`.
|
||||
|
||||
## 4. ⚠️ The `config.yaml` schema is deliberately WIDER than the registry
|
||||
|
||||
`price_provider` is a `list(off|…)` enum, and the Supervisor validates the
|
||||
options file against it **before the add-on starts**.
|
||||
|
||||
So the obvious edit — delete the three ids from the enum along with the classes
|
||||
— has a consequence that runs exactly opposite to this card's purpose:
|
||||
|
||||
```
|
||||
operator's config says price_provider: eneco_eddy
|
||||
|
|
||||
+-- id NOT in the schema enum
|
||||
|
|
||||
+-- Supervisor rejects the options file
|
||||
|
|
||||
+-- add-on never starts
|
||||
|
|
||||
+-- validate.py never runs
|
||||
|
|
||||
+-- the named refusal is NEVER SEEN
|
||||
```
|
||||
|
||||
The operator gets an opaque `expected one of ...` from a layer they did not
|
||||
configure, on **exactly the sites the withdrawal exists to inform**.
|
||||
|
||||
**Decision: keep all three ids in the schema enum.** `PROVIDERS` shrinks to
|
||||
two; the enum stays at six. The cost is that the dropdown still offers three
|
||||
dead ids to a *new* operator — and that is the cheaper failure, because
|
||||
choosing one is refused at the next startup **by name**, with the endpoint, the
|
||||
observation and the alternative, which is strictly more than a schema
|
||||
rejection would say. Both translations label them withdrawn in the option
|
||||
description.
|
||||
|
||||
`app/price.py`'s `RETIRED_PROVIDERS` is the list. `test_price.py` section 1
|
||||
asserts the schema offers exactly `PROVIDERS | RETIRED_PROVIDERS | {"off"}` —
|
||||
derived, not typed — and `register()` refuses to re-register a retired id, so
|
||||
the registry and the retired table cannot both claim one and the refusal cannot
|
||||
be taken away by an `@register` somebody adds back.
|
||||
|
||||
## 5. ⚠️ `PriceProduct.REALTIME` and the widening path are KEPT with no shipped declarer
|
||||
|
||||
`eneco_eddy` was the **sole** declarer of `PriceProduct.REALTIME`. Deleting it
|
||||
orphans:
|
||||
|
||||
* `_WIDE = frozenset({PriceProduct.REALTIME})`
|
||||
* `Uncertainty.REALTIME_PRODUCT`
|
||||
* `WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT] = 2.5`
|
||||
|
||||
**They stay.** The reasoning, stated here so the next reader finds intent
|
||||
rather than an apparent dead branch:
|
||||
|
||||
1. A reviewer finding a mechanism with **zero callers** would reasonably delete
|
||||
it. That is good instinct applied to a missing fact, and this file is the
|
||||
fact.
|
||||
2. It is the **AC2 seam**. The whole point of `capabilities().product` is that
|
||||
a new supplier is a new class and nothing else: declare `REALTIME`, get the
|
||||
wide regime, edit no framework code. Deleting the enum member would mean the
|
||||
first genuine imbalance product has to **rebuild** that seam — and rebuilding
|
||||
it is precisely the "editing the optimiser to add a supplier" that AC2
|
||||
forbids.
|
||||
3. It costs nothing. Two enum members and a dict entry.
|
||||
|
||||
**⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME` UNTIL ONE DOES.** Both
|
||||
shipped plugins (`generic_dayahead`, `manual_csv`) are day-ahead. §9.2's row 2
|
||||
is unoccupied. That is not the same as it having no mechanism, and the
|
||||
difference is what this section exists to record.
|
||||
|
||||
It is **pinned, not merely documented**. `test_price.py` section 5 asserts:
|
||||
|
||||
* that **no shipped plugin** declares `REALTIME` — so if one ever does, the
|
||||
check goes red and the right fix is to **edit this record**, not to delete
|
||||
the seam;
|
||||
* that the widening still works **from a declaration alone** —
|
||||
`volatility_regime`, `uncertainty_of` and `widen_factor` are graded on a
|
||||
`ProviderCapabilities` the test file builds;
|
||||
* and section 10 registers `_NewSupplier`, a class defined **outside
|
||||
`price.py`** that declares `REALTIME` and is asserted to land in the wide
|
||||
regime. That class is now the only live declarer in the tree, which makes the
|
||||
seam something this suite exercises end to end rather than a constant it
|
||||
reads.
|
||||
|
||||
## 6. What else went, and one thing that did not
|
||||
|
||||
`_points_from_json()` went with the three. It read a `{"data": [{...}]}`
|
||||
row-per-point document that **no feed anybody here has ever reached
|
||||
publishes** — the shape was invented alongside these plugins, and the only
|
||||
bodies ever fed to it were fixtures written from the same guess. TARIFF-08
|
||||
found that loop on the response side for `generic_dayahead`; this is the rest
|
||||
of it. `needs_credentials` went too: the three were its only setters and
|
||||
nothing read it.
|
||||
|
||||
The shape survives in **one** place and in one direction: `row_shape_body()` in
|
||||
`test_price.py`, feeding `generic_dayahead` so that section 9 grades a row
|
||||
document as a **failure** for it rather than a second dialect it might sniff.
|
||||
It is named for what it is.
|
||||
|
||||
⚠️ **One check had to be rebuilt rather than deleted, and saying so matters.**
|
||||
`test_price.py` section 4b proved that `code_leaks()`'s docstring strip is
|
||||
load-bearing by finding a shipped docstring that a raw text scan would
|
||||
false-positive on. The docstring that did that was **`engie_dynamic`'s** — it
|
||||
spelled `price_markup_eur_kwh` in full. With it gone, no shipped docstring
|
||||
spells a full framework key, so the check would have **passed on a tree with
|
||||
the strip deleted**: a check that had quietly stopped being one, which is the
|
||||
third failure mode in `MUTATION.md`. The stimulus is now constructed in the
|
||||
test file (`_DocumentedPlugin`), and the shipped set is asserted clean
|
||||
separately.
|
||||
|
||||
## 7. Mutations driven
|
||||
|
||||
Every check added or rebuilt here was driven red. Harness:
|
||||
`/home/homelab/.cache/t07-mut/`, which rebuilds the tree from the worktree and
|
||||
purges `__pycache__` per `MUTATION.md`'s rule.
|
||||
|
||||
| | mutation | Δ | red on |
|
||||
|---|---|---|---|
|
||||
| M1 | delete the retired branch from `_price_problems()` | −2785 B | 7 checks in `test_price.py` |
|
||||
| M2 | move the retired branch **after** the unknown-id branch | **+0 B** | the same 7 |
|
||||
| M3 | make `been_running` constant (drop the empty-`static_profile` case) | −212 B | 2 |
|
||||
| M4 | drop the "WHAT TO DO" half of the refusal | −874 B | 1 |
|
||||
| M5 | `RETIRED_PROVIDERS = {}` | −1099 B | 5, then a traceback |
|
||||
| M6 | drop the three ids from `config.yaml`'s enum — §4's trap | −47 B | 2 |
|
||||
| M7 | delete `register()`'s retired guard | −1101 B | 2, then a traceback |
|
||||
| M8 | `_WIDE = frozenset()` | −23 B | 3 + 4 across two suites |
|
||||
| M9 | `WIDEN_FACTOR[REALTIME_PRODUCT]` 2.5 → 1.5 | **+0 B** | 1 + 1 |
|
||||
|
||||
⚠️ **M2 and M9 have a byte delta of zero**, which `MUTATION.md` names as the
|
||||
risky case. The harness rebuilds the tree from source on every run and purges
|
||||
`__pycache__`, so there is no inherited cache to read; and stale bytecode
|
||||
manufactures **survivors**, never reds, so a red from a `+0 B` mutation is not
|
||||
at risk from that defect. M2's grep confirmed the branch had moved on disk.
|
||||
|
||||
⚠️ **M5 and M7's red counts are LOWER BOUNDS.** Both runs ended in a traceback
|
||||
with checks after it ungraded — FCST-06's rule: read the output, not the count.
|
||||
M7's traceback is itself informative: with the guard gone the re-registration
|
||||
**succeeds**, `PROVIDERS` is polluted with a class that has no fixture, and the
|
||||
suite dies downstream. That is the failure the guard prevents, arriving loudly.
|
||||
|
||||
## 8. What this card did not do
|
||||
|
||||
* **It did not pick a version number.** The change is backward-incompatible — a
|
||||
config legal on the current version is refused on the next — and the
|
||||
CHANGELOG says so prominently, in the shape PKG-07 established for `slew_w`.
|
||||
Which number it belongs to depends on whether the current image has already
|
||||
been built and pulled, which is a fact about the live box that this branch
|
||||
cannot observe. Whoever deploys decides.
|
||||
* **It did not implement the formula provider.** `B × spot + A` with per-site
|
||||
coefficients is the real answer for a Belgian dynamic customer and is a
|
||||
separate card. `DOCS.md` references it as planned and is explicit about what
|
||||
`generic_dayahead` can and cannot express in the meantime — in particular
|
||||
that it cannot represent a negative offset, and that
|
||||
`price_markup_eur_kwh: float(0,1)` could not hold one anyway.
|
||||
* **It did not rewrite released CHANGELOG history.** The two historical entries
|
||||
that called `eneco_eddy` an imbalance product are annotated in place with a
|
||||
correction naming this card, rather than silently edited. The record of what
|
||||
the project believed is part of the record.
|
||||
@@ -1,141 +0,0 @@
|
||||
# TARIFF-09 — asking the feed for the horizon, and what `price_feed_url` means
|
||||
|
||||
Two decisions. The first had no defensible alternative once it was measured;
|
||||
the second did, and the alternative is argued below rather than dismissed.
|
||||
|
||||
---
|
||||
|
||||
## 0. The measurement this card exists for
|
||||
|
||||
`generic_dayahead.fetch()` accepted `since` and `until` and requested the bare
|
||||
`https://api.energy-charts.info/price?bzn=BE`. That URL names no range, and the
|
||||
feed answers **one Brussels day**.
|
||||
|
||||
Measured on the merged tree (`4257e38`) at 2026-09-10 09:00 CEST, 48 h horizon,
|
||||
live feed, yesterday seeded through the shipped read path:
|
||||
|
||||
```
|
||||
60 firm points (15 whole hours) + 33 carried hours of 48 requested
|
||||
carried: 24 h on 2026-09-11, 9 h on 2026-09-12
|
||||
arbitrage(0.10): allowed, spread 0.4645, buy 11:45 and sell 19:45 -- BOTH on 2026-09-10
|
||||
firm local days = [2026-09-10]
|
||||
```
|
||||
|
||||
Everything past today was carry-forward, flagged provisional, and §8.3 drops
|
||||
provisional hours from the trade set. **The optimiser never saw tomorrow as
|
||||
firm, at any hour.** The ceiling was structural rather than incidental: with a
|
||||
one-day window, at hour *H* of the local day a 48 h horizon is at most
|
||||
**24−H hours firm and 24+H carried** — 15/33 at 09:00, and **1/47 at 23:00**,
|
||||
where the remaining firm spread (0.0464 EUR/kWh) no longer even clears the
|
||||
default 0.10 round-trip threshold and arbitrage is refused outright.
|
||||
|
||||
That is not what FCST-04's fill was built for. FCST-04 covers the
|
||||
pre-publication window until the ~13:00–14:00 CET auction, after which a
|
||||
healthy feed hands over tomorrow in full.
|
||||
|
||||
**The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z:
|
||||
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns **192 quarter-hours across
|
||||
two local days**, committed unmodified as
|
||||
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`. The
|
||||
plugin simply never asked.
|
||||
|
||||
After the change, same code path, same window, a feed serving both days:
|
||||
**39 firm hours and 9 carried**, firm across **two** Brussels days, and the
|
||||
permitted cycle buys on one day and sells on the other. Graded in
|
||||
`test_price_history.py` section 1c against both feed states.
|
||||
|
||||
---
|
||||
|
||||
## 1. The range is derived on the **Brussels** calendar, from `since`/`until`
|
||||
|
||||
`since` and `until` are **instants**; the API takes **dates**. Those are not
|
||||
the same question, and the conversion is where the day gets lost.
|
||||
|
||||
`brussels_date_range()` returns two inclusive `date`s:
|
||||
|
||||
* the first is `brussels(since).date()`. **Not `since.date()`, and not the UTC
|
||||
date.** 22:30 UTC in summer is already tomorrow in Belgium, so a UTC-derived
|
||||
start asks for the day the controller has just left and drops the day it is
|
||||
standing in — for the last one or two hours of *every* day. This is
|
||||
`price_history._bdate()`'s lesson one layer up, with a worse consequence:
|
||||
`_bdate()` misfiles an hour, this never fetches it.
|
||||
* the last is `brussels(until).date()`, **stepped back a day when `until`
|
||||
falls exactly on local midnight**, because `until` is exclusive and that
|
||||
instant is the first tick of a day nobody asked for. Without it every
|
||||
whole-hour horizon requests one extra day — harmless on this feed, but it
|
||||
makes the request a claim the caller never made.
|
||||
* an empty or inverted window collapses to a single day rather than producing
|
||||
a backwards range, which no feed has a defined answer for.
|
||||
|
||||
Both ends are graded, and both are driven red by mutation — including the two
|
||||
that swap `brussels()` for the UTC date (`test_price.py` section 2c).
|
||||
|
||||
---
|
||||
|
||||
## 2. `price_feed_url` is the **exact URL**, used as given. The plugin appends
|
||||
nothing.
|
||||
|
||||
The alternative — merge our `start`/`end` into whatever query string the
|
||||
operator supplied — was considered and rejected. Three reasons, in the order
|
||||
they decided it:
|
||||
|
||||
1. **`start`/`end` are energy-charts' spelling, and this provider declares a
|
||||
second host.** `generic_dayahead.capabilities().egress_hosts` is
|
||||
`("api.energy-charts.info", "web-api.tp.entsoe.eu")`. That second host's
|
||||
day-ahead date parameters have not been verified by this card and are not
|
||||
guessed at here — but they are not `start`/`end`. So an override to the
|
||||
*other declared host* — a case the declaration itself anticipates — is
|
||||
exactly where merging appends two parameters that host ignores, producing
|
||||
the short horizon anyway, with a URL nobody typed as the receipt.
|
||||
2. **Merging means re-serialising a URL somebody else wrote.** Parsing and
|
||||
rebuilding reorders parameters, collapses duplicates and re-escapes values.
|
||||
"Override the feed" is what the option already says it means, and an
|
||||
override that is silently rewritten is not one.
|
||||
3. **Blind appending is not on the table at all.** `dated_feed_url()` refuses
|
||||
to add a query string to a base that already has one, so
|
||||
`?bzn=BE?bzn=BE&start=…` cannot be produced by this module even by a later
|
||||
edit that folds the parameters back into the constant.
|
||||
|
||||
⚠️ **The cost is real and it is therefore NOT a doc footnote.** An overriding
|
||||
operator gets exactly the short horizon this card exists to fix. So the plugin
|
||||
emits a `WARNING` on the shipped `goodwe.price` logger at **fetch time**, naming
|
||||
the consequence — one Brussels day, the rest carried and flagged, §8.3 dropping
|
||||
them from the trade set — and naming the `start..end` it *would* have asked
|
||||
for. §11.4 is what makes a price defect quiet; an operator who has taken the
|
||||
short horizon has to be able to find out from the log that they did.
|
||||
|
||||
The warning is graded, with a control that the default path stays silent, and
|
||||
a mutation that downgrades it to `debug()` takes that check red.
|
||||
|
||||
**Fetch time rather than startup**, because that is where the URL actually in
|
||||
use is known. The trade-off is one line per refresh (the tariff task, not the
|
||||
30 s control tick). If the status page ever grows a price-source row, this is
|
||||
the sentence it should carry.
|
||||
|
||||
---
|
||||
|
||||
## 3. The plugin contract: `fetch(self, opts, since, until, transport)`
|
||||
|
||||
The signature promises every plugin the range, and until this card **none of
|
||||
the four networked plugins used it**. The obvious follow-up is to enforce the
|
||||
promise. It is **not** enforced, and the reason is TARIFF-08's:
|
||||
|
||||
Three of the five shipped plugins point at endpoints nobody has ever verified
|
||||
(TARIFF-07 is the open decision on withdrawing them). A rule obliging them to
|
||||
pass a date range would be satisfied by an **invented parameter name** — which
|
||||
is the parser-graded-against-its-own-guess defect moved from the response to
|
||||
the request, and it would ship looking like a guarantee.
|
||||
|
||||
What exists instead is a **roster pin** in `test_price.py` section 2c: which
|
||||
plugins reference their `since`/`until` is asserted as a literal, derived from
|
||||
the registry and the AST. A sixth supplier, or a change to any of the three,
|
||||
has to edit that line and make the decision rather than inherit the silence.
|
||||
It is named as a source-shape pin, not as evidence that any feed parses;
|
||||
`generic_dayahead`'s behaviour is graded separately by the URL it really
|
||||
requests.
|
||||
|
||||
**Recommendation for whoever picks up TARIFF-07:** enforce it at the moment
|
||||
those three endpoints are either verified or withdrawn, not before. If they are
|
||||
withdrawn, `generic_dayahead` and `manual_csv` are the whole roster and the
|
||||
rule is one line — `manual_csv` reads a whole file and is windowed downstream,
|
||||
so it would be the single documented exemption.
|
||||
@@ -1,277 +0,0 @@
|
||||
# TARIFF-10 — fetching a price and trading on one are two decisions
|
||||
|
||||
**Status: DECIDED 2026-09-12 — Option A. If a price provider is configured,
|
||||
prices are fetched. Trading stays a separate switch (`arbitrage_enabled`), and
|
||||
this card moves nothing that moves energy.**
|
||||
|
||||
Filed with TARIFF-10. Follows [[TARIFF-09]] and [[FCST-04]]; §4 settles the
|
||||
limitation FCST-04 recorded and did not decide.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect, and why it was invisible
|
||||
|
||||
`app/main.py`'s `amain()` read:
|
||||
|
||||
```
|
||||
if arbitrage.enabled(opts):
|
||||
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||||
```
|
||||
|
||||
`run_tariff_refresh` is the **only** caller of `resolve_tariff()`, which is the
|
||||
**only** caller of `price_history.carry()`. `arbitrage_enabled` ships `false`.
|
||||
|
||||
So on a shipped install with a provider configured:
|
||||
|
||||
* no HTTPS request was ever made to the feed;
|
||||
* no plan was ever resolved, so `self.tariff_plan` stayed `None` forever;
|
||||
* `/data/price_history.json` was never created;
|
||||
* the *Price history* status row read `empty` for the life of the install;
|
||||
* TARIFF-09's short-horizon warning could not fire, because it is logged on a
|
||||
fetch and there was no fetch.
|
||||
|
||||
Measured, not inferred — a 40 h soak, two instances, one feed:
|
||||
|
||||
| | shipped default + provider | same, arbitrage armed |
|
||||
|---|---|---|
|
||||
| feed requests | **0** | fetching |
|
||||
| `/data/price_history.json` | `present: false`, **802/802 samples** | written |
|
||||
| resolved plan | none | 132 points |
|
||||
|
||||
⚠️ **AND FOUR SURFACES ALREADY DESCRIBED THE FIXED BEHAVIOUR.** `DOCS.md`
|
||||
§*Energy price providers*: *"With `arbitrage_enabled: false`, which is the
|
||||
shipped default, turning a provider on still fetches, validates and publishes
|
||||
prices and changes no charging decision."* `config.yaml` and both translations
|
||||
said the same, and additionally carried a claim that had gone stale in the other
|
||||
direction — *"nothing consumes the price series yet"*, untrue since OPT-04
|
||||
shipped the consumer. The code was the only surface that disagreed with all of
|
||||
them, and no check compared the two.
|
||||
|
||||
The dating is worth being exact about, because it says this was never a
|
||||
regression. `git log -S` puts the refresh task, its `arbitrage.enabled()` guard
|
||||
**and** the DOCS sentence in the *same commit* — OPT-04, `bdd7f18`;
|
||||
`config.yaml`'s version of the claim goes back further, to TARIFF-02,
|
||||
`852fc7c`. The prose and the code disagreed from the moment both existed.
|
||||
|
||||
## 2. Why a check on the `if` would not have caught it
|
||||
|
||||
The `if` was exactly what somebody meant to write, and its comment argued for
|
||||
it honestly:
|
||||
|
||||
> ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on reads the
|
||||
> resolved plan … so on a default install this task would be network traffic in
|
||||
> aid of an attribute nobody reads.
|
||||
|
||||
That was true when OPT-04 wrote it and stopped being true when FCST-04 gave the
|
||||
plan a *store* and a *status row*. A pin on the expression would have gone red
|
||||
on the fix and green on the defect. What was wrong is the **set of
|
||||
configurations from which a fetch is reachable**, so that is what
|
||||
`test_price.py` section 13 grades:
|
||||
|
||||
1. the guards on every `asyncio.create_task` in `amain()` are **evaluated**
|
||||
against real option dicts — whatever the guard is spelled as;
|
||||
2. the call graph under `app/` is walked to see whether the coroutines those
|
||||
guards start can arrive at `resolve_tariff()` — wherever it has moved to;
|
||||
3. and then the fetch is **driven**, with a counting transport, a real
|
||||
`Controller` and a real store path, because a reachability graph is an
|
||||
argument and a counted request is an observation.
|
||||
|
||||
⚠️ **Step 3 grades the gate only because it starts the task the way `amain()`
|
||||
would.** The driver asks `_fetchers(opts)` — the evaluated guard — before it
|
||||
calls the coroutine. Measured: with the shipped defect restored **and** that one
|
||||
line defeated (`if False and "run_tariff_refresh" not in names:`), the four
|
||||
driven rows go **green** while only the structural rows stay red. Without it
|
||||
they are decoration, which is MUTATION.md's habit 4 in a harness.
|
||||
|
||||
What the instrument **cannot** see is printed on every run: a coroutine started
|
||||
by anything but `create_task`; a guard that is not an enclosing `if` in
|
||||
`amain()`; anything outside `app/`; and — because the graph is keyed by **name**
|
||||
— two same-named functions as one node, which makes every *reachable* answer an
|
||||
**upper** bound and every *not reachable* answer the strong one.
|
||||
|
||||
## 3. The gate, and why it is an `or`
|
||||
|
||||
```
|
||||
def tariff_refresh_wanted(opts: dict) -> bool:
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
```
|
||||
|
||||
The first term is what this card adds. **The second term is the old gate, kept
|
||||
verbatim, and it is not there for symmetry.**
|
||||
|
||||
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
configuration `validate.py` explicitly **accepts** (rule 7 refuses only the
|
||||
no-prices-at-all case), and it **trades**. Measured on this branch:
|
||||
|
||||
```
|
||||
opts: price_provider off, arbitrage_enabled true,
|
||||
cycle_cost_threshold_eur 0.05,
|
||||
static_profile 0.20 night / 0.284 day
|
||||
plan : static_profile from static_profile, 48 h, 48 points
|
||||
WITH the plan -> ARBITRAGE_CHARGE -5000.0 W
|
||||
"cheapest firm hour at 0.2000 EUR/kWh, 0.0840 below the
|
||||
dearest and clear of the 0.0750 this cycle had to cover"
|
||||
WITHOUT the plan -> None "no tariff plan has been resolved yet"
|
||||
```
|
||||
|
||||
Gating the refresh on the provider **alone** — which is the literal reading of
|
||||
the ticket's second acceptance criterion — would leave that site's
|
||||
`tariff_plan` at `None` forever and answer *"no tariff plan has been resolved
|
||||
yet"* on every cycle. That is a strategy the UI says is running and that cannot
|
||||
fire: the exact state `validate.py`'s OPT-04 rules exist to refuse, arrived at
|
||||
from inside the code instead of from the configuration.
|
||||
|
||||
So the gate is a **strict widening**: every configuration that started the task
|
||||
before still starts it, and `test_price.py` grades that as its own row rather
|
||||
than leaving it to prose.
|
||||
|
||||
⚠️ **The AC's stated harm is still fully met, and it is worth being exact about
|
||||
this rather than quietly declaring victory.** The criterion reads *"with
|
||||
`price_provider: off`: no fetch task at all, whatever `arbitrage_enabled` says
|
||||
— an operator with no provider must not acquire a periodic HTTPS call."* The
|
||||
letter of it is not met in one configuration (provider off, trading armed),
|
||||
because that configuration keeps the task it already had. The harm it names is:
|
||||
|
||||
* with `price_provider` off, `resolve_tariff()` returns `static_plan()` — pure
|
||||
arithmetic, **no socket**. Driven and counted: **0 requests**.
|
||||
* the true shipped default — no provider, no trading — starts **no task at
|
||||
all**.
|
||||
|
||||
Nobody *acquires* a periodic HTTPS call from this card, and the only thing that
|
||||
decides whether a request leaves the machine is `price_provider`. **If the
|
||||
owner wants the literal reading instead, it is a one-token change** (drop the
|
||||
`or` term) **and it costs the site in the measurement above its trading.** That
|
||||
is the trade, stated rather than chosen silently.
|
||||
|
||||
## 4. `TariffPlan.cheap_now()` — available, and deliberately NOT consumed
|
||||
|
||||
**Decision: do not wire it in this card. `self.cheap` stays on
|
||||
`static_status()`.**
|
||||
|
||||
FCST-04 recorded that `cheap_now()` has no production caller on the provider
|
||||
plan: `main.read_inputs()` assigns `self.cheap, self.tariff_words =
|
||||
static_status(o, now)`, which builds its **own** static plan and calls
|
||||
`cheap_now()` on that. Under Option A a provider-backed plan now exists on every
|
||||
configured site, so the limitation stops being structural and becomes a choice.
|
||||
|
||||
**It is not cosmetic, and that is the whole reason for the decision.**
|
||||
`self.cheap` is read in `cycle()` by the maintenance cycle's cheap-window grid
|
||||
top-up — the add-on's one shiftable load, and the one place a *price* reaches
|
||||
something that **moves energy** on an install that has not armed trading.
|
||||
|
||||
⚠️ **And the direction it would move is not "a slightly different window".**
|
||||
`static_status()` returns `(False, "no static profile")` whenever
|
||||
`static_profile` is empty, so today a provider-only site — a plausible shape
|
||||
for a dynamic-tariff install, and the shape this card makes work — has
|
||||
`self.cheap = False` on **every** cycle and never forces a paid grid top-up at
|
||||
all. Wiring `cheap_now()` would take that from *never* to *sometimes*. That is
|
||||
a strictly larger behaviour change than this card's headline, and it belongs to
|
||||
whoever owns the maintenance cycle rather than to the ticket that made it
|
||||
reachable.
|
||||
|
||||
Three further reasons, in the order they would bite:
|
||||
|
||||
1. **Two sources, one attribute, and the loser is silent.** `validate.py`
|
||||
already refuses `static_profile` alongside the Home Assistant price
|
||||
entities *because* `read_inputs()` picks one and the other disappears. A
|
||||
provider plan would be a third writer of the same attribute with no rule
|
||||
saying which wins.
|
||||
2. **The freshness question is not answered for this consumer.** The plan
|
||||
retires after `TARIFF_MAX_AGE_S` and `fresh_tariff_plan()` returns `None`.
|
||||
Arbitrage has an answer for that (refuse). The maintenance top-up has none:
|
||||
"the plan expired" would have to mean *not cheap*, and *not cheap* on this
|
||||
path means **wait for sun**, which is a real decision nobody has taken.
|
||||
3. **The comparison basis changes, and so does the answer on a bad feed.**
|
||||
`cheap_now()` is strictly-below-the-mean-of-the-**firm**-hours, and it is
|
||||
`False` when THIS hour is provisional or is not in the series at all
|
||||
(FCST-03's rule: this hour decides, not the whole plan). So a feed that
|
||||
published nothing, or an hour the §8.3 fill could not cover, reads *not
|
||||
cheap* where a `static_profile` reads whatever the clock says. Defensible
|
||||
either way — and it is a change to the shipped maintenance cycle that needs
|
||||
its own measurement rather than a side effect of this one.
|
||||
|
||||
**What adopting it would take**, so the next card does not start from scratch:
|
||||
|
||||
* a rule for which source wins when both a provider plan and a
|
||||
`static_profile` are configured, enforced in `validate.py` the way the
|
||||
entity/profile conflict already is;
|
||||
* a decision on what an **expired** or **absent** plan means for the grid
|
||||
top-up, stated as a default rather than left to `None`;
|
||||
* `read_inputs()` splitting `self.cheap` from `self.tariff_words`, which are
|
||||
currently one assignment from one function;
|
||||
* a check that the maintenance top-up actually fires and does not fire on the
|
||||
provider plan — driven through `cycle()`, not asserted on the attribute;
|
||||
* and the *Tariff* status row, which FCST-04 pinned as a **pair** with
|
||||
`DOCS.md` in `test_price_history.py` §9: it does not report the provider
|
||||
plan, DOCS does not claim it does, and moving one without the other is
|
||||
already red.
|
||||
|
||||
## 5. The *Arbitrage* status row, re-read and left alone
|
||||
|
||||
`main.status_payload()` renders `("Arbitrage", "off (arbitrage_enabled)" if
|
||||
self.arb is None else self.arb.explain())`. `self.arb` is assigned only inside
|
||||
`if arbitrage.enabled(self.o):` and `arbitrage.decide()` never returns `None`,
|
||||
so `self.arb is None` **iff** the option is off. The string still names the
|
||||
option that is actually off, and under Option A it gains a job it did not have:
|
||||
on a site with a provider configured it is the row that tells an operator the
|
||||
prices arriving on the *Price history* row above are moving nothing. Unchanged,
|
||||
with a note at the call site saying it was checked.
|
||||
|
||||
## 6. What this card did not do
|
||||
|
||||
* It did not touch `run_tariff_refresh()`'s body (TARIFF-12 is there).
|
||||
* It did not change `cycle()`'s `if arbitrage.enabled(self.o):`, the sizing,
|
||||
the thresholds, the SoC bounds or the peak interlock.
|
||||
* It did not wire the provider plan into the *Tariff* status row or into
|
||||
`self.cheap` — §4.
|
||||
* It did not pick a version number (PKG-06).
|
||||
|
||||
## 7. Mutation ledger
|
||||
|
||||
Every mutation applied to a **fresh `cp -a`** of the clean worktree rather than
|
||||
reverted (habit 18: there is no revert to verify and no shared scratch path),
|
||||
`__pycache__` purged, tree mounted `:ro`, run under `python3 -B`.
|
||||
|
||||
| id | mutation | Δ bytes | result |
|
||||
|---|---|---|---|
|
||||
| M1 | the shipped defect: gate back to `arbitrage.enabled(opts)` | **-4 B** | **RED**, 7 named checks |
|
||||
| M2 | drop the `or arbitrage.enabled(opts)` term | -27 B | **RED**, 2 named checks (strict-widening + the static-profile row) |
|
||||
| M3 | `tariff_refresh_wanted` -> always `True` | +8 B | **RED**, the AC2 reachability row |
|
||||
| M4 | `provider_configured`: `"off"` counts as configured | -6 B | **RED**, the AC2 reachability row |
|
||||
| M5 | `cycle()`'s trading gate -> `if True or arbitrage.enabled(self.o)` | +8 B | **RED**, both AC3 rows (`-5000 W` with trading off) |
|
||||
| M6 | instrument: `_reaches()` always `True` | **+0 B** | **RED**, 4 checks incl. its own calibration row |
|
||||
| M7 | instrument: `_task_sites()` returns `[]` | **+0 B** | **RED**, 10 checks incl. "it found 0 sites" |
|
||||
| M8 | fixture: 5 minutes of telegrams instead of a whole quarter | **+0 B** | **RED**, the peak-guard precondition and both armed rows |
|
||||
| M9 | M1 **plus** the harness's guard-driven start defeated | -4 B | the four DRIVEN rows go **GREEN** — see §2 |
|
||||
| M10 | `provider_id()` drops `.strip()` | -8 B | **survivor**, then CLOSED — see below |
|
||||
| M11 | `provider_configured()` -> `return True` | -32 B | **RED**, 4 checks |
|
||||
| M12 | DOCS.md's sentence reverted to the old, vaguer claim | +0 B (docs) | **RED**, the AC4 pairing row (`fetches = True, DOCS says = False`) |
|
||||
| M13 | the Dutch translation left behind while English moved | +0 B (docs) | **RED**, the both-languages row |
|
||||
|
||||
⚠️ **M10 was a real survivor and is recorded as one.** Dropping `.strip()` left
|
||||
`test_price.py`, `test_price_history.py`, `test_static_tariff.py` and
|
||||
`test_validate.py` all green: nothing in the repository fed a `price_provider`
|
||||
with surrounding whitespace, and the `.strip()` predates this card — it was
|
||||
carried into `provider_id()` from the three call sites it replaced. The
|
||||
Supervisor's `list(off|…)` schema cannot produce `" off "`, but
|
||||
`validate_startup_config()` is also `sim/inject.py`'s entry point and takes any
|
||||
dict at all. Closed in the same commit by the padded-id pair in section 13;
|
||||
re-run as **M10b**, red on two named checks.
|
||||
|
||||
⚠️ **M8 is the fixture defect this card walked into and then fenced.** The
|
||||
first draft fed five minutes of telegrams. Whether that **covers** the current
|
||||
billing quarter depends on where the wall clock sits inside it, so the peak
|
||||
guard was blind and engaged on some runs and not others — and `decide()` refuses
|
||||
outright while the guard is holding (§9.1 over §10.1). On those runs the armed
|
||||
baseline did not trade, and "trading off never trades" would have been graded
|
||||
on a fixture in which nothing could trade either way. It is now 900 s of
|
||||
telegrams, and the guard's clear state is a **check** rather than an assumption.
|
||||
|
||||
## 8. What is still open
|
||||
|
||||
* The literal reading of AC2 (§3). One token, and it costs a measured trading
|
||||
configuration its plan. **Owner's call.**
|
||||
* `cheap_now()` (§4). Owner's call, with the adoption list above.
|
||||
* The *Tariff* status row still shows the static-profile sentence on a
|
||||
provider-backed site; the provider plan's own line goes to the log. Pinned as
|
||||
a pair with `DOCS.md` by FCST-04 and untouched here.
|
||||
@@ -1,252 +0,0 @@
|
||||
# TARIFF-12 — a returned degradation is not a refresh
|
||||
|
||||
Three decisions. The first is what the ticket put up for judgement; the second
|
||||
and third are what fall out of it and would otherwise be decided by accident.
|
||||
|
||||
---
|
||||
|
||||
## 0. What was actually wrong, stated once
|
||||
|
||||
`run_tariff_refresh()`'s docstring:
|
||||
|
||||
> ⚠️ **AND A FAILURE DOES NOT CLEAR THE PLAN.** … Zeroing the plan here would
|
||||
> replace a coarse tariff with no tariff, which is the opposite of what §11.4
|
||||
> asks for.
|
||||
|
||||
The guarantee was real. The condition it was enforced on was not:
|
||||
|
||||
```python
|
||||
try:
|
||||
plan = await resolve_tariff(...)
|
||||
self.tariff_plan, self.tariff_at = plan, now # unconditional
|
||||
except Exception as err:
|
||||
_LOG.warning("tariff refresh failed (%s); keeping the plan ...")
|
||||
```
|
||||
|
||||
`load_prices()` says in its own docstring that it **"RETURNS, NEVER RAISES, for
|
||||
every failure a site can actually have"**, because a degradation arriving as a
|
||||
traceback into the planning tick would stop the controller regulating over an
|
||||
expired certificate. So a supplier outage — the case the docstring names first —
|
||||
is a **return**: the `try` succeeds, `_degrade()` hands back a §11.4 plan, and
|
||||
the good plan is overwritten. The `except` is reachable only by something
|
||||
nobody expected, and nothing in the shipped path produces it.
|
||||
|
||||
This is the fourth card in one family: FCST-04's M4 (`not ok` only), FCST-05's
|
||||
HIGH (`(JSONDecodeError, OSError)` only), FCST-06 (`not self.ok` only), and now
|
||||
`except Exception` only, for a path that never raises. **A guarantee enforced on
|
||||
the condition someone thought of, rather than the condition that occurs.**
|
||||
|
||||
Reproduced before the fix, driving the shipped chain with nothing stubbed but
|
||||
the socket — 48 h horizon, `static_profile` empty, one good fetch then a
|
||||
`ClientConnectorError` 1800 s later, `TARIFF_MAX_AGE_S` = 5400 s:
|
||||
|
||||
```
|
||||
after a good fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
|
||||
after the failed fetch origin='none' n_points=0 'no tariff - generic_dayahead: ClientConnectorError: ...'
|
||||
tariff_at moved: True
|
||||
```
|
||||
|
||||
and after it:
|
||||
|
||||
```
|
||||
after the failed fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
|
||||
tariff_at moved: False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. The question is asked ONCE, after the attempt, from the evidence that exists
|
||||
|
||||
FCST-06's answer, one module over, and for the same reason: **the enumeration
|
||||
of failure conditions cannot be completed.** There is no pre-check that a feed
|
||||
will answer which is not itself a guess, and adding one would be a second
|
||||
symptom test beside the `except`. So the predicate is an **outcome**, read off
|
||||
the object the attempt produced:
|
||||
|
||||
```python
|
||||
standing = self.fresh_tariff_plan(now)
|
||||
keep = (not plan.from_feed
|
||||
and standing is not None and standing.from_feed)
|
||||
```
|
||||
|
||||
Two facts, both of which exist by the time the question is asked:
|
||||
|
||||
* **`plan.from_feed`** — did *this* resolution observe the configured provider?
|
||||
`TariffPlan.origin` already names the four ways a plan is obtained; this
|
||||
collapses them to the one question a caller holding a previous plan has to
|
||||
ask. It lives on `TariffPlan` rather than as an `origin == "provider"` at the
|
||||
call site for `fresh_tariff_plan()`'s own stated reason — *a check has to be
|
||||
able to assert on the shipped expression rather than on a transcription that
|
||||
drifts* — and because `origin`'s vocabulary belongs to `static_tariff.py`.
|
||||
* **`fresh_tariff_plan(now)`** — is a standing observation still current. It
|
||||
already existed, it is already the one place the age is judged, and it is
|
||||
**reused, not transcribed**. `test_arbitrage.py` 8b pins that reuse on the
|
||||
AST; a copy of the comparison behaves identically until the day the two
|
||||
drift, which is a claim about the code's shape, so a shape check is what
|
||||
grades it (FCST-06's precedent for the one source check among driven ones).
|
||||
|
||||
### What was NOT done
|
||||
|
||||
* **Not `except Exception` widened, or a second `if` beside it.** That is the
|
||||
defect's own shape — a place where the rule is spelt twice, so a fifth cause
|
||||
needs a fifth branch. The `except` stays, relabelled *"not the outage path"*,
|
||||
keeps the plan on the same terms, and is now honestly the
|
||||
something-nobody-expected branch.
|
||||
* **Not the persisted price store as a cache.** Out of scope by the product
|
||||
owner's call, and it is the right call: FCST-03 exists precisely to stop
|
||||
stale prices being acted on, and "recent" is not "current". Keeping the last
|
||||
**fetched** plan until it ages out is a different promise from serving the
|
||||
store, and that one needs its own ticket.
|
||||
* **Not a `no_data`-only guard.** `not plan.has_prices` is the tempting
|
||||
spelling and it is wrong — see decision 2. It leaves the §11.4 fallback free
|
||||
to replace a fresh observation, and it is a 1-byte edit from the shipped
|
||||
line, which is why it is in the mutation table rather than in a comment.
|
||||
|
||||
---
|
||||
|
||||
## 2. A §11.4 static fallback does NOT replace a fresh provider plan
|
||||
|
||||
The ticket asked this directly, because the fallback is *not* nothing: it is
|
||||
the operator's own configured profile, with real prices, for the requested
|
||||
window. The answer is still no, and the reason is not that it is worse data —
|
||||
it is that **nothing may be acted on it, and it is no more current.**
|
||||
|
||||
* Every point `_degrade()` builds carries `provisional=True`. So
|
||||
`firm_points()` is empty, `arbitrage()` finds fewer than two firm hours and
|
||||
refuses, and `cheap_now()` is False. Both of the two things `TariffPlan`
|
||||
exposes for a consumer to act on are already off.
|
||||
* A day-ahead price for a given hour **does not change once published**. A plan
|
||||
fetched 30 minutes ago is exactly as true about 19:00 as one fetched now; at
|
||||
most `TARIFF_MAX_AGE_S` of its horizon has elapsed. The observation is not
|
||||
stale in any sense the fallback is fresher than.
|
||||
|
||||
So replacing the observation with the fallback trades a plan with firm hours
|
||||
for a plan with none, and buys no currency with it. The fallback takes over
|
||||
when the observation **retires** — which is exactly the row §11.4 describes,
|
||||
arrived at when there is no longer an observation to prefer.
|
||||
|
||||
### And the rule is deliberately NOT symmetrical
|
||||
|
||||
`standing.from_feed` is a term in its own right. A standing plan that is itself
|
||||
a degradation **is** replaced by the next degradation, and its timestamp moves.
|
||||
Without that term:
|
||||
|
||||
* an outage on a static-profile site would freeze the operator's profile at the
|
||||
window it was first generated for, and the guard would be *"never overwrite"*
|
||||
wearing a bound's name;
|
||||
* and on a `price_provider: off` install — the shipped default — the plan would
|
||||
flip between kept and re-derived every `TARIFF_MAX_AGE_S`, for no behavioural
|
||||
gain, changing which reason the status page prints on a two-hour cycle.
|
||||
|
||||
Each of the three terms is the only false one in some check in 8b, which is
|
||||
MUTATION.md habit 17's test for whether a suite can tell an `AND` from an `OR`.
|
||||
|
||||
---
|
||||
|
||||
## 3. `tariff_at` does not move on a kept plan
|
||||
|
||||
The docstring's other half is that a plan must not *"freeze at its last value
|
||||
and look current forever"*. `tariff_at` is what makes that work: it is the
|
||||
moment of the fetch that produced the plan standing, and `fresh_tariff_plan()`
|
||||
judges the age from it.
|
||||
|
||||
Re-stamping it on a kept plan would be this task asserting the feed answered
|
||||
when it did not. The plan would be re-certified as current at every refresh, an
|
||||
outage of any length would never retire it, and `TARIFF_MAX_AGE_S` would be
|
||||
**exactly as dead as it was before this card** — reachable only by the refresh
|
||||
task itself dying. Leaving it alone is what makes the bound govern: the plan is
|
||||
served for the age bound after its own fetch, and no longer.
|
||||
|
||||
It is also the whole of **"distinguishable from a freshly-fetched one"**. A
|
||||
kept plan and a fetched plan are the same object shape; what separates them is
|
||||
that one's timestamp advanced and the other's did not, and every consumer that
|
||||
asks how old the plan is already reads that number. The kept case additionally
|
||||
logs at WARNING, naming the degradation's own reason, the fetch it is keeping,
|
||||
its age, and the deadline it retires at.
|
||||
|
||||
⚠️ **What this does not give the operator, and why it is not here.** The status
|
||||
page has no row for "the last refresh failed" — the banner/level not moving
|
||||
when the plan is lost is separately carded, and the ticket rules it out of
|
||||
scope. This card's change makes that surface *more* truthful, not less: during
|
||||
the first `TARIFF_MAX_AGE_S` of an outage there is now a real plan behind the
|
||||
Arbitrage row instead of a no-plan refusal, so the row says something true for
|
||||
longer. The no-plan state is entered later by up to 5400 s; it is not removed,
|
||||
and the 30-minute lag reported on that row is untouched by anything here.
|
||||
|
||||
---
|
||||
|
||||
## 4. Merged after TARIFF-10 — what the wider gate changed, and what it did not
|
||||
|
||||
TARIFF-10 landed on `release/1.0` while this card was open. It changed **which
|
||||
installs run this task** — `if arbitrage.enabled(opts)` became
|
||||
`tariff_refresh_wanted(opts)`, i.e. `provider_configured(opts) or
|
||||
arbitrage.enabled(opts)` — while this card changed **what the task does on a
|
||||
failed fetch**. Git auto-merged the two hunks, which is exactly when the
|
||||
interaction needs reading rather than trusting.
|
||||
|
||||
**Nothing in the decisions above changed.** Two configurations are newly
|
||||
reachable, and both are now driven in `test_arbitrage.py` 8b rather than
|
||||
reasoned about:
|
||||
|
||||
* **A provider configured with trading DISARMED** — TARIFF-10's headline case.
|
||||
This is precisely the configuration the guard was designed for: the plans are
|
||||
`origin == "provider"`, and a dead feed must not delete one that is still
|
||||
inside `TARIFF_MAX_AGE_S`. It needs no second fixture, because **nothing on
|
||||
this path reads `arbitrage_enabled` at all**, and that is asserted rather
|
||||
than assumed — over the AST's symbols and the non-docstring string constants,
|
||||
because two earlier drafts of that check were **red against a correct tree**:
|
||||
the first matched the docstring paragraph that *says* `arbitrage_enabled` is
|
||||
not read, the second matched the log line's "arbitrage, where it is armed".
|
||||
A substring test over a function grades its prose as much as its behaviour,
|
||||
which is habit 4 in a third costume. A fixture that set the option and
|
||||
asserted no difference would have been the other defect — comparing a thing
|
||||
against itself.
|
||||
* **`price_provider: off` + a `static_profile`** — the configuration TARIFF-10's
|
||||
`or` exists to protect, and the one where a latching guard would do real harm.
|
||||
`resolve_tariff()` returns `static_plan(provisional=False)`, whose origin is
|
||||
`"static"`: the CONFIGURED product, not a §11.4 row. So `standing.from_feed`
|
||||
is false on every refresh, `keep` is false on every refresh, and the plan is
|
||||
regenerated for the current window every 30 minutes exactly as before this
|
||||
card. **Driven, twice in a row**, because a guard that latched here would
|
||||
freeze a static site's plan at its first window for the life of the install —
|
||||
and mutation M3, which drops the `standing.from_feed` term, is red on that
|
||||
check as well as on the fallback one.
|
||||
|
||||
Two further facts settle the rest of the interaction:
|
||||
|
||||
* **This decision is strictly downstream of every side effect.** The §8.3 carry
|
||||
into `price_history` happens inside `resolve_tariff()`, on the `series`
|
||||
branch, before `keep` is computed — so which of two plans is kept can neither
|
||||
add nor lose a recorded day, and TARIFF-10's *Price history* row is
|
||||
unaffected either way.
|
||||
* **The kept-plan log line was reworded.** It said "arbitrage stops with it",
|
||||
which after TARIFF-10 is false on a site that fetches without trading; it now
|
||||
says "arbitrage, **where it is armed**, stops with it".
|
||||
|
||||
All eleven mutations were **re-driven on the merged tree** rather than carried
|
||||
over — the surrounding code changed, so a red against the old base is not
|
||||
evidence about the new one. Eleven red, no survivors, every revert
|
||||
fingerprinted. The added member is M11: making the guard conditional on
|
||||
`arbitrage.enabled(self.o)` (+86 B), red on five named checks.
|
||||
|
||||
⚠️ **And one process note that belongs in this file.** This card's mutation
|
||||
harness was written to a shared scratchpad path and was **overwritten mid-run
|
||||
by another agent's harness for a different card**, pointing at a different
|
||||
worktree. That is MUTATION.md habit 18's second bullet almost verbatim. It was
|
||||
caught because the harness fingerprints the tree after every revert and the
|
||||
runs are what proved this worktree was never left mutated; the harness now
|
||||
lives at a card-specific path. **A harness that edits source must not share a
|
||||
name, a directory or a backup path with another one.**
|
||||
|
||||
---
|
||||
|
||||
## What this does not change
|
||||
|
||||
* **Nothing on the 30 s tick.** `cycle()` still reads two attributes and awaits
|
||||
no fetch; the AST pin in `test_arbitrage.py` section 8 is unchanged and green.
|
||||
* **Nothing about the prices themselves.** No series is invented, extended,
|
||||
re-stamped or carried. The only question this card answers is *which of two
|
||||
plans the controller holds*.
|
||||
* **A `price_provider: off` install is bit-for-bit unaffected.** Its standing
|
||||
plan is never `origin == "provider"`, so `keep` is false on every refresh it
|
||||
will ever run, and the assignment happens exactly as it did before.
|
||||
@@ -1,225 +0,0 @@
|
||||
# TARIFF-13 — the coverage of a peak, and which quarter gets the credit
|
||||
|
||||
Two decisions. §1 is the defect the ticket was raised on; §2 is the smaller
|
||||
question it asked to be settled deliberately rather than left as an accident.
|
||||
§3 is what this branch deliberately did **not** do, and why.
|
||||
|
||||
---
|
||||
|
||||
## 0. What was actually wrong, stated once
|
||||
|
||||
`main.py`'s capacity-tariff status row prints a peak read from SQLite and a
|
||||
coverage figure read from **process memory**:
|
||||
|
||||
```python
|
||||
_pk = self.peak.peak(when) # SQLite, durable
|
||||
...
|
||||
+ f"; {self.peak.recorded} quarter(s) banked" # an int on the tracker
|
||||
```
|
||||
|
||||
`PeakTracker.recorded` was `self.recorded = 0` in `__init__`, incremented once
|
||||
per block inside `sync()`. A 40-hour soak caught what that means at a driven
|
||||
restart, on both instances independently:
|
||||
|
||||
```
|
||||
before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 120 quarter(s) banked
|
||||
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
|
||||
```
|
||||
|
||||
Reproduced here before anything was changed, through the real `Controller`, the
|
||||
real `P1Ingest`, and a **real restart** — a second `Controller` object against
|
||||
the same file on disk, never a re-read of the first:
|
||||
|
||||
```
|
||||
before: 2026-09: peak 365 W, set by the quarter at 2026-09-10T09:00:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 12 quarter(s) banked
|
||||
after : 2026-09: peak 365 W, set by the quarter at 2026-09-10T09:00:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
|
||||
|
||||
peak survives the restart: True
|
||||
count survives the restart: False (12 -> 0, store held 12 rows)
|
||||
```
|
||||
|
||||
**TARIFF-01's argument, one column over.** That module's whole design is that
|
||||
an unreadable store must not read as a zero peak, *because a zero peak is
|
||||
disarming*. A coverage figure of zero is disarming in exactly the same way and
|
||||
about the same number: the count is what says how much of the billing month the
|
||||
watt figure actually covers, and 365 W over 120 quarters and 365 W over 0
|
||||
quarters are different claims about the same 365 W. After a restart the page
|
||||
made the weaker claim while displaying the stronger one's value. It did not
|
||||
under-report the peak — it under-reported **its own evidence**.
|
||||
|
||||
Two further defects fell out of the same variable once it was driven, neither
|
||||
of them in the ticket:
|
||||
|
||||
* **it was never scoped to a month.** It counted every block the *process* had
|
||||
banked, so on 1 October the row read September's tally beside October's "no
|
||||
completed quarter recorded yet". Measured, on the shipped tree:
|
||||
`2026-10: no completed quarter recorded yet - 0 % of the 3500 W soft limit;
|
||||
1 quarter(s) banked`.
|
||||
* **it missed the block `restore()` banks.** A stale block closed from the
|
||||
checkpoint goes into `quarter_blocks` without passing through `sync()`, so
|
||||
the counter never saw it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Decision: the count is READ FROM THE TIER THE PEAK IS READ FROM
|
||||
|
||||
The acceptance criterion allowed either route — make the count durable, or stop
|
||||
printing a number that silently resets. **Durable**, because the count is the
|
||||
useful half of the row and removing it would have cost the operator the one
|
||||
thing that says whether the peak means anything yet.
|
||||
|
||||
`PeakStore.banked_count(month)` asks the database `SELECT COUNT(*) FROM
|
||||
quarter_blocks WHERE month=?`, and `PeakTracker.recorded` is a property over it
|
||||
rather than an attribute. Three consequences, and all three are the defects
|
||||
above:
|
||||
|
||||
* a restart re-reads the same rows, so the figure survives it;
|
||||
* the count is per **month**, so it agrees with the peak it is printed beside;
|
||||
* a block banked by `restore()` is a row like any other and is counted.
|
||||
|
||||
Three things this route deliberately does **not** do:
|
||||
|
||||
* **it does not count `len(blocks_in(month))`.** `blocks_in()` returns `[]` on a
|
||||
read error — safe there only because no caller reads its emptiness as a peak,
|
||||
which its own docstring says — so taking its length would rebuild the
|
||||
disarming zero one call further out. `banked_count()` returns **`None`** when
|
||||
the database will not answer.
|
||||
* **it does not count `monthly_peak`.** That tier holds one row per *month* and
|
||||
would answer `1` for every month ever measured — wrong by exactly the amount
|
||||
that makes it look plausible. The two tables are proven equal on the **peak**
|
||||
by `recompute_month()`; they were never equal on the **count**.
|
||||
* **it does not remove the dedupe.** `_seen` is still what stops `cycle()`'s
|
||||
once-a-second `sync()` re-banking a block that `P1Ingest.blocks` keeps for a
|
||||
day, and `sync()`'s return value is still the number newly banked. What moved
|
||||
is only where the *status page* reads its figure. ⚠️ The check that graded the
|
||||
dedupe used to assert `recorded == 1` — the dedupe and the figure reporting it
|
||||
were the same variable. Counting rows in `quarter_blocks` cannot see a
|
||||
re-bank at all (it is a PRIMARY KEY no-op), so that check is now graded on
|
||||
`sync()`'s **return**, with the store's tally as the second half.
|
||||
|
||||
### ⚠️ The unreadable case renders a WORD, not a number
|
||||
|
||||
`banked_count()` returns `int | None`; `recorded_in()` renders the `None` as
|
||||
the module constant `COUNT_UNREADABLE = "an unreadable number of"`, so the
|
||||
sentence reads
|
||||
|
||||
```
|
||||
... ; an unreadable number of quarter(s) banked
|
||||
```
|
||||
|
||||
`0` was never available. `None` was rejected too: `f"{None} quarter(s) banked"`
|
||||
renders **"None quarter(s) banked"**, which an operator reads as the same zero.
|
||||
This is the `PeakReading` trade — no `peak_w_or(default)`, ever — applied to the
|
||||
coverage figure: a caller that formats it gets a true sentence in both states,
|
||||
and a caller that does arithmetic on it gets a `TypeError`, which is loud.
|
||||
|
||||
"0 quarter(s) banked" remains a **true and reachable** sentence: it is what a
|
||||
month nothing has been written to says, and part 11 asserts that the two states
|
||||
are spelled differently.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision: at EQUAL wattage, the EARLIEST quarter keeps the attribution
|
||||
|
||||
**First, not most recent.** The reasoning, in the order it matters:
|
||||
|
||||
1. **It is not a money question.** The utility bills the wattage; the timestamp
|
||||
is not part of the bill. So the decision is settled entirely on what the
|
||||
status row is *for*.
|
||||
2. **The row answers "when did my monthly peak happen".** An operator reading it
|
||||
goes and looks at one event — what was running, what the weather was. A
|
||||
stable answer is the one that can be investigated; "most recent wins"
|
||||
guarantees the answer moves every time the wattage recurs, and an operator
|
||||
who looked yesterday is sent somewhere else today for no reason.
|
||||
3. **It is what one reader already did unconditionally.** `recompute_month()`
|
||||
is `ORDER BY offtake_avg_w DESC, quarter_start ASC LIMIT 1` — earliest wins.
|
||||
Choosing "most recent" would have meant changing both readers; choosing
|
||||
"earliest" meant making the other one say out loud what it was already doing
|
||||
by accident.
|
||||
|
||||
### ⚠️ And the two readers DID diverge, which is why this is a code change
|
||||
|
||||
The `monthly_peak` upsert was `WHERE excluded.peak_w > monthly_peak.peak_w` —
|
||||
strictly greater, so an exact tie never wrote and the row kept whichever tied
|
||||
block was **written first**. That coincides with *earliest* only while blocks
|
||||
arrive in time order. Fed four equal 365 W blocks newest-first, measured on the
|
||||
shipped tree:
|
||||
|
||||
```
|
||||
ascending arrival monthly_peak -> 09:00:00 recompute_month -> 09:00:00 agree=True
|
||||
DESCENDING arrival monthly_peak -> 09:45:00 recompute_month -> 09:00:00 agree=False
|
||||
```
|
||||
|
||||
That is a divergence in the derived record that part 8 of `test_tariff.py`
|
||||
exists to prove cannot happen — latent, because nothing in the shipped path
|
||||
banks out of order today. The upsert now carries the tie term explicitly:
|
||||
|
||||
```sql
|
||||
WHERE excluded.peak_w > monthly_peak.peak_w
|
||||
OR (excluded.peak_w = monthly_peak.peak_w
|
||||
AND excluded.quarter_start < monthly_peak.quarter_start)
|
||||
```
|
||||
|
||||
**`peak_w` is untouched.** The `>` term is exactly as it was, no tie can move
|
||||
the billed number, and SAFETY-07's guard reads the same watt figure it read
|
||||
before. The second term moves only the timestamp.
|
||||
|
||||
### ⚠️ AND THE SOAK'S DRIFT WAS NOT A TIE AT ALL
|
||||
|
||||
The ticket's reading — "ties at the same wattage are re-attributed to the most
|
||||
recent matching quarter" — is **false**, and driving it is what showed that.
|
||||
Six exactly-equal 365.0 W quarters on the shipped tree moved the attribution
|
||||
**zero** times, because `>` is strict.
|
||||
|
||||
What the soak saw was a genuine new maximum **smaller than the row's own
|
||||
resolution**. The row renders `{peak_w:.0f}` — whole watts — so 365.0000001 W is
|
||||
a new peak to the database and the same `365 W` to the reader:
|
||||
|
||||
```
|
||||
q0 fed 365.0 -> renders 365 W, attributed 09:00:00
|
||||
q1 fed 365.0000001 -> renders 365 W, attributed 09:15:00
|
||||
q2 fed 365.0000002 -> renders 365 W, attributed 09:30:00
|
||||
```
|
||||
|
||||
**Decision: left alone, deliberately.** It is the instrument behaving correctly
|
||||
— the peak really did rise — and the alternative, printing more decimal places,
|
||||
puts noise in the one row an operator reads at the moment the guard engages, to
|
||||
explain a movement that is real. Both cases are pinned in part 12 so that
|
||||
neither can be changed by accident, and the distinction is recorded here so the
|
||||
next reader does not re-diagnose it as a tie.
|
||||
|
||||
---
|
||||
|
||||
## 3. What this branch did NOT do, and who owns it
|
||||
|
||||
**`main.py`'s status row was not touched.** TARIFF-11 holds `main.py`'s status
|
||||
rows on a concurrent branch, and the standing instruction is to stop rather than
|
||||
race there. Everything above is in `app/tariff.py`, and the row picks the fix up
|
||||
without editing, because `self.peak.recorded` is an attribute access and is now
|
||||
a property.
|
||||
|
||||
**The residual, stated with its scope.** `recorded` answers for
|
||||
`datetime.now()`. The peak beside it follows `checks(when=...)`. The shipped
|
||||
install has **exactly one** call site — `self.checks()` with no argument, at
|
||||
`main.py:4359` — where the two are the same month, so **the live page is correct
|
||||
and durable**. They part company only under the `when=` affordance TEST-17 added
|
||||
for the suite, and the mismatch there is strictly smaller than the one it
|
||||
replaces (this month's tally, rather than a process-wide total across all
|
||||
months).
|
||||
|
||||
`PeakTracker.recorded_in(when)` is the scoped form and is shipped. Moving the
|
||||
row onto it is:
|
||||
|
||||
```python
|
||||
- + f"; {self.peak.recorded} quarter(s) banked"
|
||||
+ + f"; {self.peak.recorded_in(when)} quarter(s) banked"
|
||||
```
|
||||
|
||||
one token, on one line, inside the block TARIFF-11 owns. `test_tariff.py`
|
||||
part 10 **pins the present behaviour** — graded against a row count taken
|
||||
straight from `quarter_blocks`, not against the property that produces it — so
|
||||
the gap goes red if it changes and cannot be quietly forgotten.
|
||||
@@ -1,349 +0,0 @@
|
||||
# TARIFF-14 — how long one price point covers, and what the last one does
|
||||
|
||||
One defect and one genuinely open decision. The defect had no defensible
|
||||
alternative once it was measured; the decision — the **last** point's window —
|
||||
did, and the alternative is argued below rather than dismissed.
|
||||
|
||||
---
|
||||
|
||||
## 0. The measurement this card exists for
|
||||
|
||||
`_Series.at()` asked `s <= u < s + 1 h` of every point **in list order** and
|
||||
returned the **first** match. Run against the shipped code at `5059e28` on a
|
||||
quarter-hourly series:
|
||||
|
||||
```
|
||||
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14 ...
|
||||
|
||||
ask + 0min -> 02:00 0.10
|
||||
ask +15min -> 02:00 0.10 <-- WRONG (wanted 02:15)
|
||||
ask +30min -> 02:00 0.10 <-- WRONG
|
||||
ask +45min -> 02:00 0.10 <-- WRONG
|
||||
ask +60min -> 02:15 0.11 <-- WRONG (wanted 03:00)
|
||||
ask +75min -> 02:30 0.12 <-- WRONG (wanted 03:15)
|
||||
```
|
||||
|
||||
**Two failure modes, not one.** Inside the first hour the `:00` point wins
|
||||
because its hour has not run out. From the second hour on the answer is not
|
||||
even the top of the hour — the first point still inside its own hour wins — so
|
||||
**the error compounds with depth into the series**.
|
||||
|
||||
Same code, same run, after the fix:
|
||||
|
||||
```
|
||||
ask + 0min -> 02:00 0.10 ask +60min -> 03:00 0.14
|
||||
ask +15min -> 02:15 0.11 ask +75min -> 03:15 0.15
|
||||
ask +30min -> 02:30 0.12 ask +90min -> 03:30 0.16
|
||||
ask +45min -> 02:45 0.13 ask+120min -> 04:00 0.18
|
||||
```
|
||||
|
||||
An hourly series is **unchanged at every instant**, before and after — which is
|
||||
the whole reason nothing caught this. Every price fixture in this repository
|
||||
before TARIFF-08 was the invented hourly row shape, `static_tariff.py` emits
|
||||
hourly points, and the DST checks count points on the 23- and 25-hour days
|
||||
without ever asking `at()` for a quarter-hour. **The suite had been exercising
|
||||
the one resolution on which the broken rule and the right one agree.**
|
||||
|
||||
Belgium moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
|
||||
returns **96 points at 900 s**, so this is reachable only down the dynamic path
|
||||
TARIFF-08/09 just made work. Static sites are immune.
|
||||
|
||||
## 0.1 What it cost, which is not "a slightly wrong price"
|
||||
|
||||
`arbitrage._act()` calls `_point_at(plan, now)` → `series.at(now)` and then
|
||||
tests `here.start == v.cheapest.start` / `== v.dearest.start` to decide whether
|
||||
this interval is the buy one or the sell one. That is an **identity test
|
||||
between the matched point and the point the verdict was built from**, and on a
|
||||
quarter-hourly series it almost never matched — so the strategy idled through
|
||||
exactly the interval it exists to trade, reporting *"this hour is neither the
|
||||
buy hour nor the sell hour"*. It also read `here.provisional` (§8.3) and §17's
|
||||
`price < 0` off that same wrong point.
|
||||
|
||||
---
|
||||
|
||||
## 1. The rule
|
||||
|
||||
> **A point covers from its own start until the next point's start, and never
|
||||
> more than one hour.**
|
||||
|
||||
* **900 s** → each point covers its own quarter.
|
||||
* **3600 s** → each point covers its own hour. Identical to what shipped.
|
||||
* **MIXED** → **each point takes its own gap.** FCST-04's carry-forward emits
|
||||
hourly carried points beside quarter-hourly firm ones, and the soak measured
|
||||
spacing `[900, 3600]` in **70 % of samples** — so a window derived from one
|
||||
global spacing would be wrong in the **normal steady state**, not in an edge
|
||||
case.
|
||||
|
||||
### 1.1 Why the one-hour cap is not decoration
|
||||
|
||||
FCST-04 **leaves holes on purpose**: an hour with no published price and no
|
||||
yesterday value is left *out* of the series, and `price_forecast.py`'s module
|
||||
note names `at()` raising `Absence` for it as the behaviour that sends the
|
||||
caller to the static profile (§11.4 for that hour).
|
||||
|
||||
Uncapped, "until the next point's start" stretches the point **before** a
|
||||
three-hour hole across the whole hole and answers every instant in it with a
|
||||
stale price. Filling holes is the defect this module exists to refuse, and it
|
||||
would have been introduced **by the fix for a different one**. Graded in
|
||||
`test_price.py` 14d; the mutation that removes the cap is red there.
|
||||
|
||||
---
|
||||
|
||||
## 2. The decision: what the **last** point covers
|
||||
|
||||
There is no next start, and §9.3 says nothing about how wide the final interval
|
||||
is — a `PricePoint` has **three fields** and must not learn a fourth, because
|
||||
that is the wire format every store, replay, fixture and captured body in this
|
||||
repository is written against. So the tail is a **choice**, and both candidates
|
||||
are defensible.
|
||||
|
||||
**Chosen: the last point is given the same span as the point before it** (one
|
||||
hour when it is the only point).
|
||||
|
||||
| | flat one hour | the span in front of it (**chosen**) |
|
||||
|---|---|---|
|
||||
| 96-point 900 s day, `at(23:50)` | answers 23:45 | answers 23:45 |
|
||||
| the same day, `at(00:30)` **tomorrow** | **answers with today's 23:45 price** | `Absence` |
|
||||
| 24-point hourly day, `at(23:50)` | answers 23:00 | answers 23:00 |
|
||||
| mixed series ending at the firm/carried seam | answers for an hour | `Absence` after 15 min |
|
||||
|
||||
**The second row is why.** A flat one-hour tail reaches across a day boundary
|
||||
and prices tomorrow morning with yesterday's last print — which is
|
||||
"reaching for a neighbouring one", the single thing the `Absence` message has
|
||||
always refused to do. The chosen rule never extends a point past a span the
|
||||
series itself demonstrates.
|
||||
|
||||
### 2.1 The cost of the choice, stated rather than buried
|
||||
|
||||
On a **mixed** series whose last point is a carried **hourly** one sitting
|
||||
behind quarter-hourly firm ones, the tail is **900 s**, and the last 45 minutes
|
||||
of the horizon raise `Absence` for an hour something did carry a value for.
|
||||
|
||||
That is accepted, not tolerated, and the direction is the safe one:
|
||||
|
||||
* the caller falls back to the static profile (§11.4) instead of trading on a
|
||||
span the series never demonstrated;
|
||||
* the point is **carried**, so §8.3 forbids any irreversible action resting on
|
||||
it regardless;
|
||||
* it is at the far end of a 48 h horizon, where nothing is decided at 30 s.
|
||||
|
||||
Under-covering costs a missed decision. Over-covering prices a real interval
|
||||
with a number that does not describe it. Those are not symmetric.
|
||||
|
||||
### 2.2 The degenerate case
|
||||
|
||||
A series of **one** point has no gap in front of it to copy and gets **one
|
||||
hour** — the shipped behaviour, kept deliberately. `test_price.py` 14e states
|
||||
it as its own check so it cannot decay into an accident.
|
||||
|
||||
---
|
||||
|
||||
## 3. Does `at()` now depend on sortedness? Yes — and it establishes it itself
|
||||
|
||||
The old rule read one point at a time and did not care about order. The new one
|
||||
reads a point's **neighbours**, so it *would* depend on the series being
|
||||
ascending.
|
||||
|
||||
It does not, because **the lookup index is sorted by UTC start at construction**
|
||||
(`_cover_table()`, called once from `_Series.__init__`). No caller's list order
|
||||
can reach `at()`.
|
||||
|
||||
That matters because the three things that "guarantee" order today each
|
||||
guarantee it somewhere else:
|
||||
|
||||
| | what it guarantees | where |
|
||||
|---|---|---|
|
||||
| `_validate_raw()` | strictly increasing starts | the `load_prices()` path only |
|
||||
| `price_history._parse_points()` | re-sorts by instant | the store read path only |
|
||||
| FCST-04's fill | emits ascending | that one producer only |
|
||||
| `PriceRead.series()` | **nothing** | every other caller, including tests |
|
||||
|
||||
Three producers agreeing today is not an invariant; it is a coincidence with
|
||||
good references. `test_price.py` 14f drives a series handed its 96 points in
|
||||
**reverse** order and asserts `at()` answers identically at every one of them —
|
||||
red the moment the sort is removed.
|
||||
|
||||
**Duplicated starts** (refused by `_validate_raw()`, constructible by hand) give
|
||||
the earlier list entry a zero-width window and the later one the real span. One
|
||||
of the pair becomes unreachable through `at()`; **nothing raises**, at
|
||||
construction or on the 30 s control path. Stated as a check rather than left to
|
||||
be discovered.
|
||||
|
||||
---
|
||||
|
||||
## 4. `covers()` shared it. `window()` did not
|
||||
|
||||
* **`covers()`** has no window rule of its own — it calls `at()` — so it shared
|
||||
the defect exactly and the same change fixes it. It is kept as a delegation
|
||||
for that reason: a second copy of the rule is a second place for it to be
|
||||
wrong.
|
||||
* **`window(since, until)`** grades a point's **start** against a half-open
|
||||
range and never asks how long a point lasts, so it carries no resolution
|
||||
assumption at all. It is **not** a coverage query — a point that starts before
|
||||
`since` and runs into it is absent from its result by design. That is now
|
||||
said in its docstring and pinned by a check, because the next reader will ask.
|
||||
It has **no callers** in `app/`.
|
||||
|
||||
---
|
||||
|
||||
## 5. What this card does NOT fix, with its shape
|
||||
|
||||
`price_forecast._walk()` — §8.3's irreversible-action gate — iterates
|
||||
`committed_hours()`, which steps in **whole hours**, and asks `series.at(u)` at
|
||||
each. On a quarter-hourly series that now reliably returns the hour's `:00`
|
||||
point (it previously returned something earlier and arbitrary), so the gate
|
||||
reads **one point per hour** and never looks at the `:15`/`:30`/`:45` flags.
|
||||
|
||||
A partially-provisional hour would therefore be gated on its first quarter
|
||||
alone. **Two things keep it off every shipped install, and both are
|
||||
circumstantial rather than structural:** `price_permits()`/`begin_phase()` have
|
||||
**no caller in `app/main.py`** — the §12 price-justified entry point is built
|
||||
and not yet wired — and FCST-04 carries **whole** hours while
|
||||
`generic_dayahead` publishes whole days, so the four quarters of an hour are
|
||||
firm or carried together anyway. Both are facts about the *callers and
|
||||
producers*, not properties of the *gate*, so this is written down here rather
|
||||
than left for the first sub-hourly feed or the first wiring-up to find. Fixing it means teaching
|
||||
`committed_hours()` about resolution, which is a data-model question this card
|
||||
deliberately does not open.
|
||||
|
||||
---
|
||||
|
||||
## 6. Mutations
|
||||
|
||||
Eight, each purged before the run (TEST-15), each reverted with the tree
|
||||
fingerprint checked afterwards (MUTATION.md habit 18), backup at a `mktemp`
|
||||
path per invocation.
|
||||
|
||||
⚠️ **Re-driven in full on the MERGED tree** after TARIFF-10 landed on
|
||||
`release/1.0` (base `88c59a9` -> `559f0eb`). All eight give the same verdicts
|
||||
and the same red-check counts as before the merge, which is the answer that
|
||||
matters: TARIFF-10 added 650 lines of checks to `test_price.py` and none of
|
||||
them moves under any of these edits, because they grade `resolve_tariff()`'s
|
||||
*reachability from a configuration* and not the lookup. Clean `app/price.py` on
|
||||
the merged tree = **`90d8ca31f7c0109d`, 85953 B** (pre-merge it was
|
||||
`30d1a56f0117e571`, 83742 B; the difference is TARIFF-10's `provider_id()` /
|
||||
`provider_configured()`, which this card does not touch).
|
||||
|
||||
| | mutation | Δ | red in |
|
||||
|---|---|---|---|
|
||||
| M1 | `at()` back to the shipped fixed one-hour scan over `self.points` | +68 B | **24 named checks**: `test_price.py` 14b ×4, 14c ×3, 14d, 14e ×3, 14f, 14g; `test_arbitrage.py` 10b ×6, 10c ×3, 10d ×2 |
|
||||
| M2 | drop the one-hour cap | −68 B | `test_price.py` 14d |
|
||||
| M3 | flat one-hour tail instead of the span in front | **+0 B** | `test_price.py` 14e ×3, 14g — 4 checks |
|
||||
| M4 | trust the caller's list order — drop the sort | −61 B | `test_price.py` 14f |
|
||||
| M5 | `covers()` grows its own hour-wide rule | +95 B | `test_price.py` 14e, 14g |
|
||||
| M6 | `window()` becomes a coverage query | −37 B | `test_price.py` 14g |
|
||||
| M7 | the window's end becomes inclusive | +1 B | `test_price.py` 14b ×4, 14c ×4, 14d, 14e ×4, 14f ×2, 14g — 16 checks |
|
||||
| M8 | the window's start becomes exclusive | −1 B | `test_price.py` 14b ×4, 14c ×4, 14d, 14f ×2, 14g, 14h, plus the pre-existing section 6 check — 14 checks |
|
||||
|
||||
**M3 is the zero-byte one and it is the tail decision itself** — the case
|
||||
MUTATION.md names as the risky size, caught by the check written for it.
|
||||
|
||||
**What the arbitrage file does NOT see, with sizes.** M2, M4, M5, M6 and M8
|
||||
leave `test_arbitrage.py` green, and that is honest rather than a gap to round
|
||||
off: they are `at()`-level properties (holes, caller order, `covers()`,
|
||||
`window()`, the start boundary) that no arbitrage fixture reaches, because the
|
||||
probes are seven minutes into a slot and the plans have no holes. The one that
|
||||
matters end to end — M1, the shipped rule — is red in **both** files, on the
|
||||
named check `THE PROPERTY THE DEFECT BROKE`.
|
||||
|
||||
---
|
||||
|
||||
## 7. TARIFF-10 landed on the base while this was open — what it changes here
|
||||
|
||||
Base moved `88c59a9` → `559f0eb` mid-card. The question is not whether the
|
||||
branches merge; it is whether TARIFF-10 makes any sentence above **false**.
|
||||
|
||||
**In `app/price.py` the two do not meet.** TARIFF-10 added `provider_id()` and
|
||||
`provider_configured()` beside `price_option()` and routed `load_prices()`
|
||||
through them. That is the option-reading layer; it touches nothing in
|
||||
`_Series`, `at()`, `covers()`, `window()` or `_cover_table()`. Git merged the
|
||||
file without a conflict, and the merged `at()` is byte-identical to the one the
|
||||
mutations above were driven against.
|
||||
|
||||
**Behaviourally it widens who has a series at all, and that cuts one way only.**
|
||||
`run_tariff_refresh()` now runs on installs with `arbitrage_enabled` **off**, so
|
||||
a provider-configured site has a standing plan — and therefore a `_Series` —
|
||||
where before it had none. **It does not add a new `at()` consumer to the 30 s
|
||||
tick**, which is the claim worth checking rather than assuming:
|
||||
|
||||
| caller of `at()`/`covers()` | reached by a non-trading install? |
|
||||
|---|---|
|
||||
| `arbitrage._point_at()` (`arbitrage.py:300`) | no — trading only |
|
||||
| `static_tariff.TariffPlan.price_at()` / `.words` `here` (`:750`, `:819`) | on the tick these are reached through `static_status()`, which builds a **`static_plan`** — hourly points, immune. `main.py` calls only `plan.words` on the fetched plan, which counts points and never looks one up |
|
||||
| `price_forecast._walk()` (`:562`, `:568`) | **`price_permits()` and `begin_phase()` have no caller in `app/main.py` at all** — §12's price-justified entry point is built and not yet wired |
|
||||
|
||||
So TARIFF-10 neither creates nor closes any exposure this card is about. What it
|
||||
does do is make the defect's reach larger the moment such a site arms trading,
|
||||
because the series it would trade against now exists on every configured
|
||||
install rather than only on armed ones. That strengthens the case for the fix
|
||||
and changes nothing in its reasoning.
|
||||
|
||||
**In `test_price.py` both cards appended a section 13.** Nothing is dropped:
|
||||
TARIFF-10's stays **13**, this card's is renumbered **14** (which is also this
|
||||
card's number), and the temp-directory teardown TARIFF-10 put at the end of the
|
||||
file stays at the end of the file. The two sections share no fixture, no option
|
||||
dict and no helper name; TARIFF-10's grades `resolve_tariff()`'s *reachability
|
||||
from a configuration* and drives real fetches with a counting transport, and
|
||||
**none of its checks moves under any of the eight mutations above** — measured,
|
||||
not assumed.
|
||||
|
||||
---
|
||||
|
||||
## 8. TARIFF-12 landed too — a plan may now be 90 minutes old when it is asked
|
||||
|
||||
Base moved `559f0eb` → `a6e64fb` while this was open. TARIFF-12 stops a
|
||||
returned §11.4 degradation from overwriting a standing plan that is still
|
||||
inside `TARIFF_MAX_AGE_S`, so **a served plan can now be up to
|
||||
`3 × TARIFF_REFRESH_S = 5400 s` old** where before it was at most one refresh
|
||||
interval. `at()` is what answers *"what does it cost right now"* out of that
|
||||
older plan, so the question is fair and the answer is written out rather than
|
||||
assumed.
|
||||
|
||||
**Per-point span: unaffected, structurally.** Every window is derived from the
|
||||
neighbouring points' **absolute `start` instants**. Those are `datetime`s inside
|
||||
a frozen dataclass inside a tuple — they cannot move, and `_cover_table()` runs
|
||||
once at construction over data that cannot change afterwards. A plan's *age* is
|
||||
a property of `fetched_at` / `tariff_at`, never of a window. **The index cannot
|
||||
go stale**, which is a small positive under TARIFF-12 rather than a risk: a kept
|
||||
plan now lives longer, and there is nothing in it that needed recomputing.
|
||||
|
||||
**The one-hour cap: unaffected.** It bites only where a gap exceeds an hour,
|
||||
i.e. at a hole FCST-04 left. Walking further into a series crosses more windows;
|
||||
each is still resolved on its own neighbours.
|
||||
|
||||
**The tail: reachable for the first time, and the choice made in §2 is the one
|
||||
that survives it.** This is the half worth spelling out.
|
||||
|
||||
* At the **shipped 48 h horizon** the tail is unreachable by staleness. A plan
|
||||
is served for at most 90 minutes, so `now` is at most 90 minutes into a
|
||||
~48 h series — the last point is still about 46½ hours away.
|
||||
* But `price_horizon_h` is `int(1,72)` in `config.yaml`, so an operator **can**
|
||||
configure a **1 h** horizon. A 90-minute serve-stale window against a 1 h
|
||||
series puts `now` genuinely past the end. That is not hypothetical; it is a
|
||||
supported configuration meeting a supported outage.
|
||||
* Under the **chosen** rule a quarter-hourly 1 h series ends at `:45` and that
|
||||
point's window closes at the top of the next hour: `at()` raises `Absence`,
|
||||
`_point_at()` returns None, `_act()` idles with its "no price for this
|
||||
instant" reason, and §11.4 sends the site to the static profile.
|
||||
* Under the **rejected** flat one-hour tail the same stale plan would have gone
|
||||
on trading against the `:45` print for **up to 45 minutes after the interval
|
||||
it describes had ended** — pricing an outage with a number for a quarter that
|
||||
is over.
|
||||
|
||||
So TARIFF-12 does not weaken the tail decision. It is the first thing that makes
|
||||
it reachable on a shipped configuration, and it independently selects the same
|
||||
answer. ⚠️ **Note which direction that runs**: the more stale a plan is allowed
|
||||
to be, the more the tail rule is doing real work, so it should be re-read if
|
||||
`TARIFF_MAX_AGE_S` is ever widened again.
|
||||
|
||||
**`TariffPlan.from_feed` cannot produce a series this code has not seen.** It is
|
||||
a pure predicate on `origin` (`== "provider"`), and TARIFF-12's decision keeps or
|
||||
replaces a **whole plan object**. It never splices two reads into one series, so
|
||||
it cannot hand `at()` an unsorted or duplicated point list — the case §3 covers
|
||||
defensively and that nothing shipped produces.
|
||||
|
||||
**Re-graded on this base.** All 29 files green. `app/price.py` is byte-identical
|
||||
to the TARIFF-10 merge (`90d8ca31f7c0109d`, 85953 B) because TARIFF-12 does not
|
||||
touch it. **M1** re-driven: +68 B, red on the same **24 named checks** across
|
||||
both files. **M3** re-driven: **+0 B**, red on the same 4. This branch
|
||||
contributes **zero diff** to `app/main.py` and `app/static_tariff.py`.
|
||||