Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4914a174a | ||
|
|
9aadf469bc | ||
|
|
4b521b2df0 | ||
|
|
0942228d65 | ||
|
|
6aabb832fc | ||
|
|
d31a12b7c6 | ||
|
|
2983d7c3b0 | ||
|
|
611d3dbcdf | ||
|
|
46383c5ae5 | ||
|
|
5e966ca59f | ||
|
|
4e66583a52 | ||
|
|
c34b5a2922 | ||
|
|
9c1a23328a | ||
|
|
c040aac4b8 | ||
|
|
0cdaa3bd3e | ||
|
|
7cde5d862a | ||
|
|
b0a17474a3 | ||
|
|
0ba3a200fe | ||
|
|
7aa1bd052e |
@@ -8,3 +8,11 @@
|
|||||||
* text=auto eol=lf
|
* text=auto eol=lf
|
||||||
*.png binary
|
*.png binary
|
||||||
*.gz binary
|
*.gz binary
|
||||||
|
|
||||||
|
# `.gitea/workflows/*.yml` steps embed shell heredocs; a `\r` on the terminator
|
||||||
|
# line means the heredoc never closes and the job fails on a syntax error that is
|
||||||
|
# invisible in the diff. A Windows checkout with core.autocrlf=true would
|
||||||
|
# reintroduce it on the next edit. CI-01.
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# The image the add-on's tests run in (CI-01). This repo had NO CI at all before
|
||||||
|
# that ticket - eight runnable test files, none of them ever run by a machine.
|
||||||
|
#
|
||||||
|
# ⚠️ THE DEPENDENCIES ARE THE SHIPPED ONES, FROM APK, AND THAT IS THE POINT.
|
||||||
|
# goodwe_controller/Dockerfile installs `py3-aiohttp py3-paho-mqtt` from Alpine
|
||||||
|
# packages rather than pip, because Alpine ships musl and there are no musl
|
||||||
|
# wheels for aiohttp - pip would compile it from source on a client's Raspberry
|
||||||
|
# Pi. Testing against pip-installed glibc wheels would be testing a different
|
||||||
|
# add-on from the one that ships. Same base, same package manager, same names.
|
||||||
|
#
|
||||||
|
# ⚠️ NOT `FROM ghcr.io/home-assistant/...-base`. The real Dockerfile takes its
|
||||||
|
# base from the Supervisor's `BUILD_FROM` build-arg, which is per-architecture
|
||||||
|
# and not resolvable outside a Supervisor build. `alpine:3` is what those images
|
||||||
|
# are, minus the s6 supervision the tests do not use - and it is already cached
|
||||||
|
# on this runner's host, so the gate does not depend on a registry pull.
|
||||||
|
#
|
||||||
|
# ⚠️ The runner container itself CANNOT run these tests: it is Alpine with a bare
|
||||||
|
# python3, no aiohttp, no pip (`No module named pip`; checked on the box, not
|
||||||
|
# assumed). An image is not gold-plating here, it is the only option that does
|
||||||
|
# not mutate a shared long-lived runner.
|
||||||
|
FROM alpine:3
|
||||||
|
RUN apk add --no-cache python3 py3-aiohttp py3-paho-mqtt
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# CI-01: this repository's FIRST automated check. Before this file, admin/goodwe-addon
|
||||||
|
# had no `.gitea/workflows/` directory at all - eight runnable test files, ~100 s
|
||||||
|
# of real coverage including a wall-clock cadence test, and not one machine ever
|
||||||
|
# ran them. Every merge here was a human reading a diff.
|
||||||
|
#
|
||||||
|
# ⚠️ NO `paths:` FILTER, AND ITS ABSENCE IS LOAD-BEARING. Branch protection on
|
||||||
|
# release/1.0 requires the context `checks / unit (pull_request)`. Gitea leaves a
|
||||||
|
# required context it never receives PENDING forever, so the moment this workflow
|
||||||
|
# learns to sit a PR out, that PR becomes unmergeable. Condition a STEP if you
|
||||||
|
# must; never the workflow.
|
||||||
|
#
|
||||||
|
# ⚠️ THE RUNNER IS SHARED WITH THE OTHER REPO'S RIG. `goodwe-runner` on
|
||||||
|
# 192.168.2.147 carries the labels `self-hosted` and `goodwe-rig` and is
|
||||||
|
# registered `:host`, and it is the only runner there is - so this job wears the
|
||||||
|
# rig label without wanting a rig. Nothing below starts, stops, or reads the
|
||||||
|
# `goodwe-sim` compose project or takes `sim/riglock.py`; it runs one short
|
||||||
|
# container and exits. It does occupy the runner's single slot (`capacity: 1`),
|
||||||
|
# so it QUEUES behind a rig campaign - a delayed verdict, never a destroyed one.
|
||||||
|
#
|
||||||
|
# NO JS ACTIONS: the runner image has no node, so `actions/checkout` cannot run.
|
||||||
|
# Checkout is seven lines of git below.
|
||||||
|
name: checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [release/1.0, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
# ⚠️ PER PULL REQUEST, NOT A BARE NAME. A shared group holds one running and
|
||||||
|
# one pending run, so a bare name lets a second PR supersede a first PR's run
|
||||||
|
# before any runner has picked it up - which is exactly what happened to the
|
||||||
|
# other repo's run 521 (four jobs, `started_at` at the Unix epoch,
|
||||||
|
# `runner_id: 0`, concluded `cancelled`, and the PR merged anyway). Do not
|
||||||
|
# replace this with a constant.
|
||||||
|
group: addon-checks-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit:
|
||||||
|
runs-on: [self-hosted, goodwe-rig]
|
||||||
|
timeout-minutes: 70 # ~4 to run; the rest is queueing for the single slot
|
||||||
|
env:
|
||||||
|
# ⚠️ UNDER /home/homelab, NOT $GITHUB_WORKSPACE (ENV-01, and re-measured
|
||||||
|
# during CI-01). act's workspace is `/root/.cache/act/<id>/hostexecutor`, a
|
||||||
|
# path that exists only INSIDE the runner container. Hand it to the host's
|
||||||
|
# snap-confined dockerd and the build fails with "failed to read
|
||||||
|
# dockerfile", or - worse, and this is the documented case - a bind mount
|
||||||
|
# resolves to an EMPTY DIRECTORY with no error at all. /home/homelab is
|
||||||
|
# mirrored 1:1 into the runner and is under $HOME, where snap confinement
|
||||||
|
# accepts it. The same probe under /tmp failed and under $HOME succeeded.
|
||||||
|
WS: /home/homelab/ci/addon-checks-${{ github.run_number }}
|
||||||
|
steps:
|
||||||
|
# ⚠️ THE CHECKOUT IS TWO LEVELS BELOW THE MOUNT ROOT, DELIBERATELY.
|
||||||
|
# test_freeze.py's find_hwsim() reads `HERE.parents[1] / "goodwe" / "sim"`,
|
||||||
|
# looking for the infra repo as a SIBLING of this one. Check out flat -
|
||||||
|
# goodwe_controller directly at the mount root - and `parents[1]` does not
|
||||||
|
# exist, so the test dies on `IndexError: 1` before its first assertion.
|
||||||
|
# Measured during CI-01; the flat layout was tried first and did exactly
|
||||||
|
# that. $WS/goodwe-addon/goodwe_controller gives parents[1] == $WS.
|
||||||
|
- name: checkout into a path the host shares (git; no node for actions/checkout)
|
||||||
|
env:
|
||||||
|
URL: ${{ github.server_url }}/${{ github.repository }}.git
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
rm -rf "$WS" && mkdir -p "$WS/goodwe-addon" && cd "$WS/goodwe-addon"
|
||||||
|
git init -q .
|
||||||
|
git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:${TOKEN}@#")"
|
||||||
|
git fetch -q --depth 1 origin "$GITHUB_REF" || git fetch -q --depth 1 origin "$GITHUB_SHA"
|
||||||
|
git checkout -qf FETCH_HEAD
|
||||||
|
echo "checked out $(git log --oneline -1)"
|
||||||
|
|
||||||
|
- name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile)
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
docker build -q -t goodwe-addon-checks:1 "$WS/goodwe-addon/.gitea/ci" >/dev/null
|
||||||
|
docker run --rm goodwe-addon-checks:1 python3 -c \
|
||||||
|
'import aiohttp, paho.mqtt.client as m, sys; print("python", sys.version.split()[0], "aiohttp", aiohttp.__version__)'
|
||||||
|
|
||||||
|
# ⚠️ THE FILE LIST IS DERIVED FROM THE TREE, NOT TYPED HERE. A hand-written
|
||||||
|
# list is how a new test file joins the repo and is never run - the exact
|
||||||
|
# shape of failure this ticket exists to close. The step globs `test_*.py`,
|
||||||
|
# prints the count, and REFUSES A COUNT OF ZERO: a loop over an empty glob
|
||||||
|
# exits 0 and would report a green gate that examined nothing.
|
||||||
|
#
|
||||||
|
# ⚠️ test_loop.py TAKES ~93 SECONDS OF WALL CLOCK ON PURPOSE (its own
|
||||||
|
# docstring says so, and says why: it drives the real `run_control()`
|
||||||
|
# against a real wall-clock floor, and mocking the clock would leave the
|
||||||
|
# assertion testing the mock). It is not hung. Do not "fix" it with a
|
||||||
|
# shorter timeout - a 60 s limit was tried during CI-01 and reported
|
||||||
|
# exactly the false hang the docstring predicts.
|
||||||
|
#
|
||||||
|
# ⚠️ WHAT THIS RUN DOES NOT COVER, stated because a pass here would
|
||||||
|
# otherwise over-claim. test_freeze.py's third section wants the infra
|
||||||
|
# repo's `sim/hwsim.py` to drive a real HTTP meter through the real
|
||||||
|
# transport. admin/goodwe is PRIVATE (checked via the API) and this
|
||||||
|
# workflow's GITHUB_TOKEN is minted for THIS repository only, so hwsim is
|
||||||
|
# absent here and `find_hwsim()` returns None. The rest of the file runs
|
||||||
|
# and passes. It does so SILENTLY - the file prints nothing to say the
|
||||||
|
# hwsim section was skipped - so "test_freeze.py: PASS" below is a weaker
|
||||||
|
# claim than it looks. That belongs to whoever owns test_freeze.py next;
|
||||||
|
# naming it here is the least this gate can do about it.
|
||||||
|
- name: the add-on's own tests
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
docker run --rm -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
|
||||||
|
cd /w/goodwe-addon/goodwe_controller
|
||||||
|
set -- test_*.py
|
||||||
|
if [ "$1" = "test_*.py" ]; then
|
||||||
|
echo "NO test_*.py FOUND - the mount is empty or the layout moved. NOTHING has been checked."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$# test file(s) to run"
|
||||||
|
rc=0
|
||||||
|
for f in "$@"; do
|
||||||
|
start=$(date +%s)
|
||||||
|
if python3 "$f" > /tmp/out 2>&1; then
|
||||||
|
echo "PASS $f ($(( $(date +%s) - start ))s)"
|
||||||
|
else
|
||||||
|
echo "FAIL $f ($(( $(date +%s) - start ))s)"
|
||||||
|
tail -40 /tmp/out
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit $rc
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: clean up
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
rm -rf "$WS" || true
|
||||||
|
# Root-owned by this runner inside a directory the `homelab` user owns,
|
||||||
|
# and sudo on that box is interactive-only, so the user cannot clean
|
||||||
|
# this up and the job that made it has to.
|
||||||
|
ls -1dt /home/homelab/ci/addon-checks-*/ 2>/dev/null | tail -n +6 | xargs -r rm -rf || true
|
||||||
@@ -2,6 +2,154 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
**The capacity-tariff peak is now measured, banked and survives a restart
|
||||||
|
(TARIFF-01).** New `app/tariff.py`. The 15-minute block average itself was
|
||||||
|
already built and unwired - `p1.QuarterAverager` computes clock-aligned 900 s
|
||||||
|
blocks, time-weighted over irregular telegram spacing, offtake-only and
|
||||||
|
per-phase - and `P1Ingest.blocks` was a 96-entry list a restart erased. This
|
||||||
|
adds the SQLite behind it (`/data/tariff.db`): one row per completed quarter in
|
||||||
|
the indefinite 15-min tier, one row per calendar month in the permanent peak
|
||||||
|
tier, and no `DELETE` against either anywhere in the module.
|
||||||
|
|
||||||
|
**Blocks, not a sliding window.** The card says "rolling"; 9.1 bills "the
|
||||||
|
highest 15-minute average offtake", stated per QUARTER. "Rolling" here means
|
||||||
|
continuously updated, not a trailing-15-minutes window - a trailing window
|
||||||
|
reports a peak the meter never bills, and reports it HIGH, because a burst
|
||||||
|
straddling :15 fills it completely while contributing to two block averages at
|
||||||
|
partial weight.
|
||||||
|
|
||||||
|
**A restart mid-block resumes it, and a restart that outlives the block banks
|
||||||
|
it.** `QuarterAverager` grew `snapshot()`/`restore()`; the tracker checkpoints
|
||||||
|
the open block every 15 s (worst case 1.7 % of one quarter) and clears the
|
||||||
|
checkpoint in the same breath as a block close. The held sample keeps its
|
||||||
|
ORIGINAL timestamp across the restart, so the outage is integrated under the
|
||||||
|
same `max_hold_s` rule a running process would apply and uncovered seconds drag
|
||||||
|
the closed block DOWN rather than being held forward. `quarter_start` is the
|
||||||
|
primary key and a re-bank may only ever RAISE a figure, so a replay cannot
|
||||||
|
double-count and a partial re-run cannot lower a quarter already measured.
|
||||||
|
|
||||||
|
**An unreadable store is not a zero peak.** `peak()` answers `OBSERVED` /
|
||||||
|
`NO_RECORD` / `NOT_OBSERVED` and carries `peak_w = None` unless observed - the
|
||||||
|
`riglock.holder()` fix applied to something that spends money. A peak read as
|
||||||
|
0 W hands SAFETY-07 maximum headroom and silently disarms the guard at the
|
||||||
|
moment its own storage breaks, so there is no accessor that defaults it: a
|
||||||
|
caller who forgets gets a `TypeError`, and the status page prints a different
|
||||||
|
row for "no quarter yet" than for "the database did not answer".
|
||||||
|
|
||||||
|
**Calendar months are LOCAL, blocks are UTC.** A quarter starting 22:15 UTC on
|
||||||
|
31 August bills in September. Block alignment still needs no timezone.
|
||||||
|
|
||||||
|
**No new option, and no second peak entity.** The soft limit is the existing
|
||||||
|
`peak_cap_w`; `config.yaml` and `validate.py` are untouched, so CFG-03 is not
|
||||||
|
widened. `peak_forecast_entity` stays the input to `maintenance_charge_floor()`
|
||||||
|
- repointing that consumer changes what moves energy and belongs to SAFETY-07.
|
||||||
|
|
||||||
|
**It measures and nothing else.** No claim, no clamp, no command; graded on the
|
||||||
|
parsed syntax in `test_tariff.py`, which also proves the same grader lights up
|
||||||
|
on `main.py`. Per SAFETY-20, the quantity is DELIVERED power - `tariff.py` only
|
||||||
|
ever sees `P1Sample`, the meter's own reading, never a setpoint.
|
||||||
|
|
||||||
|
**AC 3 is not met and the check says so.** It asks for 30 days of replay; two
|
||||||
|
days of this house's real meter exist (2026-08-20, 2026-08-23), now committed as
|
||||||
|
`scenarios/p1-net-*.jsonl.gz`. `test_tariff.py` part 9 replays them, reports
|
||||||
|
the percentage under the shipped limit next to the sample size in days, and
|
||||||
|
asserts nothing about the 95 % figure.
|
||||||
|
|
||||||
|
**A computed setpoint over the rating now alarms instead of being clipped in
|
||||||
|
silence (SAFETY-03).** The clamp was never the missing half - specs.md 10.3
|
||||||
|
records an unclamped integrator climbing ~130 W per 4 s past 10 kW and reporting
|
||||||
|
**14 768 W** while pinned at the 5 kW rail, and a silent clamp turns exactly that
|
||||||
|
into a flat, legal-looking 5000 W hold. `write_setpoint()` - the one path every
|
||||||
|
write takes, cycle, heartbeat, operator button and shutdown alike - now clamps to
|
||||||
|
the **validated** `max_w` and reports whether the raw value differed, and
|
||||||
|
`Controller.check_output_clamp()` raises `OUTPUT_CLAMP`: a persistent
|
||||||
|
notification (`goodwe_output_clamp`), an `ERROR`, an event row and a red status
|
||||||
|
banner. **Not a trip**: the clamped value is written and the loop keeps running,
|
||||||
|
per 11.2's "clamp *and alarm*".
|
||||||
|
|
||||||
|
**The bound is `max_w` and nothing is `min()`ed at runtime.** Section 14 requires
|
||||||
|
the loop clamp and the driver's `setpoint_limit_w` to agree, validated at startup
|
||||||
|
with a refusal on mismatch - which CFG-01 already ships. The device range is
|
||||||
|
still applied second, as defence in depth; after a successful validation it
|
||||||
|
cannot engage, so if it ever does, that is louder than the first stage and it
|
||||||
|
alarms too, naming the bound. No new config key: the guard's limit is the one
|
||||||
|
the operator already set, and its cadence is a module constant.
|
||||||
|
|
||||||
|
**`Decision` grew `i_clamped` / `clamped` booleans, and that is the load-bearing
|
||||||
|
part.** `reason` is one string that later stages overwrite - a cycle that clamps
|
||||||
|
the integrator and is then slew limited reports `slew-limited`, and one that
|
||||||
|
clamps while saturated reports `saturated-freeze`. Both *are* the winding case,
|
||||||
|
so an alarm keyed on the string is silent on exactly the cycles it exists for:
|
||||||
|
mutating main.py back onto `"clamp" in decision.reason` drops the 10.3 replay
|
||||||
|
from 2 engagements to **0**. The flags are set where the clamp happens and are
|
||||||
|
never overwritten. On a default install (`integrator_max_w` following `max_w`)
|
||||||
|
the integrator bound is the one that bites and the output clamp is unreachable,
|
||||||
|
so the integrator flag is the whole signal.
|
||||||
|
|
||||||
|
**Distinct from the saturation freeze, and checked in the negative.** A clamp is
|
||||||
|
a *software* bug signal; saturation is the *hardware* failing to follow a legal
|
||||||
|
command, and its action is to freeze the integrator, not to alarm. They share no
|
||||||
|
reason code, notification id, status row or counter. `test_clamp.py` drives a
|
||||||
|
loop that saturates for 10 consecutive cycles at 1.4 % of the rating and asserts
|
||||||
|
zero clamp notifications, zero engagements, no log row and no status row - after
|
||||||
|
first asserting that the saturation freeze *really did* engage, so the silence is
|
||||||
|
an observation rather than a scenario that did nothing.
|
||||||
|
|
||||||
|
**The action log is coalesced per episode, and that was a real bug.**
|
||||||
|
`log_event()` keeps 40 rows; one row per engagement flushed the entire ring
|
||||||
|
within 40 cycles of a winding loop, and the first thing it evicted was
|
||||||
|
SAFETY-18's own `METER FROZEN?` row - this guard silencing another guard's log.
|
||||||
|
Caught by `test_freeze.py` going red, not by inspection. One row per episode now,
|
||||||
|
carrying the exact engagement count read off the counter.
|
||||||
|
|
||||||
|
**A non-finite setpoint is refused, and it was worse than passing one through.**
|
||||||
|
`nan` is loss of input (11.1 rule 1: command zero, keep commanding), not an
|
||||||
|
out-of-range value. Measured: because every comparison against `nan` is False, a
|
||||||
|
naive `max(-limit, min(limit, nan))` keeps its *first* argument and turns a `nan`
|
||||||
|
into a command **at the full rating**, then reports it as a clamp engagement.
|
||||||
|
|
||||||
|
**Review fixes (PR #12).** Three, and the first was a behavioural regression
|
||||||
|
this PR introduced: `clamp_to_rating()` **returned early** from the `max_w`
|
||||||
|
stage, so the device bound - the "applied second, defence in depth" half - was
|
||||||
|
reached only when the rating bound had NOT fired. The bigger the overshoot, the
|
||||||
|
less the guard did. At `max_w` 5000 against a +-4000 device, +9999 emitted
|
||||||
|
**+5000 W, outside the device range**, while a mere +4500 was correctly held to
|
||||||
|
+4000. That undid the pre-PR guarantee "never send a value the device will
|
||||||
|
reject outright" and undercut this module's own case for clamping to `max_w`
|
||||||
|
rather than a runtime `min()`, which rests entirely on that stage catching a
|
||||||
|
validation that did not run. Both stages now always run, the result names both
|
||||||
|
bounds when both bite (`max_w+device`), and the property is asserted over a
|
||||||
|
492-point sweep rather than on witness values - the defect was invisible to any
|
||||||
|
single-value check that happened to sit inside one stage. No reachable
|
||||||
|
production path was found for it (two validation gates and the arbiter bound sit
|
||||||
|
in front), but latent is not harmless.
|
||||||
|
|
||||||
|
Two harness defects, both of the kind where a check cannot fail for the value it
|
||||||
|
forbids: the hysteresis check issued `range(CLEAR_CYCLES - 1)` clean cycles,
|
||||||
|
which at `CLEAR_CYCLES = 1` issues **zero** and passes while printing
|
||||||
|
"0 clean cycles do NOT clear it" - green for the chatter setting the constant
|
||||||
|
exists to prevent. And the re-assert check stimulated at `REASSERT_S + 9`, green
|
||||||
|
for **any** value including 3600, i.e. the hour of silence the module promises
|
||||||
|
not to produce. Both are now written in wall-clock terms that do not move when
|
||||||
|
the constant does: **one** clean cycle must not clear the alarm, and a loop
|
||||||
|
pinned across the 900 s capacity-tariff quarter must speak at least 5 times and
|
||||||
|
on no more than 20 % of cycles. `CLEAR_CYCLES = 1`, `REASSERT_S = 3600` and
|
||||||
|
`REASSERT_S = 0` each go red.
|
||||||
|
|
||||||
|
**`test_clamp.py`** (new, assert-based, no framework) covers the clamp
|
||||||
|
arithmetic and its boundaries - exactly +-`max_w` is *not* an engagement - the
|
||||||
|
alarm's edge/re-assert/hysteresis cadence, the 10.3 runaway replayed through the
|
||||||
|
real `Controller.cycle()` against an unclamped transcription, the saturation
|
||||||
|
negative, and the direct-write path. Six author mutations reproduced red,
|
||||||
|
including "clamp kept, alarm deleted" (the historical build) and "alarm keyed on
|
||||||
|
`reason`". Two of the check's own hypotheses about how the freeze and the clamp
|
||||||
|
interact were **refused by the sweep** and rewritten to what it measured: a
|
||||||
|
frozen cycle *can* clamp, and does so exactly when the integrator reverses.
|
||||||
|
|
||||||
|
`test_freeze.py`'s notification assertions now filter by `notification_id`
|
||||||
|
rather than by service - the add-on raises more than one kind of notification,
|
||||||
|
and its railing scenarios legitimately raise this one too.
|
||||||
|
|
||||||
**Cheap-window maintenance charging was unreachable at the shipped defaults, and
|
**Cheap-window maintenance charging was unreachable at the shipped defaults, and
|
||||||
now the config that makes it unreachable is refused (MAINT-04).** `config.yaml`
|
now the config that makes it unreachable is refused (MAINT-04).** `config.yaml`
|
||||||
shipped `max_w: 2000` against `maintenance_charge_w: 2500`. The cheap-window
|
shipped `max_w: 2000` against `maintenance_charge_w: 2500`. The cheap-window
|
||||||
@@ -81,6 +229,59 @@ a Supervisor install (every key is always supplied) and reachable only from
|
|||||||
tests/`sim`. The real fix is one source of truth for option defaults, which is
|
tests/`sim`. The real fix is one source of truth for option defaults, which is
|
||||||
bigger than this ticket and touches files another ticket is in.
|
bigger than this ticket and touches files another ticket is in.
|
||||||
|
|
||||||
|
## 0.5.2
|
||||||
|
|
||||||
|
**A meter frozen at a wrong-but-fresh value now raises an alarm. It still is not
|
||||||
|
stopped.** (SAFETY-18)
|
||||||
|
|
||||||
|
A meter that answers `200 OK` with a stale document forever is *arriving* on
|
||||||
|
every poll, so every freshness mechanism in the stack reads it as healthy:
|
||||||
|
`meter_max_age_s` is reset by each response, the firmware watchdog is fed by the
|
||||||
|
heartbeat and by a sample age that never climbs, and the arbiter contributes only
|
||||||
|
±`max_w`. The loop integrates a constant error and rails — replaying the real
|
||||||
|
`compute()` at 15 s cycles, in 150/60/30 s at `max_w: 2000` for a meter frozen at
|
||||||
|
350/1000/≥3000 W, and 360/135/75 s at `max_w: 5000`. **The live install runs
|
||||||
|
`max_w: 5000`**, which is both the schema maximum and the device rating.
|
||||||
|
|
||||||
|
The new detector (`app/freeze.py`) is a **conjunction**, not the bare
|
||||||
|
`unchanged_s` threshold TEL-05 measured and rejected:
|
||||||
|
|
||||||
|
unchanged_s ≥ meter_freeze_s AND |command| within 5 % of max_w
|
||||||
|
|
||||||
|
Both terms already existed and were already on the status page. They fail for
|
||||||
|
independent reasons: at the converged point the command is nowhere near the rail,
|
||||||
|
and at the rail against a real load the kWh registers move within seconds. At the
|
||||||
|
shipped 300 s there is also a structural relation, not just a margin — the
|
||||||
|
slowest a healthy 1 Wh register can tick and still look unchanged is 12 W, which
|
||||||
|
is inside the 15 W deadband, so the command there is parked at 0 W and the two
|
||||||
|
halves cannot both be true.
|
||||||
|
|
||||||
|
**Notification only, deliberately.** An HA persistent notification (fixed
|
||||||
|
`notification_id`, so a repeat replaces rather than stacks), an `ERROR` log line,
|
||||||
|
a UI event and a red status banner. No claim, no latch, no 0 W: a false trip
|
||||||
|
parks the battery and the operator's next move is to switch the guard off; a
|
||||||
|
false notification costs an alert. The trip is a separate decision that needs
|
||||||
|
data from a real install.
|
||||||
|
|
||||||
|
- New option `meter_freeze_s` (default 300, range 60–3600, no "off").
|
||||||
|
- **Only `meter_source: homewizard_local` is covered** — it is the only transport
|
||||||
|
that sees the meter's own document. Everywhere else the status page says
|
||||||
|
**NOT COVERED** rather than "armed".
|
||||||
|
- `DOCS.md` now states the **capacity-tariff exposure** this does *not* close:
|
||||||
|
`peak_cap_w` caps maintenance charging and nothing caps the loop, so a lying
|
||||||
|
meter can drive a 5 kW unattended import, and ≥15 minutes of that sets the
|
||||||
|
month's billed peak (measured: one 4998 W quarter moved a monthly peak from
|
||||||
|
4832 → 5013 W). That cap is SAFETY-07.
|
||||||
|
- `test_freeze.py` grades it in both directions: silent over 40 minutes of the
|
||||||
|
live house's real converged capture (committed as
|
||||||
|
`scenarios/p1-age-2026-08-26.jsonl.gz`), silent over an hour of TEL-05's
|
||||||
|
low-power case where a bare threshold is shown to fire, and alarming on a
|
||||||
|
genuinely frozen meter driven over real HTTP through the real
|
||||||
|
`HomeWizardLocalSource` — against the rig's own `hwsim` `freeze` fault when the
|
||||||
|
infra repo is reachable, an equivalent stub otherwise. Its negative twin is the
|
||||||
|
load-bearing one: the same railed command against a meter that is still moving
|
||||||
|
must stay silent at the same threshold.
|
||||||
|
|
||||||
## 0.5.1
|
## 0.5.1
|
||||||
|
|
||||||
**SAFETY-02's latch now survives an add-on restart, which is the difference
|
**SAFETY-02's latch now survives an add-on restart, which is the difference
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ The inverter **holds its last command forever** — it has no meter-timeout. So:
|
|||||||
- The **optional RS485 e-stop** is the only thing that covers this machine
|
- The **optional RS485 e-stop** is the only thing that covers this machine
|
||||||
dying. Without it, a failed host leaves the battery latched at its last
|
dying. Without it, a failed host leaves the battery latched at its last
|
||||||
command until someone intervenes.
|
command until someone intervenes.
|
||||||
|
- This add-on **raises an alarm — and nothing more —** when the meter keeps
|
||||||
|
answering with a reading that never changes while the command sits at the
|
||||||
|
rail. See "The frozen-meter alarm" and, before you rely on it, "Capacity-tariff
|
||||||
|
exposure of the grid-following loop".
|
||||||
|
|
||||||
**If anything looks wrong: stop the add-on.** That commands 0 W and the
|
**If anything looks wrong: stop the add-on.** That commands 0 W and the
|
||||||
hardware holds it there.
|
hardware holds it there.
|
||||||
@@ -433,6 +437,150 @@ under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp
|
|||||||
is dropped so the battery can shave the peak instead. Money outranks the
|
is dropped so the battery can shave the peak instead. Money outranks the
|
||||||
maintenance schedule.
|
maintenance schedule.
|
||||||
|
|
||||||
|
#### Capacity-tariff exposure of the grid-following loop
|
||||||
|
|
||||||
|
⚠️ **`peak_cap_w` caps MAINTENANCE charging. It does not cap the loop.** The
|
||||||
|
grid-following loop is bounded by `max_w` and by the device rating, and by
|
||||||
|
nothing else. That is deliberate — the loop exists to *reduce* what crosses the
|
||||||
|
meter, so under every healthy condition it lowers the peak rather than raising
|
||||||
|
it — but it means one thing is true and worth writing down:
|
||||||
|
|
||||||
|
**If the meter lies, the loop will happily buy `max_w` from the grid for as long
|
||||||
|
as the lie lasts, and no cap in this add-on stops it.** A meter frozen at a large
|
||||||
|
export reads as "the house is exporting hard", the loop charges the battery to
|
||||||
|
correct it, and the correction is a grid import. At the schema maximum
|
||||||
|
`max_w: 5000` — which is what this house runs, and is also the device rating —
|
||||||
|
that is a 5 kW unattended import.
|
||||||
|
|
||||||
|
The bill is not the energy. In the Belgian capaciteitstarief the month is billed
|
||||||
|
on its highest quarter-hour average, so **fifteen minutes of this sets the whole
|
||||||
|
month's peak.** A measured case: one 4998 W quarter moved a monthly peak from
|
||||||
|
4832 W to 5013 W, and every day of that month was billed at the higher figure.
|
||||||
|
|
||||||
|
What exists today, and what does not:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `max_w` bounds the magnitude | ✅ — but the exposure IS the magnitude |
|
||||||
|
| Maintenance charging capped under `peak_cap_w` | ✅ |
|
||||||
|
| **Loop capped under `peak_cap_w`** | ❌ — not built. This is SAFETY-07 |
|
||||||
|
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
|
||||||
|
| Frozen meter *stopped* | ❌ — the alarm does not command anything |
|
||||||
|
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
|
||||||
|
|
||||||
|
So the frozen-meter alarm below **narrows the window; it does not close it.** On
|
||||||
|
a capacity-tariff site where an unattended runaway is unacceptable, the mitigation
|
||||||
|
today is operational: set `max_w` no higher than the loop actually needs (the
|
||||||
|
tuning was measured at 2000 W and a 1.78 kW load step is tracked fine there), fit
|
||||||
|
the e-stop, and watch for the alarm.
|
||||||
|
|
||||||
|
### The winding-loop alarm (the output clamp)
|
||||||
|
|
||||||
|
Every setpoint this add-on writes is clamped to `max_w` before it leaves the
|
||||||
|
process, and **when that clamp actually engages, it alarms.** The clamp on its
|
||||||
|
own is not the feature - this project already clipped, and the clipping is what
|
||||||
|
hid the defect the guard exists for: with the inverter switched off entirely an
|
||||||
|
unclamped integrator climbed ~130 W per 4 s past 10 kW and, pinned at the 5 kW
|
||||||
|
rail, reported **14 768 W**. Clipped silently, that is a flat, legal-looking
|
||||||
|
5000 W hold on every dashboard, and nothing anywhere says the loop asked for
|
||||||
|
three times it.
|
||||||
|
|
||||||
|
**What "the rating" is.** The bound is `max_w`, and only `max_w`. It is already
|
||||||
|
validated against the setpoint device's own range at startup - the add-on
|
||||||
|
*refuses to start* if `max_w` exceeds what the device accepts (and again once
|
||||||
|
ESPHome is up and the real rating is known), so by the time the loop runs the two
|
||||||
|
numbers agree. The device range is applied a second time inside the write as
|
||||||
|
defence in depth; after a successful validation it cannot bite, and if it ever
|
||||||
|
does, that engagement alarms too and names the bound.
|
||||||
|
|
||||||
|
**Clamping is not saturation, and the two never share a channel.**
|
||||||
|
|
||||||
|
| | what it means | what happens |
|
||||||
|
|---|---|---|
|
||||||
|
| **clamp** | the *computed* value exceeded the rating | a software bug signal - something upstream is winding. **Alarm.** |
|
||||||
|
| **saturation** | the inverter is not following a perfectly *legal* command | a hardware condition - freeze the integrator. **No alarm.** |
|
||||||
|
|
||||||
|
A loop can be legally saturated all day: a cold pack, a curtailed inverter, an
|
||||||
|
export limit. Routing the clamp through a saturation-shaped alarm puts the bug
|
||||||
|
signal inside the noise, which is the same silence as clipping quietly. They
|
||||||
|
share no reason code, no notification id, no status row and no counter.
|
||||||
|
|
||||||
|
**It is an alarm, not a trip.** The clamped value is written and the loop keeps
|
||||||
|
running - this is not the sign-disagreement failsafe, which commands 0 W. You
|
||||||
|
get a persistent notification (`goodwe_output_clamp`, replaced rather than
|
||||||
|
stacked), an `ERROR` in the log with the reason code `OUTPUT_CLAMP`, a row in
|
||||||
|
the event list, and a red status banner while it is engaged. Once it clears, a
|
||||||
|
warning row keeps the engagement count so the evidence does not vanish with the
|
||||||
|
condition. Consecutive engagements share one event row carrying the exact count,
|
||||||
|
because the event list keeps only 40 rows and a row per cycle would flush every
|
||||||
|
other guard's entries out of it.
|
||||||
|
|
||||||
|
**There is nothing to configure**, deliberately. The bound is `max_w`, which you
|
||||||
|
already set; giving the guard a limit of its own would be a second number that
|
||||||
|
can disagree with the first, which is exactly the failure the startup validation
|
||||||
|
exists to prevent. The alarm's cadence - first engagement, then re-asserted every
|
||||||
|
30 s while it persists, cleared after 3 clean cycles - is fixed for the same
|
||||||
|
reason `RAIL_FRAC` is: it describes the notification bus, not the plant.
|
||||||
|
|
||||||
|
WARNING: **on a default install the engagement you will see is the integrator's,
|
||||||
|
not the output's.** The control law bounds the integrator to `max_w` and *then*
|
||||||
|
derives the command from it, so the command itself can never come out over the
|
||||||
|
rating unless `integrator_max_w` has been deliberately raised above `max_w`.
|
||||||
|
Both are watched. A guard that watched only the emitted value would be silent on
|
||||||
|
every standard configuration.
|
||||||
|
|
||||||
|
### The frozen-meter alarm
|
||||||
|
|
||||||
|
A meter that has died is easy — it stops answering, the sample age climbs past
|
||||||
|
`meter_max_age_s`, the reading becomes missing, and the add-on and the ESP32 both
|
||||||
|
command 0 W. A meter that keeps answering `200 OK` with a document that never
|
||||||
|
changes is the hard one: **every freshness mechanism in the stack reads it as
|
||||||
|
healthy**, because it really is arriving on every poll. The loop then integrates
|
||||||
|
a constant error and winds to the rail, in 75–360 s at `max_w: 5000`.
|
||||||
|
|
||||||
|
The add-on alarms when **both** of these are true at once:
|
||||||
|
|
||||||
|
- the meter's own document (power figure *and* both kWh registers) has not
|
||||||
|
changed for `meter_freeze_s` — 300 s by default; **and**
|
||||||
|
- the command standing on the wire is at the rail, within 5 % of `max_w`.
|
||||||
|
|
||||||
|
Neither half is usable alone. A bare "unchanged for N seconds" rule false-trips
|
||||||
|
at the exact operating point this controller aims for — at a converged −10 W a
|
||||||
|
1 Wh register legitimately takes about six minutes to move — and a bare "at the
|
||||||
|
rail" rule fires on any genuinely large load. Together they are safe in both
|
||||||
|
directions, because a real railed condition means kilowatts still crossing the
|
||||||
|
meter, and a kilowatt advances a 1 Wh register every 3.6 s.
|
||||||
|
|
||||||
|
**It is an alarm and nothing else.** It does not command 0 W, does not latch, and
|
||||||
|
does not claim the actuator. A false trip here parks the battery; a false
|
||||||
|
notification costs an alert. Turning it into a trip is a separate decision that
|
||||||
|
needs data from a real install — and an answer to "what should the loop do
|
||||||
|
instead", given that 0 W is itself an import of whatever the house is drawing.
|
||||||
|
|
||||||
|
When it fires you get a Home Assistant persistent notification
|
||||||
|
(`goodwe_meter_frozen`, replaced rather than stacked on a repeat), an `ERROR` in
|
||||||
|
the add-on log, an entry in the UI's event list, and a red banner on the status
|
||||||
|
page. **The loop keeps running.** If the meter really is stuck, stop control from
|
||||||
|
the add-on UI.
|
||||||
|
|
||||||
|
⚠️ **Only `meter_source: homewizard_local` is covered.** It is the only transport
|
||||||
|
that reads the meter's own document; on `ha_dsmr`, `mqtt_p1`, `ha_signed` and
|
||||||
|
`off` there is no signal to threshold and the status page says **NOT COVERED**
|
||||||
|
rather than pretending the guard is armed.
|
||||||
|
|
||||||
|
| option | default | meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `meter_freeze_s` | 300 | Seconds of an unchanged measurement, *while the command is at the rail*, before the alarm |
|
||||||
|
|
||||||
|
Why 300 s: over 2399 s of this house at the converged operating point (mean grid
|
||||||
|
−14.2 W, 1194 polls, 735 of them repeating the previous reading) the largest
|
||||||
|
`unchanged_s` any sample sees is **8.0 s**, and the longest interval serving no
|
||||||
|
distinct reading at all is **15.4 s**. There is also a structural reason not to
|
||||||
|
lower it much: at 300 s the slowest a *healthy* register can tick and still look
|
||||||
|
unchanged is 12 W, which is inside the 15 W deadband — so the command there is
|
||||||
|
parked at 0 W and the two halves of the rule cannot both be true. At the schema
|
||||||
|
floor of 60 s that figure is 60 W and only the margin is left.
|
||||||
|
|
||||||
### Site
|
### Site
|
||||||
|
|
||||||
| option | meaning |
|
| option | meaning |
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""SAFETY-03 - the output clamp, and the alarm that makes it audible.
|
||||||
|
|
||||||
|
WHAT IT CATCHES, in one sentence: a control law that computes a setpoint the
|
||||||
|
actuator cannot deliver, which every layer below it will happily clip to
|
||||||
|
something legal-looking while the thing that produced it goes on winding.
|
||||||
|
|
||||||
|
THE CLAMP IS NOT THE DELIVERABLE. specs.md 10.3 is explicit that this project
|
||||||
|
already clipped, and that the clipping is what hid the defect:
|
||||||
|
|
||||||
|
with the inverter switched off entirely, an unclamped integrator climbed
|
||||||
|
~130 W per 4 s to over 10 kW and, pinned at the 5 kW rail, reported
|
||||||
|
14 768 W
|
||||||
|
|
||||||
|
A clamp alone turns that into a silent, perfectly flat 5000 W hold. Every
|
||||||
|
dashboard reads "commanded 5000 W", which is a legal number, and nothing
|
||||||
|
anywhere says the loop asked for three times it. 11.2's watchdog row says
|
||||||
|
"Clamp AND ALARM - the loop is winding"; the second verb is this module.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
WHAT "THE RATING" IS HERE, AND WHY IT IS ONE NUMBER AND NOT A MIN()
|
||||||
|
|
||||||
|
The card names two bounds and warns against choosing between them:
|
||||||
|
|
||||||
|
- `DriverCapabilities.setpoint_limit_w` (3.2), the inverter's own magnitude
|
||||||
|
rating. In this add-on that arrives as the setpoint entity's `min`/`max`
|
||||||
|
from `hass.limits()` - `Controller.dev_min` / `dev_max`.
|
||||||
|
- `optimizer.loop.clamp_w` (14), the configured loop clamp. In this add-on
|
||||||
|
that is the `max_w` option, which `Tuning.max_w` and the arbiter's
|
||||||
|
`supervised` limit both already use.
|
||||||
|
|
||||||
|
14 says these two must AGREE, validated at startup with a refusal on
|
||||||
|
mismatch - and that gate already ships: `validate_startup_config()` refuses
|
||||||
|
`max_w > dev_max` and `-max_w < dev_min` (validate.py, problem 1), and
|
||||||
|
run_control() re-runs it once ESPHome is up and the real rating is known.
|
||||||
|
|
||||||
|
So this guard clamps to `max_w`, THE VALIDATED VALUE, and does not `min()` it
|
||||||
|
with the device rating at runtime. That is the card's instruction and it is also
|
||||||
|
the only reading that keeps `max_w` meaningful: a silent runtime `min()` is
|
||||||
|
precisely the "helper bounded to +-500 W under a loop clamped to 1500 W" failure
|
||||||
|
the 14 warning box is about.
|
||||||
|
|
||||||
|
WARNING - THE DEVICE BOUND IS STILL APPLIED, SECOND, AND IT IS NOT REDUNDANT.
|
||||||
|
After a successful validation `+-max_w` lies inside `[dev_min, dev_max]`, so the
|
||||||
|
second stage is arithmetically a no-op and can never engage. If it DOES engage,
|
||||||
|
the validation did not run or no longer holds - `hass.limits()` returns
|
||||||
|
(None, None) whenever ESPHome is not up yet, and the range checks are skipped
|
||||||
|
rather than guessed at - and that is a louder fault than the first stage, not a
|
||||||
|
quieter one. It therefore alarms too, through the same channel, with the bound
|
||||||
|
named. This is NOT SAFETY-20; nothing here changes what `freeze.py` measures its
|
||||||
|
rail against.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
WHERE THE WIND ACTUALLY SHOWS UP - READ THIS BEFORE "SIMPLIFYING" THE CALLER
|
||||||
|
|
||||||
|
`control.compute()` bounds the integrator to `integrator_max_w or max_w` and
|
||||||
|
THEN derives the command from it, so on a default install (`integrator_max_w`
|
||||||
|
unset, i.e. following `max_w`) the command can never exceed `max_w` and the
|
||||||
|
actuator-side clamp below IS UNREACHABLE. control.py says so in as many words:
|
||||||
|
|
||||||
|
SAFETY-03 ... must watch for THIS, not for "clamped" below.
|
||||||
|
|
||||||
|
An alarm wired only to this module's `ClampResult.engaged` would therefore be
|
||||||
|
silent on the exact configuration the whole ticket is about. The winding is
|
||||||
|
visible one layer up, at the integrator bound, and that is why `Decision` now
|
||||||
|
carries `i_clamped` / `clamped` BOOLEANS and the caller ORs them in here.
|
||||||
|
|
||||||
|
AND WHY BOOLEANS RATHER THAN `Decision.reason`. `reason` is a single string that
|
||||||
|
later stages overwrite: a cycle that clamps the integrator and is then slew
|
||||||
|
limited reports "slew-limited", and one that clamps while saturated reports
|
||||||
|
"saturated-freeze". Both are exactly the winding-into-a-rail case, and on the
|
||||||
|
string the clamp signal is destroyed before anything can alarm on it. The two
|
||||||
|
flags are set where the clamp happens and are never overwritten. A check in
|
||||||
|
test_clamp.py drives that precise combination.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
DISTINCT FROM SATURATION - THIS IS ACCEPTANCE CRITERION 2, NOT A NICETY
|
||||||
|
|
||||||
|
clamp the COMPUTED value exceeded the rating. A software bug signal:
|
||||||
|
something upstream is winding. Alarm, every engagement.
|
||||||
|
saturation |command - inverter AC power| > `saturation_w` for
|
||||||
|
`saturation_cycles`. The HARDWARE cannot follow a perfectly legal
|
||||||
|
command. Action is "freeze the integrator" (11.2), not an alarm.
|
||||||
|
|
||||||
|
A loop can be legally saturated all day - a cold pack, a curtailed inverter, an
|
||||||
|
export limit - so a saturation-shaped alarm is one an operator learns to ignore.
|
||||||
|
Routing the clamp through it puts the bug signal inside the noise, which is the
|
||||||
|
same silence 10.3 is complaining about. They share no reason code, no
|
||||||
|
notification id, no status row and no counter.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
ANTI-FLOOD, WITHOUT GOING SILENT
|
||||||
|
|
||||||
|
A genuinely winding loop clamps on EVERY cycle for as long as it winds. One HA
|
||||||
|
notification per cycle is how an operator learns to dismiss notifications
|
||||||
|
unread, and this add-on already made that argument once (see the fixed
|
||||||
|
`notification_id` note in main.check_meter_frozen).
|
||||||
|
|
||||||
|
So the two surfaces are split, deliberately:
|
||||||
|
|
||||||
|
- EVERY engagement is recorded, never deduplicated, and that record is what
|
||||||
|
says how hard the loop is pushing. 15's action log is this add-on's
|
||||||
|
`Controller.events`, and it gets a row from `write_setpoint()` on every
|
||||||
|
engaged WRITE; `engagements` here counts every engaged CYCLE. The two layers
|
||||||
|
are counted separately on purpose - the heartbeat re-writes an
|
||||||
|
already-clamped value between cycles, so a per-write count would inflate and
|
||||||
|
a per-cycle log would miss an out-of-cycle write.
|
||||||
|
- THE NOTIFICATION fires on the rising edge and then RE-ASSERTS every
|
||||||
|
`REASSERT_S`, so a loop pinned on the rail for an hour does not go quiet.
|
||||||
|
- It DE-ASSERTS only after `CLEAR_CYCLES` consecutive clean cycles, which is
|
||||||
|
the 10.2 hysteresis: a command sitting exactly on the rail dithers in and
|
||||||
|
out of engagement by one quantisation step, and without this the alarm
|
||||||
|
chatters.
|
||||||
|
|
||||||
|
`REASSERT_S` and `CLEAR_CYCLES` are module constants, not options, for the same
|
||||||
|
reason `freeze.RAIL_FRAC` is: they describe the event bus, not the plant, and
|
||||||
|
every knob 10.3 asked to keep exposed is a LOOP TUNING knob and is untouched by
|
||||||
|
this module. No new config key is added anywhere - CFG-03 already has three
|
||||||
|
places a default has to be kept in step and this ticket does not earn a fourth.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
NOT A TRIP
|
||||||
|
|
||||||
|
Clamping does not stop the loop and does not command 0 W. The clamped value is
|
||||||
|
written and control continues, which is what 11.2 asks for ("Clamp and alarm")
|
||||||
|
and what distinguishes this from SAFETY-01/02, whose action is 0 W. It follows
|
||||||
|
that this must NOT drive the failsafe status - `publish()` keeps reporting
|
||||||
|
"running", because the controller is.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# The reason code, distinct from any saturation-freeze code by construction:
|
||||||
|
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
|
||||||
|
REASON_CODE = "OUTPUT_CLAMP"
|
||||||
|
|
||||||
|
# Re-assert the notification this often while the clamp stays engaged. 30 s is
|
||||||
|
# the planning cadence (2.3), i.e. slow enough not to flood and fast enough
|
||||||
|
# that a pinned loop is never quiet for a whole billing quarter.
|
||||||
|
REASSERT_S = 30.0
|
||||||
|
|
||||||
|
# Consecutive clean cycles before the alarm de-asserts (10.2 hysteresis).
|
||||||
|
CLEAR_CYCLES = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ClampResult:
|
||||||
|
"""What the guard did, with the RAW value preserved.
|
||||||
|
|
||||||
|
AC 2 turns on "the raw computed value differed from the clamped one", so the
|
||||||
|
raw value has to survive the call - a guard that returns only the applied
|
||||||
|
number cannot be asked the question the acceptance criterion asks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
requested_w: float
|
||||||
|
applied_w: float
|
||||||
|
limit_w: float
|
||||||
|
engaged: bool
|
||||||
|
bound: str = "" # "" | "max_w" | "device" - which stage bit
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
return (f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W "
|
||||||
|
f"was clamped to {self.applied_w:+.0f} W by the {self.bound} "
|
||||||
|
f"bound (rating {self.limit_w:.0f} W) - something upstream is "
|
||||||
|
f"winding")
|
||||||
|
|
||||||
|
|
||||||
|
def is_commandable(value) -> bool:
|
||||||
|
"""False for None, NaN and the infinities.
|
||||||
|
|
||||||
|
A non-finite setpoint is LOSS OF INPUT, not an out-of-range value, and
|
||||||
|
11.1 rule 1 says loss of input means command zero and keep commanding. It
|
||||||
|
must not reach the clamp arithmetic: `min(limit, nan)` returns nan on
|
||||||
|
CPython, so a nan would sail straight through a naive clamp and then report
|
||||||
|
`engaged` True on the `applied != requested` test - because nan != nan - i.e.
|
||||||
|
a transport bug filed in the winding-loop counter, with a nan on the wire.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return math.isfinite(float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_to_rating(requested_w, limit_w, dev_min=None, dev_max=None) -> ClampResult:
|
||||||
|
"""Bound a computed setpoint to the validated rating. The ONLY clamp.
|
||||||
|
|
||||||
|
`limit_w` the validated magnitude bound - `max_w`. Symmetric, because
|
||||||
|
3.2 calls `setpoint_limit_w` a MAGNITUDE clamp: it binds import
|
||||||
|
(+) and export (-) identically.
|
||||||
|
`dev_min` / `dev_max` the device's own asymmetric range, applied second and
|
||||||
|
expected never to bite. See the module note.
|
||||||
|
|
||||||
|
Public, and the predicate lives here rather than in the test, for the reason
|
||||||
|
`freeze.looks_frozen()` gives: a safety expression transcribed into its own
|
||||||
|
check is one copy plus a future disagreement about what the check means.
|
||||||
|
|
||||||
|
EXACTLY +-`limit_w` IS NOT AN ENGAGEMENT. The bound is inclusive; a loop
|
||||||
|
resting on the rail is doing what it was told, and alarming there would fire
|
||||||
|
on every healthy full-power hour.
|
||||||
|
"""
|
||||||
|
requested = float(requested_w)
|
||||||
|
limit = abs(float(limit_w))
|
||||||
|
|
||||||
|
# ⚠️ BOTH STAGES ALWAYS RUN. THIS USED TO `return` FROM THE FIRST ONE, AND
|
||||||
|
# THAT WAS A REGRESSION AGAINST THE CODE IT REPLACED. `write_setpoint()`
|
||||||
|
# previously clamped to the device range unconditionally, under the comment
|
||||||
|
# "never send a value the device will reject outright"; short-circuiting here
|
||||||
|
# meant the device bound was applied only when the rating bound had NOT
|
||||||
|
# fired, i.e. THE BIGGER THE OVERSHOOT THE LESS THE GUARD DID. Measured on
|
||||||
|
# the shipped function at max_w 5000 against a device of +-4000:
|
||||||
|
#
|
||||||
|
# +9999 -> +5000 bound='max_w' <- outside the device range
|
||||||
|
# +4500 -> +4000 bound='device'
|
||||||
|
#
|
||||||
|
# It also undercut this module's own argument for clamping to `max_w` rather
|
||||||
|
# than a runtime `min()`, which rests entirely on this stage catching the
|
||||||
|
# case where the startup validation did not run.
|
||||||
|
applied = max(-limit, min(limit, requested))
|
||||||
|
bound = "max_w" if applied != requested else ""
|
||||||
|
|
||||||
|
# Second stage. Arithmetically dead after a successful startup validation -
|
||||||
|
# and therefore worth alarming on precisely BECAUSE it fired.
|
||||||
|
if dev_min is not None and dev_max is not None:
|
||||||
|
bounded = max(float(dev_min), min(float(dev_max), applied))
|
||||||
|
if bounded != applied:
|
||||||
|
bound = "device" if not bound else "max_w+device"
|
||||||
|
applied = bounded
|
||||||
|
|
||||||
|
return ClampResult(requested, applied, limit, applied != requested, bound)
|
||||||
|
|
||||||
|
|
||||||
|
class ClampWatchdog:
|
||||||
|
"""One instance per controller. Counts every engagement, alarms sparsely.
|
||||||
|
|
||||||
|
`note()` is called once per write with whether the clamp bit; the caller ORs
|
||||||
|
in the loop's own `i_clamped` / `clamped` flags, which is where the winding
|
||||||
|
is actually visible on a default install (see the module note).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.active = False
|
||||||
|
self.engagements = 0 # every engagement, never deduplicated
|
||||||
|
self.episodes = 0 # rising edges
|
||||||
|
self.since: datetime | None = None
|
||||||
|
self.last_alarm: datetime | None = None
|
||||||
|
self.reason = ""
|
||||||
|
self.clean_cycles = 0
|
||||||
|
|
||||||
|
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
|
||||||
|
"""Advance one cycle. True when the caller should raise the alarm.
|
||||||
|
|
||||||
|
`now` is the caller's clock, the same discipline as
|
||||||
|
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`.
|
||||||
|
"""
|
||||||
|
if engaged:
|
||||||
|
self.engagements += 1
|
||||||
|
self.clean_cycles = 0
|
||||||
|
self.reason = reason
|
||||||
|
if not self.active:
|
||||||
|
self.active = True
|
||||||
|
self.since = now
|
||||||
|
self.episodes += 1
|
||||||
|
self.last_alarm = now
|
||||||
|
return True
|
||||||
|
# Still engaged: re-assert on the slow cadence, not every cycle.
|
||||||
|
if (self.last_alarm is None
|
||||||
|
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||||
|
self.last_alarm = now
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.active:
|
||||||
|
return False
|
||||||
|
self.clean_cycles += 1
|
||||||
|
if self.clean_cycles >= CLEAR_CYCLES:
|
||||||
|
self.active = False
|
||||||
|
self.since = None
|
||||||
|
self.last_alarm = None
|
||||||
|
self.reason = ""
|
||||||
|
self.clean_cycles = 0
|
||||||
|
return False
|
||||||
@@ -64,6 +64,23 @@ class Decision:
|
|||||||
# and quantisation. Carry it back in as `i_w` next cycle; that is what keeps
|
# and quantisation. Carry it back in as `i_w` next cycle; that is what keeps
|
||||||
# it a separate quantity from the command.
|
# it a separate quantity from the command.
|
||||||
i_w: float = 0.0
|
i_w: float = 0.0
|
||||||
|
# SAFETY-03. Did a clamp actually engage this cycle - i.e. did the RAW
|
||||||
|
# computed value differ from the bounded one?
|
||||||
|
#
|
||||||
|
# ⚠️ THESE EXIST BECAUSE `reason` CANNOT ANSWER THAT QUESTION. `reason` is
|
||||||
|
# one string and every later stage overwrites it: a cycle that clamps the
|
||||||
|
# integrator and is then slew limited reports "slew-limited", and one that
|
||||||
|
# clamps while saturated reports "saturated-freeze". Both ARE the winding
|
||||||
|
# case, and on the string the clamp signal is gone before anything can alarm
|
||||||
|
# on it. These two are written where the clamp happens and are never
|
||||||
|
# overwritten, so they mean exactly what they say and nothing else.
|
||||||
|
#
|
||||||
|
# `i_clamped` is the one that fires on a default install: the integrator
|
||||||
|
# bound is reached first, so `clamped` is unreachable unless
|
||||||
|
# `integrator_max_w` is deliberately set above `max_w`. Alarm on BOTH - see
|
||||||
|
# the comment at the bound below, and clamp.py.
|
||||||
|
i_clamped: bool = False
|
||||||
|
clamped: bool = False
|
||||||
|
|
||||||
|
|
||||||
def compute(
|
def compute(
|
||||||
@@ -188,7 +205,8 @@ def compute(
|
|||||||
# bound is not. It is what makes the worst-case unwind time finite and
|
# bound is not. It is what makes the worst-case unwind time finite and
|
||||||
# knowable instead of a function of how long the error happened to stand.
|
# knowable instead of a function of how long the error happened to stand.
|
||||||
bounded = max(-limit, min(limit, i_w))
|
bounded = max(-limit, min(limit, i_w))
|
||||||
if bounded != i_w:
|
i_clamped = bounded != i_w
|
||||||
|
if i_clamped:
|
||||||
# ⚠️ SAFETY-03 (alarm whenever the loop winds into a rail) must watch
|
# ⚠️ SAFETY-03 (alarm whenever the loop winds into a rail) must watch
|
||||||
# for THIS, not for "clamped" below. At the default limit == max_w the
|
# for THIS, not for "clamped" below. At the default limit == max_w the
|
||||||
# integrator bound is reached first and the command derived from it can
|
# integrator bound is reached first and the command derived from it can
|
||||||
@@ -209,7 +227,8 @@ def compute(
|
|||||||
# --- ORDER MATTERS: clamp -> slew -> freeze ----------------------------
|
# --- ORDER MATTERS: clamp -> slew -> freeze ----------------------------
|
||||||
# An early draft applied a floor after the clamp and let demand escape it.
|
# An early draft applied a floor after the clamp and let demand escape it.
|
||||||
target = max(-tuning.max_w, min(tuning.max_w, want))
|
target = max(-tuning.max_w, min(tuning.max_w, want))
|
||||||
if target != want:
|
clamped = target != want
|
||||||
|
if clamped:
|
||||||
reason = "clamped"
|
reason = "clamped"
|
||||||
|
|
||||||
slewed = max(prev_w - tuning.slew_w, min(prev_w + tuning.slew_w, target))
|
slewed = max(prev_w - tuning.slew_w, min(prev_w + tuning.slew_w, target))
|
||||||
@@ -235,7 +254,8 @@ def compute(
|
|||||||
step = max(1, int(tuning.step_w))
|
step = max(1, int(tuning.step_w))
|
||||||
target = round(target / step) * step
|
target = round(target / step) * step
|
||||||
|
|
||||||
return Decision(float(target), sat_count, frozen, reason, float(i_w))
|
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||||
|
i_clamped, clamped)
|
||||||
|
|
||||||
|
|
||||||
def maintenance_charge_floor(
|
def maintenance_charge_floor(
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""SAFETY-18 - the frozen-but-fresh meter detector.
|
||||||
|
|
||||||
|
WHAT IT CATCHES, in one sentence: a meter that keeps answering 200 OK with a
|
||||||
|
document that never changes, which every freshness mechanism in this stack reads
|
||||||
|
as perfectly healthy while the loop winds to the rail and stays there.
|
||||||
|
|
||||||
|
THE FAILURE NEEDS NO FAULT ANYWHERE ELSE. A frozen-but-talking meter is
|
||||||
|
*arriving* on every poll, so:
|
||||||
|
|
||||||
|
- `meter_max_age_s` is structurally blind. Every response resets the arrival
|
||||||
|
stamp (p1.py, `HomeWizardLocalSource`), so the age never climbs. Age catches
|
||||||
|
a meter that stops TALKING; this one stops CHANGING.
|
||||||
|
- firmware layer 1 is fed. Its watchdog keys on setpoint staleness, which the
|
||||||
|
heartbeat satisfies, and on `sensor.goodwe_p1_sample_age`, which a talking
|
||||||
|
meter satisfies.
|
||||||
|
- the arbiter contributes only the device rating and +-`max_w` - it bounds the
|
||||||
|
magnitude, which is the number this fault drives us to, not away from.
|
||||||
|
- there is no SoC guard on the grid-following loop at all. `soc_floor` belongs
|
||||||
|
to the maintenance cycle; the BMS is the sole energy backstop.
|
||||||
|
|
||||||
|
The loop reads a constant error and integrates it. Time from freeze to the rail,
|
||||||
|
replaying the real `control.compute()` at 15 s cycles:
|
||||||
|
|
||||||
|
meter frozen at to rail, max_w 2000 to rail, max_w 5000
|
||||||
|
350 W 150 s 360 s
|
||||||
|
1000 W 60 s 135 s
|
||||||
|
>= 3000 W 30 s 75 s
|
||||||
|
|
||||||
|
⚠️ THE LIVE INSTALL RUNS `max_w: 5000`, which is both the schema maximum and the
|
||||||
|
device rating, so the right-hand column is the one that describes this house.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
THIS HAS A EURO FIGURE, AND IT IS THE REASON THE TICKET IS P1
|
||||||
|
|
||||||
|
`maintenance_charge_floor()` caps MAINTENANCE charging against `peak_cap_w`. The
|
||||||
|
grid-following loop is not capped by anything but `max_w`. A meter frozen
|
||||||
|
reading a large export drives a 5 kW unattended grid IMPORT, and >= 15 minutes
|
||||||
|
of that sets the month's billed capaciteitstarief peak: specs.md:583 records a
|
||||||
|
measured case where a single 4998 W quarter moved a monthly peak from 4832 W to
|
||||||
|
5013 W and billed the whole month at it.
|
||||||
|
|
||||||
|
⚠️ THIS CUT DOES NOT CAP THAT. It notifies. See DOCS.md, "Capacity-tariff
|
||||||
|
exposure of the grid-following loop", and SAFETY-07.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
WHY IT IS A CONJUNCTION AND NOT AN `unchanged_s` THRESHOLD
|
||||||
|
|
||||||
|
`unchanged_s` (p1.py) already measures exactly the right thing: seconds since
|
||||||
|
the meter served a DIFFERENT measurement, fingerprinted over the power figure
|
||||||
|
and both energy registers. It is computed, it is on the status page, and TEL-05
|
||||||
|
deliberately refused to threshold it - correctly:
|
||||||
|
|
||||||
|
this controller regulates grid power toward ~0 W, and at a converged -10 W
|
||||||
|
the export register takes about six minutes to advance by its 1 Wh
|
||||||
|
resolution while the power figure legitimately repeats.
|
||||||
|
|
||||||
|
So a bare `unchanged_s > N` false-trips at the exact operating point we aim for,
|
||||||
|
and `p1.py` says as much in a note that ends "whoever wires a freeze detector
|
||||||
|
must handle the low-power case first". This is that handling, and it is not a
|
||||||
|
smaller number:
|
||||||
|
|
||||||
|
unchanged_s >= hold_s AND |commanded_w| is at the rail
|
||||||
|
|
||||||
|
The second term is what TEL-05's case cannot satisfy. At the converged point the
|
||||||
|
command is nowhere near +-`max_w`; on the 40 minutes of live converged behaviour
|
||||||
|
in `sim/scenarios/p1-age-2026-08-26.jsonl.gz` the meter never once leaves
|
||||||
|
+-500 W, so the loop never even approaches a 5000 W rail. And the reverse case -
|
||||||
|
a command genuinely pinned at the rail by a real 8 kW load - cannot satisfy the
|
||||||
|
FIRST term, because a real railed condition means kilowatts still crossing the
|
||||||
|
meter, and a kilowatt advances a 1 Wh register every 3.6 s.
|
||||||
|
|
||||||
|
The two terms fail for independent reasons. That is the whole design.
|
||||||
|
|
||||||
|
⚠️ AND THE SECOND HALF OF THAT WAS MEASURED ON THE RIG, WITH A RESULT WORTH
|
||||||
|
READING CAREFULLY. Driving the real transport against the rig's own hwsim under
|
||||||
|
+8000 W of house load - a genuinely railed command, meter perfectly healthy - the
|
||||||
|
POWER FIGURE DID NOT MOVE AT ALL: `active_power_w` sat at exactly 10130.0 W for
|
||||||
|
26 consecutive polls. What kept `unchanged_s` pinned at 0.0 s was the kWh
|
||||||
|
registers advancing, and nothing else.
|
||||||
|
|
||||||
|
So the register terms in `measurement_fingerprint()` are not belt-and-braces
|
||||||
|
here, they are the entire negative case. A freeze detector fingerprinted on the
|
||||||
|
power figure alone would have raised a false alarm on that rig run - a healthy
|
||||||
|
meter, a real load, a correctly railed command - which is the exact failure mode
|
||||||
|
that gets a safety guard switched off. Anyone tempted to simplify the fingerprint
|
||||||
|
should read that run first.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
WHERE `hold_s` COMES FROM
|
||||||
|
|
||||||
|
Measured on `sim/scenarios/p1-age-2026-08-26.jsonl.gz` - 2399 s (40 min) of the
|
||||||
|
live house at the converged operating point, mean grid -14.2 W, 1194 polls:
|
||||||
|
|
||||||
|
polls that repeated the previous reading 735 of 1193 (61.6 %)
|
||||||
|
distinct-reading transitions 458
|
||||||
|
median gap between distinct readings 6.0 s
|
||||||
|
p95 gap 6.0 s
|
||||||
|
LARGEST `unchanged_s` any sample observes 8.0 s
|
||||||
|
longest interval serving no distinct reading 15.4 s
|
||||||
|
samples outside +-500 W 0
|
||||||
|
|
||||||
|
The default 300 s is 37x the largest `unchanged_s` this house has been recorded
|
||||||
|
producing while converged, and 19.5x the longest interval in which it served no
|
||||||
|
distinct reading at all. The two figures differ because the capture has an 11.4 s
|
||||||
|
hole in its OWN polling; the second is quoted because it is the pessimistic one.
|
||||||
|
300 s is not fitted to either margin - it is picked as "clearly past any
|
||||||
|
plausible register-tick interval, and clearly inside the 900 s quarter that bills
|
||||||
|
the capacity tariff". test_freeze.py re-measures both every run, so this
|
||||||
|
paragraph cannot quietly stop being true.
|
||||||
|
|
||||||
|
⚠️ BE PRECISE ABOUT WHAT THAT CAPTURE PROVES. It is POWER-ONLY - the shipped
|
||||||
|
fingerprint also covers both kWh registers, so a real `unchanged_s` on that data
|
||||||
|
can only be shorter, never longer, which makes it a conservative measurement of
|
||||||
|
the converged case. But it does NOT on its own refute a bare `unchanged_s`
|
||||||
|
threshold, and saying it did would be exactly the overstatement this project
|
||||||
|
keeps having to walk back: at 8 s worst, a bare 300 s threshold is silent on it
|
||||||
|
too. The case that refutes the bare threshold is the one below, which no capture
|
||||||
|
of THIS meter contains because this meter's power figure jitters.
|
||||||
|
|
||||||
|
The floor on the other side: at the rail, the smallest residual grid flow that
|
||||||
|
still ticks a 1 Wh register inside 300 s is 12 W. Any condition that genuinely
|
||||||
|
pins this loop at +-5000 W has three orders of magnitude more than that crossing
|
||||||
|
the meter.
|
||||||
|
|
||||||
|
⚠️ AND THOSE TWO NUMBERS DO NOT MERELY HAVE MARGIN - THEY CANNOT BOTH BE TRUE.
|
||||||
|
The worry the conjunction has to answer is "a HEALTHY meter whose register is
|
||||||
|
slower than `hold_s`". At 300 s that is |grid| < 12 W. The shipped `deadband_w`
|
||||||
|
is 15 W, so anywhere the register is that slow the control law is already inside
|
||||||
|
its deadband and the command is parked - the rail term is not merely unlikely to
|
||||||
|
be true there, it is 0 W. The two terms are mutually exclusive by construction
|
||||||
|
at the defaults, not just far apart, and the check re-derives that every run.
|
||||||
|
|
||||||
|
That relationship is the one to protect. `hold_s` may be lowered only while
|
||||||
|
3600/hold_s stays under `deadband_w`; at the schema floor of 60 s that is 60 W
|
||||||
|
against a 15 W deadband, and the mutual exclusion is gone. A 60 s `meter_freeze_s`
|
||||||
|
is a supported configuration that gives up this argument and keeps only the
|
||||||
|
margin - do not set one without a capture that says the margin is enough.
|
||||||
|
|
||||||
|
⚠️ WHAT `hold_s` DOES NOT BUY IS DETECTION SPEED. The alert lands at
|
||||||
|
max(hold_s, time-to-rail) after the freeze, so at the shipped default and
|
||||||
|
`max_w: 5000` that is 300-360 s. The rail is REACHED first, from 75 s. A
|
||||||
|
notification is not a brake; see the DOCS.md note.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
THE RAIL TERM, PRECISELY
|
||||||
|
|
||||||
|
abs(commanded_w) >= RAIL_FRAC * max_w
|
||||||
|
|
||||||
|
`commanded_w` is the EMITTED setpoint - what the arbiter resolved and
|
||||||
|
`write_setpoint()` put on the wire - not the loop's raw wish, for the same
|
||||||
|
reason SAFETY-02 uses the emitted value: the wire carries the emitted number.
|
||||||
|
|
||||||
|
RAIL_FRAC is 0.95 and is a constant rather than an option. The command is
|
||||||
|
quantised to `step_w` (max 100) and clamped to `max_w` (min 100), so at the rail
|
||||||
|
the emitted value IS `max_w` on every schema-legal combination; the 5 % is slack
|
||||||
|
for a device rating that clips `max_w` slightly in `write_setpoint()`, not a
|
||||||
|
tuning knob. Making it configurable would offer a way to widen the one term that
|
||||||
|
carries the whole no-false-positive argument.
|
||||||
|
|
||||||
|
⚠️ NOT GATED ON `following`, and that is deliberate. A maintenance charge
|
||||||
|
commands against the meter on purpose and can sit at the rail for hours - but it
|
||||||
|
does so while pushing kilowatts through the meter, so the FIRST term is what
|
||||||
|
excludes it, and it excludes it on physics rather than on bookkeeping. Gating on
|
||||||
|
`following` as well would additionally blind the detector to a maintenance phase
|
||||||
|
running against a meter that really has frozen, which is the same fault with a
|
||||||
|
worse actuator behind it.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
NOTIFY, DO NOT TRIP
|
||||||
|
|
||||||
|
First cut is an alarm and nothing else: an HA persistent notification, an ERROR
|
||||||
|
in the log, an event on the status page, and a failed check row. It does NOT
|
||||||
|
claim the actuator, does NOT file a P_SAFETY claim and does NOT latch.
|
||||||
|
|
||||||
|
That is a deliberate ordering of costs, not timidity. A false trip here parks
|
||||||
|
the battery - on a house that is running this loop precisely to avoid buying
|
||||||
|
power at the wrong moment - and the operator's response to a parked battery is
|
||||||
|
to switch the guard off. A false notification costs one alert. The trip is a
|
||||||
|
separate decision that has to be earned with observed data from a real install,
|
||||||
|
and it needs an answer to a question this cut does not have one for: what should
|
||||||
|
the loop do instead, given that 0 W is itself a grid import of whatever the
|
||||||
|
house is drawing.
|
||||||
|
|
||||||
|
⚠️ TRANSPORT-CONDITIONAL. `unchanged_s` exists ONLY on `homewizard_local`,
|
||||||
|
because it is the only transport that sees the meter's own document. Under
|
||||||
|
`ha_dsmr`, `mqtt_p1`, `ha_signed` and `meter_source: off` this detector is
|
||||||
|
silent and the exposure above is uncovered. The caller passes None and gets
|
||||||
|
False; there is no half-measure available on those paths, and pretending
|
||||||
|
otherwise would be worse than the gap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# See "THE RAIL TERM, PRECISELY" above. A constant, on purpose.
|
||||||
|
RAIL_FRAC = 0.95
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FreezeWatchdogConfig:
|
||||||
|
"""Default, not a constant - config.yaml carries `meter_freeze_s`."""
|
||||||
|
|
||||||
|
hold_s: float = 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def rail_w(max_w) -> float:
|
||||||
|
"""The magnitude at or above which a command counts as railed."""
|
||||||
|
return abs(float(max_w)) * RAIL_FRAC
|
||||||
|
|
||||||
|
|
||||||
|
def looks_frozen(unchanged_s, commanded_w, max_w,
|
||||||
|
cfg: FreezeWatchdogConfig) -> bool:
|
||||||
|
"""The predicate, and the ONLY place the conjunction is written down.
|
||||||
|
|
||||||
|
Public for the same reason `watchdog.disagrees()` is: the checks that grade
|
||||||
|
it must assert on THE SHIPPED EXPRESSION. A safety predicate transcribed
|
||||||
|
into its own test is one copy plus a future disagreement about what the
|
||||||
|
check means, and the copy that drifts is always the one doing the grading.
|
||||||
|
|
||||||
|
`unchanged_s` seconds since the meter served a different measurement, or
|
||||||
|
None on a transport that cannot know (see the module note).
|
||||||
|
`commanded_w` the EMITTED setpoint standing on the wire.
|
||||||
|
`max_w` the configured output bound the rail is measured against.
|
||||||
|
"""
|
||||||
|
if unchanged_s is None or commanded_w is None or not max_w:
|
||||||
|
return False
|
||||||
|
return (float(unchanged_s) >= cfg.hold_s
|
||||||
|
and abs(float(commanded_w)) >= rail_w(max_w))
|
||||||
|
|
||||||
|
|
||||||
|
class FreezeWatchdog:
|
||||||
|
"""One instance per controller, evaluated once per control cycle.
|
||||||
|
|
||||||
|
Level-triggered state with an EDGE for the alarm: `evaluate()` returns True
|
||||||
|
on the cycle the conjunction first becomes true and False on every cycle
|
||||||
|
after, so the caller raises one notification per episode rather than one
|
||||||
|
every second. `active` is the level, for the status page.
|
||||||
|
|
||||||
|
No latch, deliberately - see "NOTIFY, DO NOT TRIP". A latch exists so a trip
|
||||||
|
cannot erase its own evidence by working; nothing here acts, so there is
|
||||||
|
nothing to erase. When the meter starts moving again the alert clears
|
||||||
|
itself, which is the honest report of the state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cfg: FreezeWatchdogConfig | None = None):
|
||||||
|
self.cfg = cfg or FreezeWatchdogConfig()
|
||||||
|
self.active = False
|
||||||
|
self.since: datetime | None = None
|
||||||
|
self.reason = ""
|
||||||
|
self.episodes = 0
|
||||||
|
|
||||||
|
def evaluate(self, unchanged_s, commanded_w, max_w, now: datetime) -> bool:
|
||||||
|
"""Advance one cycle. Returns True ONLY on the rising edge.
|
||||||
|
|
||||||
|
`now` is the caller's clock so this stays testable and replayable - the
|
||||||
|
same discipline as `SignWatchdog.evaluate()`.
|
||||||
|
"""
|
||||||
|
if not looks_frozen(unchanged_s, commanded_w, max_w, self.cfg):
|
||||||
|
self.active = False
|
||||||
|
self.since = None
|
||||||
|
self.reason = ""
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.reason = (
|
||||||
|
f"METER_FROZEN: the meter has answered {float(unchanged_s):.0f} s "
|
||||||
|
f"without its measurement changing while the command stands at "
|
||||||
|
f"{float(commanded_w):+.0f} W, at the {float(max_w):.0f} W rail "
|
||||||
|
f"(limits: unchanged over {self.cfg.hold_s:.0f} s, command over "
|
||||||
|
f"{rail_w(max_w):.0f} W)")
|
||||||
|
if self.active:
|
||||||
|
return False
|
||||||
|
self.active = True
|
||||||
|
self.since = now
|
||||||
|
self.episodes += 1
|
||||||
|
return True
|
||||||
@@ -36,10 +36,16 @@ from .arbiter import (
|
|||||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_SAFETY, Claim, resolve,
|
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_SAFETY, Claim, resolve,
|
||||||
)
|
)
|
||||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||||
|
from .clamp import (
|
||||||
|
REASON_CODE as CLAMP_REASON, ClampResult, ClampWatchdog, clamp_to_rating,
|
||||||
|
is_commandable,
|
||||||
|
)
|
||||||
|
from .freeze import FreezeWatchdog, FreezeWatchdogConfig, rail_w
|
||||||
from .hass import HomeAssistant
|
from .hass import HomeAssistant
|
||||||
from .maintenance import IDLE, MaintConfig, Maintenance
|
from .maintenance import IDLE, MaintConfig, Maintenance
|
||||||
from .mqtt import MqttPublisher
|
from .mqtt import MqttPublisher
|
||||||
from .p1 import P1Ingest, build_source, is_enabled
|
from .p1 import P1Ingest, build_source, is_enabled
|
||||||
|
from .tariff import PeakStore, PeakTracker
|
||||||
from .validate import ConfigRefused, refuse, validate_startup_config
|
from .validate import ConfigRefused, refuse, validate_startup_config
|
||||||
from .watchdog import SignWatchdog, SignWatchdogConfig, loop_is_following
|
from .watchdog import SignWatchdog, SignWatchdogConfig, loop_is_following
|
||||||
from . import web
|
from . import web
|
||||||
@@ -58,7 +64,8 @@ def load_options() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
class Controller:
|
class Controller:
|
||||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop):
|
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||||
|
peak_db_path: str = "/data/tariff.db"):
|
||||||
self.o = opts
|
self.o = opts
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.store = store
|
self.store = store
|
||||||
@@ -127,12 +134,60 @@ class Controller:
|
|||||||
"once you know why the command opposed the meter.",
|
"once you know why the command opposed the meter.",
|
||||||
self.sign_wd.reason or "(reason not recorded)")
|
self.sign_wd.reason or "(reason not recorded)")
|
||||||
|
|
||||||
|
# SAFETY-18. The frozen-but-fresh meter detector - see freeze.py for why
|
||||||
|
# it is a conjunction and not an `unchanged_s` threshold, and for the
|
||||||
|
# measurements behind the default.
|
||||||
|
#
|
||||||
|
# ⚠️ NOT restored from the store, unlike SAFETY-02's latch, and the
|
||||||
|
# asymmetry is the point: this one does not latch because it does not
|
||||||
|
# act. A restart re-derives its state from the meter within one
|
||||||
|
# `meter_freeze_s` window, and until then the honest report is "not
|
||||||
|
# alarming yet" rather than an alarm nobody can clear.
|
||||||
|
self.freeze_wd = FreezeWatchdog(
|
||||||
|
FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300))))
|
||||||
|
|
||||||
|
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
|
||||||
|
# itself is the cheap half.
|
||||||
|
#
|
||||||
|
# ⚠️ NO CONFIG OF ITS OWN, on purpose. The bound it clamps to is `max_w`,
|
||||||
|
# which is already an option, already validated against the device's
|
||||||
|
# rating at startup, and already used by Tuning and the arbiter. Giving
|
||||||
|
# this a second key would be a fourth place a limit has to be kept in
|
||||||
|
# step (CFG-03 already tracks three) and, worse, would recreate exactly
|
||||||
|
# the two-numbers-that-disagree failure the §14 warning box describes.
|
||||||
|
# The anti-flood cadence is a module constant for the same reason
|
||||||
|
# freeze.RAIL_FRAC is: it describes the event bus, not the plant.
|
||||||
|
self.clamp_wd = ClampWatchdog()
|
||||||
|
|
||||||
# P1 ingestion (TEL-01). `meter_source: off` keeps the original
|
# P1 ingestion (TEL-01). `meter_source: off` keeps the original
|
||||||
# single-entity meter_entity path, so an existing install is unchanged
|
# single-entity meter_entity path, so an existing install is unchanged
|
||||||
# until it opts in.
|
# until it opts in.
|
||||||
self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)),
|
self.p1 = P1Ingest(phases=int(opts.get("meter_phases", 1)),
|
||||||
max_age_s=float(opts.get("meter_max_age_s", 30)))
|
max_age_s=float(opts.get("meter_max_age_s", 30)))
|
||||||
self.p1_enabled = is_enabled(opts)
|
self.p1_enabled = is_enabled(opts)
|
||||||
|
|
||||||
|
# TARIFF-01. The capacity-tariff peak: measured from the meter, banked
|
||||||
|
# in SQLite, restored across a restart. It is an INSTRUMENT - it files no
|
||||||
|
# claim and clamps nothing. SAFETY-07 is what will read it.
|
||||||
|
#
|
||||||
|
# ⚠️ NO NEW OPTION. The soft limit is `peak_cap_w`, already shipped,
|
||||||
|
# already validated, already read by control.peak_at_risk(). CFG-03
|
||||||
|
# tracks three places option defaults live; a fourth, for a limit that
|
||||||
|
# already exists, is the mistake the clamp watchdog declined to make ~30
|
||||||
|
# lines up. config.yaml and validate.py are untouched by this ticket.
|
||||||
|
#
|
||||||
|
# ⚠️ AND NO SECOND PEAK ENTITY. `peak_forecast_entity` is the existing
|
||||||
|
# HA-side delegation and it stays the input to maintenance_charge_floor():
|
||||||
|
# repointing that consumer at this measurement CHANGES WHAT MOVES ENERGY
|
||||||
|
# during a maintenance charge, which belongs to SAFETY-07 with its own
|
||||||
|
# acceptance, not to a measurement ticket. What this adds is the MEASURED
|
||||||
|
# MONTH PEAK - a different quantity from a forecast entity, published
|
||||||
|
# nowhere as a duplicate of it.
|
||||||
|
self.peak_store = PeakStore(peak_db_path)
|
||||||
|
self.peak = PeakTracker(self.peak_store,
|
||||||
|
cap_w=float(opts.get("peak_cap_w", 3500)))
|
||||||
|
self.peak.restore(self.p1.averager)
|
||||||
|
_LOG.info("capacity-tariff peak store: %s", self.peak.restore_state)
|
||||||
# Set by amain() once the transport is built, so the status page can show
|
# Set by amain() once the transport is built, so the status page can show
|
||||||
# what only the transport knows (homewizard_local's unchanged_s). Stays
|
# what only the transport knows (homewizard_local's unchanged_s). Stays
|
||||||
# None when P1 is off, or under a transport that has no such counter.
|
# None when P1 is off, or under a transport that has no such counter.
|
||||||
@@ -158,6 +213,11 @@ class Controller:
|
|||||||
self.last_write_ok = None
|
self.last_write_ok = None
|
||||||
self.inputs_bad_since = None
|
self.inputs_bad_since = None
|
||||||
self.dev_min = self.dev_max = None
|
self.dev_min = self.dev_max = None
|
||||||
|
# SAFETY-03. What the last write's clamp did, and what the loop's own
|
||||||
|
# bounds did last cycle. `loop_clamped` is a (engaged, note) pair so the
|
||||||
|
# alarm can say WHICH bound bit without re-deriving it from a string.
|
||||||
|
self.clamp_last: ClampResult | None = None
|
||||||
|
self.loop_clamped: tuple[bool, str] = (False, "")
|
||||||
self.events: list[str] = []
|
self.events: list[str] = []
|
||||||
self.stopping = False
|
self.stopping = False
|
||||||
|
|
||||||
@@ -216,11 +276,37 @@ class Controller:
|
|||||||
entity = self.o.get("setpoint_entity", "")
|
entity = self.o.get("setpoint_entity", "")
|
||||||
if not entity:
|
if not entity:
|
||||||
return
|
return
|
||||||
# Never send a value the device will reject outright. A rejected write is
|
|
||||||
# silent from the controller's point of view, and the inverter then keeps
|
# --- SAFETY-03: THE CLAMP, AND IT IS ON THE ONLY PATH TO THE WIRE -----
|
||||||
# doing whatever it was already doing.
|
# Every write in this add-on comes through here - the cycle, the
|
||||||
if self.dev_min is not None and self.dev_max is not None:
|
# heartbeat, the operator's stop button and shutdown() - so no optimizer,
|
||||||
value = max(self.dev_min, min(self.dev_max, value))
|
# maintenance or manual path can route around it. That placement is the
|
||||||
|
# first acceptance criterion, and it is why the guard is not in
|
||||||
|
# control.py: a guard the control law owns is a guard every other claimer
|
||||||
|
# bypasses.
|
||||||
|
#
|
||||||
|
# ⚠️ A NON-FINITE VALUE IS LOSS OF INPUT, NOT AN OUT-OF-RANGE ONE. §11.1
|
||||||
|
# rule 1: command zero and keep commanding. It must not reach the clamp
|
||||||
|
# arithmetic - see is_commandable() for what a nan does to it - and it
|
||||||
|
# must not be counted as a clamp engagement, or a transport bug lands in
|
||||||
|
# the winding-loop counter.
|
||||||
|
if not is_commandable(value):
|
||||||
|
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W")
|
||||||
|
_LOG.error("refusing to write a non-finite setpoint %r - commanding "
|
||||||
|
"0 W and continuing to command. This is a bug upstream, "
|
||||||
|
"not a limit.", value)
|
||||||
|
value = 0.0
|
||||||
|
result = clamp_to_rating(value, float(self.o.get("max_w", 2000)),
|
||||||
|
self.dev_min, self.dev_max)
|
||||||
|
self.clamp_last = result
|
||||||
|
if result.engaged:
|
||||||
|
# ⚠️ EVERY engagement, never deduplicated: this is the action log
|
||||||
|
# (§15), and it is a different surface from the notification, which
|
||||||
|
# IS deduplicated. The count is what says how hard the loop pushed;
|
||||||
|
# the notification only says that it did.
|
||||||
|
self.log_event(result.describe())
|
||||||
|
value = result.applied_w
|
||||||
|
|
||||||
changed = (self.last_write is None or value != self.last_write)
|
changed = (self.last_write is None or value != self.last_write)
|
||||||
if not (changed or force):
|
if not (changed or force):
|
||||||
return
|
return
|
||||||
@@ -366,11 +452,29 @@ class Controller:
|
|||||||
self.sat_count = decision.sat_count
|
self.sat_count = decision.sat_count
|
||||||
self.i_w = decision.i_w
|
self.i_w = decision.i_w
|
||||||
self.loop_w = decision.target_w
|
self.loop_w = decision.target_w
|
||||||
|
# --- SAFETY-03 ---------------------------------------------------
|
||||||
|
# ⚠️ READ THE BOOLEANS, NOT `decision.reason`. On a default install
|
||||||
|
# the integrator bound is what bites and the output clamp is
|
||||||
|
# unreachable (control.py says so), and `reason` is then overwritten
|
||||||
|
# by whichever of slew-limiting or the saturation freeze ran after
|
||||||
|
# it - so a string test is silent on the two cases a winding loop
|
||||||
|
# most often presents as. These flags are set where the clamp
|
||||||
|
# happens and nothing downstream touches them.
|
||||||
|
self.loop_clamped = (
|
||||||
|
decision.i_clamped or decision.clamped,
|
||||||
|
(f"the loop asked for {decision.i_w:+.0f} W"
|
||||||
|
+ (" (integrator bound)" if decision.i_clamped else "")
|
||||||
|
+ (" (output bound)" if decision.clamped else "")),
|
||||||
|
)
|
||||||
claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason))
|
claims.append(Claim.set("loop", P_LOOP, decision.target_w, decision.reason))
|
||||||
else:
|
else:
|
||||||
# Stopped or blind: no accumulation may survive the outage.
|
# Stopped or blind: no accumulation may survive the outage.
|
||||||
self.i_w = 0.0
|
self.i_w = 0.0
|
||||||
self.loop_w = None
|
self.loop_w = None
|
||||||
|
# A loop that is not running cannot be winding. Clearing this here
|
||||||
|
# rather than letting the last value stand is what makes the alarm
|
||||||
|
# de-assert when control is stopped.
|
||||||
|
self.loop_clamped = (False, "")
|
||||||
|
|
||||||
resolution = resolve(claims)
|
resolution = resolve(claims)
|
||||||
if resolution.contradiction:
|
if resolution.contradiction:
|
||||||
@@ -393,6 +497,164 @@ class Controller:
|
|||||||
self.reason = resolution.explain()
|
self.reason = resolution.explain()
|
||||||
await self.write_setpoint(self.target)
|
await self.write_setpoint(self.target)
|
||||||
|
|
||||||
|
# --- SAFETY-03, and its boundary is the WRITE ------------------------
|
||||||
|
# ⚠️ NOT THE SAME BOUNDARY AS SAFETY-18's BELOW, THOUGH THE CALLS ABUT.
|
||||||
|
# This one reads `self.clamp_last`, which is None until write_setpoint()
|
||||||
|
# has actually run, so it has to follow the write. The one below grades
|
||||||
|
# `self.target`, which is assigned further up - see check_output_clamp()
|
||||||
|
# and check_meter_frozen() for each. Two guards, two orderings, adjacent
|
||||||
|
# lines: do not "tidy" them into one rule.
|
||||||
|
await self.check_output_clamp()
|
||||||
|
|
||||||
|
# --- SAFETY-18, and its boundary is the ASSIGNMENT --------------------
|
||||||
|
# ⚠️ THE BOUNDARY IS `self.target = resolution.target_w` ABOVE, NOT
|
||||||
|
# write_setpoint(). This comment used to say "the command that has just
|
||||||
|
# gone on the wire", which is wrong twice over and was corrected by
|
||||||
|
# TEST-06's reviewer: `check_meter_frozen()` grades `self.target` - the
|
||||||
|
# arbiter's resolved intent - and `write_setpoint()` clamps only a local
|
||||||
|
# `value` and can skip the write entirely, so that number may never have
|
||||||
|
# gone anywhere.
|
||||||
|
#
|
||||||
|
# What both terms of the conjunction must describe is the SAME moment:
|
||||||
|
# the command this cycle resolved, against how long the meter that chose
|
||||||
|
# it has been standing still. Evaluated before
|
||||||
|
# `self.target = resolution.target_w` it would grade the PREVIOUS
|
||||||
|
# cycle's command against THIS cycle's meter - the one combination that
|
||||||
|
# was true of neither moment. Moving it between the assignment and
|
||||||
|
# write_setpoint() is therefore harmless; moving it above the assignment
|
||||||
|
# is not, and test_freeze.py part 5 fails on exactly that.
|
||||||
|
await self.check_meter_frozen()
|
||||||
|
|
||||||
|
# --- TARIFF-01, and it is LAST on purpose -----------------------------
|
||||||
|
# Banking a quarter is bookkeeping about a moment that has already
|
||||||
|
# happened; a slow SD card must never sit between the meter and the
|
||||||
|
# setpoint. Its position is unconstrained in the way SAFETY-03's and
|
||||||
|
# SAFETY-18's are not - it grades neither `target` nor `standing` and
|
||||||
|
# reads only `self.p1`, which no line in this method writes.
|
||||||
|
self.peak.sync(self.p1)
|
||||||
|
|
||||||
|
async def check_output_clamp(self) -> None:
|
||||||
|
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
|
||||||
|
|
||||||
|
Runs AFTER write_setpoint() for the same reason SAFETY-18 does: the
|
||||||
|
clamp is arithmetic done inside the write, so before the write there is
|
||||||
|
nothing yet to report.
|
||||||
|
|
||||||
|
⚠️ PUBLISHING IS NOT IN THE WRITE PATH. The clamp itself is synchronous
|
||||||
|
inside write_setpoint(); this - the awaits, the notification, anything
|
||||||
|
that can block on Home Assistant - is out here. A stalled supervisor must
|
||||||
|
never be able to delay or drop a frame.
|
||||||
|
|
||||||
|
⚠️ THIS IS NOT A TRIP. No claim is filed, nothing is latched, 0 W is not
|
||||||
|
commanded and `publish()` still reports "running". §11.2's action for
|
||||||
|
this row is "clamp and alarm", and the clamped value is written and the
|
||||||
|
loop keeps running. Turning this into a trip means coming here and
|
||||||
|
changing that deliberately, not discovering it in the field.
|
||||||
|
"""
|
||||||
|
engaged_write = bool(self.clamp_last and self.clamp_last.engaged)
|
||||||
|
engaged_loop, loop_note = self.loop_clamped
|
||||||
|
engaged = engaged_write or engaged_loop
|
||||||
|
if engaged:
|
||||||
|
where = (self.clamp_last.describe() if engaged_write
|
||||||
|
else f"{CLAMP_REASON}: {loop_note}, above the "
|
||||||
|
f"{float(self.o.get('max_w', 2000)):.0f} W rating, and "
|
||||||
|
f"was clamped to {self.target:+.0f} W")
|
||||||
|
reason = (f"{where}. This is a BUG SIGNAL, not a limit being used: "
|
||||||
|
f"something upstream computed a setpoint the actuator "
|
||||||
|
f"cannot deliver.")
|
||||||
|
else:
|
||||||
|
reason = ""
|
||||||
|
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
# --- THE ACTION LOG, AND IT IS COALESCED PER EPISODE ------------------
|
||||||
|
# Every engagement is recorded, but consecutive ones are recorded by
|
||||||
|
# UPDATING one row's count rather than inserting a row each.
|
||||||
|
#
|
||||||
|
# ⚠️ THAT IS NOT COSMETIC, AND IT WAS FOUND THE HARD WAY. `log_event()`
|
||||||
|
# keeps 40 rows; a winding loop clamps on every cycle, so a row per
|
||||||
|
# engagement flushes the whole ring within 40 cycles - and the first
|
||||||
|
# thing it evicted, in test_freeze.py, was SAFETY-18's "METER FROZEN?"
|
||||||
|
# row. A guard whose log drowns out the other guards' logs has moved the
|
||||||
|
# flood rather than removed it. The count here is `clamp_wd.engagements`,
|
||||||
|
# so nothing is lost: the row says exactly how many, and the number is
|
||||||
|
# read off the counter rather than off the number of rows.
|
||||||
|
#
|
||||||
|
# This lives here rather than only in write_setpoint() because on a
|
||||||
|
# default install the actuator-side clamp is unreachable - compute() has
|
||||||
|
# already bounded the integrator - so the only engagements that exist are
|
||||||
|
# loop-side ones. Measured: 60 cycles of the §10.3 runaway produce 0
|
||||||
|
# write-path engagements and 2 loop-side ones.
|
||||||
|
if engaged and not engaged_write:
|
||||||
|
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
|
||||||
|
row = (f"{tag}: {self.clamp_wd.engagements} engagement(s) - "
|
||||||
|
f"{reason}")
|
||||||
|
if self.events and tag in self.events[0]:
|
||||||
|
stamp = self.events[0].split(" ", 1)[0]
|
||||||
|
self.events[0] = f"{stamp} {row}"
|
||||||
|
else:
|
||||||
|
self.log_event(row)
|
||||||
|
if not fired:
|
||||||
|
return
|
||||||
|
_LOG.error(
|
||||||
|
"SAFETY-03 - %s Silently clipping this is how a winding integrator "
|
||||||
|
"reached 14 768 W on this project before: the clamp makes every "
|
||||||
|
"dashboard read a legal number while the thing producing it goes on "
|
||||||
|
"climbing. The command IS clamped and the loop is STILL RUNNING - "
|
||||||
|
"this is an alarm, not a trip. Engagements so far: %d.",
|
||||||
|
self.clamp_wd.reason, self.clamp_wd.engagements)
|
||||||
|
# A fixed notification_id, for the reason check_meter_frozen() gives -
|
||||||
|
# and a DIFFERENT one from every other alarm this add-on raises, because
|
||||||
|
# a clamp is not a frozen meter and neither is a saturation freeze.
|
||||||
|
await self.hass.call("persistent_notification", "create", {
|
||||||
|
"notification_id": "goodwe_output_clamp",
|
||||||
|
"title": "GoodWe: a computed setpoint exceeded the rating",
|
||||||
|
"message": (
|
||||||
|
f"{self.clamp_wd.reason}\n\nThe command has been clamped and "
|
||||||
|
f"the controller is STILL RUNNING. This is NOT the inverter "
|
||||||
|
f"failing to follow a legal command (that is the saturation "
|
||||||
|
f"freeze, which is normal and does not alarm) - the value asked "
|
||||||
|
f"for was itself out of range."),
|
||||||
|
})
|
||||||
|
|
||||||
|
async def check_meter_frozen(self) -> None:
|
||||||
|
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||||
|
|
||||||
|
Notification only - no claim, no latch, no 0 W. freeze.py carries the
|
||||||
|
reasoning and the euro figure; DOCS.md carries the exposure this does
|
||||||
|
NOT close.
|
||||||
|
|
||||||
|
⚠️ getattr, not an attribute access: `unchanged_s` exists only on
|
||||||
|
`homewizard_local`, and `p1_source` is None until amain() builds the
|
||||||
|
transport. Every other meter_source hands the predicate None and gets
|
||||||
|
False, which is the honest answer - see the transport note in freeze.py.
|
||||||
|
"""
|
||||||
|
unchanged = getattr(self.p1_source, "unchanged_s", None)
|
||||||
|
fired = self.freeze_wd.evaluate(unchanged, self.target,
|
||||||
|
float(self.o.get("max_w", 2000)),
|
||||||
|
datetime.now(timezone.utc))
|
||||||
|
if not fired:
|
||||||
|
return
|
||||||
|
self.log_event("METER FROZEN? " + self.freeze_wd.reason)
|
||||||
|
_LOG.error(
|
||||||
|
"SAFETY-18 - %s. The grid-following loop is regulating against a "
|
||||||
|
"number that has stopped moving, and nothing in this stack stops "
|
||||||
|
"it: the meter is still answering, so the age is fresh, the "
|
||||||
|
"firmware watchdog is fed and the arbiter only bounds the "
|
||||||
|
"magnitude. CHECK THE METER. Sustained for a quarter of an hour "
|
||||||
|
"this sets the month's billed capacity-tariff peak. This is an "
|
||||||
|
"ALARM ONLY - the loop is still running.", self.freeze_wd.reason)
|
||||||
|
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
|
||||||
|
# rather than stacking. An operator who has to dismiss eleven identical
|
||||||
|
# notifications learns to dismiss the twelfth without reading it.
|
||||||
|
await self.hass.call("persistent_notification", "create", {
|
||||||
|
"notification_id": "goodwe_meter_frozen",
|
||||||
|
"title": "GoodWe: the P1 meter may be frozen",
|
||||||
|
"message": (
|
||||||
|
f"{self.freeze_wd.reason}.\n\nThe controller is STILL RUNNING "
|
||||||
|
f"and still following this reading. Check the meter; if it is "
|
||||||
|
f"stuck, stop control from the add-on UI."),
|
||||||
|
})
|
||||||
|
|
||||||
# -- tasks ---------------------------------------------------------------
|
# -- tasks ---------------------------------------------------------------
|
||||||
async def run_control(self) -> None:
|
async def run_control(self) -> None:
|
||||||
entity = self.o.get("setpoint_entity", "")
|
entity = self.o.get("setpoint_entity", "")
|
||||||
@@ -581,6 +843,54 @@ class Controller:
|
|||||||
f"{self.p1.parse_errors} rejected"
|
f"{self.p1.parse_errors} rejected"
|
||||||
+ (f", measurement unchanged for {unchanged:.0f} s"
|
+ (f", measurement unchanged for {unchanged:.0f} s"
|
||||||
if unchanged is not None else "")})
|
if unchanged is not None else "")})
|
||||||
|
# SAFETY-18. Reported in both states, for the reason SAFETY-02 is:
|
||||||
|
# a detector nobody can see is armed is a detector nobody trusts
|
||||||
|
# when it fires. ok=False/warn=False so the banner goes red - this
|
||||||
|
# is an alarm, and the banner is the loudest surface we own. It
|
||||||
|
# changes NOTHING about control; see check_meter_frozen().
|
||||||
|
if self.freeze_wd.active:
|
||||||
|
out.append({"ok": False, "warn": False,
|
||||||
|
"text": "METER MAY BE FROZEN - " + self.freeze_wd.reason
|
||||||
|
+ ". The loop is STILL FOLLOWING it and is "
|
||||||
|
"still running. Check the meter. Held here "
|
||||||
|
"for a quarter of an hour this sets the "
|
||||||
|
"month's billed capacity peak."})
|
||||||
|
elif getattr(self.p1_source, "unchanged_s", None) is None:
|
||||||
|
# Not "armed". Every transport but homewizard_local is invisible
|
||||||
|
# to this check, and saying "armed" there would be a lie the
|
||||||
|
# status page tells on behalf of a guard that cannot run.
|
||||||
|
out.append({"ok": False, "warn": True,
|
||||||
|
"text": f"freeze detector: NOT COVERED on "
|
||||||
|
f"meter_source {o.get('meter_source')} - only "
|
||||||
|
f"homewizard_local sees whether the meter's own "
|
||||||
|
f"document changes"})
|
||||||
|
else:
|
||||||
|
out.append({"ok": True, "warn": False,
|
||||||
|
"text": f"freeze detector: armed (alerts after "
|
||||||
|
f"{self.freeze_wd.cfg.hold_s:.0f} s unchanged "
|
||||||
|
f"while the command is over "
|
||||||
|
f"{rail_w(self.o.get('max_w', 2000)):.0f} W)"})
|
||||||
|
|
||||||
|
# TARIFF-01. The month's billed peak, and - the load-bearing half -
|
||||||
|
# a DIFFERENT row when the store could not answer. "no peak yet" is
|
||||||
|
# a normal state on the 1st; "unreadable" is a broken instrument, and
|
||||||
|
# a status page that spelled them the same way would let a 0 W read
|
||||||
|
# look like a quiet month right up until SAFETY-07 shaved nothing.
|
||||||
|
_pk = self.peak.peak()
|
||||||
|
if _pk.is_unreadable:
|
||||||
|
out.append({"ok": False, "warn": True,
|
||||||
|
"text": "capacity-tariff peak: " + _pk.explain()})
|
||||||
|
else:
|
||||||
|
# §9.1's "warning entity at 80 %", as a status row rather than
|
||||||
|
# a second entity - see the constructor on not doubling up.
|
||||||
|
_frac = self.peak.warning_frac() or 0.0
|
||||||
|
_ok = _frac < 0.8
|
||||||
|
out.append({"ok": _ok, "warn": not _ok,
|
||||||
|
"text": _pk.explain()
|
||||||
|
+ f" - {_frac * 100:.0f} % of the "
|
||||||
|
f"{self.peak.cap_w:.0f} W soft limit"
|
||||||
|
+ f"; {self.peak.recorded} quarter(s) banked"
|
||||||
|
+ f"; on start: {self.peak.restore_state}"})
|
||||||
rows = [("battery SoC", self.soc, o.get("soc_entity")),
|
rows = [("battery SoC", self.soc, o.get("soc_entity")),
|
||||||
("battery power", self.batt, o.get("batt_entity"))]
|
("battery power", self.batt, o.get("batt_entity"))]
|
||||||
if not self.p1_enabled:
|
if not self.p1_enabled:
|
||||||
@@ -607,6 +917,23 @@ class Controller:
|
|||||||
out.append({"ok": True, "warn": False,
|
out.append({"ok": True, "warn": False,
|
||||||
"text": f"setpoint {sp} (range {self.dev_min:g}…{self.dev_max:g} W)"})
|
"text": f"setpoint {sp} (range {self.dev_min:g}…{self.dev_max:g} W)"})
|
||||||
|
|
||||||
|
# SAFETY-03. Red while a clamp is engaged, and the engagement COUNT is
|
||||||
|
# shown even after it clears - a loop that wound once and stopped is
|
||||||
|
# still a bug, and a row that vanishes with the condition takes the
|
||||||
|
# evidence with it. Reported only when there is something to report:
|
||||||
|
# unlike the freeze detector this guard has no "not covered" state, it
|
||||||
|
# runs on every write on every transport.
|
||||||
|
if self.clamp_wd.active:
|
||||||
|
out.append({"ok": False, "warn": False,
|
||||||
|
"text": "OUTPUT CLAMP ENGAGED - " + self.clamp_wd.reason
|
||||||
|
+ f" Engagements: {self.clamp_wd.engagements}."})
|
||||||
|
elif self.clamp_wd.engagements:
|
||||||
|
out.append({"ok": False, "warn": True,
|
||||||
|
"text": f"output clamp: not engaged now, but it has "
|
||||||
|
f"engaged {self.clamp_wd.engagements} time(s) in "
|
||||||
|
f"{self.clamp_wd.episodes} episode(s) since "
|
||||||
|
f"start - see the event log"})
|
||||||
|
|
||||||
if self.last_write_ok is False:
|
if self.last_write_ok is False:
|
||||||
out.append({"ok": False, "warn": False,
|
out.append({"ok": False, "warn": False,
|
||||||
"text": "last write to the inverter was REJECTED - check the log"})
|
"text": "last write to the inverter was REJECTED - check the log"})
|
||||||
|
|||||||
@@ -353,6 +353,54 @@ class QuarterAverager:
|
|||||||
self._last_pp = sample.per_phase_w
|
self._last_pp = sample.per_phase_w
|
||||||
return closed
|
return closed
|
||||||
|
|
||||||
|
# -- surviving a restart (TARIFF-01) ------------------------------------
|
||||||
|
def snapshot(self) -> dict:
|
||||||
|
"""The open block's whole state, as JSON-safe primitives.
|
||||||
|
|
||||||
|
⚠️ `last_t` is the HELD SAMPLE'S OWN timestamp and is stored as it is,
|
||||||
|
not refreshed to "now". That is what makes a restart integrate the gap
|
||||||
|
under exactly the `max_hold_s` rule a running process would have used:
|
||||||
|
a short restart is a plateau, a long one leaves uncovered seconds that
|
||||||
|
drag the closed block's average down. Rewriting it to the restore time
|
||||||
|
would silently credit the whole outage at the last value seen before it.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"block": self._block,
|
||||||
|
"acc": self._acc,
|
||||||
|
"pp_acc": list(self._pp_acc),
|
||||||
|
"elapsed": self._elapsed,
|
||||||
|
"last_t": self._last_t,
|
||||||
|
"last_net": self._last_net,
|
||||||
|
"last_pp": list(self._last_pp) if self._last_pp is not None else None,
|
||||||
|
"phases": self.phases,
|
||||||
|
}
|
||||||
|
|
||||||
|
def restore(self, state: dict) -> bool:
|
||||||
|
"""Put a `snapshot()` back. False if it does not fit this averager.
|
||||||
|
|
||||||
|
A phase-count change is a reconfiguration, not a resumption: the
|
||||||
|
per-phase accumulator would be reshaped mid-block and the two halves
|
||||||
|
would not be the same measurement. Refusing is the honest answer, and
|
||||||
|
the caller reports starting fresh rather than pretending it resumed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if int(state.get("phases", self.phases)) != self.phases:
|
||||||
|
return False
|
||||||
|
pp_acc = list(state.get("pp_acc") or [])
|
||||||
|
if len(pp_acc) != self.phases:
|
||||||
|
return False
|
||||||
|
self._block = None if state["block"] is None else int(state["block"])
|
||||||
|
self._acc = float(state["acc"])
|
||||||
|
self._pp_acc = [float(v) for v in pp_acc]
|
||||||
|
self._elapsed = float(state["elapsed"])
|
||||||
|
self._last_t = None if state["last_t"] is None else float(state["last_t"])
|
||||||
|
self._last_net = float(state["last_net"])
|
||||||
|
lp = state.get("last_pp")
|
||||||
|
self._last_pp = tuple(float(v) for v in lp) if lp is not None else None
|
||||||
|
return True
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# what the rest of the add-on talks to
|
# what the rest of the add-on talks to
|
||||||
|
|||||||
@@ -0,0 +1,682 @@
|
|||||||
|
"""TARIFF-01 - the monthly capacity-tariff peak, and the record that outlives it.
|
||||||
|
|
||||||
|
WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT. This module is an INSTRUMENT. It
|
||||||
|
measures the number the Belgian capaciteitstarief bills on - the highest
|
||||||
|
15-minute average offtake of the calendar month (Fluvius / Resa / Ores) - and it
|
||||||
|
writes that number down somewhere a restart cannot reach. It makes no claim,
|
||||||
|
applies no clamp, and commands nothing. The guard that will read it is SAFETY-07,
|
||||||
|
and it belongs in `arbiter.py` as a `P_PEAK` claim when it is built. Keeping the
|
||||||
|
measurement and the actuation apart is the whole reason this file is small.
|
||||||
|
|
||||||
|
WHAT WAS ALREADY BUILT, AND WHY THIS CARD IS NOT 8 SP OF NEW ARITHMETIC.
|
||||||
|
`p1.QuarterAverager` already computes AC 1 in full: clock-aligned 900 s blocks,
|
||||||
|
time-weighted over irregular telegram spacing, offtake-only, per-phase, with a
|
||||||
|
`max_hold_s` gap rule. It was unwired and unpersisted - `P1Ingest.blocks` is a
|
||||||
|
96-entry list in memory that a restart erases. So this file is the persistence
|
||||||
|
and the monthly reduction, not a second accumulator. Adding one would be adding
|
||||||
|
a second source of truth for the exact quantity the money depends on.
|
||||||
|
|
||||||
|
BLOCKS, NOT A SLIDING WINDOW. The Description says "rolling"; AC 1 says
|
||||||
|
"15-min block average". They are different numbers and the utility bills the
|
||||||
|
second one. §9.1 calls the billed unit the "highest 15-minute average offtake",
|
||||||
|
and §9.1's own worked case - "one 4998 W quarter moved a monthly peak from
|
||||||
|
4832 -> 5013 W" - is stated per QUARTER, an object a sliding window does not
|
||||||
|
have. "Rolling" here means CONTINUOUSLY UPDATED (the open block's partial
|
||||||
|
average is readable at every sample), not a trailing-15-minutes window. A
|
||||||
|
trailing window would report a peak the meter never bills, and it reports it
|
||||||
|
HIGHER than the real one: a load burst straddling :15 fills a trailing window
|
||||||
|
completely while contributing to two block averages at partial weight. Guarding
|
||||||
|
that would spend battery defending an interval nobody charges for.
|
||||||
|
|
||||||
|
DELIVERED POWER, NOT INTENDED POWER. SAFETY-20 has an open question about
|
||||||
|
whether guards should grade `Decision.target` (pre-clamp intent) or `standing`
|
||||||
|
(what actually reached the wire). For a capacity tariff there is no question to
|
||||||
|
ask: the utility's meter integrates the current that flowed. This module never
|
||||||
|
sees a setpoint at all - it consumes `P1Sample`, which is the meter's own
|
||||||
|
reading - and that is on purpose. A peak computed from intent would be wrong in
|
||||||
|
the expensive direction (a clamped 9 kW request that delivered 5 kW would
|
||||||
|
record 9 kW, and every consumer would shave against a peak that never existed).
|
||||||
|
|
||||||
|
AN UNREADABLE STORE IS NOT A ZERO PEAK. This is the defect this repo spent a
|
||||||
|
day removing from `sim/riglock.py`, where `holder()` was rewritten to answer
|
||||||
|
`observed / not_observed / gone` instead of a partial dict every caller
|
||||||
|
`.get()`s a default out of. The same trap is here and it is worse, because the
|
||||||
|
degenerate value is not merely wrong, it is DISARMING: a peak read as 0 W gives
|
||||||
|
SAFETY-07 the maximum possible headroom and silently switches the guard off at
|
||||||
|
the moment its own storage is broken. So `peak()` returns a `PeakReading` whose
|
||||||
|
`state` is one of OBSERVED / NO_RECORD / NOT_OBSERVED, `peak_w` is `None`
|
||||||
|
unless the state is OBSERVED, and there is no `.get(..., 0)` shaped accessor
|
||||||
|
anywhere on it. NO_RECORD ("this month has no completed block yet", a normal
|
||||||
|
state on the 1st) and NOT_OBSERVED ("the database did not answer") are
|
||||||
|
different types of thing and are never spelled the same way.
|
||||||
|
|
||||||
|
CONFIG: NONE ADDED, ON PURPOSE. CFG-03 is open because the option defaults
|
||||||
|
already live in three places held apart by a tripwire in `test_validate.py`. The
|
||||||
|
soft limit this measurement is graded against already exists as `peak_cap_w`
|
||||||
|
(config.yaml, default 3500), already validated, already read by
|
||||||
|
`control.peak_at_risk()`. §14 names a `tariffs.capacity_peak_limit_kw: 2.5` - in
|
||||||
|
KILOwatts, against watts everywhere else - and adding it would have made a fourth
|
||||||
|
defaults site AND imported the unit mismatch §14's own warning box says must be
|
||||||
|
caught at startup. So this module takes its limit as a plain constructor
|
||||||
|
argument in WATTS and config.yaml is untouched by this ticket.
|
||||||
|
|
||||||
|
THE DATABASE. SQLite in /data, the add-on's only durable volume, alongside
|
||||||
|
`store.py`'s state.json. Two tiers from §15, and their retention is the point:
|
||||||
|
|
||||||
|
quarter_blocks one row per completed 900 s block INDEFINITE
|
||||||
|
monthly_peak one row per calendar month PERMANENT
|
||||||
|
|
||||||
|
Neither is ever pruned here, and no sweep in this file can prune them - there is
|
||||||
|
no DELETE statement against either table anywhere in this module. §15's asymmetry
|
||||||
|
is why: raw samples vanish after 7 days, so a 15-minute aggregate that was not
|
||||||
|
written AS IT HAPPENED can never be recomputed. `monthly_peak` is maintained as a
|
||||||
|
derived record - upserted on every block close, and `recompute_month()` re-derives
|
||||||
|
it from `quarter_blocks` so the two can be PROVEN not to have diverged rather
|
||||||
|
than assumed not to have.
|
||||||
|
|
||||||
|
RESTART MID-BLOCK. The open block's accumulator is memory. A restart at minute 12
|
||||||
|
that dropped it would under-read the rest of the quarter, and SAFETY-07's clamp
|
||||||
|
would release exactly when it was most needed. So the accumulator is checkpointed
|
||||||
|
(`QuarterAverager.snapshot()`) into a single row, at most `CHECKPOINT_S` stale,
|
||||||
|
and restored on startup. Three cases, all of them exercised in `test_tariff.py`:
|
||||||
|
|
||||||
|
* the checkpoint's block is the block containing `now` -> restore it verbatim,
|
||||||
|
including the held sample's ORIGINAL timestamp, so the first sample after the
|
||||||
|
restart integrates the gap under the same `max_hold_s` rule a running process
|
||||||
|
would have applied. A restart longer than `max_hold_s` therefore leaves
|
||||||
|
uncovered seconds, and a closed block is always divided by the full 900 s, so
|
||||||
|
the gap DRAGS THE BILLED AVERAGE DOWN instead of being averaged away;
|
||||||
|
* the checkpoint's block ended while we were down -> that block really happened
|
||||||
|
and its measured part is real, so it is closed at `acc / 900` and recorded,
|
||||||
|
then a fresh block is opened. Discarding it would lose measured energy;
|
||||||
|
* no checkpoint, or one that cannot be read -> start clean and SAY SO. The
|
||||||
|
return value of `restore()` names which of the three happened; it is never
|
||||||
|
silently the first one.
|
||||||
|
|
||||||
|
Double counting is answered structurally rather than by care: `quarter_start` is
|
||||||
|
the PRIMARY KEY, and the recorded average may only ever be RAISED by a later
|
||||||
|
write (`ON CONFLICT ... WHERE excluded.offtake_avg_w > ...`). Replaying the same
|
||||||
|
block twice is therefore a no-op, and a restart that reprocesses a block with
|
||||||
|
LESS coverage than the first pass cannot lower a figure already banked.
|
||||||
|
|
||||||
|
MONTH BOUNDARY. Calendar months are LOCAL (Europe/Brussels); blocks are stored in
|
||||||
|
UTC. In August those differ by two hours, so a block starting 2026-08-31 22:15
|
||||||
|
UTC belongs to SEPTEMBER and putting it in August would move a month's bill.
|
||||||
|
Block alignment itself needs no timezone - every Belgian offset is a whole number
|
||||||
|
of hours, so the 900 s UTC grid lands on :00/:15/:30/:45 local on both sides of a
|
||||||
|
DST transition, which is the argument `QuarterAverager` already carries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone, tzinfo
|
||||||
|
|
||||||
|
_LOG = logging.getLogger("goodwe.tariff")
|
||||||
|
|
||||||
|
QUARTER_S = 900
|
||||||
|
|
||||||
|
# How stale the partial-block checkpoint may be. This is the ONLY thing a crash
|
||||||
|
# can lose from an open block, and the loss is bounded and one-directional: the
|
||||||
|
# lost seconds were already integrated into `_acc`, so dropping them makes the
|
||||||
|
# restored block read LOW, never high. At 15 s against a 900 s block the
|
||||||
|
# worst-case under-read of one quarter's average is 15/900 = 1.7 % of that
|
||||||
|
# quarter's own offtake - see test_tariff.py, which computes that bound from
|
||||||
|
# these two constants rather than quoting it.
|
||||||
|
CHECKPOINT_S = 15.0
|
||||||
|
|
||||||
|
# -- reading states. Three, and never fewer. -------------------------------- #
|
||||||
|
OBSERVED = "observed" # the store answered and there is a peak
|
||||||
|
NO_RECORD = "no_record" # the store answered and this month has none yet
|
||||||
|
NOT_OBSERVED = "not_observed" # the store did not answer. NOT a zero peak.
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# local time, without a hard dependency on the tz database
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class _EUTimezone(tzinfo):
|
||||||
|
"""Europe/Brussels by the EU harmonised rule, for hosts with no tzdata.
|
||||||
|
|
||||||
|
⚠️ THIS IS A FALLBACK AND IT IS NOT SILENT. `zoneinfo` is tried first and is
|
||||||
|
what runs on the add-on's container. The fallback exists because the
|
||||||
|
alternative - quietly using UTC when tzdata is missing - misfiles every block
|
||||||
|
in the last two hours of a month into the wrong month's bill, and does it
|
||||||
|
without a symptom. Encoding the rule (CET, CEST from 01:00 UTC on the last
|
||||||
|
Sunday of March to 01:00 UTC on the last Sunday of October) is 20 lines and
|
||||||
|
is checked against zoneinfo, date by date, wherever zoneinfo is available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The rule, in both frames. `fromutc` is overridden rather than inherited
|
||||||
|
# because the base implementation calls back into `utcoffset` with a
|
||||||
|
# datetime whose tzinfo is self - and the first version of this class
|
||||||
|
# answered that by calling `astimezone`, which calls `utcoffset`, which
|
||||||
|
# recursed until the stack ran out. A tzinfo must answer from the value it
|
||||||
|
# is handed, never by converting it.
|
||||||
|
@staticmethod
|
||||||
|
def _last_sunday(year: int, month: int, hour: int) -> datetime:
|
||||||
|
d = datetime(year, month + 1, 1) - timedelta(days=1)
|
||||||
|
return (d - timedelta(days=(d.weekday() + 1) % 7)).replace(hour=hour)
|
||||||
|
|
||||||
|
def _dst_local(self, naive: datetime) -> bool:
|
||||||
|
"""From a LOCAL wall clock: 02:00 CET last Sunday March -> 03:00 CEST
|
||||||
|
last Sunday October. The one ambiguous hour each autumn resolves to
|
||||||
|
standard time, which cannot move a month boundary."""
|
||||||
|
y = naive.year
|
||||||
|
return self._last_sunday(y, 3, 2) <= naive < self._last_sunday(y, 10, 3)
|
||||||
|
|
||||||
|
def _dst_utc(self, naive_utc: datetime) -> bool:
|
||||||
|
"""From UTC: both EU transitions happen at 01:00 UTC, everywhere."""
|
||||||
|
y = naive_utc.year
|
||||||
|
return self._last_sunday(y, 3, 1) <= naive_utc < self._last_sunday(y, 10, 1)
|
||||||
|
|
||||||
|
def fromutc(self, dt: datetime) -> datetime:
|
||||||
|
return dt + timedelta(hours=2 if self._dst_utc(dt.replace(tzinfo=None)) else 1)
|
||||||
|
|
||||||
|
def utcoffset(self, dt):
|
||||||
|
if dt is None:
|
||||||
|
return timedelta(hours=1)
|
||||||
|
return timedelta(hours=2 if self._dst_local(dt.replace(tzinfo=None)) else 1)
|
||||||
|
|
||||||
|
def dst(self, dt):
|
||||||
|
if dt is None:
|
||||||
|
return timedelta(0)
|
||||||
|
return timedelta(hours=1 if self._dst_local(dt.replace(tzinfo=None)) else 0)
|
||||||
|
|
||||||
|
def tzname(self, dt):
|
||||||
|
return "CEST" if dt is not None and self._dst_local(dt.replace(tzinfo=None)) else "CET"
|
||||||
|
|
||||||
|
|
||||||
|
def _billing_tz():
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
tz = ZoneInfo("Europe/Brussels")
|
||||||
|
# Prove it resolves before trusting it: a ZoneInfo built from an empty
|
||||||
|
# tzdata answers, and answering wrongly is the failure being avoided.
|
||||||
|
if datetime(2026, 7, 1, tzinfo=timezone.utc).astimezone(tz).hour == 2:
|
||||||
|
return tz
|
||||||
|
_LOG.warning("zoneinfo Europe/Brussels resolved but gave the wrong "
|
||||||
|
"summer offset - falling back to the encoded EU rule")
|
||||||
|
except Exception as err: # noqa: BLE001 - any failure
|
||||||
|
_LOG.info("no usable tz database (%s) - using the encoded EU rule", err)
|
||||||
|
return _EUTimezone()
|
||||||
|
|
||||||
|
|
||||||
|
BILLING_TZ = _billing_tz()
|
||||||
|
|
||||||
|
|
||||||
|
def month_key(when: datetime) -> str:
|
||||||
|
"""The calendar month a UTC instant is billed in, as 'YYYY-MM' LOCAL time."""
|
||||||
|
if when.tzinfo is None:
|
||||||
|
when = when.replace(tzinfo=timezone.utc)
|
||||||
|
local = when.astimezone(BILLING_TZ)
|
||||||
|
return f"{local.year:04d}-{local.month:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# the per-phase reduction policy - one function, deliberately
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# §9.1 requires the average be computed per-phase-aware, but does NOT say whether
|
||||||
|
# the BILLED figure on a three-phase connection is the sum of the phases or the
|
||||||
|
# worst single phase. §5.2 requires the per-phase data; nothing states the
|
||||||
|
# reduction. So it is one function with one argument, and correcting it later is
|
||||||
|
# a one-line change plus a test parameter rather than a change to the
|
||||||
|
# accumulator. §17's "Three-phase unbalanced load" scenario is the case that
|
||||||
|
# tells the three apart - on a balanced load all three agree, which is exactly
|
||||||
|
# why a balanced fixture must not be what this is tested on.
|
||||||
|
POLICY_NET = "net" # default: the connection's net offtake, what the meter bills
|
||||||
|
POLICY_SUM = "sum" # sum of the per-phase offtakes (differs when a phase exports)
|
||||||
|
POLICY_MAX = "max" # the worst single phase
|
||||||
|
|
||||||
|
|
||||||
|
def billed_w(block, policy: str = POLICY_NET) -> float:
|
||||||
|
"""Reduce one closed block to the single figure the month is billed on."""
|
||||||
|
pp = getattr(block, "per_phase_offtake_avg_w", None)
|
||||||
|
if policy == POLICY_NET or not pp:
|
||||||
|
return float(block.offtake_avg_w)
|
||||||
|
if policy == POLICY_SUM:
|
||||||
|
return float(sum(pp))
|
||||||
|
if policy == POLICY_MAX:
|
||||||
|
return float(max(pp))
|
||||||
|
raise ValueError(f"unknown per-phase reduction policy: {policy!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# what a read of the store answers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PeakReading:
|
||||||
|
"""An answer about a month's peak, or an honest refusal to give one.
|
||||||
|
|
||||||
|
⚠️ There is no `peak_w_or(default)` and there must never be one. Every
|
||||||
|
accessor that lets a caller turn "the database did not answer" into a number
|
||||||
|
re-creates the defect this type exists to remove. Callers ask `is_observed`
|
||||||
|
first, and a caller that forgets gets `None` and a TypeError, which is loud.
|
||||||
|
"""
|
||||||
|
|
||||||
|
state: str
|
||||||
|
month: str
|
||||||
|
peak_w: float | None = None
|
||||||
|
quarter_start: datetime | None = None
|
||||||
|
per_phase_w: tuple | None = None
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_observed(self) -> bool:
|
||||||
|
return self.state == OBSERVED
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_unreadable(self) -> bool:
|
||||||
|
return self.state == NOT_OBSERVED
|
||||||
|
|
||||||
|
def explain(self) -> str:
|
||||||
|
if self.state == OBSERVED:
|
||||||
|
at = self.quarter_start.isoformat() if self.quarter_start else "?"
|
||||||
|
return f"{self.month}: peak {self.peak_w:.0f} W, set by the quarter at {at}"
|
||||||
|
if self.state == NO_RECORD:
|
||||||
|
return f"{self.month}: no completed quarter recorded yet"
|
||||||
|
return (f"{self.month}: PEAK UNREADABLE ({self.detail}) - this is NOT a "
|
||||||
|
f"zero peak and must not be used as headroom")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# the store
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS quarter_blocks (
|
||||||
|
quarter_start TEXT PRIMARY KEY, -- ISO-8601 UTC, aligned to 900 s
|
||||||
|
month TEXT NOT NULL, -- 'YYYY-MM', LOCAL calendar month
|
||||||
|
offtake_avg_w REAL NOT NULL,
|
||||||
|
per_phase_json TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_quarter_month ON quarter_blocks(month);
|
||||||
|
CREATE TABLE IF NOT EXISTS monthly_peak (
|
||||||
|
month TEXT PRIMARY KEY,
|
||||||
|
peak_w REAL NOT NULL,
|
||||||
|
quarter_start TEXT NOT NULL,
|
||||||
|
per_phase_json TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS partial_block (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
state_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class PeakStore:
|
||||||
|
"""SQLite behind /data. Every read answers with a state, never with a default."""
|
||||||
|
|
||||||
|
def __init__(self, path: str = "/data/tariff.db"):
|
||||||
|
self.path = path
|
||||||
|
self.ok = False
|
||||||
|
self.last_error: str | None = None
|
||||||
|
self._db: sqlite3.Connection | None = None
|
||||||
|
self._open()
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
parent = os.path.dirname(self.path)
|
||||||
|
# ⚠️ THE DIRECTORY IS NOT CREATED. /data is mounted by the Supervisor and
|
||||||
|
# always exists on a real install, so `makedirs` here could only ever
|
||||||
|
# succeed where the durable volume is MISSING - manufacturing a database
|
||||||
|
# on the container's ephemeral layer that looks durable, reads fine, and
|
||||||
|
# is thrown away on the next restart. That is a peak store that silently
|
||||||
|
# forgets, which is worse than one that says it cannot answer.
|
||||||
|
if parent and not os.path.isdir(parent):
|
||||||
|
self.last_error = f"no durable volume at {parent}"
|
||||||
|
# INFO, not ERROR: on a dev host or in a test container there is no
|
||||||
|
# /data and nothing is wrong. A store that OPENED and then broke is
|
||||||
|
# the error case, and it is logged as one below.
|
||||||
|
_LOG.info("no peak store: %s - the monthly peak will read "
|
||||||
|
"NOT_OBSERVED", self.last_error)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._db = sqlite3.connect(self.path, timeout=5.0,
|
||||||
|
check_same_thread=False)
|
||||||
|
# WAL: a power cut mid-write must not leave a torn database that
|
||||||
|
# reads as "no peak this month" - the exact shape of the defect.
|
||||||
|
self._db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self._db.executescript(_SCHEMA)
|
||||||
|
self._db.commit()
|
||||||
|
self.ok = True
|
||||||
|
self.last_error = None
|
||||||
|
except sqlite3.Error as err:
|
||||||
|
self._db = None
|
||||||
|
self.ok = False
|
||||||
|
self.last_error = str(err)
|
||||||
|
# ⚠️ Deliberately NOT raised. §6's fail-open rule: an aggregation
|
||||||
|
# fault must not stop a controller that is regulating a battery. The
|
||||||
|
# cost of continuing is a peak that reads NOT_OBSERVED, and every
|
||||||
|
# reader is built to treat that as "do not use me", not as 0 W.
|
||||||
|
_LOG.error("peak store unusable at %s (%s) - the monthly peak will "
|
||||||
|
"read NOT_OBSERVED, which is not a zero peak", self.path, err)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._db is not None:
|
||||||
|
try:
|
||||||
|
self._db.close()
|
||||||
|
finally:
|
||||||
|
self._db = None
|
||||||
|
self.ok = False
|
||||||
|
|
||||||
|
# -- writing ------------------------------------------------------------
|
||||||
|
def record_block(self, block, policy: str = POLICY_NET) -> bool:
|
||||||
|
"""Persist one completed block and fold it into its month's peak.
|
||||||
|
|
||||||
|
Both tables move in ONE transaction. A crash between them would leave a
|
||||||
|
banked quarter that the monthly peak does not know about, and the peak is
|
||||||
|
the record the money is read off.
|
||||||
|
"""
|
||||||
|
if self._db is None:
|
||||||
|
return False
|
||||||
|
w = billed_w(block, policy)
|
||||||
|
start = block.start.astimezone(timezone.utc).isoformat()
|
||||||
|
month = month_key(block.start)
|
||||||
|
pp = getattr(block, "per_phase_offtake_avg_w", None)
|
||||||
|
pp_json = json.dumps(list(pp)) if pp else None
|
||||||
|
try:
|
||||||
|
with self._db:
|
||||||
|
# A block may only ever be RAISED. A restart that reprocesses a
|
||||||
|
# quarter it had already banked will have LESS coverage than the
|
||||||
|
# first pass (its first seconds are gone), so an unconditional
|
||||||
|
# REPLACE would silently lower a recorded figure.
|
||||||
|
self._db.execute(
|
||||||
|
"INSERT INTO quarter_blocks(quarter_start, month, offtake_avg_w, per_phase_json)"
|
||||||
|
" VALUES(?,?,?,?)"
|
||||||
|
" ON CONFLICT(quarter_start) DO UPDATE SET"
|
||||||
|
" offtake_avg_w=excluded.offtake_avg_w,"
|
||||||
|
" per_phase_json=excluded.per_phase_json"
|
||||||
|
" WHERE excluded.offtake_avg_w > quarter_blocks.offtake_avg_w",
|
||||||
|
(start, month, w, pp_json))
|
||||||
|
self._db.execute(
|
||||||
|
"INSERT INTO monthly_peak(month, peak_w, quarter_start, per_phase_json)"
|
||||||
|
" VALUES(?,?,?,?)"
|
||||||
|
" ON CONFLICT(month) DO UPDATE SET"
|
||||||
|
" peak_w=excluded.peak_w,"
|
||||||
|
" quarter_start=excluded.quarter_start,"
|
||||||
|
" per_phase_json=excluded.per_phase_json"
|
||||||
|
" WHERE excluded.peak_w > monthly_peak.peak_w",
|
||||||
|
(month, w, start, pp_json))
|
||||||
|
return True
|
||||||
|
except sqlite3.Error as err:
|
||||||
|
self.last_error = str(err)
|
||||||
|
_LOG.error("could not record quarter %s (%s)", start, err)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# -- reading ------------------------------------------------------------
|
||||||
|
def peak(self, when: datetime | None = None, month: str | None = None) -> PeakReading:
|
||||||
|
"""The billed peak for a month. Three possible answers, never a default."""
|
||||||
|
if month is None:
|
||||||
|
month = month_key(when or datetime.now(timezone.utc))
|
||||||
|
if self._db is None:
|
||||||
|
return PeakReading(NOT_OBSERVED, month,
|
||||||
|
detail=self.last_error or "store never opened")
|
||||||
|
try:
|
||||||
|
row = self._db.execute(
|
||||||
|
"SELECT peak_w, quarter_start, per_phase_json FROM monthly_peak"
|
||||||
|
" WHERE month=?", (month,)).fetchone()
|
||||||
|
except sqlite3.Error as err:
|
||||||
|
self.last_error = str(err)
|
||||||
|
return PeakReading(NOT_OBSERVED, month, detail=str(err))
|
||||||
|
if row is None:
|
||||||
|
return PeakReading(NO_RECORD, month)
|
||||||
|
pp = tuple(json.loads(row[2])) if row[2] else None
|
||||||
|
return PeakReading(OBSERVED, month, peak_w=float(row[0]),
|
||||||
|
quarter_start=datetime.fromisoformat(row[1]),
|
||||||
|
per_phase_w=pp)
|
||||||
|
|
||||||
|
def recompute_month(self, month: str) -> PeakReading:
|
||||||
|
"""Re-derive the peak from `quarter_blocks` - the divergence check.
|
||||||
|
|
||||||
|
`monthly_peak` is a derived record, so it can be PROVEN equal to the
|
||||||
|
tier it derives from instead of trusted to be. Read-only.
|
||||||
|
"""
|
||||||
|
if self._db is None:
|
||||||
|
return PeakReading(NOT_OBSERVED, month,
|
||||||
|
detail=self.last_error or "store never opened")
|
||||||
|
try:
|
||||||
|
row = self._db.execute(
|
||||||
|
"SELECT offtake_avg_w, quarter_start, per_phase_json FROM quarter_blocks"
|
||||||
|
" WHERE month=? ORDER BY offtake_avg_w DESC, quarter_start ASC LIMIT 1",
|
||||||
|
(month,)).fetchone()
|
||||||
|
except sqlite3.Error as err:
|
||||||
|
self.last_error = str(err)
|
||||||
|
return PeakReading(NOT_OBSERVED, month, detail=str(err))
|
||||||
|
if row is None:
|
||||||
|
return PeakReading(NO_RECORD, month)
|
||||||
|
pp = tuple(json.loads(row[2])) if row[2] else None
|
||||||
|
return PeakReading(OBSERVED, month, peak_w=float(row[0]),
|
||||||
|
quarter_start=datetime.fromisoformat(row[1]),
|
||||||
|
per_phase_w=pp)
|
||||||
|
|
||||||
|
def blocks_in(self, month: str) -> list:
|
||||||
|
"""(quarter_start, offtake_avg_w) for a month, oldest first. [] on error
|
||||||
|
is safe here ONLY because no caller reads emptiness as a peak - AC 3's
|
||||||
|
counter reports the sample size it actually got."""
|
||||||
|
if self._db is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return [(datetime.fromisoformat(a), float(b)) for a, b in self._db.execute(
|
||||||
|
"SELECT quarter_start, offtake_avg_w FROM quarter_blocks"
|
||||||
|
" WHERE month=? ORDER BY quarter_start", (month,))]
|
||||||
|
except sqlite3.Error as err:
|
||||||
|
self.last_error = str(err)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# -- the open block's checkpoint ----------------------------------------
|
||||||
|
def save_partial(self, state: dict) -> bool:
|
||||||
|
if self._db is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with self._db:
|
||||||
|
self._db.execute(
|
||||||
|
"INSERT INTO partial_block(id, state_json) VALUES(1,?)"
|
||||||
|
" ON CONFLICT(id) DO UPDATE SET state_json=excluded.state_json",
|
||||||
|
(json.dumps(state),))
|
||||||
|
return True
|
||||||
|
except (sqlite3.Error, TypeError, ValueError) as err:
|
||||||
|
self.last_error = str(err)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def load_partial(self) -> dict | None:
|
||||||
|
if self._db is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
row = self._db.execute(
|
||||||
|
"SELECT state_json FROM partial_block WHERE id=1").fetchone()
|
||||||
|
return json.loads(row[0]) if row else None
|
||||||
|
except (sqlite3.Error, ValueError) as err:
|
||||||
|
self.last_error = str(err)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def clear_partial(self) -> None:
|
||||||
|
# The one DELETE in this module, and it is against the checkpoint - a
|
||||||
|
# scratch row, not a retention tier. Neither quarter_blocks nor
|
||||||
|
# monthly_peak is deleted from anywhere in this file.
|
||||||
|
if self._db is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with self._db:
|
||||||
|
self._db.execute("DELETE FROM partial_block WHERE id=1")
|
||||||
|
except sqlite3.Error as err:
|
||||||
|
self.last_error = str(err)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# the tracker: what main.py holds
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Projection:
|
||||||
|
"""The open block's end-of-quarter estimate, with the reasons to distrust it.
|
||||||
|
|
||||||
|
`elapsed_s` travels WITH the number rather than beside it, because early in a
|
||||||
|
quarter a projection is a 10-second extrapolation over 900 s and a consumer
|
||||||
|
that cannot see that will react to noise. `covered_frac` is separately useful:
|
||||||
|
it is below 1.0 when the meter had a gap, which is the "unreliable quarter"
|
||||||
|
the functional note asks be marked rather than reported low.
|
||||||
|
"""
|
||||||
|
|
||||||
|
projected_w: float
|
||||||
|
elapsed_w: float
|
||||||
|
elapsed_s: float
|
||||||
|
covered_frac: float
|
||||||
|
complete: bool
|
||||||
|
block_start: datetime | None
|
||||||
|
|
||||||
|
def explain(self) -> str:
|
||||||
|
return (f"quarter projects {self.projected_w:.0f} W "
|
||||||
|
f"({self.elapsed_s:.0f} s elapsed, {self.covered_frac * 100:.0f} % covered"
|
||||||
|
+ ("" if self.complete else ", INCOMPLETE") + ")")
|
||||||
|
|
||||||
|
|
||||||
|
class PeakTracker:
|
||||||
|
"""Drives the store from `P1Ingest`. Measures; never commands."""
|
||||||
|
|
||||||
|
def __init__(self, store: PeakStore, cap_w: float = 3500.0,
|
||||||
|
policy: str = POLICY_NET, checkpoint_s: float = CHECKPOINT_S):
|
||||||
|
self.store = store
|
||||||
|
self.cap_w = float(cap_w)
|
||||||
|
self.policy = policy
|
||||||
|
self.checkpoint_s = float(checkpoint_s)
|
||||||
|
self.recorded = 0
|
||||||
|
self.restore_state = "not attempted"
|
||||||
|
self._seen: set = set()
|
||||||
|
self._last_ckpt = 0.0
|
||||||
|
|
||||||
|
# -- startup ------------------------------------------------------------
|
||||||
|
def restore(self, averager, now: datetime | None = None) -> str:
|
||||||
|
"""Put the open block back. Returns which of the three cases happened."""
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
snap = self.store.load_partial()
|
||||||
|
if not snap:
|
||||||
|
self.restore_state = ("fresh: no checkpoint" if self.store.ok
|
||||||
|
else "fresh: store unreadable")
|
||||||
|
return self.restore_state
|
||||||
|
block = snap.get("block")
|
||||||
|
if block is None:
|
||||||
|
self.restore_state = "fresh: checkpoint had no open block"
|
||||||
|
return self.restore_state
|
||||||
|
|
||||||
|
current = int(now.timestamp() // QUARTER_S) * QUARTER_S
|
||||||
|
if block == current:
|
||||||
|
if not averager.restore(snap):
|
||||||
|
# A reshaped accumulator (meter_phases changed) is a different
|
||||||
|
# measurement, not a resumable one. Say so; do not half-resume.
|
||||||
|
self.store.clear_partial()
|
||||||
|
self.restore_state = "fresh: checkpoint does not fit this averager"
|
||||||
|
return self.restore_state
|
||||||
|
self.restore_state = (
|
||||||
|
f"resumed the open block at {snap.get('elapsed', 0.0):.0f} s "
|
||||||
|
f"of {QUARTER_S} s ({snap.get('acc', 0.0) / QUARTER_S:.0f} W banked)")
|
||||||
|
return self.restore_state
|
||||||
|
|
||||||
|
# The block ended while the add-on was down. Its measured part really
|
||||||
|
# happened, so bank it at acc/900 rather than discard measured energy.
|
||||||
|
# It reads LOW because the seconds after the shutdown were never covered,
|
||||||
|
# and that is the honest figure: coverage a closed block did not get.
|
||||||
|
closed = _ClosedBlock(
|
||||||
|
start=datetime.fromtimestamp(block, timezone.utc),
|
||||||
|
offtake_avg_w=float(snap.get("acc", 0.0)) / QUARTER_S,
|
||||||
|
per_phase_offtake_avg_w=(tuple(v / QUARTER_S for v in snap["pp_acc"])
|
||||||
|
if snap.get("pp_acc") and snap.get("last_pp") else None))
|
||||||
|
banked = self.store.record_block(closed, self.policy)
|
||||||
|
self.store.clear_partial()
|
||||||
|
self.restore_state = (
|
||||||
|
f"closed a stale block from {closed.start.isoformat()} at "
|
||||||
|
f"{closed.offtake_avg_w:.0f} W (partial coverage), "
|
||||||
|
+ ("banked" if banked else "NOT banked - store unwritable"))
|
||||||
|
return self.restore_state
|
||||||
|
|
||||||
|
# -- steady state -------------------------------------------------------
|
||||||
|
def sync(self, ingest, now_mono: float | None = None) -> int:
|
||||||
|
"""Bank any blocks that closed, and refresh the checkpoint. Returns the
|
||||||
|
number of blocks newly recorded on this call."""
|
||||||
|
import time as _t
|
||||||
|
n = 0
|
||||||
|
for block in getattr(ingest, "blocks", ()):
|
||||||
|
key = block.start.timestamp()
|
||||||
|
if key in self._seen:
|
||||||
|
continue
|
||||||
|
if self.store.record_block(block, self.policy):
|
||||||
|
self._seen.add(key)
|
||||||
|
self.recorded += 1
|
||||||
|
n += 1
|
||||||
|
if n:
|
||||||
|
# A block just closed, so the checkpoint's block is gone: clearing it
|
||||||
|
# in the same breath is what stops a crash here from replaying it.
|
||||||
|
self.store.clear_partial()
|
||||||
|
self._last_ckpt = 0.0
|
||||||
|
self.checkpoint(ingest, now_mono or _t.monotonic())
|
||||||
|
return n
|
||||||
|
|
||||||
|
def checkpoint(self, ingest, now_mono: float, force: bool = False) -> bool:
|
||||||
|
if not force and (now_mono - self._last_ckpt) < self.checkpoint_s:
|
||||||
|
return False
|
||||||
|
avg = getattr(ingest, "averager", None)
|
||||||
|
if avg is None:
|
||||||
|
return False
|
||||||
|
self._last_ckpt = now_mono
|
||||||
|
return self.store.save_partial(avg.snapshot())
|
||||||
|
|
||||||
|
# -- reading ------------------------------------------------------------
|
||||||
|
def peak(self, when: datetime | None = None) -> PeakReading:
|
||||||
|
return self.store.peak(when)
|
||||||
|
|
||||||
|
def headroom_w(self, when: datetime | None = None) -> float | None:
|
||||||
|
"""Watts left under the soft limit, or None when it cannot be known.
|
||||||
|
|
||||||
|
⚠️ `None`, not `self.cap_w`. An unreadable peak returning full headroom
|
||||||
|
is the disarming default this whole module is shaped against.
|
||||||
|
"""
|
||||||
|
r = self.peak(when)
|
||||||
|
if r.state == NO_RECORD:
|
||||||
|
return self.cap_w
|
||||||
|
if not r.is_observed:
|
||||||
|
return None
|
||||||
|
return max(0.0, self.cap_w - r.peak_w)
|
||||||
|
|
||||||
|
def warning_frac(self, when: datetime | None = None) -> float | None:
|
||||||
|
"""Peak as a fraction of the soft limit - §9.1's 80 % warning entity."""
|
||||||
|
r = self.peak(when)
|
||||||
|
if r.state == NO_RECORD:
|
||||||
|
return 0.0
|
||||||
|
if not r.is_observed or self.cap_w <= 0:
|
||||||
|
return None
|
||||||
|
return r.peak_w / self.cap_w
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def project(averager, now: datetime) -> Projection:
|
||||||
|
"""End-of-quarter average if the last sample held for the rest of it.
|
||||||
|
|
||||||
|
⚠️ The two divisors are DIFFERENT and only coincide at the end of the
|
||||||
|
block. `elapsed_w` divides by the seconds actually covered (what the
|
||||||
|
meter has shown so far); `projected_w` divides by the full 900 s and
|
||||||
|
credits the held value over what remains. At `elapsed_s == 900` and full
|
||||||
|
coverage they are equal - which is precisely why nothing may be tested
|
||||||
|
only at that point.
|
||||||
|
"""
|
||||||
|
start = averager.block_start
|
||||||
|
if start is None:
|
||||||
|
return Projection(0.0, 0.0, 0.0, 0.0, False, None)
|
||||||
|
elapsed_wall = max(0.0, (now - start).total_seconds())
|
||||||
|
elapsed = averager.elapsed_s
|
||||||
|
acc = averager.partial_ws
|
||||||
|
remaining = max(0.0, QUARTER_S - elapsed_wall)
|
||||||
|
held = max(getattr(averager, "_last_net", 0.0), 0.0)
|
||||||
|
return Projection(
|
||||||
|
projected_w=(acc + held * remaining) / QUARTER_S,
|
||||||
|
elapsed_w=(acc / elapsed) if elapsed > 0 else 0.0,
|
||||||
|
elapsed_s=elapsed_wall,
|
||||||
|
covered_frac=(elapsed / elapsed_wall) if elapsed_wall > 0 else 0.0,
|
||||||
|
complete=elapsed_wall <= 0 or elapsed >= elapsed_wall - 1e-9,
|
||||||
|
block_start=start)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ClosedBlock:
|
||||||
|
"""A block reconstructed from a checkpoint. Shaped like `p1.QuarterBlock` so
|
||||||
|
`record_block` has one input type; kept private so nothing else makes one."""
|
||||||
|
|
||||||
|
start: datetime
|
||||||
|
offtake_avg_w: float
|
||||||
|
per_phase_offtake_avg_w: tuple | None
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
name: GoodWe RS485 Controller
|
name: GoodWe RS485 Controller
|
||||||
version: "0.5.1"
|
version: "0.5.2"
|
||||||
slug: goodwe_controller
|
slug: goodwe_controller
|
||||||
description: >-
|
description: >-
|
||||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||||
@@ -114,6 +114,26 @@ options:
|
|||||||
sign_disagree_w: 300
|
sign_disagree_w: 300
|
||||||
sign_disagree_s: 180
|
sign_disagree_s: 180
|
||||||
|
|
||||||
|
# --- SAFETY-18 frozen-but-fresh meter detector -----------------------------
|
||||||
|
# Seconds the meter may keep answering with an UNCHANGED measurement while the
|
||||||
|
# command stands at the rail before the add-on raises an alarm. Both halves
|
||||||
|
# are required: neither one alone is safe, and app/freeze.py carries why.
|
||||||
|
#
|
||||||
|
# ALARM ONLY. It does not command 0 W and does not latch. A false trip parks
|
||||||
|
# the battery; a false notification costs an alert.
|
||||||
|
#
|
||||||
|
# ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the
|
||||||
|
# only transport that reads the meter's own document. On every other source
|
||||||
|
# the status page says NOT COVERED rather than "armed".
|
||||||
|
#
|
||||||
|
# 300 s is measured, not chosen. Over 2399 s of the live house at the
|
||||||
|
# converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean
|
||||||
|
# grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the
|
||||||
|
# LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any
|
||||||
|
# sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and
|
||||||
|
# still inside the 900 s quarter that bills the capacity tariff.
|
||||||
|
meter_freeze_s: 300
|
||||||
|
|
||||||
# --- maintenance -----------------------------------------------------------
|
# --- maintenance -----------------------------------------------------------
|
||||||
maintenance_enabled: false
|
maintenance_enabled: false
|
||||||
maintenance_interval_days: 28
|
maintenance_interval_days: 28
|
||||||
@@ -196,6 +216,15 @@ schema:
|
|||||||
sign_disagree_w: int(50,5000)
|
sign_disagree_w: int(50,5000)
|
||||||
sign_disagree_s: int(30,1800)
|
sign_disagree_s: int(30,1800)
|
||||||
|
|
||||||
|
# No "off" value, for SAFETY-02's reason: disarming a safety alarm is not a
|
||||||
|
# supported configuration. ⚠️ THE FLOOR OF 60 IS NOT A RECOMMENDATION. At 300 s
|
||||||
|
# the slowest a HEALTHY meter's 1 Wh register can tick and still look unchanged
|
||||||
|
# is 12 W, which is inside the 15 W deadband, so the two terms of the
|
||||||
|
# conjunction are mutually exclusive rather than merely far apart. At 60 s that
|
||||||
|
# figure is 60 W and the argument is gone - only the margin is left. See
|
||||||
|
# app/freeze.py.
|
||||||
|
meter_freeze_s: int(60,3600)
|
||||||
|
|
||||||
maintenance_enabled: bool
|
maintenance_enabled: bool
|
||||||
maintenance_interval_days: int(1,90)
|
maintenance_interval_days: int(1,90)
|
||||||
maintenance_start_hour: int(0,23)
|
maintenance_start_hour: int(0,23)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,550 @@
|
|||||||
|
"""Runnable check for SAFETY-03, the output clamp and its winding-loop alarm.
|
||||||
|
|
||||||
|
python3 test_clamp.py
|
||||||
|
|
||||||
|
No framework, asserts only, same house style as test_freeze.py.
|
||||||
|
|
||||||
|
Five parts, and parts 3 and 4 are the ones that carry the acceptance criteria:
|
||||||
|
|
||||||
|
1. THE CLAMP ARITHMETIC, on the shipped `clamp_to_rating()` - boundaries,
|
||||||
|
symmetry, the device second stage, and the non-finite cases. Table driven.
|
||||||
|
2. THE ALARM'S CADENCE - rising edge, re-assert, hysteresis - on the shipped
|
||||||
|
`ClampWatchdog`. Every engagement counted, notifications not flooded.
|
||||||
|
3. AC 1 + AC 3, WIRED. A computed setpoint that exceeds the rating, driven
|
||||||
|
through the REAL `Controller.cycle()`: nothing over the rating reaches the
|
||||||
|
device, and the alarm fires. Includes the historical runaway from
|
||||||
|
specs.md 10.3 replayed against an unclamped transcription, so the check
|
||||||
|
says what the clamp is actually worth in watts rather than asserting a
|
||||||
|
constant.
|
||||||
|
4. AC 2, AND IT IS THE LOAD-BEARING HALF. A loop that SATURATES WITHOUT
|
||||||
|
CLAMPING - the inverter refusing to follow a perfectly legal command - must
|
||||||
|
raise NO clamp alarm. If it does, the bug signal is inside the noise again
|
||||||
|
and the whole ticket has bought nothing.
|
||||||
|
5. NO PATH BYPASSES IT. write_setpoint() is the only route to the wire, so it
|
||||||
|
is called directly with an out-of-range value the way the heartbeat and the
|
||||||
|
operator's buttons call it.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
WHY PART 4 ALSO ASSERTS ON `Decision`'s BOOLEANS AND NOT ON `reason`
|
||||||
|
|
||||||
|
`Decision.reason` is one string and the stages after the clamp overwrite it. The
|
||||||
|
check below drives a cycle that clamps the integrator AND is then slew limited,
|
||||||
|
and asserts that `reason` does NOT say so while `i_clamped` does. That is not a
|
||||||
|
style preference - an alarm keyed on the string is silent on exactly the cycles a
|
||||||
|
winding loop produces, and this is the check that would go red if someone
|
||||||
|
"simplified" main.py back onto it.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
WHAT THIS CHECK DOES NOT COVER
|
||||||
|
|
||||||
|
- The firmware-side magnitude clamp (the card's "two clamps, not one"). This
|
||||||
|
add-on's `write_setpoint()` is the add-on half; the ESP32 half is DRIVER-07's
|
||||||
|
and is not in this repo's Python.
|
||||||
|
- A rig run. Nothing here needs one: the clamp is arithmetic on the write path
|
||||||
|
and part 3 drives the shipped `Controller.cycle()` against a stub Home
|
||||||
|
Assistant, which is the same surface test_freeze.py part 1-2 use. The wire-
|
||||||
|
level invariant the card asks for (`|emitted| <= rating` on every frame) is
|
||||||
|
asserted here over every value that reached `set_number()`, which is the last
|
||||||
|
thing this process controls; proving it at the inverter needs the witness log
|
||||||
|
and belongs with DRIVER-07.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.clamp import (
|
||||||
|
CLEAR_CYCLES, REASON_CODE, REASSERT_S, ClampWatchdog, clamp_to_rating,
|
||||||
|
is_commandable,
|
||||||
|
)
|
||||||
|
from app.control import Tuning, compute
|
||||||
|
from app.main import Controller
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
T0 = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# The live install's numbers. NOTES.md and freeze.py both say max_w: 5000 there,
|
||||||
|
# which is also the device rating, so this is the bound the alarm is written at.
|
||||||
|
LIVE_MAX_W = 5000.0
|
||||||
|
CLAMP_ID = "goodwe_output_clamp"
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print("1. the clamp arithmetic, on the shipped expression")
|
||||||
|
|
||||||
|
# (requested, limit, expected applied, expected engaged, expected bound)
|
||||||
|
TABLE = [
|
||||||
|
(5001.0, 5000.0, 5000.0, True, "max_w"), # +limit+1
|
||||||
|
(-5001.0, 5000.0, -5000.0, True, "max_w"), # -limit-1, the export side
|
||||||
|
(5000.0, 5000.0, 5000.0, False, ""), # exactly +limit: NOT engaged
|
||||||
|
(-5000.0, 5000.0, -5000.0, False, ""), # exactly -limit: NOT engaged
|
||||||
|
(0.0, 5000.0, 0.0, False, ""), # the failsafe value
|
||||||
|
(1234.0, 5000.0, 1234.0, False, ""),
|
||||||
|
(14768.0, 5000.0, 5000.0, True, "max_w"), # 10.3's reported runaway
|
||||||
|
]
|
||||||
|
for requested, limit, want_applied, want_engaged, want_bound in TABLE:
|
||||||
|
r = clamp_to_rating(requested, limit)
|
||||||
|
check(f"{requested:+.0f} W against a {limit:.0f} W rating -> "
|
||||||
|
f"{r.applied_w:+.0f} W, engaged={r.engaged}, bound={r.bound!r}",
|
||||||
|
r.applied_w == want_applied and r.engaged is want_engaged
|
||||||
|
and r.bound == want_bound)
|
||||||
|
|
||||||
|
# It is a MAGNITUDE clamp (3.2), so import and export must be bound identically.
|
||||||
|
# Derived over a sweep rather than asserted on two hand-picked values.
|
||||||
|
asym = [m for m in range(0, 12001, 37)
|
||||||
|
if clamp_to_rating(m, LIVE_MAX_W).applied_w
|
||||||
|
!= -clamp_to_rating(-m, LIVE_MAX_W).applied_w]
|
||||||
|
check(f"symmetric over {len(range(0, 12001, 37))} magnitudes "
|
||||||
|
f"({len(asym)} asymmetric)", not asym)
|
||||||
|
|
||||||
|
# The engagement flag has to mean "the RAW value differed", which is the words
|
||||||
|
# AC 2 uses. Re-derived from the result's own fields, not from the table.
|
||||||
|
mismatch = [m for m in range(0, 12001, 37)
|
||||||
|
for r in [clamp_to_rating(m, LIVE_MAX_W)]
|
||||||
|
if r.engaged != (r.requested_w != r.applied_w)]
|
||||||
|
check("engaged is exactly 'raw differs from applied', over the same sweep",
|
||||||
|
not mismatch)
|
||||||
|
|
||||||
|
# The device second stage. Only reachable when max_w is ABOVE the device range,
|
||||||
|
# i.e. when validate_startup_config() did not run or no longer holds.
|
||||||
|
r = clamp_to_rating(3000.0, 5000.0, dev_min=-2000.0, dev_max=2000.0)
|
||||||
|
check(f"a device narrower than max_w still bites, and says which bound "
|
||||||
|
f"({r.applied_w:+.0f} W, {r.bound!r})",
|
||||||
|
r.applied_w == 2000.0 and r.engaged and r.bound == "device")
|
||||||
|
r = clamp_to_rating(3000.0, 5000.0, dev_min=-5000.0, dev_max=5000.0)
|
||||||
|
check("...and is a no-op once the startup validation holds",
|
||||||
|
r.applied_w == 3000.0 and not r.engaged)
|
||||||
|
|
||||||
|
# ⚠️ REGRESSION, REVIEW-FOUND. THE FIRST STAGE USED TO `return` AND THE DEVICE
|
||||||
|
# STAGE WAS THEN NEVER REACHED, so the harder the overshoot the less the guard
|
||||||
|
# did: at max_w 5000 against a +-4000 device, +9999 emitted +5000 - OUTSIDE THE
|
||||||
|
# DEVICE RANGE - while a mere +4500 was correctly held to +4000. That inverts
|
||||||
|
# the guard and it was a regression against the pre-PR `write_setpoint()`, whose
|
||||||
|
# comment read "never send a value the device will reject outright".
|
||||||
|
#
|
||||||
|
# The property is asserted over a sweep, not on the two witness values, because
|
||||||
|
# the defect was invisible to every single-value check that happened to sit
|
||||||
|
# inside one stage.
|
||||||
|
DEV_LO, DEV_HI = -4000.0, 4000.0
|
||||||
|
escapes = [(m, clamp_to_rating(m, 5000.0, DEV_LO, DEV_HI).applied_w)
|
||||||
|
for m in range(-15000, 15001, 61)
|
||||||
|
for a in [clamp_to_rating(m, 5000.0, DEV_LO, DEV_HI).applied_w]
|
||||||
|
if not (DEV_LO <= a <= DEV_HI)]
|
||||||
|
big = clamp_to_rating(9999.0, 5000.0, DEV_LO, DEV_HI)
|
||||||
|
small = clamp_to_rating(4500.0, 5000.0, DEV_LO, DEV_HI)
|
||||||
|
print(f" max_w 5000 vs a device of {DEV_LO:+.0f}..{DEV_HI:+.0f}: "
|
||||||
|
f"+9999 -> {big.applied_w:+.0f} W ({big.bound}), "
|
||||||
|
f"+4500 -> {small.applied_w:+.0f} W ({small.bound})")
|
||||||
|
check(f"BOTH stages always run - {big.requested_w:+.0f} W emits "
|
||||||
|
f"{big.applied_w:+.0f} W, inside the device's {DEV_HI:+.0f} W, and names "
|
||||||
|
f"both bounds ({big.bound!r})",
|
||||||
|
big.applied_w == DEV_HI and big.bound == "max_w+device")
|
||||||
|
check(f"...and it holds over the whole range, so no overshoot escapes the "
|
||||||
|
f"device ({len(escapes)} escapes in {len(range(-15000, 15001, 61))})",
|
||||||
|
not escapes)
|
||||||
|
check("...a big overshoot is not clamped LESS than a small one",
|
||||||
|
abs(big.applied_w) <= abs(small.applied_w) + 0.0)
|
||||||
|
|
||||||
|
print(" non-finite input is loss of input, not an out-of-range value")
|
||||||
|
for bad in (None, float("nan"), float("inf"), float("-inf"), "abc", object()):
|
||||||
|
check(f"is_commandable({bad!r:.30}) is False", is_commandable(bad) is False)
|
||||||
|
for good in (0, -5000.0, 5000, 12.5):
|
||||||
|
check(f"is_commandable({good!r}) is True", is_commandable(good) is True)
|
||||||
|
|
||||||
|
# ⚠️ WHY THAT GUARD EXISTS, AND THE ANSWER IS WORSE THAN "it passes a nan
|
||||||
|
# through". MEASURED HERE, not recalled: every comparison against a nan is
|
||||||
|
# False, so `min(limit, nan)` keeps its FIRST argument and a naive clamp turns a
|
||||||
|
# nan into a command at the FULL RATING - and then reports engaged=True, because
|
||||||
|
# limit != nan, so the winding-loop counter gets a transport bug filed in it.
|
||||||
|
# The number below is computed by the same expression the guard replaces.
|
||||||
|
nan = float("nan")
|
||||||
|
naive = max(-LIVE_MAX_W, min(LIVE_MAX_W, nan))
|
||||||
|
check(f"a naive clamp turns a nan into {naive:+,.0f} W and calls it "
|
||||||
|
f"engaged={naive != nan} - hence the guard",
|
||||||
|
naive == LIVE_MAX_W and (naive != nan) is True)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("2. the alarm's cadence: every engagement counted, no flood")
|
||||||
|
|
||||||
|
wd = ClampWatchdog()
|
||||||
|
t = T0
|
||||||
|
fired = [wd.note(True, "winding", t + timedelta(seconds=i)) for i in range(10)]
|
||||||
|
check(f"the rising edge fires ({fired[0]})", fired[0] is True)
|
||||||
|
check(f"the next 9 one-second cycles fire {sum(fired[1:])} more times "
|
||||||
|
f"(re-assert is {REASSERT_S:.0f} s)", sum(fired[1:]) == 0)
|
||||||
|
check(f"but every one of them is counted: engagements={wd.engagements}",
|
||||||
|
wd.engagements == 10)
|
||||||
|
check(f"and it is one episode, not ten: episodes={wd.episodes}",
|
||||||
|
wd.episodes == 1)
|
||||||
|
|
||||||
|
# --- THE RE-ASSERT CADENCE, PINNED IN BOTH DIRECTIONS ------------------------
|
||||||
|
# ⚠️ REVIEW-FOUND. This used to read `wd.note(True, ..., T0 + REASSERT_S + 9)`,
|
||||||
|
# which is green for ANY value of the constant - REASSERT_S = 3600 passed, and an
|
||||||
|
# hour of silence is precisely what the module promises not to do. A check whose
|
||||||
|
# stimulus is written in terms of the constant it is grading cannot grade it.
|
||||||
|
#
|
||||||
|
# The requirements are stated in wall-clock seconds and derived from what the
|
||||||
|
# alarm is FOR, so they do not move when the constant does:
|
||||||
|
#
|
||||||
|
# floor - a loop pinned on the rail must speak at least MIN_PER_QUARTER times
|
||||||
|
# inside the 900 s quarter that bills the capacity tariff. That window
|
||||||
|
# is the same one freeze.py and DOCS.md reason about.
|
||||||
|
# ceiling - it must not speak on more than MAX_FRACTION of cycles, or it is
|
||||||
|
# the per-cycle flood the split exists to remove.
|
||||||
|
QUARTER_S = 900.0
|
||||||
|
MIN_PER_QUARTER = 5
|
||||||
|
MAX_FRACTION = 0.20
|
||||||
|
|
||||||
|
pinned = ClampWatchdog()
|
||||||
|
alarms = sum(1 for i in range(int(QUARTER_S))
|
||||||
|
if pinned.note(True, "pinned", T0 + timedelta(seconds=i)))
|
||||||
|
print(f" pinned on the rail for {QUARTER_S:.0f} s at 1 s cycles: "
|
||||||
|
f"{pinned.engagements} engagements, {alarms} alarm(s) "
|
||||||
|
f"({alarms / QUARTER_S:.1%} of cycles)")
|
||||||
|
check(f"a loop pinned for a whole billing quarter speaks at least "
|
||||||
|
f"{MIN_PER_QUARTER} times (it spoke {alarms})",
|
||||||
|
alarms >= MIN_PER_QUARTER)
|
||||||
|
check(f"...and on no more than {MAX_FRACTION:.0%} of cycles (it was "
|
||||||
|
f"{alarms / QUARTER_S:.1%})", alarms <= MAX_FRACTION * QUARTER_S)
|
||||||
|
check("...while every single cycle is still counted",
|
||||||
|
pinned.engagements == int(QUARTER_S))
|
||||||
|
|
||||||
|
# --- THE HYSTERESIS, PINNED AGAINST THE CHATTER DIRECTION --------------------
|
||||||
|
# ⚠️ REVIEW-FOUND. This used to issue `range(CLEAR_CYCLES - 1)` clean cycles,
|
||||||
|
# which at CLEAR_CYCLES = 1 issues ZERO of them and passes while printing
|
||||||
|
# "0 clean cycles do NOT clear it". Green for every value >= 1 - including 1,
|
||||||
|
# which IS the chatter the constant exists to prevent. The stimulus is a literal
|
||||||
|
# now: ONE clean cycle must not clear the alarm, whatever the constant says.
|
||||||
|
wd2 = ClampWatchdog()
|
||||||
|
wd2.note(True, "winding", T0)
|
||||||
|
wd2.note(False, "", T0 + timedelta(seconds=1))
|
||||||
|
check(f"ONE clean cycle does NOT clear the alarm (active={wd2.active}) - "
|
||||||
|
f"a command dithering across the rail must not chatter",
|
||||||
|
wd2.active is True)
|
||||||
|
survived = 1
|
||||||
|
while wd2.active and survived < 50:
|
||||||
|
wd2.note(False, "", T0 + timedelta(seconds=1 + survived))
|
||||||
|
survived += 1
|
||||||
|
print(f" it took {survived} consecutive clean cycles to de-assert "
|
||||||
|
f"(CLEAR_CYCLES = {CLEAR_CYCLES})")
|
||||||
|
check(f"...and it does de-assert, after {survived} of them, rather than "
|
||||||
|
f"latching", wd2.active is False and survived == CLEAR_CYCLES)
|
||||||
|
check("...and the engagement count survives the clear, so the evidence does",
|
||||||
|
wd2.engagements == 1)
|
||||||
|
check("a clean cycle on an idle watchdog says nothing",
|
||||||
|
wd2.note(False, "", T0 + timedelta(seconds=300)) is False)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("3. AC 1 + AC 3, wired: over the rating both clamps AND alarms")
|
||||||
|
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
def set(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_time(self, *a):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _Pub:
|
||||||
|
enabled = False
|
||||||
|
|
||||||
|
def publish(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _Hass:
|
||||||
|
"""Stub HA. `track` False models an inverter that is switched off entirely,
|
||||||
|
which is the 10.3 runaway's own setup."""
|
||||||
|
|
||||||
|
def __init__(self, grid, batt=0.0, track=True):
|
||||||
|
self.grid = grid
|
||||||
|
self.batt = batt
|
||||||
|
self.track = track
|
||||||
|
self.calls = []
|
||||||
|
self.writes = []
|
||||||
|
|
||||||
|
async def limits(self, entity):
|
||||||
|
return (-LIVE_MAX_W, LIVE_MAX_W, 1.0)
|
||||||
|
|
||||||
|
async def number(self, entity, invert=False):
|
||||||
|
return {"sensor.grid": self.grid, "sensor.soc": 50.0,
|
||||||
|
"sensor.batt": self.batt}.get(entity)
|
||||||
|
|
||||||
|
async def set_number(self, entity, value):
|
||||||
|
self.writes.append(float(value))
|
||||||
|
if self.track:
|
||||||
|
self.batt = float(value)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def call(self, domain, service, data):
|
||||||
|
self.calls.append((domain, service, data))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def wired(grid, cycles=40, batt=0.0, track=True, **over):
|
||||||
|
async def go():
|
||||||
|
hass = _Hass(grid, batt=batt, track=track)
|
||||||
|
c = Controller(dict(
|
||||||
|
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||||
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||||
|
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
|
||||||
|
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
|
||||||
|
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15},
|
||||||
|
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||||
|
c.auto = True
|
||||||
|
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||||
|
# The red status row exists only WHILE the clamp is engaged, and the
|
||||||
|
# hysteresis clears it a few cycles later - so it is captured live
|
||||||
|
# rather than looked for after the run, when it is correctly gone.
|
||||||
|
c.red_snapshot = []
|
||||||
|
for _ in range(cycles):
|
||||||
|
await c.read_inputs()
|
||||||
|
await c.cycle()
|
||||||
|
if c.clamp_wd.active and not c.red_snapshot:
|
||||||
|
c.red_snapshot = c.checks()
|
||||||
|
return hass, c
|
||||||
|
return asyncio.run(go())
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_notes(hass):
|
||||||
|
return [d for dom, svc, d in hass.calls
|
||||||
|
if (dom, svc) == ("persistent_notification", "create")
|
||||||
|
and d.get("notification_id") == CLAMP_ID]
|
||||||
|
|
||||||
|
|
||||||
|
# THE 10.3 RUNAWAY, replayed. "With the inverter switched off entirely, an
|
||||||
|
# unclamped integrator climbed ~130 W per 4 s to over 10 kW and, pinned at the
|
||||||
|
# 5 kW rail, reported 14 768 W." `track=False` is the inverter switched off: it
|
||||||
|
# reports 0 W however hard it is commanded, so the error never closes.
|
||||||
|
hass_r, ctl_r = wired(6000.0, cycles=60, track=False)
|
||||||
|
emitted = [abs(w) for w in hass_r.writes]
|
||||||
|
|
||||||
|
# What the SAME error would have done to an accumulator with no bound - the
|
||||||
|
# transcription is deliberately the unclamped velocity form 10.3 describes, so
|
||||||
|
# the figure below is computed here and not quoted from the spec.
|
||||||
|
runaway, err = 0.0, 6000.0
|
||||||
|
for _ in range(60):
|
||||||
|
runaway += 0.6 * err
|
||||||
|
print(f" unclamped, the same 60 cycles reach {runaway:,.0f} W; "
|
||||||
|
f"the largest value that reached the device is {max(emitted):,.0f} W")
|
||||||
|
check(f"an unbounded accumulator really does run away here "
|
||||||
|
f"({runaway:,.0f} W >> {LIVE_MAX_W:.0f} W)", runaway > 3 * LIVE_MAX_W)
|
||||||
|
check(f"AC 1: NOT ONE of the {len(emitted)} values that reached the device "
|
||||||
|
f"exceeds the {LIVE_MAX_W:.0f} W rating",
|
||||||
|
emitted and max(emitted) <= LIVE_MAX_W)
|
||||||
|
# ⚠️ AND THE CLAMP IS NOT THE ONLY THING HOLDING THAT LINE HERE, which is worth
|
||||||
|
# saying out loud rather than letting the number above imply otherwise. Against
|
||||||
|
# an inverter reporting 0 W the saturation freeze engages after
|
||||||
|
# `saturation_cycles` and pins the command well BELOW the rail - that is why the
|
||||||
|
# largest emitted value is what it is and not 5000 W. The clamp is what makes it
|
||||||
|
# bounded; the freeze is what makes it small. A check that read
|
||||||
|
# `max(emitted) == max_w` would have been asserting the freeze, not the clamp.
|
||||||
|
check(f"...and the saturation freeze is what pins it at {max(emitted):,.0f} W "
|
||||||
|
f"rather than the rail", ctl_r.sat_count >= ctl_r.tuning.saturation_cycles)
|
||||||
|
|
||||||
|
notes_r = clamp_notes(hass_r)
|
||||||
|
print(f" the loop clamped on {ctl_r.clamp_wd.engagements} of {60} cycles, "
|
||||||
|
f"in {ctl_r.clamp_wd.episodes} episode(s), raising {len(notes_r)} "
|
||||||
|
f"notification(s)")
|
||||||
|
check("AC 3: and it ALARMED - silently clipping this is the defect",
|
||||||
|
ctl_r.clamp_wd.engagements > 0 and len(notes_r) >= 1)
|
||||||
|
check("...with its own notification_id, not shared with any other guard",
|
||||||
|
all(d["notification_id"] == CLAMP_ID for d in notes_r))
|
||||||
|
check("...one episode, re-asserted rather than one per cycle",
|
||||||
|
ctl_r.clamp_wd.episodes == 1 and len(notes_r) < ctl_r.clamp_wd.engagements)
|
||||||
|
check("...and it names itself a bug signal, not a limit in use",
|
||||||
|
bool(notes_r) and "out of range" in notes_r[0]["message"])
|
||||||
|
rows_r = [e for e in ctl_r.events if REASON_CODE in e]
|
||||||
|
check(f"the action log records every engagement, coalesced into one row per "
|
||||||
|
f"episode ({len(rows_r)} row(s), {ctl_r.clamp_wd.engagements} "
|
||||||
|
f"engagement(s))",
|
||||||
|
len(rows_r) == ctl_r.clamp_wd.episodes
|
||||||
|
and f"{ctl_r.clamp_wd.engagements} engagement(s)" in rows_r[0])
|
||||||
|
|
||||||
|
# ⚠️ REGRESSION, AND IT WAS A REAL BUG BEFORE IT WAS A CHECK. `log_event()` keeps
|
||||||
|
# 40 rows. A row per engagement flushes that ring inside 40 cycles of a winding
|
||||||
|
# loop, and the first thing it evicted was SAFETY-18's own "METER FROZEN?" row -
|
||||||
|
# i.e. this guard silencing another guard's log. Driven here with a plant that
|
||||||
|
# TRACKS the command, so the saturation freeze never engages and the integrator
|
||||||
|
# clamps on essentially every cycle.
|
||||||
|
hass_l, ctl_l = wired(9000.0, cycles=80, track=True)
|
||||||
|
rows_l = [e for e in ctl_l.events if REASON_CODE in e]
|
||||||
|
other_l = [e for e in ctl_l.events if REASON_CODE not in e]
|
||||||
|
print(f" 80 winding cycles: {ctl_l.clamp_wd.engagements} engagement(s) -> "
|
||||||
|
f"{len(rows_l)} log row(s), leaving room for {len(other_l)} other event(s)")
|
||||||
|
check(f"a long winding episode does NOT flood the 40-row event log "
|
||||||
|
f"({len(rows_l)} rows for {ctl_l.clamp_wd.engagements} engagements)",
|
||||||
|
ctl_l.clamp_wd.engagements > 40 and len(rows_l) <= ctl_l.clamp_wd.episodes)
|
||||||
|
check("...and the count in the row is the counter's, so nothing is lost",
|
||||||
|
f"{ctl_l.clamp_wd.engagements} engagement(s)" in rows_l[-1])
|
||||||
|
# The RED row exists only while engaged, and the episode above is short, so it
|
||||||
|
# is captured mid-run rather than asserted after the hysteresis has cleared it.
|
||||||
|
check("the status page goes red WHILE it is engaged",
|
||||||
|
any(not c["ok"] and not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
|
||||||
|
for c in ctl_r.red_snapshot))
|
||||||
|
check("...and once clear, a warn row still says how many times it engaged",
|
||||||
|
any(c["warn"] and f"{ctl_r.clamp_wd.engagements} time(s)" in c["text"]
|
||||||
|
for c in ctl_r.checks()))
|
||||||
|
|
||||||
|
# ⚠️ NOT A TRIP. 11.2's action is "clamp and alarm"; the loop keeps running and
|
||||||
|
# keeps writing the clamped value. If a later cut changes that it must come here.
|
||||||
|
check(f"it is NOT a trip - the command stands at {ctl_r.target:+.0f} W, not 0 W",
|
||||||
|
ctl_r.target != 0.0 and ctl_r.target == hass_r.writes[-1])
|
||||||
|
check("...and the controller still reports itself running, not failsafe",
|
||||||
|
ctl_r.auto and not ctl_r.sign_wd.tripped)
|
||||||
|
|
||||||
|
# The export side, because the clamp is a magnitude clamp and a sign bug in it
|
||||||
|
# would be invisible on the import case alone.
|
||||||
|
hass_x, ctl_x = wired(-6000.0, cycles=60, track=False)
|
||||||
|
check(f"the export side clamps and alarms too "
|
||||||
|
f"({min(hass_x.writes):+,.0f} W, {len(clamp_notes(hass_x))} note(s), "
|
||||||
|
f"{ctl_x.clamp_wd.engagements} engagement(s))",
|
||||||
|
min(hass_x.writes) < 0 and abs(min(hass_x.writes)) <= LIVE_MAX_W
|
||||||
|
and clamp_notes(hass_x) and ctl_x.clamp_wd.engagements > 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("4. AC 2: saturation WITHOUT clamping must NOT raise a clamp alarm")
|
||||||
|
|
||||||
|
# A legal command the inverter will not follow. The meter sits just outside the
|
||||||
|
# deadband so the loop moves a little and then rests; `batt` is pinned far from
|
||||||
|
# whatever is commanded, which is the saturation condition (|cmd - actual| >
|
||||||
|
# saturation_w) and nothing else. The integrator never approaches the rating.
|
||||||
|
hass_s, ctl_s = wired(60.0, cycles=40, batt=2500.0, track=False)
|
||||||
|
peak = max(abs(w) for w in hass_s.writes)
|
||||||
|
print(f" saturated for {ctl_s.sat_count} consecutive cycles; the command "
|
||||||
|
f"peaked at {peak:.0f} W, {peak / LIVE_MAX_W:.1%} of the rating")
|
||||||
|
# ⚠️ THE POSITIVE HALF FIRST. A negative check that saw nothing happen is not an
|
||||||
|
# observation - if the saturation freeze never engaged, "no clamp alarm" is
|
||||||
|
# vacuous and this check is worthless.
|
||||||
|
check(f"the saturation freeze REALLY DID engage (sat_count="
|
||||||
|
f"{ctl_s.sat_count} >= {ctl_s.tuning.saturation_cycles})",
|
||||||
|
ctl_s.sat_count >= ctl_s.tuning.saturation_cycles)
|
||||||
|
check("...and the event log says so, in the saturation's own words",
|
||||||
|
any("saturation freeze" in e for e in ctl_s.events))
|
||||||
|
check(f"...while the command stayed well inside the rating "
|
||||||
|
f"({peak:.0f} W < {LIVE_MAX_W:.0f} W)", peak < LIVE_MAX_W)
|
||||||
|
# Now the negative, which is only worth anything because of the three above.
|
||||||
|
check("AC 2: NO clamp notification was raised", not clamp_notes(hass_s))
|
||||||
|
check("AC 2: the clamp counted ZERO engagements", ctl_s.clamp_wd.engagements == 0)
|
||||||
|
check("AC 2: no OUTPUT_CLAMP row in the action log",
|
||||||
|
not [e for e in ctl_s.events if REASON_CODE in e])
|
||||||
|
check("AC 2: and no clamp row on the status page",
|
||||||
|
not [c for c in ctl_s.checks() if "output clamp" in c["text"].lower()])
|
||||||
|
|
||||||
|
print(" ...and the two alarms do not share a notification_id")
|
||||||
|
ids_clamp = {d["notification_id"] for d in clamp_notes(hass_r)}
|
||||||
|
ids_other = {d.get("notification_id") for dom, svc, d in hass_r.calls
|
||||||
|
if svc == "create"} - ids_clamp
|
||||||
|
check(f"clamp ids {sorted(ids_clamp)} are disjoint from every other alarm's "
|
||||||
|
f"{sorted(x for x in ids_other if x)}", not (ids_clamp & ids_other))
|
||||||
|
|
||||||
|
print(" ...and the alarm cannot be keyed on Decision.reason")
|
||||||
|
# A cycle that clamps the integrator AND is then slew limited. `reason` reports
|
||||||
|
# the LAST stage, so a string-keyed alarm is silent on it; the boolean is not.
|
||||||
|
t = Tuning(max_w=2000.0, slew_w=100.0, gain=0.6)
|
||||||
|
d = compute(prev_w=1000.0, grid_w=6000.0, actual_w=1000.0, tuning=t, i_w=1900.0)
|
||||||
|
print(f" reason={d.reason!r} i_clamped={d.i_clamped} clamped={d.clamped}")
|
||||||
|
check("the integrator DID clamp on that cycle", d.i_clamped is True)
|
||||||
|
check("...but `reason` says 'slew-limited' - the clamp was overwritten "
|
||||||
|
"downstream, so a string-keyed alarm is SILENT here",
|
||||||
|
d.reason == "slew-limited" and "clamp" not in d.reason)
|
||||||
|
# And the same for the OUTPUT clamp, on a config that lets the integrator run
|
||||||
|
# above the rail - then both flags fire and `reason` still reports neither.
|
||||||
|
t2 = Tuning(max_w=2000.0, integrator_max_w=3000.0, slew_w=100.0, gain=0.6)
|
||||||
|
d2 = compute(prev_w=1000.0, grid_w=6000.0, actual_w=1000.0, tuning=t2, i_w=2900.0)
|
||||||
|
print(f" reason={d2.reason!r} i_clamped={d2.i_clamped} clamped={d2.clamped}")
|
||||||
|
check("both clamps engaged, and `reason` reports neither",
|
||||||
|
d2.i_clamped and d2.clamped and "clamp" not in d2.reason)
|
||||||
|
# ⚠️ HOW THE FREEZE AND THE CLAMP INTERACT, MEASURED RATHER THAN ASSUMED, AND
|
||||||
|
# THE MEASUREMENT REFUSED TWO GUESSES BEFORE THIS ONE. Written first as "a
|
||||||
|
# frozen cycle never clamps" (false), then as "only the i_w == 0 reseed
|
||||||
|
# exception clamps" (also false - at i_w == 0 with a matching actual the cycle
|
||||||
|
# is not saturated at all, so it is never frozen). What the sweep actually
|
||||||
|
# shows: a frozen cycle clamps exactly when the integrator REVERSES. control.py
|
||||||
|
# permits that on purpose - "it may fall, cross zero, or reverse outright" -
|
||||||
|
# because the alternative deadlocks the loop, and a reversal is a full swing
|
||||||
|
# that can clear the bound on the far side in one step.
|
||||||
|
#
|
||||||
|
# That matters for this ticket rather than being trivia: it is the one shape
|
||||||
|
# where the saturation freeze and a clamp engagement coincide, so it is exactly
|
||||||
|
# where an alarm that shared a channel with saturation would lose the clamp.
|
||||||
|
grid_sweep = (-9000.0, -3000.0, 3000.0, 9000.0)
|
||||||
|
grid = [(pp, g) for pp in (-1900.0, -500.0, 0.0, 500.0, 1900.0)
|
||||||
|
for g in grid_sweep]
|
||||||
|
runs = {(pp, g): compute(pp, g, 0.0, t, sat_count=5, i_w=pp) for pp, g in grid}
|
||||||
|
frozen = [k for k, d in runs.items() if d.frozen]
|
||||||
|
frozen_clamps = [k for k in frozen if runs[k].i_clamped]
|
||||||
|
# The property, stated independently of the control law's own branches: the
|
||||||
|
# correction pushes AGAINST the standing integrator, and the resulting swing
|
||||||
|
# clears the bound.
|
||||||
|
reversals = [(pp, g) for pp, g in frozen
|
||||||
|
if pp * g < 0 and abs(pp + t.gain * g) > t.max_w]
|
||||||
|
print(f" {len(frozen)} of {len(grid)} sweep cells are frozen; "
|
||||||
|
f"those that also clamp: {frozen_clamps}")
|
||||||
|
check(f"a frozen cycle CAN clamp, and only by reversing "
|
||||||
|
f"({len(frozen_clamps)} of {len(frozen)} frozen cells)",
|
||||||
|
frozen_clamps and frozen_clamps == reversals)
|
||||||
|
check("...and a frozen cycle pushing the way it already leans never clamps - "
|
||||||
|
"that is the freeze doing its job",
|
||||||
|
not [k for k in frozen if runs[k].i_clamped and k[0] * k[1] > 0])
|
||||||
|
check("a healthy cycle sets neither flag",
|
||||||
|
not any([compute(0.0, 100.0, 0.0, t).i_clamped,
|
||||||
|
compute(0.0, 100.0, 0.0, t).clamped]))
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("5. no path reaches the wire around the clamp")
|
||||||
|
|
||||||
|
|
||||||
|
async def _direct():
|
||||||
|
hass = _Hass(0.0)
|
||||||
|
c = Controller({"setpoint_entity": "number.sp", "max_w": 2000,
|
||||||
|
"meter_source": "off"},
|
||||||
|
hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||||
|
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
|
||||||
|
# The shape the heartbeat, shutdown() and handle_action() all use.
|
||||||
|
await c.write_setpoint(9999.0, force=True)
|
||||||
|
await c.write_setpoint(-9999.0, force=True)
|
||||||
|
await c.write_setpoint(float("nan"), force=True)
|
||||||
|
await c.write_setpoint(1500.0, force=True)
|
||||||
|
return hass, c
|
||||||
|
|
||||||
|
|
||||||
|
hass_d, ctl_d = asyncio.run(_direct())
|
||||||
|
print(f" asked for 9999 / -9999 / nan / 1500 W; the device saw "
|
||||||
|
f"{hass_d.writes}")
|
||||||
|
check("a direct out-of-range write is clamped, not passed through",
|
||||||
|
hass_d.writes[:2] == [2000.0, -2000.0])
|
||||||
|
check("a nan is refused and becomes 0 W, per 11.1 rule 1",
|
||||||
|
hass_d.writes[2] == 0.0)
|
||||||
|
check("...and is NOT counted as a clamp engagement - it is a transport bug",
|
||||||
|
sum(1 for e in ctl_d.events if REASON_CODE in e) == 2)
|
||||||
|
check("an in-range value is untouched", hass_d.writes[3] == 1500.0)
|
||||||
|
check("every emitted frame satisfies |emitted| <= the rating",
|
||||||
|
all(abs(w) <= 2000.0 for w in hass_d.writes))
|
||||||
|
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("all checks passed")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,910 @@
|
|||||||
|
"""Runnable check for TARIFF-01, the capacity-tariff measurement.
|
||||||
|
|
||||||
|
python3 test_tariff.py
|
||||||
|
|
||||||
|
Ten parts. What each is for, and - where it matters - what it is deliberately
|
||||||
|
NOT allowed to be tested at:
|
||||||
|
|
||||||
|
1. THE BLOCK ARITHMETIC, against a worked example written out segment by
|
||||||
|
segment. The fixture is chosen to be non-degenerate on three axes at once:
|
||||||
|
irregular sample spacing, a sample that STRADDLES the :15 boundary, and a
|
||||||
|
gap longer than `max_hold_s` so the block's coverage is short of 900 s.
|
||||||
|
2. THE 900 s DEGENERACY, named and then avoided. `acc/elapsed`, `acc/900` and
|
||||||
|
the end-of-quarter projection are THREE DIFFERENT FORMULAS that all agree
|
||||||
|
at exactly full coverage of a complete block. A check run only there proves
|
||||||
|
nothing about any of them, so part 2 first shows them agreeing at that
|
||||||
|
point (so the trap is documented rather than asserted around), then shows
|
||||||
|
part 1's fixture separating all three - and every later part uses fixtures
|
||||||
|
that are away from it.
|
||||||
|
3. RESTART MID-BLOCK, through the real `QuarterAverager.snapshot()`/`restore()`
|
||||||
|
and a real SQLite file: a restarted run must land on the same closed block
|
||||||
|
as an uninterrupted one, and a run that DOES NOT restore must land lower.
|
||||||
|
Without that second half, "restore works" is satisfied by a fixture where
|
||||||
|
restoring changes nothing.
|
||||||
|
4. MONTH ROLLOVER, in LOCAL time, including a restart that straddles it.
|
||||||
|
5. AN UNREADABLE STORE IS NOT A ZERO PEAK - the acceptance argument. Three
|
||||||
|
states, three different answers, and no accessor that turns "the database
|
||||||
|
did not answer" into a number.
|
||||||
|
6. THE TIMEZONE FALLBACK, graded against `zoneinfo` day by day.
|
||||||
|
7. THE PER-PHASE REDUCTION on an UNBALANCED load. §17's three-phase case. On a
|
||||||
|
balanced load net/sum/max all coincide, which is why a balanced fixture is
|
||||||
|
worth nothing here.
|
||||||
|
8. `monthly_peak` vs a recompute from `quarter_blocks` - the derived record
|
||||||
|
proven equal to the tier it derives from, and proven never to be lowered.
|
||||||
|
9. AC 3, ON THE REPLAY DATA THAT ACTUALLY EXISTS. See the honesty note below.
|
||||||
|
10. THE WIRING, through the real `Controller` and its real status page.
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
⚠️ AC 3 IS NOT MET, AND PART 9 DOES NOT PRETEND OTHERWISE.
|
||||||
|
|
||||||
|
AC 3 asks for "the monthly peak held under the soft limit in >= 95 % of
|
||||||
|
intervals over a 30-day simulation replay". THERE IS NO 30-DAY DATASET. The
|
||||||
|
infra repo's `sim/scenarios/` holds exactly two days of this house's real meter
|
||||||
|
- 2026-08-20 (the Illusmart failure day) and 2026-08-23 (the healthy reference)
|
||||||
|
- exported on 2026-08-25 because they were about to age out of HA's recorder,
|
||||||
|
and nothing longer was ever captured. `sim/scenarios/README.md` lists the whole
|
||||||
|
inventory; the rest is RS485 bus frames, OTA witness logs and a 578-row rollback
|
||||||
|
poll, none of which carries grid power over calendar time.
|
||||||
|
|
||||||
|
So part 9 replays the two days that exist, reports the percentage over them, and
|
||||||
|
states the sample size in days next to it. It is a MEASUREMENT, not a verdict:
|
||||||
|
nothing in it exits non-zero on the 95 % figure, because a 2-day run cannot pass
|
||||||
|
or fail a 30-day criterion and a check that claimed it could would be the more
|
||||||
|
expensive of the two mistakes. Both days are also *uncontrolled* - the add-on
|
||||||
|
was not shaving peaks on either - so the figure they give is the BASELINE the
|
||||||
|
guard has to improve on, which is the useful thing a 2-day replay can say.
|
||||||
|
|
||||||
|
The two days are committed here as `scenarios/p1-net-<date>.jsonl.gz`
|
||||||
|
(`{"t": iso8601, "w": signed_watts}` per line, derived from the HA recorder
|
||||||
|
export) for the reason `scenarios/p1-age-2026-08-26.jsonl.gz` already is: the
|
||||||
|
infra repo is private, CI's token is minted for this repository only, and a
|
||||||
|
regression fixture that CI cannot see is not a regression fixture.
|
||||||
|
|
||||||
|
Closing AC 3 needs 30 days of capture. That is TEST-01's replay harness, and it
|
||||||
|
cannot start before somebody starts recording.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.main import Controller
|
||||||
|
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
|
||||||
|
from app.tariff import (
|
||||||
|
BILLING_TZ, CHECKPOINT_S, NO_RECORD, NOT_OBSERVED, POLICY_MAX,
|
||||||
|
POLICY_NET, POLICY_SUM, PeakStore, PeakTracker, _EUTimezone, billed_w,
|
||||||
|
month_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
TMP = Path(tempfile.mkdtemp(prefix="goodwe-tariff-"))
|
||||||
|
|
||||||
|
# The live install's numbers: NOTES.md and freeze.py both record max_w 5000
|
||||||
|
# there. `peak_cap_w` ships at 3500 in config.yaml and is the SOFT LIMIT AC 3
|
||||||
|
# grades against - read out of the shipped file in part 10 rather than typed.
|
||||||
|
LIVE_MAX_W = 5000.0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def near(a, b, tol=1e-6):
|
||||||
|
return a is not None and b is not None and abs(a - b) <= tol
|
||||||
|
|
||||||
|
|
||||||
|
def sample(t: datetime, net_w: float, phases=1, phase_net=None):
|
||||||
|
"""One P1 telegram, through the SHIPPED derivation.
|
||||||
|
|
||||||
|
`split_signed` and `make_sample` are what the real transports call, so the
|
||||||
|
sign convention under test is the one that ships rather than a local
|
||||||
|
re-derivation of it.
|
||||||
|
"""
|
||||||
|
imp, exp = split_signed(net_w)
|
||||||
|
pi = pe = None
|
||||||
|
if phase_net is not None:
|
||||||
|
pairs = [split_signed(v) for v in phase_net]
|
||||||
|
pi = [a for a, _ in pairs]
|
||||||
|
pe = [b for _, b in pairs]
|
||||||
|
return make_sample("ha_signed", imp, exp, phases=phases,
|
||||||
|
phase_import_w=pi, phase_export_w=pe,
|
||||||
|
ingest_ts=t, ingest_mono=t.timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_store(name: str) -> PeakStore:
|
||||||
|
return PeakStore(str(TMP / f"{name}.db"))
|
||||||
|
|
||||||
|
|
||||||
|
# B is a clock-aligned quarter boundary: proved, not assumed, because every
|
||||||
|
# other number in part 1 rests on it.
|
||||||
|
B = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print("1. the block arithmetic, on a worked example")
|
||||||
|
|
||||||
|
check(f"the fixture's start {B.isoformat()} really is 900 s aligned",
|
||||||
|
int(B.timestamp()) % QUARTER_S == 0)
|
||||||
|
|
||||||
|
# (t seconds after B, net W). Deliberately irregular, with an export, a gap
|
||||||
|
# longer than max_hold_s, and a last sample that straddles the boundary.
|
||||||
|
MAX_HOLD_S = 30.0
|
||||||
|
FIXTURE = [(0, 1000.0), (20, 2000.0), (30, -500.0), (50, 3000.0),
|
||||||
|
(250, 4000.0), (880, 1200.0), (1000, 900.0)]
|
||||||
|
|
||||||
|
# The worked example, written as the segments a person would compute by hand:
|
||||||
|
# each HELD value and how many seconds of the FIRST block it is evidence for.
|
||||||
|
# Two rules do all the work: a sample is held forward until the next one, capped
|
||||||
|
# at MAX_HOLD_S; and export is 0 W of offtake, never a negative contribution.
|
||||||
|
SEGMENTS = [
|
||||||
|
(1000.0, 20), # 0 -> 20, a normal telegram interval, held in full
|
||||||
|
(2000.0, 10), # 20 -> 30, likewise
|
||||||
|
(0.0, 20), # 30 -> 50: -500 W is EXPORT, so 0 W of offtake for 20 s
|
||||||
|
(3000.0, 30), # 50 -> 250 is a 200 s GAP; only the first 30 s is evidence
|
||||||
|
(4000.0, 30), # 250 -> 880 is a 630 s gap; again only 30 s is evidence
|
||||||
|
(1200.0, 20), # 880 -> 900: the STRADDLE's first half, up to the boundary
|
||||||
|
]
|
||||||
|
# The straddle's second half: the same held 1200 W, from the boundary until its
|
||||||
|
# hold expires at 880 + 30. It must be credited to the NEXT block, not to this
|
||||||
|
# one and not to neither.
|
||||||
|
SPLIT_TAIL_W, SPLIT_TAIL_S = 1200.0, 10
|
||||||
|
EXP_WS = sum(w * s for w, s in SEGMENTS)
|
||||||
|
EXP_COVERED_S = sum(s for _, s in SEGMENTS)
|
||||||
|
EXP_BLOCK_AVG = EXP_WS / QUARTER_S # what the utility bills
|
||||||
|
EXP_ELAPSED_AVG = EXP_WS / EXP_COVERED_S # what a /elapsed divisor gives
|
||||||
|
NAIVE_MEAN = sum(max(w, 0.0) for _, w in FIXTURE[:-1]) / (len(FIXTURE) - 1)
|
||||||
|
|
||||||
|
avg = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||||
|
closed = []
|
||||||
|
for dt_s, w in FIXTURE:
|
||||||
|
closed += avg.add(sample(B + timedelta(seconds=dt_s), w))
|
||||||
|
|
||||||
|
print(f" {len(SEGMENTS)} held segments -> {EXP_WS:,.0f} Ws over "
|
||||||
|
f"{EXP_COVERED_S:.0f} s of the {QUARTER_S} s block")
|
||||||
|
check(f"exactly one block closed", len(closed) == 1)
|
||||||
|
check(f"...starting at the fixture's own boundary {B.isoformat()}",
|
||||||
|
bool(closed) and closed[0].start == B)
|
||||||
|
check(f"...averaging {EXP_BLOCK_AVG:.2f} W - the worked example's "
|
||||||
|
f"{EXP_WS:,.0f} Ws over the FULL {QUARTER_S} s",
|
||||||
|
bool(closed) and near(closed[0].offtake_avg_w, EXP_BLOCK_AVG, 1e-9))
|
||||||
|
|
||||||
|
# The three ways of being wrong, each of which produces a plausible number.
|
||||||
|
print(f" the same data read three other ways: /elapsed = {EXP_ELAPSED_AVG:.2f} W, "
|
||||||
|
f"naive mean of samples = {NAIVE_MEAN:.2f} W, "
|
||||||
|
f"billed = {EXP_BLOCK_AVG:.2f} W")
|
||||||
|
check("...and /elapsed is NOT the billed figure - a gap must drag the average "
|
||||||
|
"down, not be averaged away",
|
||||||
|
abs(EXP_ELAPSED_AVG - EXP_BLOCK_AVG) > 1.0)
|
||||||
|
check("...and a naive mean over samples is NOT the billed figure either - it "
|
||||||
|
"weights a burst of fast telegrams like a slow one",
|
||||||
|
abs(NAIVE_MEAN - EXP_BLOCK_AVG) > 1.0)
|
||||||
|
|
||||||
|
# The straddle: the 4000 W sample at t=400 is held ACROSS 900. Its evidence
|
||||||
|
# expires at 430, so the second block gets none of it - and the second block
|
||||||
|
# must still have been OPENED at 900, not at 1000.
|
||||||
|
check(f"the block after the straddle opens at the boundary, not at the sample",
|
||||||
|
avg.block_start == B + timedelta(seconds=QUARTER_S))
|
||||||
|
print(f" the straddling sample is SPLIT at the boundary: "
|
||||||
|
f"{SEGMENTS[-1][0]:.0f} W x {SEGMENTS[-1][1]} s into the first block, "
|
||||||
|
f"x {SPLIT_TAIL_S} s into the second")
|
||||||
|
check(f"...and the second block is credited the tail "
|
||||||
|
f"({SPLIT_TAIL_W * SPLIT_TAIL_S:,.0f} Ws), so a sample spanning :15 is "
|
||||||
|
f"attributed to BOTH blocks in proportion and wholly to neither",
|
||||||
|
near(avg.partial_ws, SPLIT_TAIL_W * SPLIT_TAIL_S, 1e-9))
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("2. the 900 s degeneracy - where three different formulas agree")
|
||||||
|
|
||||||
|
# A complete block at a constant power, fully covered. Here acc/elapsed,
|
||||||
|
# acc/900 and the projection are ALL the same number. Anything checked only
|
||||||
|
# here is checked against nothing.
|
||||||
|
HELD_W = 2400.0
|
||||||
|
deg = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||||
|
deg_closed = []
|
||||||
|
for i in range(0, QUARTER_S + 1, 5):
|
||||||
|
deg_closed += deg.add(sample(B + timedelta(seconds=i), HELD_W))
|
||||||
|
d_block = deg_closed[0].offtake_avg_w
|
||||||
|
deg2 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||||
|
LAST_S = QUARTER_S - 5
|
||||||
|
for i in range(0, LAST_S + 1, 5):
|
||||||
|
deg2.add(sample(B + timedelta(seconds=i), HELD_W))
|
||||||
|
d_partial = deg2.offtake_avg_w
|
||||||
|
# Graded AT the last sample: full coverage behind it, a held value in front.
|
||||||
|
d_proj = PeakTracker.project(deg2, B + timedelta(seconds=LAST_S)).projected_w
|
||||||
|
print(f" at full coverage of a whole block: acc/900 = {d_block:.2f} W, "
|
||||||
|
f"acc/elapsed = {d_partial:.2f} W, projection = {d_proj:.2f} W")
|
||||||
|
check("all three formulas coincide at exactly 900 s of full coverage - so a "
|
||||||
|
"check placed only there distinguishes none of them",
|
||||||
|
near(d_block, HELD_W, 1e-6) and near(d_partial, HELD_W, 1e-6)
|
||||||
|
and near(d_proj, HELD_W, 1e-6))
|
||||||
|
|
||||||
|
# Away from it, on part 1's fixture at a point mid-block, they must separate.
|
||||||
|
mid = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||||
|
for dt_s, w in FIXTURE[:-1]:
|
||||||
|
mid.add(sample(B + timedelta(seconds=dt_s), w))
|
||||||
|
NOW = B + timedelta(seconds=500)
|
||||||
|
p = PeakTracker.project(mid, NOW)
|
||||||
|
print(f" at t=500 s on part 1's fixture: acc/elapsed = {p.elapsed_w:.2f} W, "
|
||||||
|
f"projection = {p.projected_w:.2f} W, coverage = {p.covered_frac * 100:.1f} %")
|
||||||
|
check("mid-block, with a gap in it, the elapsed average and the projection are "
|
||||||
|
"DIFFERENT numbers", abs(p.elapsed_w - p.projected_w) > 1.0)
|
||||||
|
check("...and the projection carries elapsed_s, so a consumer can see it is an "
|
||||||
|
f"extrapolation over {QUARTER_S - p.elapsed_s:.0f} s it has not seen",
|
||||||
|
near(p.elapsed_s, 500.0, 1e-6))
|
||||||
|
check("...and a gap shows up as coverage below 1.0 rather than as a confident "
|
||||||
|
"low number", p.covered_frac < 1.0 and not p.complete)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("3. restart mid-block: the same block, or a lower one, never a lost one")
|
||||||
|
|
||||||
|
RUN_W = 2000.0
|
||||||
|
STEP_S = 1
|
||||||
|
RESTART_AT_S = 600 # minute 10 of the quarter - the functional note's case
|
||||||
|
|
||||||
|
|
||||||
|
def feed(avg, t0_s, t1_s, w=RUN_W):
|
||||||
|
out = []
|
||||||
|
for i in range(t0_s, t1_s + 1, STEP_S):
|
||||||
|
out += avg.add(sample(B + timedelta(seconds=i), w))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# (a) the control: no restart at all.
|
||||||
|
ctrl = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||||
|
ctrl_closed = feed(ctrl, 0, QUARTER_S)
|
||||||
|
CTRL_W = ctrl_closed[0].offtake_avg_w
|
||||||
|
|
||||||
|
# (b) restarted at minute 10, checkpoint restored through a real SQLite file.
|
||||||
|
st = fresh_store("restart")
|
||||||
|
tr = PeakTracker(st, cap_w=LIVE_MAX_W)
|
||||||
|
a1 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||||
|
feed(a1, 0, RESTART_AT_S)
|
||||||
|
check("the checkpoint round-trips through SQLite", st.save_partial(a1.snapshot()))
|
||||||
|
a2 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S) # a brand-new process
|
||||||
|
restored = tr.restore(a2, B + timedelta(seconds=RESTART_AT_S + 1))
|
||||||
|
print(f" restore() said: {restored}")
|
||||||
|
check("...and restore() reports RESUMING rather than starting fresh",
|
||||||
|
restored.startswith("resumed"))
|
||||||
|
r_closed = feed(a2, RESTART_AT_S + STEP_S, QUARTER_S)
|
||||||
|
RESTART_W = r_closed[0].offtake_avg_w if r_closed else None
|
||||||
|
|
||||||
|
# (c) the mutation guard: the SAME restart with no restore.
|
||||||
|
a3 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||||
|
n_closed = feed(a3, RESTART_AT_S + STEP_S, QUARTER_S)
|
||||||
|
NORESTORE_W = n_closed[0].offtake_avg_w if n_closed else None
|
||||||
|
|
||||||
|
print(f" uninterrupted {CTRL_W:.2f} W | restored {RESTART_W:.2f} W | "
|
||||||
|
f"restart with no restore {NORESTORE_W:.2f} W")
|
||||||
|
check(f"a restart at {RESTART_AT_S} s lands on the SAME billed block as an "
|
||||||
|
f"uninterrupted run", near(RESTART_W, CTRL_W, 1e-6))
|
||||||
|
check("...and without the restore it lands LOWER - so 'the restore works' is "
|
||||||
|
"not satisfied by a fixture where restoring changes nothing",
|
||||||
|
NORESTORE_W is not None and NORESTORE_W < CTRL_W - 1.0)
|
||||||
|
# Derived, not asserted against a literal: the lost part is exactly the seconds
|
||||||
|
# before the restart, at the power that was flowing.
|
||||||
|
DROPPED_S = RESTART_AT_S + STEP_S # the resumed feed starts one step later
|
||||||
|
check(f"...and it is lower by exactly the {DROPPED_S} s it dropped "
|
||||||
|
f"({RUN_W * DROPPED_S / QUARTER_S:.2f} W)",
|
||||||
|
near(CTRL_W - NORESTORE_W, RUN_W * DROPPED_S / QUARTER_S, 1e-6))
|
||||||
|
|
||||||
|
# (d) a LONG outage: restored, but the meter was away past max_hold_s. The
|
||||||
|
# uncovered seconds must drag the block DOWN, not be held forward.
|
||||||
|
OUTAGE_S = 200
|
||||||
|
a4 = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||||
|
st4 = fresh_store("outage")
|
||||||
|
st4.save_partial(a1.snapshot())
|
||||||
|
PeakTracker(st4, cap_w=LIVE_MAX_W).restore(a4, B + timedelta(seconds=RESTART_AT_S + OUTAGE_S))
|
||||||
|
o_closed = feed(a4, RESTART_AT_S + OUTAGE_S, QUARTER_S)
|
||||||
|
OUTAGE_UNCOVERED_S = OUTAGE_S - MAX_HOLD_S
|
||||||
|
print(f" a {OUTAGE_S} s outage inside the quarter, of which "
|
||||||
|
f"{OUTAGE_UNCOVERED_S:.0f} s is past the {MAX_HOLD_S:.0f} s hold: "
|
||||||
|
f"{o_closed[0].offtake_avg_w:.2f} W")
|
||||||
|
check(f"...reads lower than the uninterrupted block by exactly the uncovered "
|
||||||
|
f"seconds ({RUN_W * OUTAGE_UNCOVERED_S / QUARTER_S:.2f} W) - a gap is "
|
||||||
|
f"never held forward across the whole outage",
|
||||||
|
near(CTRL_W - o_closed[0].offtake_avg_w,
|
||||||
|
RUN_W * OUTAGE_UNCOVERED_S / QUARTER_S, 1e-6))
|
||||||
|
|
||||||
|
# (e) double counting is answered by the primary key, not by care.
|
||||||
|
st5 = fresh_store("dedupe")
|
||||||
|
st5.record_block(ctrl_closed[0])
|
||||||
|
st5.record_block(ctrl_closed[0])
|
||||||
|
st5.record_block(ctrl_closed[0])
|
||||||
|
rows = st5.blocks_in(month_key(B))
|
||||||
|
check("banking the same quarter three times leaves ONE row - quarter_start is "
|
||||||
|
"the primary key, so a replay or a restart cannot double-count",
|
||||||
|
len(rows) == 1 and near(rows[0][1], CTRL_W, 1e-9))
|
||||||
|
|
||||||
|
# ...and a later pass with LESS coverage may not lower a banked figure.
|
||||||
|
from app.tariff import _ClosedBlock as _CB
|
||||||
|
st5.record_block(_CB(start=B, offtake_avg_w=CTRL_W / 2, per_phase_offtake_avg_w=None))
|
||||||
|
after = st5.blocks_in(month_key(B))
|
||||||
|
check(f"...and re-banking the same quarter at half the figure leaves it at "
|
||||||
|
f"{CTRL_W:.0f} W - a restart's partial re-run must never lower a "
|
||||||
|
f"quarter that was already measured in full",
|
||||||
|
len(after) == 1 and near(after[0][1], CTRL_W, 1e-9))
|
||||||
|
|
||||||
|
# (f) the checkpoint's own staleness bound, derived from the constant.
|
||||||
|
print(f" CHECKPOINT_S = {CHECKPOINT_S:.0f} s, so a crash can lose at most "
|
||||||
|
f"{CHECKPOINT_S / QUARTER_S * 100:.1f} % of one quarter's average")
|
||||||
|
check("the checkpoint bound is small enough that its worst case is under 2 % "
|
||||||
|
"of a quarter", CHECKPOINT_S / QUARTER_S < 0.02)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("4. month rollover, in LOCAL time, and across a restart that straddles it")
|
||||||
|
|
||||||
|
# CEST is UTC+2, so the last two hours of 31 August UTC are already September.
|
||||||
|
AUG_LATE = datetime(2026, 8, 31, 21, 45, tzinfo=timezone.utc)
|
||||||
|
SEP_EARLY = datetime(2026, 8, 31, 22, 15, tzinfo=timezone.utc)
|
||||||
|
print(f" {AUG_LATE.isoformat()} is local "
|
||||||
|
f"{AUG_LATE.astimezone(BILLING_TZ).isoformat()} -> {month_key(AUG_LATE)}")
|
||||||
|
print(f" {SEP_EARLY.isoformat()} is local "
|
||||||
|
f"{SEP_EARLY.astimezone(BILLING_TZ).isoformat()} -> {month_key(SEP_EARLY)}")
|
||||||
|
check("a quarter starting 21:45 UTC on 31 Aug bills in AUGUST",
|
||||||
|
month_key(AUG_LATE) == "2026-08")
|
||||||
|
check("a quarter starting 22:15 UTC on 31 Aug bills in SEPTEMBER - filing it "
|
||||||
|
"under August would move a whole month's bill",
|
||||||
|
month_key(SEP_EARLY) == "2026-09")
|
||||||
|
check("...and the two are genuinely on opposite sides of the boundary, not "
|
||||||
|
"the same answer twice", month_key(AUG_LATE) != month_key(SEP_EARLY))
|
||||||
|
# Winter, where the offset is +1 and the boundary sits an hour later.
|
||||||
|
JAN_LATE = datetime(2026, 1, 31, 22, 45, tzinfo=timezone.utc)
|
||||||
|
check("in WINTER the same trap is an hour later: 22:45 UTC on 31 Jan is still "
|
||||||
|
"January, and 23:15 UTC is February",
|
||||||
|
month_key(JAN_LATE) == "2026-01"
|
||||||
|
and month_key(JAN_LATE + timedelta(minutes=30)) == "2026-02")
|
||||||
|
|
||||||
|
st6 = fresh_store("months")
|
||||||
|
AUG_PEAK_W, SEP_PEAK_W = 4998.0, 900.0
|
||||||
|
st6.record_block(_CB(AUG_LATE, AUG_PEAK_W, None))
|
||||||
|
st6.record_block(_CB(SEP_EARLY, SEP_PEAK_W, None))
|
||||||
|
r_aug, r_sep = st6.peak(month="2026-08"), st6.peak(month="2026-09")
|
||||||
|
print(f" {r_aug.explain()}")
|
||||||
|
print(f" {r_sep.explain()}")
|
||||||
|
check(f"August's peak is the {AUG_PEAK_W:.0f} W quarter (specs.md:583's own "
|
||||||
|
f"measured case) and September's is not",
|
||||||
|
r_aug.is_observed and near(r_aug.peak_w, AUG_PEAK_W)
|
||||||
|
and r_sep.is_observed and near(r_sep.peak_w, SEP_PEAK_W))
|
||||||
|
check("...so a new month starts from its OWN quarters, not from the previous "
|
||||||
|
"month's peak", r_sep.peak_w < r_aug.peak_w)
|
||||||
|
|
||||||
|
# The restart that straddles the boundary: a checkpoint whose open block is in
|
||||||
|
# August, restored when `now` is already September.
|
||||||
|
st7 = fresh_store("straddle")
|
||||||
|
tr7 = PeakTracker(st7, cap_w=LIVE_MAX_W)
|
||||||
|
straddler = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||||
|
for i in range(0, 400, STEP_S):
|
||||||
|
straddler.add(sample(AUG_LATE + timedelta(seconds=i), RUN_W))
|
||||||
|
st7.save_partial(straddler.snapshot())
|
||||||
|
after_boot = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||||
|
said = tr7.restore(after_boot, SEP_EARLY + timedelta(minutes=3))
|
||||||
|
print(f" restore() across the boundary said: {said}")
|
||||||
|
aug7, sep7 = st7.peak(month="2026-08"), st7.peak(month="2026-09")
|
||||||
|
check("a restart that straddles the month boundary BANKS the dead August "
|
||||||
|
"quarter rather than dropping measured energy",
|
||||||
|
said.startswith("closed a stale block") and aug7.is_observed)
|
||||||
|
check(f"...at its partial-coverage figure ({aug7.peak_w:.2f} W = "
|
||||||
|
f"{RUN_W * 399 / QUARTER_S:.2f} W, the seconds it actually covered)",
|
||||||
|
near(aug7.peak_w, RUN_W * 399 / QUARTER_S, 1e-6))
|
||||||
|
check("...and September reads NO_RECORD, which is a different answer from "
|
||||||
|
"August's peak AND from a zero",
|
||||||
|
sep7.state == NO_RECORD and sep7.peak_w is None)
|
||||||
|
check("...and the checkpoint is cleared, so a second boot cannot re-bank it",
|
||||||
|
st7.load_partial() is None)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("5. AN UNREADABLE STORE IS NOT A ZERO PEAK (the acceptance argument)")
|
||||||
|
|
||||||
|
# Three stores, three states, and the three must not answer alike.
|
||||||
|
good = fresh_store("states")
|
||||||
|
good_tr = PeakTracker(good, cap_w=LIVE_MAX_W)
|
||||||
|
empty_r = good_tr.peak(B)
|
||||||
|
|
||||||
|
gone = fresh_store("gone")
|
||||||
|
gone_tr = PeakTracker(gone, cap_w=LIVE_MAX_W)
|
||||||
|
gone.record_block(_CB(B, 3000.0, None))
|
||||||
|
gone.close() # the database goes away under a live reader
|
||||||
|
gone_r = gone_tr.peak(B)
|
||||||
|
|
||||||
|
never = PeakStore(str(TMP / "no-such-dir" / "x.db"))
|
||||||
|
never_tr = PeakTracker(never, cap_w=LIVE_MAX_W)
|
||||||
|
never_r = never_tr.peak(B)
|
||||||
|
|
||||||
|
# A store that is OPEN and whose query FAILS is a third code path - a closed
|
||||||
|
# handle short-circuits before the SQL ever runs, so without this the
|
||||||
|
# `except sqlite3.Error` arm of peak() is never executed by anything.
|
||||||
|
broken = fresh_store("broken-table")
|
||||||
|
broken.record_block(_CB(B, 3000.0, None))
|
||||||
|
broken._db.execute("DROP TABLE monthly_peak")
|
||||||
|
broken._db.commit()
|
||||||
|
broken_r = PeakTracker(broken, cap_w=LIVE_MAX_W).peak(B)
|
||||||
|
|
||||||
|
for label, r in (("fresh store, no quarter yet", empty_r),
|
||||||
|
("store that broke under a live reader", gone_r),
|
||||||
|
("store that never opened", never_r),
|
||||||
|
("store open, query failed", broken_r)):
|
||||||
|
print(f" {label:38s} -> {r.state:13s} {r.explain()}")
|
||||||
|
|
||||||
|
check("a fresh store with no quarter yet says NO_RECORD - a normal state on "
|
||||||
|
"the 1st of the month", empty_r.state == NO_RECORD)
|
||||||
|
check("a store that broke says NOT_OBSERVED", gone_r.state == NOT_OBSERVED)
|
||||||
|
check("a store that never opened says NOT_OBSERVED", never_r.state == NOT_OBSERVED)
|
||||||
|
check("a store whose QUERY fails says NOT_OBSERVED too - a different code path "
|
||||||
|
"from a closed handle, and the one a half-corrupt database takes",
|
||||||
|
broken_r.state == NOT_OBSERVED and broken_r.peak_w is None)
|
||||||
|
# ...and losing the DERIVED record does not lose the peak, which is the whole
|
||||||
|
# argument for keeping monthly_peak recomputable from the 15-min tier.
|
||||||
|
_rec = broken.recompute_month(month_key(B))
|
||||||
|
print(f" ...but with the 15-min tier intact, recompute_month() still "
|
||||||
|
f"answers: {_rec.explain()}")
|
||||||
|
check("...while recompute_month() STILL answers from the surviving 15-min "
|
||||||
|
"tier - the permanent record is derived, so losing it is recoverable",
|
||||||
|
_rec.is_observed and near(_rec.peak_w, 3000.0))
|
||||||
|
broken._db.execute("DROP TABLE quarter_blocks")
|
||||||
|
broken._db.commit()
|
||||||
|
check("...and once BOTH tiers are gone it says NOT_OBSERVED rather than "
|
||||||
|
"raising into the control loop",
|
||||||
|
broken.recompute_month(month_key(B)).state == NOT_OBSERVED)
|
||||||
|
check("...and NO_RECORD and NOT_OBSERVED are DIFFERENT states, not two "
|
||||||
|
"spellings of the same one", empty_r.state != gone_r.state)
|
||||||
|
check("...and they read differently to a human, too",
|
||||||
|
empty_r.explain() != gone_r.explain()
|
||||||
|
and "NOT" in gone_r.explain() and "zero peak" in gone_r.explain())
|
||||||
|
|
||||||
|
check("an unreadable peak is None, not 0.0 - there is no number to mistake",
|
||||||
|
gone_r.peak_w is None and never_r.peak_w is None)
|
||||||
|
check("...and NO_RECORD carries no number either", empty_r.peak_w is None)
|
||||||
|
_typed = False
|
||||||
|
try:
|
||||||
|
float(gone_r.peak_w)
|
||||||
|
except TypeError:
|
||||||
|
_typed = True
|
||||||
|
check("...and a caller who forgets to ask gets a TypeError rather than a "
|
||||||
|
"silently disarming zero", _typed)
|
||||||
|
check("PeakReading exposes no accessor that defaults a missing peak to a "
|
||||||
|
"number - no .get(), no _or() ",
|
||||||
|
not [a for a in dir(gone_r)
|
||||||
|
if a.startswith(("get", "value")) or a.endswith("_or")])
|
||||||
|
|
||||||
|
# The consequence, which is the reason any of this matters.
|
||||||
|
print(f" headroom under a {LIVE_MAX_W:.0f} W limit: "
|
||||||
|
f"no quarter yet -> {good_tr.headroom_w(B)}, "
|
||||||
|
f"unreadable -> {gone_tr.headroom_w(B)}")
|
||||||
|
check("an unreadable store yields NO headroom figure - the disarming default "
|
||||||
|
"(full headroom, guard silently off) is unreachable",
|
||||||
|
gone_tr.headroom_w(B) is None and never_tr.headroom_w(B) is None)
|
||||||
|
check(f"...where a genuinely empty month yields the full {LIVE_MAX_W:.0f} W, "
|
||||||
|
f"which is correct and is a different answer",
|
||||||
|
near(good_tr.headroom_w(B), LIVE_MAX_W))
|
||||||
|
check("the 80 % warning fraction is likewise None when nothing was observed",
|
||||||
|
gone_tr.warning_frac(B) is None and good_tr.warning_frac(B) == 0.0)
|
||||||
|
|
||||||
|
# And a real peak reduces headroom, so "always None" does not pass this part.
|
||||||
|
good.record_block(_CB(B, LIVE_MAX_W * 0.9, None))
|
||||||
|
print(f" after one {LIVE_MAX_W * 0.9:.0f} W quarter: "
|
||||||
|
f"headroom {good_tr.headroom_w(B):.0f} W, "
|
||||||
|
f"warning {good_tr.warning_frac(B) * 100:.0f} % of the limit")
|
||||||
|
check("a real observed peak gives a real headroom, so this part is not passed "
|
||||||
|
"by a store that answers None to everything",
|
||||||
|
near(good_tr.headroom_w(B), LIVE_MAX_W * 0.1, 1e-6))
|
||||||
|
check(f"...and it is over §9.1's 80 % warning threshold",
|
||||||
|
good_tr.warning_frac(B) > 0.8)
|
||||||
|
|
||||||
|
# A CORRUPT database file is the third way a store fails, and it must land in
|
||||||
|
# NOT_OBSERVED too rather than raising into the control loop.
|
||||||
|
corrupt_path = TMP / "corrupt.db"
|
||||||
|
corrupt_path.write_bytes(b"this is not a database" * 64)
|
||||||
|
corrupt = PeakStore(str(corrupt_path))
|
||||||
|
c_r = PeakTracker(corrupt, cap_w=LIVE_MAX_W).peak(B)
|
||||||
|
print(f" a corrupt database file -> {c_r.state}")
|
||||||
|
check("a corrupt database reads NOT_OBSERVED and does not raise into the "
|
||||||
|
"control loop (§6 fail-open)", c_r.state == NOT_OBSERVED)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("6. the timezone fallback, graded against zoneinfo")
|
||||||
|
|
||||||
|
_zi = None
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
_zi = ZoneInfo("Europe/Brussels")
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
print(f" zoneinfo unavailable here ({err}) - the fallback IS the tz")
|
||||||
|
|
||||||
|
if _zi is None:
|
||||||
|
check("zoneinfo is absent, so the encoded rule cannot be graded here - "
|
||||||
|
"reported, not silently passed", True)
|
||||||
|
else:
|
||||||
|
fb = _EUTimezone()
|
||||||
|
# ⚠️ HOURLY, NOT 3-HOURLY, ACROSS THE TRANSITION DAYS. Both EU transitions
|
||||||
|
# happen at 01:00 UTC, so a rule that is wrong BY AN HOUR differs from the
|
||||||
|
# right one only between 01:00 and 02:00 UTC. A 3-hourly sweep steps over
|
||||||
|
# that window entirely and grades nothing - measured: a mutation moving the
|
||||||
|
# UTC threshold from 01:00 to 02:00 survived the whole file at 3-hourly.
|
||||||
|
probes = [datetime(2026, 1, 1, 12, tzinfo=timezone.utc)]
|
||||||
|
probes += [datetime(2026, m, d, h, mi, tzinfo=timezone.utc)
|
||||||
|
for m in (3, 10) for d in (28, 29, 30, 31)
|
||||||
|
for h in range(24) for mi in (0, 30)]
|
||||||
|
probes += [datetime(2026, m, 1, 0, 30, tzinfo=timezone.utc) for m in range(1, 13)]
|
||||||
|
# ⚠️ COMPARE THE CONVERTED WALL CLOCK, NOT ITS utcoffset(). `astimezone`
|
||||||
|
# goes through `fromutc`, but the RESULT carries local wall values, so
|
||||||
|
# asking it for `.utcoffset()` routes straight back into the LOCAL arm and
|
||||||
|
# never grades `fromutc` at all. Measured: a mutation moving the UTC
|
||||||
|
# threshold by an hour survived 397 offset-comparing probes and dies
|
||||||
|
# immediately on the wall clock they produce.
|
||||||
|
def _wallclock(t, tz):
|
||||||
|
return t.astimezone(tz).replace(tzinfo=None)
|
||||||
|
|
||||||
|
bad = [t for t in probes if _wallclock(t, fb) != _wallclock(t, _zi)]
|
||||||
|
print(f" {len(probes)} probes across both DST transitions and every "
|
||||||
|
f"month boundary: {len(bad)} disagreement(s) in the LOCAL WALL CLOCK "
|
||||||
|
f"each one converts to")
|
||||||
|
check("the encoded EU rule converts every probe to the same local wall "
|
||||||
|
"clock as zoneinfo, including the two transition weekends", not bad)
|
||||||
|
# ...and the probes are not all the same offset, or the check is vacuous.
|
||||||
|
offsets = {t.astimezone(_zi).utcoffset() for t in probes}
|
||||||
|
check(f"...and the probe set spans BOTH offsets ({sorted(str(o) for o in offsets)}), "
|
||||||
|
f"so agreeing is not free", len(offsets) == 2)
|
||||||
|
check(f"BILLING_TZ resolved to zoneinfo on this host, not the fallback",
|
||||||
|
not isinstance(BILLING_TZ, _EUTimezone))
|
||||||
|
|
||||||
|
# ⚠️ THE PROBES ABOVE ONLY EXERCISE `fromutc()`. `astimezone` on a UTC
|
||||||
|
# instant never calls `utcoffset()` with a local wall time, so the local
|
||||||
|
# arm of the rule was ungraded - a mutation that moved it by an hour
|
||||||
|
# survived the whole file. These probes drive it directly. Noon is used
|
||||||
|
# because it is unambiguous on every day of the year, including the two
|
||||||
|
# transition days where 02:00-03:00 local is missing or repeated.
|
||||||
|
#
|
||||||
|
# ⚠️ AND THE LOCAL PROBES MUST INCLUDE 03:00-03:59 ON THE OCTOBER SUNDAY.
|
||||||
|
# That hour is where a local rule that ends DST an hour late still claims
|
||||||
|
# CEST while zoneinfo has already returned to CET; at noon both answers
|
||||||
|
# agree and the probe is worth nothing. Measured: at noon-only, a mutation
|
||||||
|
# moving the local thresholds from 02:00/03:00 to 03:00/04:00 survived.
|
||||||
|
#
|
||||||
|
# The MARCH gap (02:00-02:59 local, which does not exist) is deliberately
|
||||||
|
# NOT probed: Python hands a nonexistent local time to `utcoffset` as an
|
||||||
|
# ordinary value and any answer is defensible, so grading it would pin an
|
||||||
|
# arbitrary choice. It cannot move a month boundary - it is the last Sunday
|
||||||
|
# of March, never the 1st of a month - which is the only thing this tz is
|
||||||
|
# used for.
|
||||||
|
wall = [datetime(2026, m, d, 12) for m, d in
|
||||||
|
[(3, 27), (3, 28), (3, 29), (3, 30), (10, 24), (10, 25),
|
||||||
|
(10, 26), (10, 27)]]
|
||||||
|
wall += [datetime(2026, 10, 25, h, mi) for h in range(0, 6) for mi in (0, 30)
|
||||||
|
if h != 2] # 02:00 local is the ambiguous fold
|
||||||
|
wall += [datetime(2026, 3, 29, h, mi) for h in (0, 1, 3, 4, 5) for mi in (0, 30)]
|
||||||
|
wall += [datetime(2026, m, 1, 12) for m in range(1, 13)]
|
||||||
|
bad_w = [w for w in wall
|
||||||
|
if w.replace(tzinfo=fb).utcoffset() != w.replace(tzinfo=_zi).utcoffset()]
|
||||||
|
w_offsets = {w.replace(tzinfo=_zi).utcoffset() for w in wall}
|
||||||
|
print(f" {len(wall)} LOCAL-wall probes through utcoffset(): "
|
||||||
|
f"{len(bad_w)} disagreement(s), spanning "
|
||||||
|
f"{sorted(str(o) for o in w_offsets)}")
|
||||||
|
check("utcoffset() on local wall time matches zoneinfo across both "
|
||||||
|
"transition weekends - the arm astimezone() never reaches", not bad_w)
|
||||||
|
check("...and those probes span both offsets too", len(w_offsets) == 2)
|
||||||
|
bad_n = [w for w in wall
|
||||||
|
if w.replace(tzinfo=fb).tzname() != w.replace(tzinfo=_zi).tzname()]
|
||||||
|
check("...and tzname() agrees with zoneinfo at every one of them (CET/CEST)",
|
||||||
|
not bad_n)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("7. the per-phase reduction, on an UNBALANCED three-phase load (§17)")
|
||||||
|
|
||||||
|
# One phase exporting is what separates the three policies. On a balanced load
|
||||||
|
# they all give the same number, which is why this fixture is not balanced.
|
||||||
|
PH = (3000.0, 500.0, -200.0)
|
||||||
|
NET = sum(PH)
|
||||||
|
u = QuarterAverager(phases=3, max_hold_s=MAX_HOLD_S)
|
||||||
|
u_closed = []
|
||||||
|
for i in range(0, QUARTER_S + 1, 5):
|
||||||
|
u_closed += u.add(sample(B + timedelta(seconds=i), NET, phases=3, phase_net=PH))
|
||||||
|
blk = u_closed[0]
|
||||||
|
vals = {p: billed_w(blk, p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)}
|
||||||
|
print(f" phases {PH} (net {NET:+.0f} W) -> "
|
||||||
|
+ ", ".join(f"{k}={v:.0f} W" for k, v in vals.items()))
|
||||||
|
check(f"the NET policy bills the connection's net offtake ({NET:.0f} W)",
|
||||||
|
near(vals[POLICY_NET], NET, 1e-6))
|
||||||
|
check(f"the SUM policy clips each phase first, so the exporting phase does not "
|
||||||
|
f"net off ({sum(max(v, 0.0) for v in PH):.0f} W)",
|
||||||
|
near(vals[POLICY_SUM], sum(max(v, 0.0) for v in PH), 1e-6))
|
||||||
|
check(f"the MAX policy bills the worst single phase ({max(PH):.0f} W)",
|
||||||
|
near(vals[POLICY_MAX], max(PH), 1e-6))
|
||||||
|
check("...and on THIS fixture all three are different numbers - which is the "
|
||||||
|
"whole reason the reduction is a named policy and not an assumption",
|
||||||
|
len({round(v, 6) for v in vals.values()}) == 3)
|
||||||
|
check("the shipped default is NET, the meter's own billed register",
|
||||||
|
billed_w(blk) == vals[POLICY_NET])
|
||||||
|
|
||||||
|
bal = QuarterAverager(phases=3, max_hold_s=MAX_HOLD_S)
|
||||||
|
b_closed = []
|
||||||
|
for i in range(0, QUARTER_S + 1, 5):
|
||||||
|
b_closed += bal.add(sample(B + timedelta(seconds=i), 3000.0, phases=3,
|
||||||
|
phase_net=(1000.0, 1000.0, 1000.0)))
|
||||||
|
b_vals = {p: billed_w(b_closed[0], p) for p in (POLICY_NET, POLICY_SUM, POLICY_MAX)}
|
||||||
|
check(f"...and on a BALANCED load NET and SUM coincide ({b_vals[POLICY_NET]:.0f} W), "
|
||||||
|
f"so a balanced fixture could not have told them apart",
|
||||||
|
near(b_vals[POLICY_NET], b_vals[POLICY_SUM], 1e-6))
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("8. monthly_peak is DERIVED, and provably has not diverged")
|
||||||
|
|
||||||
|
st8 = fresh_store("derive")
|
||||||
|
QUARTERS = [(0, 1200.0), (1, 4998.0), (2, 3357.0), (3, 800.0), (4, 4832.0)]
|
||||||
|
for q, w in QUARTERS:
|
||||||
|
st8.record_block(_CB(B + timedelta(seconds=q * QUARTER_S), w, None))
|
||||||
|
MAXQ = max(w for _, w in QUARTERS)
|
||||||
|
stored = st8.peak(month=month_key(B))
|
||||||
|
derived = st8.recompute_month(month_key(B))
|
||||||
|
print(f" {len(QUARTERS)} quarters banked; stored peak {stored.peak_w:.0f} W, "
|
||||||
|
f"recomputed from quarter_blocks {derived.peak_w:.0f} W")
|
||||||
|
check(f"the stored monthly peak is the largest quarter ({MAXQ:.0f} W - "
|
||||||
|
f"specs.md:583's own figure)",
|
||||||
|
stored.is_observed and near(stored.peak_w, MAXQ))
|
||||||
|
check("...and re-deriving it from the 15-min tier gives the same number and "
|
||||||
|
"the same quarter, so the two records are proven equal rather than "
|
||||||
|
"assumed to be",
|
||||||
|
derived.is_observed and near(derived.peak_w, stored.peak_w)
|
||||||
|
and derived.quarter_start == stored.quarter_start)
|
||||||
|
check("...and the two measured regressions from §9.1 land on DIFFERENT peaks - "
|
||||||
|
f"the sustained {MAXQ:.0f} W quarter, not the oscillating 3357 W one",
|
||||||
|
not near(stored.peak_w, 3357.0))
|
||||||
|
# A quarter arriving LATER and lower must not lower the month.
|
||||||
|
st8.record_block(_CB(B + timedelta(seconds=9 * QUARTER_S), 100.0, None))
|
||||||
|
check("a later, smaller quarter does not lower the month's peak",
|
||||||
|
near(st8.peak(month=month_key(B)).peak_w, MAXQ))
|
||||||
|
check("...and the recompute agrees with it after that write, too",
|
||||||
|
near(st8.recompute_month(month_key(B)).peak_w, MAXQ))
|
||||||
|
# No pruning: §15 says these two tiers are indefinite / permanent.
|
||||||
|
src = (HERE / "app" / "tariff.py").read_text(encoding="utf-8")
|
||||||
|
check("§15's retention is structural: no DELETE against quarter_blocks or "
|
||||||
|
"monthly_peak exists anywhere in tariff.py",
|
||||||
|
"DELETE FROM quarter_blocks" not in src
|
||||||
|
and "DELETE FROM monthly_peak" not in src
|
||||||
|
and "DROP TABLE" not in src)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("9. AC 3, on the replay data that actually exists")
|
||||||
|
|
||||||
|
REPLAY = sorted((HERE / "scenarios").glob("p1-net-*.jsonl.gz"))
|
||||||
|
print(f" replay fixtures found: {len(REPLAY)} "
|
||||||
|
f"({', '.join(p.name for p in REPLAY) or 'none'})")
|
||||||
|
check("the committed replay fixtures are present - a regression fixture CI "
|
||||||
|
"cannot see is not a regression fixture", len(REPLAY) >= 2)
|
||||||
|
|
||||||
|
# ⚠️ `peak_cap_w:` appears TWICE in config.yaml - once under `options:` with the
|
||||||
|
# default, once under `schema:` with `int(500,15000)`. Taking the last match
|
||||||
|
# would blow up on the schema line, and taking any match that parses would be
|
||||||
|
# luck; the FIRST is the options block, which is the number an install lands on.
|
||||||
|
SHIPPED_CAP_W = None
|
||||||
|
for line in (HERE / "config.yaml").read_text(encoding="utf-8").splitlines():
|
||||||
|
if line.strip().startswith("peak_cap_w:"):
|
||||||
|
try:
|
||||||
|
SHIPPED_CAP_W = float(line.split(":", 1)[1].strip())
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
check("config.yaml's shipped peak_cap_w was read out of the file rather than "
|
||||||
|
"typed here", SHIPPED_CAP_W is not None)
|
||||||
|
|
||||||
|
replay_days = 0
|
||||||
|
total_q = 0
|
||||||
|
under = 0
|
||||||
|
for path in REPLAY:
|
||||||
|
st9 = fresh_store("replay-" + path.stem)
|
||||||
|
ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S)
|
||||||
|
tr9 = PeakTracker(st9, cap_w=SHIPPED_CAP_W or LIVE_MAX_W)
|
||||||
|
first = last = None
|
||||||
|
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
row = json.loads(line)
|
||||||
|
t = datetime.fromisoformat(row["t"])
|
||||||
|
first = first or t
|
||||||
|
last = t
|
||||||
|
ing.submit(sample(t, float(row["w"])))
|
||||||
|
tr9.sync(ing)
|
||||||
|
months = {month_key(b.start) for b in ing.blocks}
|
||||||
|
span_h = (last - first).total_seconds() / 3600.0
|
||||||
|
qs = [w for _, w in st9.blocks_in(sorted(months)[0])] if months else []
|
||||||
|
peak = max(qs) if qs else 0.0
|
||||||
|
ok_q = sum(1 for w in qs if w <= (SHIPPED_CAP_W or LIVE_MAX_W))
|
||||||
|
total_q += len(qs)
|
||||||
|
under += ok_q
|
||||||
|
replay_days += span_h / 24.0
|
||||||
|
print(f" {path.name}: {span_h:.1f} h, {len(qs)} closed quarters, "
|
||||||
|
f"month peak {peak:.0f} W, "
|
||||||
|
f"{ok_q}/{len(qs)} = {100.0 * ok_q / max(len(qs), 1):.1f} % under the "
|
||||||
|
f"{SHIPPED_CAP_W:.0f} W soft limit")
|
||||||
|
check(f"{path.name}: the month's peak equals its own largest quarter",
|
||||||
|
near(st9.peak(month=sorted(months)[0]).peak_w, peak, 1e-9))
|
||||||
|
check(f"{path.name}: a full day of real telegrams produces ~96 quarters, "
|
||||||
|
f"got {len(qs)}", 90 <= len(qs) <= 96)
|
||||||
|
|
||||||
|
PCT = 100.0 * under / max(total_q, 1)
|
||||||
|
print()
|
||||||
|
print(f" AC 3 MEASURED: {under}/{total_q} quarters = {PCT:.1f} % under the "
|
||||||
|
f"{SHIPPED_CAP_W:.0f} W soft limit, over {replay_days:.1f} DAYS of "
|
||||||
|
f"replay, UNCONTROLLED (no peak shaving was running on either day).")
|
||||||
|
print(f" AC 3 ASKS FOR 30 DAYS. {replay_days:.1f} days exist. "
|
||||||
|
f"AC 3 IS NOT MET and this run does not claim it is - see the module "
|
||||||
|
f"docstring. The figure above is the BASELINE SAFETY-07 has to improve.")
|
||||||
|
check("the replay produced a measurable number over a stated sample size, "
|
||||||
|
"rather than a criterion quietly redefined to fit the data",
|
||||||
|
total_q > 100 and replay_days < 30)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
print("10. the wiring: the real Controller, and what its status page says")
|
||||||
|
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
def set(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_time(self, *a):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _Pub:
|
||||||
|
enabled = False
|
||||||
|
|
||||||
|
def publish(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _Hass:
|
||||||
|
async def limits(self, entity):
|
||||||
|
return (-LIVE_MAX_W, LIVE_MAX_W, 1.0)
|
||||||
|
|
||||||
|
async def number(self, entity, invert=False):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set_number(self, entity, value):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def call(self, domain, service, data):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def built(db_path, opts=None):
|
||||||
|
return Controller(dict(
|
||||||
|
{"setpoint_entity": "number.sp", "meter_source": "ha_signed",
|
||||||
|
"p1_net_entity": "sensor.net", "meter_phases": 1,
|
||||||
|
"max_w": LIVE_MAX_W, "gain": 0.6, "slew_w": 1000, "deadband_w": 15,
|
||||||
|
"target_grid_w": 0, "step_w": 10, "heartbeat_s": 10,
|
||||||
|
"stale_input_s": 15}, **(opts or {})),
|
||||||
|
_Hass(), _Store(), _Pub(), stop=asyncio.Event(),
|
||||||
|
peak_db_path=str(db_path))
|
||||||
|
|
||||||
|
|
||||||
|
c = built(TMP / "wired.db")
|
||||||
|
check(f"a Controller built with peak_cap_w ABSENT takes the SHIPPED "
|
||||||
|
f"{SHIPPED_CAP_W:.0f} W, so config.yaml and main.py's fallback cannot "
|
||||||
|
f"drift apart unnoticed", c.peak.cap_w == SHIPPED_CAP_W)
|
||||||
|
|
||||||
|
# Drive one real quarter through the real ingest, then the real cycle's sync.
|
||||||
|
for i in range(0, QUARTER_S + 1, 5):
|
||||||
|
c.p1.submit(sample(B + timedelta(seconds=i), 4998.0))
|
||||||
|
n = c.peak.sync(c.p1)
|
||||||
|
wr = c.peak.peak(B)
|
||||||
|
print(f" after one 4998 W quarter through the real ingest: "
|
||||||
|
f"{n} banked, {wr.explain()}")
|
||||||
|
check("the closed quarter reached SQLite through the tracker the cycle calls",
|
||||||
|
n == 1 and wr.is_observed and near(wr.peak_w, 4998.0, 1e-6))
|
||||||
|
|
||||||
|
# ⚠️ `cycle()` calls sync() ONCE A SECOND against a `blocks` list that keeps a
|
||||||
|
# closed quarter for a DAY. Without a dedupe that is ~86 000 re-banks of the
|
||||||
|
# same row per block, and `recorded` - the number the status page prints as
|
||||||
|
# "quarters banked" - would be a count of loop iterations rather than of
|
||||||
|
# quarters. The database stays correct either way, which is exactly why this
|
||||||
|
# needs its own check instead of being caught by the peak.
|
||||||
|
again = c.peak.sync(c.p1)
|
||||||
|
third = c.peak.sync(c.p1)
|
||||||
|
print(f" sync() called three times over the same closed quarter: "
|
||||||
|
f"{n}, {again}, {third} newly banked; tracker reports "
|
||||||
|
f"{c.peak.recorded} quarter(s)")
|
||||||
|
check("...and syncing the same quarter again banks nothing new, so the count "
|
||||||
|
"the status page shows is quarters and not cycles",
|
||||||
|
again == 0 and third == 0 and c.peak.recorded == 1)
|
||||||
|
check("...and the peak is unchanged by the repeat",
|
||||||
|
near(c.peak.peak(B).peak_w, 4998.0, 1e-6))
|
||||||
|
|
||||||
|
rows = [r for r in c.checks() if "capacity-tariff" in r["text"] or "peak" in r["text"]]
|
||||||
|
peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r["text"]]
|
||||||
|
print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)"))
|
||||||
|
check("the status page carries exactly one capacity-tariff peak row",
|
||||||
|
len(peak_rows) == 1)
|
||||||
|
check("...naming the peak, the soft limit and the quarters banked, all derived "
|
||||||
|
"from the controller that was actually built",
|
||||||
|
bool(peak_rows) and "4998 W" in peak_rows[0]["text"]
|
||||||
|
and f"{SHIPPED_CAP_W:.0f} W soft limit" in peak_rows[0]["text"]
|
||||||
|
and "1 quarter(s) banked" in peak_rows[0]["text"])
|
||||||
|
check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
|
||||||
|
f"on a {SHIPPED_CAP_W:.0f} W limit",
|
||||||
|
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
|
||||||
|
|
||||||
|
# The half that matters: the same page with a store that cannot answer.
|
||||||
|
c2 = built(TMP / "no-such-dir" / "nope.db")
|
||||||
|
u_rows = [r for r in c2.checks() if "capacity-tariff peak" in r["text"]]
|
||||||
|
print(" status row (unreadable store): "
|
||||||
|
+ (u_rows[0]["text"] if u_rows else "(none)"))
|
||||||
|
check("an unreadable store gets its OWN row, saying UNREADABLE", len(u_rows) == 1)
|
||||||
|
check("...and it does NOT read as a quiet month at 0 W - without this, a broken "
|
||||||
|
"instrument looks exactly like a month with no peak",
|
||||||
|
bool(u_rows) and "UNREADABLE" in u_rows[0]["text"]
|
||||||
|
and "0 %" not in u_rows[0]["text"] and u_rows[0]["warn"])
|
||||||
|
check("...and the healthy row and the unreadable row are different text",
|
||||||
|
bool(u_rows) and bool(peak_rows)
|
||||||
|
and u_rows[0]["text"] != peak_rows[0]["text"])
|
||||||
|
|
||||||
|
# THE MEASUREMENT IS OF DELIVERED POWER (SAFETY-20). Graded on the parsed
|
||||||
|
# SYNTAX, not on the text: the module docstring argues this at length and names
|
||||||
|
# every forbidden word while doing so, so a grep over the source would be
|
||||||
|
# satisfied by deleting the explanation and would fail on writing one. What must
|
||||||
|
# be true is that no NAME the code evaluates refers to a command.
|
||||||
|
import ast as _ast
|
||||||
|
|
||||||
|
_tree = _ast.parse(src)
|
||||||
|
_names = {n.id for n in _ast.walk(_tree) if isinstance(n, _ast.Name)}
|
||||||
|
_names |= {n.attr for n in _ast.walk(_tree) if isinstance(n, _ast.Attribute)}
|
||||||
|
_names |= {n.name for n in _ast.walk(_tree)
|
||||||
|
if isinstance(n, (_ast.FunctionDef, _ast.ClassDef))}
|
||||||
|
_FORBIDDEN = {"setpoint", "write_setpoint", "target", "Decision", "i_clamped",
|
||||||
|
"clamped", "Claim", "resolve", "P_PEAK", "arbiter"}
|
||||||
|
_found = sorted(_names & _FORBIDDEN)
|
||||||
|
_imports = sorted(m.module or "" for m in _ast.walk(_tree)
|
||||||
|
if isinstance(m, _ast.ImportFrom))
|
||||||
|
print(f" tariff.py evaluates {len(_names)} distinct names and imports "
|
||||||
|
f"from {_imports or ['(stdlib only)']}")
|
||||||
|
check(f"none of them is a command-side name {sorted(_FORBIDDEN)} - the utility "
|
||||||
|
f"bills what FLOWED, so this reads the meter and nothing else"
|
||||||
|
+ (f"; found {_found}" if _found else ""), not _found)
|
||||||
|
check("...and it imports nothing from arbiter, clamp or control: it files no "
|
||||||
|
"claim, applies no clamp, and commands nothing - SAFETY-07's job",
|
||||||
|
not [m for m in _imports if m in ("arbiter", "clamp", "control",
|
||||||
|
".arbiter", ".clamp", ".control")])
|
||||||
|
# ...and the guard is not vacuous: the same grader run over main.py, which DOES
|
||||||
|
# command, must light up.
|
||||||
|
_main = {n.attr for n in _ast.walk(
|
||||||
|
_ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8")))
|
||||||
|
if isinstance(n, _ast.Attribute)}
|
||||||
|
check(f"the same grader over main.py finds {sorted(_main & _FORBIDDEN)}, so it "
|
||||||
|
f"is capable of failing", bool(_main & _FORBIDDEN))
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print()
|
||||||
|
shutil.rmtree(TMP, ignore_errors=True)
|
||||||
|
if fails:
|
||||||
|
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("all checks passed")
|
||||||
@@ -253,6 +253,91 @@ check("...and the shipped charge rate is at or under the shipped ceiling",
|
|||||||
check("...and the shipped discharge rate too, so it warns about nothing",
|
check("...and the shipped discharge rate too, so it warns about nothing",
|
||||||
_shipped["maintenance_discharge_w"] <= _shipped["max_w"])
|
_shipped["maintenance_discharge_w"] <= _shipped["max_w"])
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("the shipped meter_freeze_s, pinned (TEST-06 / AC3)")
|
||||||
|
# ⚠️ WHY A NUMBER GETS ITS OWN CHECK. `meter_freeze_s: 300` is the threshold
|
||||||
|
# SAFETY-18's entire argument is about, and before this it was pinned NOWHERE:
|
||||||
|
# 300 -> 3600 left test_freeze.py AND this file green, because the schema
|
||||||
|
# accepts int(60,3600) and no check ever read the shipped value. The schema
|
||||||
|
# bounds what is CONFIGURABLE; this pins what is SHIPPED, which is a different
|
||||||
|
# question and the one the docs answer.
|
||||||
|
#
|
||||||
|
# Read by regex like _shipped above, for the same reason: no PyYAML in the
|
||||||
|
# image, and a check that skips on ImportError is not a check.
|
||||||
|
_freeze_s = [float(v) for v in re.findall(
|
||||||
|
r"^ meter_freeze_s: (\d+(?:\.\d+)?)\s*$", _txt, re.M)]
|
||||||
|
_freeze_schema = re.findall(r"^ meter_freeze_s: int\((\d+),(\d+)\)\s*$",
|
||||||
|
_txt, re.M)
|
||||||
|
check(f"config.yaml carries exactly one meter_freeze_s default {_freeze_s}",
|
||||||
|
len(_freeze_s) == 1)
|
||||||
|
check("...and exactly one schema range for it", len(_freeze_schema) == 1)
|
||||||
|
_hold_s = _freeze_s[0] if _freeze_s else None
|
||||||
|
_lo, _hi = ((float(_freeze_schema[0][0]), float(_freeze_schema[0][1]))
|
||||||
|
if _freeze_schema else (None, None))
|
||||||
|
check("the shipped meter_freeze_s is still the measured 300 s that freeze.py, "
|
||||||
|
"config.yaml's own comment and test_freeze.py are all written at",
|
||||||
|
_hold_s == 300.0)
|
||||||
|
check(f"...and it is inside its own schema range ({_lo}-{_hi} s)",
|
||||||
|
None not in (_lo, _hi, _hold_s) and _lo <= _hold_s <= _hi)
|
||||||
|
|
||||||
|
# The two properties the number has to have, DERIVED rather than restated - so
|
||||||
|
# they go red on a value that is merely schema-legal. Both come straight from
|
||||||
|
# config.yaml's own comment on the key.
|
||||||
|
#
|
||||||
|
# upper: the alert must land inside the 900 s quarter that bills the capacity
|
||||||
|
# tariff, or the notification arrives after the money is spent;
|
||||||
|
# lower: the slowest a HEALTHY meter can tick a 1 Wh register and still look
|
||||||
|
# unchanged for hold_s is 3600/hold_s W, and that must stay inside the
|
||||||
|
# deadband or the conjunction's two terms stop being mutually
|
||||||
|
# exclusive (freeze.py, "WHERE hold_s COMES FROM").
|
||||||
|
_QUARTER_S = 900.0
|
||||||
|
_shipped_deadband = [float(v) for v in re.findall(
|
||||||
|
r"^ deadband_w: (\d+(?:\.\d+)?)\s*$", _txt, re.M)]
|
||||||
|
check(f"config.yaml carries exactly one deadband_w default {_shipped_deadband}",
|
||||||
|
len(_shipped_deadband) == 1)
|
||||||
|
_db = _shipped_deadband[0] if _shipped_deadband else None
|
||||||
|
_slowest_healthy_w = 3600.0 / _hold_s if _hold_s else None
|
||||||
|
print(f" shipped meter_freeze_s {_hold_s} s: alert inside the "
|
||||||
|
f"{_QUARTER_S:.0f} s billing quarter, and a healthy meter slow enough to "
|
||||||
|
f"look unchanged for it is {_slowest_healthy_w} W against a "
|
||||||
|
f"{_db} W deadband")
|
||||||
|
check(f"...the alarm still lands inside the {_QUARTER_S:.0f} s billing quarter",
|
||||||
|
_hold_s is not None and _hold_s < _QUARTER_S)
|
||||||
|
check("...and a healthy meter that looks unchanged for it is still inside the "
|
||||||
|
"deadband, so the two terms stay mutually exclusive",
|
||||||
|
_slowest_healthy_w is not None and _db is not None
|
||||||
|
and _slowest_healthy_w < _db)
|
||||||
|
|
||||||
|
# ⚠️ AND THE RANGE'S OWN BOUNDS. "300 sits inside the range" is worth nothing
|
||||||
|
# while the range itself is unpinned: widened to `int(1,86400)` an operator can
|
||||||
|
# configure `meter_freeze_s: 86400` and the guard is dead for a day at a time,
|
||||||
|
# with every check in this repo still green. That mutation survived the first
|
||||||
|
# cut of this PR and is what this block closes. Both numbers are argued for in
|
||||||
|
# config.yaml's own comment and in freeze.py, and each is re-derived here:
|
||||||
|
#
|
||||||
|
# 60 s the floor freeze.py calls NOT A RECOMMENDATION - there a healthy
|
||||||
|
# meter can look unchanged at 60 W, four times the shipped deadband, so
|
||||||
|
# the conjunction's two terms stop being mutually exclusive and only
|
||||||
|
# the margin is left;
|
||||||
|
# 3600 s the ceiling - already four times the billing quarter, so the top of
|
||||||
|
# the range has given up detecting a freeze inside the quarter that
|
||||||
|
# bills it. It is the last bound between a supported configuration and
|
||||||
|
# a disabled guard, and moving it is a decision rather than a tidy-up.
|
||||||
|
print(f" schema range {_lo}-{_hi} s: at the floor a healthy meter that looks "
|
||||||
|
f"unchanged is "
|
||||||
|
+ (f"{3600.0 / _lo:.0f} W" if _lo else "?")
|
||||||
|
+ f" against a {_db} W deadband; the ceiling is "
|
||||||
|
+ (f"{_hi / _QUARTER_S:.1f}x" if _hi else "?")
|
||||||
|
+ " the billing quarter")
|
||||||
|
check(f"the schema range itself is still the argued 60-3600 s (found "
|
||||||
|
f"{_lo}-{_hi})", (_lo, _hi) == (60.0, 3600.0))
|
||||||
|
check("...a floor that gives up the mutual exclusion and keeps only the "
|
||||||
|
"margin, which is why config.yaml calls it not a recommendation",
|
||||||
|
None not in (_lo, _db) and 3600.0 / _lo > _db)
|
||||||
|
check("...and a ceiling already past the billing quarter, so no value the "
|
||||||
|
"schema still accepts can alarm inside the quarter that bills it",
|
||||||
|
_hi is not None and _hi > _QUARTER_S)
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
print("the KNOWN cross-file default divergence, pinned rather than commented")
|
print("the KNOWN cross-file default divergence, pinned rather than commented")
|
||||||
# ⚠️ WHAT THIS GUARDS, AND WHY IT ASSERTS THE BUG RATHER THAN THE FIX.
|
# ⚠️ WHAT THIS GUARDS, AND WHY IT ASSERTS THE BUG RATHER THAN THE FIX.
|
||||||
|
|||||||
@@ -274,6 +274,16 @@ from app.main import Controller # noqa: E402
|
|||||||
class _Hass:
|
class _Hass:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.writes = []
|
self.writes = []
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
# ⚠️ ADDED FOR SAFETY-03, AND THE REASON IS WORTH READING. The e2e below
|
||||||
|
# runs a DELIBERATELY INVERTED loop (gain -0.6) into a steady 2000 W import,
|
||||||
|
# which is a loop that winds straight into the rail - so the output-clamp
|
||||||
|
# alarm fires here, and the stub not having `call` is how that was
|
||||||
|
# discovered. This scenario is now a second, independent witness that the
|
||||||
|
# clamp alarm sees a real winding loop; test_clamp.py asserts it explicitly.
|
||||||
|
async def call(self, domain, service, data):
|
||||||
|
self.calls.append((domain, service, data))
|
||||||
|
|
||||||
async def limits(self, entity):
|
async def limits(self, entity):
|
||||||
return (-5000.0, 5000.0, 1.0)
|
return (-5000.0, 5000.0, 1.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user