Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a617c5482 | ||
|
|
2097b7aaf6 |
@@ -1,18 +0,0 @@
|
||||
# This add-on is deployed to a Linux container. core.autocrlf=true on the
|
||||
# authoring box gave the checkout CRLF, so a plain copy shipped CRLF files -
|
||||
# run.sh with CRLF is a "bad interpreter" failure, and any hash-based drift
|
||||
# check between repo and deployment fails for a reason that has nothing to do
|
||||
# with the code. Deploy with:
|
||||
# git -c core.autocrlf=false archive release/1.0 goodwe_controller | tar -x
|
||||
# which is how 0.3.0 went out, byte-identical to the blobs.
|
||||
* text=auto eol=lf
|
||||
*.png 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
|
||||
@@ -1,22 +0,0 @@
|
||||
# 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
|
||||
@@ -1,136 +0,0 @@
|
||||
# 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
-33
@@ -12,7 +12,7 @@ a GoodWe GW5000S-BP.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| running | `local_goodwe_controller` **0.4.0**, `boot: auto`, holding grid at zero |
|
||||
| running | `local_goodwe_controller` **0.2.0**, `boot: auto`, holding grid at zero |
|
||||
| installed as | a **Local** add-on: `/addons/goodwe_controller` on HA OS at 192.168.2.158 |
|
||||
| repo | Gitea `admin/goodwe-addon` (ssh://git@192.168.2.147:2222) — **private** |
|
||||
| tuning | gain 0.6, `max_w` 5000, slew 1000, deadband 15, step 10, saturation 500/3, heartbeat 10 s |
|
||||
@@ -52,40 +52,9 @@ Run the checks before deploying. No framework needed:
|
||||
|
||||
```bash
|
||||
cd goodwe_controller
|
||||
python3 test_control.py && python3 test_maintenance.py && python3 test_arbiter.py \
|
||||
&& python3 test_validate.py && python3 test_p1.py && python3 test_watchdog.py \
|
||||
&& python3 test_loop.py
|
||||
python3 test_control.py && python3 test_maintenance.py && python3 test_arbiter.py
|
||||
```
|
||||
|
||||
`test_watchdog.py` covers SAFETY-02's logic, its arming gate (derived from the
|
||||
real arbiter, not passed in), its trip path, and the fact that the latch survives
|
||||
an add-on restart. Its headline validation is not here at all: `sim/replay_sign.py`
|
||||
in the infra repo drives the same module over the two captured RS485 days and must
|
||||
fire on all four of the 2026-08-20 episodes and stay silent on 2026-08-23. That
|
||||
needs both checkouts:
|
||||
|
||||
```bash
|
||||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 sim/replay_sign.py
|
||||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 sim/replay_sign.py --stub
|
||||
```
|
||||
|
||||
The second run checks the grader still *discriminates* rather than merely still
|
||||
passing — a watchdog that never trips and one that always trips must both be
|
||||
rejected.
|
||||
|
||||
⚠️ **If you are testing the failsafe by hand, note that stopping and starting the
|
||||
add-on will not clear a trip.** That is the point of it; use the *Start / stop
|
||||
control* button twice, or delete `sign_tripped` from `/data/state.json`.
|
||||
|
||||
⚠️ `test_loop.py` takes about **95 seconds** (measured 93 s) and that is not a bug. It
|
||||
drives the real control loop, which sleeps 1 s a tick, eight times over, to prove a
|
||||
wall-clock property about it (SAFETY-12); mocking the clock there would leave the
|
||||
assertion testing the mock. The other six suites together are ~440 checks and finish in
|
||||
seconds — `test_watchdog.py` spends ~3 s of that driving a real `Controller` through a
|
||||
trip. If you shorten `test_loop.py`'s windows, re-measure and correct this figure —
|
||||
an understated runtime is what makes a slow test look like a hung one, and a slow test
|
||||
that looks hung gets deleted.
|
||||
|
||||
---
|
||||
|
||||
## 3. Known broken / unfinished
|
||||
|
||||
@@ -1,964 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
**The grid-following loop is now capped against the month's billed capacity peak
|
||||
(SAFETY-07).** New `app/peakguard.py`. TARIFF-01 measured the quarter; this is the
|
||||
consumer it was built for. Every cycle the guard projects the open quarter,
|
||||
compares it to a ceiling and files ONE arbiter claim: a lower bound on the
|
||||
setpoint at `P_PEAK`. That shape is both behaviours §10.1 asks for — a hard clamp
|
||||
on charging and pre-emptive discharge — and it cannot cap discharge or export,
|
||||
because a floor cannot. `excess = 900 × (projected − ceiling) ÷ remaining`, and
|
||||
the floor is the MEASURED battery power plus that: a floor built on the standing
|
||||
command winds itself to the rail whenever the inverter fails to follow, and one
|
||||
built on delivered power cannot.
|
||||
|
||||
**It trips where SAFETY-18 only notifies, and the captures decided that.** On the
|
||||
healthy reference day it engages on zero of 95 quarters; the action is
|
||||
level-triggered, unlatched and re-derived every cycle, so a false engagement costs
|
||||
one cycle of discharge rather than a parked battery. Replayed on 2026-08-20 the
|
||||
billed quarter goes from **5029.8 W — above the inverter's own 5000 W rating — to
|
||||
3485 W**, under the soft limit and without saturating. Quarters under the limit
|
||||
across both committed days: 189/190 → 190/190. Two days is not thirty; AC 3 is
|
||||
still TARIFF-01's open item and this does not claim it.
|
||||
|
||||
**Blindness forbids importing, and it is checked before the projection is
|
||||
graded.** A fresh averager projects a confident 0 W, which is under every ceiling
|
||||
there is; a meter gap longer than `meter_max_age_s` makes the projection read LOW;
|
||||
and a frozen meter reading export projects 0 W at full coverage. All three become
|
||||
a floor of 0 W, which is one-directional and cannot cost pack energy. That is the
|
||||
backstop SAFETY-18 could not be — its alarm arrives at 300 s of a 900 s quarter
|
||||
and commands nothing. The frozen term is `freeze.looks_frozen()` itself, evaluated
|
||||
on this cycle's standing command, not a second copy of the conjunction.
|
||||
|
||||
**The ceiling is `peak_cap_w`, raised only by an OBSERVED reading.** `NO_RECORD`
|
||||
and `NOT_OBSERVED` both leave it at the cap, so an unreadable peak store makes the
|
||||
guard STRICTER. The gate is the reading's STATE, not whether a number happens to
|
||||
be attached to it: today a `peak_w is not None` test behaves identically, and
|
||||
depending on that coincidence is how a future last-known-peak field would silently
|
||||
raise the ceiling off a read that failed. `headroom_w()` is deliberately not
|
||||
consumed — `cap − peak` turns a zero peak into FULL freedom.
|
||||
|
||||
**Two defects found building it, both in the interaction rather than the
|
||||
arithmetic.** A LIMIT does not out-vote another LIMIT — `arbiter.resolve()` applies
|
||||
every limit at or above the winner's priority and takes the most restrictive — so
|
||||
the peak floor (`lo ≥ 0`) and the maintenance charge-only shaping (`hi ≤ 0`)
|
||||
resolved to a CONTRADICTION and 0 W, parking the battery in the exact quarter it
|
||||
should shave. Fixed by suppression rather than priority: main.py drops the
|
||||
maintenance shaping while the guard is engaged, which is what that branch already
|
||||
did for the optional `peak_forecast_entity` — a key that is empty on a default
|
||||
install, so until now the suspension could not fire on the site that needed it.
|
||||
And thresholding `excess > 0` released the claim the instant it started working,
|
||||
producing seventeen engage/release cycles in one 900 s quarter (§10.2's
|
||||
oscillation) and leaving that quarter 49 W over; the threshold is now the floor
|
||||
itself, which is the same arithmetic read as a level rather than as a switch.
|
||||
|
||||
**`PeakTracker.project()` no longer reads a private name with a default.**
|
||||
`getattr(averager, "_last_net", 0.0)` failed quiet AND in the disarming direction:
|
||||
a rename credits 0 W held, the projection reads low, and the money guard does not
|
||||
engage. `QuarterAverager.held_net_w` is now a documented public property, read
|
||||
without a default, and `Projection` carries it. TEST-13 item 4.
|
||||
|
||||
**No new option, and no fourth defaults site.** The ceiling is `peak_cap_w`, the
|
||||
coverage threshold is `meter_max_age_s`, the freeze predicate is `meter_freeze_s`,
|
||||
and the achievable discharge comes from `max_w` and the device rating.
|
||||
`config.yaml` and `validate.py` are untouched; §14's `capacity_peak_limit_kw`
|
||||
stays declined as the kW/W mismatch trap it is. On `meter_source: off` there are
|
||||
no telegrams, so the guard does not run at all and the status page says NOT
|
||||
COVERED — running it there would have blinded it permanently and stopped every
|
||||
legacy install from charging, which is what the first cut did until
|
||||
`test_clamp.py`'s export case caught it.
|
||||
|
||||
**Static day/night and fully-fixed tariffs, first-class (TARIFF-03).**
|
||||
§9.2 puts a large share of the Belgian installed base on a static profile, and
|
||||
an EMS that only works under dynamic pricing is an EMS for a minority. A new
|
||||
`static_profile` option describes a two-period day/night tariff — or a single
|
||||
fixed price — as JSON, and `app/static_tariff.py` turns it into the same
|
||||
canonical `{start, eur_per_kwh, provisional}` series the fetched providers emit,
|
||||
so nothing downstream learns that a tariff can be static.
|
||||
|
||||
**It is not a sixth provider plugin, and the reason is mechanical.**
|
||||
`PriceProvider.capabilities()` is a classmethod, so a plugin's `PriceProduct` is
|
||||
fixed when the class is written — but §9.2 makes STATIC and FIXED two different
|
||||
products with two different required behaviours, and which one a site is on is a
|
||||
property of the operator's data, not of any class. `StaticProfile.product`
|
||||
derives it from the number of distinct prices instead. The second reason: the
|
||||
plugin framework exists to stop a *fetched* figure being marked up twice, and a
|
||||
number the operator typed as all-in has no publisher to disagree with.
|
||||
|
||||
**So the profile sits where §11.4 already says it does — under the providers, as
|
||||
the fallback.** The configured-product path and the feed-is-down path are the
|
||||
same generator differing in one flag: `provisional`. A static-tariff site runs
|
||||
that path every day, so the degradation path for a dynamic site cannot rot
|
||||
unused.
|
||||
|
||||
**"No arbitrage" is a type, not a boolean somebody has to remember to check.**
|
||||
`TariffPlan.arbitrage()` returns a variant: the *allowed* arm carries the
|
||||
spread and the cheapest and dearest hours, and the *refused* arm carries a
|
||||
reason and nothing else — `.spread_eur_kwh` on a refusal raises rather than
|
||||
answering 0.0. An optimiser cannot compute a trade from a refusal because it
|
||||
cannot get the inputs out of one. Four refusals, all derived: the peak guard is
|
||||
holding, there is no price data, the plan is provisional, or **the series is
|
||||
flat**. That last one is AC2 — a fully-fixed tariff disables arbitrage by
|
||||
arithmetic rather than by matching on an enum, and a dynamic feed that happens
|
||||
to print one price all day is refused for the same true reason.
|
||||
|
||||
**Peak shaving outranks arbitrage, expressed so OPT-04 cannot skip it.**
|
||||
`arbitrage()` takes `peak_guard_active` as a required keyword argument, so the
|
||||
spread is not obtainable without first answering whether §10.1's priority 2 is
|
||||
holding. §9.1's capacity tariff is a network cost charged whatever the energy
|
||||
product is.
|
||||
|
||||
**Time-window shifting reaches the one shiftable load that exists.** main.py's
|
||||
cheap-window flag — previously fed only by the `price_now_entity` /
|
||||
`price_avg_entity` pair — now falls back to the static profile, and the Web UI
|
||||
gained a **Tariff** row naming the window, the price, when it next changes and
|
||||
why it is or is not cheap. The rule is *strictly* below the seven-day mean:
|
||||
under a flat tariff `<=` would call every hour cheap and a fixed-tariff site
|
||||
would top up from the grid at any hour, which is the opposite of
|
||||
self-consumption only. Seven days rather than 48 hours because a 48 h mean taken
|
||||
on a Friday is two-thirds weekend, and the same Tuesday 09:00 would come out
|
||||
cheap or dear depending on when you asked.
|
||||
|
||||
**A profile must tile every day type exactly once, checked at startup.** A gap
|
||||
is refused with the uncovered range named rather than filled in — an invented
|
||||
price is one an optimiser would trade on — and an overlap is refused because
|
||||
otherwise one of two prices wins silently on list order. The trap this catches
|
||||
in practice: `22:00–07:00 mon-fri` does **not** cover Saturday morning, because
|
||||
a window applies on the day the clock reads.
|
||||
|
||||
**validate.py rule 7's stated reason expired, exactly as TARIFF-02 predicted.**
|
||||
That ticket left a tripwire in `test_price.py` section 12 asserting that nothing
|
||||
in `app/` consumed `load_prices()`, with instructions to fix the refusal's
|
||||
wording in the same commit when it went red. It went red naming
|
||||
`static_tariff.py`. The message now says what is true instead — the series *is*
|
||||
consumed, but main.py acts on the static side of it and not on a fetched one —
|
||||
and the tripwire is re-aimed rather than removed: the consumer set is pinned, so
|
||||
a second consumer (OPT-04) goes red again, and the expired phrase is asserted
|
||||
gone so a bad merge cannot restore it. Rule 7 also grew a second refusal:
|
||||
`static_profile` alongside the Home Assistant price pair is the same
|
||||
silent-loser conflict, and a live one.
|
||||
|
||||
**No new place for an option default (CFG-03 is not widened).**
|
||||
`static_profile` was added to `app/price.py`'s `PRICE_OPTION_DEFAULTS` — the one
|
||||
in-code mirror TARIFF-02 created and pinned to `config.yaml` by a check — rather
|
||||
than to a defaults dict in the new module, which has none. main.py grew no
|
||||
`o.get("static_profile", …)` fallback. That is TARIFF-02's pattern, stated
|
||||
here so a reviewer does not have to infer it.
|
||||
|
||||
**Measured, on the data that exists.** `test_static_tariff.py` replays the two
|
||||
committed days of this house's real P1 meter (`scenarios/p1-net-2026-08-{20,23}`)
|
||||
priced under a Belgian day/night profile. 2026-08-20 is a Thursday: 15.220 kWh
|
||||
imported, 9.606 in the day window and 5.614 in the night one, EUR 4.7185 under
|
||||
day/night against EUR 4.6725 flat, and shifting the day-window energy into the
|
||||
night window saves EUR 0.8069 — which is 9.606 kWh x the 0.0840 spread, derived
|
||||
twice and required to agree. 2026-08-23 is a Sunday: 0.728 kWh, **all** of it
|
||||
night-priced, which is the weekend row proving itself on real data. §17 names
|
||||
six Belgian scenarios and a static-profile case is not among them; this is the
|
||||
seventh, and it is two captured days rather than a season — the file says so
|
||||
rather than implying an annual figure.
|
||||
|
||||
Also worth knowing before OPT-04: on realistic Belgian day/night numbers the
|
||||
whole-tariff spread is about 0.08 EUR/kWh, **below** the shipped
|
||||
`cycle_cost_threshold_eur` of 0.10. A correct optimiser will decline to cycle
|
||||
for the tariff alone on such a site. That is a product finding, not a defect.
|
||||
|
||||
**Price provider plugins, and one canonical all-in price format (TARIFF-02).**
|
||||
`app/price.py` ships five providers - `generic_dayahead` (wholesale EPEX BE),
|
||||
`engie_dynamic`, `totalenergies_dynamic`, `eneco_eddy` and the offline
|
||||
`manual_csv` - all emitting exactly `{"prices": [{"start", "eur_per_kwh",
|
||||
"provisional"}]}`, tz-aware at the Brussels offset, EUR/kWh, all-in. **Nothing
|
||||
consumes it yet**: this is the seam, landed first, so that the arbitrage work
|
||||
that follows never learns a supplier's name.
|
||||
|
||||
**Double-counting the markup is refused rather than discouraged, given a
|
||||
correct declaration.** A plugin does not return a price; it returns the figure
|
||||
its source published and declares which of {energy, markup, taxes} that figure
|
||||
contains. The framework adds the complement, once. Engie and TotalEnergies
|
||||
publish a retail figure with their margin already inside; adding the configured
|
||||
3 c/kWh on top would make every future arbitrage decision wrong by that amount
|
||||
with nothing reporting it.
|
||||
|
||||
Two mechanisms, because one was not enough. `PluginOptions` raises on the
|
||||
framework-owned keys - and review of PR #16 then wrote a double-counting plugin
|
||||
three more ways, one of them touching nothing but `PRICE_OPTION_DEFAULTS`, a
|
||||
public dict in the module every plugin already imports. So `register()` now also
|
||||
**refuses at import any plugin whose own code mentions a framework key**
|
||||
(`code_leaks()`, docstrings stripped via `ast` so a plugin's mandated
|
||||
`SOURCES:` line does not false-positive). Honest wholesale baseline 0.188
|
||||
EUR/kWh; every attack landed 0.219.
|
||||
|
||||
**The guarantee's precise shape**, stated because an overclaim is worse than no
|
||||
claim: the framework adds the complement exactly once *given a correct `sources`
|
||||
declaration*. A plugin that declares `{energy}` while its feed is really all-in
|
||||
still comes out one markup high while touching no framework name, and no code
|
||||
can catch that - the declaration is the plugin's unverifiable claim about
|
||||
somebody else's tariff. What is mechanised is reachability; what stays human is
|
||||
truthfulness.
|
||||
|
||||
**The product type is declared, not inferred from the plugin's name.**
|
||||
`eneco_eddy` is a real-time/imbalance product and gets widened arbitrage
|
||||
thresholds; that reaches a consumer through `capabilities().product` and
|
||||
`volatility_regime()`, never through a name comparison. `test_price.py` asserts
|
||||
mechanically that no plugin id appears anywhere in the framework's own code,
|
||||
and drives a sixth provider defined inside the test file to the correct regime
|
||||
with `price.py` untouched.
|
||||
|
||||
**"No price data" is a type, not an empty list.** Following the same rework
|
||||
`sim/riglock.py` took, a fetch returns `PriceRead.series(...)` or
|
||||
`PriceRead.no_data(reason)`, and only the first carries fields. An empty series
|
||||
is not constructible; `no_data` has no `.points` to iterate, no `.at()`, and
|
||||
`.get("points", [])` raises rather than returning the default. **A day of free
|
||||
electricity and a day with no prices are different instructions** - arbitrage is
|
||||
pointless under the first and forbidden under the second - and an implementation
|
||||
that returns `[]` on a failed fetch cannot tell them apart. Every site-level
|
||||
failure returns `no_data`; nothing raises into the control tick.
|
||||
|
||||
**What is not verified, and cannot be by an offline suite:** every fixture is
|
||||
synthetic, so the five feed URLs and JSON shapes are checked against a belief
|
||||
about what those suppliers publish, not against the feeds. The parse layer is
|
||||
exercised; the contract with each supplier is not. Expect the first live fetch
|
||||
of each to need a shape fix, and note that a wrong shape degrades to
|
||||
no-price-data rather than to a wrong price.
|
||||
|
||||
**`manual_csv` is the documented offline fallback** and needs no network, no
|
||||
supplier account and no HTTP stack. See DOCS.md for the format. Its `start`
|
||||
column must carry a UTC offset - a bare local timestamp is refused, because one
|
||||
hour every October names two moments.
|
||||
|
||||
**Injection is priced separately** (`export_price_eur_kwh`), a scalar rather
|
||||
than a second series, and negative values are accepted. Consumption plugins
|
||||
cannot reach it.
|
||||
|
||||
**`price_provider` may not be combined with `price_now_entity` /
|
||||
`price_avg_entity`** - the add-on refuses to start on that pair. The Home
|
||||
Assistant sensors are the existing delegation of pricing to HA and still work
|
||||
alone; with both configured they would silently keep deciding while the status
|
||||
page named a supplier that decided nothing.
|
||||
|
||||
**No timezone database is used.** The shipped Alpine image has no `tzdata`, so
|
||||
Brussels offsets are computed from the EU rule and every instant is carried as
|
||||
UTC and rendered at the offset that applies to it. The checks drive both 2026
|
||||
transitions - the 23-hour and 25-hour days - rather than a normal day, where a
|
||||
fixed 24-slot implementation and a correct one agree.
|
||||
|
||||
**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
|
||||
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
|
||||
shaping files `Claim.limit(hi = -maintenance_charge_w)` against the supervised
|
||||
SAFETY limit's `lo = -max_w`, so `lo > hi` — the arbiter's clause 3, correctly
|
||||
resolved to **0 W** and logged as a CONTRADICTION. **Maintenance silently never
|
||||
charged in a cheap window on a default install**, and the only evidence read
|
||||
like a bug in the arbiter rather than like the two numbers an operator set.
|
||||
Measured against the real arbiter, loop asking +500 W:
|
||||
|
||||
```
|
||||
max_w=2000 hi=-2500 -> +0 W contradiction=True
|
||||
max_w=2500 hi=-2500 -> -2500 W limited by supervised(max_w)
|
||||
max_w=3000 hi=-2500 -> -2500 W limited by maintenance(cheap-window charge)
|
||||
```
|
||||
|
||||
`validate_startup_config()` now refuses `maintenance_charge_w > max_w` (rule 6),
|
||||
the same shape as CFG-01's `sign_disagree_w >= max_w`: a number the operator set,
|
||||
overridden by a layer they cannot see. Equality is reachable (`-max_w` is a
|
||||
single valid point) and deliberately not refused. Verified by starting the
|
||||
add-on on the pre-fix pair: exit 78, the refusal banner, no traceback.
|
||||
|
||||
**`maintenance_discharge_w` was checked, not assumed, and it is NOT the same
|
||||
case.** The shaping is not symmetric. The drain phase does not shape the loop,
|
||||
it OWNS the actuator — `Claim.set(P_MAINTENANCE, +discharge_w)` — and a SET is
|
||||
*clamped* by the limits above it rather than contradicted by them. 2500 against
|
||||
a 2000 W ceiling emits **+2000 W**, and the arbiter's own `explain()` prints
|
||||
`maintenance -> 2000 W, limited by supervised(max_w)` every cycle. Nothing is
|
||||
silent and energy still moves; the cycle completes slower (13.8 kWh usable at
|
||||
2000 W is ~6.9 h against the 720 min drain timeout). **Warned about, not
|
||||
refused.**
|
||||
|
||||
**The shipped defaults changed so the validator does not reject them.**
|
||||
`maintenance_charge_w` and `maintenance_discharge_w` are now **2000**, at the
|
||||
shipped `max_w`. `max_w` was deliberately NOT raised instead: it is the
|
||||
supervised safety ceiling and raising a default upward ships more power to every
|
||||
new install. Raising `max_w` (the reference install runs 5000) is what unlocks a
|
||||
faster maintenance cycle — raise it first, then these. `test_validate.py` now
|
||||
reads `config.yaml` itself and asserts the shipped numbers pass the rule, so a
|
||||
default that the validator refuses cannot ship again.
|
||||
|
||||
### ⚠️ Upgrading an existing default install: the add-on may refuse to start
|
||||
|
||||
An install still carrying `maintenance_charge_w: 2500` against `max_w: 2000`
|
||||
now meets rule 6 and **exits 78 with the refusal banner** instead of starting.
|
||||
That is the intended behaviour — the configuration was already broken, it was
|
||||
just silent about it — but an add-on that will not come back after an update
|
||||
needs the fix stated here rather than only in the log.
|
||||
|
||||
**The fix, either way: set `maintenance_charge_w` to `2000` or lower, OR raise
|
||||
`max_w` to `2500` or higher** (Settings → Add-ons → GoodWe RS485 Controller →
|
||||
Configuration), then start it. The banner in the log says the same thing and
|
||||
names both numbers.
|
||||
|
||||
Whether you hit this at all depends on whether the Supervisor kept the old
|
||||
value, and **both cases are covered above because we did not verify which one
|
||||
applies here.** The Supervisor merges an add-on's *stored* options over the
|
||||
defaults in `config.yaml`, so:
|
||||
|
||||
- **A key the operator never edited** re-derives from the new `config.yaml` and
|
||||
becomes 2000 on upgrade. Non-event, nothing to do.
|
||||
- **A key the operator did set** persists at 2500 and the add-on refuses to
|
||||
start until it is changed. Note this includes anyone who used the
|
||||
Configuration page's YAML editor, which writes the whole options block back —
|
||||
so "I never touched that field" is not a guarantee the old value is gone.
|
||||
|
||||
⚠️ **This is reasoned from the Supervisor's documented merge behaviour, not
|
||||
measured on the live install.** The reference install's stored options could not
|
||||
be read (`/api/hassio/` refuses a long-lived token) and probing it was out of
|
||||
scope — it regulates a real battery. It runs `max_w: 5000` and is unaffected
|
||||
either way.
|
||||
|
||||
⚠️ **Known, deliberately left, and worth a follow-up:** `main.py` and
|
||||
`maintenance.py` still fall back to **2500** when the key is absent from the
|
||||
options dict, while `config.yaml` and `validate.py` now say 2000. Unreachable on
|
||||
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
|
||||
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
|
||||
|
||||
**SAFETY-02's latch now survives an add-on restart, which is the difference
|
||||
between a latch and a pause.** `auto` was persisted and `sign_wd.tripped` was
|
||||
not, so reconstructing the `Controller` against the same store — exactly what a
|
||||
container restart does — brought it back **armed, into the same fault, with no
|
||||
operator involved**: `tripped=False`, banner back to `NOT READY`, and it resumed
|
||||
commanding (measured: −1000 W, −2000 W) before re-tripping 180 s later. Under a
|
||||
Supervisor that restarts a crashed add-on that is a loop of three-minute
|
||||
wrong-direction bursts — and "restart the add-on" is the first thing an operator
|
||||
looking at a `FAILSAFE` banner and a 0 W inverter will try.
|
||||
|
||||
`sign_tripped` and `sign_reason` are now `Store` keys beside the maintenance
|
||||
state, written the moment the trip happens (reason first, flag second, so a crash
|
||||
between the two can never persist a latch with no explanation) and restored in
|
||||
`Controller.__init__`. Cleared only by the operator's start. `DOCS.md` said "it
|
||||
does not clear itself"; that was false across a restart and now is not.
|
||||
|
||||
Also in this release, all of it review follow-up rather than new behaviour:
|
||||
|
||||
- **The arming gate is tested against the real arbiter.** It was the decision the
|
||||
whole ticket turns on and it was asserted in three comment blocks and verified
|
||||
by nothing — every case passed `following` in as a literal. `test_watchdog.py`
|
||||
now derives it from `resolve()` across twelve claim configurations, including
|
||||
the redirect case `winner == "loop"` alone would have got wrong and the `lo`
|
||||
limit that raises magnitude without flipping sign.
|
||||
- **The MQTT `status: "failsafe"` value is tested.** The check that claimed to
|
||||
cover it restated the assertion three lines above it and never touched the
|
||||
payload.
|
||||
- **A forward hazard written down where it will be read, and made to bite.**
|
||||
`winner == "loop"` disarms the watchdog under any higher-priority SET claim.
|
||||
Today only maintenance and the safety stops emit one and neither follows the
|
||||
meter; a peak-shaving strategy at `P_PEAK` **does**, and would blind the
|
||||
watchdog for every peak event.
|
||||
|
||||
The gate is now `watchdog.loop_is_following()`, called by both `main.py` and
|
||||
the test, for the same reason `disagrees()` was extracted: the test held a
|
||||
*transcription* of the shipped expression, so editing the shipped gate alone to
|
||||
`winner in ("loop", "peak")` left every suite green while the watchdog went
|
||||
blind. Two copies, and the one that drifts is the one doing the grading.
|
||||
|
||||
⚠️ Extracting it was not sufficient on its own. The realistic P_PEAK row —
|
||||
peak shaving asking for *more* than the loop — still could not detect the
|
||||
widened gate, because the magnitude term rejects it regardless and masks the
|
||||
winner term. A second row with peak asking for *less* isolates `winner` as the
|
||||
only deciding term, and that is the one that turns red.
|
||||
- **The worked example described a configuration that does not ship.** At the
|
||||
defaults `maintenance_charge_w` (2500) exceeds `max_w` (2000), so that claim is
|
||||
an arbiter *contradiction* resolving to 0 W, not a −2500 W charge. The example
|
||||
is now written at 1500 W. (The contradiction is a real pre-existing bug —
|
||||
a default cheap-window maintenance charge silently never happens — filed
|
||||
separately, deliberately not fixed here.)
|
||||
- **`disagrees()` is public**, so `sim/replay_sign.py` stops carrying its own copy
|
||||
of the predicate it grades.
|
||||
- `held_s` takes the clock instead of reading one, restoring the pure-function
|
||||
property the module advertises.
|
||||
- **The `sign_reason`-before-`sign_tripped` write ordering is enforced, not just
|
||||
documented.** Swapping the two lines used to pass every check. A store that
|
||||
fails the second write now proves the property the ordering exists for: what
|
||||
survives a crash mid-trip is a reason with no latch, never a latch with no
|
||||
explanation.
|
||||
- Measured figures corrected throughout: 93.4 % / 4212 of 17280 (was 92.9 % /
|
||||
4127 of 16349), per-cluster 0.3 / 0.9 / 10.5 / 0.0 %.
|
||||
|
||||
## 0.5.0
|
||||
|
||||
**SAFETY-02 — the sign-disagreement watchdog.** The add-on now watches whether
|
||||
the setpoint it is emitting and the grid figure it is regulating on point the
|
||||
same way. If they oppose each other, both over `sign_disagree_w` (300 W), for
|
||||
`sign_disagree_s` (180 s) **continuously**, it files a failsafe claim at
|
||||
`P_SAFETY`, commands **0 W**, and latches there until an operator starts control
|
||||
again. New module `app/watchdog.py`; new options `sign_disagree_w` and
|
||||
`sign_disagree_s`.
|
||||
|
||||
This is the gap the existing failsafes leave open. The stale-input watchdog asks
|
||||
whether the meter is still talking, the saturation freeze asks whether the
|
||||
inverter can follow, the output clamp asks whether the number is too big — a
|
||||
loop can pass all three while charging the battery out of the grid, because the
|
||||
only thing wrong is the sign. On 2026-08-20 a commercial controller did exactly
|
||||
that four times in one day, for up to 77 minutes at a stretch, and reached a
|
||||
commanded −14 768 W against a ±5000 W inverter.
|
||||
|
||||
Validated against that day. `sim/replay_sign.py` in the infra repo drives this
|
||||
module over the committed RS485 captures of both 2026-08-20 (the four episodes)
|
||||
and 2026-08-23 (healthy): **18 trips on the fault day, every one inside a window
|
||||
`NOTES.md` §4.7 accounts for, and 0 on the healthy day.** The four episodes do
|
||||
share one signature rather than merely resembling each other — sign agreement
|
||||
runs 0.3 / 0.9 / 10.5 / 0.0 % inside them against 93.4 % on the healthy day.
|
||||
|
||||
Three decisions worth knowing about:
|
||||
|
||||
- **It is disarmed whenever the grid-following loop is not what is driving.**
|
||||
Sign agreement is a property of a loop that follows the meter. A maintenance
|
||||
grid charge opposes the meter *by design* — 2500 W of charge into a house
|
||||
drawing 500 W is +3000 W on the meter against a −2500 W command, for hours —
|
||||
and armed unconditionally this would trip on every maintenance cycle. The gate
|
||||
tests the emitted number, not the winning claim's name: a limit claim can let
|
||||
the loop "win" and still replace its value with one pointing the other way.
|
||||
- **The trip latches.** Commanding 0 W removes the command side of the
|
||||
comparison, so the disagreement always clears the moment the trip works.
|
||||
Un-latched, this would trip, clear, and hand the actuator back to the loop that
|
||||
was driving the wrong way, every three minutes forever.
|
||||
- **`sign_disagree_w` at or above `max_w` is refused at startup** (validator rule
|
||||
5). The arbiter never lets a command exceed `max_w`, so such a threshold makes
|
||||
the watchdog incapable of firing while the status page still reports it armed.
|
||||
A disarmed guard that advertises itself as armed is the failure mode this
|
||||
project keeps refusing to ship.
|
||||
|
||||
What it does **not** catch, stated so nobody assumes otherwise: an inverter that
|
||||
acts opposite to what it was told moves the meter *further* the same way, so
|
||||
command and meter agree while everything runs away. That is the saturation
|
||||
freeze's job, which compares the command against the battery's actual power.
|
||||
|
||||
`test_validate.py`'s "the smallest sane max_w still starts" case now names a
|
||||
reachable sign threshold, because a 1 W ceiling genuinely does disarm the 300 W
|
||||
default and is now correctly refused.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
**CFG-01.** The add-on now refuses to start on a configuration it cannot honour,
|
||||
instead of logging a line and carrying on. `validate_startup_config()` runs in
|
||||
`amain()` before the broker, the web server or any task exists, so a refusal
|
||||
cannot leave half a controller running behind a UI.
|
||||
|
||||
It refuses only where continuing produces one of two outcomes: **(a)** a stated
|
||||
limit is untrue - the operator configured a bound and a lower layer silently
|
||||
enforces a different one - or **(b)** the add-on is inert and indistinguishable
|
||||
from healthy. (b) is not a special case of (a): with no `setpoint_entity`, layer
|
||||
1 pinning the inverter at 0 W is the failsafe *working*. The system does not
|
||||
become unsafe, it becomes a convincing impression of a working controller.
|
||||
|
||||
Four things qualify:
|
||||
|
||||
- **`max_w` above (or below) the setpoint device's own rating.** This is the one
|
||||
that changed behaviour. It used to warn `the device wins` and run anyway, with
|
||||
`write_setpoint()` silently clamping, so the configured ceiling was never the
|
||||
real one and the only evidence had scrolled off the log. The refusal names the
|
||||
configured value, the device's value, and which wins.
|
||||
- **No `setpoint_entity`.** A controller with no actuator: every write is a
|
||||
no-op, the device rating is never read, and the ESP32 heartbeat that holds off
|
||||
the layer-1 failsafe never happens. It ran, looked healthy, and moved nothing.
|
||||
- **A `meter_source` that can never produce a reading** - missing `p1_host`,
|
||||
missing `p1_net_entity`, a blank MQTT topic, a phase list disagreeing with
|
||||
`meter_phases`, an unknown source name, or a `meter_poll_s` at or past
|
||||
`meter_max_age_s`. These were already caught, but they only *disabled
|
||||
ingestion*, which leaves the grid reading None forever: permanent "inputs
|
||||
missing", 0 W, and a status page indistinguishable from a healthy meter nobody
|
||||
has sent anything to yet.
|
||||
- **`meter_max_age_s` above the firmware's ingest veto.** The schema accepts
|
||||
`int(5,300)`; the ESP32's threshold is a compile-time `max_age_s: "30"` and it
|
||||
trips layer 1 at `p1_age_s >= 30`. Set 120 and the add-on treats a 119 s
|
||||
reading as fresh and keeps commanding, while the firmware has already vetoed
|
||||
and is holding the inverter at 0 W - healthy meter, running controller,
|
||||
nothing moving, and no line anywhere saying the number you set was overruled.
|
||||
Structurally the same fault as the `max_w` case. Only *above* the veto is
|
||||
refused: below it the add-on gives up first, which is fail-safe and visible on
|
||||
its own status page. Mirrored as `validate.FIRMWARE_INGEST_VETO_S` - it cannot
|
||||
be imported from a compile-time YAML substitution, so it carries a note about
|
||||
the one direction in which it can go stale.
|
||||
|
||||
`max_w` at or below zero is refused too. Schema-blocked on a real install, but
|
||||
`validate_startup_config()` is now also the simulation suite's entry point, and
|
||||
a non-positive `max_w` inverts the arbiter's `supervised` bounds into a
|
||||
contradiction that resolves to 0 W - i.e. it would read as a safety trip rather
|
||||
than as the typo it is.
|
||||
|
||||
The predicates for the meter group still live in `p1.validate_meter_options()`,
|
||||
which `build_source()` also calls - one definition, two callers, not two
|
||||
competing validators. Two transports gained a check they never had: `ha_dsmr`
|
||||
with only one of its two entities, and `mqtt_p1` with a blank topic, both of
|
||||
which previously built a transport that could never read.
|
||||
|
||||
Deliberately **not** refused, because it is untidy rather than unsafe:
|
||||
`integrator_max_w` above `max_w` (the arbiter still bounds the wire to ±max_w,
|
||||
so the cost is unwind latency, not amperage - warned), a missing `soc_entity` /
|
||||
`batt_entity` (fail-safe, and already a red NOT READY banner naming it), a
|
||||
`setpoint_entity` that has not appeared on the Core API yet (ESPHome is routinely
|
||||
late; refusing would make a slow boot need a human - re-checked once the real
|
||||
rating arrives), and a thin-but-nonzero `meter_poll_s` margin.
|
||||
|
||||
A refusal is legible as a refusal: a banner that says in words that this is a
|
||||
configuration problem and nothing is broken, the numbered problems, no traceback,
|
||||
and exit status 78 (`EX_CONFIG`).
|
||||
|
||||
**SAFETY-12.** The control loop now evaluates on a clock as well as on a change.
|
||||
`run_control()` ran `cycle()` only when the meter value differed from the
|
||||
previous poll, so a meter that repeated a value ran **no control cycles at
|
||||
all** - while the heartbeat went on re-emitting the last command and the
|
||||
firmware went on putting it on the wire every 2 s. Layer 1 stayed fed, every
|
||||
indicator read healthy, and a command computed from an old reading kept being
|
||||
executed through whatever the load did next. Measured on the rig during TEL-05:
|
||||
70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
|
||||
|
||||
**It was worse than a latency limit: it was a safety claim that could not
|
||||
fire.** `stale_input_s` and the `inputs missing -> 0 W` claim both live *inside*
|
||||
`cycle()`. Pre-fix, if the meter value happened to be constant, `cycle()` was
|
||||
never called again - so that claim had no way to run. Constant meter, SoC entity
|
||||
dying at t=4 s, same binary:
|
||||
|
||||
```
|
||||
pre-fix writes: [(0.0, 220.0), (0.0, 220.0), (10.1, 220.0)]
|
||||
final target: 220.0 W reason: 'loop -> 220 W'
|
||||
post-fix writes: [(0.0, 220.0), (0.0, 220.0), (3.0, 430.0), (9.1, 0.0), (10.1, 0.0)]
|
||||
final target: 0.0 W reason: 'safety -> 0 W'
|
||||
```
|
||||
|
||||
The pre-fix controller held 220 W through the entire input outage with its
|
||||
status line still reading `loop -> 220 W`, and layer 1 does not cover that case
|
||||
on a `meter_source: off` install: no P1 age sensor is published, so the firmware
|
||||
has nothing to veto on, while the heartbeat keeps its watchdog fed. So the old
|
||||
behaviour was not "holds a stale command safely" - it was a failsafe that a
|
||||
repeating meter switched off. `test_loop.py` asserts both halves.
|
||||
|
||||
`cycle_floor_s` (default **15 s**) is a deadline on the gap between cycles, not
|
||||
a second scheduler: every cycle resets it, whichever trigger fired. On a healthy
|
||||
meter it never elapses, so the cycle rate, the write sequence and therefore the
|
||||
per-cycle gain the tuning was measured against are unchanged - asserted in
|
||||
`test_loop.py` by driving the real loop twice on one binary (a huge
|
||||
`cycle_floor_s` reproduces the old behaviour exactly) and comparing the
|
||||
commanded values.
|
||||
|
||||
The reset is not the only thing holding that up, and the other argument is the
|
||||
sturdier one: the combined cycle rate is `max(arrival rate, 1/floor)`, never the
|
||||
sum. At 1/15 s the floor is strictly slower than the ~1/5 s arrival rate, so
|
||||
even a hypothetical floor racing the arrivals could not exceed the nominal
|
||||
healthy rate. The re-tune is unreachable by construction. ⚠️ That holds only
|
||||
while `cycle_floor_s` stays above the meter's update interval, and the schema
|
||||
minimum is 5 s - which is exactly the reference meter's interval. Nothing
|
||||
enforces the relationship; `validate.py` is the obvious home for it and does not
|
||||
have it yet.
|
||||
|
||||
Observed over six real minutes on the same rig, one binary, meter held at a
|
||||
literally constant 350.0 W: **before, 1 cycle** (at startup) and then nothing,
|
||||
the command stuck at 220 W against a 350 W import for the rest of the window.
|
||||
**After, 24 cycles**, worst gap 15.14 s, and the ordinary PI ramp doing its job -
|
||||
220, 430, 650, 860, 1080, 1300, 1510, 1730, 1940, 2000 W, one step per cycle,
|
||||
rail at t=136 s and flat after. Three minutes with the meter parked at the
|
||||
target instead: **1 cycle before, 12 after, and 19 writes either way - the same
|
||||
19**. Three minutes on a normally-changing meter: **36 cycles and 49 writes on
|
||||
both sides, the same values in the same order.** The heartbeat write count never
|
||||
moves; the only writes the fix adds are the ten the loop was supposed to be
|
||||
making all along.
|
||||
|
||||
15 s is bracketed, not round.
|
||||
|
||||
**Below it, ~11 s, and it arises more routinely than a dropped poll.**
|
||||
`HomeWizardLocalSource` polls at 5.0 s against a meter that updates every 4.97 s
|
||||
(NOTES.md:640). Two near-equal periods beat, so roughly once every ~14 minutes
|
||||
the poller steps over an update entirely - a ~10 s gap between changed values on
|
||||
a perfectly healthy meter, plus up to 1 s for the 1 s tick to notice. A failed
|
||||
HTTP poll (retried on the next tick, no backoff) adds another 5 s. The floor
|
||||
sits above the beat-skip so it does not fire on that alone; what it takes to
|
||||
reach 15 s is a beat-skip *and* a failed poll coinciding.
|
||||
|
||||
**Above it, 30 s.** `meter_max_age_s` is 30 s, past which there is no reading to
|
||||
evaluate, and the firmware's ingest veto is also 30 s - so the loop is
|
||||
guaranteed to have re-decided at least twice inside any window in which layer 1
|
||||
might otherwise be the only thing deciding. ⚠️ The default is therefore not
|
||||
independently justified at 15: if the firmware's veto ever moves, this moves
|
||||
with it. Noted beside the constant in `main.py`.
|
||||
|
||||
It is also exactly `stale_input_s`, this system's existing answer to "how long
|
||||
may we hold without re-deciding".
|
||||
|
||||
A floor cycle runs the **full** law on the same inputs, integrator included. In
|
||||
P1 mode the freshness question is already answered upstream and answered harder
|
||||
- past `meter_max_age_s` the reading is None, which is "inputs missing", which
|
||||
is a `P_SAFETY` 0 W claim - so a floor cycle there can only ever run on a reading
|
||||
this system already calls fresh. ⚠️ That is transport-conditional: with
|
||||
`meter_source: off`, `read_inputs()` takes the grid figure straight from
|
||||
`hass.number()` with **no age gate at all**, so a Home Assistant entity that has
|
||||
gone quiet reads as current on that path, with or without this change. Running
|
||||
the full law is still right there - the alternative is a degraded second law
|
||||
reasoning about an age nobody measured - but the floor does not make that path
|
||||
fresh. The integrator advances per *cycle*, not per second,
|
||||
so bounding the gap bounds the accumulation; `integrator_max_w` bounds it after
|
||||
that. A gentler second law for floor cycles would be a second set of dynamics
|
||||
nobody can test on this hardware.
|
||||
|
||||
⚠️ **This is not a freeze detector**, and TEL-05 established one cannot be built
|
||||
here: at the converged -10 W target a 1 Wh register needs ~6 minutes to move
|
||||
while the power figure legitimately repeats, so any threshold on `unchanged_s`
|
||||
false-trips at the exact operating point we run at. Nothing in this change
|
||||
decides whether a repeated reading is *true*. It stops "the number is the same"
|
||||
from meaning "do not think". The consequence, stated plainly: against a meter
|
||||
genuinely frozen at a wrong value the loop will now correct toward that wrong
|
||||
value rather than hold a stale command, bounded by `max_w`, the arbiter and the
|
||||
device rating. That was already what happened to a lying meter whose value
|
||||
merely jittered; bit-exact repetition was never a safety property.
|
||||
|
||||
`DOCS.md`'s "Saturation is counted in cycles, not seconds" section documented
|
||||
the old behaviour as an accepted deviation. It is **qualified, not deleted**:
|
||||
the deviation survives (the window is still counted in cycles, and 3 x 15 s is
|
||||
still not the specification's 10 s) but it is now bounded at 45 s instead of
|
||||
open-ended, and the passage's second justification - "nothing accumulates while
|
||||
it is stalled" - is called out as having covered integrator windup and not
|
||||
staleness, which was the actual hazard. The same wrong argument in
|
||||
`control.py`'s saturation comment is corrected too.
|
||||
|
||||
**TEL-04.** `meter_source: ha_signed` reads a single *signed* power entity, which
|
||||
is the shape a HomeWizard P1 publishes. Both transports 0.3.0 shipped require two
|
||||
unsigned registers and refuse negatives, so neither fits the meter installed here.
|
||||
|
||||
**TEL-05.** `meter_source: homewizard_local` polls the meter's own local API
|
||||
directly. This exists because of something the HA paths cannot do: Home Assistant
|
||||
fires no state change for a repeated reading and exposes no arrival signal at
|
||||
all, so `sensor.p1_sample_age_s` on those transports measures time since the
|
||||
*value changed*, not since the meter *reported*. Real captured data has gaps of
|
||||
42 s and 97 s between changes on a perfectly healthy meter - long enough for a
|
||||
30 s staleness threshold to command 0 W for no reason. Every HTTP response to the
|
||||
local API is a genuine arrival, so the age means what it says.
|
||||
|
||||
Verified on the rig against a literally constant 350.0 W for 70 s - the exact
|
||||
state where the HA path recorded zero samples in 45 s - with 14 arrivals and the
|
||||
age never exceeding the poll cadence.
|
||||
|
||||
Set `p1_host` to the meter's address and `meter_poll_s` (default 5 s, matching
|
||||
the meter's own ~5 s update). Still defaults to `off`; nothing changes for an
|
||||
install that does not opt in.
|
||||
|
||||
Freeze detection is separate and deliberately not thresholded: a meter answering
|
||||
200 OK with a stale number *is* arriving, so no arrival stamp can flag it. The
|
||||
energy registers carry that as `unchanged_s`, surfaced on the status line for a
|
||||
human to read rather than alarmed on - at a converged -10 W a 1 Wh register needs
|
||||
about six minutes to move, so any threshold would false-trip at the target
|
||||
operating point.
|
||||
|
||||
## Unreleased
|
||||
|
||||
**TEL-05.** A fourth `meter_source`, `homewizard_local`, which polls a
|
||||
HomeWizard P1's **own local API** (`GET /api/v1/data`) instead of watching a
|
||||
Home Assistant entity. Set `p1_host` to the meter's address; `meter_poll_s`
|
||||
(default 5 s, the meter's own update rate) sets the cadence.
|
||||
|
||||
✅ **This is the first transport whose `sensor.p1_sample_age_s` measures when
|
||||
the meter *reported*, and therefore the first one a firmware watchdog may
|
||||
threshold.** Every HTTP response is an arrival: the meter answered, now, with
|
||||
its current reading, and whether the *number* moved is not consulted. Home
|
||||
Assistant cannot express that at all — a repeated reading emits no
|
||||
`state_changed`, advances `last_reported` on neither serialiser, and
|
||||
`state_reported` is not subscribable ("Event filter is required"). On
|
||||
`ha_signed` that made a healthy meter under a flat load indistinguishable from
|
||||
a dead one, and our own capture of this house's meter goes 42.2 s and 97.0 s
|
||||
between changes — both past the default `meter_max_age_s` of 30, i.e. a false
|
||||
trip to 0 W on a meter that is fine. If you have a HomeWizard P1, move to this
|
||||
mode.
|
||||
|
||||
Everything TEL-01 established is reused, not re-implemented: ingest
|
||||
timestamping, `meter_max_age_s`, the clock-recomputed age, the plausibility
|
||||
bounds and the §20 unsigned-decode rejection, and the same `split_signed` sign
|
||||
convention `ha_signed` uses. A failed or timed-out poll submits nothing, so it
|
||||
is a *missing* reading — never 0 W — and it does not reset the age.
|
||||
|
||||
Verified on the ENV-01 rig against `sim/hwsim.py`, steady and with `--fault
|
||||
freeze` injected. Still defaults to `off`.
|
||||
|
||||
⚠️ **An arrival stamp still cannot see a *frozen* meter**, and no arrival
|
||||
detector can: a meter answering `200 OK` forever with a stale number is
|
||||
arriving. The local API does expose what the HA path never had — the
|
||||
`total_power_*_kwh` registers stop advancing — and the transport tracks it as
|
||||
`unchanged_s`, but that is deliberately **not** folded into the age and not
|
||||
thresholded: this controller regulates grid power toward ~0 W, and at a
|
||||
converged −10 W the export register needs six minutes to move by its 1 Wh
|
||||
resolution while the power figure legitimately repeats. Thresholding it at 30 s
|
||||
would rebuild the false-trip limit cycle at the exact operating point we aim
|
||||
for. Freeze detection needs the low-power case solved first, separately. It is
|
||||
shown on the status page's P1 line instead — leaving it unthresholded only
|
||||
holds up if a human can read it, so now they can.
|
||||
|
||||
**TEL-04.** A third `meter_source`, `ha_signed`, reading **one signed** Home
|
||||
Assistant entity: positive = import, negative = export. That is the shape a
|
||||
HomeWizard P1 publishes (`sensor.p1_meter_active_power`), and it is the meter
|
||||
actually fitted here - which neither TEL-01 transport can read, because
|
||||
`ha_dsmr` needs two unsigned registers and refuses a negative one, i.e. every
|
||||
exporting telegram. Set `p1_net_entity`, and `p1_phase_net_entities` for the
|
||||
per-phase capacity-tariff figures on a three-phase connection.
|
||||
|
||||
Everything TEL-01 established is inherited rather than re-implemented - the
|
||||
new transport is a subclass of the `ha_dsmr` one overriding only which
|
||||
entities it wants and how they become a sample. So ingest timestamping,
|
||||
`meter_max_age_s`, `sensor.p1_sample_age_s` recomputed against the clock and
|
||||
republished once a second, the plausibility bounds, and `unavailable` /
|
||||
`unknown` treated as a *missing reading and never 0 W* all behave identically
|
||||
across the three sources.
|
||||
|
||||
Still defaults to `off`; an existing install is unaffected until it opts in.
|
||||
|
||||
⚠️ **`sensor.p1_sample_age_s` is published on `ha_signed`, but must not yet be
|
||||
thresholded by the ESP32 stale-input watchdog.** On the HA WebSocket paths the
|
||||
age is stamped from `state_changed`, so it measures time since the value
|
||||
*changed*, not since the meter *reported* - and Home Assistant exposes no
|
||||
arrival signal for a repeated reading (no `state_changed`, no `last_reported`
|
||||
movement on either serialiser, and `state_reported` is not subscribable over
|
||||
the WebSocket). Measured on the ENV-01 rig against the real HomeWizard
|
||||
integration. `ha_dsmr` mostly escapes it because a telegram moves several
|
||||
entities at once; `ha_signed` has one, so a healthy meter under a flat load is
|
||||
indistinguishable from a dead one. Our own capture has the house meter going
|
||||
42.2 s and 97.0 s between changes. Raising `meter_max_age_s` does not fix that,
|
||||
it only chooses which error you get; the fix is an arrival stamp from the meter
|
||||
itself and is a separate ticket. Full detail in DOCS.md.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
**SAFETY-04.** The control law's integrator is now an explicit accumulator,
|
||||
bounded independently of the output clamp instead of inheriting whatever
|
||||
headroom the clamp happened to leave. It also freezes while the inverter is
|
||||
not tracking, rather than continuing to wind up against a command nothing is
|
||||
acting on. `integrator_max_w` (default `0`) governs the bound; `0` means
|
||||
"follow `max_w`", which is the existing behaviour.
|
||||
|
||||
Behaviour is unchanged at the defaults - a 4,928-case equivalence sweep
|
||||
against the previous control law confirms it decides identically at
|
||||
`integrator_max_w: 0`.
|
||||
|
||||
**TEL-01.** P1 meter ingestion, so a Belgian P1's two unsigned registers
|
||||
(consumption, injection) no longer need a hand-written signed template
|
||||
sensor: the subtraction moves into the add-on, done once and tested. Two
|
||||
transports, chosen with the new `meter_source` option: `ha_dsmr` subscribes
|
||||
to the DSMR integration over the HA WebSocket, `mqtt_p1` reads a topic.
|
||||
Defaults to `off`, which keeps the existing `meter_entity` path untouched -
|
||||
nothing changes for an install that does not opt in.
|
||||
|
||||
Enabling it publishes `sensor.p1_sample_age_s`: seconds since the newest
|
||||
accepted telegram, recomputed against the clock and republished roughly once
|
||||
a second rather than only when a telegram lands. That is deliberate - Home
|
||||
Assistant only pushes a state on change, so a meter sitting at a genuinely
|
||||
constant reading would otherwise look identical to a dead one. Watching the
|
||||
age instead means a frozen meter shows a climbing age, not a flat line. The
|
||||
firmware watchdog subscribes to this exact entity id.
|
||||
|
||||
Known limits, both already in DOCS.md: on `mqtt_p1`, a bridge stuck
|
||||
republishing its last telegram still "arrives", so the age cannot detect
|
||||
that particular failure - prefer `ha_dsmr` where both are available. And a
|
||||
dead P1 meter takes 45 s to reach 0 W commanded (30 s for `meter_max_age_s`
|
||||
to call the reading stale, then 15 s of `stale_input_s` on top), which is
|
||||
`meter_max_age_s` and `stale_input_s` stacking, not either one alone.
|
||||
|
||||
## 0.2.1
|
||||
|
||||
`target_grid_w` (default -10 W): what the meter should rest at. The deadband
|
||||
|
||||
+15
-621
@@ -24,16 +24,9 @@ The inverter **holds its last command forever** — it has no meter-timeout. So:
|
||||
commanding it.
|
||||
- This add-on commands 0 W when its inputs go missing, when you stop control,
|
||||
and when it shuts down.
|
||||
- This add-on commands 0 W **and latches there** if what it is commanding and
|
||||
what the meter reads point opposite ways for three minutes — see "The
|
||||
sign-disagreement failsafe" below.
|
||||
- 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
|
||||
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
|
||||
hardware holds it there.
|
||||
@@ -58,67 +51,21 @@ phase having charged nothing.
|
||||
### P1 meter ingestion
|
||||
|
||||
`meter_entity` above expects one signed sensor, which usually means a template
|
||||
someone wrote by hand. Setting `meter_source` moves the whole derivation into
|
||||
the add-on, where it is done once and tested, and replaces `meter_entity`
|
||||
entirely.
|
||||
|
||||
Which mode you want depends on what your P1 reader publishes, and there are two
|
||||
shapes in the wild:
|
||||
|
||||
- **Two unsigned registers**, consumption and injection, which is what a Belgian
|
||||
P1 read over DSMR gives you → `ha_dsmr`, or `mqtt_p1` for a bridge. The add-on
|
||||
subtracts them.
|
||||
- **One signed figure**, positive = import and negative = export, which is what
|
||||
a HomeWizard P1 gives you (`sensor.p1_meter_active_power`) → `ha_signed`. The
|
||||
add-on splits it. `ha_dsmr` **cannot** read this: it wants two registers and
|
||||
rejects a negative one outright, which is every exporting telegram.
|
||||
|
||||
Either way, do not build the missing shape out of template sensors. The point of
|
||||
`meter_source` is that the sign convention is derived in one tested place rather
|
||||
than in YAML nobody reviews underneath a safety input.
|
||||
|
||||
> ✅ **If your meter is a HomeWizard P1, use `homewizard_local`, not
|
||||
> `ha_signed`.** It reads the same meter and produces the same numbers, but it
|
||||
> polls the meter directly instead of watching a Home Assistant entity — and
|
||||
> that is the difference between an age sensor a watchdog can threshold and one
|
||||
> it cannot. See "`sensor.p1_sample_age_s`" below; `ha_signed` remains for
|
||||
> installs where the meter is only reachable through Home Assistant.
|
||||
|
||||
> ⚠️ **`ha_dsmr` and `mqtt_p1` have never processed a telegram from real
|
||||
> hardware.** No meter in this installation uses either one. Both were written
|
||||
> to the assumption in `specs.md` §5.2 that a Belgian P1 exposes two unsigned
|
||||
> registers, and the meter actually fitted here does not — it is the HomeWizard
|
||||
> P1 that `ha_signed` reads. They are covered by the unit checks in `test_p1.py`
|
||||
> and by an end-to-end test against a fake Home Assistant, and nothing more.
|
||||
>
|
||||
> This is recorded because the realistic way it bites is someone debugging a
|
||||
> meter problem months from now treating those two paths as proven and looking
|
||||
> for the fault elsewhere. If you are the first person to point one at a real
|
||||
> meter, expect to find something, and please update this note when you do.
|
||||
someone wrote by hand. A Belgian P1 meter does not publish one: it publishes two
|
||||
**unsigned** registers, consumption and injection. Setting `meter_source` moves
|
||||
that subtraction into the add-on, where it is done once and tested, and replaces
|
||||
`meter_entity` entirely.
|
||||
|
||||
| option | default | meaning |
|
||||
|---|---|---|
|
||||
| `meter_source` | `off` | `off` keeps `meter_entity`. `ha_dsmr` subscribes to the DSMR integration over the HA WebSocket; `mqtt_p1` reads a topic; `ha_signed` subscribes to one signed entity over the HA WebSocket; `homewizard_local` polls a HomeWizard P1's own local API, bypassing Home Assistant |
|
||||
| `meter_source` | `off` | `off` keeps `meter_entity`. `ha_dsmr` subscribes to the DSMR integration over the HA WebSocket; `mqtt_p1` reads a topic |
|
||||
| `meter_phases` | 1 | 1 or 3. Must match the telegram, or every telegram is rejected and logged |
|
||||
| `meter_max_age_s` | 30 | Beyond this the reading is stale and grid power reads as *missing*. On its own it does **not** command 0 W — see the timing note below. It is also the longest a reading is held forward into the 15-minute average |
|
||||
| `meter_poll_s` | 5 | `homewizard_local` only. Seconds between polls. **Must be well under `meter_max_age_s`** — see below |
|
||||
| `p1_host` | | `homewizard_local` only. The meter's own address, `host` or `host:port` (e.g. `192.168.2.250`) |
|
||||
| `meter_mqtt_topic` | | `mqtt_p1` only |
|
||||
| `p1_import_entity` | | The **unsigned** consumption sensor. Do not point this at a signed template |
|
||||
| `p1_export_entity` | | The **unsigned** injection sensor |
|
||||
| `p1_phase_import_entities` | `[]` | L1..L3, in order. Needed for the capacity-tariff peak on a three-phase connection |
|
||||
| `p1_phase_export_entities` | `[]` | L1..L3, in order |
|
||||
| `p1_net_entity` | | `ha_signed` only. The **signed** net-power sensor: `+` import, `-` export |
|
||||
| `p1_phase_net_entities` | `[]` | `ha_signed` only. L1..L3, in order, each signed the same way. Needed for the capacity-tariff peak on a three-phase connection. **The list length must equal `meter_phases`** |
|
||||
|
||||
Both per-phase lists are checked against `meter_phases` **once at startup**: a
|
||||
list of the wrong length disables P1 ingestion with an error in the log, rather
|
||||
than letting every telegram fail its phase-count check one at a time. Leaving
|
||||
the list empty is fine and is not an error — you simply get no per-phase
|
||||
figures, and therefore no capacity-tariff peak. On a three-phase connection
|
||||
that is a much bigger omission than it looks: on a surveyed reading here the
|
||||
phases carried 2769 W of import while the connection netted 187 W, so the
|
||||
billed quantity is understated roughly fifteenfold if the phases are missing.
|
||||
|
||||
#### How long a dead meter takes to reach 0 W
|
||||
|
||||
@@ -159,55 +106,6 @@ tested, and guessing a key here means guessing a kilowatt:
|
||||
it is present it is used for the age, which is what stops a retained message
|
||||
replayed on reconnect from presenting a ten-minute-old reading as current.
|
||||
|
||||
#### `homewizard_local` — polling the meter instead of Home Assistant
|
||||
|
||||
Set `p1_host` to the meter's address and the add-on does `GET /api/v1/data` on
|
||||
it every `meter_poll_s` seconds, reading `active_power_w` (signed, same
|
||||
convention as `ha_signed`) and the three `active_power_l{1,2,3}_w` fields. Home
|
||||
Assistant is not involved: no entity, no WebSocket, no integration to
|
||||
mis-configure. Per-phase figures are used only when the meter serves all
|
||||
`meter_phases` of them — a single-phase meter returns `null` for L2/L3, and the
|
||||
connection-level reading is still accepted on its own.
|
||||
|
||||
**Why this mode exists:** every HTTP response is an *arrival*. The meter
|
||||
answered, now, with its current reading — whether or not the number moved. That
|
||||
is the signal `sensor.p1_sample_age_s` needs and the one Home Assistant cannot
|
||||
give it at all (see the note below). It is also simply fewer moving parts: the
|
||||
five-second cadence is the meter's own, rather than an integration's polling of
|
||||
it re-published as a state change.
|
||||
|
||||
**Cadence.** The age is never fresher than the poll interval, so:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| meter's own update rate | ~5.0 s (measured 4.97 s) |
|
||||
| `meter_poll_s` default | 5 s — nothing to gain below the meter's own rate |
|
||||
| `meter_max_age_s` default | 30 s, i.e. six polls of headroom |
|
||||
| `meter_poll_s >= meter_max_age_s` | **refused at startup** — every reading would be stale before its successor arrived |
|
||||
| `meter_poll_s > meter_max_age_s / 2` | warned — one missed poll makes the reading stale |
|
||||
|
||||
A failed poll — timeout, connection refused, non-200, unparseable body — is a
|
||||
**missing** reading. It submits nothing, so the reading does not become 0 W, the
|
||||
last good value and its timestamp are left alone, and the age goes on climbing.
|
||||
That is exactly what a dead meter should look like.
|
||||
|
||||
**What it still cannot see: a frozen meter.** A meter that answers `200 OK`
|
||||
forever with a stale number is arriving, so no arrival detector — this one
|
||||
included — can tell it from a healthy one. The local API does expose the raw
|
||||
material the HA path never had (the `total_power_*_kwh` registers stop
|
||||
advancing), and the transport tracks it as `unchanged_s`, but it is deliberately
|
||||
*not* folded into the age and *not* thresholded: this controller regulates grid
|
||||
power toward ~0 W, and at a converged −10 W the export register needs six
|
||||
minutes to move by its 1 Wh resolution while the power figure legitimately
|
||||
repeats. Thresholding that at 30 s would rebuild the false-trip limit cycle at
|
||||
the exact operating point the controller aims for. Freeze detection is a
|
||||
separate problem and needs the low-power case solved first.
|
||||
|
||||
You read it yourself instead: the add-on's status page shows it on the P1 line,
|
||||
as `… 4 rejected, measurement unchanged for 312 s`. On a house drawing real
|
||||
power that figure stays in the seconds; minutes of it while the load is clearly
|
||||
not near zero is the meter to go and look at.
|
||||
|
||||
#### `sensor.p1_sample_age_s`
|
||||
|
||||
Published over MQTT discovery whenever a broker is available: **seconds since the
|
||||
@@ -225,48 +123,13 @@ The entity is only created when `meter_source` is not `off`. With P1 ingestion
|
||||
disabled there is nothing feeding it, and an age sensor climbing with no ingester
|
||||
behind it would trip the firmware watchdog on a system that is working fine.
|
||||
|
||||
> **Known limit, `mqtt_p1`.** The age measures *arrival*. On the MQTT path a
|
||||
> bridge that is stuck republishing its last telegram keeps arriving, so the age
|
||||
> stays near zero and a frozen meter still looks fresh. Detecting *that* needs a
|
||||
> change-detector rather than an arrival-detector, and it is not in this version.
|
||||
|
||||
> ⚠️ **Known limit, `ha_signed` — do not drive a watchdog off this age yet.**
|
||||
> On the HA WebSocket paths the age is stamped when a `state_changed` arrives,
|
||||
> which means it measures *time since the value last changed*, not time since
|
||||
> the meter last reported. Home Assistant offers nothing better: a repeated
|
||||
> reading produces no `state_changed`, does **not** advance `last_reported` on
|
||||
> either the REST or the WebSocket serialiser, and `state_reported` cannot be
|
||||
> subscribed to over the WebSocket at all (`Event filter is required for event
|
||||
> state_reported`). All three measured on the ENV-01 rig against the real
|
||||
> HomeWizard integration with the meter frozen: 0 `state_changed` in 70 s and no
|
||||
> timestamp movement anywhere.
|
||||
>
|
||||
> `ha_dsmr` mostly escapes this because a DSMR telegram updates several entities
|
||||
> and something in the set almost always moves. **`ha_signed` has exactly one
|
||||
> entity, so a healthy meter under a flat load is indistinguishable from a dead
|
||||
> one.** This is not hypothetical: in our own captures
|
||||
> (`sim/scenarios/ha-p1_meter_active_power-2026-08-20.json`) the real house meter
|
||||
> went **42.2 s and 97.0 s** between changes, and 23 Aug peaks at 29.1 s — all
|
||||
> past the default `meter_max_age_s` of 30.
|
||||
>
|
||||
> So `sensor.p1_sample_age_s` on `ha_signed` is safe to *read*, and it is
|
||||
> correct whenever the value is moving, but it must not yet be thresholded by
|
||||
> the ESP32 stale-input watchdog: a quiet house would trip the battery to 0 W.
|
||||
> Raising `meter_max_age_s` is **not** the fix — the two conditions produce an
|
||||
> identical signal, so a bigger number only chooses which of the two errors you
|
||||
> get. The real fix is an arrival stamp the meter itself provides — and that now
|
||||
> exists: **`meter_source: homewizard_local`**. If you have a HomeWizard P1,
|
||||
> switch to it. If your meter is only reachable through Home Assistant, this
|
||||
> limit still applies to you and the watchdog threshold still must not be armed.
|
||||
|
||||
**Where the age is trustworthy:**
|
||||
|
||||
| mode | the age measures | safe to threshold from firmware |
|
||||
|---|---|---|
|
||||
| `homewizard_local` | time since the meter **answered** | **yes** — every HTTP response is an arrival |
|
||||
| `ha_dsmr` | time since one of several entities changed | no — statistically usually fine, which is a masked bug, not an absent one |
|
||||
| `ha_signed` | time since the one entity changed | **no** — see above |
|
||||
| `mqtt_p1` | time since a message arrived | arrivals yes, but a stuck bridge republishing keeps arriving |
|
||||
> **Known limit, `mqtt_p1` only.** The age measures *arrival*, not change. On the
|
||||
> `ha_dsmr` path that is exactly right: a frozen meter emits no `state_changed`,
|
||||
> so nothing arrives and the age climbs. On the MQTT path a bridge that is stuck
|
||||
> republishing its last telegram keeps arriving, so the age stays near zero and a
|
||||
> frozen meter still looks fresh. Detecting *that* needs a change-detector rather
|
||||
> than an arrival-detector, and it is not in this version. Prefer `ha_dsmr` where
|
||||
> both are available.
|
||||
|
||||
### Control
|
||||
|
||||
@@ -279,113 +142,11 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
||||
| `step_w` | 10 | Quantisation |
|
||||
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
|
||||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1** |
|
||||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. **Do not set to 1** |
|
||||
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
|
||||
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
|
||||
| `cycle_floor_s` | 15 | The longest the loop may go without evaluating. A deadline on the gap between cycles, reset by every cycle — on a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than ~10 s; it must stay under `meter_max_age_s`, past which there is no reading to evaluate anyway |
|
||||
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W. In P1 mode this clock starts only *after* `meter_max_age_s` has already expired — the two stack, see "How long a dead meter takes to reach 0 W" |
|
||||
| `auto_start` | false | Start controlling on boot (only after commissioning) |
|
||||
| `sign_disagree_w` | 300 | Both the command and the meter must exceed this before a sign difference counts. Must stay **below `max_w`** or the watchdog can never fire, and the add-on refuses to start |
|
||||
| `sign_disagree_s` | 180 | How long they must stay opposed, continuously, before the failsafe trips |
|
||||
|
||||
#### The sign-disagreement failsafe
|
||||
|
||||
If the setpoint this add-on is emitting and the grid figure it is regulating on
|
||||
point **opposite ways** — charging the battery while the house imports, or
|
||||
discharging it while the house exports — with both over `sign_disagree_w`, for
|
||||
`sign_disagree_s` **continuously**, the add-on commands **0 W** and holds it
|
||||
there. The banner reads `FAILSAFE`, the commissioning list says which two
|
||||
numbers disagreed and for how long, and the `Controller status` entity reports
|
||||
`failsafe`.
|
||||
|
||||
**It does not clear itself, and restarting the add-on does not clear it either.**
|
||||
Pressing *Start / stop control* twice — stop, then start — is the only thing that
|
||||
does. That is deliberate on both counts:
|
||||
|
||||
- Commanding 0 W removes the command side of the comparison, so the disagreement
|
||||
always vanishes the instant the trip works. A watchdog that took that as proof
|
||||
the fault was over would hand the inverter straight back to the loop that was
|
||||
driving the wrong way, three minutes later, forever.
|
||||
- The trip is written to `/data/state.json` and restored on startup. Restarting
|
||||
the add-on is the first thing most people try when they see a red banner, and
|
||||
a latch that a restart clears would bring the controller back **armed, into the
|
||||
same fault, unattended** — and under a Supervisor that restarts a crashed add-on
|
||||
automatically, would do so in a loop. After a restart the banner, the 0 W and
|
||||
the recorded reason are all still there.
|
||||
|
||||
**What it is for.** This is a loop-integrity check: it asks whether the loop's
|
||||
output direction still matches the input state it was regulating. It catches a
|
||||
polarity error anywhere between the meter and the wire, and an integrator so far
|
||||
wound up that unwinding takes minutes. It is the check that would have caught all
|
||||
four episodes of 2026-08-20, when a commercial controller charged the battery out
|
||||
of the grid, four times, for up to 77 minutes at a time.
|
||||
|
||||
**What it is not for.** It does not detect an inverter that acts opposite to what
|
||||
it was told — that moves the meter *further* the same way, so command and meter
|
||||
agree while everything runs away. That is the saturation freeze's job
|
||||
(`saturation_w` / `saturation_cycles`), which compares the command against the
|
||||
battery's actual power. The two checks are complementary and neither replaces the
|
||||
other.
|
||||
|
||||
**Why it will not false-trip on ordinary operation.** A battery that refuses a
|
||||
command (empty, full, standby) leaves the meter where it was, so command and
|
||||
meter agree. A frozen meter drives the loop into agreeing with it. Around the
|
||||
resting point both signals are inside the 300 W gate, so the deadband can never
|
||||
produce one. A load step does flip the meter's sign before the loop catches up —
|
||||
measured on a full captured day, the longest such crossing is 30 s, against a
|
||||
180 s window. And the watchdog is **disarmed whenever the grid-following loop is
|
||||
not what is driving**: a maintenance grid charge opposes the meter by design and
|
||||
must not be mistaken for a fault.
|
||||
|
||||
Both numbers are measured rather than chosen, and are exposed only because the
|
||||
ticket requires defaults rather than constants. On the captured healthy day sign
|
||||
agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %,
|
||||
10.5 % and 0.0 %. There is no reason to move either value without a capture that
|
||||
says so.
|
||||
|
||||
#### Saturation is counted in cycles, not seconds
|
||||
|
||||
The specification states the saturation window as **"> 10 s"**. This add-on counts
|
||||
**cycles** instead, and that is a deliberate, accepted deviation rather than an
|
||||
oversight - the acceptance criterion is not met as literally written.
|
||||
|
||||
A cycle here is one meter update **or `cycle_floor_s` elapsing, whichever comes
|
||||
first**. At the reference P1's ~5 s update rate the default of 3 cycles is usually
|
||||
around 15 s, and the worst case is bounded at `3 x cycle_floor_s` = 45 s. It is still
|
||||
not the 10 s the specification asks for, so the deviation stands - but it is now a
|
||||
bounded number rather than an open one.
|
||||
|
||||
⚠️ **This passage used to say something different, and the second half of it was
|
||||
wrong.** Until SAFETY-12 the loop ran only when the meter value *changed*, so a meter
|
||||
that repeated a value stalled the counter for as long as it repeated, with no upper
|
||||
bound at all. That was defended here on the grounds that "a stalled counter is a
|
||||
detection-latency limit and not a runaway risk - the condition that stalls it stops the
|
||||
whole loop, so nothing accumulates while it is stalled". That argument covers
|
||||
**integrator windup and nothing else**. The hazard it misses is **staleness**: while the
|
||||
loop was stopped the heartbeat kept re-emitting the last command and the firmware kept
|
||||
putting it on the wire every 2 s, so a command computed from an old reading went on
|
||||
being executed through a real load change, with every indicator reading healthy.
|
||||
Measured on the rig during TEL-05: 70 s at a literally constant 350.0 W, 14 arrivals,
|
||||
zero control cycles.
|
||||
|
||||
The half that survives is the first one: the control law is still a pure function with
|
||||
no clock, and the wall-clock window still lives in `Controller`, which has one - see
|
||||
`run_control()`. What changed is that the controller now enforces a floor on the gap
|
||||
between cycles, so `saturation_cycles` finally means a bounded number of seconds.
|
||||
|
||||
If a tighter window matters on your site, lower `cycle_floor_s` (not below your meter's
|
||||
update interval, or the floor starts firing on a healthy meter) or raise
|
||||
`saturation_cycles` for a fast meter.
|
||||
|
||||
**This is not a freeze detector, and it deliberately cannot be one.** Nothing here
|
||||
decides whether a repeated reading is true — see `unchanged_s` in the meter section for
|
||||
why no threshold on that is safe at the converged operating point. The floor only stops
|
||||
"the number is the same" from meaning "do not think". The consequence is worth stating
|
||||
plainly: against a meter that is genuinely frozen at a wrong value, the loop will now
|
||||
correct toward that wrong value instead of holding a stale command. It is bounded by
|
||||
`max_w`, the arbiter and the device rating, and it is the same thing that already
|
||||
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
|
||||
safety property.
|
||||
|
||||
#### Why `target_grid_w` is not zero
|
||||
|
||||
@@ -418,8 +179,8 @@ help - see "Why the tuning is what it is".
|
||||
| `maintenance_enabled` | false | Enable the monthly cycle |
|
||||
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
|
||||
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
||||
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
|
||||
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
|
||||
| `maintenance_discharge_w` | 2500 | Drain rate (exports the surplus) |
|
||||
| `maintenance_charge_w` | 2500 | Charge ceiling, capped again by peak headroom |
|
||||
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor |
|
||||
| `maintenance_soc_target` | 99 | Charge target |
|
||||
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
|
||||
@@ -437,373 +198,6 @@ 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
|
||||
maintenance schedule.
|
||||
|
||||
#### Capacity-tariff exposure of the grid-following loop
|
||||
|
||||
⚠️ **This section described the exposure BEFORE SAFETY-07. The loop is now
|
||||
capped — see "The capacity-peak guard" below — and what follows is kept because it
|
||||
is still the clearest statement of WHAT is being guarded and why the number is
|
||||
worth what it is.**
|
||||
|
||||
⚠️ **`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`** | ✅ — SAFETY-07, on every `meter_source` but `off` |
|
||||
| Frozen meter detected | ✅ on `homewizard_local` only, **alarm only** |
|
||||
| Frozen meter *stopped* | ✅ — SAFETY-07 forbids importing while the meter is frozen |
|
||||
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
|
||||
|
||||
On the legacy `meter_source: off` path there are no P1 telegrams, so there is no
|
||||
quarter-hour average to guard, and the status page says **NOT COVERED**. There
|
||||
the mitigation is still 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 capacity-peak guard
|
||||
|
||||
**What it does.** Every cycle the add-on projects the open quarter to its
|
||||
end-of-interval average — from P1 telegrams only, never from the command — and
|
||||
compares it to a ceiling. Over the ceiling it files one claim: a **lower bound on
|
||||
the setpoint**, at `P_PEAK`. That single shape is both behaviours §10.1 asks for:
|
||||
a hard clamp on charging while the bound sits above the standing charge, and
|
||||
pre-emptive discharge while it sits above zero. It can never cap discharge or
|
||||
export, because a floor cannot.
|
||||
|
||||
**The number.** `excess = 900 × (projected − ceiling) ÷ seconds remaining`, and
|
||||
the floor is the **measured** battery power plus that. The 900/remaining factor
|
||||
is the design rather than an accident: it is 1.0 at the top of the quarter and
|
||||
grows, so the guard is gentlest when its projection rests on the least evidence
|
||||
and becomes aggressive only once the quarter is nearly measured.
|
||||
|
||||
**The ceiling** is `peak_cap_w`, raised to the month's banked peak when that
|
||||
already stands higher — once a month's bill is set at 5013 W, holding later
|
||||
quarters to 3500 W buys nothing and costs real charging. **That relaxation is
|
||||
granted only by an OBSERVED reading:** a peak store that cannot be read leaves the
|
||||
ceiling at the cap, so a broken instrument makes the guard stricter, never looser.
|
||||
|
||||
**When it cannot see, it forbids importing.** Three states are not evidence and
|
||||
are checked before the projection is graded: no open quarter (a fresh averager
|
||||
projects a confident 0 W, which is under every ceiling there is), a meter gap
|
||||
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
|
||||
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
|
||||
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
|
||||
and commands nothing, and this stops the import.
|
||||
|
||||
**It trips, where the freeze detector only notifies,** and the captures are why.
|
||||
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
|
||||
The action is level-triggered and unlatched — re-derived from a fresh projection
|
||||
every cycle, released the instant the projection drops back — so a false
|
||||
engagement costs one cycle of discharge rather than a parked battery. And it is a
|
||||
LIMIT, so every stop in the watchdog table still outranks it.
|
||||
|
||||
**Replayed on 2026-08-20**, the day the vendor controller failed: the billed
|
||||
quarter measured **5029.8 W**, above the inverter's own 5000 W rating, and the
|
||||
guard brings it to **3485 W** — under the soft limit, without ever asking for more
|
||||
than the inverter can give. Across both committed days the quarters under the
|
||||
limit go from 189/190 to 190/190. Two days is not thirty and does not close AC 3;
|
||||
it is the comparison two days can support.
|
||||
|
||||
**What it alarms on is saturation, not engagement.** An engagement is an ordinary
|
||||
control action and goes to the action log and the status page. A *saturated*
|
||||
guard — asking for everything the inverter has while the quarter is still
|
||||
projected over — raises one notification (`goodwe_peak_saturated`), because that
|
||||
is the one state where the money will be spent and shedding load is the only
|
||||
thing left that changes the bill.
|
||||
|
||||
**No new option.** The ceiling is `peak_cap_w`, which already shipped. The
|
||||
coverage threshold is `meter_max_age_s`, the freeze predicate is
|
||||
`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device
|
||||
rating — every one of them a key that already exists.
|
||||
|
||||
### 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.
|
||||
|
||||
### Energy price providers
|
||||
|
||||
> **No *fetched* price moves energy in 0.5.x.** This release ships the
|
||||
> *plumbing*: the five provider plugins, the canonical all-in format they emit,
|
||||
> and the config that selects one. Turning a provider on fetches, validates and
|
||||
> publishes prices and changes no charging decision; the arbitrage strategy that
|
||||
> would use it is a later ticket.
|
||||
>
|
||||
> The one exception is `static_profile` below. A static day/night tariff needs
|
||||
> no network, so its cheap window is computed on every cycle and does reach the
|
||||
> maintenance cycle's grid top-up.
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
| `price_provider` | `off` (default) or one of the five plugins below |
|
||||
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
|
||||
| `price_taxes_eur_kwh` | Levies and VAT, EUR/kWh. Same rule |
|
||||
| `export_price_eur_kwh` | The **injection** price. A separate scalar, not a series — see below |
|
||||
| `price_csv_path` | Where `manual_csv` reads its file |
|
||||
| `price_feed_url` | Override the plugin's default feed. Only a host the plugin itself declares is accepted |
|
||||
| `price_horizon_h` | How far ahead to ask for, hours |
|
||||
| `price_timeout_s` | Per-request timeout |
|
||||
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
|
||||
|
||||
**Which plugin sources what.** This is the one thing to get right, because
|
||||
adding your markup to a price that already contains it makes every future
|
||||
arbitrage decision wrong by that amount, in a direction nothing reports.
|
||||
|
||||
| plugin | product | the published figure contains | the add-on adds |
|
||||
|---|---|---|---|
|
||||
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
|
||||
| `engie_dynamic` | day-ahead | energy + Engie's markup | taxes |
|
||||
| `totalenergies_dynamic` | day-ahead | energy + TotalEnergies' markup | taxes |
|
||||
| `eneco_eddy` | **real-time / imbalance** | everything, all-in | nothing |
|
||||
| `manual_csv` | day-ahead | everything, all-in | nothing |
|
||||
|
||||
You cannot get this wrong by editing the markup: each plugin *declares* what its
|
||||
feed contains and the add-on adds only the rest. Set a markup under a plugin
|
||||
that already carries one and it is **ignored, with a line in the log saying so**
|
||||
— never applied twice.
|
||||
|
||||
That holds **given a correct declaration**, and it is worth knowing where the
|
||||
line is. What the add-on enforces mechanically is that a plugin cannot obtain
|
||||
the markup or apply it: the numbers are not reachable from plugin code, and a
|
||||
plugin whose code mentions them fails to load at all. What it cannot check is
|
||||
whether a plugin described its supplier's feed truthfully — that is a claim
|
||||
about somebody else's tariff, and no code can verify it. If you write a plugin,
|
||||
the `sources` declaration is the part a reviewer has to read.
|
||||
|
||||
`eneco_eddy` is a real-time/imbalance product rather than a day-ahead one. It is
|
||||
flagged as high-volatility, which widens arbitrage thresholds once the
|
||||
optimiser lands. That comes from the plugin's own declaration, not from its
|
||||
name.
|
||||
|
||||
**`manual_csv` — the offline fallback.** It needs no internet, no supplier
|
||||
account and no HTTP stack at all, and it is the supported way to run this
|
||||
add-on price-aware on a site with no outbound connectivity. Write a file and
|
||||
point `price_csv_path` at it:
|
||||
|
||||
```csv
|
||||
start,eur_per_kwh,provisional
|
||||
2026-01-15T00:00:00+01:00,0.1834,false
|
||||
2026-01-15T01:00:00+01:00,-0.0210,true
|
||||
2026-01-15T02:00:00+01:00,0.3115,false
|
||||
```
|
||||
|
||||
- `start` **must carry its UTC offset** (`+01:00` in winter, `+02:00` in
|
||||
summer). A bare `2026-01-15 00:00` is refused, because for one hour every
|
||||
October a local wall clock names two different moments.
|
||||
- `eur_per_kwh` is **all-in** — energy *plus* markup *plus* taxes. This plugin
|
||||
has no way to know what your numbers already include, so it assumes
|
||||
everything and adds nothing.
|
||||
- Negative prices are valid and are kept. They are the best charging hours of
|
||||
the year.
|
||||
- `provisional` is optional; omit the column and every hour is treated as
|
||||
settled.
|
||||
|
||||
**When there are no prices at all** — provider off, feed unreachable, CSV
|
||||
missing — the add-on does not guess. It reports *no price data*, which is a
|
||||
distinct state from "prices, all of them zero": the plan is marked provisional
|
||||
and arbitrage is disabled, per the degradation matrix. It never falls back to a
|
||||
price of zero, which would read as free electricity.
|
||||
|
||||
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
|
||||
often near zero, which by itself strongly favours self-consumption over export.
|
||||
It is a single number rather than a curve because no supported supplier
|
||||
publishes a dynamic injection tariff; negative values are accepted.
|
||||
|
||||
**The old Home Assistant price sensors.** `price_now_entity` and
|
||||
`price_avg_entity` still work and are unchanged: the add-on compares them and
|
||||
uses the result as a cheap-window hint for the maintenance charge. They are
|
||||
**not compatible with `price_provider`, and not with `static_profile` either**
|
||||
— configure two of them and the add-on refuses to start, naming the conflict,
|
||||
because the add-on reads exactly one and the loser is silent. Use one.
|
||||
|
||||
### Static day/night and fully-fixed tariffs (`static_profile`)
|
||||
|
||||
Most Belgian households are **not** on a dynamic contract, and an EMS that only
|
||||
works under hourly pricing is an EMS for a minority. `static_profile` is how a
|
||||
two-period day/night tariff or a single fixed price becomes a first-class
|
||||
configuration rather than "turn the price feature off and hope".
|
||||
|
||||
It is one option, holding a JSON object, on one line:
|
||||
|
||||
```json
|
||||
{"periods": [
|
||||
{"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
|
||||
{"name":"night", "from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
|
||||
{"name":"night", "from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
|
||||
```
|
||||
|
||||
A **fully fixed** tariff is one period covering the whole week:
|
||||
|
||||
```json
|
||||
{"periods": [
|
||||
{"name":"fixed","from":"00:00","to":"24:00","days":"all","eur_per_kwh":0.31}]}
|
||||
```
|
||||
|
||||
- `eur_per_kwh` is **all-in** — energy *plus* your supplier's markup *plus*
|
||||
taxes — exactly like `manual_csv`. `price_markup_eur_kwh` and
|
||||
`price_taxes_eur_kwh` are **not** added to it.
|
||||
- `days` takes `mon`…`sun`, ranges (`mon-fri`, `fri-mon`), comma lists
|
||||
(`sat,sun`) and the words `weekday`, `weekend`, `all`.
|
||||
- Times are **local Belgian time** and windows are half-open: `07:00`–`22:00`
|
||||
includes 07:00 and excludes 22:00. A window may cross midnight.
|
||||
- The clock changes twice a year and the add-on handles it. It generates prices
|
||||
against absolute instants, so a spring-forward day is 23 hours and an
|
||||
autumn-back day is 25.
|
||||
|
||||
> ⚠️ **A window belongs to the day the clock reads, not the day it started.**
|
||||
> `22:00`–`07:00` on `mon-fri` covers Friday 23:00 but **not** Saturday 03:00 —
|
||||
> Saturday 03:00 is Saturday's. That is why the example above has a third row
|
||||
> for the weekend. Leave it out and the add-on **refuses to start**, naming the
|
||||
> uncovered hours.
|
||||
|
||||
**Every day type must be covered exactly once.** The periods have to tile all
|
||||
seven days with no gap and no overlap, and the add-on checks that at startup. A
|
||||
gap is refused rather than filled in, because a made-up price is one the
|
||||
optimiser would trade on; an overlap is refused because otherwise one of the two
|
||||
prices silently wins on the order you typed them in.
|
||||
|
||||
**What it changes today.** The add-on works out which window you are in and
|
||||
whether that window is *cheap* — strictly below the tariff's seven-day average.
|
||||
The Web UI shows both on a new **Tariff** row, and the maintenance cycle's
|
||||
cheap-window grid top-up uses it, which is exactly the "shift load into the
|
||||
cheap window" behaviour a day/night tariff asks for. Nothing else moves energy
|
||||
on a price yet.
|
||||
|
||||
Under a **fully fixed** tariff every hour equals the average, so there is *no*
|
||||
cheap window by construction and the add-on runs self-consumption only — which
|
||||
is the correct behaviour for that product, not a limitation. Peak shaving, the
|
||||
capacity-tariff tracker, the reserve floor and the maintenance schedule are all
|
||||
unaffected: the capacity tariff is a network cost charged whatever your energy
|
||||
contract is.
|
||||
|
||||
**Recommended even on a dynamic contract.** When a price feed is unreachable the
|
||||
add-on falls back to your static profile, marks the plan provisional and
|
||||
disables arbitrage. With `static_profile` empty there is nothing to fall back
|
||||
to, and the site simply has no tariff until the feed returns. It is not required
|
||||
— the add-on warns rather than refusing — but filling it in is what makes that
|
||||
fallback work.
|
||||
|
||||
### Site
|
||||
|
||||
| option | meaning |
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
"""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,23 +64,6 @@ class Decision:
|
||||
# and quantisation. Carry it back in as `i_w` next cycle; that is what keeps
|
||||
# it a separate quantity from the command.
|
||||
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(
|
||||
@@ -112,23 +95,19 @@ def compute(
|
||||
# lets slew be larger than saturation_w.
|
||||
#
|
||||
# The spec states this window twice and differently: "> 10 s" (§11.2) and
|
||||
# "3 samples" (§10.3). This counts CYCLES, and a cycle is still not a unit
|
||||
# of time - but it is now a BOUNDED one. run_control() runs a cycle on a
|
||||
# changed meter value OR on `cycle_floor_s` elapsing, whichever comes first
|
||||
# (SAFETY-12), so three cycles is at most 3 x cycle_floor_s = 45 s at the
|
||||
# defaults, and usually ~15 s at the reference P1's ~5 s update rate.
|
||||
# "3 samples" (§10.3). This counts CYCLES, and a cycle is not a unit of
|
||||
# time: run_control() calls cycle() only when the meter value CHANGES
|
||||
# (`if self.grid != last_grid`), so three cycles is three distinct meter
|
||||
# readings and nothing more. At the reference P1's ~5 s update rate that is
|
||||
# usually ~15 s, but there is no upper bound on it - a meter that repeats a
|
||||
# value stalls the counter.
|
||||
#
|
||||
# ⚠️ THIS COMMENT USED TO CLAIM there was no upper bound and that it did not
|
||||
# matter, "because the same condition that stalls the counter stalls the
|
||||
# whole loop, so nothing accumulates in the meantime". The first clause was
|
||||
# true and the second was the bug: a stalled loop does not stop commanding,
|
||||
# it goes on emitting its last command through the heartbeat. Windup was
|
||||
# never the hazard; staleness was.
|
||||
#
|
||||
# The wall-clock window lives in Controller, which has a clock, exactly as
|
||||
# this comment always said it should. ponytail: this function stays
|
||||
# clockless; the remaining ceiling is that saturation_cycles expresses a
|
||||
# bound in seconds rather than an exact number of them.
|
||||
# That is a detection-latency limit, not a windup hazard: the same
|
||||
# condition that stalls the counter stalls the whole loop, so nothing
|
||||
# accumulates in the meantime either. If a wall-clock window is ever
|
||||
# required, it belongs in Controller (which has a clock) and not here.
|
||||
# ponytail: this function is worth keeping clockless; the ceiling is that
|
||||
# saturation_cycles cannot express a guaranteed number of seconds.
|
||||
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
|
||||
sat_count = min(sat_count + 1, 10) if saturated_now else 0
|
||||
frozen = sat_count >= tuning.saturation_cycles
|
||||
@@ -177,46 +156,15 @@ def compute(
|
||||
# has been in service on real hardware. It is applied here as well
|
||||
# because the requirement is that the INTEGRATOR stop accumulating, not
|
||||
# only the command.
|
||||
#
|
||||
# ⚠️ EXACTLY ZERO IS ITS OWN CASE, and it must be handled explicitly
|
||||
# rather than falling into one of the two branches. "May not wind
|
||||
# further in the direction it is already pushing" has no referent at
|
||||
# zero: nothing is wound, and neither direction is "further". Writing
|
||||
# this as `if i_w > 0 ... else ...` silently files zero under
|
||||
# rising-only and permanently blocks the first push toward charging -
|
||||
# the same deadlock as the shrink-only encoding above, mirrored in sign,
|
||||
# and reachable because main.py resets i_w to exactly 0.0 on every stop
|
||||
# and every reseed. Measured before the fix: 12 800 of 25 920 frozen
|
||||
# ticks at i_w == 0.0 held the integrator, 8 304 of them changing the
|
||||
# emitted command, worst case abandoning a 2 kW charge into a 4 kW
|
||||
# export.
|
||||
#
|
||||
# Freezing at zero would also be pointless: the freeze exists to stop
|
||||
# accumulation running away, and a first step from zero is bounded by
|
||||
# the gain, the output clamp and the slew limit like any other.
|
||||
if not frozen or i_w == 0.0:
|
||||
if not frozen:
|
||||
i_w = moved
|
||||
elif i_w > 0:
|
||||
i_w = min(moved, i_w)
|
||||
else:
|
||||
i_w = max(moved, i_w)
|
||||
i_w = min(moved, i_w) if i_w > 0 else max(moved, i_w)
|
||||
|
||||
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
|
||||
# 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.
|
||||
bounded = max(-limit, min(limit, i_w))
|
||||
i_clamped = bounded != i_w
|
||||
if i_clamped:
|
||||
# ⚠️ 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
|
||||
# integrator bound is reached first and the command derived from it can
|
||||
# then never exceed max_w, so "clamped" is unreachable on a default
|
||||
# install - it survives only for a configuration that deliberately lets
|
||||
# the integrator run above the rail. Two reasons rather than one
|
||||
# because the two events want different alarms: "i-clamped" is the loop
|
||||
# winding, "clamped" is a command that came out over the rating anyway.
|
||||
reason = "i-clamped"
|
||||
i_w = bounded
|
||||
i_w = max(-limit, min(limit, i_w))
|
||||
want = i_w
|
||||
|
||||
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
|
||||
@@ -227,8 +175,7 @@ def compute(
|
||||
# --- ORDER MATTERS: clamp -> slew -> freeze ----------------------------
|
||||
# An early draft applied a floor after the clamp and let demand escape it.
|
||||
target = max(-tuning.max_w, min(tuning.max_w, want))
|
||||
clamped = target != want
|
||||
if clamped:
|
||||
if target != want:
|
||||
reason = "clamped"
|
||||
|
||||
slewed = max(prev_w - tuning.slew_w, min(prev_w + tuning.slew_w, target))
|
||||
@@ -254,8 +201,7 @@ def compute(
|
||||
step = max(1, int(tuning.step_w))
|
||||
target = round(target / step) * step
|
||||
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||
i_clamped, clamped)
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w))
|
||||
|
||||
|
||||
def maintenance_charge_floor(
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
"""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
|
||||
+24
-872
File diff suppressed because it is too large
Load Diff
@@ -28,10 +28,9 @@ _LOG = logging.getLogger("goodwe.maint")
|
||||
|
||||
IDLE, DRAIN, CHARGE, HOLD = "idle", "drain", "charge", "hold"
|
||||
|
||||
# A drain of a 13.8 kWh usable pack (300 Ah x 51.2 V, DoD 90) at the shipped
|
||||
# 2000 W is ~6.9 h even with no house load helping - and less in practice, since
|
||||
# house load drains alongside the export and soc_floor stops short of empty.
|
||||
# 12 h means the battery is not following commands - a fault, not a slow day. The charge phase deliberately waits for sun and for cheap hours, so
|
||||
# A drain of a 13.5 kWh usable pack at 2500 W is ~5 h even with no house load
|
||||
# helping. 12 h means the battery is not following commands - a fault, not a
|
||||
# slow day. The charge phase deliberately waits for sun and for cheap hours, so
|
||||
# it is expected to span a night; 36 h still bounds it.
|
||||
DRAIN_TIMEOUT_MIN = 720
|
||||
CHARGE_TIMEOUT_MIN = 2160
|
||||
|
||||
@@ -45,32 +45,13 @@ SENSORS = [
|
||||
("soc", "goodwe_battery_soc", "Battery SoC", "%", "battery", "measurement", None),
|
||||
("phase", "goodwe_maintenance_phase", "Maintenance phase", None, None, None, "mdi:battery-sync"),
|
||||
("status", "goodwe_controller_status", "Controller status", None, None, None, "mdi:heart-pulse"),
|
||||
# ⚠️ THE LIVE ENTITY IS `sensor.goodwe_p1_sample_age`, NOT what this
|
||||
# object_id spells. An earlier version of this comment said the id "must be
|
||||
# exactly sensor.p1_sample_age_s" - that name has never existed on any HA,
|
||||
# and believing it is how a safety layer gets disarmed by someone tidying up.
|
||||
#
|
||||
# On HA 2026.8 `object_id` in a discovery payload does NOT determine the
|
||||
# entity_id. HA composes it from the device name plus the entity name:
|
||||
# "GoodWe RS485 Controller" + "P1 sample age" -> sensor.goodwe_p1_sample_age.
|
||||
# Verified against the live system 2026-08-26; the firmware subscribes to
|
||||
# that composed name (goodwe-master-stage4-failsafe.yaml, homeassistant
|
||||
# sensor p1_age_s), and its own comment records that sensor.p1_sample_age_s
|
||||
# never existed.
|
||||
#
|
||||
# So: do NOT "fix" the firmware to match the object_id below, and do not
|
||||
# rename the device or this entity's friendly name - either changes the
|
||||
# composed id and silently disarms the ingest guard, because has_state()
|
||||
# simply stays false forever and nothing reports it. The firmware calls this
|
||||
# out as the failure that "FAILS SILENTLY AND LOOKS CORRECT".
|
||||
#
|
||||
# The value: seconds since the newest accepted P1 telegram, republished
|
||||
# every second so a meter frozen at a constant value still shows a climbing
|
||||
# age - the false-trip this entity exists to remove.
|
||||
#
|
||||
# ⚠️ Separately, and not fixed here: the firmware's ingest guard tests this
|
||||
# sensor's VALUE rather than its arrival, so a dead publisher freezes the
|
||||
# number and reads permanently fresh. Filed as TEL-06.
|
||||
# ⚠️ This one deliberately breaks the goodwe_ prefix above: the entity id
|
||||
# must be exactly `sensor.p1_sample_age_s`, because SAFETY-01's firmware
|
||||
# watchdog subscribes to that literal id and the ENV-01 simulation rig
|
||||
# asserts on it. Renaming it silently disarms a safety layer. It is seconds
|
||||
# since the newest accepted P1 telegram, republished every second so that a
|
||||
# meter frozen at a constant value still shows a climbing age - which is the
|
||||
# false-trip that this entity exists to remove.
|
||||
("p1_age", "p1_sample_age_s", "P1 sample age", "s", "duration", "measurement", None),
|
||||
]
|
||||
|
||||
|
||||
+7
-571
@@ -4,22 +4,13 @@ Everything downstream trusts this module: the safety checks, the capacity-tariff
|
||||
peak, the optimizer, the control loop's sign. So three things happen here and
|
||||
nowhere else.
|
||||
|
||||
1. The IMPORT/EXPORT DERIVATION. A Belgian P1 read over DSMR exposes two
|
||||
UNSIGNED registers - consumption and injection. Net power is
|
||||
`import_w - export_w`, positive = import, and that subtraction is done
|
||||
1. The IMPORT/EXPORT DERIVATION. A Belgian P1 meter exposes two UNSIGNED
|
||||
registers - consumption and injection - never one signed figure. Net power
|
||||
is `import_w - export_w`, positive = import, and that subtraction is done
|
||||
exactly once, here (spec §5.2: "the derivation is the EMS's job, not a
|
||||
template the user has to write"). A second copy of it somewhere else is a
|
||||
second chance to invert the control loop.
|
||||
|
||||
Some P1 readers - the HomeWizard P1 among them - publish the OTHER shape:
|
||||
one SIGNED figure, positive = import, and no unsigned registers at all.
|
||||
`split_signed()` fans that back out into the same two magnitudes, so there
|
||||
is still exactly one internal representation and one sign convention. ⚠️ It
|
||||
lives here, next to the subtraction, for the same reason the subtraction
|
||||
does: the moment a user is asked to write two template sensors that split a
|
||||
signed value, the sign convention is back in unreviewed YAML underneath a
|
||||
safety input, which is precisely what §5.2 moved into the EMS.
|
||||
|
||||
2. THE INGEST TIMESTAMP. Every accepted sample is stamped on arrival. A value
|
||||
with no age is a value that cannot be trusted (§5.2), and staleness is the
|
||||
failsafe trigger (§11.2).
|
||||
@@ -56,8 +47,6 @@ _LOG = logging.getLogger("goodwe.p1")
|
||||
|
||||
SOURCE_HA = "ha_dsmr"
|
||||
SOURCE_MQTT = "mqtt_p1"
|
||||
SOURCE_HA_SIGNED = "ha_signed"
|
||||
SOURCE_HOMEWIZARD = "homewizard_local"
|
||||
|
||||
QUARTER_S = 900
|
||||
|
||||
@@ -89,7 +78,7 @@ class P1Sample:
|
||||
ingest_ts: datetime # tz-aware UTC, set at ingest
|
||||
ingest_mono: float # time.monotonic() at ingest - see age_s()
|
||||
telegram_ts: datetime | None # from the telegram, where the source has one
|
||||
source: str # one of the SOURCE_* constants above
|
||||
source: str # SOURCE_HA | SOURCE_MQTT
|
||||
import_w: float # unsigned magnitude, as the meter reports it
|
||||
export_w: float # unsigned magnitude
|
||||
net_w: float # import_w - export_w (+ import, - export)
|
||||
@@ -140,29 +129,6 @@ def _watts(value, what: str) -> float:
|
||||
return out
|
||||
|
||||
|
||||
def split_signed(net_w) -> tuple[float, float]:
|
||||
"""One signed figure -> the (import, export) magnitudes the module speaks.
|
||||
|
||||
The inverse of make_sample's subtraction, and the easy direction: no second
|
||||
register to disagree with, so there is nothing to mix a fresh reading with a
|
||||
stale one. `+` is import, `-` is export - verified in test_p1.py against real
|
||||
captured readings from this house's own meter, not against a datasheet.
|
||||
|
||||
⚠️ Exactly one of the two comes out non-zero. Splitting into `(max(v,0),
|
||||
max(-v,0))` rather than clamping keeps `import_w - export_w == v` exactly, so
|
||||
the signed value the meter published survives the round trip bit for bit -
|
||||
a control loop must not be steered by a number that changed on the way in.
|
||||
|
||||
⚠️ Validation is `_watts`, the same gate the unsigned path uses: NaN,
|
||||
infinity, non-numbers and the §20 open-question-5 unsigned-decode
|
||||
contamination (64954 for -582 W) are all refused here rather than believed.
|
||||
A signed source makes that check MORE important, not less - on this path
|
||||
64954 is not obviously wrong the way a negative "unsigned" register is.
|
||||
"""
|
||||
v = _watts(net_w, "net")
|
||||
return (v, 0.0) if v >= 0 else (0.0, -v)
|
||||
|
||||
|
||||
def make_sample(source: str, import_w, export_w, *, phases: int,
|
||||
phase_import_w=None, phase_export_w=None,
|
||||
telegram_ts: datetime | None = None,
|
||||
@@ -286,26 +252,6 @@ class QuarterAverager:
|
||||
"""Offtake watt-seconds accumulated in the open block so far."""
|
||||
return self._acc
|
||||
|
||||
@property
|
||||
def held_net_w(self) -> float:
|
||||
"""The signed net power currently being held forward, in W.
|
||||
|
||||
PUBLIC BECAUSE SAFETY-07 DEPENDS ON IT, AND THAT IS THE WHOLE REASON.
|
||||
`PeakTracker.project()` extrapolates the rest of the quarter from this
|
||||
value and used to reach it as `getattr(averager, "_last_net", 0.0)`. A
|
||||
default on a private name fails QUIET and fails in the DISARMING
|
||||
direction: rename `_last_net` and every projection silently credits 0 W
|
||||
held, which reads LOW, which puts the peak guard under the ceiling
|
||||
exactly when the meter says it is over. Filed as TEST-13 item 4 and
|
||||
fixed here rather than inherited - a money guard may not rest on a read
|
||||
that can go wrong without a symptom.
|
||||
|
||||
Positive is offtake, matching `P1Sample.net_w`. It is the raw held
|
||||
value, NOT floored at zero: the flooring is the accumulator's rule (only
|
||||
offtake is billed) and belongs where it is applied, not here.
|
||||
"""
|
||||
return self._last_net
|
||||
|
||||
@property
|
||||
def offtake_avg_w(self) -> float:
|
||||
"""Average offtake over the part of the open block seen so far."""
|
||||
@@ -373,54 +319,6 @@ class QuarterAverager:
|
||||
self._last_pp = sample.per_phase_w
|
||||
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
|
||||
@@ -770,333 +668,6 @@ class MqttP1Source:
|
||||
self.ingest.reject(err)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# transport 3: Home Assistant WebSocket, one signed entity
|
||||
# --------------------------------------------------------------------------- #
|
||||
class HaSignedSource(HaDsmrSource):
|
||||
"""The same websocket, subscribed to ONE signed power entity.
|
||||
|
||||
For readers that publish net power as a single signed figure - a HomeWizard
|
||||
P1's `sensor.p1_meter_active_power`, positive = import - rather than the two
|
||||
unsigned DSMR registers. This is the meter actually installed at the house,
|
||||
and `ha_dsmr` cannot read it: it needs two registers and refuses a negative
|
||||
one outright, which is every exporting telegram.
|
||||
|
||||
⚠️ A subclass, not a copy. The connect / auth / subscribe / reconnect /
|
||||
`_absorb` machinery above is transport, not shape, and it has already been
|
||||
debugged once - notably "prime the cache from get_states but never build a
|
||||
sample out of it" and "a reconnect emits nothing". Only `_wanted` (which
|
||||
entity ids) and `build` (how they become a sample) differ, so only those two
|
||||
are overridden. Everything TEL-01 established therefore applies unchanged:
|
||||
ingest timestamping, meter_max_age_s, the clock-recomputed age sensor, and
|
||||
`unavailable` treated as a missing reading rather than 0 W.
|
||||
|
||||
⚠️ THE AGE ON THIS TRANSPORT MEASURES TIME SINCE THE VALUE CHANGED, not time
|
||||
since the meter reported, and on one entity those are very different things.
|
||||
Home Assistant offers no arrival signal for a repeated reading: it emits no
|
||||
`state_changed`, it does not advance `last_reported` on either serialiser,
|
||||
and `state_reported` cannot be subscribed to over the websocket at all
|
||||
("Event filter is required for event state_reported"). All three measured on
|
||||
the ENV-01 rig against the real HomeWizard integration with the meter frozen
|
||||
- 0 state_changed in 70 s, no timestamp movement anywhere.
|
||||
|
||||
`ha_dsmr` mostly escapes it because a DSMR telegram moves several entities at
|
||||
once. This transport has ONE, so a healthy meter under a flat load looks
|
||||
exactly like a dead one - and our own capture has the real meter going 42.2 s
|
||||
and 97.0 s between changes, both past the default max_age_s of 30. Hence
|
||||
DOCS.md: sensor.p1_sample_age_s is correct while the value moves and must not
|
||||
yet be thresholded by the firmware watchdog on this transport. Raising
|
||||
meter_max_age_s does not fix it, it only chooses which of the two errors you
|
||||
get. The fix is an arrival stamp from the meter itself - reading the
|
||||
HomeWizard local API rather than an HA entity - which is a separate ticket.
|
||||
|
||||
⚠️ The debounce is inherited but does nothing useful here, and that is fine:
|
||||
one telegram is one entity, so there is no burst of per-entity events to
|
||||
coalesce and no window in which a new reading sits beside a stale one. It
|
||||
costs one scheduled sleep per telegram at ~0.2 Hz. Left in place rather than
|
||||
special-cased, because a second code path through build() is a second place
|
||||
for the sign to go wrong.
|
||||
"""
|
||||
|
||||
def _wanted(self) -> set[str]:
|
||||
out = set()
|
||||
if self.entities.get("net"):
|
||||
out.add(self.entities["net"])
|
||||
out.update(e for e in self.entities.get("phase_net") or [] if e)
|
||||
return out
|
||||
|
||||
def build(self) -> bool:
|
||||
"""Assemble one sample from the cache. Returns True if one was accepted."""
|
||||
net_id = self.entities.get("net")
|
||||
if net_id not in self.cache:
|
||||
return False
|
||||
pn = [self.cache.get(e) for e in self.entities.get("phase_net") or []]
|
||||
if pn and None in pn:
|
||||
return False # incomplete phase set: wait, do not guess
|
||||
try:
|
||||
imp, exp = split_signed(self.cache[net_id])
|
||||
pi = pe = None
|
||||
if pn:
|
||||
# ponytail: this split is arithmetically redundant today -
|
||||
# make_sample subtracts the two lists again and does not
|
||||
# sign-check per-phase figures, so handing it the signed values
|
||||
# with a zero export list produces the identical tuple. Verified:
|
||||
# mutating it that way leaves all 174 checks green, i.e. no test
|
||||
# can tell the difference, and it is recorded here rather than
|
||||
# left as a silent equivalent mutant for the next reviewer to
|
||||
# rediscover. Kept because `phase_import_w` means a MAGNITUDE:
|
||||
# a negative in it is the double-signing that make_sample refuses
|
||||
# outright for the connection-level registers, and the day that
|
||||
# check is extended per-phase the shortcut breaks the meter, not
|
||||
# the test.
|
||||
pairs = [split_signed(v) for v in pn]
|
||||
pi = [a for a, _ in pairs]
|
||||
pe = [b for _, b in pairs]
|
||||
self.ingest.submit(make_sample(
|
||||
SOURCE_HA_SIGNED, imp, exp,
|
||||
phases=self.ingest.phases,
|
||||
phase_import_w=pi, phase_export_w=pe,
|
||||
# ⚠️ No telegram_ts, for the same reason as ha_dsmr: HA's
|
||||
# last_changed is when the VALUE changed, which on a steady meter
|
||||
# is minutes ago while the telegram is current. sensor.
|
||||
# p1_sample_age_s is what covers a genuinely frozen meter.
|
||||
))
|
||||
return True
|
||||
except P1Error as err:
|
||||
self.ingest.reject(err)
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# transport 4: the HomeWizard P1's own local API
|
||||
# --------------------------------------------------------------------------- #
|
||||
HOMEWIZARD_PATH = "/api/v1/data"
|
||||
HOMEWIZARD_PORT = 80
|
||||
|
||||
|
||||
def parse_homewizard(doc, phases: int, *, now: datetime | None = None,
|
||||
ingest_mono: float | None = None) -> P1Sample:
|
||||
"""One `GET /api/v1/data` document -> a sample. Raises P1Error.
|
||||
|
||||
The meter's own JSON, of which we read four keys:
|
||||
|
||||
{"active_power_w": -5710.0, # SIGNED, + import - export
|
||||
"active_power_l1_w": ..., "active_power_l2_w": ..., "..._l3_w": ...,
|
||||
"total_power_import_kwh": 1234.567, "total_power_export_kwh": 890.123}
|
||||
|
||||
Same shape as `ha_signed` - one signed connection figure plus signed
|
||||
per-phase figures - so it goes through the same `split_signed` and the same
|
||||
`make_sample`. Nothing about the sign convention is re-derived here.
|
||||
|
||||
⚠️ No telegram timestamp, because the API carries none, and that is correct
|
||||
rather than a gap: the document was produced by the meter in the moment it
|
||||
answered, so the ingest stamp IS the measurement time. This is the whole
|
||||
reason the transport exists - see HomeWizardLocalSource.
|
||||
|
||||
⚠️ Per-phase figures are taken only when the meter serves ALL `phases` of
|
||||
them. A single-phase HomeWizard returns `active_power_l2_w: null`, and a
|
||||
partial set must not become a fabricated tuple. Unlike the HA transports
|
||||
there is no "wait for the rest" case to worry about: every field here came
|
||||
out of ONE response, so a missing phase cannot be a stale phase, and the
|
||||
connection-level reading is still a genuine, complete measurement. Dropping
|
||||
the whole sample over an absent per-phase field would turn a healthy meter
|
||||
into a climbing age, which is the exact false-trip this ticket removes.
|
||||
"""
|
||||
if not isinstance(doc, dict):
|
||||
raise P1Error(f"meter response is not a JSON object ({type(doc).__name__})")
|
||||
|
||||
imp, exp = split_signed(doc.get("active_power_w"))
|
||||
|
||||
pi = pe = None
|
||||
# ⚠️ range(phases), not the legs the meter served: a 3-phase meter configured
|
||||
# as meter_phases: 1 yields a per-phase tuple covering ONE leg of three.
|
||||
# Control is unaffected (it uses the connection figure), but the
|
||||
# capacity-tariff peak is understated. Filed separately - not fixed here.
|
||||
legs = [doc.get(f"active_power_l{i + 1}_w") for i in range(phases)]
|
||||
if all(v is not None for v in legs):
|
||||
pairs = [split_signed(v) for v in legs]
|
||||
pi = [a for a, _ in pairs]
|
||||
pe = [b for _, b in pairs]
|
||||
|
||||
return make_sample(SOURCE_HOMEWIZARD, imp, exp, phases=phases,
|
||||
phase_import_w=pi, phase_export_w=pe,
|
||||
ingest_ts=now, ingest_mono=ingest_mono)
|
||||
|
||||
|
||||
def measurement_fingerprint(doc) -> tuple:
|
||||
"""The part of a response that a genuinely new measurement has to move.
|
||||
|
||||
Power plus both energy registers. A meter under any real load advances a
|
||||
kWh counter (1 Wh resolution: ~10 s at 350 W), so this changes even when the
|
||||
power figure happens to repeat. Used ONLY for `unchanged_s` - see there for
|
||||
why it must not be allowed anywhere near the age.
|
||||
"""
|
||||
if not isinstance(doc, dict):
|
||||
return ()
|
||||
return (doc.get("active_power_w"),
|
||||
doc.get("total_power_import_kwh"),
|
||||
doc.get("total_power_export_kwh"))
|
||||
|
||||
|
||||
class HomeWizardLocalSource:
|
||||
"""Polls the meter's own local HTTP API instead of a Home Assistant entity.
|
||||
|
||||
⚠️ THIS IS THE ONLY TRANSPORT WHOSE AGE MEASURES ARRIVAL. That is the entire
|
||||
reason it exists, and it is not an optimisation - it is the difference
|
||||
between an age sensor a firmware watchdog may threshold and one it may not.
|
||||
|
||||
Every HTTP response is a genuine arrival: the meter answered, now, with its
|
||||
current reading. Whether the NUMBER moved is irrelevant, so a healthy meter
|
||||
under a flat load resets the age exactly like a busy one. Home Assistant
|
||||
cannot provide this at all - it emits `state_changed` only on a change, does
|
||||
not advance `last_reported` on either serialiser for a repeat, and refuses a
|
||||
`state_reported` subscription outright ("Event filter is required"). Measured
|
||||
on the ENV-01 rig over 70 s of a frozen meter, and independently against the
|
||||
live house over ten repeated readings. So on `ha_signed` the age means "time
|
||||
since the value changed", and our own capture of this house's meter has
|
||||
42.2 s and 97.0 s between changes - both past the default `meter_max_age_s`
|
||||
of 30, i.e. a false trip to 0 W on a perfectly healthy meter.
|
||||
|
||||
Everything else is TEL-01's pipeline unchanged: ingest timestamping,
|
||||
`meter_max_age_s`, the clock-recomputed age, the plausibility bounds, and a
|
||||
failed read treated as a MISSING reading rather than 0 W.
|
||||
|
||||
⚠️ A failed or timed-out poll submits nothing. It is a rejection, so the
|
||||
last good sample and its stamp stay exactly where they were and the age goes
|
||||
on climbing - which is precisely the signal a dead meter should produce.
|
||||
|
||||
⚠️ POLL CADENCE vs `meter_max_age_s`. The age can only be as fresh as the
|
||||
poll interval, so `meter_poll_s` must sit well under `meter_max_age_s` or the
|
||||
reading flaps in and out of staleness on a healthy meter. The real meter
|
||||
updates every ~5.0 s (NOTES.md:640 measures 4.97 s), so polling faster than
|
||||
that buys nothing but re-reads. Default 5 s against a 30 s max age; the
|
||||
startup check in build_source refuses `meter_poll_s >= meter_max_age_s` and
|
||||
warns past half of it.
|
||||
|
||||
ponytail: a plain `asyncio.sleep` loop rather than a scheduler, and no
|
||||
backoff. A poll that fails costs one rejection and is retried on the next
|
||||
tick; a meter that is down stays down and the age reports it. The ceiling is
|
||||
a meter so slow that polls overlap - the request timeout is held under the
|
||||
poll interval so they cannot.
|
||||
"""
|
||||
|
||||
def __init__(self, session, ingest: P1Ingest, host: str, *,
|
||||
port: int = HOMEWIZARD_PORT, poll_s: float = 5.0,
|
||||
timeout_s: float | None = None):
|
||||
self.session = session
|
||||
self.ingest = ingest
|
||||
self.host, self.port = host, int(port)
|
||||
self.poll_s = float(poll_s)
|
||||
# ⚠️ Held under the poll interval on purpose: a timeout longer than the
|
||||
# cadence queues polls behind a hung meter, and a backlog of requests
|
||||
# all landing at once would stamp several arrivals for one measurement.
|
||||
self.timeout_s = float(timeout_s) if timeout_s else max(1.0, self.poll_s * 0.8)
|
||||
self.url = f"http://{self.host}:{self.port}{HOMEWIZARD_PATH}"
|
||||
self.connected = False
|
||||
self.polls = 0
|
||||
self.poll_errors = 0
|
||||
self._fingerprint: tuple | None = None
|
||||
self._changed_mono: float | None = None
|
||||
|
||||
@property
|
||||
def unchanged_s(self) -> float | None:
|
||||
"""Seconds since the meter last served a DIFFERENT measurement.
|
||||
|
||||
⚠️ Diagnostic only, and deliberately NOT folded into
|
||||
`sensor.p1_sample_age_s`. A frozen meter (SAFETY-01's six-minute
|
||||
runaway) answers 200 OK with a stale document forever, so no arrival
|
||||
detector can see it - only the document standing still can, and this is
|
||||
that signal. It is exposed rather than acted on because it is NOT safe
|
||||
as a 30 s watchdog input: this controller regulates grid power toward
|
||||
~0 W, and at a converged -10 W the export register takes six minutes to
|
||||
advance by its 1 Wh resolution while the power figure legitimately
|
||||
repeats. Thresholding that would rebuild the very limit cycle the
|
||||
arrival stamp just removed, at the exact operating point we aim for.
|
||||
Whoever wires a freeze detector must handle the low-power case first.
|
||||
"""
|
||||
if self._changed_mono is None:
|
||||
return None
|
||||
return max(0.0, time.monotonic() - self._changed_mono)
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Long-lived task: poll, submit, sleep, forever.
|
||||
|
||||
⚠️ Nothing but cancellation may end this loop. An escaping exception
|
||||
would kill the poll task for the lifetime of the add-on, and it would do
|
||||
it QUIETLY: the failure mode is safe (no submissions, the age climbs, the
|
||||
watchdog holds the battery at 0 W) but it looks identical to a dead
|
||||
meter, so the operator goes hunting the wrong device. Retry on the next
|
||||
tick instead - poll_s is already the retry cadence, so no backoff.
|
||||
"""
|
||||
_LOG.info("P1 ingest: polling %s every %.1fs", self.url, self.poll_s)
|
||||
while True:
|
||||
try:
|
||||
await self.poll_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - the task must outlive it
|
||||
_LOG.warning("P1 meter poll %s: %s - retrying in %.1fs", self.url,
|
||||
str(err) or type(err).__name__, self.poll_s)
|
||||
await asyncio.sleep(self.poll_s)
|
||||
|
||||
async def poll_once(self) -> bool:
|
||||
"""One GET. Returns True if a sample was accepted."""
|
||||
self.polls += 1
|
||||
try:
|
||||
async with self.session.get(
|
||||
self.url,
|
||||
timeout=aiohttp.ClientTimeout(total=self.timeout_s)) as resp:
|
||||
if resp.status != 200:
|
||||
raise P1Error(f"meter returned HTTP {resp.status}")
|
||||
# content_type=None: the meter's own firmware is the authority on
|
||||
# what it serves, and refusing a reading over a Content-Type
|
||||
# header would be a fabricated outage.
|
||||
# ⚠️ Kept because a real HomeWizard firmware that ever answers
|
||||
# text/plain would otherwise read as a dead meter and hold the
|
||||
# battery at 0 W. (This was once recorded here as an equivalent
|
||||
# mutant - it is not. aiohttp's json_response always sets
|
||||
# application/json, but web.Response(text=...) defaults to
|
||||
# text/plain, so the fake meter CAN serve valid JSON under the
|
||||
# wrong header, and test_p1.py now does.)
|
||||
doc = await resp.json(content_type=None)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - any read failure is a gap
|
||||
self.connected = False
|
||||
self.poll_errors += 1
|
||||
# ⚠️ str() of a bare asyncio.TimeoutError is the EMPTY STRING, so
|
||||
# f"...: {err}" renders "last error:" and then nothing - on the
|
||||
# status page, on a hung meter, at the moment the battery has just
|
||||
# dropped to 0 W and someone is reading that line to find out why.
|
||||
# The class name is the only thing that says "it timed out".
|
||||
# ⚠️ str(err), not `err or ...`: an exception object is ALWAYS truthy,
|
||||
# empty message or not, so the `or` would never reach the fallback.
|
||||
self.ingest.reject(
|
||||
f"meter poll {self.url}: {str(err) or type(err).__name__}")
|
||||
return False
|
||||
|
||||
self.connected = True
|
||||
try:
|
||||
sample = parse_homewizard(doc, self.ingest.phases)
|
||||
# ⚠️ Submitted unconditionally, INCLUDING a document identical to the
|
||||
# last one. The response is the arrival; the number is not.
|
||||
# Inside the try on purpose: submit() outside it would put a raise on
|
||||
# a path with no handler at all, killing the poll task permanently -
|
||||
# safely (the age climbs) but silently. run() backstops the rest.
|
||||
self.ingest.submit(sample)
|
||||
self._note(doc)
|
||||
except P1Error as err:
|
||||
self.ingest.reject(err)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _note(self, doc) -> None:
|
||||
fp = measurement_fingerprint(doc)
|
||||
if self._changed_mono is None or fp != self._fingerprint:
|
||||
self._fingerprint = fp
|
||||
self._changed_mono = time.monotonic()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# selection
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1112,133 +683,15 @@ def is_enabled(opts: dict) -> bool:
|
||||
return str(opts.get("meter_source", "off") or "off").strip() not in ("off", "")
|
||||
|
||||
|
||||
def homewizard_host_port(opts: dict) -> tuple[str, str]:
|
||||
"""`p1_host` split into host and (still unparsed) port text.
|
||||
|
||||
One definition because validate_meter_options() and build_source() must
|
||||
agree byte for byte about what counts as a usable address - a pasted browser
|
||||
URL is the obvious way to fill this in wrong.
|
||||
"""
|
||||
host = str(opts.get("p1_host", "") or "").strip()
|
||||
host = host.removeprefix("http://").removeprefix("https://").rstrip("/")
|
||||
host, _, port_s = host.partition(":")
|
||||
return host, port_s
|
||||
|
||||
|
||||
def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[str]:
|
||||
"""Every way `meter_source` can name a meter that will never produce a reading.
|
||||
|
||||
⚠️ THIS IS THE ONLY PLACE THAT KNOWS THESE RULES. It used to be a run of
|
||||
inline `return None`s inside build_source(); CFG-01 lifted them out because
|
||||
the same predicates now have two callers with two different consequences:
|
||||
|
||||
validate.validate_startup_config() refuses to start the add-on
|
||||
build_source() returns None, as it always did
|
||||
|
||||
They are one function and not two on purpose. A second copy of "what makes a
|
||||
meter config wrong" would drift, and the half that drifted would be the one
|
||||
that decides whether the process is allowed to run.
|
||||
|
||||
Checked ONCE at startup rather than per telegram, because a misconfigured
|
||||
source otherwise fails in the one way indistinguishable from a healthy source
|
||||
nobody has sent anything to yet: no samples, a climbing age, the firmware
|
||||
watchdog holding the battery at 0 W, and nothing in the log saying why.
|
||||
|
||||
Returns a list of human-readable problems; empty means the config is usable.
|
||||
`phases` and `max_age_s` are passed in rather than read from opts so the
|
||||
caller decides whether it is validating the options or an already-built
|
||||
P1Ingest - build_source() has an ingest whose values may not have come from
|
||||
this dict.
|
||||
"""
|
||||
source = str(opts.get("meter_source", "off") or "off").strip()
|
||||
if source in ("off", ""):
|
||||
return []
|
||||
problems: list[str] = []
|
||||
|
||||
if source == SOURCE_HA:
|
||||
imp = str(opts.get("p1_import_entity", "") or "").strip()
|
||||
exp = str(opts.get("p1_export_entity", "") or "").strip()
|
||||
if not imp or not exp:
|
||||
problems.append(
|
||||
f"meter_source {SOURCE_HA} needs both p1_import_entity and "
|
||||
f"p1_export_entity (got {imp or '<blank>'!r} / {exp or '<blank>'!r})")
|
||||
elif source == SOURCE_HA_SIGNED:
|
||||
net = str(opts.get("p1_net_entity", "") or "").strip()
|
||||
phase_net = [str(e).strip() for e in
|
||||
(opts.get("p1_phase_net_entities") or []) if str(e).strip()]
|
||||
if not net:
|
||||
problems.append(
|
||||
f"meter_source {SOURCE_HA_SIGNED} needs p1_net_entity "
|
||||
"(sensor.p1_sample_age_s would otherwise be announced with "
|
||||
"nothing feeding it)")
|
||||
if phase_net and len(phase_net) != phases:
|
||||
problems.append(
|
||||
f"p1_phase_net_entities has {len(phase_net)} entities but "
|
||||
f"meter_phases is {phases} - every telegram would be rejected "
|
||||
"on the phase-count check")
|
||||
elif source == SOURCE_MQTT:
|
||||
if not str(opts.get("meter_mqtt_topic", "") or "").strip():
|
||||
problems.append(f"meter_source {SOURCE_MQTT} needs meter_mqtt_topic")
|
||||
elif source == SOURCE_HOMEWIZARD:
|
||||
host, port_s = homewizard_host_port(opts)
|
||||
if not host:
|
||||
problems.append(
|
||||
f"meter_source {SOURCE_HOMEWIZARD} needs p1_host (the meter's "
|
||||
"own address, e.g. 192.168.2.250)")
|
||||
if port_s:
|
||||
try:
|
||||
int(port_s)
|
||||
except ValueError:
|
||||
problems.append(f"p1_host {opts.get('p1_host')!r} has an "
|
||||
"unparseable port")
|
||||
try:
|
||||
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
|
||||
# cadence of 0 is a misconfiguration that must be reported, not
|
||||
# quietly corrected into something that looks like it was asked for.
|
||||
raw = opts.get("meter_poll_s", 5)
|
||||
poll_s = float(5 if raw is None or raw == "" else raw)
|
||||
except (TypeError, ValueError):
|
||||
problems.append(f"meter_poll_s {opts.get('meter_poll_s')!r} is not a number")
|
||||
else:
|
||||
if poll_s <= 0:
|
||||
problems.append("meter_poll_s must be positive")
|
||||
# ⚠️ The age can never be fresher than the poll interval. At or past
|
||||
# max_age_s every reading is stale before its successor arrives, so
|
||||
# the controller would sit permanently on missing inputs while the
|
||||
# meter is perfectly healthy - refuse it rather than ship that.
|
||||
elif poll_s >= max_age_s:
|
||||
problems.append(
|
||||
f"meter_poll_s {poll_s:.1f} is not under meter_max_age_s "
|
||||
f"{max_age_s:.1f} - every reading would go stale before the "
|
||||
"next poll")
|
||||
else:
|
||||
problems.append(
|
||||
f"meter_source {source!r} is not one of "
|
||||
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD)))
|
||||
return problems
|
||||
|
||||
|
||||
def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
|
||||
"""Return the transport named by `meter_source`, or None if disabled.
|
||||
|
||||
This is the whole of AC 1's "switchable": every consumer reads P1Ingest, so
|
||||
changing transport is a config edit, never a code path.
|
||||
|
||||
⚠️ In the add-on this is now unreachable with a bad config -
|
||||
validate_startup_config() refuses to start on anything
|
||||
validate_meter_options() rejects. The guard stays because this function is
|
||||
also called directly by tests and by anything embedding P1Ingest, and
|
||||
"returns None" is a safer default for those callers than a transport that
|
||||
can never read.
|
||||
"""
|
||||
source = str(opts.get("meter_source", "off") or "off").strip()
|
||||
if not is_enabled(opts):
|
||||
return None
|
||||
problems = validate_meter_options(opts, ingest.phases, ingest.max_age_s)
|
||||
if problems:
|
||||
for problem in problems:
|
||||
_LOG.error("%s - P1 ingestion disabled", problem)
|
||||
return None
|
||||
if source == SOURCE_HA:
|
||||
return HaDsmrSource(session, ingest, {
|
||||
"import": opts.get("p1_import_entity", ""),
|
||||
@@ -1246,29 +699,12 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
|
||||
"phase_import": opts.get("p1_phase_import_entities") or [],
|
||||
"phase_export": opts.get("p1_phase_export_entities") or [],
|
||||
})
|
||||
if source == SOURCE_HA_SIGNED:
|
||||
return HaSignedSource(session, ingest, {
|
||||
"net": str(opts.get("p1_net_entity", "") or "").strip(),
|
||||
"phase_net": [str(e).strip() for e in
|
||||
(opts.get("p1_phase_net_entities") or []) if str(e).strip()],
|
||||
})
|
||||
if source == SOURCE_HOMEWIZARD:
|
||||
host, port_s = homewizard_host_port(opts)
|
||||
port = int(port_s) if port_s else HOMEWIZARD_PORT
|
||||
raw = opts.get("meter_poll_s", 5)
|
||||
poll_s = float(5 if raw is None or raw == "" else raw)
|
||||
# A warning, not a refusal: one missed poll costs a stale reading and a
|
||||
# 0 W cycle, which is fail-safe. The refusal above is for cadences that
|
||||
# can NEVER be fresh.
|
||||
if poll_s > ingest.max_age_s / 2:
|
||||
_LOG.warning("meter_poll_s %.1f leaves no room under meter_max_age_s "
|
||||
"%.1f: one missed poll makes the reading stale. The meter "
|
||||
"updates every ~5 s; 5 s against 30 s is the tested pair.",
|
||||
poll_s, ingest.max_age_s)
|
||||
return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s)
|
||||
if source == SOURCE_MQTT:
|
||||
broker = broker or {}
|
||||
return MqttP1Source(ingest, str(opts.get("meter_mqtt_topic", "")),
|
||||
broker.get("host"), broker.get("port", 1883),
|
||||
broker.get("username"), broker.get("password"))
|
||||
if source:
|
||||
_LOG.error("meter_source %r is not %s or %s - P1 ingestion disabled",
|
||||
source, SOURCE_HA, SOURCE_MQTT)
|
||||
return None
|
||||
|
||||
@@ -1,517 +0,0 @@
|
||||
"""SAFETY-07 - the quarter-peak projection guard, and the only thing in this
|
||||
add-on that is allowed to spend the loop's freedom to save money.
|
||||
|
||||
WHAT IT IS. TARIFF-01 built the INSTRUMENT: `tariff.py` measures the number the
|
||||
Belgian capaciteitstarief bills on - the highest 15-minute average offtake of the
|
||||
calendar month - and banks it where a restart cannot reach. It files no claim and
|
||||
commands nothing, and says so in its own first paragraph. This module is the
|
||||
CONSUMER it named. It turns that measurement into one arbiter claim and nothing
|
||||
else: no writes, no I/O, no clock, no state between calls. Given a projection and
|
||||
two measured numbers it returns a decision; `main.py` files it.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE HAZARD, WITH THE EURO FIGURE
|
||||
|
||||
specs.md:583 records a measured case: ONE 4998 W quarter moved a monthly peak
|
||||
from 4832 W to 5013 W and billed the whole month at it. The capacity tariff has
|
||||
no averaging and no forgiveness - the month's single worst quarter is the bill.
|
||||
|
||||
SAFETY-18 shipped the ALARM for the way this system produces such a quarter by
|
||||
itself: a meter that answers 200 OK forever with a document that never changes,
|
||||
which every freshness mechanism in this stack reads as healthy while the loop
|
||||
integrates a constant error to the rail and holds it there. freeze.py's own
|
||||
table: at the live install's `max_w: 5000`, a meter frozen at >= 3000 W rails the
|
||||
command in 75 s. SAFETY-18 notifies at `meter_freeze_s` (300 s default), against
|
||||
a 900 s billing quarter. That is about nine minutes of human reaction time and
|
||||
nothing else - freeze.py says so in the words "THIS CUT DOES NOT CAP THAT."
|
||||
|
||||
This is the cap. It is deliberately NOT a second freeze detector: it consumes
|
||||
freeze.py's shipped predicate rather than transcribing it.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE MECHANISM: ONE `lo` LIMIT AT `P_PEAK`, AND WHY THAT SHAPE
|
||||
|
||||
specs.md 10.1 row 2 asks for exactly two behaviours - "pre-emptive discharge as
|
||||
the projected quarter average approaches the soft limit; hard clamp on charging
|
||||
above it" - and 9.1 constrains the lever: "Discharge and export are never capped
|
||||
by the peak guard; only charging and import are."
|
||||
|
||||
A LOWER BOUND on the setpoint is both of those in one object, and it is the only
|
||||
shape that is:
|
||||
|
||||
* incapable of capping discharge - a floor never stops the loop discharging
|
||||
HARDER, so 9.1's asymmetry is structural here rather than remembered;
|
||||
* incapable of capping export, for the same reason;
|
||||
* a hard clamp on charging whenever the floor is above the standing charge;
|
||||
* pre-emptive discharge whenever the floor is above zero.
|
||||
|
||||
It is a LIMIT, not a SET, and that matters at both ends of the priority table.
|
||||
`P_PEAK` (50) is above `P_LOOP` (10), so it binds the grid-following loop. It is
|
||||
BELOW `P_MAINTENANCE` (60) and `P_SAFETY` (100), so a maintenance phase that owns
|
||||
the actuator and every layer-1 stop still outrank it: arbiter.py's clause 2 only
|
||||
applies limits whose priority is >= the WINNING SET's, so a P_SAFETY 0 W stop is
|
||||
not dragged off zero by this floor. That is the correct order - a controller
|
||||
pushing power the wrong way is wrong however much money it is trying to save.
|
||||
|
||||
⚠️ AND A LIMIT DOES NOT OUT-VOTE ANOTHER LIMIT. This was assumed, and it is
|
||||
false: arbiter.py clause 2 applies EVERY limit at or above the winner's priority
|
||||
and takes the MOST RESTRICTIVE, with no notion of priority between them - which
|
||||
is exactly what makes the safety bounds unconditional and must not be changed.
|
||||
So this floor (`lo >= 0`) and the maintenance charge-only shaping (`hi <= 0`) do
|
||||
not resolve in P_PEAK's favour when both are filed. They resolve to `lo > hi`, a
|
||||
CONTRADICTION, which arbiter.py answers with 0 W - the battery parked in the one
|
||||
quarter of the month it should be shaving hardest. The rule for two limits that
|
||||
can cross is therefore SUPPRESSION, not priority: main.py drops the maintenance
|
||||
shaping for as long as this guard is engaged, which is the same thing that branch
|
||||
already did for `peak_forecast_entity` and is why 9.1's "money outranks the
|
||||
maintenance schedule" reads as a suppression there rather than as a priority
|
||||
here. The floor's clamp to `achievable_w` closes the only other pair that could
|
||||
cross - the P_SAFETY rating bounds.
|
||||
|
||||
AND IT IS WHY OPT-04 NEEDS NO CHANGE HERE. 9.1 requires peak shaving to outrank
|
||||
arbitrage. Arbitrage does not exist yet, but it will arrive as a SET below
|
||||
P_PEAK, and arbiter.py clause 2 will bind it with this floor on the day it lands,
|
||||
with no edit to this file. Expressing the priority as a claim rather than as an
|
||||
if/else in main.py is the whole reason that holds.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE FLOOR IS DERIVED, AND EVERY TERM OF IT IS MEASURED
|
||||
|
||||
Let `ceiling_w` be the quarter average that must not be exceeded and `remaining`
|
||||
the seconds left in the open block. `project()` gives
|
||||
|
||||
projected = (acc + held * remaining) / 900
|
||||
|
||||
so the held level that would land the quarter exactly on the ceiling is
|
||||
`held + 900 * (ceiling - projected) / remaining`, and the watts that must come
|
||||
off the meter are the difference between the two:
|
||||
|
||||
excess_w = 900 * (projected - ceiling) / remaining
|
||||
|
||||
That single expression carries the whole control law, and two properties of it
|
||||
are the argument for this design:
|
||||
|
||||
1. THE CLAIM IS FILED WHILE THE FLOOR IS POSITIVE, NOT WHILE THE EXCESS IS.
|
||||
`excess` is what must come off the METER, so it falls to zero the moment the
|
||||
battery is already taking it - and a guard that disengaged there would hand
|
||||
the actuator back, watch the offtake spring straight to the raw load, and
|
||||
engage again next cycle. That is not a hypothetical: it is what the first
|
||||
cut of this file did to 2026-08-20's billed quarter on replay - seventeen
|
||||
engage/release cycles in 900 s, and the quarter still finished 49 W over the
|
||||
limit it had spent a quarter of an hour holding. 10.2 names the cost
|
||||
exactly: "an EMS that oscillates is worse than no EMS, both for hardware
|
||||
wear and - per 9.1 - for the peak it is trying to avoid."
|
||||
|
||||
`batt_w + excess` IS the quantity to threshold: the offtake there would be
|
||||
with the battery at zero, less the level this quarter can still afford. Read
|
||||
as a LEVEL rather than as a switch, the same arithmetic gives a claim that
|
||||
is filed for as long as a positive floor is needed and decays continuously
|
||||
to nothing as the load falls. So there is still no hysteresis constant in
|
||||
this file - not because oscillation was ruled out by argument, but because
|
||||
the thresholded quantity is the one that does not oscillate.
|
||||
|
||||
2. THE GAIN IS 900/remaining, WHICH IS 1.0 AT THE START OF THE QUARTER AND
|
||||
GROWS. This is the answer to the degeneracy at the top of a block, where the
|
||||
projection is an extrapolation from almost no evidence: the guard is at its
|
||||
GENTLEST exactly when its evidence is weakest, and becomes aggressive only
|
||||
once most of the quarter is behind it and the projection is nearly a
|
||||
measurement. The formula supplies that; no minimum-evidence threshold was
|
||||
invented, because inventing one would delay the guard in the case it exists
|
||||
for - a sustained rail from second zero.
|
||||
|
||||
The floor itself is then
|
||||
|
||||
floor_w = batt_w + excess_w
|
||||
|
||||
`batt_w` IS THE MEASURED BATTERY POWER, NOT THE STANDING COMMAND. Both were
|
||||
available. The measured one is right for two independent reasons. First, 9.1's
|
||||
settled constraint is that the utility bills what FLOWED - the arithmetic
|
||||
`grid = load - batt` is true of delivered power and false of intent. Second, and
|
||||
concretely: when the inverter cannot follow (the saturation case control.py
|
||||
already freezes the integrator for), a floor built on the command asks for
|
||||
`command + excess` every cycle while the command never arrives, and winds itself
|
||||
to the rail; a floor built on the measurement asks for `delivered + excess`,
|
||||
which stops rising the moment delivery stops rising. The delivered form cannot
|
||||
wind up. The intent form can.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE CEILING IS `peak_cap_w`, RAISED ONLY BY AN OBSERVATION
|
||||
|
||||
11.2's row is "Quarter peak | Projected quarter average approaches THE MONTHLY
|
||||
PEAK | Clamp the command", and 9.1's soft limit is `peak_cap_w`. Both matter,
|
||||
because the money is spent on `max(month's peak so far, this quarter)`:
|
||||
|
||||
ceiling_w = max(peak_cap_w, the month's banked peak)
|
||||
|
||||
Once a month's peak already stands at 5013 W, holding every later quarter to
|
||||
3500 W buys nothing - the bill for that month is already 5013 W - while the
|
||||
holding itself costs real charging the site wanted. Raising the ceiling to a peak
|
||||
that has already been paid for is what stops one bad quarter from parking the
|
||||
battery's charging for three weeks.
|
||||
|
||||
AND THAT RELAXATION IS GRANTED ONLY BY AN `OBSERVED` READING. `NO_RECORD` and
|
||||
`NOT_OBSERVED` both leave the ceiling at `peak_cap_w`, the tighter of the two, so
|
||||
a peak store that cannot be read makes this guard STRICTER and never looser. That
|
||||
direction is the whole point: tariff.py's `PeakReading` exists because a database
|
||||
that did not answer must not become a number, and the number it would have become
|
||||
here - a zero peak - is exactly the one that reads as "no peak recorded, full
|
||||
freedom". It is also why this module does NOT consume `headroom_w()`, whose
|
||||
disarming failure it would otherwise inherit: a peak read as 0 W returns FULL
|
||||
headroom, and full headroom is a guard that is not there.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
BLINDNESS COMES FIRST, AND IT IS A DIFFERENT ACTION
|
||||
|
||||
A projection is evidence only if the quantity under it was actually measured.
|
||||
Three ways it is not, all of them checked BEFORE the projection is graded,
|
||||
because grading a stimulus where the quantity it grades is degenerate is how a
|
||||
guard passes its own tests while being switched off:
|
||||
|
||||
* NO OPEN BLOCK. `project()` answers a fresh averager with
|
||||
`Projection(0.0, 0.0, 0.0, 0.0, False, None, 0.0)` - a ZERO projection, which
|
||||
compares as comfortably under any ceiling. That is not "the quarter is fine",
|
||||
it is "there is no quarter". Graded as a number it silently disarms the guard
|
||||
on exactly the startup path where nothing else is watching.
|
||||
|
||||
* COVERAGE. A meter gap longer than one `max_hold_s` window leaves seconds that
|
||||
`QuarterAverager` deliberately does not accumulate (p1.py: holding a dead
|
||||
meter's last value forward is how a fabricated peak gets onto a permanent
|
||||
record). Those seconds make the projection read LOW, which is the
|
||||
under-guarding direction. The threshold is `max_hold_s` itself - the point
|
||||
p1.py already stops trusting a held sample - not a new number.
|
||||
|
||||
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
|
||||
rather than re-written here. A frozen meter is the worst case of all: the
|
||||
samples keep ARRIVING, so coverage stays perfect and the projection is
|
||||
confident - and if the stuck value is an export, the projection is a
|
||||
confident ZERO while the real meter runs at the rail. This is the case
|
||||
SAFETY-07 exists to backstop, and it is the case a projection cannot see.
|
||||
|
||||
THE BLIND ACTION IS `floor_w = 0.0`: the battery may not IMPORT while the billed
|
||||
quantity is unobservable. It is the minimal action that closes the hazard, and it
|
||||
is one-directional - forbidding charging can only ever lower delivered offtake,
|
||||
never raise it - so a false blindness costs a charge the site wanted and cannot
|
||||
cost energy the pack did not want to give. Contrast the graded branch, which can
|
||||
force discharge; blindness deliberately does not earn that, because there is by
|
||||
definition no measurement to size it from.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHY THIS ONE TRIPS WHERE SAFETY-18 ONLY NOTIFIES
|
||||
|
||||
SAFETY-18 shipped notification-first, and config.yaml says why in one line: "A
|
||||
false trip parks the battery; a false alarm costs an alert." That reasoning is
|
||||
right for a DETECTOR whose negative case had to be established on a rig run. It
|
||||
does not carry to this guard, and the two committed captures say so:
|
||||
|
||||
* MEASURED FALSE-ENGAGEMENT RATE ON THE HEALTHY DAY: zero. 2026-08-23's worst
|
||||
quarter is a few hundred watts against a 3500 W ceiling and nothing in its 95
|
||||
quarters comes near engaging. test_peakguard.py re-measures that every run
|
||||
from the committed capture rather than quoting it here.
|
||||
* THE ACTION IS LEVEL-TRIGGERED AND UNLATCHED. It is re-derived from a fresh
|
||||
projection every cycle and releases the instant the projection drops back
|
||||
under the ceiling. There is no state to clear and no operator to wake. A
|
||||
false engagement costs one cycle of discharge, not a parked battery - which
|
||||
is precisely the cost SAFETY-18 could not bound and this one can.
|
||||
* THE ACTION IS A LIMIT, NOT A SET. Every safety stop in 11.2's table outranks
|
||||
it, so the worst thing a bug in this file can do is fail to relax a floor -
|
||||
and a floor is bounded above by `achievable_w`, which main.py takes from the
|
||||
same `max_w` and device rating the arbiter already enforces.
|
||||
|
||||
So it trips. It is also the only guard here whose trigger IS the billed quantity
|
||||
itself, measured from parsed P1 telegrams, rather than a proxy for it.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
SATURATION IS REPORTED, NOT ALARMED AS A BUG
|
||||
|
||||
10.3's doctrine - "treat an out-of-range computed setpoint as a bug signal, clamp
|
||||
it AND alarm" - is SAFETY-03's, and it is right there: a loop computing 14 768 W
|
||||
is a winding integrator, i.e. a defect. A peak floor above the inverter's rating
|
||||
is NOT a defect. It is the ordinary physics of a 5900 W house load on a 5000 W
|
||||
inverter, which is literally what 2026-08-20 15:45 was. So the floor is clamped
|
||||
to `achievable_w` and the decision carries `saturated`, and main.py raises ONE
|
||||
notification on that - not because the controller is broken, but because it is
|
||||
the one case where the money WILL be spent and the controller has already done
|
||||
everything it can. An engagement that is not saturated is an ordinary control
|
||||
action and goes to the action log, like the clamp's engagements do.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
NO NEW OPTION. CFG-03 STILL TRACKS THREE PLACES.
|
||||
|
||||
`peak_cap_w` already ships, is already validated `int(500,15000)`, and is already
|
||||
read by `control.peak_at_risk()`. `max_hold_s` comes off the averager the caller
|
||||
already owns; `achievable_w` off `max_w` and the device rating the arbiter
|
||||
already enforces; the freeze predicate off `meter_freeze_s`. config.yaml and
|
||||
validate.py are untouched by this ticket, exactly as TARIFF-01 left them, and
|
||||
14's `capacity_peak_limit_kw` stays declined - a kW key beside a W key is the
|
||||
mismatch trap 14's own warning box describes.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
⚠️ WHAT THIS GUARD DOES NOT HAVE: AN ENERGY BACKSTOP. SEE SAFETY-24.
|
||||
|
||||
The graded branch can FORCE DISCHARGE, and nothing in this file or in the loop
|
||||
below it consults the state of charge - `soc_floor` belongs to the maintenance
|
||||
cycle, and freeze.py already records that for the grid-following loop "the BMS
|
||||
is the sole energy backstop". On the 2026-08-20 replay the guard asks for 878 Wh
|
||||
in a day. Against a pack sitting near 9 % that is not nothing, and the guard will
|
||||
keep asking as long as the meter says the quarter is over the ceiling.
|
||||
|
||||
That is deliberate for THIS card and it is filed as SAFETY-24. It is also the
|
||||
reason this must not go to a client install before SAFETY-24 lands: on the
|
||||
reference site the BMS is a known quantity and an operator is present, and on a
|
||||
client site neither is true.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
|
||||
|
||||
The sum/max/net reduction is an open product question with the owner. TARIFF-01
|
||||
put it behind `billed_w()` and defaulted to NET. Everything this module consumes
|
||||
- `PeakReading.peak_w`, `Projection.projected_w` - is already reduced by that one
|
||||
function, so whichever way the owner answers, this file does not change and does
|
||||
not need re-testing. Nothing here indexes a phase.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .arbiter import P_PEAK, Claim
|
||||
from .control import peak_at_risk
|
||||
from .tariff import QUARTER_S
|
||||
|
||||
# Why the projection may not be graded. Reasons, not booleans: the status page
|
||||
# and the action log both have to say WHICH, and "blind" alone is unactionable.
|
||||
BLIND_NO_BLOCK = "no open quarter - the meter has produced no sample yet"
|
||||
BLIND_COVERAGE = "meter gap"
|
||||
BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
|
||||
|
||||
# Where the ceiling came from. Only the second is a relaxation, and only an
|
||||
# OBSERVED reading may produce it.
|
||||
CEILING_CAP = "peak_cap_w"
|
||||
CEILING_MONTH_PEAK = "the month's banked peak"
|
||||
|
||||
SOURCE = "peak"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PeakDecision:
|
||||
"""What the guard concluded this cycle, with everything it concluded it from.
|
||||
|
||||
Every field is derived from a measurement taken in the same cycle. There is
|
||||
no `floor_w_or(default)` and there must not be one, for `PeakReading`'s
|
||||
reason: an accessor that turns "not measured" into a number re-creates the
|
||||
defect the type exists to remove.
|
||||
"""
|
||||
|
||||
engaged: bool
|
||||
floor_w: float | None # the `lo` the claim carries; None = no claim
|
||||
ceiling_w: float
|
||||
ceiling_from: str
|
||||
blind: str | None = None
|
||||
projected_w: float | None = None
|
||||
excess_w: float = 0.0
|
||||
wanted_w: float | None = None # the floor before `achievable_w` clamped it
|
||||
saturated: bool = False
|
||||
reason: str = ""
|
||||
|
||||
def explain(self) -> str:
|
||||
if not self.engaged:
|
||||
if self.projected_w is None:
|
||||
return f"peak guard idle - {self.reason}"
|
||||
return (f"peak guard idle - quarter projects "
|
||||
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W "
|
||||
f"ceiling ({self.ceiling_from})")
|
||||
return self.reason
|
||||
|
||||
|
||||
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
|
||||
"""The average this quarter may not exceed, and where the number came from.
|
||||
|
||||
ONLY an OBSERVED reading may raise it. `NO_RECORD` (the store answered, this
|
||||
month has nothing yet) and `NOT_OBSERVED` (the store did not answer) both
|
||||
return the cap, which is the tighter answer. A failed read therefore makes
|
||||
the guard stricter, never looser - see the module note.
|
||||
"""
|
||||
cap = float(cap_w)
|
||||
if (reading is not None and getattr(reading, "is_observed", False)
|
||||
and reading.peak_w is not None and float(reading.peak_w) > cap):
|
||||
return float(reading.peak_w), CEILING_MONTH_PEAK
|
||||
return cap, CEILING_CAP
|
||||
|
||||
|
||||
def excess_w(projection, ceiling_w) -> float:
|
||||
"""Watts that must come off the meter NOW to land the quarter on the ceiling.
|
||||
|
||||
The whole control law, and the only place it is written down:
|
||||
|
||||
excess = 900 * (projected - ceiling) / remaining
|
||||
|
||||
Positive means the quarter is projected over; zero or negative means it is
|
||||
not. `remaining <= 0` is not a large number, it is an undefined one - a
|
||||
quarter with no seconds left cannot be influenced - so it answers 0.0 rather
|
||||
than dividing.
|
||||
"""
|
||||
remaining = QUARTER_S - float(projection.elapsed_s)
|
||||
if remaining <= 0.0:
|
||||
return 0.0
|
||||
return QUARTER_S * (float(projection.projected_w) - float(ceiling_w)) / remaining
|
||||
|
||||
|
||||
def uncovered_s(projection) -> float:
|
||||
"""Seconds of the open block the meter did not actually cover.
|
||||
|
||||
Derived from what `Projection` already carries rather than re-measured:
|
||||
`covered_frac` is `elapsed / elapsed_wall`, and `elapsed_s` IS that wall.
|
||||
"""
|
||||
return max(0.0, float(projection.elapsed_s)
|
||||
* (1.0 - float(projection.covered_frac)))
|
||||
|
||||
|
||||
def blindness(projection, max_hold_s, frozen: bool) -> str | None:
|
||||
"""Why this projection may not be graded, or None if it may be.
|
||||
|
||||
Evaluated BEFORE the projection is compared to anything. Each of the three is
|
||||
a way for the projected number to be confident and wrong, and the first - a
|
||||
fresh averager's all-zero `Projection` - is confident, wrong AND under every
|
||||
ceiling there is.
|
||||
"""
|
||||
if frozen:
|
||||
return BLIND_FROZEN
|
||||
if projection.block_start is None:
|
||||
return BLIND_NO_BLOCK
|
||||
gap = uncovered_s(projection)
|
||||
if max_hold_s is not None and gap > float(max_hold_s):
|
||||
return (f"{BLIND_COVERAGE}: {gap:.0f} s of this quarter uncovered, past "
|
||||
f"the {float(max_hold_s):.0f} s the averager will hold a sample")
|
||||
return None
|
||||
|
||||
|
||||
def decide(projection, *, batt_w, cap_w, reading, achievable_w,
|
||||
max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
||||
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
|
||||
|
||||
`batt_w` MEASURED battery power, positive = discharge. None when the
|
||||
sensor for it is missing - and then there is nothing to size a
|
||||
floor from, so no floor is invented.
|
||||
`achievable_w` the largest discharge the arbiter could actually grant
|
||||
(`min(max_w, device max)`). The floor is clamped to it so this
|
||||
claim can NEVER produce `lo > hi` in the resolver - a
|
||||
contradiction there resolves to 0 W, which would park the
|
||||
battery in the exact minute it should be discharging hardest.
|
||||
"""
|
||||
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
|
||||
ceil_top = abs(float(achievable_w))
|
||||
|
||||
blind = blindness(projection, max_hold_s, frozen)
|
||||
if blind is not None:
|
||||
# 0.0, not a sized floor. There is no trustworthy measurement to size one
|
||||
# from; the claim that remains is "do not add to the offtake you cannot
|
||||
# see", which is one-directional and cannot cost pack energy.
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
|
||||
blind=blind, projected_w=None,
|
||||
reason=(f"PEAK GUARD BLIND ({blind}) - the battery may not import "
|
||||
f"while the billed quarter cannot be measured; commanding no "
|
||||
f"charging (floor 0 W)"))
|
||||
|
||||
projected = float(projection.projected_w)
|
||||
if batt_w is None:
|
||||
# main.py's P_SAFETY stop already owns a cycle with a missing input, at
|
||||
# 0 W. Filing nothing is the honest answer: a floor sized from a
|
||||
# measurement that is not there is a made-up number.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected,
|
||||
reason="no battery measurement to size a floor from")
|
||||
if QUARTER_S - float(projection.elapsed_s) <= 0.0:
|
||||
# A block with no seconds left cannot be influenced. This is degenerate
|
||||
# rather than urgent - `excess_w` answers 0.0 there and a floor equal to
|
||||
# the battery's own current output would be a claim that says nothing.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected,
|
||||
reason="the quarter has no seconds left to influence")
|
||||
|
||||
ex = excess_w(projection, ceiling)
|
||||
wanted = float(batt_w) + ex
|
||||
if wanted <= 0.0:
|
||||
# ⚠️ THE TEST IS ON THE FLOOR, NOT ON `excess > 0`, AND THE REPLAY IS WHY.
|
||||
# `excess` is the watts that must come off the METER, so it goes to zero
|
||||
# as soon as the battery is already removing them - and a guard that
|
||||
# disengaged there handed the actuator straight back, the offtake sprang
|
||||
# back to the raw load, and the next cycle engaged again. On 2026-08-20's
|
||||
# billed quarter that bang-bang cycled 17 times in 900 s and left the
|
||||
# quarter 49 W over the limit it had just spent a quarter of an hour
|
||||
# holding. §10.2: "an EMS that oscillates is worse than no EMS, both for
|
||||
# hardware wear and - per §9.1 - for the peak it is trying to avoid."
|
||||
#
|
||||
# `batt_w + excess` is the offtake that would exist with the battery at
|
||||
# zero, minus the level the quarter can afford. Testing THAT is the same
|
||||
# arithmetic read as a level rather than as a switch: the claim is filed
|
||||
# for as long as a positive floor is needed and decays continuously to
|
||||
# nothing as the load falls, so there is still no hysteresis constant -
|
||||
# the quantity being thresholded is simply the right one.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected, excess_w=ex,
|
||||
wanted_w=wanted,
|
||||
reason="the load is under the level this quarter can afford")
|
||||
|
||||
floor = min(wanted, ceil_top)
|
||||
saturated = floor < wanted
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
|
||||
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
|
||||
saturated=saturated,
|
||||
reason=(
|
||||
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
|
||||
f"{ceiling:.0f} W ceiling ({from_where}) with "
|
||||
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
|
||||
f"come off the meter; battery measured {float(batt_w):+.0f} W, "
|
||||
f"floor {floor:+.0f} W"
|
||||
+ (f" - SATURATED, {wanted:+.0f} W was needed and the inverter can "
|
||||
f"only give {ceil_top:.0f} W" if saturated else "")))
|
||||
|
||||
|
||||
def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
|
||||
"""Must the maintenance charge-only shaping stand down this cycle?
|
||||
|
||||
THE ONE PLACE THIS CONJUNCTION IS WRITTEN, and it is public for the reason
|
||||
`watchdog.loop_is_following()` and `freeze.looks_frozen()` are: the check
|
||||
that grades it has to 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 grading one.
|
||||
It was inline in `main.py` and nothing could reach it; a reviewer mutated it
|
||||
back to its historical broken form and all twelve suites stayed green.
|
||||
|
||||
⚠️ WHY SUPPRESSION AND NOT PRIORITY. The shaping is a LIMIT (`hi <= 0`) and
|
||||
the peak guard's claim is a LIMIT (`lo >= 0`). arbiter.py clause 2 applies
|
||||
EVERY limit at or above the winner's priority and takes the MOST RESTRICTIVE,
|
||||
with no notion of one limit outranking another - which is precisely what
|
||||
makes the safety bounds unconditional and must not be changed. Two crossing
|
||||
limits are therefore a CONTRADICTION, which resolves to 0 W: the battery
|
||||
PARKED, in the one quarter of the month that sets the bill. P_PEAK 50 >
|
||||
P_MAINT_SHAPE 40 does not save it and never could.
|
||||
|
||||
⚠️ AND THE FIRST TERM IS NOT ENOUGH ON ITS OWN. `peak_forecast_entity` is
|
||||
OPTIONAL and is empty on a default install, so `peak_forecast_w` is None
|
||||
there and `peak_at_risk()` answers False forever. Before this card the
|
||||
suspension therefore could not fire on the very site that needed it. The
|
||||
second term is the MEASURED guard, which is always present wherever P1 is.
|
||||
Deleting either term - or re-gating the whole thing on the forecast - parks
|
||||
a default install at 0 W, silently, in the billed quarter. test_peakguard.py
|
||||
part 7 drives the real cycle with each of those mutations in place.
|
||||
"""
|
||||
if peak_at_risk(peak_forecast_w, float(cap_w)):
|
||||
return True
|
||||
return decision is not None and decision.engaged
|
||||
|
||||
|
||||
def claim(decision: PeakDecision) -> Claim | None:
|
||||
"""The decision as one arbiter claim, or None when the guard is idle.
|
||||
|
||||
A LIMIT with only `lo` set. `hi` stays `+inf`, which is 9.1's "discharge and
|
||||
export are never capped by the peak guard" expressed as a fact about the
|
||||
object rather than as a rule somebody has to remember.
|
||||
"""
|
||||
if not decision.engaged or decision.floor_w is None:
|
||||
return None
|
||||
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
|
||||
reason=("blind" if decision.blind else "quarter peak"))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,924 +0,0 @@
|
||||
"""TARIFF-03 - static day/night and fully-fixed tariffs, first-class.
|
||||
|
||||
WHERE THIS LIVES, AND WHY IT IS NOT WHERE THE SPEC SAYS. specs.md §2.2 puts the
|
||||
price machinery at `ems/tariff/...`. That module layout does not exist and is
|
||||
not being built; the product owner's decision of 2026-08-30 is that everything
|
||||
ships in `goodwe_controller/app/`, next to price.py, arbiter.py and validate.py.
|
||||
The BEHAVIOUR §9.2 / §9.3 / §11.4 / §10.1 describes is unchanged and still
|
||||
authoritative - only the path moved.
|
||||
|
||||
WHY THIS TICKET EXISTS, in §9.2's own words: "a large share of the installed
|
||||
base is on [a static profile], and an EMS that only works under dynamic pricing
|
||||
is an EMS for a minority." The §9.2 product table maps four products onto four
|
||||
EMS behaviours, and TARIFF-02 shipped the machinery for the top two only:
|
||||
|
||||
Dynamic day-ahead hourly EPEX BE + markup full arbitrage
|
||||
Real-time/imbalance e.g. Eneco Eddy Flex arbitrage, wider thresholds
|
||||
Static day/night two-period tariff TIME-WINDOW SHIFTING <- here
|
||||
Fully fixed single price SELF-CONSUMPTION ONLY <- here
|
||||
|
||||
|
||||
THE PLACEMENT DECISION, AND THE TWO REASONS IT IS NOT A PROVIDER PLUGIN
|
||||
=======================================================================
|
||||
The obvious move is a sixth `@register`ed `PriceProvider` alongside
|
||||
`generic_dayahead` and `manual_csv`. It was considered and rejected, on two
|
||||
grounds that are mechanical rather than aesthetic:
|
||||
|
||||
1. A PROVIDER'S PRODUCT IS FIXED AT CLASS-DEFINITION TIME AND THIS ONE IS NOT.
|
||||
`PriceProvider.capabilities()` is a `classmethod` taking no options, and
|
||||
`load_prices()` reads `caps.product` to stamp the series. But §9.2 makes
|
||||
STATIC and FIXED two DIFFERENT products with two different required EMS
|
||||
behaviours, and which of the two a site is on is a property of the
|
||||
OPERATOR'S DATA - one period or several - not of any class. A plugin would
|
||||
have to declare one of them and be wrong for half its users, or the
|
||||
framework would have to grow a per-call product override, which is a
|
||||
change to TARIFF-02's contract for the benefit of one caller.
|
||||
`StaticProfile.product` derives it from the periods instead (see below).
|
||||
|
||||
2. THE PLUGIN FRAMEWORK EXISTS TO SOLVE A PROBLEM THIS PATH DOES NOT HAVE.
|
||||
`capabilities().sources` / `assemble()` exist because a FETCHED figure may
|
||||
or may not already contain the supplier's markup, and adding it twice is
|
||||
invisible (price.py, trap 1). A static profile's numbers are typed by the
|
||||
operator as all-in; there is no publisher to disagree with, so the whole
|
||||
`RawPoint` -> `assemble()` -> `PricePoint` pipeline would be an identity
|
||||
function wrapped in a declaration nobody can check.
|
||||
|
||||
⚠️ WHAT IS *NOT* A REASON: "it is offline". `manual_csv` is offline and is a
|
||||
plugin, correctly. Offline-ness is not what separates these two.
|
||||
|
||||
So the static profile sits UNDER the provider framework, as the layer §11.4
|
||||
already says it is:
|
||||
|
||||
§11.4 No price data -> fall back to the static profile, mark the plan
|
||||
provisional, no arbitrage.
|
||||
|
||||
which makes the §9.2 product option and the §11.4 degradation row THE SAME CODE
|
||||
PATH, differing in exactly one flag. A fully-static site is one that leaves
|
||||
`price_provider: off` (already the shipped default) and fills in
|
||||
`static_profile`; a dynamic site whose feed died falls into the same generator
|
||||
with `provisional=True`. One generator, two call sites, one flag - so the
|
||||
fallback cannot rot, because it is the path a whole class of installs runs on
|
||||
every day.
|
||||
|
||||
It emits `PricePoint`s in TARIFF-02's canonical `{start, eur_per_kwh,
|
||||
provisional}` shape (§9.3, "so the optimiser compares like with like"), so
|
||||
every downstream consumer works unchanged and none of them learns that a
|
||||
tariff can be static.
|
||||
|
||||
|
||||
HOW "NO ARBITRAGE" IS EXPRESSED SO A FUTURE OPTIMISER CANNOT IGNORE IT
|
||||
======================================================================
|
||||
A boolean called `arbitrage_ok` that OPT-04 is asked to remember to check is not
|
||||
a mechanism; it is a comment with a type. So the numbers an arbitrage strategy
|
||||
NEEDS - the spread, and which hours are the cheap and dear ends of it - EXIST
|
||||
ONLY ON THE PERMITTING VARIANT of `Arbitrage`. `refused` carries `.reason` and
|
||||
nothing else, and reaches for anything else raises `Absence`, the same shape
|
||||
price.py borrowed from `sim/riglock.py`. An optimiser cannot compute a trade
|
||||
from a refusal, because it cannot get the inputs out of one.
|
||||
|
||||
And the verdict is only obtainable from `TariffPlan.arbitrage(...)`, which takes
|
||||
`peak_guard_active` as a REQUIRED KEYWORD ARGUMENT. §9.1 and §10.1 put peak
|
||||
shaving (priority 2) above price arbitrage (priority 3) - the capacity tariff is
|
||||
a network cost charged whatever the energy product is - and the way that
|
||||
ordering is made to survive OPT-04 landing is that OPT-04 cannot obtain a spread
|
||||
without first stating whether the peak guard is holding. It is not free to
|
||||
forget the question; the call does not compile without an answer.
|
||||
|
||||
⚠️ FOUR REFUSALS, IN THIS ORDER, EACH DERIVED FROM SOMETHING MEASURED:
|
||||
|
||||
1. the peak guard is active §10.1: priority 2 outranks priority 3
|
||||
2. no price data at all §11.4, and no static_profile to fall to
|
||||
3. the plan is provisional §11.4 fallback / §8.3 unknown hours
|
||||
4. the series is FLAT max - min == 0, so there is nothing to
|
||||
arbitrage. THIS is AC2: a fully-fixed
|
||||
tariff disables arbitrage by ARITHMETIC,
|
||||
not by a special case keyed on
|
||||
`PriceProduct.FIXED`. A dynamic feed
|
||||
that happens to print one price all day
|
||||
gets refused for the same true reason.
|
||||
|
||||
Nothing here decides a trade. The spread is handed over; OPT-04 owns
|
||||
`cycle_cost_threshold_eur` (§10.1/§14) and what to do about it. ⚠️ Worth knowing
|
||||
before that lands: on the Belgian day/night numbers in DOCS.md the whole-tariff
|
||||
spread is 0.08 EUR/kWh, which is BELOW the shipped 0.10 threshold - so a
|
||||
correct OPT-04 will decline to cycle for the tariff alone on such a site. That
|
||||
is a product finding, not a bug here.
|
||||
|
||||
WHAT STAYS ON WHEN ARBITRAGE IS OFF (AC2, and §10.1 in full): priorities 1
|
||||
(self-consumption), 2 (peak shaving), 4 (reserve management) and 5 (load
|
||||
shifting) are untouched by any refusal above. Only priority 3 goes away. In
|
||||
0.5.x that is literally true because none of them reads a price: the grid
|
||||
following loop, the capacity-tariff peak tracker and the freeze/sign watchdogs
|
||||
all run off P1 and know nothing about this module.
|
||||
|
||||
|
||||
TIME-WINDOW SHIFTING, AND THE ONE SHIFTABLE LOAD THAT EXISTS TODAY
|
||||
==================================================================
|
||||
§9.2's handling for a two-period tariff is "time-window shifting", and §10.1
|
||||
priority 5 is load shifting. The add-on has exactly one shiftable load in
|
||||
0.5.x: the maintenance cycle's cheap-window grid top-up. `static_status()` is
|
||||
what main.py reads for it, and `cheap_now()` is the rule:
|
||||
|
||||
cheap <=> the price now is STRICTLY BELOW the mean over the horizon
|
||||
|
||||
⚠️ STRICTLY BELOW, AND THE STRICTNESS IS AC2. Under a fully-fixed tariff every
|
||||
hour equals the mean, so `<=` would make EVERY hour a cheap window and a fixed
|
||||
tariff site would top the battery up from the grid at any hour of the day -
|
||||
which is the exact opposite of "self-consumption only". With `<`, a flat series
|
||||
has no cheap window at all, derived rather than special-cased. (The Home
|
||||
Assistant `price_now_entity`/`price_avg_entity` pair main.py has always used
|
||||
computes `now <= avg`; the difference is deliberate and is why this is not
|
||||
simply the same expression.)
|
||||
|
||||
⚠️ THE MEAN IS TAKEN OVER SEVEN LOCAL DAYS, not over the 48 h forecast horizon,
|
||||
and that is not tidiness. Belgian night tariffs usually cover the whole weekend,
|
||||
so a 48 h mean computed on a Friday afternoon is weighted about 2/3 weekend and
|
||||
the SAME Tuesday 09:00 comes out cheap or dear depending on the day you asked.
|
||||
Seven days contains each weekday exactly once, so the mean is a property of the
|
||||
tariff instead of a property of the hour you called at.
|
||||
|
||||
|
||||
DAYLIGHT SAVING, AND WHY THERE IS NO `zoneinfo` HERE EITHER
|
||||
============================================================
|
||||
The shipped Alpine image has no `tzdata` (price.py's docstring, and the
|
||||
Dockerfile), so `ZoneInfo("Europe/Brussels")` raises there. price.py already
|
||||
carries `brussels()` / `brussels_offset()` - the EU rule, in arithmetic, checked
|
||||
in review of PR #16 against `zoneinfo` over 149,040 hourly instants with zero
|
||||
mismatches - and this module uses it rather than growing a second answer to the
|
||||
same question.
|
||||
|
||||
Generation iterates HOURLY IN UTC and renders each instant to local, so a
|
||||
spring-forward day yields 23 points and an autumn-back day 25, BY CONSTRUCTION.
|
||||
An implementation that walked 24 local hours would emit 24 on both and misprice
|
||||
an hour twice a year; `test_static_tariff.py` drives both 2026 transitions and
|
||||
counts, rather than asserting 24.
|
||||
|
||||
⚠️ A WINDOW BELONGS TO THE DAY THE CLOCK READS, NOT THE DAY IT STARTED. A
|
||||
`22:00-07:00 mon-fri` night window covers Friday 23:00 (Friday) but NOT
|
||||
Saturday 03:00 - Saturday 03:00 is Saturday's, and Saturday needs its own
|
||||
period. That is the only rule under which the tiling check below can be
|
||||
decidable at all, and it is why the shipped Belgian example carries an
|
||||
explicit weekend row.
|
||||
|
||||
|
||||
VALIDATION: NEVER INVENT A PRICE FOR AN UNCOVERED HOUR
|
||||
======================================================
|
||||
For every day type the periods must tile the 1440-minute clock exactly once -
|
||||
no gap, no overlap - and `parse_static_profile()` refuses anything else with the
|
||||
uncovered or doubled range named in local time. `validate.py` calls it at
|
||||
startup, so a malformed profile is a refusal to start, not a surprise at 02:00.
|
||||
|
||||
The alternative - defaulting an uncovered hour to some price - is the defect
|
||||
price.py's whole `_NoData` type exists to prevent, one level down: an invented
|
||||
number that arbitrage then acts on. A missing profile is not a zero price, and
|
||||
a zero price is exactly the value that makes everything look free.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .price import (
|
||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
_Outcome, brussels, load_prices, price_option,
|
||||
)
|
||||
|
||||
_LOG = logging.getLogger("goodwe.tariff.static")
|
||||
|
||||
DAY_NAMES = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
||||
_DAY_INDEX = {n: i for i, n in enumerate(DAY_NAMES)} # Mon=0, as weekday()
|
||||
_DAY_ALIASES = {
|
||||
"weekday": "mon-fri", "weekdays": "mon-fri", "week": "mon-fri",
|
||||
"weekend": "sat-sun", "weekends": "sat-sun",
|
||||
"all": "mon-sun", "daily": "mon-sun", "every": "mon-sun", "*": "mon-sun",
|
||||
}
|
||||
|
||||
MINUTES_PER_DAY = 1440
|
||||
|
||||
# The mean that `cheap_now()` grades against is taken over this many local days.
|
||||
# Seven, so each weekday appears exactly once - see the module docstring. It is
|
||||
# a MODULE CONSTANT and not an option on purpose: CFG-03 already tracks three
|
||||
# places option defaults live, and this describes the Belgian week rather than
|
||||
# the site.
|
||||
MEAN_WINDOW_DAYS = 7
|
||||
|
||||
# The only timezone a `static_profile` may name. See the docstring: there is no
|
||||
# tz database in the shipped image, so anything else cannot be honoured and is
|
||||
# refused rather than silently treated as Brussels.
|
||||
SUPPORTED_TZ = ("europe/brussels", "cet", "cest", "")
|
||||
|
||||
|
||||
class ProfileInvalid(Exception):
|
||||
"""A `static_profile` that cannot be turned into a tariff.
|
||||
|
||||
Refused at startup by validate.py. Carries a message naming the key and the
|
||||
fix, because the operator who typed it is the only one who can repair it.
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _minutes(value, field: str) -> int:
|
||||
""""HH:MM" -> minutes past local midnight. `to: "24:00"` means end-of-day."""
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
raise ProfileInvalid(f"{field} is empty; write it as \"HH:MM\", e.g. "
|
||||
f"\"07:00\"")
|
||||
parts = text.split(":")
|
||||
if len(parts) != 2:
|
||||
raise ProfileInvalid(
|
||||
f"{field} is {value!r}; it must be \"HH:MM\" local Belgian time, "
|
||||
f"e.g. \"22:00\". A bare hour or a decimal is refused rather than "
|
||||
f"guessed at - 7.5 could be 07:30 or 07:50.")
|
||||
try:
|
||||
h, m = int(parts[0]), int(parts[1])
|
||||
except ValueError:
|
||||
raise ProfileInvalid(f"{field} is {value!r}; \"HH:MM\" needs two "
|
||||
f"integers, e.g. \"07:00\"")
|
||||
if not (0 <= m < 60) or not (0 <= h <= 24) or (h == 24 and m != 0):
|
||||
raise ProfileInvalid(
|
||||
f"{field} is {value!r}, which is not a time of day. Hours run 0-23, "
|
||||
f"minutes 0-59; \"24:00\" is accepted only as an END meaning "
|
||||
f"midnight at the end of the day.")
|
||||
return h * 60 + m
|
||||
|
||||
|
||||
def _days(value, field: str) -> frozenset:
|
||||
"""A day selector -> a frozenset of weekday() indices. Mon=0 .. Sun=6.
|
||||
|
||||
Accepts a list (`["mon", "tue"]`), a comma list (`"sat,sun"`), a range
|
||||
(`"mon-fri"`), the aliases in `_DAY_ALIASES`, and any mixture.
|
||||
"""
|
||||
if value is None:
|
||||
raise ProfileInvalid(
|
||||
f"{field} is missing. Name the days this period applies to - "
|
||||
f"\"mon-fri\", \"sat,sun\" or \"all\". It is required rather than "
|
||||
f"defaulted to every day, because a Belgian night tariff that "
|
||||
f"silently covered the weekend too would misprice 48 h a week with "
|
||||
f"nothing reporting it.")
|
||||
tokens = []
|
||||
if isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
tokens.extend(str(item).strip().lower().split(","))
|
||||
else:
|
||||
tokens.extend(str(value).strip().lower().split(","))
|
||||
|
||||
out = set()
|
||||
for token in tokens:
|
||||
token = _DAY_ALIASES.get(token.strip(), token.strip())
|
||||
if not token:
|
||||
continue
|
||||
if "-" in token:
|
||||
a, _, b = token.partition("-")
|
||||
a, b = a.strip(), b.strip()
|
||||
if a not in _DAY_INDEX or b not in _DAY_INDEX:
|
||||
raise ProfileInvalid(
|
||||
f"{field}: {token!r} is not a day range. Days are "
|
||||
f"{', '.join(DAY_NAMES)}; a range is written \"mon-fri\".")
|
||||
i, j = _DAY_INDEX[a], _DAY_INDEX[b]
|
||||
# A range may wrap (`fri-mon`), because a Belgian weekend tariff is
|
||||
# often written that way.
|
||||
out.update((i + k) % 7 for k in range((j - i) % 7 + 1))
|
||||
else:
|
||||
if token not in _DAY_INDEX:
|
||||
raise ProfileInvalid(
|
||||
f"{field}: {token!r} is not a day. Use "
|
||||
f"{', '.join(DAY_NAMES)}, a range like \"mon-fri\", or one "
|
||||
f"of {', '.join(sorted(_DAY_ALIASES))}.")
|
||||
out.add(_DAY_INDEX[token])
|
||||
if not out:
|
||||
raise ProfileInvalid(f"{field} selected no days at all")
|
||||
return frozenset(out)
|
||||
|
||||
|
||||
def _price(value, field: str) -> float:
|
||||
"""The all-in EUR/kWh for a period, bounded exactly as price.py bounds a feed."""
|
||||
if value is None:
|
||||
raise ProfileInvalid(
|
||||
f"{field} is missing. Every period needs an all-in eur_per_kwh "
|
||||
f"(energy + supplier markup + taxes, §9.3) - the same basis the "
|
||||
f"dynamic providers are assembled onto, so the two are comparable.")
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
||||
raise ProfileInvalid(f"{field} is {value!r}, which is not a number")
|
||||
try:
|
||||
out = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ProfileInvalid(f"{field} is {value!r}, which is not a number")
|
||||
if not math.isfinite(out):
|
||||
raise ProfileInvalid(f"{field} is {value!r}, which is not finite")
|
||||
if abs(out) > MAX_PLAUSIBLE_EUR_KWH:
|
||||
raise ProfileInvalid(
|
||||
f"{field} is {out} EUR/kWh, beyond +-{MAX_PLAUSIBLE_EUR_KWH}. The "
|
||||
f"usual cause is a figure in cents or in EUR/MWh - 34 instead of "
|
||||
f"0.34 - which would make every hour look like an emergency. Same "
|
||||
f"bound the fetched feeds are held to (price.py `_validate_raw`).")
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Period:
|
||||
"""One priced window of the local week. Half-open [start, end) on the clock.
|
||||
|
||||
`end <= start` means the window wraps past local midnight (22:00-07:00), and
|
||||
`days` selects the day THE CLOCK READS, not the day the window opened - see
|
||||
the module docstring.
|
||||
"""
|
||||
|
||||
name: str
|
||||
start_min: int
|
||||
end_min: int
|
||||
days: frozenset
|
||||
eur_per_kwh: float
|
||||
|
||||
@property
|
||||
def wraps(self) -> bool:
|
||||
return self.end_min <= self.start_min
|
||||
|
||||
def covers(self, weekday: int, minute: int) -> bool:
|
||||
if weekday not in self.days:
|
||||
return False
|
||||
if not self.wraps:
|
||||
return self.start_min <= minute < self.end_min
|
||||
return minute >= self.start_min or minute < self.end_min
|
||||
|
||||
@property
|
||||
def words(self) -> str:
|
||||
def hhmm(m):
|
||||
return f"{m // 60:02d}:{m % 60:02d}"
|
||||
days = ",".join(DAY_NAMES[d] for d in sorted(self.days))
|
||||
return (f"{self.name} {hhmm(self.start_min)}-"
|
||||
f"{hhmm(self.end_min if self.end_min else MINUTES_PER_DAY)} "
|
||||
f"{days} @ {self.eur_per_kwh:.4f}")
|
||||
|
||||
|
||||
class StaticProfile:
|
||||
"""A validated two-or-more-period tariff. Immutable, pure, I/O-free."""
|
||||
|
||||
def __init__(self, periods):
|
||||
self.periods = tuple(periods)
|
||||
if not self.periods:
|
||||
raise ProfileInvalid(
|
||||
"static_profile has no periods. A profile with no periods is "
|
||||
"not a fixed tariff, it is an absent one - leave the option "
|
||||
"empty to say that on purpose (§11.4 then applies).")
|
||||
_check_tiling(self.periods)
|
||||
|
||||
# -- what kind of tariff this is, DERIVED --------------------------------
|
||||
@property
|
||||
def prices(self) -> tuple:
|
||||
return tuple(p.eur_per_kwh for p in self.periods)
|
||||
|
||||
@property
|
||||
def product(self) -> PriceProduct:
|
||||
"""§9.2's product, derived from the DATA rather than declared.
|
||||
|
||||
One distinct price across every period is a fully-fixed tariff whatever
|
||||
the operator called the rows; two or more is a static profile. Nobody
|
||||
has to tick a "this is fixed" box and nobody can tick it wrongly.
|
||||
"""
|
||||
return (PriceProduct.FIXED if len(set(self.prices)) == 1
|
||||
else PriceProduct.STATIC)
|
||||
|
||||
# -- lookups -------------------------------------------------------------
|
||||
def at(self, instant: datetime) -> Period:
|
||||
"""The period covering an INSTANT. Tiling makes this total."""
|
||||
local = brussels(instant)
|
||||
minute = local.hour * 60 + local.minute
|
||||
for p in self.periods:
|
||||
if p.covers(local.weekday(), minute):
|
||||
return p
|
||||
# Unreachable given _check_tiling, and it stays an exception rather than
|
||||
# a default for the reason the module docstring gives.
|
||||
raise Absence(
|
||||
f"no period covers {local.isoformat()} - the tiling check that "
|
||||
f"makes this impossible has been weakened")
|
||||
|
||||
def price_at(self, instant: datetime) -> float:
|
||||
return self.at(instant).eur_per_kwh
|
||||
|
||||
def next_change(self, instant: datetime, *, limit_days: int = 8):
|
||||
"""The next instant at which the period NAME changes, or None.
|
||||
|
||||
Coarse 15-minute scan to bracket the change, then a 1-minute refine, so
|
||||
this is ~800 lookups rather than ~11,500. Returns UTC.
|
||||
"""
|
||||
u = instant.astimezone(timezone.utc).replace(second=0, microsecond=0)
|
||||
# ⚠️ KEYED ON (name, price), NOT ON THE NAME. Two rows may legitimately
|
||||
# share a name - a Belgian night tariff is often written as a weekday
|
||||
# row and a weekend row both called "night" - and the tariff has not
|
||||
# changed when the clock crosses between them. But two rows with one
|
||||
# name and DIFFERENT prices is a change an operator must see, and a
|
||||
# name-only comparison would report the next change as never.
|
||||
here = self._key(u)
|
||||
coarse = timedelta(minutes=15)
|
||||
end = u + timedelta(days=limit_days)
|
||||
probe = u
|
||||
while probe < end:
|
||||
probe += coarse
|
||||
if self._key(probe) != here:
|
||||
fine = probe - coarse
|
||||
while fine < probe:
|
||||
fine += timedelta(minutes=1)
|
||||
if self._key(fine) != here:
|
||||
return fine
|
||||
return probe
|
||||
return None
|
||||
|
||||
def _key(self, instant):
|
||||
p = self.at(instant)
|
||||
return (p.name, p.eur_per_kwh)
|
||||
|
||||
# -- the canonical series ------------------------------------------------
|
||||
def series(self, since: datetime, until: datetime, *,
|
||||
provisional: bool) -> list:
|
||||
"""Hourly `PricePoint`s over [since, until), in §9.3's canonical shape.
|
||||
|
||||
⚠️ ITERATES IN UTC. That is what makes a spring-forward day 23 points and
|
||||
an autumn-back day 25 without anything here knowing the dates.
|
||||
"""
|
||||
a = since.astimezone(timezone.utc).replace(minute=0, second=0,
|
||||
microsecond=0)
|
||||
b = until.astimezone(timezone.utc)
|
||||
out = []
|
||||
u = a
|
||||
while u < b:
|
||||
out.append(PricePoint(start=brussels(u),
|
||||
eur_per_kwh=round(self.price_at(u), 6),
|
||||
provisional=bool(provisional)))
|
||||
u += timedelta(hours=1)
|
||||
if not out:
|
||||
raise ProfileInvalid(
|
||||
f"static_profile.series({since.isoformat()}, "
|
||||
f"{until.isoformat()}) spans no whole hour. An empty series is "
|
||||
f"not constructible downstream on purpose (price.py trap 3).")
|
||||
return out
|
||||
|
||||
@property
|
||||
def words(self) -> str:
|
||||
kind = ("fully fixed" if self.product is PriceProduct.FIXED
|
||||
else f"static, {len(set(self.prices))} price levels")
|
||||
return f"{kind}: " + "; ".join(p.words for p in self.periods)
|
||||
|
||||
|
||||
def _check_tiling(periods) -> None:
|
||||
"""Every day type must be covered by EXACTLY ONE period, every minute.
|
||||
|
||||
⚠️ REFUSES BOTH DIRECTIONS. A gap means an hour with no price, and the only
|
||||
way to serve it is to invent one. An overlap means two prices for one hour
|
||||
and a silent winner decided by list order - the "two writers, one of which
|
||||
quietly wins" shape validate.py exists to refuse.
|
||||
"""
|
||||
for weekday in range(7):
|
||||
gap_from = over_from = None
|
||||
for minute in range(MINUTES_PER_DAY):
|
||||
n = sum(1 for p in periods if p.covers(weekday, minute))
|
||||
if n == 0 and gap_from is None:
|
||||
gap_from = minute
|
||||
if n > 1 and over_from is None:
|
||||
over_from = (minute, n)
|
||||
if n != 0 and gap_from is not None:
|
||||
_raise_gap(weekday, gap_from, minute)
|
||||
if n <= 1 and over_from is not None:
|
||||
_raise_overlap(weekday, over_from, minute, periods)
|
||||
if gap_from is not None:
|
||||
_raise_gap(weekday, gap_from, MINUTES_PER_DAY)
|
||||
if over_from is not None:
|
||||
_raise_overlap(weekday, over_from, MINUTES_PER_DAY, periods)
|
||||
|
||||
|
||||
def _hhmm(m):
|
||||
return f"{m // 60:02d}:{m % 60:02d}"
|
||||
|
||||
|
||||
def _raise_gap(weekday, start, end):
|
||||
raise ProfileInvalid(
|
||||
f"static_profile leaves {DAY_NAMES[weekday]} "
|
||||
f"{_hhmm(start)}-{_hhmm(end)} with no period, so those hours have no "
|
||||
f"price. Refused rather than defaulted: an invented price is one an "
|
||||
f"optimiser would trade on, and §11.4 already says what a missing price "
|
||||
f"means. Add a period covering it - remember a window written "
|
||||
f"\"22:00-07:00 mon-fri\" does NOT cover Saturday morning.")
|
||||
|
||||
|
||||
def _raise_overlap(weekday, over, end, periods):
|
||||
start, n = over
|
||||
names = [p.name for p in periods if p.covers(weekday, start)]
|
||||
raise ProfileInvalid(
|
||||
f"static_profile gives {DAY_NAMES[weekday]} {_hhmm(start)}-"
|
||||
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Two prices for "
|
||||
f"one hour means one of them silently wins on list order. Narrow one of "
|
||||
f"the windows or split the day selector.")
|
||||
|
||||
|
||||
def parse_static_profile(value):
|
||||
"""`static_profile` -> a `StaticProfile`, or None when unset.
|
||||
|
||||
Accepts what the option can actually arrive as:
|
||||
* `None` / `""` / `"null"` - unset. §14 ships `static_profile: null`.
|
||||
* a JSON string - what config.yaml's flat `str?` option
|
||||
carries on a Supervisor install.
|
||||
* a dict `{"periods": [...]}` - already-parsed, from /data/options.json
|
||||
under a future nested schema, from
|
||||
sim/inject.py, or from a check.
|
||||
* a bare list of period dicts - the same thing without the wrapper.
|
||||
|
||||
Raises `ProfileInvalid`; never returns a half-built profile.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text or text.lower() in ("null", "none", "off"):
|
||||
return None
|
||||
try:
|
||||
value = json.loads(text)
|
||||
except ValueError as e:
|
||||
raise ProfileInvalid(
|
||||
f"static_profile is not valid JSON ({e}). It is a JSON object "
|
||||
f"with a \"periods\" list, on one line, e.g.\n"
|
||||
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
|
||||
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
|
||||
f"Leave it empty for a dynamic-only or price-free site.")
|
||||
|
||||
if isinstance(value, dict):
|
||||
tz = str(value.get("timezone", "") or "").strip().lower()
|
||||
if tz not in SUPPORTED_TZ:
|
||||
raise ProfileInvalid(
|
||||
f"static_profile.timezone is {value.get('timezone')!r}. Only "
|
||||
f"Europe/Brussels is supported: the shipped image carries no tz "
|
||||
f"database (see app/price.py), so any other zone could not be "
|
||||
f"honoured and would be silently treated as Brussels - which is "
|
||||
f"wrong by an hour for part of the year.")
|
||||
rows = value.get("periods")
|
||||
if rows is None:
|
||||
raise ProfileInvalid(
|
||||
"static_profile has no \"periods\" list. Even a fully-fixed "
|
||||
"tariff is written as one period covering 00:00-24:00 all days "
|
||||
"- one shape for both products, so nothing downstream branches "
|
||||
"on which kind of site this is.")
|
||||
else:
|
||||
rows = value
|
||||
|
||||
if not isinstance(rows, (list, tuple)):
|
||||
raise ProfileInvalid(
|
||||
f"static_profile.periods is {type(rows).__name__}, not a list")
|
||||
|
||||
periods = []
|
||||
for i, row in enumerate(rows, start=1):
|
||||
if not isinstance(row, dict):
|
||||
raise ProfileInvalid(
|
||||
f"static_profile.periods[{i}] is {type(row).__name__}, not an "
|
||||
f"object with from/to/days/eur_per_kwh")
|
||||
where = f"static_profile.periods[{i}]"
|
||||
name = str(row.get("name", "") or f"period{i}").strip() or f"period{i}"
|
||||
start = _minutes(row.get("from", row.get("start")), f"{where}.from")
|
||||
end = _minutes(row.get("to", row.get("end")), f"{where}.to")
|
||||
if end == 0:
|
||||
end = MINUTES_PER_DAY # "00:00" as an END is end-of-day
|
||||
if start == end:
|
||||
raise ProfileInvalid(
|
||||
f"{where} runs {_hhmm(start)}-{_hhmm(end)}, which is either "
|
||||
f"zero minutes or the whole day and there is no way to tell "
|
||||
f"which was meant. Write a full day as 00:00-24:00.")
|
||||
if start == MINUTES_PER_DAY:
|
||||
raise ProfileInvalid(
|
||||
f"{where}.from is \"24:00\"; a period cannot START at "
|
||||
f"end-of-day. Did you mean \"00:00\"?")
|
||||
periods.append(Period(name=name, start_min=start,
|
||||
end_min=end,
|
||||
days=_days(row.get("days"), f"{where}.days"),
|
||||
eur_per_kwh=_price(row.get("eur_per_kwh"),
|
||||
f"{where}.eur_per_kwh")))
|
||||
return StaticProfile(periods)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The arbitrage verdict. A variant type, because a boolean is a suggestion.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class Arbitrage(_Outcome):
|
||||
"""May an optimiser trade on this tariff, and with what numbers.
|
||||
|
||||
* `allowed` - carries `.spread_eur_kwh`, `.cheapest`, `.dearest`,
|
||||
`.regime`, `.product`. These are the ONLY inputs an
|
||||
arbitrage strategy needs, and they exist here and nowhere
|
||||
else.
|
||||
* `refused` - carries `.reason`. Nothing else. `.spread_eur_kwh` on a
|
||||
refusal raises `Absence`.
|
||||
|
||||
⚠️ NO `__bool__`, for price.py's reason: `if verdict:` must not look like a
|
||||
guard while being none. Use `.match(allowed=..., refused=...)`, both
|
||||
handlers required, so the refusal cannot be fallen through.
|
||||
"""
|
||||
|
||||
def match(self, *, allowed, refused):
|
||||
return {"allowed": allowed, "refused": refused}[self.kind](self)
|
||||
|
||||
@staticmethod
|
||||
def allow(spread, cheapest, dearest, regime, product):
|
||||
return _Allowed(spread, cheapest, dearest, regime, product)
|
||||
|
||||
@staticmethod
|
||||
def refuse(reason):
|
||||
if not reason or not isinstance(reason, str):
|
||||
raise ValueError(
|
||||
"Arbitrage.refuse() takes a reason a human can act on - §10.2 "
|
||||
"requires every plan change to carry one, and 'arbitrage did "
|
||||
"nothing' with no reason is indistinguishable from a bug.")
|
||||
return _Refused(reason)
|
||||
|
||||
|
||||
class _Allowed(Arbitrage):
|
||||
kind = "allowed"
|
||||
|
||||
def __init__(self, spread, cheapest, dearest, regime, product):
|
||||
self.spread_eur_kwh = spread
|
||||
self.cheapest = cheapest # the PricePoint at the low end
|
||||
self.dearest = dearest # the PricePoint at the high end
|
||||
self.regime = regime # §8.3 "wide" | "normal"
|
||||
self.product = product
|
||||
self.words = (f"spread {spread:.4f} EUR/kWh over "
|
||||
f"{product.value} ({regime} thresholds)")
|
||||
|
||||
|
||||
class _Refused(Arbitrage):
|
||||
kind = "refused"
|
||||
|
||||
def __init__(self, reason):
|
||||
self.reason = reason
|
||||
self.words = f"no arbitrage - {reason}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The plan: one tariff, whatever produced it
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TariffPlan:
|
||||
"""What this site's tariff looks like right now, and what may be done on it.
|
||||
|
||||
`origin` is how the series was obtained, and it is NOT the same question as
|
||||
`product`:
|
||||
|
||||
"provider" a live feed (§9.2 rows 1-2)
|
||||
"static" the CONFIGURED static_profile (§9.2 rows 3-4)
|
||||
"fallback" the static_profile standing in for a dead feed (§11.4) -
|
||||
identical data, `provisional=True`, arbitrage refused
|
||||
"none" no feed and no profile. §6: the loop keeps regulating.
|
||||
"""
|
||||
|
||||
def __init__(self, read: PriceRead, profile, origin: str):
|
||||
self.read = read
|
||||
self.profile = profile
|
||||
self.origin = origin
|
||||
|
||||
# -- derived facts -------------------------------------------------------
|
||||
@property
|
||||
def has_prices(self) -> bool:
|
||||
return self.read.match(series=lambda _r: True, no_data=lambda _r: False)
|
||||
|
||||
@property
|
||||
def product(self):
|
||||
return self.read.match(series=lambda r: r.product,
|
||||
no_data=lambda _r: None)
|
||||
|
||||
@property
|
||||
def provisional(self) -> bool:
|
||||
"""§8.3/§11.4, derived from the points rather than asserted."""
|
||||
return self.read.match(
|
||||
series=lambda r: any(p.provisional for p in r.points),
|
||||
no_data=lambda _r: True)
|
||||
|
||||
def price_at(self, instant: datetime) -> float:
|
||||
"""EUR/kWh now. Raises `Absence` when there is no price - never 0.0."""
|
||||
return self.read.match(
|
||||
series=lambda r: r.at(instant).eur_per_kwh,
|
||||
no_data=lambda r: _absent(r.reason))
|
||||
|
||||
def window_at(self, instant: datetime) -> str:
|
||||
"""The name of the tariff window now (`day`, `night`, ...).
|
||||
|
||||
Only a static profile has named windows; a day-ahead series does not,
|
||||
and inventing one would be a label with no referent.
|
||||
"""
|
||||
if self.profile is None:
|
||||
raise Absence(
|
||||
"this plan has no static_profile, so it has no named tariff "
|
||||
"window. Time-window shifting (§9.2) is a static-profile "
|
||||
"behaviour; a day-ahead series is shifted against prices, not "
|
||||
"against window names.")
|
||||
return self.profile.at(instant).name
|
||||
|
||||
def mean_eur_kwh(self) -> float:
|
||||
return self.read.match(
|
||||
series=lambda r: sum(p.eur_per_kwh for p in r.points) / len(r.points),
|
||||
no_data=lambda r: _absent(r.reason))
|
||||
|
||||
def spread_eur_kwh(self) -> float:
|
||||
return self.read.match(
|
||||
series=lambda r: (max(p.eur_per_kwh for p in r.points)
|
||||
- min(p.eur_per_kwh for p in r.points)),
|
||||
no_data=lambda r: _absent(r.reason))
|
||||
|
||||
# -- the two things a consumer is allowed to act on ----------------------
|
||||
def cheap_now(self, instant: datetime) -> bool:
|
||||
"""Is now a cheap window? STRICTLY below the mean - see the docstring.
|
||||
|
||||
False on a provisional plan: §8.3 forbids an irreversible action on a
|
||||
provisional price, and a grid top-up of a battery is about as
|
||||
irreversible as this add-on gets. False when there is no price at all,
|
||||
which is the §11.4 row and also main.py's long-standing "fixed-tariff
|
||||
site: never force a paid grid top-up, wait for sun".
|
||||
"""
|
||||
if not self.has_prices or self.provisional:
|
||||
return False
|
||||
try:
|
||||
return self.price_at(instant) < self.mean_eur_kwh()
|
||||
except Absence:
|
||||
# An instant outside the series is an hour with no price (§11.4 for
|
||||
# that hour), not a cheap one.
|
||||
return False
|
||||
|
||||
def arbitrage(self, *, peak_guard_active: bool) -> Arbitrage:
|
||||
"""May OPT-04 trade, and on what numbers. See the module docstring.
|
||||
|
||||
⚠️ `peak_guard_active` IS REQUIRED AND KEYWORD-ONLY. §9.1/§10.1 put peak
|
||||
shaving above arbitrage, and this is how that ordering survives a
|
||||
strategy that has not been written yet: the spread is not obtainable
|
||||
without answering the question.
|
||||
"""
|
||||
if not isinstance(peak_guard_active, bool):
|
||||
raise TypeError(
|
||||
f"peak_guard_active must be a bool, not {peak_guard_active!r}. "
|
||||
f"A truthy 'unknown' is how §10.1's priority 2 gets skipped.")
|
||||
if peak_guard_active:
|
||||
return Arbitrage.refuse(
|
||||
"the capacity-tariff peak guard is holding, and §10.1 puts peak "
|
||||
"shaving (priority 2) above price arbitrage (priority 3) - the "
|
||||
"capaciteitstarief is a network cost charged whatever the "
|
||||
"energy product is (§9.1)")
|
||||
if not self.has_prices:
|
||||
return Arbitrage.refuse(
|
||||
f"§11.4: {self.read.reason}. No series exists, so no spread "
|
||||
f"exists; self-consumption, peak shaving and reserve management "
|
||||
f"(§10.1 priorities 1, 2 and 4) are unaffected")
|
||||
if self.provisional:
|
||||
return Arbitrage.refuse(
|
||||
f"§11.4: the plan is provisional (origin {self.origin!r}), and "
|
||||
f"§8.3 forbids an irreversible action on a provisional price. "
|
||||
f"Load shifting and peak shaving continue")
|
||||
spread = self.spread_eur_kwh()
|
||||
if spread == 0.0:
|
||||
return Arbitrage.refuse(
|
||||
f"the tariff is flat - every one of the "
|
||||
f"{len(self.read.points)} hours costs "
|
||||
f"{self.read.points[0].eur_per_kwh:.4f} EUR/kWh, so buying low "
|
||||
f"and selling high is arithmetically impossible. §9.2: a fully "
|
||||
f"fixed product is self-consumption only")
|
||||
pts = self.read.points
|
||||
return Arbitrage.allow(
|
||||
spread=spread,
|
||||
cheapest=min(pts, key=lambda p: p.eur_per_kwh),
|
||||
dearest=max(pts, key=lambda p: p.eur_per_kwh),
|
||||
regime=self.read.regime,
|
||||
product=self.read.product)
|
||||
|
||||
@property
|
||||
def words(self) -> str:
|
||||
if not self.has_prices:
|
||||
return f"no tariff - {self.read.reason}"
|
||||
tag = {"provider": self.read.provider, "static": "static_profile",
|
||||
"fallback": "static_profile (feed down)"}.get(self.origin,
|
||||
self.origin)
|
||||
return (f"{self.product.value} from {tag}, {len(self.read.points)} h"
|
||||
+ (", provisional" if self.provisional else ""))
|
||||
|
||||
|
||||
def _absent(reason):
|
||||
raise Absence(
|
||||
f"this plan observed no prices ({reason}). A number invented here is "
|
||||
f"the bug price.py's _NoData exists to prevent - 'no data' becoming "
|
||||
f"'free'. Ask has_prices, or use read.match(...).")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The two entry points
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def static_plan(opts: dict, since: datetime, until: datetime, *,
|
||||
provisional: bool = False, now=None) -> TariffPlan:
|
||||
"""The static-profile plan. PURE, I/O-FREE, no network, no tz database.
|
||||
|
||||
This is what main.py calls on the planning tick. It is synchronous on
|
||||
purpose: `load_prices()` may perform an HTTP fetch, and a fetch on the tick
|
||||
that feeds the ESP32 heartbeat is a supplier's TLS timeout able to stall the
|
||||
heartbeat towards layer 1's 30 s veto. Generation is pure, so §2.3 says
|
||||
regenerate every tick rather than cache, and there is nothing here that can
|
||||
block.
|
||||
"""
|
||||
profile = parse_static_profile(price_option(opts, "static_profile"))
|
||||
if profile is None:
|
||||
return TariffPlan(
|
||||
PriceRead.no_data(
|
||||
"no static_profile configured; §11.4: plan provisional, no "
|
||||
"arbitrage. Self-consumption, peak shaving and reserve "
|
||||
"management are unaffected"),
|
||||
None, "none")
|
||||
points = profile.series(since, until, provisional=provisional)
|
||||
return TariffPlan(
|
||||
PriceRead.series(points, profile.product, "static_profile",
|
||||
now or datetime.now(timezone.utc)),
|
||||
profile, "fallback" if provisional else "static")
|
||||
|
||||
|
||||
def static_status(opts: dict, now: datetime):
|
||||
"""(cheap, words) for the status page and for main.py's cheap-window flag.
|
||||
|
||||
Never raises: a `static_profile` that does not parse is refused at STARTUP
|
||||
by validate.py, so reaching this with a broken one means somebody handed the
|
||||
controller a dict the validator never saw. That is worth a warning and a
|
||||
"no cheap window", not a controller that stops regulating a battery (§6:
|
||||
absent machinery must not disable the loop).
|
||||
"""
|
||||
try:
|
||||
plan = static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
|
||||
now=now)
|
||||
except ProfileInvalid as e:
|
||||
_LOG.warning("static_profile is unusable (%s); no cheap window. This "
|
||||
"should have been refused at startup - see validate.py", e)
|
||||
return False, f"static_profile unusable: {e}"
|
||||
if not plan.has_prices:
|
||||
return False, "no static profile"
|
||||
cheap = plan.cheap_now(now)
|
||||
window = plan.window_at(now)
|
||||
price = plan.price_at(now)
|
||||
when = plan.profile.next_change(now)
|
||||
until = "" if when is None else f" until {brussels(when):%H:%M}"
|
||||
kind = ("fixed" if plan.product is PriceProduct.FIXED else "day/night")
|
||||
return cheap, (f"{kind}: {window}{until} at {price:.4f} EUR/kWh, "
|
||||
f"mean {plan.mean_eur_kwh():.4f} over "
|
||||
f"{MEAN_WINDOW_DAYS} d - "
|
||||
f"{'CHEAP window' if cheap else 'not a cheap window'}")
|
||||
|
||||
|
||||
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
transport=None, now=None) -> TariffPlan:
|
||||
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
|
||||
|
||||
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
|
||||
omission. Wiring a provider-backed plan into the control loop needs a
|
||||
BACKGROUND refresh (a day-ahead feed publishes once a day; polling it every
|
||||
30 s would be rude and would put a network timeout on the heartbeat path).
|
||||
That task belongs with the first strategy that actually trades - OPT-04 -
|
||||
and this function is the seam it consumes. Everything below it is exercised
|
||||
by test_static_tariff.py today.
|
||||
|
||||
price_provider off + profile -> the CONFIGURED product (§9.2 rows 3-4)
|
||||
price_provider off + none -> no prices at all (§11.4, nothing to
|
||||
fall back to)
|
||||
provider series -> the feed (§9.2 rows 1-2)
|
||||
provider no_data + profile -> §11.4 FALLBACK, provisional, no arbitrage
|
||||
provider no_data + none -> no prices at all
|
||||
"""
|
||||
stamp = now or datetime.now(timezone.utc)
|
||||
pid = str(price_option(opts, "price_provider") or "off").strip()
|
||||
if pid in ("", "off"):
|
||||
return static_plan(opts, since, until, provisional=False, now=stamp)
|
||||
|
||||
read = await load_prices(opts, since, until, transport)
|
||||
return read.match(
|
||||
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
|
||||
|
||||
def _quiet_profile(opts):
|
||||
"""The profile, for window naming, without letting a bad one break a good feed."""
|
||||
try:
|
||||
return parse_static_profile(price_option(opts, "static_profile"))
|
||||
except ProfileInvalid:
|
||||
return None
|
||||
|
||||
|
||||
def _degrade(opts, read, since, until, stamp) -> TariffPlan:
|
||||
"""§11.4: fall back to the static profile, mark provisional, no arbitrage."""
|
||||
try:
|
||||
profile = parse_static_profile(price_option(opts, "static_profile"))
|
||||
except ProfileInvalid as e:
|
||||
_LOG.warning("the price feed is down AND static_profile is unusable "
|
||||
"(%s), so there is no tariff at all", e)
|
||||
profile = None
|
||||
if profile is None:
|
||||
_LOG.warning("§11.4: %s, and no static_profile is configured to fall "
|
||||
"back to. The plan is provisional and arbitrage is off; "
|
||||
"self-consumption, peak shaving and reserve management "
|
||||
"continue (§6, §10.1).", read.reason)
|
||||
return TariffPlan(read, None, "none")
|
||||
_LOG.warning("§11.4: %s - falling back to the static profile, plan marked "
|
||||
"PROVISIONAL, arbitrage off.", read.reason)
|
||||
points = profile.series(since, until, provisional=True)
|
||||
return TariffPlan(
|
||||
PriceRead.series(points, profile.product, "static_profile", stamp),
|
||||
profile, "fallback")
|
||||
@@ -22,30 +22,11 @@ _LOG = logging.getLogger("goodwe.store")
|
||||
# `auto_start` option only when the key is absent - if a default supplied False,
|
||||
# the fallback could never fire and auto_start would silently do nothing on
|
||||
# every fresh install.
|
||||
#
|
||||
# ⚠️ `sign_tripped` IS in here, and the difference from `auto` is the whole
|
||||
# reason both decisions are written down. `auto` has an option to fall back to
|
||||
# (`auto_start`), so a stored default would mask a fresh install. A latched
|
||||
# failsafe has no such fallback: absent means "never tripped", which is exactly
|
||||
# what a default of False says.
|
||||
#
|
||||
# It is persisted for the reason this module's docstring gives for maintenance
|
||||
# state, applied to something with a much shorter fuse. SAFETY-02's latch is the
|
||||
# only thing standing between a controller that has been caught driving the wrong
|
||||
# way and its own restart, and "restart the add-on" is the FIRST thing an operator
|
||||
# looking at a FAILSAFE banner will try. Held only in memory it came back armed,
|
||||
# into the fault, unattended - measured: after reconstructing Controller against
|
||||
# the same store, `tripped` was False, the banner read NOT READY, and it resumed
|
||||
# commanding -1000, -2000 W before re-tripping 180 s later. Under a supervisor
|
||||
# that restarts a crashed add-on that is a loop, three minutes of wrong-direction
|
||||
# power at a time, forever.
|
||||
DEFAULTS = {
|
||||
"phase": "idle",
|
||||
"phase_started": None,
|
||||
"last_completed": None,
|
||||
"last_start_attempt": None,
|
||||
"sign_tripped": False,
|
||||
"sign_reason": "",
|
||||
}
|
||||
|
||||
|
||||
@@ -61,10 +42,8 @@ class Store:
|
||||
loaded = json.load(fh)
|
||||
if isinstance(loaded, dict):
|
||||
self.data = {**DEFAULTS, **loaded}
|
||||
_LOG.info("state restored: phase=%s last_completed=%s "
|
||||
"sign_tripped=%s",
|
||||
self.data.get("phase"), self.data.get("last_completed"),
|
||||
self.data.get("sign_tripped"))
|
||||
_LOG.info("state restored: phase=%s last_completed=%s",
|
||||
self.data.get("phase"), self.data.get("last_completed"))
|
||||
except FileNotFoundError:
|
||||
_LOG.info("no saved state, starting fresh")
|
||||
except (json.JSONDecodeError, OSError) as err:
|
||||
|
||||
@@ -1,702 +0,0 @@
|
||||
"""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
|
||||
held_w: float
|
||||
|
||||
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:
|
||||
# ⚠️ A ZERO PROJECTION, AND IT IS NOT "THE QUARTER IS FINE". It is
|
||||
# "there is no quarter". `block_start is None` is the flag that says
|
||||
# which, and SAFETY-07 grades that flag BEFORE it grades the number -
|
||||
# see peakguard.blindness(). A consumer that reads only
|
||||
# `projected_w` here reads 0 W, which is under every ceiling there
|
||||
# is, on exactly the startup path where nothing else is watching.
|
||||
return Projection(0.0, 0.0, 0.0, 0.0, False, None, 0.0)
|
||||
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)
|
||||
# ⚠️ NO `getattr` DEFAULT, AND THAT IS THE FIX, NOT A TIDY-UP. This read
|
||||
# was `getattr(averager, "_last_net", 0.0)`: a private name with a
|
||||
# fallback, so renaming the attribute would have kept every projection
|
||||
# green while crediting 0 W held - a projection that reads LOW, which is
|
||||
# the UNDER-GUARDING direction for SAFETY-07's floor. TEST-13 item 4.
|
||||
# `held_net_w` is now a documented public property on QuarterAverager and
|
||||
# is read without a default, so the same rename raises AttributeError on
|
||||
# the first cycle instead of quietly disarming the money guard.
|
||||
held = max(averager.held_net_w, 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,
|
||||
# Carried so a consumer can see WHAT was extrapolated, not only the
|
||||
# extrapolation. It is the signed held value; `projected_w` uses the
|
||||
# offtake-only form above, and the two differ whenever the site is
|
||||
# exporting - which is the healthy operating point here.
|
||||
held_w=averager.held_net_w)
|
||||
|
||||
|
||||
@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,572 +0,0 @@
|
||||
"""Startup configuration validation - the gate before the control loop runs.
|
||||
|
||||
WHAT THIS IS FOR, in one sentence: some configurations are not "a bit off", they
|
||||
are configurations under which the add-on's own safety machinery does not work,
|
||||
and for those the correct behaviour is to refuse to start rather than to log a
|
||||
line and carry on.
|
||||
|
||||
⚠️ THE FAILURE THIS REPLACES. `main.py` used to read the setpoint device's own
|
||||
±5000 W rating, notice that a configured `max_w` of, say, 8000 W exceeded it,
|
||||
write one WARNING to a log nobody reads, and then run anyway with
|
||||
`write_setpoint()` quietly clamping. The operator's stated ceiling was simply not
|
||||
the ceiling, and the only evidence was a line that had scrolled off. specs §1.5
|
||||
is explicit that a configuration in which the system cannot honour what it was
|
||||
told is refused at startup, not survived at runtime.
|
||||
|
||||
WHERE THE LINE IS DRAWN, and why it is not further out. A validator that refuses
|
||||
on trivia gets switched off by the first person it inconveniences, so this one
|
||||
refuses only where **continuing produces one of two outcomes:**
|
||||
|
||||
(a) **a stated limit is untrue** - the operator configured a bound, a lower
|
||||
layer silently enforces a different one, and nothing says which won; or
|
||||
(b) **the add-on is inert and indistinguishable from healthy** - it starts,
|
||||
serves its UI, reports green, and moves no energy, with no symptom that
|
||||
separates it from a correctly-idle system.
|
||||
|
||||
⚠️ (b) IS NOT A SPECIAL CASE OF (a), and an earlier draft of this file wrongly
|
||||
implied it was by claiming every refusal here "makes a safety mechanism
|
||||
inoperative". It does not: when `setpoint_entity` is blank, layer 1 pinning the
|
||||
inverter at 0 W is the failsafe WORKING. The problem is not that the system
|
||||
becomes unsafe, it is that it becomes a very convincing impression of a working
|
||||
controller - and an operator has no way to tell. That is its own reason to
|
||||
refuse, and stating it as one is what stops the rule being stretched later to
|
||||
cover mere untidiness.
|
||||
|
||||
Both halves have to stay narrow. (a) is not "any two numbers that could
|
||||
disagree"; it is a bound the operator SET being overridden by a layer they
|
||||
cannot see. (b) is not "anything degraded"; it is inert AND silent - if a red
|
||||
NOT READY banner already names the problem, (b) does not apply.
|
||||
|
||||
1. `max_w` outside the setpoint device's own rating. [(a)]
|
||||
The stated limit is untrue. arbiter.py's `supervised` SAFETY claim says the
|
||||
output is bounded by ±max_w; it is not, it is bounded by the device, and the
|
||||
two numbers disagree. Which one wins is a coin toss to anyone reading the
|
||||
config.
|
||||
|
||||
2. No `setpoint_entity`. [(b)]
|
||||
A controller with no actuator, and nothing about it looks broken.
|
||||
`write_setpoint()` returns immediately on a blank entity, so: no device
|
||||
rating is ever read (the `device` SAFETY claim is never made at all), and
|
||||
the ESP32 heartbeat - the thing that feeds LAYER 1 - never happens. The
|
||||
add-on serves a UI, publishes MQTT, resolves claims, and moves nothing.
|
||||
Layer 1 then holds the inverter at 0 W, which is the failsafe working
|
||||
exactly as designed - this is (b), not (a). Nothing is unsafe; it is simply
|
||||
impossible to tell from the outside that the thing is doing nothing.
|
||||
|
||||
3. A `meter_source` that can never produce a reading. [(b)]
|
||||
§5.1 makes P1 the only authoritative measurement and deliberately provides
|
||||
no fallback, so a transport that cannot be built means `grid` is None
|
||||
forever: permanent "inputs missing", 0 W, and a status page that reports a
|
||||
meter with a climbing age exactly as it would for a healthy meter nobody has
|
||||
sent anything to yet. The predicates live in `p1.validate_meter_options()`,
|
||||
which is also what `build_source()` uses - one definition, two callers.
|
||||
|
||||
4. `meter_max_age_s` above the firmware's own ingest veto. [(a)]
|
||||
The purest example of (a) in the system, and structurally the same fault as
|
||||
1. The add-on's schema accepts `int(5,300)`. The firmware's threshold is a
|
||||
COMPILE-TIME SUBSTITUTION - `max_age_s: "30"` in
|
||||
goodwe-master-stage4-failsafe.yaml - used by both its monotonic receipt
|
||||
clock and its `ingest_stale` veto, which trips layer 1 on
|
||||
`p1_age_s >= 30`. Set 120 and the add-on believes a 119 s reading is fresh
|
||||
and keeps commanding into it, while the firmware vetoed at 30 s and is
|
||||
holding the inverter at 0 W. Status page: healthy meter, running
|
||||
controller, nothing moving, and no line anywhere saying the number you set
|
||||
was overruled. That firmware file's own comment says a lower bound on this
|
||||
"belongs in CFG-01, not here" - so here it is.
|
||||
|
||||
5. `sign_disagree_w` at or above `max_w`. [(a)]
|
||||
The arbiter's `supervised` limit bounds every command to +-max_w, and
|
||||
SAFETY-02's predicate needs `abs(command) > sign_disagree_w`. Set the
|
||||
threshold at or above the ceiling and the command side of the comparison
|
||||
is unsatisfiable BY CONSTRUCTION: the watchdog can never fire, on any
|
||||
input, ever. Same shape as 1 and 4 - the operator set a number, a layer
|
||||
they cannot see makes it mean something else, and nothing says so. Worse
|
||||
here, because what the status page reports in that state is "sign
|
||||
watchdog: armed". A disarmed guard that advertises itself as armed is the
|
||||
one thing this project has decided repeatedly it will not ship.
|
||||
|
||||
6. `maintenance_charge_w` above `max_w`. [(a)]
|
||||
The same shape as 5, and found the same way - by checking a worked example
|
||||
against what actually ships. maintenance's cheap-window shaping files
|
||||
`Claim.limit(hi=-maintenance_charge_w)` at P_MAINT_SHAPE, against the
|
||||
`supervised` SAFETY limit's `lo=-max_w`. Both bind (P_SAFETY and
|
||||
P_MAINT_SHAPE both outrank the loop that wins the SET), so a charge rate
|
||||
above the ceiling gives `lo > hi` - the arbiter's clause 3, which it
|
||||
correctly resolves to 0 W and logs as a CONTRADICTION. Measured against
|
||||
the arbiter, loop asking +500 W:
|
||||
|
||||
max_w=2000 hi=-2500 -> +0 W contradiction=True
|
||||
max_w=2500 hi=-2500 -> -2500 W limited by supervised(max_w)
|
||||
max_w=3000 hi=-2500 -> -2500 W limited by maintenance(...)
|
||||
|
||||
So MAINTENANCE SILENTLY NEVER CHARGES IN A CHEAP WINDOW until max_w is at
|
||||
or above the charge rate, and the only evidence is a contradiction line
|
||||
that reads like a bug in the arbiter rather than like the two numbers the
|
||||
operator set. Equality is fine and deliberately not refused - at
|
||||
max_w == charge_w the window is a single reachable point, -max_w.
|
||||
|
||||
⚠️ `maintenance_discharge_w` IS NOT THE SAME CASE, and this was checked
|
||||
rather than assumed. THE SHAPING IS NOT SYMMETRIC: the drain phase does
|
||||
not shape the loop, it OWNS the actuator - `Claim.set(P_MAINTENANCE,
|
||||
+discharge_w)`. A SET is clamped by the limits above it, not contradicted
|
||||
by them, so 2500 against a 2000 W ceiling emits +2000 W and the arbiter's
|
||||
own explain() says `maintenance -> 2000 W, limited by supervised(max_w)`
|
||||
on the status page every cycle. That is neither (a) - nothing is silent -
|
||||
nor (b) - it moves energy and the cycle still completes, just slower
|
||||
(13.8 kWh usable at 2000 W is ~6.9 h against the 720 min DRAIN_TIMEOUT).
|
||||
It is warned about below, not refused.
|
||||
|
||||
7. A price configuration that cannot mean what it says. [(a)]
|
||||
Three cases, all in `_price_problems()`: an unknown `price_provider` id
|
||||
(refused rather than defaulted to 'off', per the §14 warning box);
|
||||
`price_provider` set alongside `price_now_entity`/`price_avg_entity`,
|
||||
which is TWO PRICE SOURCES ON ONE SITE where the Home Assistant pair
|
||||
silently wins; and `manual_csv` with no `price_csv_path`, which turns the
|
||||
documented offline fallback into a permanent no-price-data condition
|
||||
reached by accident. Same shape as 1, 4 and 5 every time - a source the
|
||||
operator chose, overridden by a layer they cannot see.
|
||||
|
||||
⚠️ `price_provider: "off"` IS NOT REFUSED AND MUST NOT BECOME SO. §11.4
|
||||
makes no-price-data a supported state, and §9.2 says a large share of the
|
||||
installed base is on a fixed or static tariff. Refusing to start without a
|
||||
price feed would lock those sites out of the add-on entirely.
|
||||
|
||||
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
|
||||
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
|
||||
extra headroom buys is more wrong-direction power to unwind after
|
||||
saturation - but the arbiter's `supervised` limit still binds the wire to
|
||||
±max_w, so nothing out of range ever reaches the inverter. The cost is
|
||||
latency, not amperage. Warned about, not refused.
|
||||
- A missing `soc_entity` / `batt_entity`. `inputs_ok` goes False, the loop
|
||||
commands 0 W, and `checks()` already renders a red NOT READY banner naming
|
||||
the missing entity. Fail-safe AND already loud; a refusal adds nothing.
|
||||
- A `setpoint_entity` that exists in the config but is not found on the Core
|
||||
API at startup. This is the transient case - ESPHome is frequently not up
|
||||
yet when the add-on boots - and refusing here would turn a device that
|
||||
arrives thirty seconds late into a restart loop needing a human. It stays a
|
||||
NOT READY banner.
|
||||
- `meter_poll_s` above half `meter_max_age_s`. One missed poll costs a stale
|
||||
reading and a 0 W cycle. Warned about in p1.py, not refused.
|
||||
- `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the
|
||||
clamp names itself in the arbiter's explain() - see rule 6. Warned about,
|
||||
not refused.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from .p1 import is_enabled, validate_meter_options
|
||||
from .price import PROVIDERS
|
||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||
|
||||
_LOG = logging.getLogger("goodwe.config")
|
||||
|
||||
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
|
||||
# threshold is a compile-time YAML substitution baked into the ESP32 image
|
||||
# (`max_age_s: "30"`, master/goodwe-master-stage4-failsafe.yaml), so nothing at
|
||||
# runtime can ask the board what value it was built with. This number is a copy,
|
||||
# and a copy of a constant in another repo is a thing that goes stale.
|
||||
#
|
||||
# What makes that acceptable here: the copy can only ever be WRONG IN THE SAFE
|
||||
# DIRECTION for the check below. If the firmware's veto is raised above 30, this
|
||||
# refuses some configs that would in fact have worked - annoying, visible
|
||||
# immediately, and fixed by editing one line. It cannot start accepting a config
|
||||
# the firmware vetoes unless the firmware's number goes DOWN, which would be a
|
||||
# tightening nobody does silently.
|
||||
#
|
||||
# ⚠️ TEL-06 IS EDITING `ingest_stale` DIRECTLY. If that ticket changes what the
|
||||
# veto MEANS - e.g. thresholding arrival rather than the sensor's value - this
|
||||
# constant's justification has to be re-read, not just its number. Do not assume
|
||||
# a matching integer means a matching rule.
|
||||
FIRMWARE_INGEST_VETO_S = 30.0
|
||||
|
||||
# ⚠️ The complete set of option keys through which this add-on can reach an
|
||||
# actuator. specs §1.5 makes "any configuration in which two writers can reach
|
||||
# one actuator" a non-goal, and the way that non-goal is ENFORCED here is by
|
||||
# construction: there is exactly one such key, so a second writer is not
|
||||
# expressible in the options schema at all. sim/inject.py's `two_writers`
|
||||
# scenario asserts on this tuple - if a second write path is ever added, that
|
||||
# scenario goes red and somebody has to decide how the two are arbitrated
|
||||
# BEFORE the config that allows both can ship.
|
||||
ACTUATOR_WRITE_KEYS = ("setpoint_entity",)
|
||||
|
||||
|
||||
class ConfigRefused(Exception):
|
||||
"""The configuration is wrong. Not "something broke" - see main.py's handler.
|
||||
|
||||
Carries the list of problems so the caller can render them; str() is the
|
||||
first one, so an unhandled traceback still names something useful.
|
||||
"""
|
||||
|
||||
def __init__(self, problems: list[str]):
|
||||
self.problems = problems
|
||||
super().__init__(problems[0] if problems else "configuration refused")
|
||||
|
||||
|
||||
def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]:
|
||||
"""Return the list of reasons this configuration must not be started.
|
||||
|
||||
Empty list means go. `dev_min`/`dev_max` are the setpoint device's own
|
||||
reported range (`hass.limits()`), or None when the device could not be
|
||||
reached - in which case the range checks are skipped rather than guessed at,
|
||||
for the transient reason in this module's docstring.
|
||||
|
||||
Pure and side-effect free apart from two warnings, so it can be called from
|
||||
a test or from sim/inject.py without a Home Assistant anywhere near it.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
|
||||
# --- 1. an actuator to write to ------------------------------------------
|
||||
writers = [k for k in ACTUATOR_WRITE_KEYS
|
||||
if str(opts.get(k, "") or "").strip()]
|
||||
if not writers:
|
||||
problems.append(
|
||||
"setpoint_entity is not set, so this add-on has no actuator: every "
|
||||
"write is a no-op, the device's own rating is never read, and the "
|
||||
"ESP32 heartbeat that holds off the layer-1 failsafe never happens. "
|
||||
"It would run, look healthy, and move no energy at all. Set it to "
|
||||
"the ESPHome number entity that carries the setpoint.")
|
||||
|
||||
# --- 2. the configured ceiling against the device's own rating -----------
|
||||
max_w = _number(opts, "max_w", 2000.0)
|
||||
if max_w is None:
|
||||
problems.append(f"max_w {opts.get('max_w')!r} is not a number")
|
||||
# ⚠️ config.yaml's int(100,5000) already blocks this on a real install, so
|
||||
# the check is not for the Supervisor path - it is because
|
||||
# validate_startup_config() is now also sim/inject.py's entry point and can
|
||||
# be handed any dict at all. A max_w of 0 or below inverts the arbiter's
|
||||
# `supervised` bounds (lo=-max_w becomes the HIGHER number), and a limit
|
||||
# claim whose lo exceeds its hi is exactly the contradiction the arbiter
|
||||
# resolves to 0 W - i.e. it would look like a safety trip rather than the
|
||||
# nonsense config it is.
|
||||
elif max_w <= 0:
|
||||
problems.append(
|
||||
f"max_w is {max_w:.0f} W and must be positive - it is a magnitude, "
|
||||
"not a signed setpoint. At or below zero the arbiter's supervised "
|
||||
f"limit becomes lo={-max_w:.0f} / hi={max_w:.0f}, which is a "
|
||||
"contradiction it resolves to 0 W, so the controller would look "
|
||||
"like it was tripping rather than misconfigured.")
|
||||
elif dev_max is not None and max_w > dev_max:
|
||||
problems.append(
|
||||
f"max_w is {max_w:.0f} W but {opts.get('setpoint_entity') or 'the setpoint device'} accepts at "
|
||||
f"most {dev_max:.0f} W - THE DEVICE WINS, so the {max_w:.0f} W you "
|
||||
f"configured would silently never happen. Lower max_w to "
|
||||
f"{dev_max:.0f} W or less, or point setpoint_entity at a device that "
|
||||
f"can take {max_w:.0f} W.")
|
||||
elif dev_min is not None and -max_w < dev_min:
|
||||
problems.append(
|
||||
f"max_w is {max_w:.0f} W, so this controller may command "
|
||||
f"{-max_w:.0f} W, but {opts.get('setpoint_entity') or 'the setpoint device'} accepts no less "
|
||||
f"than {dev_min:.0f} W - THE DEVICE WINS on the charge side. Lower "
|
||||
f"max_w to {abs(dev_min):.0f} W or less.")
|
||||
|
||||
# --- 3. a meter that can actually read -----------------------------------
|
||||
# ⚠️ Not a second copy of these rules - p1.py owns them, because
|
||||
# build_source() has to apply the identical predicates and a drifted copy
|
||||
# would be worse than no check.
|
||||
phases = _number(opts, "meter_phases", 1.0)
|
||||
max_age_s = _number(opts, "meter_max_age_s", 30.0)
|
||||
if phases is None or max_age_s is None:
|
||||
problems.append("meter_phases and meter_max_age_s must both be numbers "
|
||||
f"(got {opts.get('meter_phases')!r} / "
|
||||
f"{opts.get('meter_max_age_s')!r})")
|
||||
else:
|
||||
problems.extend(validate_meter_options(opts, int(phases), max_age_s))
|
||||
|
||||
# --- 4. the staleness window the FIRMWARE will actually enforce -----------
|
||||
# ⚠️ Only when P1 ingestion is on, and that is a real scope limit rather than
|
||||
# caution: meter_max_age_s is read by nothing but P1Ingest, and the veto it
|
||||
# collides with keys off `p1_age_s.has_state()`, which is only ever published
|
||||
# when ingestion is running (see Controller.publish()). With meter_source off
|
||||
# there is no sensor, the veto is inert, and the number governs nothing.
|
||||
#
|
||||
# Strictly greater, not >=: the firmware trips at `>= 30` and P1Ingest goes
|
||||
# stale at `> max_age_s`, so 30 against 30 has the firmware fractionally
|
||||
# stricter by one sample. That is the shipped default and the tested pair.
|
||||
# BELOW 30 is fine and deliberately not refused - the add-on then gives up
|
||||
# first, which is fail-safe and visible on its own status page. Only ABOVE is
|
||||
# the silent case, where the add-on keeps commanding into a reading the board
|
||||
# has already rejected.
|
||||
if (is_enabled(opts) and max_age_s is not None
|
||||
and max_age_s > FIRMWARE_INGEST_VETO_S):
|
||||
problems.append(
|
||||
f"meter_max_age_s is {max_age_s:.0f} s but the ESP32 firmware vetoes "
|
||||
f"any P1 sample at {FIRMWARE_INGEST_VETO_S:.0f} s and forces the "
|
||||
"layer-1 failsafe - THE FIRMWARE WINS, and it wins silently. This "
|
||||
f"add-on would treat a {max_age_s - 1:.0f} s reading as fresh and "
|
||||
"keep commanding, while the inverter sat at 0 W with a healthy-"
|
||||
"looking meter on the status page. Lower meter_max_age_s to "
|
||||
f"{FIRMWARE_INGEST_VETO_S:.0f} s or less (30 s is the tested value), "
|
||||
"or rebuild the firmware with a matching max_age_s and update "
|
||||
"validate.FIRMWARE_INGEST_VETO_S.")
|
||||
|
||||
# --- 5. a sign watchdog that can actually fire ---------------------------
|
||||
# ⚠️ Strictly `>=`, matching the predicate exactly: watchdog.py tests
|
||||
# `abs(commanded_w) > min_w`, and the arbiter holds `abs(commanded_w)` at or
|
||||
# below max_w, so threshold == max_w is already unsatisfiable. Anything
|
||||
# BELOW max_w is reachable and therefore fine - this refuses the disarmed
|
||||
# case only, not "close to the rail".
|
||||
sign_w = _number(opts, "sign_disagree_w", 300.0)
|
||||
if sign_w is None:
|
||||
problems.append(f"sign_disagree_w {opts.get('sign_disagree_w')!r} is not a number")
|
||||
elif max_w is not None and max_w > 0 and sign_w >= max_w:
|
||||
problems.append(
|
||||
f"sign_disagree_w is {sign_w:.0f} W but max_w is {max_w:.0f} W, so "
|
||||
"the SAFETY-02 sign-disagreement watchdog can never fire: the "
|
||||
"arbiter never lets a command exceed max_w, and the watchdog needs "
|
||||
f"a command over {sign_w:.0f} W. It would sit there reporting "
|
||||
"'armed' on the status page while being incapable of tripping - the "
|
||||
f"one runaway failure mode this system has actually observed. Lower "
|
||||
f"sign_disagree_w below {max_w:.0f} W (300 W is the measured "
|
||||
"default), or raise max_w.")
|
||||
|
||||
# --- 6. a maintenance charge the ceiling can actually accommodate --------
|
||||
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
|
||||
# `Claim.limit(hi=-charge_w)` against `supervised`'s `lo=-max_w`, so
|
||||
# charge_w == max_w leaves the single reachable point -max_w and works;
|
||||
# only ABOVE is `lo > hi`, arbiter clause 3, 0 W and a contradiction.
|
||||
#
|
||||
# Not scoped to maintenance_enabled, deliberately. The pair is
|
||||
# contradictory whether or not the schedule is on, and scoping it would
|
||||
# leave the trap armed for the one operator action - ticking
|
||||
# maintenance_enabled - that cannot possibly be expected to change what a
|
||||
# charge rate means.
|
||||
# ⚠️ THE FALLBACK IS config.yaml's SHIPPED VALUE, and it has to be kept
|
||||
# equal to it - that is the whole point of AC3. It is reachable only when a
|
||||
# caller hands over a dict with the key missing (sim/inject.py, a test); the
|
||||
# Supervisor always supplies every key from config.yaml, so on a real
|
||||
# install the argument is always present.
|
||||
#
|
||||
# ⚠️ KNOWN MISMATCH, LEFT DELIBERATELY AND FLAGGED RATHER THAN FIXED HERE:
|
||||
# `main.py` still falls back to 2500 in two places
|
||||
# (`opts.get("maintenance_charge_w", 2500)`), as does maintenance.py's
|
||||
# MaintConfig dataclass. So a dict with the key ABSENT is accepted by this
|
||||
# rule at 2000 and then built by main.py at 2500 - the contradiction this
|
||||
# rule exists to catch, in the one case it cannot see. It is unreachable on
|
||||
# a Supervisor install and those files are another ticket's right now.
|
||||
# THE REAL FIX is one source of truth for option defaults; until then the
|
||||
# divergence is PINNED BY A CHECK rather than by this comment - see
|
||||
# test_validate.py, "the KNOWN cross-file default divergence". It asserts
|
||||
# the mismatch as it stands and goes red if EITHER side moves, including
|
||||
# when somebody closes it in main.py, at which point this block and that
|
||||
# check both get deleted.
|
||||
charge_w = _number(opts, "maintenance_charge_w", 2000.0)
|
||||
if charge_w is None:
|
||||
problems.append(
|
||||
f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number")
|
||||
elif max_w is not None and max_w > 0 and charge_w > max_w:
|
||||
problems.append(
|
||||
f"maintenance_charge_w is {charge_w:.0f} W but max_w is "
|
||||
f"{max_w:.0f} W, so a maintenance charge in a cheap window can "
|
||||
"never happen: the shaping asks the arbiter for at most "
|
||||
f"{-charge_w:.0f} W while the supervised limit allows no less than "
|
||||
f"{-max_w:.0f} W, which is a contradiction it resolves to 0 W - "
|
||||
"THE CEILING WINS, and what you would see is an ARBITER "
|
||||
"CONTRADICTION line that reads like a bug rather than like these "
|
||||
f"two numbers. Lower maintenance_charge_w to {max_w:.0f} W or less, "
|
||||
f"or raise max_w to at least {charge_w:.0f} W.")
|
||||
|
||||
# --- 7. two price sources on one site ------------------------------------
|
||||
problems.extend(_price_problems(opts))
|
||||
|
||||
# --- warnings: real, but not worth refusing over -------------------------
|
||||
integ = opts.get("integrator_max_w")
|
||||
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
|
||||
if integ_w is not None and max_w is not None and integ_w > max_w:
|
||||
_LOG.warning(
|
||||
"integrator_max_w %.0f W is above max_w %.0f W. Not refused - the "
|
||||
"arbiter still bounds the wire to +-max_w - but the only thing the "
|
||||
"extra headroom buys is more wrong-direction power to unwind after "
|
||||
"saturation. 0 (follow max_w) is the recommended value.",
|
||||
integ_w, max_w)
|
||||
|
||||
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
|
||||
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
|
||||
# clamped by the limits above it rather than contradicted by them. So this
|
||||
# drains at max_w instead of the configured rate, the arbiter says so in
|
||||
# words on the status page every cycle, and the cycle still completes -
|
||||
# slower, and eating into maintenance.DRAIN_TIMEOUT_MIN. Loud and it moves
|
||||
# energy, so it is neither (a) nor (b): warned, not refused.
|
||||
disch_w = _number(opts, "maintenance_discharge_w", 2000.0)
|
||||
if disch_w is not None and max_w is not None and max_w > 0 and disch_w > max_w:
|
||||
_LOG.warning(
|
||||
"maintenance_discharge_w %.0f W is above max_w %.0f W. Not refused - "
|
||||
"the drain phase SETS the setpoint and the arbiter clamps it to "
|
||||
"%.0f W and names the clamp ('limited by supervised(max_w)'), so "
|
||||
"nothing is silent and the cycle still completes. But it drains at "
|
||||
"%.0f W, not the %.0f W you configured, and the drain phase has a "
|
||||
"720 min timeout. Lower it to %.0f W or raise max_w to remove the "
|
||||
"surprise.",
|
||||
disch_w, max_w, max_w, max_w, disch_w, max_w)
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
def _price_problems(opts: dict) -> list[str]:
|
||||
"""Rule 7 (TARIFF-02): the price configuration, refused where it is untrue.
|
||||
|
||||
⚠️ NOTE WHAT IS *NOT* HERE. `price_provider: "off"` is not refused and never
|
||||
will be: §11.4 makes no-price-data a first-class supported state (static
|
||||
profile, provisional plan, no arbitrage), and an add-on that will not start
|
||||
without a price feed is an add-on that a Belgian fixed-tariff site - a large
|
||||
share of the installed base, per §9.2 - cannot run at all.
|
||||
|
||||
Four refusals and two log lines (TARIFF-03 added one refusal, one parse and
|
||||
two notices), and every refusal is still case (a) from this module's
|
||||
docstring: a source the operator SET being overridden by a layer they cannot
|
||||
see. The additions are `static_profile` alongside the Home Assistant price
|
||||
pair - the same silent-loser shape as provider-plus-pair, and a LIVE one -
|
||||
and a `static_profile` that does not tile the day, which would leave hours
|
||||
with no price at all.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
pid = str(opts.get("price_provider", "") or "off").strip() or "off"
|
||||
|
||||
if pid != "off" and pid not in PROVIDERS:
|
||||
problems.append(
|
||||
f"price_provider is {pid!r}, which is not a shipped plugin. The "
|
||||
f"ids that exist are: off, {', '.join(sorted(PROVIDERS))}. "
|
||||
"Refused rather than defaulted, per the §14 warning box - a "
|
||||
"silent fall back to 'off' would look exactly like a working "
|
||||
"provider that happens never to find a cheap hour.")
|
||||
return problems # nothing below can be judged against an unknown id
|
||||
|
||||
# ⚠️ THE HA-SIDE OVERLAP THE PM TOLD US TO DECIDE ABOUT, DECIDED HERE.
|
||||
# price_now_entity/price_avg_entity are the original delegation of pricing
|
||||
# to Home Assistant: main.py reduces them to ONE BOOLEAN, `cheap`, read by
|
||||
# the maintenance cheap-window charge floor. They are KEPT - retiring them
|
||||
# would take a live install's only price signal away - but they may not run
|
||||
# alongside another price source, because main.py reads exactly one and the
|
||||
# loser is silent. That is (a), and it is the "silently double up" the PM
|
||||
# named on 2026-08-30.
|
||||
#
|
||||
# ⚠️ THE REASON THIS REFUSAL GIVES WAS REWRITTEN BY TARIFF-03, AND THE OLD
|
||||
# WORDING IS WHY. Until TARIFF-03 this message said the configured provider
|
||||
# "would decide nothing", which was true only because nothing consumed the
|
||||
# series; test_price.py section 12 carried a tripwire that went red the
|
||||
# moment a consumer landed, with instructions to fix the wording here in the
|
||||
# same commit. It landed, it went red, and this is that fix. What is true
|
||||
# NOW, stated exactly and no wider:
|
||||
#
|
||||
# * app/static_tariff.py consumes load_prices(). resolve_tariff() turns a
|
||||
# provider series into a TariffPlan, a cheap-window flag and an
|
||||
# arbitrage verdict, so the seam is no longer inert.
|
||||
# * main.py reads the STATIC side of that module every cycle
|
||||
# (static_status()), which makes static_profile a real writer of `cheap`.
|
||||
# * main.py does NOT yet call resolve_tariff() on the control tick, and
|
||||
# that is deliberate: a provider fetch is network I/O and the tick is
|
||||
# what feeds the ESP32 heartbeat. So a configured PROVIDER still shapes
|
||||
# no energy decision on its own; a configured STATIC PROFILE does.
|
||||
#
|
||||
# Hence two refusals below rather than one, and neither claims more than
|
||||
# that list. When OPT-04 puts resolve_tariff() on a background refresh, the
|
||||
# first message's middle clause is what changes - not the rule.
|
||||
ha_price = [k for k in ("price_now_entity", "price_avg_entity")
|
||||
if str(opts.get(k, "") or "").strip()]
|
||||
if pid != "off" and ha_price:
|
||||
problems.append(
|
||||
f"price_provider is {pid!r} AND {' and '.join(ha_price)} "
|
||||
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
|
||||
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
|
||||
"what main.py reduces to the `cheap` flag the maintenance cycle "
|
||||
"reads. The provider series IS consumed now - app/static_tariff.py "
|
||||
"builds a tariff plan and an arbitrage verdict from it - but it is "
|
||||
"not what main.py acts on, so the supplier you configured would "
|
||||
"shape no energy decision while the status page named it. Clear "
|
||||
"price_now_entity and price_avg_entity to use the "
|
||||
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
|
||||
"Home Assistant sensors.")
|
||||
|
||||
# ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE
|
||||
# CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap`
|
||||
# from the Home Assistant pair when it is set and from the static profile
|
||||
# otherwise - one flag, first writer wins, and the operator who filled in a
|
||||
# day/night tariff has no way to see that it lost.
|
||||
if ha_price and str(opts.get("static_profile", "") or "").strip():
|
||||
problems.append(
|
||||
f"static_profile is set AND {' and '.join(ha_price)} "
|
||||
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two "
|
||||
"tariff sources feeding one cheap-window flag. THE HOME ASSISTANT "
|
||||
"ENTITIES WIN: main.py reads them first and never looks at the "
|
||||
"static profile, so the day/night tariff you typed would decide "
|
||||
"nothing and nothing would say so. Clear price_now_entity and "
|
||||
"price_avg_entity to use static_profile, or clear static_profile "
|
||||
"to keep the Home Assistant sensors.")
|
||||
|
||||
# ⚠️ THE PROFILE ITSELF, PARSED AT STARTUP RATHER THAN AT 02:00. A profile
|
||||
# that does not tile the day has hours with no price, and the only ways to
|
||||
# serve those are to invent a number or to fail at the moment the clock
|
||||
# reaches them. §14's warning box - refuse to start on a mismatch rather
|
||||
# than clamp quietly - is the same instruction one level down.
|
||||
raw_profile = opts.get("static_profile", "")
|
||||
if str(raw_profile or "").strip() or isinstance(raw_profile, (list, dict)):
|
||||
try:
|
||||
profile = parse_static_profile(raw_profile)
|
||||
except ProfileInvalid as e:
|
||||
profile = None
|
||||
problems.append(f"static_profile cannot be used: {e}")
|
||||
if profile is not None and profile.product is not None \
|
||||
and len(set(profile.prices)) == 1:
|
||||
_LOG.info(
|
||||
"static_profile carries one distinct price (%.4f EUR/kWh "
|
||||
"all-in), so this site is priced as FULLY FIXED. Arbitrage is "
|
||||
"off by arithmetic - a flat series has no spread - and the "
|
||||
"add-on runs self-consumption only, which is §9.2's handling "
|
||||
"for this product. Peak shaving, reserve management and the "
|
||||
"maintenance cycle are unaffected.", profile.prices[0])
|
||||
|
||||
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
||||
# site with no price data falls back to the static profile; a dynamic site
|
||||
# with none configured has nothing to fall back to when its feed dies. That
|
||||
# is a real gap worth naming - but refusing to start over it would break
|
||||
# every install TARIFF-02 shipped, none of which could have had one.
|
||||
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
|
||||
_LOG.warning(
|
||||
"price_provider is %r but static_profile is empty. §11.4's "
|
||||
"degradation row is 'no price data -> fall back to the static "
|
||||
"profile'; with none configured, a feed outage leaves this site "
|
||||
"with no tariff at all rather than with a coarse one. Not refused, "
|
||||
"but filling it in is what makes that row work.", pid)
|
||||
|
||||
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
|
||||
problems.append(
|
||||
"price_provider is 'manual_csv' but price_csv_path is empty, so "
|
||||
"the offline fallback has no file to read. Every fetch would "
|
||||
"return no-price-data with a filesystem error as its reason - "
|
||||
"which is §11.4 behaviour arrived at by accident rather than by "
|
||||
"configuration. Point it at the CSV, or set price_provider to "
|
||||
"'off', which says the same thing on purpose.")
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
def _number(opts: dict, key, default):
|
||||
raw = opts.get(key, default)
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def refuse(problems: list[str]) -> None:
|
||||
"""Log a refusal an operator can tell apart from a crash. Does not raise.
|
||||
|
||||
⚠️ THE POINT OF THE BANNER. "You configured this wrong" must not read like
|
||||
"it broke". A Python traceback in an add-on log means the same thing to most
|
||||
operators as a segfault - file a bug - whereas this block names the keys,
|
||||
names the fix, and says in words that nothing is broken. main.py catches
|
||||
ConfigRefused and exits 78 (sysexits EX_CONFIG), so no traceback is printed
|
||||
at all and even the exit status distinguishes the two.
|
||||
"""
|
||||
_LOG.error("=" * 72)
|
||||
_LOG.error("REFUSING TO START: the add-on configuration is not usable.")
|
||||
_LOG.error("This is a CONFIGURATION problem, not a crash. Nothing is broken;")
|
||||
_LOG.error("the add-on will start as soon as the settings below are fixed.")
|
||||
_LOG.error("Fix them in Settings > Add-ons > GoodWe RS485 Controller >")
|
||||
_LOG.error("Configuration, then start the add-on again.")
|
||||
_LOG.error("-" * 72)
|
||||
for i, problem in enumerate(problems, 1):
|
||||
_LOG.error(" %d. %s", i, problem)
|
||||
_LOG.error("=" * 72)
|
||||
@@ -1,354 +0,0 @@
|
||||
"""SAFETY-02 - the sign-disagreement watchdog.
|
||||
|
||||
WHAT IT CATCHES, in one sentence: a grid-following loop that is pushing power in
|
||||
the wrong direction, which every other guard in this system is blind to.
|
||||
|
||||
The stale-input watchdog (SAFETY-01) asks "is the meter still talking?". The
|
||||
saturation freeze asks "can the inverter follow?". The output clamp asks "is the
|
||||
number too big?". A loop can pass all three and still be charging the battery
|
||||
out of the grid while the house imports - the meter is fresh, the inverter is
|
||||
tracking beautifully, and the magnitude is inside the rating. The only thing
|
||||
that separates that from healthy operation is the SIGN.
|
||||
|
||||
⚠️ THIS IS NOT A HYPOTHETICAL. On 2026-08-20 the vendor controller (Illusmart)
|
||||
did exactly this four times in one day (NOTES.md §4.7). Both captures of that
|
||||
bus are committed - `sim/scenarios/bus-2026-08-20.log.gz` and the healthy
|
||||
`bus-2026-08-23.log.gz` in the infra repo - and `sim/replay_sign.py` drives this
|
||||
module over both of them. Every number in this file was measured on those two
|
||||
days, not chosen:
|
||||
|
||||
day samples gated in sign agreement
|
||||
2026-08-23 4 212 / 17 280 93.4 % (healthy)
|
||||
2026-08-20 7 182 / 17 280 56.4 % (the whole fault day)
|
||||
cluster A 15:26-16:43 0.3 %
|
||||
cluster B 17:11-18:28 0.9 %
|
||||
cluster C 20:01-20:31 10.5 %
|
||||
cluster D 20:55-21:11 0.0 %
|
||||
|
||||
⚠️ THOSE AGREEMENT FIGURES ARE THE WEAKEST EVIDENCE ON THIS PAGE, and an earlier
|
||||
version of this comment leaned on them as though they were the strongest. The
|
||||
clusters are found by looking for sustained opposed samples, so measuring "how
|
||||
opposed is this cluster" over them is close to a tautology - and "the healthy day
|
||||
contains no such cluster" is the same sentence as "the healthy day produces no
|
||||
trip", not independent corroboration of it.
|
||||
|
||||
What actually carries the argument is two things neither the detector nor the
|
||||
clustering assumes:
|
||||
|
||||
1. The RUN-LENGTH DISTRIBUTION. The longest unbroken opposed run on the healthy
|
||||
day is 30 s. On the fault day there are 18 runs over 180 s and the longest is
|
||||
1750 s. Nothing about that gap was chosen - it is what the two days measure.
|
||||
2. The clusters land where a human independently wrote down onsets. NOTES.md
|
||||
§4.7 records 15:01, 16:56, 19:57 and 20:23 from watching it happen, before
|
||||
any of this existed, and cluster D coincides with the separately documented
|
||||
inverter-off runaway of §4.7 item 2.
|
||||
|
||||
Be precise about (2), because the convenient overstatement is available and wrong:
|
||||
the clustering does NOT reproduce four episodes. It yields THREE clusters over the
|
||||
four documented onsets - C spans both 19:57 and 20:23 - plus a fourth for §4.7
|
||||
item 2, which is not one of the four. Every trip does fall inside a documented
|
||||
window, which is the claim worth making.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHY IT LIVES HERE AND NOT IN THE FIRMWARE OR THE WITNESS
|
||||
|
||||
Three vantage points see three different things. The firmware knows what it
|
||||
WROTE, the witness Pi knows what was ON THE WIRE and whether the inverter ACKed
|
||||
it, and the add-on knows what it INTENDED and holds the authoritative meter.
|
||||
|
||||
This check is not command-versus-wire; it is command-versus-physics. It needs
|
||||
the emitted setpoint and the P1 net figure in the same place, and it needs the
|
||||
authority to command zero when they fight:
|
||||
|
||||
- the witness is read-only by charter and cannot command anything, so it could
|
||||
raise the alarm but never take the trip action the AC requires;
|
||||
- the firmware has the wire but not the meter. §5.1 makes P1 the only
|
||||
authoritative grid figure and the board only ever receives its AGE
|
||||
(`sensor.goodwe_p1_sample_age`), never its value. Putting the check there
|
||||
would mean shipping the meter to the ESP32 for no other reason, and it would
|
||||
be C++ per install rather than one generic guard;
|
||||
- the add-on has both halves already, plus an arbiter that can outrank every
|
||||
strategy with a P_SAFETY claim.
|
||||
|
||||
The cost of standing here is real and worth stating: this watchdog compares what
|
||||
we MEANT to put on the wire against the meter. If the firmware or the driver
|
||||
inverts the sign on the way out, the add-on's own view still agrees with itself
|
||||
and this check stays silent. That gap is a wire-versus-intent problem and it
|
||||
belongs to the witness, not here.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHAT "DISAGREEMENT" MEANS, PRECISELY
|
||||
|
||||
disagree = following
|
||||
and commanded_w * grid_w < 0
|
||||
and abs(commanded_w) > min_w
|
||||
and abs(grid_w) > min_w
|
||||
|
||||
Four gates, and each one exists to exclude a specific thing that is NOT a fault:
|
||||
|
||||
1. `following`. ⚠️ THE GATE THAT IS EASIEST TO FORGET AND MOST EXPENSIVE TO
|
||||
OMIT. Sign agreement is a property of a GRID-FOLLOWING loop. It is not
|
||||
expected of a strategy that deliberately commands against the meter -
|
||||
maintenance is exactly that. A maintenance grid charge of 1500 W into a house
|
||||
drawing 500 W puts +2000 W on the meter against a -1500 W command: opposed,
|
||||
both far over any sane threshold, and sustained for HOURS. Armed
|
||||
unconditionally this watchdog would trip on every single maintenance cycle,
|
||||
which is not a tuning problem, it is the wrong question being asked. So the
|
||||
caller says whether the grid-following loop actually won the arbitration this
|
||||
cycle, and the timer only accumulates when it did.
|
||||
|
||||
⚠️ 1500 W, NOT THE DEFAULT 2500 W, AND THE REASON IS NOT COSMETIC. At the
|
||||
shipped defaults `maintenance_charge_w` is 2500 and `max_w` is 2000, so the
|
||||
cheap-window claim is `hi = -2500` against a supervised `lo = -2000`: lo > hi,
|
||||
which arbiter.py resolves as a CONTRADICTION and 0 W, not as a -2500 W charge.
|
||||
The example above is written at 1500 W because that is a combination the
|
||||
schema and the arbiter both actually produce. (The contradiction at the
|
||||
defaults is a real pre-existing bug - a default maintenance charge silently
|
||||
never happens in a cheap window - but it is maintenance's, not this module's,
|
||||
and it is filed separately. Do not "fix" it here.)
|
||||
|
||||
2. The strict `< 0` product. "Opposed" and "neither side is zero" in one
|
||||
expression.
|
||||
|
||||
3. The 300 W magnitude gate ON BOTH SIDES. Near zero the sign is noise, and near
|
||||
zero is where a healthy loop LIVES - the deadband holds the command still
|
||||
while the meter wanders either side of `target_grid_w`. On the healthy day
|
||||
only 4 212 of 17 280 samples (24 %) are even gated in; the other 76 % are the
|
||||
converged band where sign carries no information. This is also why the
|
||||
deadband cannot produce a false trip: inside it the meter is by definition
|
||||
under 300 W.
|
||||
|
||||
4. The 180 s continuity requirement. This is what excludes the legitimate
|
||||
transient. A load step reverses the meter's sign instantly while the command
|
||||
is still where the last cycle left it; slew limiting then needs a few cycles
|
||||
to cross over. Measured on the healthy day, the LONGEST such crossing is 30 s
|
||||
- and that is with a vendor controller far cruder than ours. 180 s is six
|
||||
times that. It is not a fitted threshold: at 60 s the healthy day still
|
||||
produces zero trips, and at 300 s the fault day still produces 13.
|
||||
|
||||
⚠️ A BATTERY THAT REFUSES A COMMAND CANNOT TRIP THIS, and that is structural
|
||||
rather than lucky. At SoC 10 % in Standby, a 1350 W discharge command into a
|
||||
1350 W import is DECLINED: the battery does nothing, so the meter stays at
|
||||
+1350 W and the command stays at +1350 W. Same sign. Agreement. A refusal leaves
|
||||
the meter where it was; only the inverter ACTING moves it, and acting in the
|
||||
commanded direction moves it toward zero, not through it. The failure mode this
|
||||
check keys on is the opposite of a refusal - it is the inverter obediently doing
|
||||
a wrong thing.
|
||||
|
||||
⚠️ A FROZEN METER cannot trip it either, for the same reason read the other way:
|
||||
a stuck reading of +800 W makes the loop command discharge and keep commanding
|
||||
discharge. Command and meter converge on AGREEING. Freezes are SAFETY-12's and
|
||||
SAFETY-01's problem, and this watchdog neither helps nor hinders there.
|
||||
|
||||
⚠️ ALL OF THAT DEPENDS ON ONE PROPERTY OF THE CONTROL LAW, so it is an invariant
|
||||
and not a happy accident: the saturation freeze in control.py must keep letting
|
||||
the command FALL, CROSS ZERO AND REVERSE - "may not wind further in the direction
|
||||
it is already pushing", not "only corrections that shrink |i_w|". control.py
|
||||
records the shrink-only encoding as tried and rejected because it pins the command
|
||||
at its last value indefinitely; a pinned command against a meter that has moved on
|
||||
is precisely a sustained sign disagreement, so under that encoding this watchdog
|
||||
WOULD false-trip on a plain load step. If anyone ever revisits that freeze, this
|
||||
module's no-false-positive argument is downstream of the answer.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
WHAT IT DOES NOT CATCH
|
||||
|
||||
An inverter that ACTS opposite to what it was told. Tell it to discharge 2000 W
|
||||
and have it charge instead, and the meter moves FURTHER the way it was already
|
||||
going - so the command and the meter agree, all the way into the rail. This
|
||||
watchdog compares the loop's output direction against the input state it was
|
||||
regulating; it is blind to the plant lying about which way it went. That is the
|
||||
saturation freeze's question (|command - the battery's actual power| > 500 W,
|
||||
NOTES.md §4.7 row 4), and the two checks are complementary rather than
|
||||
overlapping. Neither substitutes for the other.
|
||||
|
||||
Nor does it see a sign inversion introduced DOWNSTREAM of this add-on - in the
|
||||
firmware, on the wire, or in a future driver. Our view stays self-consistent and
|
||||
this stays silent. That is a wire-versus-intent problem and it belongs to the
|
||||
witness, which is the only vantage point that sees both.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
STATE, TRIP AND LATCH
|
||||
|
||||
One timestamp, not a sample counter. The cycle rate is not fixed - a meter
|
||||
arrival, or `cycle_floor_s` elapsing, whichever comes first - so N samples is a
|
||||
different wall-clock window on every path, and the AC is written in minutes.
|
||||
|
||||
The latch is not optional and not a nicety. Commanding 0 W removes the command
|
||||
side of the comparison (|0| is not > 300), so the disagreement disappears the
|
||||
instant the trip works. Un-latched, this watchdog would trip, clear itself, and
|
||||
hand the actuator straight back to the loop that was driving the wrong way -
|
||||
three minutes later, forever. `tripped` therefore stays true until an operator
|
||||
`reset()`s it by starting control again, which is a deliberate human act after
|
||||
looking at why.
|
||||
|
||||
⚠️ AND THE LATCH HAS TO OUTLIVE THE PROCESS. Held only in memory it is not a
|
||||
latch at all - a container restart clears it and the add-on comes back armed,
|
||||
into the same fault, unattended. Worse than merely losing state: "restart the
|
||||
add-on" is the first thing an operator confronted with a FAILSAFE banner and a
|
||||
0 W inverter will try, and under a supervisor that restarts a crashed add-on it
|
||||
is a loop of three-minute wrong-direction bursts. `tripped` and `reason` are
|
||||
therefore constructor arguments, restored from `Store` in `Controller.__init__`
|
||||
and written back the moment the trip happens. See store.py.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SignWatchdogConfig:
|
||||
"""Defaults, not constants - config.yaml carries both (SAFETY-02 AC 5)."""
|
||||
|
||||
min_w: float = 300.0
|
||||
hold_s: float = 180.0
|
||||
|
||||
|
||||
def disagrees(commanded_w, grid_w, following: bool,
|
||||
cfg: SignWatchdogConfig) -> bool:
|
||||
"""The predicate, and the ONLY place it is written down.
|
||||
|
||||
⚠️ Public because `sim/replay_sign.py` needs it too - to describe episodes, to
|
||||
measure agreement, to sweep the clock-skew guard - and it had reimplemented it
|
||||
inline. Two copies of a safety predicate is one copy plus a future
|
||||
disagreement about what the check means, and the copy that drifts is always
|
||||
the one in the harness that grades it.
|
||||
"""
|
||||
return (following
|
||||
and commanded_w is not None and grid_w is not None
|
||||
and commanded_w * grid_w < 0
|
||||
and abs(commanded_w) > cfg.min_w
|
||||
and abs(grid_w) > cfg.min_w)
|
||||
|
||||
|
||||
def loop_is_following(resolution, loop_asked) -> bool:
|
||||
"""Did the grid-following loop actually choose the number going on the wire?
|
||||
|
||||
Gate 1 of the predicate above, and it lives here rather than inline in
|
||||
`cycle()` for the reason `disagrees()` does: the test suite has to be able to
|
||||
assert on THE SHIPPED EXPRESSION, not on a transcription of it that drifts
|
||||
apart from it in silence.
|
||||
|
||||
⚠️ IT WAS A TRANSCRIPTION, AND THAT WAS MEASURED, NOT FEARED. The P_PEAK case
|
||||
in test_watchdog.py exists to make whoever lands peak shaving come here and
|
||||
change an expectation. With the expression copied into the test it did not do
|
||||
that: editing the shipped gate alone to `winner in ("loop", "peak")` left every
|
||||
suite green while the watchdog went blind for the whole of every peak event.
|
||||
One definition, two callers, and that test now bites.
|
||||
|
||||
`resolution` the arbiter's Resolution. Duck-typed on `.winner` and
|
||||
`.target_w` so this module still imports nothing from arbiter.
|
||||
`loop_asked` what the grid-following loop claimed this cycle, or None if it
|
||||
did not claim at all.
|
||||
|
||||
"The loop won" is NOT the question, and the difference is a whole class of
|
||||
false trip. A LIMIT claim can survive the loop winning and still replace its
|
||||
number: maintenance's cheap-window shaping is `hi = -maintenance_charge_w`, so
|
||||
at 1500 W the loop can ask for +500 W of discharge, win the arbitration, and
|
||||
have the emitted value come out at -1500 W of grid charge. Winner says "loop";
|
||||
the wire carries maintenance's intent.
|
||||
|
||||
So the test is on the NUMBER: the loop is following only while the emitted
|
||||
value points the way the loop asked AND is no larger than it asked - limits
|
||||
reduced it, they did not redirect it. The magnitude half is load-bearing on
|
||||
its own, because a `lo` limit can raise magnitude without flipping sign, and
|
||||
that is not the loop following either. Zero on either side falls out as
|
||||
not-following, which costs nothing: a 0 W command cannot be a
|
||||
wrong-direction command.
|
||||
|
||||
⚠️ FORWARD HAZARD, and the claim block in cycle() already anticipates it.
|
||||
`winner == "loop"` disarms the watchdog under ANY higher-priority SET claim.
|
||||
Today only maintenance and the safety stops emit one and neither follows the
|
||||
meter, so the gate costs nothing. A peak-shaving strategy claiming SET at
|
||||
P_PEAK **is** grid-following in sign, and the moment it lands this goes blind
|
||||
for the duration of every peak event. Widening it means asking "does the
|
||||
winning strategy follow the meter", not adding another name to a comparison.
|
||||
"""
|
||||
return (resolution.winner == "loop" and loop_asked is not None
|
||||
and resolution.target_w * loop_asked > 0
|
||||
and abs(resolution.target_w) <= abs(loop_asked))
|
||||
|
||||
|
||||
class SignWatchdog:
|
||||
"""One instance per actuator, evaluated once per control cycle.
|
||||
|
||||
⚠️ NOT SHARED ACROSS CADENCES. `disagree_since` is a wall-clock start time,
|
||||
so driving one instance from two loops at two rates would have each one
|
||||
resetting the other's window. If a fast on-device path ever wants this
|
||||
check, it gets its own instance - the same exactly-one-writer discipline the
|
||||
arbiter applies to the actuator, applied to the detector's own state.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: SignWatchdogConfig | None = None, *,
|
||||
tripped: bool = False, reason: str = ""):
|
||||
self.cfg = cfg or SignWatchdogConfig()
|
||||
self.disagree_since: datetime | None = None
|
||||
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED. A latch that lives only in
|
||||
# memory is not a latch: a container restart clears it, and the add-on
|
||||
# comes back armed, into the same fault, with no operator involved. See
|
||||
# store.py's note on `sign_tripped` for the measured behaviour.
|
||||
self.tripped = bool(tripped)
|
||||
self.reason = reason if self.tripped else ""
|
||||
|
||||
# -- the whole check, and it is a pure function of its arguments ----------
|
||||
def evaluate(self, commanded_w, grid_w, following: bool,
|
||||
now: datetime) -> bool:
|
||||
"""Advance the detector one cycle. Returns True while tripped.
|
||||
|
||||
`commanded_w` the EMITTED setpoint - what is standing on the wire now,
|
||||
after deadband, slew, quantisation and the arbiter. Not
|
||||
the optimizer's raw wish: the wire carries the emitted
|
||||
value, so anything else can disagree with the meter for a
|
||||
reason that is not a fault.
|
||||
`grid_w` net P1, + = importing. None when the meter is stale or
|
||||
missing, which CLEARS the timer rather than accumulating
|
||||
- a frozen figure held against a moving command would
|
||||
otherwise manufacture a disagreement in about three
|
||||
minutes, on top of SAFETY-01 having already commanded
|
||||
zero for the same cause.
|
||||
`following` did the grid-following loop actually win this cycle? See
|
||||
gate 1 in the module docstring.
|
||||
`now` the caller's clock, so this stays testable and replayable.
|
||||
"""
|
||||
if self.tripped:
|
||||
# Latched. Do not re-evaluate: the trip removed the evidence.
|
||||
return True
|
||||
|
||||
cfg = self.cfg
|
||||
if not disagrees(commanded_w, grid_w, following, cfg):
|
||||
self.disagree_since = None
|
||||
return False
|
||||
|
||||
if self.disagree_since is None:
|
||||
self.disagree_since = now
|
||||
return False
|
||||
|
||||
held = (now - self.disagree_since).total_seconds()
|
||||
if held < cfg.hold_s:
|
||||
return False
|
||||
|
||||
self.tripped = True
|
||||
self.reason = (
|
||||
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against a meter "
|
||||
f"reading {grid_w:+.0f} W for {held:.0f} s continuously "
|
||||
f"(limits: both over {cfg.min_w:.0f} W, held over {cfg.hold_s:.0f} s)"
|
||||
)
|
||||
return True
|
||||
|
||||
def held_s(self, now: datetime) -> float:
|
||||
"""How long the current disagreement has been standing, for the UI.
|
||||
|
||||
⚠️ Takes the clock rather than reading one. It was a property calling
|
||||
`datetime.now()`, which quietly broke the property this module's header
|
||||
advertises - every other observable here is a pure function of what it
|
||||
was handed - and an unclocked function is one nothing can test, which is
|
||||
exactly what happened to it.
|
||||
"""
|
||||
if self.disagree_since is None:
|
||||
return 0.0
|
||||
return (now - self.disagree_since).total_seconds()
|
||||
|
||||
def reset(self) -> None:
|
||||
self.disagree_since = None
|
||||
self.tripped = False
|
||||
self.reason = ""
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.5.2"
|
||||
version: "0.2.1"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -42,24 +42,11 @@ options:
|
||||
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
|
||||
# `off` keeps the original single meter_entity path above, so an existing
|
||||
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
|
||||
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below;
|
||||
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
|
||||
# HomeWizard P1 publishes and what ha_dsmr cannot consume.
|
||||
# homewizard_local skips Home Assistant entirely and polls the meter's own
|
||||
# local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when
|
||||
# the meter REPORTED rather than when the value last CHANGED - HA emits
|
||||
# nothing at all for a repeated reading - so it is the only mode a firmware
|
||||
# watchdog may threshold. See DOCS.md.
|
||||
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below.
|
||||
meter_source: "off"
|
||||
meter_phases: 1
|
||||
meter_max_age_s: 30
|
||||
meter_mqtt_topic: ""
|
||||
# homewizard_local only. The meter's own address, `host` or `host:port`.
|
||||
p1_host: ""
|
||||
# homewizard_local only. Seconds between polls. Must be well under
|
||||
# meter_max_age_s - the age is never fresher than this interval - and there is
|
||||
# nothing to gain below the meter's own ~5.0 s update rate (NOTES.md:640).
|
||||
meter_poll_s: 5
|
||||
# The two UNSIGNED Belgian registers. The EMS derives net power from them
|
||||
# (import - export); do NOT point these at a signed template sensor.
|
||||
p1_import_entity: ""
|
||||
@@ -68,15 +55,6 @@ options:
|
||||
# three-phase connection; the list length must equal meter_phases.
|
||||
p1_phase_import_entities: []
|
||||
p1_phase_export_entities: []
|
||||
# ha_signed only. ONE signed net-power sensor: positive = import from the
|
||||
# grid, negative = export to it. Do NOT split it into two template sensors -
|
||||
# the split is done in the add-on (p1.split_signed) precisely so the sign
|
||||
# convention is tested rather than living in unreviewed YAML.
|
||||
p1_net_entity: ""
|
||||
# Optional, in L1..L3 order, each one signed the same way. Same role as
|
||||
# p1_phase_import_entities: the capacity-tariff peak on a three-phase
|
||||
# connection. The list length must equal meter_phases.
|
||||
p1_phase_net_entities: []
|
||||
|
||||
# --- control ---------------------------------------------------------------
|
||||
max_w: 2000
|
||||
@@ -90,71 +68,14 @@ options:
|
||||
integrator_max_w: 0
|
||||
heartbeat_s: 10
|
||||
stale_input_s: 15
|
||||
cycle_floor_s: 15
|
||||
auto_start: false
|
||||
|
||||
# --- SAFETY-02 sign-disagreement watchdog ----------------------------------
|
||||
# Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways,
|
||||
# both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and
|
||||
# a failsafe that latches until an operator starts control again.
|
||||
#
|
||||
# Both defaults are measured, not chosen. On the captured healthy day
|
||||
# (2026-08-23) the longest legitimate opposed run - a load step the loop has
|
||||
# not caught up with - is 30 s, and sign agreement over the gated samples is
|
||||
# 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 %
|
||||
# agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of
|
||||
# a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the
|
||||
# fault day still trips 13 times. There is no reason to move either number
|
||||
# without a capture that says so. They are options because the AC says
|
||||
# thresholds are defaults rather than constants - not because they are knobs
|
||||
# anyone is expected to turn.
|
||||
#
|
||||
# ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the
|
||||
# arbiter bounds the command to +-max_w). validate.py refuses that config.
|
||||
sign_disagree_w: 300
|
||||
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_enabled: false
|
||||
maintenance_interval_days: 28
|
||||
maintenance_start_hour: 10
|
||||
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
|
||||
# than the 2500 they shipped as. The cheap-window charge shaping asks the
|
||||
# arbiter for `hi = -maintenance_charge_w` against the supervised `lo =
|
||||
# -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to
|
||||
# 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a
|
||||
# default install. validate.py refuses that config (rule 6), so these
|
||||
# defaults had to become self-consistent with the max_w above rather than
|
||||
# ship a configuration the validator rejects.
|
||||
#
|
||||
# The discharge side is clamped rather than contradicted - it drains at max_w
|
||||
# and the arbiter says so - so it is only warned about; it is 2000 here for
|
||||
# symmetry and to keep the shipped config warning-free.
|
||||
#
|
||||
# Raising max_w (the live reference install runs 5000) is what unlocks a
|
||||
# faster maintenance cycle. Raise max_w FIRST, then these.
|
||||
maintenance_discharge_w: 2000
|
||||
maintenance_charge_w: 2000
|
||||
maintenance_discharge_w: 2500
|
||||
maintenance_charge_w: 2500
|
||||
maintenance_soc_floor: 11
|
||||
maintenance_soc_target: 99
|
||||
maintenance_hold_min: 120
|
||||
@@ -162,84 +83,9 @@ options:
|
||||
# --- tariff / capacity tariff (all optional) ------------------------------
|
||||
peak_forecast_entity: ""
|
||||
peak_cap_w: 3500
|
||||
|
||||
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
|
||||
# add-on delegating the whole price question to Home Assistant: main.py reads
|
||||
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
|
||||
# nothing except the maintenance cycle's cheap-window charge floor. That is
|
||||
# all they have ever done. They are kept, because retiring them would take
|
||||
# the maintenance cycle's only price signal away on a live install before
|
||||
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
|
||||
# are the cards that replace it, and neither has landed.
|
||||
#
|
||||
# ⚠️ They may NOT be combined with price_provider below. Two price sources on
|
||||
# one site, one of which silently wins, is exactly the "double up" the PM
|
||||
# named; validate.py rule 7 refuses that configuration at startup rather than
|
||||
# letting an operator believe the provider they configured is driving
|
||||
# something. See app/validate.py.
|
||||
price_now_entity: ""
|
||||
price_avg_entity: ""
|
||||
|
||||
# --- price providers (TARIFF-02, specs §9.3 / §14 `tariffs:`) --------------
|
||||
# `off` is the shipped default and means the §11.4 no-price-data row applies:
|
||||
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
|
||||
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
|
||||
# validates and publishes prices and changes no energy decision.
|
||||
#
|
||||
# ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's
|
||||
# PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two
|
||||
# agree key for key. Do not add a third copy in main.py - CFG-03 is open
|
||||
# because that has already happened three times.
|
||||
#
|
||||
# markup/taxes are the §14 `tariffs.energy_price.*` figures and are added by
|
||||
# the FRAMEWORK, only for the components the chosen plugin declares its feed
|
||||
# does not already contain. engie_dynamic, totalenergies_dynamic, eneco_eddy
|
||||
# and manual_csv publish a figure that already carries the markup, so setting
|
||||
# it under those is ignored - and logged as ignored, never applied twice.
|
||||
price_provider: "off"
|
||||
price_markup_eur_kwh: 0.03
|
||||
price_taxes_eur_kwh: 0.06
|
||||
price_csv_path: "/share/goodwe_prices.csv"
|
||||
price_feed_url: ""
|
||||
price_horizon_h: 48
|
||||
price_timeout_s: 10
|
||||
|
||||
# --- static / fully-fixed tariffs (TARIFF-03, specs §9.2 / §11.4) ----------
|
||||
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
|
||||
# of the installed base is on [a static profile]" - this is what makes those
|
||||
# sites first-class rather than "price_provider: off, good luck".
|
||||
#
|
||||
# A JSON object, on one line, whose periods must tile every day type exactly
|
||||
# once - no gap and no overlap. A gap is REFUSED at startup rather than
|
||||
# defaulted, because an invented price is one an optimiser would trade on.
|
||||
# A fully-fixed tariff is ONE period covering 00:00-24:00, all days; the
|
||||
# add-on derives "fixed" from there being one distinct price, so there is no
|
||||
# box to tick and none to tick wrongly.
|
||||
#
|
||||
# {"periods": [
|
||||
# {"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
|
||||
# {"name":"night","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
|
||||
# {"name":"night","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
|
||||
#
|
||||
# ⚠️ A window written "22:00-07:00 mon-fri" does NOT cover Saturday morning -
|
||||
# a period applies on the day the CLOCK reads, so the weekend row above is
|
||||
# required, not decorative. See DOCS.md.
|
||||
#
|
||||
# eur_per_kwh is ALL-IN (energy + markup + taxes, §9.3), the same basis the
|
||||
# dynamic providers are assembled onto. price_markup_eur_kwh and
|
||||
# price_taxes_eur_kwh are NOT added to it.
|
||||
#
|
||||
# ⚠️ RECOMMENDED EVEN ON A DYNAMIC SITE. §11.4's degradation row is "no price
|
||||
# data -> fall back to the static profile"; with this empty there is nothing
|
||||
# to fall back to and a dead feed leaves the site with no tariff at all.
|
||||
# validate.py warns about exactly that.
|
||||
static_profile: ""
|
||||
|
||||
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
|
||||
# which by itself strongly favours self-consumption over export. A scalar, not
|
||||
# a second series: no shipped supplier publishes a dynamic injection curve.
|
||||
export_price_eur_kwh: 0.04
|
||||
|
||||
# --- site ------------------------------------------------------------------
|
||||
estop_fitted: false
|
||||
log_level: info
|
||||
@@ -252,25 +98,20 @@ schema:
|
||||
batt_invert: bool
|
||||
setpoint_entity: str
|
||||
|
||||
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
|
||||
meter_source: list(off|ha_dsmr|mqtt_p1)
|
||||
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
|
||||
# telegram whose phase count disagrees is rejected at ingest and logged, so a
|
||||
# mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a
|
||||
# quietly wrong number.
|
||||
meter_phases: int(1,3)
|
||||
meter_max_age_s: int(5,300)
|
||||
meter_poll_s: int(1,60)
|
||||
meter_mqtt_topic: str?
|
||||
p1_host: str?
|
||||
p1_import_entity: str?
|
||||
p1_export_entity: str?
|
||||
p1_phase_import_entities:
|
||||
- str
|
||||
p1_phase_export_entities:
|
||||
- str
|
||||
p1_net_entity: str?
|
||||
p1_phase_net_entities:
|
||||
- str
|
||||
|
||||
max_w: int(100,5000)
|
||||
gain: float(0.05,1.0)
|
||||
@@ -283,23 +124,8 @@ schema:
|
||||
integrator_max_w: int(0,15000)
|
||||
heartbeat_s: int(2,25)
|
||||
stale_input_s: int(5,120)
|
||||
cycle_floor_s: int(5,60)
|
||||
auto_start: bool
|
||||
|
||||
# No "off" value, deliberately: this is a P0 safety check and the schema is
|
||||
# not the place to make disarming it a supported configuration.
|
||||
sign_disagree_w: int(50,5000)
|
||||
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_interval_days: int(1,90)
|
||||
maintenance_start_hour: int(0,23)
|
||||
@@ -314,28 +140,5 @@ schema:
|
||||
price_now_entity: str?
|
||||
price_avg_entity: str?
|
||||
|
||||
# ⚠️ The provider list is written out rather than left as `str`, so the
|
||||
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
|
||||
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
|
||||
# checks reach validate_startup_config() with a dict the Supervisor never saw.
|
||||
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
|
||||
# else, which is AC2.
|
||||
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
price_markup_eur_kwh: float(0,1)
|
||||
price_taxes_eur_kwh: float(0,1)
|
||||
price_csv_path: str?
|
||||
price_feed_url: str?
|
||||
price_horizon_h: int(1,72)
|
||||
price_timeout_s: int(2,60)
|
||||
# A JSON string rather than a nested schema, deliberately: it is ONE option
|
||||
# key (§14 has one), it survives config.yaml's flat options block, and
|
||||
# parse_static_profile() accepts an already-parsed dict too, so a future
|
||||
# nested schema is a config.yaml change and no code change at all.
|
||||
static_profile: str?
|
||||
# ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have
|
||||
# gone below zero (you pay to export at midday in summer), and a schema that
|
||||
# bottoms out at 0 would make that site inexpressible.
|
||||
export_price_eur_kwh: float(-1,1)
|
||||
|
||||
estop_fitted: bool
|
||||
log_level: list(trace|debug|info|warning|error)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,550 +0,0 @@
|
||||
"""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")
|
||||
@@ -24,63 +24,6 @@ def check(name, cond):
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# COVERAGE AUDIT - measured, not executed. Read this before adding a mechanism.
|
||||
#
|
||||
# THE INVARIANT: every mechanism in compute() must be noticed by AT LEAST TWO
|
||||
# checks when it is deleted. If you add a mechanism to compute(), re-run the
|
||||
# audit and add it to the table. If a figure here drops, a check has started
|
||||
# passing for a reason other than the one it names.
|
||||
#
|
||||
# THE TECHNIQUE, because there is no script to run: replace one mechanism in
|
||||
# control.py with a no-op, run this file, count the failures, restore. That is
|
||||
# the converse of the usual mutation - not "does a wrong value fail?" but "does
|
||||
# anyone notice when the mechanism is GONE?". It is kept as a comment rather
|
||||
# than as tooling on purpose: the only cheap way to automate it is to key on
|
||||
# source lines, which goes stale silently, and a green audit that has quietly
|
||||
# stopped testing anything is precisely the failure this ticket exists to fix.
|
||||
# A comment cannot go stale-green, because it never claims to be running.
|
||||
#
|
||||
# Measured at 389d9ec. Numbers are the lead's independent reproduction.
|
||||
#
|
||||
# mechanism in compute() checks that fail when deleted
|
||||
# ------------------------------------------ -----------------------------
|
||||
# integrator freeze (AC 3) 2
|
||||
# integrator clamp (AC 1) 6
|
||||
# integrator bound follows max_w 4
|
||||
# output clamp 3
|
||||
# slew limit 4
|
||||
# output freeze 2
|
||||
# deadband 5
|
||||
# quantisation 2
|
||||
# saturation detector, `saturated_now = False` 11
|
||||
# saturation duration (AC 2), fires instantly 2
|
||||
# sat counter reset on a good cycle 6
|
||||
# target_grid_w bias 3
|
||||
# i_w=None seeding from prev_w 6
|
||||
#
|
||||
# The detector figure is for the `saturated_now = False` form specifically;
|
||||
# disabling it further down as `frozen = False` is a weaker mutation and gives
|
||||
# 10. Reproduce the same form or the number will not match.
|
||||
#
|
||||
# ⚠️ IT HAS FOUND A DEAD MECHANISM TWICE, BOTH THE SAME WAY: a clamp standing in
|
||||
# for the mechanism under test. Deleting the integrator freeze once failed
|
||||
# NOTHING, because the fixtures sat at max_w 2000 and the integrator bound
|
||||
# truncated a wound value back to exactly 2000 - the assertion passed on the
|
||||
# clamp. The output clamp was masked the same way by the integrator bound.
|
||||
# Hence: A FIXTURE MUST SIT CLEAR OF EVERY RAIL IT IS NOT TESTING. Where a test
|
||||
# names one mechanism, make that mechanism the binding one (see TCLAMP and TF).
|
||||
#
|
||||
# ⚠️ RUN MUTATIONS WITH `python -B` AND CLEAR app/__pycache__. CPython
|
||||
# invalidates a .pyc on (source mtime in whole seconds, source size), so a
|
||||
# same-second rewrite that also preserves the file size reuses stale bytecode
|
||||
# and the suite reports on code you are no longer running. It under-reported one
|
||||
# mutation here as 2 where the true figure is 6. The error is one-directional -
|
||||
# stale bytecode can only under-report - so every figure above is a lower bound
|
||||
# at worst, and the two zeros ever recorded were both confirmed by fixing them
|
||||
# and watching the count rise, which a caching artefact cannot do.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
print("control law")
|
||||
|
||||
# Deadband: inside meter noise, hold exactly - do not drift.
|
||||
@@ -98,22 +41,13 @@ check("proportional step (gain 0.6)", d.target_w == 300)
|
||||
d = compute(prev_w=0, grid_w=-500, actual_w=0, tuning=T)
|
||||
check("export drives charging", d.target_w == -300)
|
||||
|
||||
# Clamp.
|
||||
# ⚠️ integrator_max_w is lifted clear of max_w so that the OUTPUT clamp is the
|
||||
# mechanism under test. Left at the default the integrator bound truncates
|
||||
# first, these two assertions pass on that alone, and deleting the output clamp
|
||||
# fails nothing - the same masking that hid the integrator freeze.
|
||||
TCLAMP = Tuning(max_w=2000, slew_w=5000, integrator_max_w=5000)
|
||||
d = compute(prev_w=1900, grid_w=1000, actual_w=1900, tuning=TCLAMP)
|
||||
# Clamp
|
||||
d = compute(prev_w=1900, grid_w=1000, actual_w=1900, tuning=Tuning(max_w=2000, slew_w=5000))
|
||||
check("clamped to max_w", d.target_w == 2000)
|
||||
|
||||
# Slew: from 0 with a huge error, no more than slew_w in one cycle.
|
||||
d = compute(prev_w=0, grid_w=5000, actual_w=0, tuning=Tuning(max_w=5000, slew_w=1000))
|
||||
check("slew limits one cycle", d.target_w == 1000)
|
||||
d = compute(prev_w=-1900, grid_w=-1000, actual_w=-1900, tuning=TCLAMP)
|
||||
check("clamped to -max_w", d.target_w == -2000)
|
||||
d = compute(prev_w=0, grid_w=-5000, actual_w=0, tuning=Tuning(max_w=5000, slew_w=1000))
|
||||
check("slew limits one cycle, charging", d.target_w == -1000)
|
||||
|
||||
# Saturation needs DURATION: one diverging cycle must NOT freeze.
|
||||
t = Tuning(saturation_w=500, saturation_cycles=3)
|
||||
@@ -152,12 +86,12 @@ RUNAWAY_CYCLES = 150
|
||||
HISTORICAL_W = 14768.0
|
||||
|
||||
|
||||
def runaway(tuning, sign=1):
|
||||
def runaway(tuning):
|
||||
"""Inverter off: it reports 0 W forever, the error never clears."""
|
||||
prev, i_w, sat = 0.0, 0.0, 0
|
||||
worst_i, worst_cmd = 0.0, 0.0
|
||||
for _ in range(RUNAWAY_CYCLES):
|
||||
d = compute(prev_w=prev, grid_w=sign * RUNAWAY_ERROR, actual_w=0.0,
|
||||
d = compute(prev_w=prev, grid_w=RUNAWAY_ERROR, actual_w=0.0,
|
||||
tuning=tuning, sat_count=sat, i_w=i_w)
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
worst_i = max(worst_i, abs(i_w))
|
||||
@@ -179,12 +113,6 @@ check(f"runaway with the detector defeated: integrator still bounded ({wi:.0f} W
|
||||
wi <= TD.max_w)
|
||||
check("runaway with the detector defeated: command still <= max_w", wc <= TD.max_w)
|
||||
|
||||
# The mirror: the same runaway driving the other way. An export that never
|
||||
# clears winds the integrator negative just as hard.
|
||||
wi, wc = runaway(Tuning(max_w=2000), sign=-1)
|
||||
check(f"runaway (export direction): integrator bounded at {wi:.0f} W", wi <= 2000)
|
||||
check("runaway (export direction): emitted command <= max_w", wc <= 2000)
|
||||
|
||||
# The bound is a separate quantity, and the useful direction is BELOW max_w:
|
||||
# there it binds first and caps unwind latency tighter than the rail does.
|
||||
d = compute(prev_w=0, grid_w=6000, actual_w=0,
|
||||
@@ -193,22 +121,11 @@ check("integrator bound binds independently of the output clamp",
|
||||
d.i_w == 1000 and d.target_w == 1000)
|
||||
|
||||
# Freeze = may not wind further in the direction it is already pushing.
|
||||
# ⚠️ max_w is raised WELL above the fixtures on purpose. At the default 2000
|
||||
# the integrator bound truncates a wound value back to exactly 2000 and
|
||||
# satisfies these assertions on its own, so deleting the freeze outright
|
||||
# failed nothing - the clamp was standing in for the mechanism under test.
|
||||
# Any fixture here must sit clear of every rail, or it tests the rail.
|
||||
TF = Tuning(saturation_w=500, saturation_cycles=3, max_w=5000)
|
||||
TF = Tuning(saturation_w=500, saturation_cycles=3)
|
||||
f1 = compute(prev_w=2000, grid_w=800, actual_w=0, tuning=TF, sat_count=3, i_w=2000.0)
|
||||
check("frozen: integration does not wind further", f1.i_w == 2000.0 and f1.frozen)
|
||||
f2 = compute(prev_w=2000, grid_w=-800, actual_w=0, tuning=TF, sat_count=3, i_w=2000.0)
|
||||
check("frozen: unwinding is still allowed", f2.i_w < 2000.0)
|
||||
# ...and the same two on the charging side. Every freeze rule in this file has
|
||||
# a mirror, because the one that did not is the defect that got through review.
|
||||
f3 = compute(prev_w=-2000, grid_w=-800, actual_w=0, tuning=TF, sat_count=3, i_w=-2000.0)
|
||||
check("frozen (charging): integration does not wind further", f3.i_w == -2000.0)
|
||||
f4 = compute(prev_w=-2000, grid_w=800, actual_w=0, tuning=TF, sat_count=3, i_w=-2000.0)
|
||||
check("frozen (charging): unwinding is still allowed", f4.i_w > -2000.0)
|
||||
|
||||
# ⚠️ REGRESSION, and the reason the first cut of SAFETY-04 was rejected. A
|
||||
# freeze encoded as "only corrections that shrink |i_w|" is unsatisfiable for
|
||||
@@ -228,50 +145,6 @@ check("frozen at i_w=0: the freeze then clears", not z2.frozen)
|
||||
z3 = compute(prev_w=100, grid_w=-1000, actual_w=800, tuning=T, sat_count=3, i_w=100.0)
|
||||
check("frozen at i_w=+100: a 1 kW export still moves the command",
|
||||
z3.frozen and z3.target_w < 0)
|
||||
z4 = compute(prev_w=-100, grid_w=1000, actual_w=-800, tuning=T, sat_count=3, i_w=-100.0)
|
||||
check("frozen at i_w=-100: a 1 kW import still moves the command",
|
||||
z4.frozen and z4.target_w > 0)
|
||||
|
||||
# ⚠️ EXACTLY ZERO, BOTH DIRECTIONS. This boundary has a history: the first cut
|
||||
# deadlocked here under import, and the fix for it deadlocked here under export
|
||||
# because `if i_w > 0 ... else ...` files 0.0 under rising-only. main.py resets
|
||||
# i_w to exactly 0.0 on every stop and every reseed, so it is a normal state,
|
||||
# not a corner.
|
||||
zi = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T, sat_count=3, i_w=0.0)
|
||||
check("frozen at i_w=0.0: an import push moves the integrator",
|
||||
zi.frozen and zi.i_w > 0)
|
||||
ze = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T, sat_count=3, i_w=0.0)
|
||||
check("frozen at i_w=0.0: an export push moves the integrator",
|
||||
ze.frozen and ze.i_w < 0)
|
||||
# The COMMAND still holds at 0 W in that second case, and that is release/1.0's
|
||||
# rule, not a leftover: at prev_w == 0 the output freeze forbids starting to
|
||||
# charge while saturated, because commanding 0 while the inverter reports
|
||||
# hundreds of watts means something else is driving the bus. Asserted so that
|
||||
# nobody "fixes" it by accident - the integrator moving is what this ticket
|
||||
# owns, the command rule belongs to the output freeze.
|
||||
check("frozen at i_w=0.0: the output freeze still blocks a charge from 0 W",
|
||||
ze.target_w == 0.0)
|
||||
# Where prev_w is already charging the output freeze does NOT block, and there
|
||||
# the difference reaches the wire: held at 0.0 the integrator abandons the
|
||||
# charge mid-export.
|
||||
zc = compute(prev_w=-2000, grid_w=-4000, actual_w=-600, tuning=T, sat_count=3, i_w=0.0)
|
||||
check("frozen at i_w=0.0: a charge is not abandoned during heavy export",
|
||||
zc.target_w == -2000.0)
|
||||
|
||||
# The general property, rather than another handful of points: while frozen the
|
||||
# integrator may be held ONLY when the correction would push it further from
|
||||
# zero on the side it already sits. Any other hold is a deadlock.
|
||||
stuck = []
|
||||
for i0 in [x * 25.0 for x in range(-80, 81)]:
|
||||
for g in [x * 100.0 for x in range(-40, 41)]:
|
||||
err = g - T.target_grid_w
|
||||
if abs(err) < T.deadband_w:
|
||||
continue
|
||||
dd = compute(prev_w=0.0, grid_w=g, actual_w=1500.0, tuning=T, sat_count=3, i_w=i0)
|
||||
if dd.i_w == i0 and not ((i0 > 0 and err > 0) or (i0 < 0 and err < 0)):
|
||||
stuck.append((i0, g))
|
||||
check(f"frozen integrator never deadlocks, over {161*81} states"
|
||||
+ (f" (e.g. {stuck[0]})" if stuck else ""), not stuck)
|
||||
|
||||
# False-positive guard: a normal 2 kW load step must not trip the detector,
|
||||
# because the plant needs several cycles to catch up on every one of them.
|
||||
@@ -323,67 +196,34 @@ print("SAFETY-04: the i_w=None path is still release/1.0, exactly")
|
||||
|
||||
def legacy(prev, grid, actual, t, sat_count):
|
||||
"""release/1.0's control law, transcribed. Do not 'improve' this."""
|
||||
reason = "tracking"
|
||||
sc = min(sat_count + 1, 10) if abs(prev - actual) > t.saturation_w else 0
|
||||
frozen = sc >= t.saturation_cycles
|
||||
error = grid - t.target_grid_w
|
||||
if abs(error) < t.deadband_w:
|
||||
want, reason = prev, "deadband"
|
||||
else:
|
||||
want = prev + t.gain * error
|
||||
want = prev if abs(error) < t.deadband_w else prev + t.gain * error
|
||||
target = max(-t.max_w, min(t.max_w, want))
|
||||
if target != want:
|
||||
reason = "clamped"
|
||||
slewed = max(prev - t.slew_w, min(prev + t.slew_w, target))
|
||||
if slewed != target:
|
||||
reason = "slew-limited"
|
||||
target = slewed
|
||||
target = max(prev - t.slew_w, min(prev + t.slew_w, target))
|
||||
if frozen:
|
||||
target = min(target, prev) if prev > 0 else max(target, prev)
|
||||
reason = "saturated-freeze"
|
||||
step = max(1, int(t.step_w))
|
||||
return float(round(target / step) * step), sc, frozen, reason
|
||||
return float(round(target / step) * step), sc
|
||||
|
||||
|
||||
# ⚠️ Compare EVERYTHING observable, not just the number. A previous version of
|
||||
# this sweep compared (target_w, sat_count) only and passed 3024 cases while
|
||||
# `reason` had silently lost a value - which is the kind of thing a sweep this
|
||||
# broad exists to catch. `frozen` and `reason` are both in the tuple now.
|
||||
#
|
||||
# The one deliberate rename: what release/1.0 called "clamped" is now
|
||||
# "i-clamped", because the truncation happens on the integrator before the
|
||||
# command is derived from it. Aliased here rather than papered over - if any
|
||||
# OTHER reason ever diverges, this check goes red.
|
||||
ALIAS = {"i-clamped": "clamped"}
|
||||
# Exhaustive over the interesting corners, both freeze states, both signs, and
|
||||
# either side of the deadband. This is what makes the claim in control.py's
|
||||
# integrator comment a checked fact rather than an assertion.
|
||||
diffs = []
|
||||
seen = set()
|
||||
for tune in (Tuning(), Tuning(target_grid_w=-10.0), Tuning(max_w=5000, slew_w=5000)):
|
||||
for prev in (-2000.0, -500.0, -100.0, 0.0, 100.0, 500.0, 2000.0):
|
||||
for grid in (-6000.0, -1000.0, -500.0, -14.0, 0.0, 14.0, 500.0, 1000.0, 6000.0):
|
||||
for actual in (-2000.0, 0.0, 600.0, 2000.0):
|
||||
for sc in (0, 2, 3, 9):
|
||||
d = compute(prev, grid, actual, tune, sc) # i_w defaults to None
|
||||
seen.add(d.reason)
|
||||
got = (d.target_w, d.sat_count, d.frozen,
|
||||
ALIAS.get(d.reason, d.reason))
|
||||
if got != legacy(prev, grid, actual, tune, sc):
|
||||
diffs.append((prev, grid, actual, sc, got,
|
||||
legacy(prev, grid, actual, tune, sc)))
|
||||
check(f"i_w=None reproduces release/1.0 over {3*7*9*4*4} cases, reason included"
|
||||
lt, lsc = legacy(prev, grid, actual, tune, sc)
|
||||
if (d.target_w, d.sat_count) != (lt, lsc):
|
||||
diffs.append((prev, grid, actual, sc, d.target_w, lt))
|
||||
check(f"i_w=None reproduces release/1.0 over {3*7*9*4*4} cases"
|
||||
+ (f" (first diff {diffs[0]})" if diffs else ""), not diffs)
|
||||
|
||||
# ...and the rename is not a quiet deletion: the signal SAFETY-03 alarms on has
|
||||
# to actually occur in that sweep, or its hook is dead.
|
||||
check("the integrator clamp reports itself as 'i-clamped'", "i-clamped" in seen)
|
||||
|
||||
# "clamped" stays reachable, but only where the integrator is deliberately
|
||||
# allowed above the rail - then BOTH fire and the output clamp, which describes
|
||||
# the value actually emitted, is the one reported.
|
||||
dc = compute(prev_w=0, grid_w=6000, actual_w=0,
|
||||
tuning=Tuning(max_w=2000, integrator_max_w=3000, slew_w=5000))
|
||||
check("the output clamp still reports 'clamped' when it is the binding one",
|
||||
dc.reason == "clamped" and dc.i_w == 3000 and dc.target_w == 2000)
|
||||
|
||||
print("capacity tariff")
|
||||
check("no forecast means no cap", maintenance_charge_floor(2500, None, 3500) == 2500)
|
||||
check("headroom caps the charge", maintenance_charge_floor(2500, 2000, 3500) == 1500)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,213 +0,0 @@
|
||||
"""Runnable check for the control loop's cadence. `python3 test_loop.py`
|
||||
|
||||
SAFETY-12: the loop must evaluate on a clock as well as on a change. Before the
|
||||
fix, `run_control()` ran `cycle()` only when the meter value differed from the
|
||||
previous poll, so a meter that repeats a value ran NO control cycles while the
|
||||
heartbeat kept re-emitting the last command - measured on the rig during TEL-05
|
||||
as 70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
|
||||
|
||||
⚠️ THIS TEST TAKES ~95 SECONDS OF WALL CLOCK (measured 93 s), on purpose, and
|
||||
that figure is the only thing standing between it and a future developer who
|
||||
decides a slow test is a broken one. The thing under test is a wall-clock floor
|
||||
inside a loop that sleeps 1 s; mocking the clock would leave the assertion
|
||||
testing the mock. It drives the real `run_control()` and counts what actually
|
||||
happened. Eight runs of the real loop is what the eight scenarios cost.
|
||||
|
||||
⚠️ If you shorten the windows, re-measure and update this number and the one in
|
||||
HANDOVER.md. An understated runtime reads as a hang.
|
||||
|
||||
`cycle_floor_s` is read from options with no lower bound in code, so a huge
|
||||
value reproduces the pre-fix behaviour exactly. Every claim below is therefore
|
||||
an A/B on one binary, not a comparison against a remembered old one.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
|
||||
from app.main import Controller
|
||||
|
||||
fails = []
|
||||
NEVER = 1e9 # cycle_floor_s so large the floor can never elapse: pre-fix
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class FakeHass:
|
||||
"""A scripted meter and a plant that tracks the command instantly."""
|
||||
|
||||
def __init__(self, meter, soc_dies_at=None):
|
||||
self.meter = meter
|
||||
self.soc_dies_at = soc_dies_at
|
||||
self.batt = 0.0
|
||||
self.writes = []
|
||||
self.t0 = time.monotonic()
|
||||
|
||||
async def limits(self, entity):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
now = time.monotonic() - self.t0
|
||||
if entity == "sensor.grid":
|
||||
return self.meter(now)
|
||||
if entity == "sensor.batt":
|
||||
return self.batt
|
||||
if entity == "sensor.soc":
|
||||
if self.soc_dies_at is not None and now >= self.soc_dies_at:
|
||||
return None
|
||||
return 50.0
|
||||
return None
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append(float(value))
|
||||
self.batt = float(value)
|
||||
return True
|
||||
|
||||
|
||||
class _Store:
|
||||
data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 2000, "gain": 0.6, "slew_w": 1000,
|
||||
"deadband_w": 15, "target_grid_w": -10, "step_w": 10,
|
||||
"heartbeat_s": 10, "stale_input_s": 15}
|
||||
|
||||
|
||||
def drive(meter, floor, seconds, soc_dies_at=None, **over):
|
||||
"""Run the real control loop for `seconds`; return (cycle times, writes)."""
|
||||
async def go():
|
||||
hass = FakeHass(meter, soc_dies_at)
|
||||
c = Controller(dict(BASE, cycle_floor_s=floor, **over), hass, _Store(),
|
||||
_Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
stamps = []
|
||||
real = c.cycle
|
||||
|
||||
async def counted():
|
||||
stamps.append(time.monotonic() - hass.t0)
|
||||
await real()
|
||||
|
||||
c.cycle = counted
|
||||
task = asyncio.create_task(c.run_control())
|
||||
await asyncio.sleep(seconds)
|
||||
c.stopping = True
|
||||
await asyncio.wait_for(task, timeout=10)
|
||||
return stamps, hass.writes, c.target, c.reason
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
def worst_gap(stamps, seconds):
|
||||
"""Longest stretch with no cycle, including the tail after the last one."""
|
||||
edges = [0.0] + list(stamps) + [seconds]
|
||||
return max(b - a for a, b in zip(edges, edges[1:]))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("a meter that repeats the same value")
|
||||
FLAT = lambda t: 350.0 # noqa: E731
|
||||
FLOOR, SECONDS = 3.0, 12.0
|
||||
|
||||
pre_stamps, pre_writes, _, _ = drive(FLAT, NEVER, SECONDS)
|
||||
# The defect itself, demonstrated rather than described: one cycle at startup
|
||||
# and then nothing for the rest of the window, while writes keep happening.
|
||||
check("pre-fix: a constant meter runs exactly one cycle, at startup",
|
||||
len(pre_stamps) == 1)
|
||||
check("...while the heartbeat keeps writing anyway, so nothing looks wrong",
|
||||
len(pre_writes) >= 2)
|
||||
|
||||
post_stamps, post_writes, _, _ = drive(FLAT, FLOOR, SECONDS)
|
||||
check("post-fix: the same constant meter runs cycles throughout",
|
||||
len(post_stamps) >= int(SECONDS / FLOOR) - 1)
|
||||
# The actual acceptance criterion: a BOUND on the gap, not a count.
|
||||
gap = worst_gap(post_stamps, SECONDS)
|
||||
check(f"...with no gap longer than the floor ({gap:.1f}s <= {FLOOR + 1.5:.1f}s)",
|
||||
gap <= FLOOR + 1.5)
|
||||
check("...and the loop actually corrected, instead of holding a stale command",
|
||||
len(post_writes) > len(pre_writes))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("a meter that changes normally - nothing may move")
|
||||
# ⚠️ THE REGRESSION GUARD, and the more important half. The floor is a deadline
|
||||
# on the gap between cycles, reset by every cycle whichever trigger fired - not
|
||||
# a second scheduler. On a healthy meter it must never elapse, so the cycle
|
||||
# count, the write count and the commanded values must be IDENTICAL either side
|
||||
# of the fix. Anything else is a new write cadence, which SAFETY-12 forbids:
|
||||
# the deadband, step_w and the 10 s force-write decide what reaches the wire,
|
||||
# and this change is only allowed to alter when the loop thinks.
|
||||
STEPS = [0.0, 300.0, 850.0, -400.0, 120.0, -900.0, 60.0]
|
||||
LIVE = lambda t: STEPS[int(t // 2) % len(STEPS)] # noqa: E731
|
||||
|
||||
a_stamps, a_writes, _, _ = drive(LIVE, NEVER, 12.0)
|
||||
b_stamps, b_writes, _, _ = drive(LIVE, FLOOR, 12.0)
|
||||
check("a changing meter runs the same number of cycles as before the fix",
|
||||
len(a_stamps) == len(b_stamps))
|
||||
check("...commanding exactly the same sequence of values", a_writes == b_writes)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("a meter parked at the target - the converged operating point")
|
||||
# The case TEL-05 warned about: at the converged target the power figure
|
||||
# legitimately repeats for minutes. The loop must still run, and must still
|
||||
# write nothing but the heartbeat, because the deadband - not the arrival
|
||||
# trigger - is what holds the command still.
|
||||
AT_TARGET = lambda t: -10.0 # noqa: E731
|
||||
c_stamps, c_writes, _, _ = drive(AT_TARGET, NEVER, 12.0)
|
||||
d_stamps, d_writes, _, _ = drive(AT_TARGET, FLOOR, 12.0)
|
||||
check("the loop evaluates at the converged point too", len(d_stamps) > len(c_stamps))
|
||||
check("...and the deadband still holds the command, so writes are unchanged",
|
||||
d_writes == c_writes)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("an input dies while the meter repeats - the safety claim must still fire")
|
||||
# ⚠️ THE STRONGEST PROPERTY IN SAFETY-12, and the one that makes it a safety fix
|
||||
# rather than a latency fix. The `inputs missing -> 0 W` claim lives INSIDE
|
||||
# cycle() (main.py, the stale_input_s branch and the P_SAFETY claim below it).
|
||||
# Pre-fix, on a meter whose value happened to repeat, cycle() was never called
|
||||
# again - so that claim could not fire AT ALL. The controller held its last
|
||||
# command through an input outage of unbounded length with the status line still
|
||||
# reading "loop -> N W".
|
||||
#
|
||||
# Layer 1 does not cover this either on a `meter_source: off` install: no P1 age
|
||||
# sensor is published (see Controller.publish), so the firmware has nothing to
|
||||
# veto on, and the heartbeat keeps its watchdog fed the whole time.
|
||||
#
|
||||
# stale_input_s is shortened to 3 s here so the outage resolves inside a test
|
||||
# rather than inside a quarter of a minute. The mechanism is the default one.
|
||||
c2_stamps, c2_writes, pre_target, pre_reason = drive(
|
||||
FLAT, NEVER, 10.0, soc_dies_at=4.0, stale_input_s=3)
|
||||
check("pre-fix: a dead input during a constant meter never reaches the safety claim",
|
||||
pre_target != 0.0 and "safety" not in pre_reason)
|
||||
check(f"...so it holds its last command instead ({pre_target:g} W, {pre_reason!r})",
|
||||
pre_target == 220.0)
|
||||
|
||||
d2_stamps, d2_writes, post_target, post_reason = drive(
|
||||
FLAT, FLOOR, 10.0, soc_dies_at=4.0, stale_input_s=3)
|
||||
check("post-fix: the same outage commands 0 W", post_target == 0.0)
|
||||
check(f"...through the safety claim, not by accident ({post_reason!r})",
|
||||
"safety" in post_reason)
|
||||
check("...and the 0 W actually reached the device", 0.0 in d2_writes)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -11,7 +11,6 @@ computes the wrong quarter-hour figure whenever the telegram cadence changes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -19,11 +18,8 @@ from datetime import datetime, timedelta, timezone
|
||||
import aiohttp # already required by app.p1, so this adds no new dependency
|
||||
|
||||
from app.p1 import (
|
||||
P1Error, P1Ingest, HaDsmrSource, HaSignedSource, HomeWizardLocalSource,
|
||||
QuarterAverager,
|
||||
SOURCE_HA, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD, SOURCE_MQTT,
|
||||
build_source, is_enabled, make_sample, parse_homewizard, parse_mqtt_payload,
|
||||
split_signed,
|
||||
P1Error, P1Ingest, HaDsmrSource, QuarterAverager, SOURCE_HA, SOURCE_MQTT,
|
||||
make_sample, parse_mqtt_payload,
|
||||
)
|
||||
|
||||
fails = []
|
||||
@@ -60,25 +56,6 @@ def raises(name, fn):
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def built(src):
|
||||
"""`src.build()`, with any escaping exception turned into a visible value.
|
||||
|
||||
⚠️ Legibility of a RED, not leniency. build() is contracted to return a bool
|
||||
and to funnel every bad telegram through ingest.reject() - a guard that goes
|
||||
missing (say the "is the net entity cached at all" one) makes it raise
|
||||
instead. That still fails the suite, but by aborting it with a traceback at
|
||||
whichever check happened to run first, which costs the next person ten
|
||||
minutes deciding whether the suite is broken or the code is. Returning the
|
||||
exception makes it compare unequal to True/False, so the NAMED check goes red
|
||||
and says which rule died.
|
||||
"""
|
||||
try:
|
||||
return src.build()
|
||||
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
|
||||
print(f" build() raised {type(err).__name__}: {err}")
|
||||
return err
|
||||
|
||||
|
||||
def sample(net_import, net_export=0.0, at=BASE, phases=1, pi=None, pe=None):
|
||||
return make_sample(SOURCE_HA, net_import, net_export, phases=phases,
|
||||
phase_import_w=pi, phase_export_w=pe,
|
||||
@@ -571,726 +548,6 @@ check("the averager integrated the live stream", live.averager.elapsed_s > 0.2)
|
||||
check("the primed cache let the first telegram build immediately",
|
||||
live.samples == 2 and live.last.import_w == 0.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("ha_signed: one signed entity -> the same two magnitudes")
|
||||
# The meter actually fitted at this house is a HomeWizard P1 publishing ONE
|
||||
# signed sensor. ha_dsmr cannot read it - it wants two unsigned registers and
|
||||
# refuses a negative one, which is every exporting telegram.
|
||||
|
||||
check("a positive reading is import", split_signed(1500.0) == (1500.0, 0.0))
|
||||
check("a negative reading is export", split_signed(-900.0) == (0.0, 900.0))
|
||||
check("zero is a balanced reading, not a missing one",
|
||||
split_signed(0.0) == (0.0, 0.0))
|
||||
check("exactly one magnitude is ever non-zero",
|
||||
all(a == 0.0 or b == 0.0 for a, b in
|
||||
(split_signed(v) for v in (-5710.0, -1.0, 0.0, 1.0, 4384.0))))
|
||||
# ⚠️ The split must not change the number. A control loop steered by a value
|
||||
# that was rounded or clamped on the way in is steered by a different meter.
|
||||
check("the split round-trips the signed value exactly",
|
||||
all(make_sample(SOURCE_HA_SIGNED, *split_signed(v), phases=1).net_w == v
|
||||
for v in (-11763.0, -5710.0, -0.5, 0.0, 0.5, 775.0, 4384.0)))
|
||||
|
||||
raises("a non-numeric signed reading is rejected", lambda: split_signed("n/a"))
|
||||
raises("a signed None is rejected, not read as zero", lambda: split_signed(None))
|
||||
raises("a signed NaN is rejected", lambda: split_signed(float("nan")))
|
||||
raises("a signed infinity is rejected", lambda: split_signed(float("inf")))
|
||||
# ⚠️ This one matters MORE on the signed path than on the unsigned one. On
|
||||
# ha_dsmr the §20 contamination is also caught by "unsigned cannot be negative";
|
||||
# here 64954 arrives as a perfectly well-formed positive signed reading and the
|
||||
# plausibility ceiling is the only thing standing in front of it.
|
||||
raises("the 64954 signed-decode contamination is still rejected",
|
||||
lambda: split_signed(64954.0))
|
||||
raises("...and its negative twin too", lambda: split_signed(-64954.0))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("ha_signed: the sign convention, against real captured readings")
|
||||
# ⚠️ Not a datasheet claim. These are literal values out of
|
||||
# sim/scenarios/ha-p1_meter_active_power-2026-08-{20,23}.json, HA recorder
|
||||
# exports of sensor.p1_meter_active_power at this house, copied here rather than
|
||||
# read from that repo so this file still runs on a laptop with nothing installed
|
||||
# (§17). If the convention were inverted, the physics below would be absurd.
|
||||
|
||||
# 2026-08-23T11:46:52Z - the day's most negative reading, 13:46 local, full sun.
|
||||
s = make_sample(SOURCE_HA_SIGNED, *split_signed(-5710.0), phases=1)
|
||||
check("the midday PV peak (-5710 W) is EXPORT, not a 5.7 kW draw",
|
||||
s.net_w == -5710.0 and s.export_w == 5710.0 and s.import_w == 0.0)
|
||||
# 2026-08-19T22:00:00Z - midnight local, 20 Aug's file starts here. No sun.
|
||||
s = make_sample(SOURCE_HA_SIGNED, *split_signed(775.0), phases=1)
|
||||
check("the overnight base load (+775 W) is IMPORT",
|
||||
s.net_w == 775.0 and s.import_w == 775.0 and s.export_w == 0.0)
|
||||
# 2026-08-20T12:20:58Z - 14:20 local, the largest export in either capture.
|
||||
s = make_sample(SOURCE_HA_SIGNED, *split_signed(-11763.0), phases=1)
|
||||
check("the -11763 W midday extreme is export and survives the ceiling",
|
||||
s.net_w == -11763.0 and s.export_w == 11763.0)
|
||||
# 2026-08-23T10:18:28Z - the largest import in the healthy capture.
|
||||
s = make_sample(SOURCE_HA_SIGNED, *split_signed(4384.0), phases=1)
|
||||
check("the +4384 W peak is import", s.net_w == 4384.0 and s.import_w == 4384.0)
|
||||
# The whole convention in one line: night draws, midday feeds back.
|
||||
check("night is positive and midday is negative, which is the convention",
|
||||
split_signed(775.0)[0] > 0 and split_signed(-5710.0)[1] > 0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("ha_signed transport: building a sample out of one entity state")
|
||||
|
||||
NET = {"net": "sensor.p1_meter_active_power", "phase_net": []}
|
||||
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||
sig = HaSignedSource(None, ing, NET, token="x")
|
||||
|
||||
check("nothing cached yet builds nothing", built(sig) is False and ing.last is None)
|
||||
sig._absorb("sensor.p1_meter_active_power", "1000")
|
||||
check("one signed entity is a complete telegram on its own",
|
||||
built(sig) is True and ing.net_w == 1000.0)
|
||||
check("the sample is tagged with its own transport",
|
||||
ing.last.source == SOURCE_HA_SIGNED)
|
||||
sig._absorb("sensor.p1_meter_active_power", "-2500")
|
||||
built(sig)
|
||||
check("a negative state lands as a negative net", ing.net_w == -2500.0)
|
||||
|
||||
before = ing.last
|
||||
sig._absorb("sensor.p1_meter_active_power", "unavailable")
|
||||
check("an unavailable signed entity is a parse error", ing.parse_errors == 1)
|
||||
check("an unavailable entity does not build a sample", built(sig) is False)
|
||||
# ⚠️ The rule the whole ticket turns on: a missing reading is MISSING. Resolving
|
||||
# it to 0 W would read as a perfectly balanced house and defeat the staleness
|
||||
# trigger that FW-01's watchdog is built on.
|
||||
check("an unavailable entity leaves the last good sample untouched, not 0 W",
|
||||
ing.last is before and ing.net_w == -2500.0)
|
||||
sig._absorb("sensor.p1_meter_active_power", "unknown")
|
||||
check("an unknown signed entity is treated the same way", ing.parse_errors == 2)
|
||||
sig._absorb("sensor.p1_meter_active_power", "banana")
|
||||
check("a non-numeric signed state is a parse error, not 0 W",
|
||||
ing.parse_errors == 3 and ing.net_w == -2500.0)
|
||||
sig._absorb("sensor.p1_meter_active_power", "64954")
|
||||
check("64954 is refused at the signed transport too",
|
||||
built(sig) is False and ing.parse_errors == 4)
|
||||
sig._absorb("sensor.not_ours", "123")
|
||||
check("an unsubscribed entity is never cached by the signed transport",
|
||||
"sensor.not_ours" not in sig.cache)
|
||||
|
||||
# A rejected reading must not make the age look fresh - the age is what the
|
||||
# firmware watchdog reads, and a rejection is exactly when it must keep climbing.
|
||||
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||
sig = HaSignedSource(None, ing, dict(NET), token="x")
|
||||
ing.submit(make_sample(SOURCE_HA_SIGNED, 1200, 0, phases=1,
|
||||
ingest_mono=time.monotonic() - 20.0))
|
||||
sig._absorb("sensor.p1_meter_active_power", "unavailable")
|
||||
built(sig)
|
||||
check("a rejected reading does not reset the published age",
|
||||
ing.published_age_s > 19 and ing.net_w == 1200.0)
|
||||
ing.submit(make_sample(SOURCE_HA_SIGNED, 1200, 0, phases=1,
|
||||
ingest_mono=time.monotonic() - 40.0))
|
||||
check("...and the age keeps climbing past max_age_s on its own",
|
||||
ing.stale is True and ing.net_w is None)
|
||||
|
||||
# The three-phase reading the TEL-04 survey recorded at this house: L1 +2301 W,
|
||||
# L2 +468 W, L3 -2582 W, netting +187 W. A signed per-phase set splits the same
|
||||
# way, and the exporting phase must still clamp out of the billed figure.
|
||||
ing3 = P1Ingest(phases=3, max_age_s=30.0)
|
||||
NET3 = {"net": "sensor.p1_meter_active_power",
|
||||
"phase_net": ["sensor.p1_l1", "sensor.p1_l2", "sensor.p1_l3"]}
|
||||
sig3 = HaSignedSource(None, ing3, NET3, token="x")
|
||||
for eid, val in (("sensor.p1_meter_active_power", "187"), ("sensor.p1_l1", "2301")):
|
||||
sig3._absorb(eid, val)
|
||||
check("an incomplete signed phase set waits instead of guessing", built(sig3) is False)
|
||||
sig3._absorb("sensor.p1_l2", "468")
|
||||
sig3._absorb("sensor.p1_l3", "-2582")
|
||||
check("a complete signed three-phase set builds", built(sig3) is True)
|
||||
check("signed per-phase entities keep the exporting phase negative",
|
||||
ing3.last.per_phase_w == (2301.0, 468.0, -2582.0))
|
||||
check("per-phase IMPORT clamps the exporting phase to zero",
|
||||
ing3.last.per_phase_import_w == (2301.0, 468.0, 0.0))
|
||||
check("the phase import sum is 2769 W while the connection nets 187 W",
|
||||
sum(ing3.last.per_phase_import_w) == 2769.0 and ing3.last.net_w == 187.0)
|
||||
check("the signed per-phase tuple length matches meter_phases",
|
||||
len(ing3.last.per_phase_w) == ing3.phases == 3)
|
||||
|
||||
sig_bad = HaSignedSource(None, P1Ingest(phases=3, max_age_s=30.0),
|
||||
{"net": "sensor.net", "phase_net": ["sensor.a", "sensor.b"]},
|
||||
token="x")
|
||||
for eid in ("sensor.net", "sensor.a", "sensor.b"):
|
||||
sig_bad._absorb(eid, "100")
|
||||
check("two phases delivered against meter_phases 3 is rejected, not padded",
|
||||
built(sig_bad) is False and sig_bad.ingest.last is None
|
||||
and sig_bad.ingest.parse_errors == 1)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("ha_signed transport: end to end against a fake Home Assistant")
|
||||
# The transport is a subclass, so this is what proves the INHERITED machinery -
|
||||
# auth, subscribe, the get_states priming rule, the reconnect-emits-nothing
|
||||
# rule - still behaves when only _wanted() and build() were replaced.
|
||||
|
||||
|
||||
async def _e2e_signed():
|
||||
from aiohttp import web
|
||||
import app.p1 as p1mod
|
||||
|
||||
done = asyncio.Event()
|
||||
eid = "sensor.p1_meter_active_power"
|
||||
|
||||
async def fake_ha(request):
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
await ws.send_json({"type": "auth_required", "ha_version": "2026.8"})
|
||||
auth = await ws.receive_json()
|
||||
assert auth["type"] == "auth" and auth["access_token"] == "tok"
|
||||
await ws.send_json({"type": "auth_ok"})
|
||||
sub = await ws.receive_json()
|
||||
assert sub["type"] == "subscribe_events"
|
||||
await ws.send_json({"id": sub["id"], "type": "result", "success": True})
|
||||
get = await ws.receive_json()
|
||||
assert get["type"] == "get_states"
|
||||
await ws.send_json({"id": get["id"], "type": "result", "success": True, "result": [
|
||||
{"entity_id": eid, "state": "775.0"},
|
||||
{"entity_id": "sensor.something_else", "state": "hello"},
|
||||
]})
|
||||
# Two real telegrams, both literal captured values: overnight import,
|
||||
# then the midday export peak.
|
||||
for val in ("775.0", "-5710.0"):
|
||||
await asyncio.sleep(0.5)
|
||||
await ws.send_json({"type": "event", "event": {"data": {
|
||||
"entity_id": eid,
|
||||
"new_state": {"entity_id": eid, "state": val}}}})
|
||||
await asyncio.sleep(0.5)
|
||||
await ws.send_json({"type": "event", "event": {"data": {
|
||||
"entity_id": eid,
|
||||
"new_state": {"entity_id": eid, "state": "unavailable"}}}})
|
||||
await asyncio.sleep(0.5)
|
||||
done.set()
|
||||
return ws
|
||||
|
||||
srv = web.Application()
|
||||
srv.router.add_get("/ws", fake_ha)
|
||||
runner = web.AppRunner(srv)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
p1mod.WS_URL = f"http://127.0.0.1:{port}/ws"
|
||||
|
||||
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||
async with aiohttp.ClientSession() as sess:
|
||||
src = HaSignedSource(sess, ing, dict(NET), token="tok")
|
||||
task = asyncio.get_running_loop().create_task(src.run())
|
||||
try:
|
||||
await asyncio.wait_for(done.wait(), 20)
|
||||
await asyncio.sleep(0.5)
|
||||
finally:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await runner.cleanup()
|
||||
return ing, src
|
||||
|
||||
|
||||
live, wire = asyncio.run(_e2e_signed())
|
||||
# ⚠️ TWO, not three - the same rule as the ha_dsmr e2e. get_states primes the
|
||||
# cache but must never become a sample: after a Core restart it is a
|
||||
# RestoreEntity value of unknown age, and stamping it with ingest_ts=now reports
|
||||
# a fresh meter that may have been dead for an hour.
|
||||
check("ha_signed does not manufacture a sample from cached HA state",
|
||||
live.samples == 2)
|
||||
check("the signed telegrams arrived over a real websocket",
|
||||
live.last.source == SOURCE_HA_SIGNED)
|
||||
check("the final export telegram nets negative, over the wire",
|
||||
live.last.net_w == -5710.0 and live.last.export_w == 5710.0)
|
||||
check("ha_signed subscribes to the one entity and caches nothing else",
|
||||
wire.ids == {"sensor.p1_meter_active_power"}
|
||||
and "sensor.something_else" not in wire.cache)
|
||||
check("a mid-stream unavailable signed state is a parse error, not a sample",
|
||||
live.parse_errors == 1 and live.samples == 2)
|
||||
# ⚠️ And the cached half is DROPPED, so no later telegram can be assembled out
|
||||
# of a value that stopped reporting.
|
||||
check("an unavailable entity is evicted from the cache", wire.cache == {})
|
||||
check("the last good reading survives the unavailable, and is not 0 W",
|
||||
live.net_w == -5710.0)
|
||||
check("the averager integrated the live signed stream", live.averager.elapsed_s > 0.2)
|
||||
# ⚠️ The entity FW-01 waits on. It must be a number here exactly as it is on the
|
||||
# other transports - the house P1 went 51.1 s and 36.2 s between state changes
|
||||
# overnight, and without this the watchdog false-trips the battery to 0 W.
|
||||
check("sensor.p1_sample_age_s is a live number on this transport too",
|
||||
isinstance(live.published_age_s, float) and live.published_age_s >= 0.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("ha_signed: selection by config")
|
||||
|
||||
check("ha_signed is enabled", is_enabled({"meter_source": SOURCE_HA_SIGNED}) is True)
|
||||
# ⚠️ `sel`, not `built` - that name is the build() wrapper defined at the top of
|
||||
# this file, and rebinding it here silently disarms every check appended below
|
||||
# this line. Caught in review: an added check went `TypeError: 'HaSignedSource'
|
||||
# object is not callable` and aborted the suite, which is the exact failure the
|
||||
# wrapper exists to prevent, reintroduced by a name collision.
|
||||
sel = build_source({"meter_source": SOURCE_HA_SIGNED,
|
||||
"p1_net_entity": "sensor.p1_meter_active_power"},
|
||||
P1Ingest(), None, None)
|
||||
check("meter_source ha_signed selects the signed transport",
|
||||
isinstance(sel, HaSignedSource))
|
||||
check("...wired to p1_net_entity, and subscribed to exactly that one entity",
|
||||
sel.ids == {"sensor.p1_meter_active_power"})
|
||||
# ⚠️ The three modes must not bleed into each other: ha_dsmr must keep ignoring
|
||||
# p1_net_entity, or a half-configured install silently reads the wrong sensor.
|
||||
plain = build_source({"meter_source": SOURCE_HA,
|
||||
"p1_import_entity": "sensor.i", "p1_export_entity": "sensor.e",
|
||||
"p1_net_entity": "sensor.p1_meter_active_power"},
|
||||
P1Ingest(), None, None)
|
||||
check("ha_dsmr still selects the unsigned transport and ignores p1_net_entity",
|
||||
type(plain) is HaDsmrSource and plain.ids == {"sensor.i", "sensor.e"})
|
||||
check("meter_source off still selects nothing",
|
||||
build_source({"meter_source": "off"}, P1Ingest(), None, None) is None)
|
||||
check("an unrecognised meter_source selects nothing rather than guessing",
|
||||
build_source({"meter_source": "ha_signd"}, P1Ingest(), None, None) is None)
|
||||
|
||||
# ⚠️ Caught once at startup, not once per telegram. A source that is wired up
|
||||
# wrong otherwise fails in the one way indistinguishable from a healthy source
|
||||
# nobody has sent anything to yet: no samples, a climbing age, the watchdog
|
||||
# holding the battery at 0 W, and nothing in the log saying why.
|
||||
check("a blank p1_net_entity is refused rather than silently never receiving",
|
||||
build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": ""},
|
||||
P1Ingest(), None, None) is None)
|
||||
check("...and whitespace does not sneak past it",
|
||||
build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": " "},
|
||||
P1Ingest(), None, None) is None)
|
||||
check("a phase list that disagrees with meter_phases is refused at startup",
|
||||
build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n",
|
||||
"p1_phase_net_entities": ["sensor.a", "sensor.b"]},
|
||||
P1Ingest(phases=3), None, None) is None)
|
||||
check("a phase list that agrees with meter_phases is accepted",
|
||||
isinstance(build_source(
|
||||
{"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n",
|
||||
"p1_phase_net_entities": ["sensor.a", "sensor.b", "sensor.c"]},
|
||||
P1Ingest(phases=3), None, None), HaSignedSource))
|
||||
check("no phase list at all is still fine - per-phase billing is optional",
|
||||
isinstance(build_source(
|
||||
{"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n"},
|
||||
P1Ingest(phases=3), None, None), HaSignedSource))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("homewizard_local: parsing the meter's own /api/v1/data document")
|
||||
# The same JSON `sim/hwsim.py` serves and the same JSON the real meter at
|
||||
# 192.168.2.250 serves. One SIGNED connection figure plus three signed legs, so
|
||||
# it goes through the same split_signed as ha_signed - the sign convention is
|
||||
# not re-derived on this transport, it is reused.
|
||||
|
||||
|
||||
def hw_doc(w, l1=None, l2=None, l3=None, imp_kwh=1234.567, exp_kwh=890.123):
|
||||
return {"wifi_ssid": "sim", "smr_version": 50, "meter_model": "SIM-P1",
|
||||
"total_power_import_kwh": imp_kwh, "total_power_export_kwh": exp_kwh,
|
||||
"active_power_w": w,
|
||||
"active_power_l1_w": l1, "active_power_l2_w": l2,
|
||||
"active_power_l3_w": l3, "total_gas_m3": 0.0}
|
||||
|
||||
|
||||
s = parse_homewizard(hw_doc(775.0, l1=775.0), 1)
|
||||
check("the overnight base load (+775 W) is import on this transport too",
|
||||
s.net_w == 775.0 and s.import_w == 775.0 and s.export_w == 0.0)
|
||||
check("the sample is tagged homewizard_local", s.source == SOURCE_HOMEWIZARD)
|
||||
s = parse_homewizard(hw_doc(-5710.0, l1=-5710.0), 1)
|
||||
check("the midday PV peak (-5710 W) is export, not a 5.7 kW draw",
|
||||
s.net_w == -5710.0 and s.export_w == 5710.0)
|
||||
check("a single-phase document still yields its one leg", s.per_phase_w == (-5710.0,))
|
||||
|
||||
# The TEL-04 three-phase survey reading, served the HomeWizard way.
|
||||
s = parse_homewizard(hw_doc(187.0, 2301.0, 468.0, -2582.0), 3)
|
||||
check("signed legs keep the exporting phase negative",
|
||||
s.per_phase_w == (2301.0, 468.0, -2582.0))
|
||||
check("per-phase import clamps the exporting leg out of the billed figure",
|
||||
s.per_phase_import_w == (2301.0, 468.0, 0.0))
|
||||
check("the legs sum to 2769 W while the connection nets 187 W",
|
||||
sum(s.per_phase_import_w) == 2769.0 and s.net_w == 187.0)
|
||||
|
||||
# ⚠️ A single-phase HomeWizard serves `active_power_l2_w: null`. Unlike the HA
|
||||
# transports there is no "wait for the rest" case - every field came out of ONE
|
||||
# response, so a missing leg cannot be a STALE leg. Dropping the whole sample
|
||||
# over it would turn a healthy meter into a climbing age, which is the exact
|
||||
# false trip this transport exists to remove.
|
||||
def parsed(doc, phases=1):
|
||||
"""parse_homewizard(), with an escaping exception turned into a visible value.
|
||||
|
||||
Same reason as `built()` above: if the "all legs or none" guard goes missing,
|
||||
parsing raises, and without this the suite aborts on a traceback instead of
|
||||
reddening the NAMED check that says which rule died.
|
||||
"""
|
||||
try:
|
||||
return parse_homewizard(doc, phases)
|
||||
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
|
||||
print(f" parse_homewizard raised {type(err).__name__}: {err}")
|
||||
return err
|
||||
|
||||
|
||||
s = parsed(hw_doc(500.0, l1=500.0), 3)
|
||||
check("a meter serving fewer legs than meter_phases still gives a reading",
|
||||
getattr(s, "net_w", None) == 500.0 and getattr(s, "per_phase_w", "?") is None)
|
||||
check("...and does not fabricate a partial phase tuple",
|
||||
getattr(s, "per_phase_import_w", "?") is None)
|
||||
|
||||
raises("a response that is not a JSON object is rejected",
|
||||
lambda: parse_homewizard([1, 2, 3], 1))
|
||||
raises("a document with no active_power_w is rejected, not read as 0 W",
|
||||
lambda: parse_homewizard({"total_gas_m3": 0.0}, 1))
|
||||
raises("a null active_power_w is rejected", lambda: parse_homewizard(hw_doc(None), 1))
|
||||
raises("a string active_power_w is rejected", lambda: parse_homewizard(hw_doc("775"), 1))
|
||||
raises("a NaN active_power_w is rejected",
|
||||
lambda: parse_homewizard(hw_doc(float("nan")), 1))
|
||||
raises("the 64954 signed-decode contamination is refused here too",
|
||||
lambda: parse_homewizard(hw_doc(64954.0), 1))
|
||||
raises("a non-numeric leg is rejected rather than quietly dropped",
|
||||
lambda: parse_homewizard(hw_doc(600.0, "2000", 0.0, 100.0), 3))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("homewizard_local: every HTTP response is an arrival")
|
||||
# THE WHOLE TICKET. sensor.p1_sample_age_s has to mean "time since the meter
|
||||
# REPORTED", not "time since the value CHANGED". Home Assistant cannot express
|
||||
# the first: a repeated reading emits no state_changed, advances last_reported
|
||||
# on neither serialiser, and state_reported cannot be subscribed to at all
|
||||
# ("Event filter is required"). Measured on the ENV-01 rig over 70 s of a frozen
|
||||
# meter and again against the live house over ten repeated readings. Our own
|
||||
# capture of this house's meter goes 42.2 s and 97.0 s between changes, both past
|
||||
# the default meter_max_age_s of 30 - i.e. the age sensor would command 0 W on a
|
||||
# perfectly healthy meter. Polling the meter itself removes that: the response is
|
||||
# the arrival, and the number in it is not consulted.
|
||||
|
||||
|
||||
class _FakeMeter:
|
||||
"""hwsim in eight lines: serves whatever document it is told to, or a fault."""
|
||||
|
||||
def __init__(self, doc=None):
|
||||
self.doc = doc
|
||||
self.status = 200
|
||||
self.body = None # set to raw text to serve something unparseable
|
||||
self.delay = 0.0 # set to seconds to imitate a meter that hangs
|
||||
self.hits = 0
|
||||
|
||||
async def handle(self, request):
|
||||
from aiohttp import web
|
||||
self.hits += 1
|
||||
if self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
# ⚠️ web.Response(text=...) defaults to text/plain. That is not just the
|
||||
# "unparseable body" path: fed VALID json it serves a good document under
|
||||
# the wrong mimetype, which is the only way to reach the content_type
|
||||
# guard in poll_once - json_response can never produce it.
|
||||
if self.body is not None:
|
||||
return web.Response(text=self.body, status=self.status)
|
||||
return web.json_response(self.doc, status=self.status)
|
||||
|
||||
|
||||
async def _hw_rig(fn):
|
||||
"""Run `fn(make_source, meter)` against a real HTTP server on localhost."""
|
||||
from aiohttp import web
|
||||
meter = _FakeMeter(hw_doc(350.0, l1=350.0))
|
||||
app = web.Application()
|
||||
app.router.add_get("/api/v1/data", meter.handle)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
async with aiohttp.ClientSession() as sess:
|
||||
try:
|
||||
return await fn(sess, port, meter)
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
async def _arrivals(sess, port, meter):
|
||||
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
|
||||
out = {}
|
||||
out["first"] = await src.poll_once()
|
||||
out["stamp1"] = ing.last.ingest_mono
|
||||
# Four more polls of the IDENTICAL document - the meter has not moved a watt.
|
||||
for _ in range(4):
|
||||
await asyncio.sleep(0.05)
|
||||
await src.poll_once()
|
||||
out["ing"], out["src"], out["meter"] = ing, src, meter
|
||||
return out
|
||||
|
||||
|
||||
r = asyncio.run(_hw_rig(_arrivals))
|
||||
ing, src = r["ing"], r["src"]
|
||||
check("a poll of the meter's own API builds a sample",
|
||||
r["first"] is True and ing.net_w == 350.0)
|
||||
# ⚠️ THE acceptance criterion. Five identical readings, five arrivals. On
|
||||
# ha_signed this whole sequence produces exactly ONE state_changed and then
|
||||
# silence, and the age climbs to 30 s on a meter that is answering perfectly.
|
||||
check("five identical readings are five arrivals, not one",
|
||||
ing.samples == 5 and r["meter"].hits == 5 and src.polls == 5)
|
||||
check("an unchanged value still stamps a NEW arrival time",
|
||||
ing.last.ingest_mono > r["stamp1"])
|
||||
check("...so the age resets on a response carrying an unchanged number",
|
||||
ing.published_age_s < 1.0 and ing.stale is False)
|
||||
check("no arrival was ever a parse error", ing.parse_errors == 0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("homewizard_local: frozen vs steady, the pair HA cannot separate")
|
||||
# ⚠️ Read this before changing anything here. A frozen meter (hwsim --fault
|
||||
# freeze) answers 200 OK forever with a stale document. It is ARRIVING. So the
|
||||
# age - an arrival detector, correctly - reads fresh on both, and that is not a
|
||||
# defect in the age, it is the definition of the signal. What the local API adds
|
||||
# that Home Assistant never had is the ENERGY REGISTERS: a meter under real load
|
||||
# advances total_power_import_kwh (1 Wh resolution, ~10 s at 350 W) even when the
|
||||
# power figure repeats, and a frozen one does not. That is the discriminator, and
|
||||
# it is exposed as `unchanged_s` - deliberately NOT folded into the age, because
|
||||
# this controller regulates grid power toward ~0 W and at a converged -10 W the
|
||||
# export register needs six minutes to move. Thresholding unchanged_s at 30 s
|
||||
# would rebuild the false-trip limit cycle at the exact operating point we aim
|
||||
# for. See DOCS.md and HomeWizardLocalSource.unchanged_s.
|
||||
|
||||
|
||||
async def _freeze_vs_steady(sess, port, meter):
|
||||
ing_f = P1Ingest(phases=1, max_age_s=30.0)
|
||||
frozen = HomeWizardLocalSource(sess, ing_f, "127.0.0.1", port=port, poll_s=1.0)
|
||||
meter.doc = hw_doc(350.0, l1=350.0) # --fault freeze: never moves
|
||||
for i in range(4):
|
||||
if i:
|
||||
await asyncio.sleep(0.1) # sleep BEFORE, so unchanged_s
|
||||
await frozen.poll_once() # is read the instant a poll lands
|
||||
|
||||
ing_s = P1Ingest(phases=1, max_age_s=30.0)
|
||||
steady = HomeWizardLocalSource(sess, ing_s, "127.0.0.1", port=port, poll_s=1.0)
|
||||
kwh = 1234.567
|
||||
for i in range(4):
|
||||
if i:
|
||||
await asyncio.sleep(0.1)
|
||||
# A steady 350 W house: the power figure repeats, the register climbs.
|
||||
kwh += 0.001
|
||||
meter.doc = hw_doc(350.0, l1=350.0, imp_kwh=round(kwh, 3))
|
||||
await steady.poll_once()
|
||||
return (ing_f, frozen), (ing_s, steady)
|
||||
|
||||
|
||||
(ing_f, frozen), (ing_s, steady) = asyncio.run(_hw_rig(_freeze_vs_steady))
|
||||
check("a frozen meter keeps arriving, so both read the same power",
|
||||
ing_f.net_w == 350.0 and ing_s.net_w == 350.0)
|
||||
# ⚠️ Recorded as a rule, not a shortcoming: the age is an ARRIVAL detector and a
|
||||
# frozen meter genuinely is arriving. Anyone tempted to make the age catch freeze
|
||||
# is about to reintroduce the false trip on a steady house.
|
||||
check("the age cannot separate them, and is fresh on both",
|
||||
ing_f.published_age_s < 1.0 and ing_s.published_age_s < 1.0)
|
||||
check("the frozen meter's measurement stands still", frozen.unchanged_s > 0.25)
|
||||
check("...while the steady meter's energy register keeps advancing",
|
||||
steady.unchanged_s < 0.05)
|
||||
check("so the two ARE separable on the local API, which HA could not do",
|
||||
frozen.unchanged_s > steady.unchanged_s * 3)
|
||||
check("unchanged_s is None before the first response ever lands",
|
||||
HomeWizardLocalSource(None, P1Ingest(), "h").unchanged_s is None)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("homewizard_local: a failed poll is a missing reading, never 0 W")
|
||||
|
||||
|
||||
async def _failures(sess, port, meter):
|
||||
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
|
||||
await src.poll_once()
|
||||
# Age the good sample by hand so a reset would be unmistakable.
|
||||
ing.submit(make_sample(SOURCE_HOMEWIZARD, 350.0, 0.0, phases=1,
|
||||
ingest_mono=time.monotonic() - 90.0))
|
||||
out = {}
|
||||
meter.status = 500
|
||||
out["http500"] = await src.poll_once()
|
||||
out["conn_after_500"] = src.connected
|
||||
meter.status, meter.body = 200, "<html>gateway</html>"
|
||||
out["notjson"] = await src.poll_once()
|
||||
meter.body = None
|
||||
meter.doc = {"total_gas_m3": 0.0} # 200 OK, no power in it
|
||||
out["nopower"] = await src.poll_once()
|
||||
out["ing"], out["src"] = ing, src
|
||||
|
||||
# And a meter that is not listening at all: `--fault down`.
|
||||
dead = HomeWizardLocalSource(sess, P1Ingest(phases=1, max_age_s=30.0),
|
||||
"127.0.0.1", port=1, poll_s=1.0)
|
||||
out["down"] = await dead.poll_once()
|
||||
out["dead"] = dead
|
||||
return out
|
||||
|
||||
|
||||
r = asyncio.run(_hw_rig(_failures))
|
||||
ing, src = r["ing"], r["src"]
|
||||
check("an HTTP 500 is not a reading", r["http500"] is False)
|
||||
# A source that was connected and then failed must SAY it is disconnected -
|
||||
# otherwise the diagnostic reads healthy while the age climbs, which is exactly
|
||||
# the "connected: True, parse_errors: 0, samples: 0" state the rig recorded.
|
||||
check("a poll that fails clears the connected flag", r["conn_after_500"] is False)
|
||||
check("a 200 OK carrying something that is not JSON is not a reading",
|
||||
r["notjson"] is False)
|
||||
check("a 200 OK with no active_power_w in it is not a reading", r["nopower"] is False)
|
||||
check("a meter that refuses the connection is not a reading",
|
||||
r["down"] is False and r["dead"].connected is False)
|
||||
check("every failure was counted as a parse error", ing.parse_errors == 3)
|
||||
check("...and the transport records the transport-level ones separately",
|
||||
src.poll_errors == 2 and src.polls == 4)
|
||||
# ⚠️ The rule the safety chain rests on. A failed poll must not manufacture a
|
||||
# balanced house, and must not reset the clock the watchdog reads.
|
||||
check("a failed poll does not reset the age", ing.published_age_s > 89)
|
||||
check("a failed poll leaves the last good value in place, and it is not 0 W",
|
||||
ing.last.net_w == 350.0 and ing.stale is True and ing.net_w is None)
|
||||
check("no failed poll ever became a sample", ing.samples == 2)
|
||||
check("a 200 OK marks the transport connected even when its body is refused",
|
||||
src.connected is True)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("homewizard_local: a wrong header, a hung meter, a submit that throws")
|
||||
# Three failure shapes that all end the same way if they are mishandled - no
|
||||
# sample, a climbing age, the battery at 0 W - and each of which would send the
|
||||
# operator hunting the wrong device.
|
||||
|
||||
|
||||
async def _header_and_timeout(sess, port, meter):
|
||||
out = {}
|
||||
# Valid JSON under text/plain: exactly what content_type=None is for.
|
||||
meter.body = json.dumps(hw_doc(350.0, l1=350.0))
|
||||
ing = P1Ingest(phases=1, max_age_s=30.0)
|
||||
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
|
||||
out["mimetype"] = await src.poll_once()
|
||||
out["ing"] = ing
|
||||
meter.body = None
|
||||
|
||||
# A meter that takes the connection and then does not answer.
|
||||
meter.delay = 0.6
|
||||
ing_t = P1Ingest(phases=1, max_age_s=30.0)
|
||||
slow = HomeWizardLocalSource(sess, ing_t, "127.0.0.1", port=port,
|
||||
poll_s=1.0, timeout_s=0.05)
|
||||
out["timeout"] = await slow.poll_once()
|
||||
out["timeout_err"] = ing_t.last_error
|
||||
meter.delay = 0.0
|
||||
return out
|
||||
|
||||
|
||||
r = asyncio.run(_hw_rig(_header_and_timeout))
|
||||
# ⚠️ A real firmware answering text/plain must not read as a dead meter. Drop
|
||||
# content_type=None from poll_once and this goes red with "unexpected mimetype".
|
||||
check("valid JSON under the wrong Content-Type is still a reading",
|
||||
r["mimetype"] is True and r["ing"].samples == 1 and r["ing"].net_w == 350.0)
|
||||
# ⚠️ str(asyncio.TimeoutError()) is the EMPTY STRING. Without the class-name
|
||||
# fallback the status page reads "last error:" and then nothing, on a hung
|
||||
# meter, at the moment someone is reading that line to find out why the battery
|
||||
# went to 0 W.
|
||||
check("a timed-out poll names the fault instead of logging an empty reason",
|
||||
r["timeout"] is False and "TimeoutError" in (r["timeout_err"] or ""))
|
||||
|
||||
|
||||
async def _submit_rejects(sess, port, meter):
|
||||
"""submit() raising P1Error must be a rejection, not an escaping exception."""
|
||||
class _P1Boom(P1Ingest):
|
||||
def submit(self, sample):
|
||||
raise P1Error("register went backwards")
|
||||
|
||||
ing = _P1Boom(phases=1, max_age_s=30.0)
|
||||
src = HomeWizardLocalSource(sess, ing, "127.0.0.1", port=port, poll_s=1.0)
|
||||
try:
|
||||
ok = await src.poll_once()
|
||||
except Exception as err: # noqa: BLE001 - an escape IS the failure
|
||||
ok = err
|
||||
return ok, ing.parse_errors
|
||||
|
||||
|
||||
ok, errs = asyncio.run(_hw_rig(_submit_rejects))
|
||||
check("a submit that rejects the sample is handled, not left to escape",
|
||||
ok is False and errs == 1)
|
||||
|
||||
|
||||
async def _submit_explodes(sess, port, meter):
|
||||
"""And an UNEXPECTED raise must not kill the poll task for good."""
|
||||
class _Boom(P1Ingest):
|
||||
def submit(self, sample):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
src = HomeWizardLocalSource(sess, _Boom(phases=1, max_age_s=30.0),
|
||||
"127.0.0.1", port=port, poll_s=0.05)
|
||||
task = asyncio.create_task(src.run())
|
||||
await asyncio.sleep(0.3)
|
||||
alive = not task.done()
|
||||
task.cancel()
|
||||
# ⚠️ BaseException, and the same reason built() exists: if run() loses its
|
||||
# guard the task is already dead HOLDING the RuntimeError, and awaiting it
|
||||
# re-raises - aborting the whole suite with a traceback instead of reddening
|
||||
# the check that names the rule. The death is what `alive` records; catching
|
||||
# it here is bookkeeping, not leniency.
|
||||
try:
|
||||
await task
|
||||
except BaseException: # noqa: BLE001
|
||||
pass
|
||||
return alive, src.polls
|
||||
|
||||
|
||||
# ⚠️ The silent-death case. A dead poll task fails SAFE - the age climbs and the
|
||||
# controller commands 0 W - but it looks exactly like a dead meter, so the
|
||||
# operator spends the outage power-cycling hardware that was never at fault.
|
||||
alive, polls = asyncio.run(_hw_rig(_submit_explodes))
|
||||
check("a raise inside a poll does not permanently kill the polling task",
|
||||
alive is True and polls > 1)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("homewizard_local: selection by config")
|
||||
|
||||
check("homewizard_local is enabled", is_enabled({"meter_source": SOURCE_HOMEWIZARD}) is True)
|
||||
sel = build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "192.168.2.250"},
|
||||
P1Ingest(), None, None)
|
||||
check("meter_source homewizard_local selects the polling transport",
|
||||
isinstance(sel, HomeWizardLocalSource))
|
||||
# ⚠️ getattr, not attribute access, for the same reason `built()` exists: a
|
||||
# startup guard that goes wrong returns None here, and `None.url` would abort the
|
||||
# suite with a traceback instead of reddening the check that names the rule.
|
||||
check("...pointed at the meter's own local API on the default port",
|
||||
getattr(sel, "url", None) == "http://192.168.2.250:80/api/v1/data")
|
||||
check("the default cadence is the meter's own ~5 s update rate",
|
||||
getattr(sel, "poll_s", None) == 5.0)
|
||||
# ⚠️ The request must not outlive the poll interval: a backlog of queued requests
|
||||
# behind a slow meter would land several arrivals for one measurement.
|
||||
check("the request timeout is held under the poll interval",
|
||||
getattr(sel, "timeout_s", 99) < getattr(sel, "poll_s", 0))
|
||||
|
||||
sel = build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "10.0.0.9:8080"},
|
||||
P1Ingest(), None, None)
|
||||
check("an explicit host:port is honoured, which is how the sim is reached",
|
||||
getattr(sel, "url", None) == "http://10.0.0.9:8080/api/v1/data")
|
||||
sel = build_source({"meter_source": SOURCE_HOMEWIZARD,
|
||||
"p1_host": "http://192.168.2.250/"}, P1Ingest(), None, None)
|
||||
check("a pasted browser URL still resolves to the right host",
|
||||
getattr(sel, "url", None) == "http://192.168.2.250:80/api/v1/data")
|
||||
|
||||
# ⚠️ Caught once at startup, not once per poll - a source wired up wrong
|
||||
# otherwise fails in the one way indistinguishable from a healthy one nobody has
|
||||
# polled yet: no samples, a climbing age, the watchdog at 0 W, nothing in the log.
|
||||
check("a blank p1_host is refused rather than silently never polling",
|
||||
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": ""},
|
||||
P1Ingest(), None, None) is None)
|
||||
check("...and whitespace does not sneak past it",
|
||||
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": " "},
|
||||
P1Ingest(), None, None) is None)
|
||||
check("an unparseable port is refused rather than guessed",
|
||||
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "meter:eighty"},
|
||||
P1Ingest(), None, None) is None)
|
||||
# ⚠️ The age can never be fresher than the poll interval, so a cadence at or past
|
||||
# max_age_s means every reading is stale before its successor arrives: the
|
||||
# controller would sit permanently on missing inputs while the meter is fine.
|
||||
check("a poll cadence at meter_max_age_s is refused",
|
||||
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
|
||||
"meter_poll_s": 30}, P1Ingest(max_age_s=30), None, None) is None)
|
||||
check("...and one past it too",
|
||||
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
|
||||
"meter_poll_s": 45}, P1Ingest(max_age_s=30), None, None) is None)
|
||||
check("a cadence with headroom is accepted",
|
||||
isinstance(build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
|
||||
"meter_poll_s": 5}, P1Ingest(max_age_s=30),
|
||||
None, None), HomeWizardLocalSource))
|
||||
check("a zero cadence is refused rather than spinning",
|
||||
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
|
||||
"meter_poll_s": 0}, P1Ingest(max_age_s=30), None, None) is None)
|
||||
check("a non-numeric cadence is refused",
|
||||
build_source({"meter_source": SOURCE_HOMEWIZARD, "p1_host": "m",
|
||||
"meter_poll_s": "fast"}, P1Ingest(max_age_s=30), None, None) is None)
|
||||
# The four modes must not bleed into each other.
|
||||
check("ha_signed ignores p1_host and still selects the entity transport",
|
||||
type(build_source({"meter_source": SOURCE_HA_SIGNED, "p1_net_entity": "sensor.n",
|
||||
"p1_host": "192.168.2.250"}, P1Ingest(), None, None))
|
||||
is HaSignedSource)
|
||||
check("homewizard_local ignores p1_net_entity and needs its own host",
|
||||
build_source({"meter_source": SOURCE_HOMEWIZARD,
|
||||
"p1_net_entity": "sensor.n"}, P1Ingest(), None, None) is None)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the age sensor must not exist when P1 is off")
|
||||
# ⚠️ This is a fleet-wide regression guard, not a nicety. The ESP32 watchdog
|
||||
@@ -1299,6 +556,7 @@ print("the age sensor must not exist when P1 is off")
|
||||
# age were published with meter_source off it would climb past 30 s on every
|
||||
# existing install within half a minute and pin the inverter at 0 W forever.
|
||||
|
||||
from app.p1 import is_enabled # noqa: E402
|
||||
from app.mqtt import SENSORS, MqttPublisher # noqa: E402
|
||||
|
||||
check("meter_source off is disabled", is_enabled({"meter_source": "off"}) is False)
|
||||
@@ -1306,8 +564,6 @@ check("a missing meter_source is disabled", is_enabled({}) is False)
|
||||
check("an empty meter_source is disabled", is_enabled({"meter_source": ""}) is False)
|
||||
check("ha_dsmr is enabled", is_enabled({"meter_source": SOURCE_HA}) is True)
|
||||
check("mqtt_p1 is enabled", is_enabled({"meter_source": SOURCE_MQTT}) is True)
|
||||
check("homewizard_local is enabled here too",
|
||||
is_enabled({"meter_source": SOURCE_HOMEWIZARD}) is True)
|
||||
|
||||
# The entity id SAFETY-01's firmware subscribes to, pinned by object_id.
|
||||
row = [s for s in SENSORS if s[0] == "p1_age"]
|
||||
@@ -1346,17 +602,6 @@ check("with P1 on the age sensor IS announced",
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
|
||||
def _never():
|
||||
"""A stop Event these publish-only tests never set.
|
||||
|
||||
`stop` is a REQUIRED keyword argument (CFG-01): a Controller that cannot
|
||||
stop itself used to be the default one, and on the late-refusal path that
|
||||
meant a live process with no control loop and no exit. These cases only call
|
||||
publish(), so any Event will do - but they may not skip it.
|
||||
"""
|
||||
return asyncio.Event()
|
||||
|
||||
|
||||
class _Store:
|
||||
data = {}
|
||||
|
||||
@@ -1379,80 +624,18 @@ class _Pub:
|
||||
|
||||
|
||||
pub_off = _Pub()
|
||||
Controller({"meter_source": "off"}, None, _Store(), pub_off, stop=_never()).publish()
|
||||
Controller({"meter_source": "off"}, None, _Store(), pub_off).publish()
|
||||
check("with P1 off, p1_age is absent from the published payload",
|
||||
"p1_age" not in pub_off.last)
|
||||
check("...while the normal status keys are still published",
|
||||
"setpoint" in pub_off.last and "grid" in pub_off.last)
|
||||
|
||||
pub_on = _Pub()
|
||||
Controller({"meter_source": SOURCE_HA}, None, _Store(), pub_on, stop=_never()).publish()
|
||||
Controller({"meter_source": SOURCE_HA}, None, _Store(), pub_on).publish()
|
||||
check("with P1 on, p1_age is published", "p1_age" in pub_on.last)
|
||||
check("...as a number, so has_state() becomes true only once we feed it",
|
||||
isinstance(pub_on.last["p1_age"], float))
|
||||
|
||||
# ⚠️ And on ha_signed identically - this is the whole reason TEL-04 exists. The
|
||||
# age sensor is a hard prerequisite for the FW-01 flash, and it has to appear on
|
||||
# the transport that can actually read the meter in this house.
|
||||
pub_sig = _Pub()
|
||||
Controller({"meter_source": SOURCE_HA_SIGNED}, None, _Store(), pub_sig, stop=_never()).publish()
|
||||
check("with ha_signed selected, p1_age is published too",
|
||||
"p1_age" in pub_sig.last and isinstance(pub_sig.last["p1_age"], float))
|
||||
|
||||
# ⚠️ And on homewizard_local, where it is the one age FW-01 may actually
|
||||
# threshold - every other transport's age measures when the VALUE changed.
|
||||
pub_hw = _Pub()
|
||||
Controller({"meter_source": SOURCE_HOMEWIZARD}, None, _Store(), pub_hw, stop=_never()).publish()
|
||||
check("with homewizard_local selected, p1_age is published",
|
||||
"p1_age" in pub_hw.last and isinstance(pub_hw.last["p1_age"], float))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("unchanged_s has somewhere an operator can read it")
|
||||
# ⚠️ The counter is deliberately NOT thresholded and NOT folded into the age -
|
||||
# at the converged -10 W this controller aims for, a 1 Wh register needs six
|
||||
# minutes to move, so any limit false-trips at the target operating point. That
|
||||
# refusal only holds up if a HUMAN can interpret the number instead, and DOCS.md
|
||||
# tells them "the transport tracks it as unchanged_s". Before this, nothing ever
|
||||
# read the transport object back: connected, polls, poll_errors and unchanged_s
|
||||
# were all write-only, and the documented signal existed nowhere an operator
|
||||
# could see it.
|
||||
|
||||
|
||||
def p1_rows(ctl):
|
||||
"""The P1 status line(s), or the exception that stopped checks() making one.
|
||||
|
||||
Same reason as built(): a status line that raises would abort the suite with
|
||||
a traceback instead of reddening the check that names the rule.
|
||||
"""
|
||||
try:
|
||||
return [c["text"] for c in ctl.checks() if c["text"].startswith("P1 meter")]
|
||||
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
|
||||
print(f" checks() raised {type(err).__name__}: {err}")
|
||||
return err
|
||||
|
||||
|
||||
def _fed(source):
|
||||
ctl = Controller({"meter_source": source}, None, _Store(), _Pub(), stop=_never())
|
||||
ctl.p1.submit(make_sample(source, 350.0, 0.0, phases=1,
|
||||
ingest_mono=time.monotonic()))
|
||||
return ctl
|
||||
|
||||
|
||||
hw_ctl = _fed(SOURCE_HOMEWIZARD)
|
||||
hw_src = HomeWizardLocalSource(None, hw_ctl.p1, "127.0.0.1")
|
||||
hw_src._note(hw_doc(350.0, l1=350.0))
|
||||
hw_ctl.p1_source = hw_src # what amain() does once the transport exists
|
||||
rows = p1_rows(hw_ctl)
|
||||
check("the healthy P1 status line reports the transport's unchanged_s",
|
||||
isinstance(rows, list) and len(rows) == 1 and "unchanged" in rows[0])
|
||||
|
||||
# ⚠️ getattr, not attribute access: p1_source is None until amain() builds one,
|
||||
# and ha_dsmr/ha_signed/mqtt_p1 have no such counter at all. Reaching for it
|
||||
# directly would turn the whole status page into a 500 on every other transport.
|
||||
ha_rows = p1_rows(_fed(SOURCE_HA))
|
||||
check("...and the line is unharmed on a transport that has no such counter",
|
||||
isinstance(ha_rows, list) and len(ha_rows) == 1 and "unchanged" not in ha_rows[0])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} of {total} FAILED: {', '.join(fails)}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,861 +0,0 @@
|
||||
"""Runnable check for static day/night and fully-fixed tariffs. `python3 test_static_tariff.py`
|
||||
|
||||
WHAT THIS FILE IS DEFENDING, in the order the ticket's acceptance criteria name
|
||||
it:
|
||||
|
||||
AC1 `static_profile` (§14) drives TIME-WINDOW SHIFTING for a two-period
|
||||
tariff - the right window at the right local instant, on both sides of
|
||||
both 2026 DST transitions, and reaching the one shiftable load the add-on
|
||||
actually has.
|
||||
AC2 Fully-fixed pricing DISABLES ARBITRAGE and leaves self-consumption only.
|
||||
AC3 The Belgian scenario suite carries a static-profile regression case.
|
||||
|
||||
THREE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
||||
|
||||
* EVERY VERDICT IS DERIVED. No `check("the split is right", True)`. Where a
|
||||
check needs an expected number it is computed HERE, by different arithmetic
|
||||
than app/static_tariff.py uses - the window split is re-derived by summing
|
||||
over samples with an independent local-hour test, the profile mean by a
|
||||
second pass, the saving by two routes that must agree.
|
||||
|
||||
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE. A
|
||||
two-period tariff has this trap BUILT IN and it is the main hazard here:
|
||||
|
||||
- THE PERIOD BOUNDARY IS THE DEGENERACY. At 07:00 and at 22:00 the "which
|
||||
window" question and the "is it cheap" question agree under almost any
|
||||
bug, and a probe at 12:00 cannot tell a correct wrap-around from one
|
||||
that treats 22:00-07:00 as 22:00-24:00. So probes sit at 06:59 / 07:00 /
|
||||
07:01 and 21:59 / 22:00 / 22:01, and a Saturday 03:00 probe exists
|
||||
specifically because the mon-fri night window MUST NOT cover it.
|
||||
- MEAN-CHEAP AND MINIMUM-CHEAP AGREE ON A TWO-LEVEL TARIFF. Under two
|
||||
prices "below the mean" and "is the cheapest price" are the same set, so
|
||||
a two-period profile cannot tell `cheap_now` from `price == min`.
|
||||
Section 3 therefore runs a THREE-level profile where those two answers
|
||||
differ, and asserts they differ before grading anything with it.
|
||||
- DAY AND NIGHT MUST HAVE DIFFERENT PRICES AND NEITHER MAY BE ZERO, or the
|
||||
whole shifting question collapses. Asserted as checks in their own
|
||||
right, first, rather than assumed.
|
||||
- THE DST CHECKS RUN ON THE 23-HOUR AND 25-HOUR DAYS, not on a normal day
|
||||
where a 24-slot implementation and a correct one agree exactly.
|
||||
- THE REPLAY DAY MUST CARRY IMPORT IN BOTH WINDOWS. A day whose whole
|
||||
import fell in one window grades no split at all; section 6 asserts that
|
||||
before it reports a saving.
|
||||
|
||||
* A FAILED READ IS NOT AN OBSERVATION. A missing static_profile is not a zero
|
||||
price, and zero is exactly the value that makes everything look free. So
|
||||
`static_plan` with no profile is checked to produce a read with NO
|
||||
`.points`, and `price_at` on it must RAISE rather than answer 0.0.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.price import (
|
||||
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
|
||||
plan_is_provisional,
|
||||
)
|
||||
from app.static_tariff import (
|
||||
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
|
||||
parse_static_profile, resolve_tariff, static_plan, static_status,
|
||||
)
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def raises(fn, exc):
|
||||
"""True only if fn() raises EXACTLY `exc`. A different exception is False -
|
||||
'it was refused' and 'it broke on the way to being refused' must not grade
|
||||
the same."""
|
||||
try:
|
||||
fn()
|
||||
except exc:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def reason(fn):
|
||||
try:
|
||||
fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return str(e)
|
||||
return ""
|
||||
|
||||
|
||||
class _Raised:
|
||||
def __init__(self, e):
|
||||
self.e = e
|
||||
|
||||
def __eq__(self, other):
|
||||
return False
|
||||
|
||||
def __hash__(self):
|
||||
return id(self)
|
||||
|
||||
def __bool__(self):
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return f"<raised {type(self.e).__name__}: {self.e}>"
|
||||
|
||||
|
||||
def derived(fn):
|
||||
"""A value a check grades. An exception becomes `_Raised`, which equals
|
||||
nothing and is falsy - NOT a default. See test_price.py for why."""
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _Raised(e)
|
||||
|
||||
|
||||
def near(a, b, tol=1e-9):
|
||||
try:
|
||||
return abs(float(a) - float(b)) <= tol
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The fixtures. Belgian day/night numbers, and their degeneracies asserted.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
DAY_P = 0.3410 # all-in EUR/kWh, day
|
||||
NIGHT_P = 0.2570 # all-in EUR/kWh, night
|
||||
FIXED_P = 0.3070 # all-in EUR/kWh, single-tariff site
|
||||
|
||||
TWO_PERIOD = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
|
||||
"eur_per_kwh": DAY_P},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
|
||||
"eur_per_kwh": NIGHT_P},
|
||||
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
|
||||
"eur_per_kwh": NIGHT_P},
|
||||
]}
|
||||
|
||||
FULLY_FIXED = {"periods": [
|
||||
{"name": "fixed", "from": "00:00", "to": "24:00", "days": "all",
|
||||
"eur_per_kwh": FIXED_P},
|
||||
]}
|
||||
|
||||
# ⚠️ THREE LEVELS, AND SECTION 3 EXISTS BECAUSE OF IT. Under two prices "cheaper
|
||||
# than the mean" and "is the minimum price" are the same set of hours, so a
|
||||
# two-period profile cannot distinguish `cheap_now`'s rule from `price == min`.
|
||||
# Here the middle level sits BELOW the mean without being the minimum (the four
|
||||
# expensive evening hours pull the mean up past it), so those two rules give
|
||||
# DIFFERENT answers at 12:00 - and section 3 proves they differ before grading
|
||||
# anything with it.
|
||||
THREE_LEVEL = {"periods": [
|
||||
{"name": "peak", "from": "17:00", "to": "21:00", "days": "all",
|
||||
"eur_per_kwh": 0.4800},
|
||||
{"name": "day", "from": "07:00", "to": "17:00", "days": "all",
|
||||
"eur_per_kwh": 0.2900},
|
||||
{"name": "evening", "from": "21:00", "to": "24:00", "days": "all",
|
||||
"eur_per_kwh": 0.2900},
|
||||
{"name": "night", "from": "00:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": 0.1900},
|
||||
]}
|
||||
|
||||
|
||||
def opts(profile=None, **over):
|
||||
o = {"price_provider": "off",
|
||||
"static_profile": "" if profile is None else json.dumps(profile)}
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("0. the arithmetic this file grades with must not be degenerate")
|
||||
# ⚠️ These ARE the guard against a section that passes for the wrong reason. If
|
||||
# any goes red, every shifting assertion below is measuring nothing.
|
||||
check("day and night prices differ, so 'used the wrong window' is visible",
|
||||
DAY_P != NIGHT_P)
|
||||
check("...and neither is zero, so 'dropped the price entirely' is visible",
|
||||
DAY_P != 0 and NIGHT_P != 0)
|
||||
check("...and the fixed price is neither of them, so a fixed-tariff result "
|
||||
"cannot be a day/night result in disguise",
|
||||
FIXED_P not in (DAY_P, NIGHT_P))
|
||||
check("...and the day/night spread is not the fixed price, so 'reported the "
|
||||
"spread where the price was wanted' is visible",
|
||||
not near(DAY_P - NIGHT_P, FIXED_P))
|
||||
_lv = sorted({p["eur_per_kwh"] for p in THREE_LEVEL["periods"]})
|
||||
check(f"the three-level fixture really has three levels {_lv}", len(_lv) == 3)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("1. AC1 - the two-period profile puts the right window on the right hour")
|
||||
|
||||
TWO = parse_static_profile(json.dumps(TWO_PERIOD))
|
||||
check("a JSON string parses (what config.yaml's flat `str?` option carries)",
|
||||
isinstance(TWO, StaticProfile))
|
||||
check("...and so does an already-parsed dict (what /data/options.json would "
|
||||
"carry under a nested schema, and what sim/inject.py hands over)",
|
||||
isinstance(parse_static_profile(TWO_PERIOD), StaticProfile))
|
||||
check("...and a bare list of periods, without the wrapper",
|
||||
isinstance(parse_static_profile(TWO_PERIOD["periods"]), StaticProfile))
|
||||
|
||||
# ⚠️ THE PRODUCT IS DERIVED FROM THE DATA. Nobody ticks a box saying 'static'.
|
||||
check("the product is derived from the number of distinct prices, not declared: "
|
||||
"two levels -> STATIC", TWO.product is PriceProduct.STATIC)
|
||||
check("...and one level -> FIXED, from the same code, with no flag anywhere",
|
||||
parse_static_profile(FULLY_FIXED).product is PriceProduct.FIXED)
|
||||
# ...and it is really the PRICES, not the row count: three rows, one price.
|
||||
_three_rows_one_price = parse_static_profile({"periods": [
|
||||
dict(p, eur_per_kwh=FIXED_P) for p in TWO_PERIOD["periods"]]})
|
||||
check("...and THREE rows carrying ONE price is still FIXED, so the derivation "
|
||||
"is on the prices and not on len(periods)",
|
||||
_three_rows_one_price.product is PriceProduct.FIXED
|
||||
and len(_three_rows_one_price.periods) == 3)
|
||||
|
||||
# ⚠️ PROBES AT THE BOUNDARY, NOT IN THE MIDDLE OF A WINDOW. 2026-01-15 is a
|
||||
# Thursday and Belgium is on CET (+01:00) then, so local == UTC + 1 h.
|
||||
THU = datetime(2026, 1, 15, tzinfo=UTC)
|
||||
|
||||
|
||||
def at_local(day, hh, mm=0):
|
||||
"""A UTC instant whose BRUSSELS wall clock reads hh:mm on `day`.
|
||||
|
||||
Built by search rather than by adding an offset, so this helper cannot
|
||||
inherit the same offset bug it is meant to detect.
|
||||
"""
|
||||
for minutes in range(-180, 24 * 60 + 180):
|
||||
u = day + timedelta(minutes=minutes)
|
||||
loc = brussels(u)
|
||||
if loc.hour == hh and loc.minute == mm and loc.date() == \
|
||||
brussels(day + timedelta(hours=12)).date():
|
||||
return u
|
||||
raise AssertionError(f"no instant reads {hh:02d}:{mm:02d} local on {day}")
|
||||
|
||||
|
||||
_probes = [(6, 59, "night"), (7, 0, "day"), (7, 1, "day"),
|
||||
(21, 59, "day"), (22, 0, "night"), (22, 1, "night"),
|
||||
(12, 0, "day"), (3, 0, "night")]
|
||||
_got = [(hh, mm, TWO.at(at_local(THU, hh, mm)).name) for hh, mm, _ in _probes]
|
||||
check(f"the half-open boundaries land exactly - 06:59 night, 07:00 day, "
|
||||
f"21:59 day, 22:00 night {_got}",
|
||||
all(name == want for (hh, mm, want), (_, __, name)
|
||||
in zip(_probes, _got)))
|
||||
check("...and the 22:00-07:00 window really wraps midnight: Thursday 03:00 is "
|
||||
"night, which a [22:00,24:00) reading would call uncovered",
|
||||
TWO.at(at_local(THU, 3)).name == "night")
|
||||
|
||||
# ⚠️ THE DAY-TYPE TRAP. A `mon-fri` night window must NOT bleed into Saturday
|
||||
# morning - a period applies on the day the CLOCK reads. The weekend row is what
|
||||
# covers Saturday 03:00, and it is a DIFFERENT Period object.
|
||||
SAT = datetime(2026, 1, 17, tzinfo=UTC) # a Saturday
|
||||
_sat3 = TWO.at(at_local(SAT, 3))
|
||||
_thu3 = TWO.at(at_local(THU, 3))
|
||||
check("Saturday 03:00 is served by the WEEKEND row, not by Friday's night "
|
||||
"window bleeding over - same name and price, different period",
|
||||
_sat3.name == _thu3.name and _sat3 is not _thu3
|
||||
and 5 in _sat3.days and 5 not in _thu3.days)
|
||||
check("...and Saturday 12:00 is night-priced, because a Belgian weekend tariff "
|
||||
"runs all day - a weekday-shaped lookup would call it day",
|
||||
near(TWO.price_at(at_local(SAT, 12)), NIGHT_P))
|
||||
check("...while Thursday 12:00 is day-priced, so the two are really different "
|
||||
"answers and not one answer twice",
|
||||
near(TWO.price_at(at_local(THU, 12)), DAY_P))
|
||||
|
||||
# next_change: the shifting primitive - "how long until the cheap window".
|
||||
_nc = TWO.next_change(at_local(THU, 12))
|
||||
check(f"next_change from Thursday 12:00 is the 22:00 flip "
|
||||
f"{None if _nc is None else brussels(_nc).isoformat()}",
|
||||
_nc is not None and brussels(_nc).hour == 22 and brussels(_nc).minute == 0)
|
||||
_nc2 = TWO.next_change(at_local(SAT, 12))
|
||||
check("...and from Saturday noon it is Monday 07:00, because the whole weekend "
|
||||
"is one window - a scan that stopped at midnight would say 24 h",
|
||||
_nc2 is not None and brussels(_nc2).hour == 7
|
||||
and brussels(_nc2).weekday() == 0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2. AC2 - a fully-fixed tariff disables arbitrage, by arithmetic")
|
||||
|
||||
FIX = parse_static_profile(FULLY_FIXED)
|
||||
# ⚠️ The window OPENS A DAY EARLY. `at_local(THU, 0)` is 23:00 UTC on the 14th -
|
||||
# Belgium is an hour ahead - so a plan starting at THU would not cover midnight
|
||||
# local, and `price_at` would correctly raise for it. That raise is the right
|
||||
# behaviour (an hour outside the series is an hour with no price) but it is not
|
||||
# what these sections are grading.
|
||||
SPAN_A, SPAN_B = THU - timedelta(days=1), THU + timedelta(days=2)
|
||||
p_fixed = static_plan(opts(FULLY_FIXED), SPAN_A, SPAN_B)
|
||||
p_two = static_plan(opts(TWO_PERIOD), SPAN_A, SPAN_B)
|
||||
|
||||
check("the fixed plan really produced a series (so the refusal below is about "
|
||||
"the tariff, not about a failed read)",
|
||||
p_fixed.has_prices and len(p_fixed.read.points) == 72)
|
||||
_spread_fixed = derived(p_fixed.spread_eur_kwh)
|
||||
_spread_two = derived(p_two.spread_eur_kwh)
|
||||
check(f"...and its spread is exactly zero {_spread_fixed}",
|
||||
near(_spread_fixed, 0.0))
|
||||
check(f"...while the two-period spread is the day/night difference "
|
||||
f"{_spread_two}, re-derived here as {DAY_P - NIGHT_P:.4f}",
|
||||
near(_spread_two, DAY_P - NIGHT_P, 1e-9))
|
||||
|
||||
_v_fixed = p_fixed.arbitrage(peak_guard_active=False)
|
||||
_v_two = p_two.arbitrage(peak_guard_active=False)
|
||||
check("a fully-fixed tariff REFUSES arbitrage", _v_fixed.kind == "refused")
|
||||
check("...for the true reason - the series is flat, so there is nothing to buy "
|
||||
"low and sell high, not because a FIXED enum was matched on",
|
||||
"flat" in _v_fixed.reason and "self-consumption only" in _v_fixed.reason)
|
||||
check("...and a two-period tariff, on the same code path, ALLOWS it - so the "
|
||||
"refusal is discriminating rather than universal",
|
||||
_v_two.kind == "allowed")
|
||||
|
||||
# ⚠️ NOT KEYED ON THE PRODUCT ENUM. A DAY-AHEAD series that happens to print one
|
||||
# price all day must be refused for the same reason, or the rule is a label
|
||||
# check wearing arithmetic's clothes.
|
||||
_flat_dayahead = TariffPlan(
|
||||
PriceRead.series(
|
||||
[PricePoint(start=brussels(THU + timedelta(hours=i)),
|
||||
eur_per_kwh=0.2222, provisional=False) for i in range(24)],
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", THU),
|
||||
None, "provider")
|
||||
check("a DAY_AHEAD series that happens to be flat is refused too, so the rule "
|
||||
"is arithmetic and not a product label",
|
||||
_flat_dayahead.arbitrage(peak_guard_active=False).kind == "refused")
|
||||
|
||||
# The numbers an optimiser needs exist ONLY on the permitting variant.
|
||||
check("the allowed verdict carries the spread, the cheapest hour and the "
|
||||
"dearest hour - the whole input set for a trade",
|
||||
near(_v_two.spread_eur_kwh, DAY_P - NIGHT_P)
|
||||
and near(_v_two.cheapest.eur_per_kwh, NIGHT_P)
|
||||
and near(_v_two.dearest.eur_per_kwh, DAY_P))
|
||||
check("...and a REFUSED verdict carries none of them: .spread_eur_kwh raises "
|
||||
"Absence rather than answering 0.0, which is the number that would make "
|
||||
"every hour look equally good",
|
||||
raises(lambda: _v_fixed.spread_eur_kwh, Absence)
|
||||
and raises(lambda: _v_fixed.cheapest, Absence))
|
||||
check("...and it is not iterable and has no .get(), the other two routes from "
|
||||
"a refusal to an empty, harmless-looking loop",
|
||||
raises(lambda: list(_v_fixed), Absence)
|
||||
and raises(lambda: _v_fixed.get("spread_eur_kwh", 0.0), Absence))
|
||||
check("...and match() demands BOTH arms, so the refusal cannot be fallen "
|
||||
"through", raises(lambda: _v_fixed.match(allowed=lambda v: 1), TypeError))
|
||||
check("...and there is no falsy variant, so `if verdict:` cannot look like a "
|
||||
"guard while being none", bool(_v_fixed) is True and bool(_v_two) is True)
|
||||
|
||||
# §10.1 priority 2 over priority 3, expressed so OPT-04 cannot skip it.
|
||||
check("peak shaving outranks arbitrage: the SAME allowed plan is refused when "
|
||||
"the peak guard is holding",
|
||||
p_two.arbitrage(peak_guard_active=True).kind == "refused")
|
||||
check("...and the reason names the ordering rather than the tariff",
|
||||
"priority 2" in p_two.arbitrage(peak_guard_active=True).reason)
|
||||
check("...and the question cannot be skipped: peak_guard_active is required "
|
||||
"and keyword-only, so a spread is not obtainable without answering it",
|
||||
raises(lambda: p_two.arbitrage(), TypeError)
|
||||
and raises(lambda: p_two.arbitrage(False), TypeError))
|
||||
check("...nor answered with a truthy 'unknown'",
|
||||
raises(lambda: p_two.arbitrage(peak_guard_active=None), TypeError)
|
||||
and raises(lambda: p_two.arbitrage(peak_guard_active="no"), TypeError))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. the cheap-window rule, on a tariff where it is not degenerate")
|
||||
# ⚠️ WHY THREE LEVELS. Under two prices, "below the mean" and "equal to the
|
||||
# minimum" select the SAME hours, so a two-period profile cannot tell
|
||||
# cheap_now()'s actual rule from `price == min(prices)`. This fixture's `day`
|
||||
# level sits between the two, and the check below PROVES the two rules disagree
|
||||
# before anything is graded with it.
|
||||
|
||||
THREE = parse_static_profile(THREE_LEVEL)
|
||||
p3 = static_plan(opts(THREE_LEVEL), SPAN_A,
|
||||
SPAN_A + timedelta(days=MEAN_WINDOW_DAYS))
|
||||
_mean3 = p3.mean_eur_kwh()
|
||||
_min3 = min(p.eur_per_kwh for p in p3.read.points)
|
||||
_day3 = 0.2900
|
||||
check(f"the three-level fixture is non-degenerate: the minimum {_min3:.4f} is "
|
||||
f"strictly below the middle level {_day3:.4f}, which is strictly below "
|
||||
f"the mean {_mean3:.4f} - so 'below the mean' and 'is the minimum' select "
|
||||
f"DIFFERENT sets of hours and this fixture can tell them apart",
|
||||
_min3 < _day3 < _mean3)
|
||||
check("...so cheap_now() calls the 07:00-17:00 MIDDLE level cheap - it is under "
|
||||
"the mean without being the cheapest price of the day, which a "
|
||||
"`price == min(prices)` rule would get wrong and a `price < mean` rule "
|
||||
"gets right", p3.cheap_now(at_local(THU, 12)) is True)
|
||||
check("...and the middle level really is not the minimum, so that check graded "
|
||||
"the difference rather than restating it",
|
||||
not near(p3.price_at(at_local(THU, 12)), _min3))
|
||||
check("...and calls the 00:00-07:00 minimum cheap",
|
||||
p3.cheap_now(at_local(THU, 3)) is True)
|
||||
check("...and the 17:00-21:00 peak not cheap",
|
||||
p3.cheap_now(at_local(THU, 18)) is False)
|
||||
|
||||
# ⚠️ STRICTLY BELOW, AND THAT IS AC2 IN THE SHIFTING HALF. On a flat series
|
||||
# `<=` would make EVERY hour a cheap window and a fixed-tariff site would top
|
||||
# the battery up from the grid at any hour - the opposite of self-consumption
|
||||
# only.
|
||||
_fixed_hours = [at_local(THU, h) for h in range(0, 24, 3)]
|
||||
check(f"a fully-fixed tariff has NO cheap window at any hour of the day "
|
||||
f"{[h for h in range(0, 24, 3) if p_fixed.cheap_now(at_local(THU, h))]} - "
|
||||
f"which is what `<` buys and `<=` would destroy",
|
||||
not any(p_fixed.cheap_now(t) for t in _fixed_hours))
|
||||
check("...and the price really is defined at every one of those hours, so the "
|
||||
"'no cheap window' answer is a priced no and not a missing read",
|
||||
all(near(p_fixed.price_at(t), FIXED_P) for t in _fixed_hours))
|
||||
# ...and on the two-period tariff the cheap hours are exactly the night ones.
|
||||
_cheap_h = {h for h in range(24) if p_two.cheap_now(at_local(THU, h))}
|
||||
_night_h = {h for h in range(24) if TWO.at(at_local(THU, h)).name == "night"}
|
||||
check(f"on the day/night tariff the cheap hours ARE the night hours, derived "
|
||||
f"two ways {sorted(_cheap_h)}",
|
||||
_cheap_h == _night_h and 0 < len(_cheap_h) < 24)
|
||||
|
||||
# ⚠️ THE MEAN IS OVER SEVEN DAYS, NOT OVER THE HOUR YOU ASKED AT. A 48 h mean on
|
||||
# a Friday afternoon is ~2/3 weekend, so the same Tuesday 09:00 would come out
|
||||
# cheap or dear depending on when the question was put.
|
||||
FRI = datetime(2026, 1, 16, tzinfo=UTC)
|
||||
_from_thu = static_status(opts(TWO_PERIOD), at_local(THU, 12))
|
||||
_from_fri = static_status(opts(TWO_PERIOD), at_local(FRI, 15))
|
||||
_mean_thu = static_plan(opts(TWO_PERIOD), at_local(THU, 12),
|
||||
at_local(THU, 12) + timedelta(days=MEAN_WINDOW_DAYS)
|
||||
).mean_eur_kwh()
|
||||
_mean_fri = static_plan(opts(TWO_PERIOD), at_local(FRI, 15),
|
||||
at_local(FRI, 15) + timedelta(days=MEAN_WINDOW_DAYS)
|
||||
).mean_eur_kwh()
|
||||
check(f"the 7-day mean is stable across the day it is asked on "
|
||||
f"({_mean_thu:.4f} vs {_mean_fri:.4f}), so a window's cheapness is a "
|
||||
f"property of the tariff and not of the clock",
|
||||
near(_mean_thu, _mean_fri, 5e-4))
|
||||
_mean48_thu = static_plan(opts(TWO_PERIOD), at_local(THU, 12),
|
||||
at_local(THU, 12) + timedelta(days=2)).mean_eur_kwh()
|
||||
_mean48_fri = static_plan(opts(TWO_PERIOD), at_local(FRI, 15),
|
||||
at_local(FRI, 15) + timedelta(days=2)).mean_eur_kwh()
|
||||
check(f"...and the 48 h mean this replaces is NOT stable ({_mean48_thu:.4f} vs "
|
||||
f"{_mean48_fri:.4f}), so the seven days are buying something real",
|
||||
not near(_mean48_thu, _mean48_fri, 5e-4))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("4. DST - 23 and 25 hour days, where a 24-slot implementation is wrong")
|
||||
# ⚠️ 2026: CEST from 01:00 UTC Sunday 29 March, CET from 01:00 UTC Sunday
|
||||
# 25 October (price.py's brussels_offset, checked in PR #16 against zoneinfo
|
||||
# over 149,040 instants). Both are SUNDAYS, so the weekend row prices them -
|
||||
# which is itself worth knowing, because the wrap-around weekday row is not what
|
||||
# is under test here.
|
||||
SPRING = datetime(2026, 3, 29, tzinfo=UTC)
|
||||
AUTUMN = datetime(2026, 10, 25, tzinfo=UTC)
|
||||
|
||||
|
||||
def local_day_points(profile, day_utc):
|
||||
"""Hourly points whose BRUSSELS date is that of `day_utc` + 12 h."""
|
||||
target = brussels(day_utc + timedelta(hours=12)).date()
|
||||
pts = profile.series(day_utc - timedelta(hours=6),
|
||||
day_utc + timedelta(hours=30), provisional=False)
|
||||
return [p for p in pts if p.start.date() == target]
|
||||
|
||||
|
||||
_spring = derived(lambda: local_day_points(TWO, SPRING))
|
||||
_autumn = derived(lambda: local_day_points(TWO, AUTUMN))
|
||||
check(f"the spring-forward local day emits 23 points, not 24 "
|
||||
f"({len(_spring) if not isinstance(_spring, _Raised) else _spring})",
|
||||
not isinstance(_spring, _Raised) and len(_spring) == 23)
|
||||
check(f"the autumn-back local day emits 25 points, not 24 "
|
||||
f"({len(_autumn) if not isinstance(_autumn, _Raised) else _autumn})",
|
||||
not isinstance(_autumn, _Raised) and len(_autumn) == 25)
|
||||
check("...and the two 02:00 points in October are DIFFERENT instants at "
|
||||
"DIFFERENT offsets, which a naive local-hour key would collide",
|
||||
len({p.start.utcoffset() for p in _autumn if p.start.hour == 2}) == 2)
|
||||
check("...and every emitted start is strictly increasing in UTC, which is what "
|
||||
"price.py's boundary check demands of a fetched feed too",
|
||||
all(a.start.astimezone(UTC) < b.start.astimezone(UTC)
|
||||
for a, b in zip(_autumn, _autumn[1:])))
|
||||
# A weekday profile across a transition, so the wrap-around path sees one too.
|
||||
_wrapdays = local_day_points(TWO, datetime(2026, 3, 30, tzinfo=UTC))
|
||||
check("a normal Monday still emits 24, so the 23/25 above are the transitions "
|
||||
"and not a systematic miscount", len(_wrapdays) == 24)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5. a failed read is not an observation, and a gap is not a price")
|
||||
|
||||
_none = static_plan(opts(None), THU, THU + timedelta(days=1))
|
||||
check("no static_profile produces a NO-DATA read, not an empty series and not "
|
||||
"a zero one", not _none.has_prices)
|
||||
check("...and reading a price off it RAISES rather than answering 0.0 - the "
|
||||
"value that would make every hour look free",
|
||||
raises(lambda: _none.price_at(THU), Absence)
|
||||
and raises(_none.mean_eur_kwh, Absence)
|
||||
and raises(_none.spread_eur_kwh, Absence))
|
||||
check("...and its underlying read has no .points at all, so `min(read.points)` "
|
||||
"cannot run against it",
|
||||
raises(lambda: _none.read.points, Absence))
|
||||
check("...and arbitrage is refused, per §11.4",
|
||||
_none.arbitrage(peak_guard_active=False).kind == "refused"
|
||||
and arbitrage_allowed(_none.read) is False)
|
||||
check("...and static_status() says so in words rather than reporting a price",
|
||||
static_status(opts(None), THU) == (False, "no static profile"))
|
||||
|
||||
# ⚠️ THE TILING REFUSAL. An uncovered hour has no price and the only ways to
|
||||
# serve it are to invent one or to fail when the clock reaches it.
|
||||
_gap = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
|
||||
"eur_per_kwh": DAY_P},
|
||||
{"name": "night", "from": "23:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": NIGHT_P}]}
|
||||
check("a profile with a 22:00-23:00 gap is REFUSED, not defaulted",
|
||||
raises(lambda: parse_static_profile(_gap), ProfileInvalid))
|
||||
check("...and names the uncovered range so the operator can fix it",
|
||||
"22:00-23:00" in reason(lambda: parse_static_profile(_gap)))
|
||||
_overlap = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
|
||||
"eur_per_kwh": DAY_P},
|
||||
{"name": "night", "from": "21:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": NIGHT_P}]}
|
||||
check("...and an overlap is refused too - two prices for one hour means one "
|
||||
"wins on list order",
|
||||
raises(lambda: parse_static_profile(_overlap), ProfileInvalid)
|
||||
and "2 prices" in reason(lambda: parse_static_profile(_overlap)))
|
||||
# THE CLASSIC MISTAKE the docstring warns about: no weekend row.
|
||||
_noweekend = {"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
|
||||
"eur_per_kwh": DAY_P},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
|
||||
"eur_per_kwh": NIGHT_P}]}
|
||||
check("a weekday-only profile is refused because Saturday and Sunday have no "
|
||||
"price - the mistake a wrap-around window invites",
|
||||
raises(lambda: parse_static_profile(_noweekend), ProfileInvalid)
|
||||
and "sat" in reason(lambda: parse_static_profile(_noweekend)))
|
||||
# ...and the shipped shape is accepted, so the rule is not simply "refuse all".
|
||||
check("...while the three-row Belgian shape IS accepted, so the tiling rule "
|
||||
"discriminates rather than refusing everything",
|
||||
isinstance(parse_static_profile(TWO_PERIOD), StaticProfile))
|
||||
|
||||
_bad = [
|
||||
("not JSON at all", "day 07:00-22:00 0.34"),
|
||||
("a price in cents", {"periods": [dict(FULLY_FIXED["periods"][0],
|
||||
eur_per_kwh=30.7)]}),
|
||||
("a non-numeric price", {"periods": [dict(FULLY_FIXED["periods"][0],
|
||||
eur_per_kwh="cheap")]}),
|
||||
("a missing days selector", {"periods": [
|
||||
{"name": "x", "from": "00:00", "to": "24:00", "eur_per_kwh": 0.3}]}),
|
||||
("an unknown day name", {"periods": [dict(FULLY_FIXED["periods"][0],
|
||||
days="mon-frd")]}),
|
||||
("a decimal hour", {"periods": [dict(FULLY_FIXED["periods"][0],
|
||||
**{"from": "7.5"})]}),
|
||||
("an unsupported timezone", dict(FULLY_FIXED, timezone="Europe/Madrid")),
|
||||
("an empty periods list", {"periods": []}),
|
||||
]
|
||||
for label, value in _bad:
|
||||
payload = value if isinstance(value, str) else json.dumps(value)
|
||||
check(f"refused: {label}",
|
||||
raises(lambda p=payload: parse_static_profile(p), ProfileInvalid))
|
||||
check("...and every one of those refusals says something an operator can act "
|
||||
"on, rather than just failing",
|
||||
all(len(reason(lambda p=(v if isinstance(v, str) else json.dumps(v)):
|
||||
parse_static_profile(p))) > 40 for _, v in _bad))
|
||||
for label, value in (("empty", ""), ("null", "null"), ("absent", None),
|
||||
("whitespace", " ")):
|
||||
check(f"...while {label!r} means 'no profile' rather than an error",
|
||||
parse_static_profile(value) is None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("6. AC3 - the static-profile regression case, on the Belgian data that exists")
|
||||
# ⚠️ WHAT THE "BELGIAN SCENARIO SUITE" ACTUALLY IS IN THIS REPO. specs §17 names
|
||||
# six scenarios; none of them is a static-profile case, which is why AC3 asks
|
||||
# for a seventh. What exists as DATA is two days of this house's real P1 net
|
||||
# power, captured from the live meter and committed as
|
||||
# `scenarios/p1-net-2026-08-{20,23}.jsonl.gz` - the same fixtures test_tariff.py
|
||||
# part 9 replays for the capacity tariff. Nothing longer was ever captured.
|
||||
#
|
||||
# So this case is measured against REAL BELGIAN HOUSEHOLD LOAD, and the two days
|
||||
# happen to be one weekday and one weekend day, which is exactly the pair a
|
||||
# day/night tariff needs to be graded on. It is NOT a 30-day replay and this
|
||||
# section does not claim to be one; the numbers it prints are the numbers those
|
||||
# two days actually produce.
|
||||
|
||||
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)
|
||||
|
||||
MAX_GAP_S = 60.0 # a longer hole in the capture is not counted as energy
|
||||
|
||||
|
||||
def replay_import(path):
|
||||
"""(kWh imported per local tariff window, total kWh, span hours, weekday).
|
||||
|
||||
⚠️ THE WINDOW IS DECIDED HERE BY AN INDEPENDENT ROUTE - the Brussels-local
|
||||
hour of the sample, tested against the fixture's own 07:00/22:00 boundaries
|
||||
- rather than by asking the module under test. The two must agree; that
|
||||
agreement is the check.
|
||||
"""
|
||||
per_window = {}
|
||||
total = 0.0
|
||||
first = last = None
|
||||
prev = None
|
||||
days = set()
|
||||
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
row = json.loads(line)
|
||||
t = datetime.fromisoformat(row["t"])
|
||||
w = float(row["w"])
|
||||
if first is None:
|
||||
first = t
|
||||
last = t
|
||||
if prev is not None:
|
||||
dt = (t - prev[0]).total_seconds()
|
||||
if 0 < dt <= MAX_GAP_S and prev[1] > 0:
|
||||
kwh = prev[1] * dt / 3_600_000.0
|
||||
loc = brussels(prev[0])
|
||||
days.add(loc.weekday())
|
||||
window = ("day" if loc.weekday() < 5
|
||||
and 7 <= loc.hour < 22 else "night")
|
||||
per_window[window] = per_window.get(window, 0.0) + kwh
|
||||
total += kwh
|
||||
prev = (t, w)
|
||||
span = (last - first).total_seconds() / 3600.0 if first else 0.0
|
||||
return per_window, total, span, days
|
||||
|
||||
|
||||
def cost_under(path, profile):
|
||||
"""EUR the imported energy cost, priced sample by sample by the MODULE."""
|
||||
total = 0.0
|
||||
prev = None
|
||||
with gzip.open(path, "rt", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
row = json.loads(line)
|
||||
t = datetime.fromisoformat(row["t"])
|
||||
w = float(row["w"])
|
||||
if prev is not None:
|
||||
dt = (t - prev[0]).total_seconds()
|
||||
if 0 < dt <= MAX_GAP_S and prev[1] > 0:
|
||||
total += (prev[1] * dt / 3_600_000.0) * profile.price_at(prev[0])
|
||||
prev = (t, w)
|
||||
return total
|
||||
|
||||
|
||||
replay_days = 0.0
|
||||
graded = 0
|
||||
for path in REPLAY:
|
||||
windows, total_kwh, span_h, weekdays = replay_import(path)
|
||||
replay_days += span_h / 24.0
|
||||
day_kwh = windows.get("day", 0.0)
|
||||
night_kwh = windows.get("night", 0.0)
|
||||
kind = "weekday" if weekdays and min(weekdays) < 5 else "weekend"
|
||||
print(f" {path.name}: {span_h:.1f} h, {kind} "
|
||||
f"(weekday indices {sorted(weekdays)}), {total_kwh:.3f} kWh imported "
|
||||
f"= {day_kwh:.3f} day + {night_kwh:.3f} night")
|
||||
|
||||
check(f"{path.name}: the two routes to the total agree",
|
||||
near(day_kwh + night_kwh, total_kwh, 1e-9))
|
||||
|
||||
# The module's own pricing must reproduce the independently-split volumes.
|
||||
c_two = cost_under(path, TWO)
|
||||
c_expect = day_kwh * DAY_P + night_kwh * NIGHT_P
|
||||
check(f"{path.name}: the module prices the real day at "
|
||||
f"EUR {c_two:.4f}, which equals the independently-split "
|
||||
f"{day_kwh:.3f} kWh x {DAY_P} + {night_kwh:.3f} kWh x {NIGHT_P} = "
|
||||
f"EUR {c_expect:.4f}", near(c_two, c_expect, 1e-9))
|
||||
|
||||
c_fix = cost_under(path, FIX)
|
||||
check(f"{path.name}: under a FULLY FIXED tariff the same energy costs "
|
||||
f"EUR {c_fix:.4f} = {total_kwh:.3f} kWh x {FIXED_P}, one price for "
|
||||
f"every hour", near(c_fix, total_kwh * FIXED_P, 1e-9))
|
||||
|
||||
# ⚠️ THE SHIFTING FIGURE, AND THE DEGENERACY GUARD IN FRONT OF IT. A day
|
||||
# whose entire import landed in one window grades nothing: the saving would
|
||||
# be 0 under a correct implementation AND under one that ignored the clock.
|
||||
if day_kwh > 0 and night_kwh > 0:
|
||||
graded += 1
|
||||
saving = c_two - total_kwh * NIGHT_P
|
||||
check(f"{path.name}: this day DOES import in both windows "
|
||||
f"({day_kwh:.3f} / {night_kwh:.3f} kWh), so the split grades "
|
||||
f"something", True)
|
||||
check(f"{path.name}: shifting every day-window kWh into the night "
|
||||
f"window saves EUR {saving:.4f}, which is exactly "
|
||||
f"{day_kwh:.3f} kWh x the {DAY_P - NIGHT_P:.4f} spread - two "
|
||||
f"routes to one number",
|
||||
near(saving, day_kwh * (DAY_P - NIGHT_P), 1e-9) and saving > 0)
|
||||
# AC2 on the same real data: under a fixed tariff there is nothing to
|
||||
# shift, and that is arithmetic rather than a special case.
|
||||
check(f"{path.name}: under the FIXED tariff the identical shift saves "
|
||||
f"exactly nothing, so self-consumption-only is the right "
|
||||
f"behaviour for that product",
|
||||
near(c_fix - total_kwh * FIXED_P, 0.0, 1e-12))
|
||||
else:
|
||||
print(f" {path.name}: import fell in ONE window only "
|
||||
f"({day_kwh:.3f} day / {night_kwh:.3f} night) - this day is a "
|
||||
f"day-typing witness, not a shifting one, and no saving is "
|
||||
f"claimed from it")
|
||||
|
||||
print()
|
||||
print(f" AC3 MEASURED: {replay_days:.1f} DAYS of real Belgian household "
|
||||
f"P1 data, {graded} of {len(REPLAY)} days carrying import in BOTH tariff "
|
||||
f"windows and therefore grading the split.")
|
||||
print(f" AC3 ASKS FOR A REGRESSION CASE IN THE BELGIAN SUITE, AND THIS IS "
|
||||
f"ONE - but the suite holds two captured days, not a season, so nothing "
|
||||
f"here is a claim about annual savings. §17's six named scenarios do not "
|
||||
f"include a static-profile case; this is the seventh, and TEST-01 should "
|
||||
f"say so.")
|
||||
check("the replay produced a measurable split over a stated sample size, "
|
||||
"rather than a criterion quietly redefined to fit the data",
|
||||
graded >= 1 and replay_days < 30)
|
||||
check("...and BOTH day types are present in the fixtures, which is what makes "
|
||||
"the weekend row testable on real data at all",
|
||||
len({("weekday" if min(replay_import(p)[3]) < 5 else "weekend")
|
||||
for p in REPLAY}) == 2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("7. §11.4 - the profile is the fallback, and the fallback is provisional")
|
||||
|
||||
|
||||
async def _dead(url, timeout_s):
|
||||
raise OSError("the supplier's feed is down")
|
||||
|
||||
|
||||
def run(coro):
|
||||
import asyncio
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
_cfg = opts(TWO_PERIOD, price_provider="generic_dayahead",
|
||||
price_feed_url="https://api.energy-charts.info/price?bzn=BE",
|
||||
price_timeout_s=1, price_markup_eur_kwh=0.03,
|
||||
price_taxes_eur_kwh=0.06)
|
||||
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead))
|
||||
check("a dead feed falls back to the static profile rather than to nothing",
|
||||
_fb.origin == "fallback" and _fb.has_prices)
|
||||
check("...with the SAME prices the configured-product path produces, because "
|
||||
"it is one generator and not a second implementation",
|
||||
[p.eur_per_kwh for p in _fb.read.points]
|
||||
== [p.eur_per_kwh for p in static_plan(
|
||||
opts(TWO_PERIOD), THU, THU + timedelta(days=1)).read.points])
|
||||
check("...marked PROVISIONAL, which is the one flag that differs",
|
||||
_fb.provisional and plan_is_provisional(_fb.read))
|
||||
check("...and arbitrage is refused on it, per §11.4, even though a full series "
|
||||
"exists and has a real spread",
|
||||
_fb.arbitrage(peak_guard_active=False).kind == "refused"
|
||||
and near(_fb.spread_eur_kwh(), DAY_P - NIGHT_P))
|
||||
check("...while the SAME profile as the configured product allows it - so the "
|
||||
"refusal is about provisionality and not about the profile",
|
||||
static_plan(opts(TWO_PERIOD), THU, THU + timedelta(days=1)
|
||||
).arbitrage(peak_guard_active=False).kind == "allowed")
|
||||
check("...and the provisional plan has no cheap window either: §8.3 forbids an "
|
||||
"irreversible action on a provisional price, and a grid top-up is one",
|
||||
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
|
||||
_fb_none = run(resolve_tariff(opts(None, price_provider="generic_dayahead",
|
||||
price_timeout_s=1),
|
||||
THU, THU + timedelta(days=1), transport=_dead))
|
||||
check("a dead feed with NO static_profile has nothing to fall back to, and "
|
||||
"says that rather than inventing a tariff",
|
||||
_fb_none.origin == "none" and not _fb_none.has_prices)
|
||||
|
||||
# A live feed still wins over the profile.
|
||||
_body = json.dumps({"data": [
|
||||
{"start": (THU + timedelta(hours=i)).isoformat(), "price_eur_mwh": 40 + i}
|
||||
for i in range(24)]}).encode()
|
||||
|
||||
|
||||
async def _live(url, timeout_s):
|
||||
return _body
|
||||
|
||||
|
||||
_live_plan = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1),
|
||||
transport=_live))
|
||||
check("a live feed is used in preference to the static profile",
|
||||
_live_plan.origin == "provider"
|
||||
and _live_plan.product is PriceProduct.DAY_AHEAD)
|
||||
check("...and arbitrage is allowed on it, with the spread derived from the "
|
||||
"feed rather than from the profile",
|
||||
_live_plan.arbitrage(peak_guard_active=False).kind == "allowed"
|
||||
and near(_live_plan.arbitrage(peak_guard_active=False).spread_eur_kwh,
|
||||
0.023, 1e-9))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("8. the startup gate, and CFG-03")
|
||||
|
||||
|
||||
def vbase(**over):
|
||||
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off",
|
||||
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
def why(problems, needle):
|
||||
return any(needle in p for p in problems)
|
||||
|
||||
|
||||
check("a good static_profile starts",
|
||||
validate_startup_config(
|
||||
vbase(static_profile=json.dumps(TWO_PERIOD)), -5000, 5000) == [])
|
||||
check("a gapped one is REFUSED at startup, not at 22:00 on the day it bites",
|
||||
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
|
||||
-5000, 5000),
|
||||
"static_profile cannot be used"))
|
||||
check("...and the refusal names the uncovered range",
|
||||
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
|
||||
-5000, 5000), "22:00-23:00"))
|
||||
check("static_profile alongside the Home Assistant price entities is refused - "
|
||||
"main.py reads one of them and the loser is silent",
|
||||
why(validate_startup_config(
|
||||
vbase(static_profile=json.dumps(TWO_PERIOD),
|
||||
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
|
||||
-5000, 5000), "HOME ASSISTANT"))
|
||||
check("...and the Home Assistant pair ALONE still starts, so nothing is taken "
|
||||
"away from a live install",
|
||||
validate_startup_config(vbase(price_now_entity="sensor.now",
|
||||
price_avg_entity="sensor.avg"),
|
||||
-5000, 5000) == [])
|
||||
check("...and an empty static_profile is never refused: §9.2 puts a large share "
|
||||
"of the installed base on a fixed tariff, and §11.4 makes no-price-data a "
|
||||
"supported state",
|
||||
validate_startup_config(vbase(static_profile=""), -5000, 5000) == []
|
||||
and validate_startup_config(vbase(), -5000, 5000) == [])
|
||||
check("...and a fully-fixed profile starts, since a one-price site is a "
|
||||
"supported product and not a misconfiguration",
|
||||
validate_startup_config(vbase(static_profile=json.dumps(FULLY_FIXED)),
|
||||
-5000, 5000) == [])
|
||||
|
||||
# ⚠️ CFG-03 IS NOT WIDENED BY THIS TICKET. `static_profile` was added to
|
||||
# app/price.py's PRICE_OPTION_DEFAULTS - the ONE in-code mirror TARIFF-02
|
||||
# created and pinned to config.yaml - rather than to a second defaults dict in
|
||||
# the new module. That mirror is enforced by test_price.py section 1, which
|
||||
# reads config.yaml; this check is the other half of the claim: the new module
|
||||
# must carry NO defaults dict of its own.
|
||||
_SRC = (HERE / "app" / "static_tariff.py").read_text(encoding="utf-8")
|
||||
check("the new module holds no option-defaults dict of its own, so this ticket "
|
||||
"added ZERO new places an option default lives (CFG-03)",
|
||||
"OPTION_DEFAULTS" not in _SRC.replace("PRICE_OPTION_DEFAULTS", ""))
|
||||
check("...and it reads the option through price.price_option(), the framework's "
|
||||
"single read path, rather than through opts.get(key, <a number>)",
|
||||
"price_option(opts" in _SRC and 'opts.get("static_profile"' not in _SRC)
|
||||
_MAIN = (HERE / "app" / "main.py").read_text(encoding="utf-8")
|
||||
check("...and main.py grew no `o.get(\"static_profile\", <a default>)` fallback "
|
||||
"either - it calls static_status(o, now), and the option is read once, "
|
||||
"inside the framework",
|
||||
'get("static_profile"' not in _MAIN and "static_status(" in _MAIN)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1,910 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,574 +0,0 @@
|
||||
"""Runnable check for the startup gate. `python3 test_validate.py`
|
||||
|
||||
Two halves, and the second is the one that matters. The first asserts that the
|
||||
three refusable configurations are refused. The second asserts that everything
|
||||
on validate.py's "deliberately not refused" list still STARTS - because the way
|
||||
a validator dies is not by missing a fault, it is by refusing something harmless
|
||||
until somebody deletes the call.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from app.validate import (
|
||||
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
|
||||
validate_startup_config,
|
||||
)
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def base(**over):
|
||||
"""A configuration that must start. Everything else is this, spoilt."""
|
||||
# ⚠️ The maintenance rates are named EXPLICITLY, at the values config.yaml
|
||||
# ships (MAINT-04). They are not decoration: with the key absent, rule 6
|
||||
# falls back to main.py's 2500 W, which against this 2000 W ceiling is a
|
||||
# genuine refusal - so a base() without them would be a config that does
|
||||
# not start, and every check below would be measuring the wrong thing.
|
||||
o = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off",
|
||||
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
def why(problems, needle):
|
||||
return any(needle in p for p in problems)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the baseline must start, or nothing below means anything")
|
||||
check("a good config with a 5000 W device is accepted",
|
||||
validate_startup_config(base(), -5000, 5000) == [])
|
||||
check("...and with no device reachable yet, too",
|
||||
validate_startup_config(base(), None, None) == [])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. max_w against the device's own rating")
|
||||
# ⚠️ THE ORIGINAL BUG. main.py used to log 'the device wins' and run anyway,
|
||||
# with write_setpoint() clamping, so the configured ceiling was never the real
|
||||
# one and the only evidence had scrolled off the log.
|
||||
p = validate_startup_config(base(max_w=8000), -5000, 5000)
|
||||
check("max_w above the device maximum is refused", len(p) == 1)
|
||||
check("...and the message names the configured value", why(p, "8000"))
|
||||
check("...and the device's value", why(p, "5000"))
|
||||
check("...and says which one wins", why(p, "THE DEVICE WINS"))
|
||||
check("max_w exactly at the device maximum is fine",
|
||||
validate_startup_config(base(max_w=5000), -5000, 5000) == [])
|
||||
check("max_w under it is fine",
|
||||
validate_startup_config(base(max_w=2000), -5000, 5000) == [])
|
||||
# The charge side is a separate rail and an asymmetric device is legal.
|
||||
p = validate_startup_config(base(max_w=4000), -2500, 5000)
|
||||
check("a max_w the device cannot take on the CHARGE side is refused too",
|
||||
len(p) == 1 and why(p, "charge side"))
|
||||
check("...naming the device's floor", why(p, "-2500"))
|
||||
# ⚠️ Not guessed at: the entity may simply not have booted yet. run_control()
|
||||
# re-runs this the moment the real rating arrives.
|
||||
check("an unreachable device skips the range check rather than assuming",
|
||||
validate_startup_config(base(max_w=8000), None, None) == [])
|
||||
check("a non-numeric max_w is refused",
|
||||
why(validate_startup_config(base(max_w="lots"), -5000, 5000), "not a number"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("2. an actuator to write to")
|
||||
# A controller with no actuator: write_setpoint() returns on the blank entity,
|
||||
# so no rating is read, no heartbeat is sent, layer 1 pins the inverter at 0 W,
|
||||
# and the add-on looks perfectly healthy the whole time.
|
||||
for blank in ("", " ", None):
|
||||
p = validate_startup_config(base(setpoint_entity=blank), None, None)
|
||||
check(f"setpoint_entity {blank!r} is refused", len(p) == 1)
|
||||
check("...and the message says why it is dangerous rather than untidy",
|
||||
why(validate_startup_config(base(setpoint_entity=""), None, None),
|
||||
"heartbeat"))
|
||||
# specs §1.5: two writers on one actuator is not expressible in this schema.
|
||||
check("there is exactly one way to reach the actuator",
|
||||
ACTUATOR_WRITE_KEYS == ("setpoint_entity",))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("3. a meter that can never read")
|
||||
# ⚠️ These predicates belong to p1.validate_meter_options(), which build_source()
|
||||
# also uses. Asserted here to prove the gate actually consults it - one entry
|
||||
# point, not two.
|
||||
check("meter_source off asks nothing of the P1 keys",
|
||||
validate_startup_config(base(meter_source="off"), -5000, 5000) == [])
|
||||
check("homewizard_local with no p1_host is refused",
|
||||
why(validate_startup_config(base(meter_source="homewizard_local"),
|
||||
-5000, 5000), "p1_host"))
|
||||
check("...and with a host it is accepted",
|
||||
validate_startup_config(base(meter_source="homewizard_local",
|
||||
p1_host="192.168.2.250"), -5000, 5000) == [])
|
||||
check("a poll cadence at meter_max_age_s is refused at STARTUP, not just disabled",
|
||||
why(validate_startup_config(base(meter_source="homewizard_local",
|
||||
p1_host="m", meter_poll_s=30,
|
||||
meter_max_age_s=30), -5000, 5000),
|
||||
"meter_max_age_s"))
|
||||
check("ha_signed with no p1_net_entity is refused",
|
||||
why(validate_startup_config(base(meter_source="ha_signed"), -5000, 5000),
|
||||
"p1_net_entity"))
|
||||
check("a phase list disagreeing with meter_phases is refused",
|
||||
why(validate_startup_config(base(meter_source="ha_signed",
|
||||
p1_net_entity="sensor.n", meter_phases=3,
|
||||
p1_phase_net_entities=["a", "b"]),
|
||||
-5000, 5000), "phase-count"))
|
||||
check("mqtt_p1 with no topic is refused",
|
||||
why(validate_startup_config(base(meter_source="mqtt_p1"), -5000, 5000),
|
||||
"meter_mqtt_topic"))
|
||||
check("ha_dsmr with only half its entities is refused",
|
||||
why(validate_startup_config(base(meter_source="ha_dsmr",
|
||||
p1_import_entity="sensor.i"), -5000, 5000),
|
||||
"p1_export_entity"))
|
||||
check("a misspelt meter_source is refused rather than silently ignored",
|
||||
why(validate_startup_config(base(meter_source="ha_signd"), -5000, 5000),
|
||||
"is not one of"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("4. meter_max_age_s against the firmware's compile-time veto")
|
||||
# ⚠️ The schema allows int(5,300); the firmware's threshold is baked in at 30 s
|
||||
# and wins silently. Same shape as the max_w case: a configured bound overridden
|
||||
# by a lower layer, with nothing saying so.
|
||||
hw = dict(meter_source="homewizard_local", p1_host="m")
|
||||
p = validate_startup_config(base(**hw, meter_max_age_s=120), -5000, 5000)
|
||||
check("a meter_max_age_s above the firmware veto is refused", len(p) == 1)
|
||||
check("...naming the configured value", why(p, "120"))
|
||||
check("...naming the firmware's value", why(p, "30"))
|
||||
check("...and saying which one wins", why(p, "THE FIRMWARE WINS"))
|
||||
check("the tested pair 30/30 is accepted",
|
||||
validate_startup_config(base(**hw, meter_max_age_s=30), -5000, 5000) == [])
|
||||
check("one second above it is refused",
|
||||
validate_startup_config(base(**hw, meter_max_age_s=31), -5000, 5000) != [])
|
||||
# Below the veto the add-on gives up FIRST, which is fail-safe and visible on
|
||||
# its own status page. Only above is the silent case.
|
||||
check("below the veto is fine - the add-on being stricter is not a conflict",
|
||||
validate_startup_config(base(**hw, meter_max_age_s=10,
|
||||
meter_poll_s=4), -5000, 5000) == [])
|
||||
# ⚠️ Scope: meter_max_age_s is read by nothing but P1Ingest, and the veto keys
|
||||
# off a sensor only published when ingestion runs. With the meter off it governs
|
||||
# nothing, so refusing would be pure trivia.
|
||||
check("with meter_source off the number governs nothing and is not refused",
|
||||
validate_startup_config(base(meter_source="off", meter_max_age_s=300),
|
||||
-5000, 5000) == [])
|
||||
check("the constant is the firmware's, not a rounded guess",
|
||||
FIRMWARE_INGEST_VETO_S == 30.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("6. a maintenance charge the ceiling can actually accommodate (MAINT-04)")
|
||||
# ⚠️ THE SHIPPED BUG. config.yaml shipped max_w 2000 against
|
||||
# maintenance_charge_w 2500, so the cheap-window shaping asked for hi=-2500
|
||||
# against the supervised lo=-2000 - lo > hi, arbiter clause 3, 0 W. Maintenance
|
||||
# silently never charged in a cheap window on a default install.
|
||||
p = validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
|
||||
-5000, 5000)
|
||||
check("maintenance_charge_w above max_w is refused", len(p) == 1)
|
||||
check("...and the message names the charge rate", why(p, "2500"))
|
||||
check("...and the ceiling", why(p, "2000"))
|
||||
check("...and says which one wins", why(p, "THE CEILING WINS"))
|
||||
# ⚠️ Equality is REACHABLE, not a near miss: lo=-2000, hi=-2000 is the single
|
||||
# point -2000 W. Refusing it would be exactly the trivia that gets a validator
|
||||
# deleted, and it is the boundary the rule is written `>` for.
|
||||
check("maintenance_charge_w exactly at max_w starts",
|
||||
validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
|
||||
-5000, 5000) == [])
|
||||
check("below it starts",
|
||||
validate_startup_config(base(max_w=2000, maintenance_charge_w=1500),
|
||||
-5000, 5000) == [])
|
||||
# Not scoped to maintenance_enabled: the pair is contradictory either way, and
|
||||
# scoping would leave the trap armed for the one action - ticking the box - that
|
||||
# cannot be expected to change what a charge rate means.
|
||||
check("...and it is refused with maintenance_enabled off, as shipped",
|
||||
why(validate_startup_config(
|
||||
base(max_w=2000, maintenance_charge_w=2500,
|
||||
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
|
||||
|
||||
# ⚠️ THE SIBLING, CHECKED RATHER THAN ASSUMED. The shaping is NOT symmetric:
|
||||
# drain files Claim.set at P_MAINTENANCE and owns the actuator, so the ceiling
|
||||
# CLAMPS it (+2000 W) instead of contradicting it, and the arbiter's explain()
|
||||
# names the clamp every cycle. Loud, and it still moves energy - so warned, not
|
||||
# refused. If someone later "fixes the asymmetry" by making this a refusal,
|
||||
# this check goes red and they have to justify it.
|
||||
check("maintenance_discharge_w above max_w WARNS but starts",
|
||||
validate_startup_config(base(max_w=2000, maintenance_discharge_w=2500),
|
||||
-5000, 5000) == [])
|
||||
|
||||
# The demonstration the rule is derived from, asserted rather than described.
|
||||
from app.arbiter import ( # noqa: E402
|
||||
Claim, P_LOOP, P_MAINTENANCE, P_MAINT_SHAPE, P_SAFETY, resolve,
|
||||
)
|
||||
|
||||
def _charge(max_w, charge_w):
|
||||
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-charge_w,
|
||||
reason="cheap-window charge"),
|
||||
Claim.set("loop", P_LOOP, 500.0, "grid")])
|
||||
|
||||
def _drain(max_w, disch_w):
|
||||
return resolve([Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w"),
|
||||
Claim.set("maintenance", P_MAINTENANCE, disch_w, "drain"),
|
||||
Claim.set("loop", P_LOOP, 500.0, "grid")])
|
||||
|
||||
r = _charge(2000, 2500)
|
||||
check("arbiter: the refused pair really does emit 0 W",
|
||||
r.target_w == 0.0 and r.contradiction)
|
||||
r = _charge(2000, 2000)
|
||||
check("arbiter: the permitted equality really does charge",
|
||||
r.target_w == -2000.0 and not r.contradiction)
|
||||
r = _charge(3000, 2500)
|
||||
check("arbiter: raising max_w above the rate charges at the rate",
|
||||
r.target_w == -2500.0 and not r.contradiction)
|
||||
r = _drain(2000, 2500)
|
||||
check("arbiter: the drain SET is clamped, not contradicted",
|
||||
r.target_w == 2000.0 and not r.contradiction)
|
||||
check("...and the clamp names itself, which is why it is not refused",
|
||||
"supervised" in (r.bound_by or "") and "limited by" in r.explain())
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
|
||||
# ⚠️ Shipping a default the validator refuses would be worse than the bug it
|
||||
# refuses. Read config.yaml itself rather than a transcription of it - a
|
||||
# transcription is what lets the two drift apart silently. Regex, not PyYAML:
|
||||
# the add-on image has no PyYAML (Dockerfile installs py3-aiohttp/py3-paho-mqtt
|
||||
# only) and a check that skips on ImportError is not a check.
|
||||
import os # noqa: E402
|
||||
import re # noqa: E402
|
||||
|
||||
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
||||
with open(_CFG, encoding="utf-8") as _fh:
|
||||
_txt = _fh.read()
|
||||
_shipped = {k: float(v) for k, v in re.findall(
|
||||
r"^ (max_w|maintenance_charge_w|maintenance_discharge_w|sign_disagree_w): "
|
||||
r"(-?\d+(?:\.\d+)?)\s*$", _txt, re.M)}
|
||||
check("config.yaml really was read (all four defaults found)",
|
||||
set(_shipped) == {"max_w", "maintenance_charge_w",
|
||||
"maintenance_discharge_w", "sign_disagree_w"})
|
||||
check(f"the shipped defaults are accepted {_shipped}",
|
||||
validate_startup_config(base(**_shipped), -5000, 5000) == [])
|
||||
check("...and the shipped charge rate is at or under the shipped ceiling",
|
||||
_shipped["maintenance_charge_w"] <= _shipped["max_w"])
|
||||
check("...and the shipped discharge rate too, so it warns about nothing",
|
||||
_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")
|
||||
# ⚠️ WHAT THIS GUARDS, AND WHY IT ASSERTS THE BUG RATHER THAN THE FIX.
|
||||
# config.yaml and validate.py now default maintenance_charge_w to 2000, while
|
||||
# main.py's two `opts.get(..., 2500)` fallbacks and MaintConfig's dataclass
|
||||
# default still say 2500. So an options dict with the key ABSENT is ACCEPTED by
|
||||
# rule 6 at 2000 and then built by main.py at 2500 - which is a contradiction:
|
||||
#
|
||||
# validate_startup_config -> [] (accepted)
|
||||
# MaintConfig().charge_w -> 2500.0
|
||||
# arbiter -> 0.0 W, contradiction=True
|
||||
#
|
||||
# That is the bug rule 6 exists to catch, passing rule 6. It is unreachable on a
|
||||
# Supervisor install (every key is always supplied from config.yaml) but fully
|
||||
# reachable from sim/inject.py and from tests - which is exactly where anyone
|
||||
# debugging maintenance works.
|
||||
#
|
||||
# The fix is ONE SOURCE OF TRUTH for option defaults. That is a bigger change
|
||||
# than this ticket and it lands in main.py, which another ticket is live in - so
|
||||
# the divergence is RECORDED here instead of closed. The check therefore passes
|
||||
# on the mismatch and FAILS when either side moves, in EITHER direction:
|
||||
#
|
||||
# - config.yaml moves -> FAIL: re-decide what the fallback should be
|
||||
# - main.py is fixed to match -> FAIL: the divergence is gone, DELETE this
|
||||
# check and the ⚠️ block in validate.py
|
||||
#
|
||||
# A comment cannot do that, and "a comment where a check belonged" is this
|
||||
# repo's most repeated defect.
|
||||
_APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "app")
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(*parts), encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
|
||||
# Every literal fallback for this key in main.py, whatever the call shape.
|
||||
_main_falls = [float(m) for m in re.findall(
|
||||
r'\.get\(\s*"maintenance_charge_w"\s*,\s*(-?\d+(?:\.\d+)?)\s*\)',
|
||||
_read(_APP, "main.py"))]
|
||||
# ...and MaintConfig's own dataclass default, which is the one that bites when
|
||||
# main.py is bypassed entirely (a Maintenance built straight from defaults).
|
||||
_dc_falls = [float(m) for m in re.findall(
|
||||
r"^\s*charge_w:\s*float\s*=\s*(-?\d+(?:\.\d+)?)\s*$",
|
||||
_read(_APP, "maintenance.py"), re.M)]
|
||||
|
||||
check(f"main.py's fallbacks are still found and still 2500 {_main_falls}",
|
||||
len(_main_falls) == 2 and set(_main_falls) == {2500.0})
|
||||
check(f"MaintConfig's default is still found and still 2500 {_dc_falls}",
|
||||
len(_dc_falls) == 1 and set(_dc_falls) == {2500.0})
|
||||
check(f"...and config.yaml still ships 2000, so the divergence is UNCHANGED "
|
||||
f"(shipped {_shipped['maintenance_charge_w']:.0f}, code 2500)",
|
||||
_shipped["maintenance_charge_w"] == 2000.0)
|
||||
|
||||
# ⚠️ And the consequence itself, through the REAL code rather than through the
|
||||
# numbers above - so that if someone "fixes" this by changing a literal without
|
||||
# understanding it, the thing that goes red is the behaviour, not the transcript.
|
||||
from app.maintenance import MaintConfig # noqa: E402
|
||||
|
||||
_absent = {k: v for k, v in base().items() if k != "maintenance_charge_w"}
|
||||
_built = MaintConfig().charge_w
|
||||
check("with the key ABSENT the validator accepts...",
|
||||
validate_startup_config(_absent, -5000, 5000) == [])
|
||||
check(f"...while the code would build {_built:.0f} W against a "
|
||||
f"{_absent['max_w']:.0f} W ceiling...", _built > _absent["max_w"])
|
||||
_r = _charge(_absent["max_w"], _built)
|
||||
check("...which the arbiter resolves to 0 W as a contradiction - the hole, "
|
||||
"asserted so it cannot widen unnoticed",
|
||||
_r.target_w == 0.0 and _r.contradiction)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("max_w must be a positive magnitude")
|
||||
# Schema-blocked on a real install, but validate_startup_config() is also
|
||||
# sim/inject.py's entry point and can be handed any dict at all.
|
||||
for bad in (0, -1, -2000):
|
||||
p = validate_startup_config(base(max_w=bad), -5000, 5000)
|
||||
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "must be positive"))
|
||||
check("...and the message explains it would read as a trip, not a mistake",
|
||||
why(validate_startup_config(base(max_w=0), -5000, 5000), "contradiction"))
|
||||
# ⚠️ SAFETY-02 changed what this case has to say, and the change is real rather
|
||||
# than bookkeeping: with a 1 W ceiling the default 300 W sign-disagreement
|
||||
# threshold is unreachable, so rule 5 refuses - correctly, because the watchdog
|
||||
# would report itself armed while being incapable of firing. What this check is
|
||||
# FOR is "a positive max_w is not itself a refusal", so it now names a sign
|
||||
# threshold the ceiling can actually reach, and the interaction gets its own
|
||||
# check below.
|
||||
check("the smallest sane max_w still starts",
|
||||
validate_startup_config(base(max_w=1, sign_disagree_w=0.5,
|
||||
maintenance_charge_w=1), -5000, 5000) == [])
|
||||
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
|
||||
why(validate_startup_config(base(max_w=1), -5000, 5000), "never fire"))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("what must NOT be refused - a validator that fires on trivia gets deleted")
|
||||
# DOCS.md: the arbiter's `supervised` limit still binds the wire to +-max_w, so
|
||||
# the only cost of the extra headroom is unwind latency after saturation.
|
||||
check("integrator_max_w above max_w warns but starts",
|
||||
validate_startup_config(base(max_w=2000, integrator_max_w=9000),
|
||||
-5000, 5000) == [])
|
||||
check("integrator_max_w 0 (follow max_w) starts",
|
||||
validate_startup_config(base(integrator_max_w=0), -5000, 5000) == [])
|
||||
# Fail-safe AND already a red NOT READY banner naming the entity: a refusal on
|
||||
# top of that adds nothing an operator can act on.
|
||||
check("a missing soc_entity is left to the readiness banner",
|
||||
validate_startup_config(base(soc_entity=""), -5000, 5000) == [])
|
||||
check("a missing batt_entity likewise",
|
||||
validate_startup_config(base(batt_entity=""), -5000, 5000) == [])
|
||||
# The transient case. ESPHome is routinely not up when the add-on boots, and
|
||||
# refusing here would turn a device that arrives late into a restart loop.
|
||||
check("a setpoint entity that is configured but not yet found starts",
|
||||
validate_startup_config(base(), None, None) == [])
|
||||
check("meter_poll_s with thin but non-zero headroom starts",
|
||||
validate_startup_config(base(meter_source="homewizard_local", p1_host="m",
|
||||
meter_poll_s=20, meter_max_age_s=30),
|
||||
-5000, 5000) == [])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the refusal must be legible as a refusal, not as a crash")
|
||||
p = validate_startup_config(base(max_w=8000, setpoint_entity=""), -5000, 5000)
|
||||
check("several problems are all reported, not just the first", len(p) >= 1)
|
||||
refuse(p) # prints the banner; the eye is the assertion for its wording
|
||||
err = ConfigRefused(p)
|
||||
check("ConfigRefused carries every problem", err.problems == p)
|
||||
check("...and str() names one of them", str(err) == p[0])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("the LATE refusal, when the device only shows up after the gate has run")
|
||||
# ⚠️ This path had no test at all and no live demonstration - the review's
|
||||
# catch. ESPHome is routinely not up when the add-on boots, so hass.limits()
|
||||
# returns (None, None), the range checks are skipped, and run_control() is the
|
||||
# first place the device's real rating is ever known. Everything below is about
|
||||
# what happens when it turns out to conflict.
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
|
||||
class _LateHass:
|
||||
"""Reports the range only run_control() gets to see."""
|
||||
|
||||
def __init__(self, lo, hi):
|
||||
self.lo, self.hi = lo, hi
|
||||
self.writes = []
|
||||
|
||||
async def limits(self, entity):
|
||||
return (self.lo, self.hi, 1.0)
|
||||
|
||||
async def number(self, *a, **k):
|
||||
return 0.0
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append(value)
|
||||
return True
|
||||
|
||||
|
||||
class _Store2:
|
||||
data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class _Pub2:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def late(opts_over, lo, hi):
|
||||
"""Drive run_control() against a device that only reports (lo, hi) now.
|
||||
|
||||
⚠️ Returns whether run_control() EXITED ON ITS OWN. That is the assertion
|
||||
that matters: on a refusal it must return without entering the while loop,
|
||||
and on a clean config it must not return at all. So the clean case is stopped
|
||||
from outside after it has had time to write, rather than waited on - a
|
||||
wait_for() here would just time out, which is the correct behaviour dressed
|
||||
up as a failure.
|
||||
"""
|
||||
async def go():
|
||||
stop = asyncio.Event()
|
||||
hass = _LateHass(lo, hi)
|
||||
# A device narrower than the shipped 5000 W schema ceiling is exactly
|
||||
# the case that cannot be reached through the Supervisor on the live
|
||||
# install, so it is reached here instead.
|
||||
c = Controller(base(**opts_over), hass, _Store2(), _Pub2(), stop=stop)
|
||||
c.auto = True
|
||||
task = asyncio.create_task(c.run_control())
|
||||
for _ in range(30): # up to ~3 s
|
||||
if task.done():
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
exited = task.done()
|
||||
if not exited:
|
||||
c.stopping = True
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
return c, stop, hass, exited
|
||||
return asyncio.run(go())
|
||||
|
||||
c, stop, hass, exited = late({"max_w": 5000}, -3600.0, 3600.0)
|
||||
check("a device narrower than max_w, discovered late, is refused",
|
||||
c.refused is not None and len(c.refused) == 1)
|
||||
check("...naming both values", why(c.refused, "5000") and why(c.refused, "3600"))
|
||||
check("...signalling amain to wind down", stop.is_set())
|
||||
check("...and returning instead of looping forever", exited)
|
||||
# The whole point: run_control() must not fall through into the while loop. A
|
||||
# single write here would mean it commanded the inverter under a config it had
|
||||
# just declared unusable.
|
||||
check("...having never entered the control loop, so it wrote nothing",
|
||||
hass.writes == [])
|
||||
check("...and it is recorded in the event log an operator reads",
|
||||
any("REFUSED" in e for e in c.events))
|
||||
|
||||
# The same path when there is no conflict: it must NOT refuse, and it must
|
||||
# actually regulate - asserted by a write reaching the device, not by absence.
|
||||
c2, stop2, hass2, exited2 = late({"max_w": 2000}, -5000.0, 5000.0)
|
||||
check("a late device that agrees does not trip the re-check", c2.refused is None)
|
||||
check("...and the control loop runs, writing to the device", hass2.writes != [])
|
||||
check("...and does not signal a shutdown", not stop2.is_set() and not exited2)
|
||||
|
||||
# ⚠️ THE FOOTGUN THE REVIEW FOUND. `stop` used to default to None, so this
|
||||
# construction was legal and the refusal path then returned into a live process
|
||||
# with no control loop, no exit and no further output.
|
||||
try:
|
||||
Controller(base(), _LateHass(-5000.0, 5000.0), _Store2(), _Pub2())
|
||||
check("a Controller with no stop Event is rejected at construction", False)
|
||||
except TypeError:
|
||||
check("a Controller with no stop Event is rejected at construction", True)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1,505 +0,0 @@
|
||||
"""Runnable check for the sign-disagreement watchdog. `python3 test_watchdog.py`
|
||||
|
||||
Every case here is either a fault this must catch or a piece of NORMAL behaviour
|
||||
it must not mistake for one. The second list is the longer one on purpose: an
|
||||
alarm that fires on ordinary operation is worse than no alarm, because the next
|
||||
person switches it off.
|
||||
|
||||
The headline validation is not here - it is `sim/replay_sign.py` in the infra
|
||||
repo, which drives this module over the two captured bus days. This file is the
|
||||
table-driven half: boundaries, timers, the latch, and the arming gate.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.watchdog import (
|
||||
SignWatchdog, SignWatchdogConfig, loop_is_following,
|
||||
)
|
||||
|
||||
fails = []
|
||||
T0 = datetime(2026, 8, 20, 15, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def drive(samples, cfg=None, following=True):
|
||||
"""Feed (offset_s, commanded_w, grid_w) tuples. Returns (wd, trip_offset)."""
|
||||
wd = SignWatchdog(cfg)
|
||||
trip_at = None
|
||||
for item in samples:
|
||||
offset, cmd, grid = item[0], item[1], item[2]
|
||||
f = item[3] if len(item) > 3 else following
|
||||
if wd.evaluate(cmd, grid, f, T0 + timedelta(seconds=offset)) and trip_at is None:
|
||||
trip_at = offset
|
||||
return wd, trip_at
|
||||
|
||||
|
||||
def opposed(duration_s, step=5, cmd=-1000, grid=1500, following=True, start=0):
|
||||
return [(start + t, cmd, grid, following)
|
||||
for t in range(0, int(duration_s) + 1, step)]
|
||||
|
||||
|
||||
print("the fault it exists to catch")
|
||||
wd, at = drive(opposed(300))
|
||||
check("charging into an importing house trips", wd.tripped)
|
||||
check("and trips at 180 s, not before", at == 180)
|
||||
check("the reason names both signals", "-1000" in wd.reason and "+1500" in wd.reason)
|
||||
print(f" e.g. {wd.reason!r}")
|
||||
|
||||
wd, at = drive(opposed(300, cmd=2000, grid=-2000))
|
||||
check("discharging into an exporting house trips too", wd.tripped and at == 180)
|
||||
|
||||
print()
|
||||
print("the boundaries")
|
||||
wd, _ = drive(opposed(600, cmd=-300, grid=1500))
|
||||
check("300 W exactly does NOT qualify (strictly greater)", not wd.tripped)
|
||||
wd, _ = drive(opposed(600, cmd=-301, grid=1500))
|
||||
check("301 W does", wd.tripped)
|
||||
wd, _ = drive(opposed(600, cmd=-1000, grid=300))
|
||||
check("a 300 W meter does not qualify either", not wd.tripped)
|
||||
wd, _ = drive(opposed(175))
|
||||
check("175 s of disagreement does not trip", not wd.tripped)
|
||||
wd, _ = drive(opposed(180))
|
||||
check("180 s does (>= hold_s)", wd.tripped)
|
||||
|
||||
print()
|
||||
print("continuity - the rule that produced zero false positives on real data")
|
||||
broken = opposed(175) + [(180, -1000, -1500)] + opposed(175, start=185)
|
||||
wd, _ = drive(broken)
|
||||
check("179 s, one agreeing sample, then 175 s more: no trip", not wd.tripped)
|
||||
check("and the window really was reset", wd.disagree_since is not None)
|
||||
long_enough = broken + opposed(200, start=365)
|
||||
wd, _ = drive(long_enough)
|
||||
check("but a full 180 s after the reset does trip", wd.tripped)
|
||||
|
||||
print()
|
||||
print("things that are NOT faults")
|
||||
# The load-step transient. Measured on the healthy capture: longest 30 s.
|
||||
wd, _ = drive(opposed(30) + [(35 + t, 800, 900) for t in range(0, 300, 5)])
|
||||
check("a 30 s load-step crossover does not trip", not wd.tripped)
|
||||
|
||||
# SoC 10 %, Standby, declining a 1350 W discharge. The battery does nothing, so
|
||||
# the meter stays where it was and the command agrees with it.
|
||||
wd, _ = drive([(t, 1350, 1350) for t in range(0, 900, 5)])
|
||||
check("a battery refusing a discharge command cannot trip it", not wd.tripped)
|
||||
|
||||
# A frozen meter drives command and reading into agreement, not disagreement.
|
||||
wd, _ = drive([(t, 800, 800) for t in range(0, 900, 5)])
|
||||
check("a frozen meter cannot trip it", not wd.tripped)
|
||||
|
||||
# Maintenance grid charge: 2500 W of charge against an importing house, for
|
||||
# hours. Opposed, both huge - and entirely correct.
|
||||
wd, _ = drive(opposed(7200, cmd=-2500, grid=3000, following=False))
|
||||
check("two hours of maintenance grid charge does not trip", not wd.tripped)
|
||||
check("and the timer never even started", wd.disagree_since is None)
|
||||
|
||||
wd, _ = drive([(t, -1000, None) for t in range(0, 900, 5)])
|
||||
check("a missing meter reading does not trip", not wd.tripped)
|
||||
wd, _ = drive(opposed(175) + [(180, -1000, None)] + opposed(600, start=185))
|
||||
check("a missing reading mid-window RESETS it rather than accumulating",
|
||||
wd.tripped and wd.disagree_since is not None)
|
||||
wd, at = drive(opposed(175) + [(180, -1000, None)] + opposed(600, start=185))
|
||||
check("...so the trip lands 180 s after the gap, not after 180 s total",
|
||||
at == 365)
|
||||
|
||||
wd, _ = drive([(t, 0.0, 3000) for t in range(0, 900, 5)])
|
||||
check("a 0 W command is not a wrong-direction command", not wd.tripped)
|
||||
|
||||
print()
|
||||
print("the latch")
|
||||
wd, _ = drive(opposed(200))
|
||||
check("tripped after the fault", wd.tripped)
|
||||
# The trip commands 0 W, which removes the command side of the comparison.
|
||||
for t in range(200, 900, 5):
|
||||
wd.evaluate(0.0, 3000, True, T0 + timedelta(seconds=t))
|
||||
check("stays tripped once the command is zeroed", wd.tripped)
|
||||
for t in range(900, 1500, 5):
|
||||
wd.evaluate(1000, 1000, True, T0 + timedelta(seconds=t))
|
||||
check("stays tripped even once everything looks healthy again", wd.tripped)
|
||||
wd.reset()
|
||||
check("reset() is the only way out", not wd.tripped and wd.disagree_since is None)
|
||||
check("and the reason is cleared with it", wd.reason == "")
|
||||
|
||||
print()
|
||||
print("the thresholds are configuration, not constants")
|
||||
cfg = SignWatchdogConfig(min_w=800.0, hold_s=60.0)
|
||||
wd, at = drive(opposed(300, cmd=-1000, grid=1500), cfg=cfg)
|
||||
check("a 60 s hold trips at 60 s", wd.tripped and at == 60)
|
||||
wd, _ = drive(opposed(600, cmd=-700, grid=1500), cfg=cfg)
|
||||
check("an 800 W gate ignores a 700 W command", not wd.tripped)
|
||||
|
||||
print()
|
||||
print("the configuration that would disarm it is refused")
|
||||
from app.validate import validate_startup_config # noqa: E402
|
||||
|
||||
base = {"setpoint_entity": "number.sp", "meter_source": "off",
|
||||
"meter_phases": 1, "meter_max_age_s": 30}
|
||||
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 300},
|
||||
-5000, 5000)
|
||||
check("300 W under a 2000 W ceiling is accepted", p == [])
|
||||
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 2000},
|
||||
-5000, 5000)
|
||||
check("a threshold AT max_w is refused", len(p) == 1 and "never fire" in p[0])
|
||||
# ⚠️ maintenance_charge_w is named here only to keep this case about the SIGN
|
||||
# rule. MAINT-04 added a second rule against the same ceiling, and a 500 W
|
||||
# max_w is also below the 2000 W shipped maintenance charge - so without this
|
||||
# the dict is refused twice, for two correct but different reasons, and the
|
||||
# `len(p) == 1` below would be measuring the interaction rather than this rule.
|
||||
p = validate_startup_config({**base, "max_w": 500, "sign_disagree_w": 3000,
|
||||
"maintenance_charge_w": 500}, -5000, 5000)
|
||||
check("a threshold above max_w is refused", len(p) == 1 and "never fire" in p[0])
|
||||
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 1999},
|
||||
-5000, 5000)
|
||||
check("one watt under the ceiling is reachable, so accepted", p == [])
|
||||
|
||||
print()
|
||||
print("the arming gate, derived from the REAL arbiter")
|
||||
# ⚠️ THIS IS THE DECISION THE WHOLE TICKET TURNS ON, and until now it was
|
||||
# asserted in three comment blocks and verified by nothing: every other case in
|
||||
# this file passes `following` in as a literal. What has to hold is that the
|
||||
# expression in main.py - loop won AND the emitted value points the way the loop
|
||||
# asked AND is no larger - is never True for a resolution the loop did not
|
||||
# actually get. So run real claims through the real resolve() and derive it.
|
||||
from app.arbiter import ( # noqa: E402
|
||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim, resolve,
|
||||
)
|
||||
|
||||
|
||||
def following_from(claims, asked):
|
||||
"""THE SHIPPED gate, not a copy of it.
|
||||
|
||||
⚠️ This used to transcribe main.py's expression with a comment saying
|
||||
"keep the two in step", which is exactly the arrangement that does not keep
|
||||
two things in step. Measured: editing the shipped gate alone to
|
||||
`winner in ("loop", "peak")` left this suite - including the P_PEAK row below,
|
||||
whose entire job is to catch that edit - completely green, while the watchdog
|
||||
went blind for the duration of every peak event. Import it or the P_PEAK
|
||||
assertion is decoration.
|
||||
"""
|
||||
r = resolve(claims)
|
||||
return r, loop_is_following(r, asked)
|
||||
|
||||
|
||||
MAXW = Claim.limit("supervised", P_SAFETY, -2000, 2000, "max_w")
|
||||
DEV = Claim.limit("device", P_SAFETY, -5000, 5000, "rating")
|
||||
|
||||
# name, claims, what the loop asked, expected `following`
|
||||
TABLE = [
|
||||
("the loop alone, unopposed",
|
||||
[Claim.set("loop", P_LOOP, 900)], 900, True),
|
||||
("the loop bound by max_w - reduced, not redirected",
|
||||
[Claim.set("loop", P_LOOP, 4000), MAXW], 4000, True),
|
||||
("charging, bound by max_w on the other side",
|
||||
[Claim.set("loop", P_LOOP, -4000), MAXW], -4000, True),
|
||||
("maintenance owns the setpoint outright",
|
||||
[Claim.set("loop", P_LOOP, 900),
|
||||
Claim.set("maintenance", P_MAINTENANCE, -2500, "charge"), MAXW], 900, False),
|
||||
("maintenance charge-only clamps the loop to 0 W",
|
||||
[Claim.set("loop", P_LOOP, 900),
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0, reason="charge-only"),
|
||||
MAXW], 900, False),
|
||||
# ⚠️ THE CASE `winner == \"loop\"` ALONE WOULD HAVE GOT WRONG. 1500 W, not
|
||||
# the default 2500 W: at max_w 2000 a -2500 hi against a -2000 lo is an
|
||||
# arbiter contradiction, not a charge - see main.py.
|
||||
("cheap-window charge REDIRECTS the loop while it still 'wins'",
|
||||
[Claim.set("loop", P_LOOP, 500),
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-1500, reason="cheap-window"),
|
||||
MAXW], 500, False),
|
||||
("...and the contradiction at the shipped defaults is not following either",
|
||||
[Claim.set("loop", P_LOOP, 500),
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-2500, reason="cheap-window"),
|
||||
MAXW], 500, False),
|
||||
("a lo limit RAISES the magnitude without flipping the sign",
|
||||
[Claim.set("loop", P_LOOP, 200),
|
||||
Claim.limit("floor", P_SAFETY, lo=800), MAXW], 200, False),
|
||||
("a safety stop wins outright",
|
||||
[Claim.set("loop", P_LOOP, 900),
|
||||
Claim.set("safety", P_SAFETY, 0.0, "stopped"), MAXW], 900, False),
|
||||
("no claims at all - the arbiter's own failsafe",
|
||||
[MAXW, DEV], None, False),
|
||||
("the loop asked for zero",
|
||||
[Claim.set("loop", P_LOOP, 0.0), MAXW], 0.0, False),
|
||||
# The forward hazard, asserted as it stands TODAY rather than as it should
|
||||
# be. When peak shaving lands, these expectations are what have to change.
|
||||
("peak shaving at P_PEAK disarms the gate (loop_is_following's forward hazard)",
|
||||
[Claim.set("loop", P_LOOP, 900),
|
||||
Claim.set("peak", P_PEAK, 1800, "shaving"), MAXW], 900, False),
|
||||
# ⚠️ AND THE ROW ABOVE CANNOT DETECT A WIDENED GATE ON ITS OWN, which was
|
||||
# found by trying it: with the shipped gate edited to
|
||||
# `winner in ("loop", "peak")` that case STILL comes out False, because peak
|
||||
# shaving asked for MORE than the loop and the magnitude term rejects it
|
||||
# anyway. The winner term is masked. This row isolates it - a peak claim
|
||||
# smaller than the loop's ask and pointing the same way, so `winner` is the
|
||||
# only term that can decide - and it is what actually turns red if the gate
|
||||
# is widened without anyone revisiting the hazard.
|
||||
("...and with peak BELOW the loop's ask, only the winner term separates them",
|
||||
[Claim.set("loop", P_LOOP, 1800),
|
||||
Claim.set("peak", P_PEAK, 900, "shaving"), MAXW], 1800, False),
|
||||
]
|
||||
for name, claims, asked, expect in TABLE:
|
||||
r, got = following_from(claims, asked)
|
||||
check(f"{name} -> following={expect}", got is expect)
|
||||
if got is not expect:
|
||||
print(f" resolution was {r.explain()!r}")
|
||||
|
||||
# And the gate is never True when the loop did not win, whatever else is going on.
|
||||
check("no configuration above arms the gate without the loop winning",
|
||||
all(not following_from(c, a)[1]
|
||||
for _, c, a, _ in TABLE if following_from(c, a)[0].winner != "loop"))
|
||||
|
||||
print()
|
||||
print("end to end: the trip is COMMAND ZERO, not alarm-only")
|
||||
# ⚠️ THE FAULT INJECTED HERE IS A NEGATIVE GAIN, and that is not a contrivance -
|
||||
# it is the minimal faithful model of what this watchdog is for. The check
|
||||
# compares the loop's OUTPUT DIRECTION against the INPUT STATE it was regulating,
|
||||
# so what it detects is a loop whose two ends have become decoupled in sign: a
|
||||
# gain, a claim or a strategy with its polarity inverted, or an integrator so far
|
||||
# wound up that unwinding takes minutes. (Illusmart's was the last one: it
|
||||
# reached -14 768 W against a +-5000 W rail while writing once every 20-60 s, and
|
||||
# that arithmetic alone is 5-15 minutes of wrong-direction command.)
|
||||
#
|
||||
# `gain` is float(0.05,1.0) in the schema, so this is not a reachable
|
||||
# configuration - it is a stand-in for a code regression, which is the case a
|
||||
# unit test can actually hold the line on.
|
||||
import asyncio # noqa: E402
|
||||
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
|
||||
class _Hass:
|
||||
def __init__(self):
|
||||
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):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
# A house importing 2000 W, steadily, whatever the battery does.
|
||||
return {"sensor.grid": 2000.0, "sensor.soc": 50.0,
|
||||
"sensor.batt": 0.0}.get(entity)
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append(float(value))
|
||||
return True
|
||||
|
||||
|
||||
class _S:
|
||||
data = {}
|
||||
|
||||
def set(self, *a):
|
||||
pass
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
|
||||
class _P:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
async def _e2e():
|
||||
hass = _Hass()
|
||||
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 2000, "gain": -0.6,
|
||||
"sign_disagree_w": 300, "sign_disagree_s": 1, "auto_start": True}
|
||||
c = Controller(opts, hass, _S(), _P(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -5000.0, 5000.0
|
||||
seen_wrong_way = False
|
||||
for _ in range(12):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
if c.target < -300 and not c.sign_wd.tripped:
|
||||
seen_wrong_way = True
|
||||
await asyncio.sleep(0.25)
|
||||
return c, hass, seen_wrong_way
|
||||
|
||||
|
||||
ctrl, hass, wrong_way = asyncio.run(_e2e())
|
||||
check("the broken loop really did command charge into an import first", wrong_way)
|
||||
check("the watchdog tripped", ctrl.sign_wd.tripped)
|
||||
check("the commanded value is 0 W", ctrl.target == 0.0)
|
||||
check("0 W actually reached the device", 0.0 in hass.writes)
|
||||
check("and it got there through the failsafe claim, not by accident",
|
||||
"failsafe" in ctrl.reason)
|
||||
print(f" status line: {ctrl.reason!r}")
|
||||
check("the status page says so in words",
|
||||
any("SIGN-DISAGREEMENT FAILSAFE" in c["text"] for c in ctrl.checks()))
|
||||
check("the banner leads with it",
|
||||
"FAILSAFE" in ctrl.status_payload()["banner"])
|
||||
check("it stays at 0 W on later cycles too",
|
||||
hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1)
|
||||
|
||||
# ⚠️ THIS USED TO BE `check("MQTT reports the failsafe state", ctrl.sign_wd.tripped)`
|
||||
# which restates the assertion three lines above and never touches the payload.
|
||||
# The `status: "failsafe"` value was shipped untested. Capture what publish()
|
||||
# actually hands the broker.
|
||||
published = {}
|
||||
ctrl.mqtt = type("P", (), {"enabled": True,
|
||||
"publish": lambda _s, v: published.update(v),
|
||||
"close": lambda _s: None})()
|
||||
ctrl.publish()
|
||||
check("MQTT's controller-status sensor reports 'failsafe'",
|
||||
published.get("status") == "failsafe")
|
||||
ctrl.sign_wd.reset()
|
||||
ctrl.publish()
|
||||
check("...and goes back to 'running' once cleared",
|
||||
published.get("status") == "running")
|
||||
|
||||
|
||||
print()
|
||||
print("the latch survives a restart - it is the only thing between a tripped")
|
||||
print("controller and the first thing an operator will try")
|
||||
# ⚠️ THE BLOCKER THIS FILE MISSED FIRST TIME ROUND. `auto` is persisted and
|
||||
# `tripped` was not, so reconstructing a Controller against the same Store -
|
||||
# which is exactly what a container restart does - came back armed, into the
|
||||
# fault, with no operator involved: tripped=False, banner NOT READY, and it
|
||||
# resumed commanding -1000 then -2000 W before re-tripping 180 s later.
|
||||
|
||||
|
||||
class _MemStore:
|
||||
"""A Store that keeps its dict across "restarts", like the real file does."""
|
||||
|
||||
def __init__(self):
|
||||
self.data = {"phase": "idle", "sign_tripped": False, "sign_reason": ""}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
|
||||
async def _restart():
|
||||
shared = _MemStore()
|
||||
hass1 = _Hass()
|
||||
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 2000, "gain": -0.6,
|
||||
"sign_disagree_w": 300, "sign_disagree_s": 1, "auto_start": True}
|
||||
a = Controller(opts, hass1, shared, _P(), stop=asyncio.Event())
|
||||
a.auto = True
|
||||
a.dev_min, a.dev_max = -5000.0, 5000.0
|
||||
for _ in range(12):
|
||||
await a.read_inputs()
|
||||
await a.cycle()
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
# The restart. Same options, same store, brand new Controller and Hass.
|
||||
hass2 = _Hass()
|
||||
b = Controller(opts, hass2, shared, _P(), stop=asyncio.Event())
|
||||
b.auto = True
|
||||
b.dev_min, b.dev_max = -5000.0, 5000.0
|
||||
for _ in range(6):
|
||||
await b.read_inputs()
|
||||
await b.cycle()
|
||||
await asyncio.sleep(0.05)
|
||||
return a, b, shared, hass2
|
||||
|
||||
|
||||
pre, post, store, hass2 = asyncio.run(_restart())
|
||||
check("it tripped before the restart", pre.sign_wd.tripped)
|
||||
check("the trip reached the store", store.data["sign_tripped"] is True)
|
||||
check("...with the reason beside it, so the operator is told why",
|
||||
"SIGN_DISAGREEMENT" in store.data["sign_reason"])
|
||||
check("the new Controller comes back TRIPPED", post.sign_wd.tripped)
|
||||
check("...and says why, restored rather than re-detected",
|
||||
"SIGN_DISAGREEMENT" in post.sign_wd.reason)
|
||||
check("...commands 0 W from its very first cycle", post.target == 0.0)
|
||||
check("...and never commanded anything else after the restart",
|
||||
hass2.writes != [] and set(hass2.writes) == {0.0})
|
||||
check("the banner still leads with the failsafe",
|
||||
"FAILSAFE" in post.status_payload()["banner"])
|
||||
# ⚠️ TWO PRESSES, and the first one must NOT clear it. `auto` is True while
|
||||
# tripped, so the first press stops control; only the transition back to True
|
||||
# clears. That is the documented behaviour and it is worth an assertion, because
|
||||
# a one-press clear would be reachable by an operator who never read the banner.
|
||||
asyncio.run(post.handle_action("auto_toggle")) # -> stopped
|
||||
check("pressing stop does not clear the failsafe",
|
||||
post.sign_wd.tripped and store.data["sign_tripped"] is True)
|
||||
asyncio.run(post.handle_action("auto_toggle")) # -> started
|
||||
check("pressing start DOES clear it, and clears the store with it",
|
||||
not post.sign_wd.tripped and store.data["sign_tripped"] is False
|
||||
and store.data["sign_reason"] == "")
|
||||
# One more restart from the cleared store, to prove the clear persisted too.
|
||||
fresh = Controller({"setpoint_entity": "number.sp", "meter_source": "off"},
|
||||
_Hass(), store, _P(), stop=asyncio.Event())
|
||||
check("...so the next restart comes back armed and untripped, as it should",
|
||||
not fresh.sign_wd.tripped)
|
||||
|
||||
# ⚠️ THE WRITE ORDERING IS AN INVARIANT, SO IT GETS AN ASSERTION. main.py writes
|
||||
# `sign_reason` and THEN `sign_tripped`, each one its own `save()`, so that a
|
||||
# process that dies between the two leaves a reason nothing reads - harmless -
|
||||
# rather than a latch with no explanation for the operator who has to decide
|
||||
# whether to clear it. Documented reasoning is not enforcement: swapping those two
|
||||
# lines used to pass every check in this file.
|
||||
#
|
||||
# The death is modelled as the second write raising. In the field it is a power
|
||||
# cut or a container kill (Store.save() swallows OSError rather than raising), so
|
||||
# what is asserted here is the PROPERTY - what survives on disk - not the
|
||||
# mechanism.
|
||||
|
||||
|
||||
class _CrashOnFlag(_MemStore):
|
||||
def set(self, k, v):
|
||||
if k == "sign_tripped" and v:
|
||||
raise RuntimeError("power cut, mid-trip")
|
||||
super().set(k, v)
|
||||
|
||||
|
||||
async def _crash_mid_write():
|
||||
store = _CrashOnFlag()
|
||||
hass = _Hass()
|
||||
opts = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 2000, "gain": -0.6,
|
||||
"sign_disagree_w": 300, "sign_disagree_s": 1, "auto_start": True}
|
||||
c = Controller(opts, hass, store, _P(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -5000.0, 5000.0
|
||||
died = False
|
||||
for _ in range(12):
|
||||
try:
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
except RuntimeError:
|
||||
died = True
|
||||
break
|
||||
await asyncio.sleep(0.25)
|
||||
return store, died
|
||||
|
||||
|
||||
crashed_store, died = asyncio.run(_crash_mid_write())
|
||||
check("the second write is the one that sets the flag", died)
|
||||
check("a crash between the two writes leaves the REASON on disk",
|
||||
"SIGN_DISAGREEMENT" in crashed_store.data.get("sign_reason", ""))
|
||||
check("...and never a latch with nothing to explain it",
|
||||
not crashed_store.data.get("sign_tripped"))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
Reference in New Issue
Block a user