Compare commits
32
Commits
@@ -0,0 +1,2 @@
|
||||
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
|
||||
a1d0618eb339b1d4841cd31d6399b67a22b9dfee
|
||||
+33
-13
@@ -286,8 +286,9 @@ jobs:
|
||||
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 init -q . && git remote add origin "$URL"
|
||||
# Token as an env-set header: not in argv or .git/config (TOOL-9).
|
||||
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN"
|
||||
if [ -n "$PRNUM" ]; then
|
||||
# Two fetches, not one, so that a failure to reach either ref fails
|
||||
# HERE with the ref in the message, rather than inside the merge
|
||||
@@ -488,6 +489,31 @@ jobs:
|
||||
fi
|
||||
echo "OK: the \`on:\` block of the merged checks.yml carries no event filter."
|
||||
|
||||
# CI-12: the infra repo at the ONE pinned commit in .gitea/infra-pin (read
|
||||
# from the merged tree), as the sibling test_freeze.py looks for. Any
|
||||
# failure here fails the job; the tests step below refuses the stub.
|
||||
- name: check out admin/goodwe at the pin in .gitea/infra-pin
|
||||
env:
|
||||
URL: ${{ github.server_url }}/admin/goodwe.git
|
||||
# Read access to admin/goodwe only; this job's own token cannot see it.
|
||||
TOKEN: ${{ secrets.INFRA_READ_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
# `|| true`: under pipefail a missing or comment-only pin would skip the FAIL line (TOOL-9).
|
||||
PIN=$(grep -v '^#' "$WS/goodwe-addon/.gitea/infra-pin" | tr -d '[:space:]') || true
|
||||
echo "$PIN" | grep -Eq '^[0-9a-f]{40}$' || { echo "FAIL: .gitea/infra-pin holds '$PIN', not a full 40-hex SHA."; exit 1; }
|
||||
test -n "$TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
|
||||
rm -rf "$WS/goodwe" && mkdir -p "$WS/goodwe" && cd "$WS/goodwe"
|
||||
git init -q .
|
||||
# The token goes in a header via env: not in argv, and not in .git/config,
|
||||
# which is mounted into the tests' container and outlives a failed job.
|
||||
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
|
||||
git fetch -q "$URL" "$PIN" || { echo "FAIL: could not fetch admin/goodwe at $PIN."; exit 1; }
|
||||
git -c advice.detachedHead=false checkout -qf FETCH_HEAD
|
||||
test "$(git rev-parse HEAD)" = "$PIN"
|
||||
test -f sim/hwsim.py || { echo "FAIL: admin/goodwe@$PIN has no sim/hwsim.py."; exit 1; }
|
||||
echo "infra: admin/goodwe @ $(git log --oneline -1)"
|
||||
|
||||
- name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile)
|
||||
run: |
|
||||
set -eu
|
||||
@@ -508,16 +534,9 @@ jobs:
|
||||
# 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.
|
||||
# test_freeze.py part 3 runs against the real `sim/hwsim.py` from the
|
||||
# checkout above; GOODWE_REQUIRE_HWSIM=1 makes it fail rather than use its
|
||||
# stub (CI-12). Lines a test prints with a `CI: ` prefix are echoed on PASS.
|
||||
# ⚠️ PYTHONDONTWRITEBYTECODE=1 IS BELT AND BRACES, NOT A FIX FOR A LIVE
|
||||
# HOLE. TEST-15 measured this runner's actual exposure and found none: the
|
||||
# checkout below is bind mounted `:ro`, so the interpreter physically
|
||||
@@ -533,7 +552,7 @@ jobs:
|
||||
- name: the add-on's own tests
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
|
||||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -e GOODWE_REQUIRE_HWSIM=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
|
||||
cd /w/goodwe-addon/goodwe_controller
|
||||
set -- test_*.py
|
||||
if [ "$1" = "test_*.py" ]; then
|
||||
@@ -546,6 +565,7 @@ jobs:
|
||||
start=$(date +%s)
|
||||
if python3 "$f" > /tmp/out 2>&1; then
|
||||
echo "PASS $f ($(( $(date +%s) - start ))s)"
|
||||
grep "^CI: " /tmp/out || true
|
||||
else
|
||||
echo "FAIL $f ($(( $(date +%s) - start ))s)"
|
||||
tail -40 /tmp/out
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# CI-12: goes red when admin/goodwe release/1.0 has moved past .gitea/infra-pin.
|
||||
# It runs on add-on activity only; admin/goodwe's own `addon-pin` workflow
|
||||
# covers a move of release/1.0 there. Deliberately NOT a required check: it
|
||||
# reports; `checks / unit` stays the only gate. How to bump: README.md,
|
||||
# "CI and the infra pin".
|
||||
name: infra-pin
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [release/1.0]
|
||||
|
||||
jobs:
|
||||
fresh:
|
||||
runs-on: [self-hosted, goodwe-rig]
|
||||
timeout-minutes: 30 # seconds of work; the rest is queueing for the shared slot
|
||||
steps:
|
||||
- name: compare .gitea/infra-pin with admin/goodwe release/1.0
|
||||
env:
|
||||
SELF: ${{ github.server_url }}/${{ github.repository }}.git
|
||||
INFRA: ${{ github.server_url }}/admin/goodwe.git
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INFRA_TOKEN: ${{ secrets.INFRA_READ_TOKEN }} # admin/goodwe is private
|
||||
run: |
|
||||
set -eu
|
||||
D=$(mktemp -d) && trap 'cd / && rm -rf "$D"' EXIT && cd "$D"
|
||||
git init -q .
|
||||
# The pin of the commit this event is about (the PR head, or the push).
|
||||
# Token as an env-set header: not in argv or .git/config (TOOL-9).
|
||||
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
|
||||
git fetch -q --depth 1 "$SELF" "$GITHUB_SHA" || { echo "FAIL: could not fetch $GITHUB_SHA here."; exit 1; }
|
||||
# `|| true`: under the runner's pipefail a missing or comment-only pin
|
||||
# would stop here on git's error instead of the FAIL line below (TOOL-9).
|
||||
PIN=$(git show FETCH_HEAD:.gitea/infra-pin | grep -v '^#' | tr -d '[:space:]') || true
|
||||
test -n "$PIN" || { echo "FAIL: .gitea/infra-pin is missing at $GITHUB_SHA, or holds only comments."; exit 1; }
|
||||
test -n "$INFRA_TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
|
||||
# Header via env, as in checks.yml: the token stays out of argv.
|
||||
HEAD=$(GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $INFRA_TOKEN" \
|
||||
git ls-remote "$INFRA" refs/heads/release/1.0 | cut -f1) || true
|
||||
test -n "$HEAD" || { echo "FAIL: could not read admin/goodwe release/1.0."; exit 1; }
|
||||
if [ "$PIN" != "$HEAD" ]; then
|
||||
echo "STALE: .gitea/infra-pin is $PIN but admin/goodwe release/1.0 is $HEAD."
|
||||
echo " Test against $HEAD and bump the pin (README.md, \"CI and the infra pin\")."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: .gitea/infra-pin $PIN is admin/goodwe release/1.0 HEAD."
|
||||
+6
-1
@@ -4,6 +4,10 @@ The guide technicians carry is the PDF:
|
||||
|
||||
**`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided.
|
||||
|
||||
⚠️ **Outdated: it is v0.1.0.** Its `slew_w` row is corrected in `build_guide.py`
|
||||
and `field-guide.html`, but the PDF has not been rebuilt (see below). Where the
|
||||
guide disagrees with `goodwe_controller/DOCS.md`, DOCS.md wins.
|
||||
|
||||
It covers, in the order the work is done: pre-visit checks, hardware, firmware,
|
||||
add-on install and configuration, how to read the Web UI, the six commissioning
|
||||
gates, the e-stop (and the statement a site without one must sign), proving a
|
||||
@@ -18,7 +22,8 @@ copy cannot drift from the PDF someone is holding in a cellar.
|
||||
python3 docs/build_guide.py # regenerates field-guide.html and the PDF
|
||||
```
|
||||
|
||||
Requires Chrome or Chromium (used headless for print-to-PDF). Screenshots live
|
||||
Requires Chrome or Chromium (used headless for print-to-PDF); `CHROME` in the
|
||||
script is a Windows path, so edit it on any other machine. Screenshots live
|
||||
in `docs/img/` and are base64-embedded, so `field-guide.html` is standalone.
|
||||
|
||||
⚠️ **Every section must stay under the A4 printable height** (1039 px at 96 dpi
|
||||
|
||||
@@ -30,7 +30,7 @@ goodwe_controller/ the add-on
|
||||
returns, run against its own release image
|
||||
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
|
||||
estop/rs485_log.py optional RS485 e-stop / bus witness
|
||||
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
|
||||
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; outdated)
|
||||
docs/build_guide.py its single source - rebuilds the HTML and PDF
|
||||
FIELD-GUIDE.md how to edit and rebuild the guide
|
||||
docs/RELEASE.md how a change reaches the battery - `master` is
|
||||
@@ -50,6 +50,9 @@ https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
|
||||
|
||||
then install **GoodWe RS485 Controller**.
|
||||
|
||||
Then turn on **Watchdog** on the add-on's Info tab — see
|
||||
[Before you start](goodwe_controller/DOCS.md#before-you-start).
|
||||
|
||||
⚠️ **The repository must be PUBLIC for this to work.** Home Assistant clones it
|
||||
anonymously; against a private repo the store shows nothing and gives no useful
|
||||
error. If it must stay private, install locally instead: copy
|
||||
@@ -79,8 +82,8 @@ machine itself dying:
|
||||
2. **Wind-down before firmware updates** — 0 W written before the update starts.
|
||||
3. **RS485 e-stop (optional)** — writes 0 W after 30 s of total bus silence.
|
||||
|
||||
Sites sold without the e-stop must have the acknowledgement in `FIELD-GUIDE.md`
|
||||
§10 signed.
|
||||
Sites sold without the e-stop must have the client acknowledgement in the field
|
||||
guide (Step 7b) signed.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -102,6 +105,12 @@ has the mechanism, the measurement, and what the flags do not do. They are not u
|
||||
each assertion corresponds to a rule whose absence produced an observed failure
|
||||
on real hardware, and the comments in `control.py` say which.
|
||||
|
||||
### CI and the infra pin
|
||||
|
||||
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py`, with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
|
||||
The pin tracks `admin/goodwe` **release/1.0**, not master. Two non-required checks go red when release/1.0 has moved past the pin: `infra-pin / fresh` here, on any add-on PR or push to release/1.0, and `addon-pin / fresh` in `admin/goodwe`, on every push to its release/1.0. Nothing re-runs `addon-pin` after a bump, so it stays red on that admin/goodwe commit; `infra-pin / fresh` green on the bump is the all-clear.
|
||||
**Bump the pin** whenever either is red. Set `.gitea/infra-pin` to the new release/1.0 SHA (`git ls-remote <gitea>/admin/goodwe.git refs/heads/release/1.0`) in a PR, and let `checks / unit` grade the tests against it. CI reads `admin/goodwe` with the Actions secret `INFRA_READ_TOKEN`.
|
||||
|
||||
## Compatibility
|
||||
|
||||
**Check the serial number, not the model name.** Characters 6-8 of the serial are
|
||||
|
||||
+2
-2
@@ -95,7 +95,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<div class="page" id="p1">
|
||||
<div class="hdr">
|
||||
<div><h1>GoodWe RS485 Controller</h1>
|
||||
<div class="sub">Field installation & commissioning guide — v0.1.0</div></div>
|
||||
<div class="sub">Field installation & commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
|
||||
<small>Installer copy</small>
|
||||
</div>
|
||||
|
||||
@@ -503,7 +503,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<table>
|
||||
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
|
||||
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
|
||||
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
|
||||
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code> / 9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
|
||||
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
|
||||
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
|
||||
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
|
||||
|
||||
@@ -60,7 +60,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<div class="page" id="p1">
|
||||
<div class="hdr">
|
||||
<div><h1>GoodWe RS485 Controller</h1>
|
||||
<div class="sub">Field installation & commissioning guide — v0.1.0</div></div>
|
||||
<div class="sub">Field installation & commissioning guide — v0.1.0, <b>outdated</b>: where it disagrees with DOCS.md, DOCS.md wins</div></div>
|
||||
<small>Installer copy</small>
|
||||
</div>
|
||||
|
||||
@@ -468,7 +468,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
|
||||
<table>
|
||||
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
|
||||
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 3–6 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
|
||||
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
|
||||
<tr><td><code>slew_w</code></td><td>0</td><td>0 follows the rail (<code>max_w</code> / 9). A faster fixed value is <b>refused at startup</b> (SAFETY-39) — see DOCS.md</td></tr>
|
||||
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
|
||||
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
|
||||
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# SAFETY-61 — the control task's death ends the process
|
||||
|
||||
## 0. The defect
|
||||
|
||||
`amain()` started `run_control()` as a bare task and waited on `stop`. Nothing
|
||||
watched the task. An uncaught exception in `read_inputs()`, `cycle()` or
|
||||
`publish()` ended the task silently, and `stop` was never set. The web UI, P1
|
||||
and tariff tasks kept running, and the Supervisor saw a healthy container. The
|
||||
traceback appeared only at shutdown. The ESP32's layer-1 veto zeroed the
|
||||
inverter about 30 s later, so the hardware was safe. The pack then sat at 0 W
|
||||
indefinitely while the add-on showed green.
|
||||
|
||||
`test_control_task.py` reproduces this on the pre-fix tree. The child process
|
||||
reaches its 40 s deadline, has written no 0 W, and has logged nothing.
|
||||
|
||||
## 1. Decision: exit with status 70 after the ordinary wind-down
|
||||
|
||||
A done-callback on the control task (`control_task_done()`) handles the death.
|
||||
It logs the traceback at CRITICAL at the moment of death, then sets `stop`.
|
||||
`amain()` then runs the same wind-down that SIGTERM runs (`shutdown()` →
|
||||
`command_site_zero()`, then cancel, then clean up). After that it raises
|
||||
`ControlTaskDied`, and `main()` turns that into `SystemExit(70)` (sysexits
|
||||
EX_SOFTWARE). A normal stop and `run_control()`'s own refusal path both set
|
||||
`stop` before the task ends, so the callback ignores them. The refusal still
|
||||
exits with 78.
|
||||
|
||||
**Why not restart the loop in the same process:**
|
||||
|
||||
* `cycle()` writes controller state before its awaits: `cycle_at`,
|
||||
`peak_guard`, `allocation`, then `target`/`reason`, then the writes. An
|
||||
exception between those steps leaves the controller half-updated. A loop
|
||||
restarted in the same process would continue from a state that no boot ever
|
||||
produces. A process restart rebuilds the controller from the Store, which is
|
||||
the path every boot already runs.
|
||||
* An unbounded retry is the invisible spin the card forbids. A bounded retry
|
||||
would reimplement the Supervisor watchdog's own throttle: 10 restarts per
|
||||
30 min, and 5 attempts with backoff (`apps/const.py`).
|
||||
|
||||
## 2. What happens after the exit, verified rather than assumed
|
||||
|
||||
* **Exit status reaches Docker.** The add-on image (s6-overlay, `init: false`,
|
||||
`CMD /run.sh`) was run with `exec python3 -c 'raise SystemExit(70)'`.
|
||||
`docker inspect` gave ExitCode 70. The same test with exit 0 gave 0, and an
|
||||
uncaught exception gave 1.
|
||||
* **Supervisor 2026.08.0** (`ghcr.io/home-assistant/amd64-hassio-supervisor`,
|
||||
source read from the image):
|
||||
* A non-zero container exit is recorded as `ContainerState.FAILED`
|
||||
(`docker/monitor.py`). The Supervisor logs *"App … exited with non-zero
|
||||
exit code 70"* at ERROR (`apps/app.py container_state_changed`).
|
||||
* The Supervisor restarts the add-on **only if the per-install Watchdog
|
||||
toggle is on**. `watchdog_container()` returns when `not self.watchdog`.
|
||||
The toggle defaults to **off** (`apps/validate.py`:
|
||||
`vol.Optional(ATTR_WATCHDOG, default=False)`).
|
||||
* Add-on containers get **no Docker restart policy**. The only
|
||||
`restart_policy=` in the Supervisor belongs to the observer.
|
||||
* `boot: auto` in `config.yaml` only controls starting at host boot. The
|
||||
`watchdog:` key in `config.yaml` is a health-check URL, not a restart
|
||||
policy, and this add-on does not set it.
|
||||
* **So on a default install the add-on stays stopped after this exit.** The UI
|
||||
shows it as stopped, its log holds the traceback, and the Supervisor log holds
|
||||
an ERROR line. The pack is at 0 W, which is the same physical state as before
|
||||
this card. The difference is that 0 W is written immediately instead of after
|
||||
the ~30 s veto, and the add-on no longer shows green. With the Watchdog toggle
|
||||
on, the Supervisor restarts the add-on.
|
||||
* **Not verified:** an actual restart by a running Supervisor. That would need a
|
||||
live Supervisor, and the only one available is the live house.
|
||||
|
||||
## 3. Left open for the owner
|
||||
|
||||
* Whether `DOCS.md` should tell installers to enable Watchdog. DOCS.md is not
|
||||
this card's file.
|
||||
* Setting the Watchdog toggle from inside the add-on (`/addons/self/options`)
|
||||
was rejected here, because it would change a user setting without the user
|
||||
knowing.
|
||||
|
||||
## 4. Scope: the siblings, and why they are not supervised
|
||||
|
||||
* `run_tariff_refresh()` and the P1 HTTP and HA transports already catch
|
||||
`Exception` inside their loops by design, and the values they feed are
|
||||
age-bounded downstream.
|
||||
* The P1 MQTT source *returns* on purpose when it is misconfigured. Making every
|
||||
long-lived task's end exit the process would turn that deliberate fail-safe
|
||||
into a restart loop.
|
||||
* Only the control task is supervised.
|
||||
|
||||
## 5. `hass.py`: an undecodable body is an absence
|
||||
|
||||
A non-JSON `200` served as `text/html` was **already** treated as an absence,
|
||||
because aiohttp's `ContentTypeError` is a `ClientError`. The reads that actually
|
||||
escaped were these three:
|
||||
|
||||
* a malformed body labelled `application/json`, which raises `JSONDecodeError`
|
||||
(a `ValueError`);
|
||||
* a JSON body that is not an object, which raises `AttributeError` in
|
||||
`number()` and `limits()`;
|
||||
* an undecodable error body in `call()`, which raises `UnicodeDecodeError`.
|
||||
|
||||
All three were measured against aiohttp 3.13.5. The first two now read as
|
||||
`None`, and the third is now a failed write (`False`).
|
||||
|
||||
## 6. Status row
|
||||
|
||||
`checks()` now starts with a *control loop* row showing how long ago the loop
|
||||
last completed a pass. The row turns red once that age exceeds
|
||||
`FIRMWARE_INGEST_VETO_S`. The heartbeat is sent from this loop, so past that age
|
||||
layer 1 has zeroed the inverter.
|
||||
@@ -38,6 +38,8 @@ the trade [[product-not-one-house]] names.
|
||||
|
||||
## 2. The sign convention: **positive is production**, and there is no `pv_invert`
|
||||
|
||||
> Reversed by TEL-14 (flag and negative caution): `DECISIONS/TEL-14-pv-sign.md`.
|
||||
|
||||
### The convention is derived, not assumed
|
||||
|
||||
Two sign rules already ship, and both are recorded in `DOCS.md`'s *Sources*
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# TEL-14 — the PV sign, declared per entity
|
||||
|
||||
Owner report on 0.12.0: `sensor.espressif_vermogen` (ESPHome clamp) and
|
||||
`sensor.kwh_meter_actueel_vermogen` (kWh meter in the PV circuit) read
|
||||
**positive for consumption, negative for production**. At night the page said
|
||||
"✓ PV production: 13 W" — standby draw. By day production would publish negative.
|
||||
|
||||
## 1. This reverses TEL-11 §2, and why
|
||||
|
||||
TEL-11 §2 refused a `pv_invert` on two grounds. "PV is read by nothing in the
|
||||
control path" still holds: the flip lives in `pv.total()`, and `test_pv.py`
|
||||
part 12 re-runs part 6's 40-cycle identity with a flip set. "A reversed sensor
|
||||
announces itself" did not survive a real site: these sensors follow a different
|
||||
convention, read a plausible small positive number at night, and the only other
|
||||
fix is a template sensor, the place TEL-12 §2 says goes quietly wrong.
|
||||
|
||||
## 2. The shape: a parallel list, `pv_invert_entities`
|
||||
|
||||
Measured by `tools/supervisor_shape_probe.py` §H (Supervisor 2026.08.0, against
|
||||
a 0.12.0 blob); re-run it for the transcript.
|
||||
|
||||
| shape | schema | why it lost / won |
|
||||
|---|---|---|
|
||||
| (a) `-sensor.x` prefix | accepted | invisible: the sign is a syntax only the description explains, in a field an installer fills with the entity picker's text |
|
||||
| (b) parallel list | accepted, 0.12.0 blob unchanged | **ships**: its own labelled, translated field ("PV entities whose sign is reversed"); an installer lists the odd ones out |
|
||||
| (c) dicts on `pv_entities` | **refuses 0.12.0's stored strings** | bricks the upgrade — TEL-12's trap again |
|
||||
| (c2) dicts as a third key | accepted | per-entry labels render raw (`entity`, `invert`) — probe §F; and a third PV surface |
|
||||
| (d) global bool | accepted | one sign cannot describe inverter + meter in one roster |
|
||||
|
||||
(b)'s known cost is a typo in the second list: `pv.stray_inverts()` turns it into
|
||||
a named caution. `[]` in `options:` is load-bearing (§H, isolated counterfactual).
|
||||
Default `[]` flips nothing, so every 0.12.0 install is byte-identical (part 12).
|
||||
|
||||
## 3. The negative caution: per entity, below −50 W
|
||||
|
||||
The owner's two meters draw ~13 W at night, so a *correctly* signed meter reads
|
||||
about −13 W then. −50 W clears that with room for a few such devices behind one
|
||||
entity; a reversed sensor by day reads hundreds to thousands of watts below
|
||||
zero. It is tested **per entity, not on the total** (a deviation from the brief):
|
||||
three correct meters at −20 W sum to −60 W and must stay quiet, while a reversed
|
||||
−1500 W hidden inside a positive total must not. It is a caution, never `bad`;
|
||||
a wrong threshold costs a spurious or missed caution, never data. The text says
|
||||
ADD (unflipped) or REMOVE (already flipped).
|
||||
|
||||
## 4. Mutations
|
||||
|
||||
Fresh `cp -a` each, `__pycache__` purged, CI image, `:ro`, `--network none`;
|
||||
`test_pv.py` only unless stated. T1-T12 at 7559d21; M2/M5 are review #1628's,
|
||||
red since B1 (merged into release/1.0 @ 43b2740).
|
||||
|
||||
| # | mutation | bytes | reds |
|
||||
|---|---|---|---|
|
||||
| T1 | flip ignored in `total()` | −45 | 9 |
|
||||
| T2 | flip applied to every member | −7 | 6 |
|
||||
| T3 | `0.0 - w` → `-w` | −5 | 1 |
|
||||
| T4 | flip written as `abs()` | −1 | 2 |
|
||||
| T5 | `_read_pv()` stops passing the flip | −26 | 7 |
|
||||
| T6 | caution threshold −50 → 0 W | −17 | 3 |
|
||||
| T7 | `[sign inverted]` marker dropped | −39 | 3 |
|
||||
| T8 | negative caution made `bad` | +1 | 1 |
|
||||
| T9 | `stray_inverts()` always empty | −50 | 1 |
|
||||
| T10 | `inverted()` stops stripping | −8 | 1 |
|
||||
| T11 | ADD/REMOVE advice swapped | +4 | 2 |
|
||||
| T12 | `pv_invert_entities: []` deleted from `options:` | −25 | 2 test_translations + 17 probe |
|
||||
| M2 | `stray_inverts()` roster ignores `pv_entity` | +15 | 2 (REPORTED CASE, PADDING) |
|
||||
| M5 | `_read_pv()` flip set not stripped | +25 | 1 (PADDING) |
|
||||
|
||||
## 5. History
|
||||
|
||||
OPT-10's `sensor.goodwe_pv_power` recorded since 2026-09-24 on an install with
|
||||
such sensors carries the old sign; nothing rewrites it.
|
||||
|
||||
## 6. CHANGELOG entry for the next PKG cut
|
||||
|
||||
Not written into `CHANGELOG.md`: `test_version_gate.py` 11b/11d refuse an
|
||||
`## Unreleased` heading (measured on this branch: 4 reds). For PKG to paste:
|
||||
|
||||
> **`pv_invert_entities` (TEL-14).** A PV sensor that reads negative while
|
||||
> producing — typically a kWh meter or power clamp in the PV circuit — can now
|
||||
> be listed under *PV entities whose sign is reversed* in the Configuration tab;
|
||||
> its sign is flipped before the sum and the status page marks it. Empty (the
|
||||
> default) changes nothing. ⚠️ PV history recorded before you set it keeps the
|
||||
> old sign.
|
||||
+37
-28
@@ -4,8 +4,8 @@ Drives a GoodWe ES/BP battery inverter over its RS485 meter bus: holds net grid
|
||||
exchange at zero, and runs a monthly battery maintenance cycle so the BMS can
|
||||
balance cells and recalibrate its coulomb counter.
|
||||
|
||||
Installers: read `FIELD-GUIDE.md` in the repository. It is not optional reading —
|
||||
it contains the commissioning gates and the failure modes.
|
||||
Installers: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is
|
||||
v0.1.0 and outdated; where it disagrees with this page, this page wins.
|
||||
|
||||
## Before you start
|
||||
|
||||
@@ -16,6 +16,11 @@ You need:
|
||||
- A T-CAN485 (ESP32) flashed with `firmware/goodwe-master.yaml`
|
||||
- A grid-power sensor already working in Home Assistant, updating every ~5–10 s
|
||||
|
||||
After installing, turn on **Watchdog** on the add-on's Info tab. A crashed
|
||||
control loop stops the add-on at 0 W, and the Supervisor restarts it only when
|
||||
Watchdog is on (it is off by default). See
|
||||
`DECISIONS/SAFETY-61-control-task-death.md`.
|
||||
|
||||
## The safety model, in short
|
||||
|
||||
The inverter **holds its last command forever** — it has no meter-timeout. So:
|
||||
@@ -141,6 +146,7 @@ either direction, and the check is mutation-tested both ways.
|
||||
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
|
||||
| `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity-tel-11) |
|
||||
| `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) |
|
||||
| `pv_invert_entities` | | Optional. The PV entities above that read **negative** while producing; each one's sign is flipped. See [The sign](#the-sign-and-pv_invert_entities-tel-14) |
|
||||
|
||||
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
|
||||
those serve cached values, and a stale reading here ends the maintenance charge
|
||||
@@ -165,12 +171,14 @@ inverters:
|
||||
soc_entity: sensor.goodwe_a_soc
|
||||
batt_entity: sensor.goodwe_a_battery_power
|
||||
max_w: 5000
|
||||
bms_soc_floor: 10
|
||||
capacity_kwh: 15.36
|
||||
- name: inverter-b
|
||||
setpoint_entity: number.goodwe_b_setpoint_w
|
||||
soc_entity: sensor.goodwe_b_soc
|
||||
batt_entity: sensor.goodwe_b_battery_power
|
||||
max_w: 4600
|
||||
bms_soc_floor: 10
|
||||
capacity_kwh: 20.48
|
||||
```
|
||||
|
||||
@@ -190,16 +198,18 @@ inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
|
||||
| `name` | yes | Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so **two units may not share one** |
|
||||
| `setpoint_entity` | yes | This inverter's own ESPHome `number.*_goodwe_setpoint_w` |
|
||||
| `max_w` | yes | The supervised cap on what may be written to **this** inverter. The site's ceiling is the **sum** |
|
||||
| `bms_soc_floor` | yes | This pack's own BMS cut-out percent (SAFETY-32). See [`bms_soc_floor`](#tariff-all-optional) |
|
||||
| `soc_entity` | | This pack's state of charge |
|
||||
| `batt_entity` | | This unit's battery power, `+` = discharging |
|
||||
| `batt_invert` | | Flip if needed |
|
||||
| `capacity_kwh` | | This pack's usable capacity. State it on every entry or on none — see [OPT-09](#how-the-site-target-is-divided-between-the-packs-opt-09) |
|
||||
|
||||
⚠️ **Nothing is inherited.** An entry that leaves out `soc_entity` has **no SoC
|
||||
sensor** — it does *not* fall back to the flat one. That is deliberate: every
|
||||
per-device value became per-instance in 0.5.x precisely so that no inverter is
|
||||
ever graded against a sibling's numbers, and a silent fallback would rebuild
|
||||
that in YAML. `name`, `setpoint_entity` and `max_w` are required of every entry
|
||||
for the same reason: there is no rating to borrow.
|
||||
that in YAML. `name`, `setpoint_entity`, `max_w` and `bms_soc_floor` are required
|
||||
of every entry for the same reason: there is no rating or cut-out to borrow.
|
||||
|
||||
⚠️ **Clear the flat `setpoint_entity` when you declare a roster,** or the add-on
|
||||
refuses to start and says so. It is the single-inverter form and it is not read
|
||||
@@ -452,21 +462,15 @@ Only the third is a software defect, and **none of them can be told apart
|
||||
without a record of production.** This entity is that record. The attribution
|
||||
itself is OPT-10's, with a week of history behind it.
|
||||
|
||||
#### The sign, and why there is no `pv_invert`
|
||||
#### The sign, and `pv_invert_entities` (TEL-14)
|
||||
|
||||
**Positive must mean producing.** That is the same convention as the two sign
|
||||
rules the add-on already ships — `meter_entity` is positive when **importing**
|
||||
and `batt_entity` is positive when **discharging**, both of which are "power
|
||||
arriving at the house" — and production arrives there too.
|
||||
|
||||
`meter_invert` and `batt_invert` exist because those two quantities are
|
||||
**bipolar and are read by the control path**: a reversed battery sensor is a
|
||||
perfectly plausible number that the per-inverter sign failsafe then acts on.
|
||||
Production is unipolar and is read by **nothing**, so a reversed sensor here
|
||||
cannot mislead a decision — and it announces itself immediately as −4 kW at
|
||||
noon in the very history this entity creates. A flag would only hide it, so
|
||||
there is not one. If your sensor really is inverted, fix it where it is
|
||||
produced.
|
||||
**Positive must mean producing.** Some sensors — typically a kWh meter or an
|
||||
ESPHome power clamp in the PV circuit — read negative while producing and a few
|
||||
watts positive at night: list each of those in `pv_invert_entities`, spelled as
|
||||
in `pv_entity`/`pv_entities`, and its sign is flipped before the sum; the status
|
||||
page marks it `[sign inverted]` and cautions on any entity reading below −50 W.
|
||||
History recorded before you set it keeps the old sign. See
|
||||
`DECISIONS/TEL-14-pv-sign.md`.
|
||||
|
||||
#### The unit is read off the entity
|
||||
|
||||
@@ -600,7 +604,7 @@ than in YAML nobody reviews underneath a safety input.
|
||||
> `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
|
||||
> it cannot. See "`sensor.goodwe_p1_sample_age`" 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
|
||||
@@ -755,7 +759,7 @@ 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
|
||||
is the signal `sensor.goodwe_p1_sample_age` 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.
|
||||
@@ -792,12 +796,13 @@ 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`
|
||||
#### `sensor.goodwe_p1_sample_age`
|
||||
|
||||
Published over MQTT discovery whenever a broker is available: **seconds since the
|
||||
newest accepted telegram**, refreshed every second rather than only when a
|
||||
telegram lands. The ESP32's stale-input watchdog subscribes to this exact entity
|
||||
id, so do not rename it.
|
||||
id; do not rename the `GoodWe` device or this entity, since HA composes the id
|
||||
from both (see [What appears in Home Assistant at two inverters](#what-appears-in-home-assistant-at-two-inverters)).
|
||||
|
||||
The reason it is recomputed against the clock is that Home Assistant only pushes
|
||||
a state when the state *changes*. A meter sitting at a genuinely constant reading
|
||||
@@ -833,7 +838,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
> 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
|
||||
> So `sensor.goodwe_p1_sample_age` 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
|
||||
@@ -2820,8 +2825,8 @@ a **copy** of the state file and reports the difference.
|
||||
--live-entities /w/live-entities.txt \
|
||||
--reverse --round-trip'
|
||||
|
||||
Exit `0` clean · `1` at least one finding · `2` could not run. ⚠️ **`2` is not a
|
||||
pass.**
|
||||
Exit `0` clean · `1` at least one finding · `2` could not run · `3` clean but
|
||||
degraded (`--no-live-entities`). ⚠️ **`2` and `3` are not a pass.**
|
||||
|
||||
### The owner steps, because this session could not take them
|
||||
|
||||
@@ -2843,7 +2848,8 @@ check, **before** the roster is declared:
|
||||
|
||||
2. **The options blob**, as `--before`. It is not reachable through the Home
|
||||
Assistant REST API and no entity carries it; export it from the Supervisor,
|
||||
the same way `tools/predeploy_check.py` needs it. Write the roster you intend
|
||||
the same way `tools/predeploy_check.py` needs it: the bare options object,
|
||||
not the `ha apps info` wrapper, which is refused. Write the roster you intend
|
||||
to deploy into a second file and pass it as `--after`.
|
||||
|
||||
3. **The entity list**, as `--live-entities` — one entity id per line:
|
||||
@@ -2857,7 +2863,8 @@ check, **before** the roster is declared:
|
||||
hand back a clean bill of health for a half of the transition it did not
|
||||
check. If you genuinely have to run without it, pass `--no-live-entities`:
|
||||
the run then carries a standing banner in the report header **and** on the
|
||||
exit line saying the orphan check was not graded. The asymmetry used to be
|
||||
exit line saying the orphan check was not graded, and a clean result exits
|
||||
3, not 0. The asymmetry used to be
|
||||
the other way round — an unreadable file refused, a missing flag passed with
|
||||
exit 0 and no disclosure at all — and the owner runs this once, at
|
||||
commissioning, under time pressure.
|
||||
@@ -3005,7 +3012,9 @@ Run it inside the container, so the validator half actually runs:
|
||||
--deployed-version 0.7.0 --options /w/live-options.json
|
||||
|
||||
Export `live-options.json` from the Supervisor first — the options blob is not
|
||||
reachable through the Home Assistant REST API, and no entity carries it.
|
||||
reachable through the Home Assistant REST API, and no entity carries it. Pass the
|
||||
bare object (`ha apps info <slug> --raw-json | jq .data.options`); the wrapped
|
||||
output is refused.
|
||||
|
||||
**Exit codes.** `0` cleared · `1` REFUSED, do not deploy · `2` NOT CLEARED,
|
||||
meaning a check did not run. ⚠️ **2 is not a pass.** On a bare host without the
|
||||
|
||||
@@ -61,10 +61,17 @@ class HomeAssistant:
|
||||
if resp.status == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
obj = await resp.json()
|
||||
# SAFETY-61: an undecodable body is an absence like any other failed
|
||||
# read, never an exception into the control loop.
|
||||
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
|
||||
self._moan(f"read:{entity_id}", "read %s failed: %s", entity_id, err)
|
||||
return None
|
||||
if not isinstance(obj, dict):
|
||||
self._moan(f"shape:{entity_id}", "read %s: not a state object: %.80r",
|
||||
entity_id, obj)
|
||||
return None
|
||||
return obj
|
||||
|
||||
async def number(self, entity_id: str, invert: bool = False) -> float | None:
|
||||
"""Numeric state, or None. Never substitutes a default."""
|
||||
@@ -117,7 +124,8 @@ class HomeAssistant:
|
||||
domain, service, resp.status, body[:200])
|
||||
return False
|
||||
return True
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
# ValueError: an undecodable error body (SAFETY-61) is a failed write.
|
||||
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
|
||||
self._moan(f"svcerr:{domain}.{service}", "service %s.%s failed: %s",
|
||||
domain, service, err)
|
||||
return False
|
||||
|
||||
@@ -25,9 +25,11 @@ battery to zero on its own. Never "hold the last value to be safe".
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import aiohttp
|
||||
@@ -74,8 +76,8 @@ from .price_history import PriceHistory
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
from .tariff import PeakStore, PeakTracker
|
||||
from .store import migrate_maint_keys, migrate_unit_keys
|
||||
from .validate import (ConfigRefused, refuse, validate_startup_config,
|
||||
validate_unit_ranges)
|
||||
from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
|
||||
validate_startup_config, validate_unit_ranges)
|
||||
from .watchdog import (
|
||||
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
|
||||
SignWatchdog, SignWatchdogConfig, loop_is_following,
|
||||
@@ -985,6 +987,8 @@ class Controller:
|
||||
# the value a clamp bit is a value written to ONE device.
|
||||
self.loop_clamped: tuple[bool, str] = (False, "")
|
||||
self.stopping = False
|
||||
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
|
||||
self.loop_pass_mono = None
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
def log_event(self, msg: str) -> None:
|
||||
@@ -1722,7 +1726,7 @@ class Controller:
|
||||
states = await asyncio.gather(
|
||||
*(self.hass.state(entity) for entity in roster))
|
||||
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
|
||||
list(zip(roster, states)))
|
||||
list(zip(roster, states)), pv_read.inverted(self.o))
|
||||
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
@@ -4397,6 +4401,7 @@ class Controller:
|
||||
await self.send_heartbeats(asyncio.get_running_loop().time(),
|
||||
heartbeat)
|
||||
self.publish()
|
||||
self.loop_pass_mono = time.monotonic()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None:
|
||||
@@ -4596,6 +4601,22 @@ class Controller:
|
||||
"""
|
||||
o = self.o
|
||||
out = []
|
||||
# SAFETY-61. First, so a stalled loop is the headline: the heartbeat is
|
||||
# sent from this loop, so past layer 1's veto the inverter is at 0 W.
|
||||
if self.loop_pass_mono is None:
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": "control loop: starting"})
|
||||
else:
|
||||
_pass_age = time.monotonic() - self.loop_pass_mono
|
||||
if _pass_age > FIRMWARE_INGEST_VETO_S:
|
||||
out.append({"ok": False, "warn": False,
|
||||
"text": f"control loop: NO PASS FOR {_pass_age:.0f} s"
|
||||
f" (layer 1 zeroes the inverter after "
|
||||
f"{FIRMWARE_INGEST_VETO_S:.0f} s without a "
|
||||
f"heartbeat)"})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": f"control loop: last pass {_pass_age:.0f} s ago"})
|
||||
if self.p1_enabled:
|
||||
age = self.p1.published_age_s
|
||||
if self.p1.stale:
|
||||
@@ -5054,6 +5075,13 @@ class Controller:
|
||||
# have to say something the single-entity ones never could.
|
||||
roster = self.pv_roster
|
||||
if roster:
|
||||
# TEL-14: each flipped entity is marked where its watts are printed;
|
||||
# an unflipped one gets "", so 0.12.0's sentences are unchanged.
|
||||
flip = pv_read.inverted(self.o)
|
||||
|
||||
def inv(ent):
|
||||
return " [sign inverted]" if ent in flip else ""
|
||||
|
||||
if len(roster) == 1:
|
||||
if self.pv_w is None:
|
||||
out.append({"ok": False, "warn": True,
|
||||
@@ -5068,7 +5096,7 @@ class Controller:
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": f"PV production: {self.pv_w:.0f} W "
|
||||
f"from {roster[0]} (reported "
|
||||
f"in {self.pv_why})"})
|
||||
f"in {self.pv_why})" + inv(roster[0])})
|
||||
elif self.pv_w is None:
|
||||
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
|
||||
# THOSE WORDS. `pv.total()`'s note is the argument; this is
|
||||
@@ -5083,7 +5111,7 @@ class Controller:
|
||||
"text": "PV production: NO SITE TOTAL - "
|
||||
+ self.pv_why + ". Reporting: "
|
||||
+ (", ".join(
|
||||
f"{ent} {watt:.0f} W"
|
||||
f"{ent} {watt:.0f} W" + inv(ent)
|
||||
for ent, watt, _y in self.pv_parts
|
||||
if watt is not None) or "nothing")})
|
||||
else:
|
||||
@@ -5096,6 +5124,7 @@ class Controller:
|
||||
f"{len(self.pv_parts)} entities ("
|
||||
+ ", ".join(
|
||||
f"{ent} {watt:.0f} W in {why}"
|
||||
+ inv(ent)
|
||||
for ent, watt, why in self.pv_parts)
|
||||
+ ")"})
|
||||
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
|
||||
@@ -5115,6 +5144,26 @@ class Controller:
|
||||
f"{len(roster)} entities in the "
|
||||
"list are what is summed. Clear "
|
||||
"`pv_entity` to remove this caution"})
|
||||
# TEL-14: a sign that looks reversed, per entity rather than on the
|
||||
# total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3.
|
||||
for ent, watt, _y in self.pv_parts:
|
||||
if watt is not None and watt < pv_read.NEGATIVE_W:
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": f"PV production: {ent} reads "
|
||||
f"{watt:.0f} W - far below zero, so "
|
||||
f"its sign is probably reversed. If "
|
||||
f"it is producing now, "
|
||||
+ ("REMOVE it from "
|
||||
"`pv_invert_entities`"
|
||||
if ent in flip else
|
||||
"ADD it to `pv_invert_entities`")
|
||||
+ " in the Configuration tab"})
|
||||
for ent in pv_read.stray_inverts(self.o):
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": f"PV production: `pv_invert_entities` "
|
||||
f"names {ent}, which is not a PV entity in "
|
||||
f"use, so it flips nothing - check the "
|
||||
f"spelling against `pv_entities`"})
|
||||
return out
|
||||
|
||||
def status_payload(self) -> dict:
|
||||
@@ -5449,6 +5498,30 @@ class Controller:
|
||||
return None
|
||||
|
||||
|
||||
# SAFETY-61. Exit status when the control task dies; 70 is sysexits(3)
|
||||
# EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md.
|
||||
EXIT_CONTROL_DIED = 70
|
||||
|
||||
|
||||
class ControlTaskDied(RuntimeError):
|
||||
"""The control task ended while nothing had asked it to stop."""
|
||||
|
||||
|
||||
def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
|
||||
died: list) -> None:
|
||||
"""Done-callback on the control task: log its death now and wake amain()
|
||||
to wind down and exit (SAFETY-61, see its DECISIONS record)."""
|
||||
if stop.is_set() or controller.stopping:
|
||||
return # the normal wind-down, or run_control()'s refusal path
|
||||
exc = None if task.cancelled() else task.exception()
|
||||
_LOG.critical("CONTROL LOOP DIED (%s) - commanding 0 W and exiting with "
|
||||
"status %d", "cancelled" if task.cancelled() else
|
||||
(repr(exc) if exc else "returned"), EXIT_CONTROL_DIED,
|
||||
exc_info=exc)
|
||||
died.append(exc)
|
||||
stop.set()
|
||||
|
||||
|
||||
async def amain() -> None:
|
||||
opts = load_options()
|
||||
logging.basicConfig(
|
||||
@@ -5546,7 +5619,13 @@ async def amain() -> None:
|
||||
with contextlib.suppress(NotImplementedError):
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
|
||||
tasks = [asyncio.create_task(controller.run_control())]
|
||||
# SAFETY-61: the control task's death ends the process, after the
|
||||
# wind-down below. See DECISIONS/SAFETY-61-control-task-death.md.
|
||||
died: list = []
|
||||
control = asyncio.create_task(controller.run_control())
|
||||
control.add_done_callback(functools.partial(
|
||||
control_task_done, controller=controller, stop=stop, died=died))
|
||||
tasks = [control]
|
||||
|
||||
# OPT-04. The tariff refresh runs as its OWN long-lived task, for the
|
||||
# reason P1 ingestion does and then some: `resolve_tariff()` may open an
|
||||
@@ -5581,10 +5660,14 @@ async def amain() -> None:
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
for task in tasks:
|
||||
if task is control and died:
|
||||
continue # already logged by control_task_done()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
await runner.cleanup()
|
||||
_LOG.info("stopped")
|
||||
if died:
|
||||
raise ControlTaskDied(died[0])
|
||||
# The late-arriving-device refusal from run_control(). Re-raised only
|
||||
# now, so the wind-down above still ran and the inverter is at 0 W.
|
||||
if controller.refused:
|
||||
@@ -5603,6 +5686,9 @@ def main() -> None:
|
||||
# EX_CONFIG, so even a script reading the exit status can tell a bad
|
||||
# config from a bug.
|
||||
raise SystemExit(78)
|
||||
except ControlTaskDied:
|
||||
# No second traceback: control_task_done() logged it at the death.
|
||||
raise SystemExit(EXIT_CONTROL_DIED)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+32
-17
@@ -50,21 +50,10 @@ SITE figure, and it publishes as an absence. A partial sum is a second way of
|
||||
reporting 0-where-there-was-production, one layer up, and it is refused for the
|
||||
same reason.
|
||||
|
||||
THE SIGN CONVENTION: POSITIVE IS PRODUCTION. Derived from the two conventions
|
||||
this add-on already ships rather than assumed:
|
||||
|
||||
`meter_entity` positive = IMPORTING from the grid (DOCS.md, Sources)
|
||||
`batt_entity` positive = DISCHARGING the pack (DOCS.md, Sources)
|
||||
|
||||
Both are "positive = power arriving at the house's AC bus", and production
|
||||
arrives there too. There is deliberately NO `pv_invert` to go with
|
||||
`meter_invert` and `batt_invert`: those two quantities are BIPOLAR and are READ
|
||||
BY THE CONTROL PATH - a reversed `batt_entity` feeds SAFETY-30's per-unit sign
|
||||
check and SAFETY-31's circulating-power sensor with a plausible number the site
|
||||
then acts on - whereas production is unipolar and is read by nothing. A reversed
|
||||
PV sensor announces itself as -4 kW at noon in the very history this card
|
||||
creates, and flipping it here would only hide it. See
|
||||
`DECISIONS/TEL-11-pv-production.md` §2.
|
||||
THE SIGN CONVENTION: POSITIVE IS PRODUCTION, as for `meter_entity` and
|
||||
`batt_entity` ("power arriving at the house's AC bus"). TEL-14: a kWh meter or
|
||||
power clamp in the PV circuit reads the other way, so `pv_invert_entities`
|
||||
flips the sign per entity. See `DECISIONS/TEL-14-pv-sign.md`.
|
||||
"""
|
||||
|
||||
from .hass import BAD_STATES
|
||||
@@ -193,7 +182,28 @@ def entity_is_ignored(opts: dict) -> bool:
|
||||
if str(x or "").strip()])
|
||||
|
||||
|
||||
def total(readings) -> tuple:
|
||||
def inverted(opts: dict) -> frozenset:
|
||||
"""The entity ids `pv_invert_entities` flips. TEL-14."""
|
||||
return frozenset(e for e in (str(x or "").strip()
|
||||
for x in (opts.get("pv_invert_entities") or []))
|
||||
if e)
|
||||
|
||||
|
||||
def stray_inverts(opts: dict) -> list:
|
||||
"""`pv_invert_entities` entries naming no entity in the roster - a typo
|
||||
that would otherwise leave a sign unflipped with nothing said. TEL-14."""
|
||||
roster = set(entities(opts))
|
||||
return sorted(e for e in inverted(opts) if e not in roster)
|
||||
|
||||
|
||||
# TEL-14: a reading below this is not standby draw. A correctly signed meter in
|
||||
# the PV circuit reads a few watts NEGATIVE at night (the owner's: 13 W); -50 W
|
||||
# clears that with room for a couple of such devices on one entity, while a
|
||||
# reversed sensor in daylight reads hundreds to thousands of watts below zero.
|
||||
NEGATIVE_W = -50.0
|
||||
|
||||
|
||||
def total(readings, flip=frozenset()) -> tuple:
|
||||
"""`(watts, why, parts)` - the SITE's production, or an ABSENCE. TEL-12.
|
||||
|
||||
`readings` is `[(entity_id, state), ...]` in `entities()` order, one entry
|
||||
@@ -237,8 +247,13 @@ def total(readings) -> tuple:
|
||||
site: shading, a tripped string and nightfall are all real 0s, and a roster
|
||||
that went to `unknown` whenever one member read zero would report an absence
|
||||
on every clear evening and defeat the channel from the other end.
|
||||
|
||||
⚠️ TEL-14: `flip` is `inverted(opts)`; each member's sign is applied HERE,
|
||||
before the sum, so every surface reads the corrected figure. `0.0 - w`
|
||||
rather than `-w`, so a flipped 0 W is `0.0` and never `-0.0` on the wire.
|
||||
"""
|
||||
parts = [(ent, *watts(st)) for ent, st in readings]
|
||||
parts = [(ent, w if w is None or ent not in flip else 0.0 - w, why)
|
||||
for ent, (w, why) in ((e, watts(st)) for e, st in readings)]
|
||||
if not parts:
|
||||
# No roster. `Controller` does not call this in that state - the gate is
|
||||
# `pv_roster` - but a reader of `parts` should never have to wonder.
|
||||
|
||||
@@ -93,7 +93,7 @@ options:
|
||||
# against a sibling's numbers; an entry silently inheriting `soc_entity` would
|
||||
# rebuild that defect in YAML. `name`, `setpoint_entity`, `max_w` and
|
||||
# `bms_soc_floor` are therefore REQUIRED of every entry (the schema below enforces it, and
|
||||
# validate.py rule 9 enforces it again for the callers the Supervisor never
|
||||
# validate.py rules 8 and 9 enforce it again for the callers the Supervisor never
|
||||
# sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH
|
||||
# SENSOR - `inputs_ok` already stops the site for that - and never means
|
||||
# "use the other one's".
|
||||
@@ -159,12 +159,8 @@ options:
|
||||
# So this takes whatever `sensor.*` the site's PV integration provides, any
|
||||
# brand Home Assistant can read.
|
||||
#
|
||||
# ⚠️ POSITIVE IS PRODUCTION, and there is deliberately no `pv_invert` beside
|
||||
# `meter_invert` and `batt_invert`. Those two quantities are bipolar AND are
|
||||
# read by the control path, so a reversed sensor there is a plausible number
|
||||
# the site acts on; production is unipolar and is read by nothing, so a
|
||||
# reversed sensor announces itself as -4 kW at noon in the history this key
|
||||
# exists to create. See DECISIONS/TEL-11-pv-production.md.
|
||||
# POSITIVE IS PRODUCTION. An entity that reads the other way round goes in
|
||||
# `pv_invert_entities` below - see DECISIONS/TEL-14-pv-sign.md.
|
||||
#
|
||||
# ⚠️ THE ENTITY'S OWN `unit_of_measurement` IS WHAT IS BELIEVED - W, kW, MW
|
||||
# and the rest of Home Assistant's power units. A sensor with no unit, or with
|
||||
@@ -215,6 +211,12 @@ options:
|
||||
# which is also empty" - i.e. no PV channel at all, which is every install.
|
||||
pv_entities: []
|
||||
|
||||
# TEL-14. Every PV entity listed here (in `pv_entity` or `pv_entities`) has
|
||||
# its sign flipped, for a kWh meter or power clamp that reads production as
|
||||
# negative. Per entity, because one roster can mix both conventions. `[]` is
|
||||
# load-bearing like `pv_entities: []`; see DECISIONS/TEL-14-pv-sign.md.
|
||||
pv_invert_entities: []
|
||||
|
||||
# ==========================================================================
|
||||
# METER - where the grid reading comes from
|
||||
# ==========================================================================
|
||||
@@ -224,7 +226,7 @@ options:
|
||||
# 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
|
||||
# local API. ⚠️ It is the ONLY mode whose sensor.goodwe_p1_sample_age 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.
|
||||
@@ -916,12 +918,13 @@ schema:
|
||||
# batteries, whereas an absent capacity has an honest answer - no opinion
|
||||
# about the split, every unit weighs 1.0, which is what shipped.
|
||||
#
|
||||
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
|
||||
# ⚠️ FOUR KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
|
||||
# no `name` has no Store namespace and would share another unit's SAFETY-02
|
||||
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
|
||||
# no `max_w` would have to borrow a rating, which is the sibling-grading
|
||||
# defect DEV-02 removed. The optional pair is the sensors, whose absence
|
||||
# honestly means "this unit reports none".
|
||||
# defect DEV-02 removed; `bms_soc_floor` is SAFETY-32's (options block above).
|
||||
# The optional keys are the sensors, whose absence honestly means "this unit
|
||||
# reports none", and `capacity_kwh`.
|
||||
#
|
||||
# ⚠️ THIS BLOCK IS DECLARED BEFORE `max_w:` ON PURPOSE. `max_w` now appears
|
||||
# TWICE under `schema:` - once nested here, once flat below - and a reader
|
||||
@@ -971,6 +974,10 @@ schema:
|
||||
# `p1_phase_import_entities` is the shipped precedent for the pair.
|
||||
pv_entities:
|
||||
- str
|
||||
# TEL-14. A parallel list, not a prefix or a dict: measured by
|
||||
# tools/supervisor_shape_probe.py section H, DECISIONS/TEL-14-pv-sign.md.
|
||||
pv_invert_entities:
|
||||
- str
|
||||
|
||||
# ==========================================================================
|
||||
# METER - where the grid reading comes from
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Read a value a check prints or grades without raising (TEST-26).
|
||||
|
||||
Each test file is one script, so an IndexError in a print or a check name
|
||||
kills every check after it (MUTATION.md, FCST-06). Read through these instead,
|
||||
and conjoin `present(x)` in the check so the absence grades as a named FAIL.
|
||||
|
||||
`ABSENT` values never raise: they format as `<ABSENT ...>` under any spec,
|
||||
index and attribute to themselves, compare unequal AND not-unequal, and are
|
||||
falsy. `not x`, `"a" not in x` and `all(... for _ in x)` are therefore TRUE of
|
||||
one, which is why the check needs the `present()` term. `int()`, `range()` and
|
||||
use as a list index still raise: guard those with `present()`.
|
||||
"""
|
||||
|
||||
_NAN = float("nan")
|
||||
|
||||
|
||||
class Absent:
|
||||
__slots__ = ("what",)
|
||||
|
||||
def __init__(self, what):
|
||||
object.__setattr__(self, "what", what)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ABSENT {self.what}>"
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
def __format__(self, spec):
|
||||
return repr(self)
|
||||
|
||||
def __bool__(self):
|
||||
return False
|
||||
|
||||
def __eq__(self, other):
|
||||
return False
|
||||
|
||||
__ne__ = __lt__ = __le__ = __gt__ = __ge__ = __eq__
|
||||
|
||||
def __hash__(self):
|
||||
return id(self)
|
||||
|
||||
def __getitem__(self, _):
|
||||
return self
|
||||
|
||||
def __getattr__(self, _):
|
||||
return self
|
||||
|
||||
def __call__(self, *_a, **_k):
|
||||
return self
|
||||
|
||||
def __len__(self):
|
||||
return 0
|
||||
|
||||
def __iter__(self):
|
||||
return iter(())
|
||||
|
||||
def __contains__(self, _):
|
||||
return False
|
||||
|
||||
def __float__(self):
|
||||
return _NAN
|
||||
|
||||
def _same(self, *_):
|
||||
return self
|
||||
|
||||
__add__ = __radd__ = __sub__ = __rsub__ = __mul__ = __rmul__ = _same
|
||||
__truediv__ = __rtruediv__ = __floordiv__ = __rfloordiv__ = _same
|
||||
__mod__ = __rmod__ = __neg__ = __pos__ = __abs__ = __round__ = _same
|
||||
|
||||
|
||||
def present(*xs):
|
||||
"""True when none of `xs` is an ABSENT stand-in."""
|
||||
return not any(isinstance(x, Absent) for x in xs)
|
||||
|
||||
|
||||
def nth(seq, i=0, what=None):
|
||||
"""`seq[i]`, or an ABSENT saying how many items there were."""
|
||||
n = 0 if seq is None else len(seq)
|
||||
if -n <= i < n:
|
||||
return seq[i]
|
||||
return Absent(what or f"[{i}] of {n} item(s)")
|
||||
|
||||
|
||||
def first(it, what="no match"):
|
||||
"""`next(it)`, or ABSENT when it is exhausted."""
|
||||
return next(iter(it), Absent(what))
|
||||
|
||||
|
||||
def field(mapping, key, what=None):
|
||||
"""`mapping[key]`, or ABSENT when the key (or the mapping) is missing."""
|
||||
try:
|
||||
return mapping[key]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return Absent(what or f"key {key!r}")
|
||||
|
||||
|
||||
def given(x, what="None"):
|
||||
"""`x`, or ABSENT when it is None - so `:.2f` and arithmetic cannot raise."""
|
||||
return Absent(what) if x is None else x
|
||||
@@ -10,12 +10,10 @@ IN FULL.
|
||||
⚠️ WHAT THIS FILE CAN AND CANNOT CLAIM, SAID BEFORE THE FIRST CHECK.
|
||||
The two-master rig lives in admin/goodwe (TEST-19, `sim/sitesim.py`), where
|
||||
`compute()` closes the loop against `plantsim.Plant` with the firmware's own
|
||||
encoder on the wire. That repository is PRIVATE and this repository's CI token
|
||||
is minted for this one, so the rig is not reachable from the gate - and a test
|
||||
file that needed it would either fail the gate or, like test_freeze.py's hwsim
|
||||
section, pass SILENTLY having checked nothing.
|
||||
encoder on the wire. CI checks admin/goodwe out at `.gitea/infra-pin` (CI-12),
|
||||
but this file does not import the rig.
|
||||
|
||||
So the split is deliberate:
|
||||
The split:
|
||||
|
||||
* `tools/alloc_sim.py` drives the REAL rig, and its output is quoted in the
|
||||
pull request. It is where "no limit cycle" is a claim about the shipped
|
||||
|
||||
@@ -79,6 +79,7 @@ import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app import arbitrage as _arb
|
||||
from named_absence import first
|
||||
from app import main as _main
|
||||
from app import peakguard as _pg
|
||||
from app.arbiter import (
|
||||
@@ -933,12 +934,12 @@ check("main.py passes the guard's OWN measured headroom at its one "
|
||||
# once in `__init__` and never refreshed would satisfy the pin above and would
|
||||
# size every trade from the guard decision of whatever cycle happened to run
|
||||
# last - which is the defect the hoist's own comment warns about, one level up.
|
||||
_s45_cycle = next(n for n in ast.walk(_S45_MAIN_TREE)
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and n.name == "cycle"
|
||||
and any(isinstance(c, ast.Call)
|
||||
and ast.unparse(c.func).endswith("arbitrage.decide")
|
||||
for c in ast.walk(n)))
|
||||
_s45_cycle = first((n for n in ast.walk(_S45_MAIN_TREE)
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and n.name == "cycle"
|
||||
and any(isinstance(c, ast.Call)
|
||||
and ast.unparse(c.func).endswith("arbitrage.decide")
|
||||
for c in ast.walk(n))), "no cycle() calling arbitrage.decide")
|
||||
check("...and the assignment is INSIDE the same `cycle()` that makes the call, "
|
||||
"so the headroom the trade is sized from is this cycle's guard decision "
|
||||
"and not one carried from an arbitrary earlier one",
|
||||
@@ -1069,22 +1070,23 @@ def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
|
||||
res = resolve(claims)
|
||||
target = res.target_w
|
||||
head_w = _pg.charge_headroom_w(pk) if sized else HEADROOM_CLEAR
|
||||
rec.append(dict(
|
||||
row = dict(
|
||||
t_s=t, action=arb.action, power_w=arb.power_w,
|
||||
reason=arb.reason, price=arb.price_now_eur_kwh,
|
||||
spread=arb.spread_eur_kwh, winner=res.winner,
|
||||
target_w=float(res.target_w), headroom_w=head_w,
|
||||
engaged=bool(pk.engaged),
|
||||
sized_from_meter=_arb.sized_from_meter(
|
||||
arb, head_w, winner=res.winner)))
|
||||
arb, head_w, winner=res.winner))
|
||||
rec.append(row)
|
||||
if shadow:
|
||||
# --- TRANSCRIBED 1/3: `_shadow_settle()`'s record, taken
|
||||
# BEFORE the re-seed destroys its evidence.
|
||||
rec[-1]["would_w"] = float(res.target_w)
|
||||
rec[-1]["row"] = _shadow.words(
|
||||
row["would_w"] = float(res.target_w)
|
||||
row["row"] = _shadow.words(
|
||||
{"shadow_mode": "on"}, control_started=False,
|
||||
would_w=float(res.target_w),
|
||||
sized_from_meter=rec[-1]["sized_from_meter"],
|
||||
sized_from_meter=row["sized_from_meter"],
|
||||
reason=res.explain())
|
||||
# --- TRANSCRIBED 2/3: `_write_unit()`'s substitution - the
|
||||
# plant below is driven from WIRE_W, not from the decision.
|
||||
|
||||
@@ -62,10 +62,9 @@ the step it wants: if checks.yml is restructured so that the step cannot be
|
||||
found, this file goes RED rather than quietly grading nothing.
|
||||
|
||||
⚠️ NEEDS `git`, which is why .gitea/ci/Dockerfile installs it. No network: the
|
||||
fixtures are local repositories built in a temp directory, and the workflow's
|
||||
`git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:...@#")"`
|
||||
is a no-op substitution on a path with no `://` in it, which is what lets the
|
||||
shipped line be used verbatim.
|
||||
fixtures are local repositories built in a temp directory; the workflow's
|
||||
token header (TOOL-9) is ignored for a local path, which is what lets the
|
||||
shipped lines be used verbatim.
|
||||
|
||||
⚠️ This file writes only under a temporary directory. It must stay that way:
|
||||
CI mounts the checkout READ-ONLY.
|
||||
|
||||
@@ -78,6 +78,8 @@ import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from named_absence import given, nth, present
|
||||
|
||||
from app.clamp import (
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
|
||||
clamp_to_rating,
|
||||
@@ -322,15 +324,15 @@ _esc_fires = [5.0 * (CLEAR_CYCLES + 2 + _j)
|
||||
T0 + timedelta(seconds=5.0 * (CLEAR_CYCLES + 2 + _j)))]
|
||||
_esc_delay = _esc_fires[0] - _RUNAWAY_AT if _esc_fires else None
|
||||
print(f" budget spent by a benign episode, then a continuous runaway from "
|
||||
f"t={_RUNAWAY_AT:.0f}s: first alarm at t={_esc_fires[0]:.0f}s "
|
||||
f"(delay {_esc_delay:.0f}s)")
|
||||
f"t={_RUNAWAY_AT:.0f}s: first alarm at t={nth(_esc_fires, 0):.0f}s "
|
||||
f"(delay {given(_esc_delay):.0f}s)")
|
||||
check(f"⚠️ a SUSTAINED runaway speaks even when an earlier, unrelated episode "
|
||||
f"spent the budget - it is not held for REASSERT_S ({_esc_delay:.0f}s "
|
||||
f"spent the budget - it is not held for REASSERT_S ({given(_esc_delay):.0f}s "
|
||||
f"delay, escalation is {SUSTAINED_CYCLES} cycles)",
|
||||
_esc_delay is not None and _esc_delay < REASSERT_S / 2)
|
||||
check("...and it speaks at the escalation boundary rather than immediately, "
|
||||
"which is what keeps the scattered flood suppressed",
|
||||
_esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1))
|
||||
_esc_delay is not None and _esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1))
|
||||
|
||||
# ⚠️ `last_alarm` SURVIVING THE DE-ASSERT IS NOW LOAD-BEARING, AND NOTHING
|
||||
# PINNED IT. Review built the mutation: if the de-assert path ALSO refreshed
|
||||
@@ -523,7 +525,7 @@ 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])
|
||||
f"{ctl_l.clamp_wd.engagements} engagement(s)" in nth(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",
|
||||
@@ -1093,7 +1095,8 @@ check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
|
||||
not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" in e for e in _rows_x)
|
||||
and f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" not in notes_x[0]["message"])
|
||||
f"{_asked_x:+.0f} W" not in nth(notes_x, 0)["message"]
|
||||
and present(nth(notes_x, 0)))
|
||||
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The
|
||||
# unit of coverage is the call site, and `bound` is the only thing that differs
|
||||
# between the two rows an operator can receive.
|
||||
@@ -1105,7 +1108,7 @@ check("...and the same ordered pair holds on the DEVICE-bound row, where the "
|
||||
and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in e for e in ctl_w.events)
|
||||
and f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in notes_w[0]["message"])
|
||||
f"{_sent_w:+.0f} W" in nth(notes_w, 0)["message"])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
@@ -17,6 +17,7 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import sys
|
||||
|
||||
from named_absence import nth
|
||||
from app.control import (LOOP_TICK_S, PLANT_SETTLE_DOWN_S, PLANT_SETTLE_S,
|
||||
SETTLE_CYCLES, SLEW_RAIL_FRAC, Tuning, compute,
|
||||
slew_limit_w, unasked_power,
|
||||
@@ -59,68 +60,34 @@ def check(name, cond):
|
||||
# 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.
|
||||
# ⚠️ THE SAFETY-13 ROWS WERE RE-MEASURED IN ROUND 2 AND THREE OF THEM WERE
|
||||
# WRONG AS FIRST WRITTEN. Every figure below marked (S13) was taken by deleting
|
||||
# the mechanism, purging __pycache__, running this file in the CI image and
|
||||
# counting `FAIL` lines, on THIS tree; the release/1.0 comparison figures were
|
||||
# taken the same way on a pristine c8dcb38 worktree.
|
||||
# Measured at TEST-26 on a purged tree in the CI image, each run graded to the
|
||||
# end of the file (98/99 where the `if _bad:` check is skipped). Before TEST-26
|
||||
# the rows marked * killed the file with an IndexError or ZeroDivisionError in
|
||||
# a check, so their earlier figures were short; those checks now fail by name.
|
||||
#
|
||||
# - `output freeze` was written as 11. The mutation form its own note
|
||||
# describes - the whole `target = min(...)/max(...)` line removed, `reason`
|
||||
# left alone - gives 6 on this tree AND 6 on the first SAFETY-13 commit,
|
||||
# against 2 on release/1.0. 11 is not reproducible by that form, or by
|
||||
# removing the line and its `reason` (4), or by swapping min/max (12). The
|
||||
# row is 6.
|
||||
# - `saturation detector, saturated_now = False` was left at 11 while the
|
||||
# SAFETY-13 section added checks that grade it. It is 15.
|
||||
# - the footnote's `frozen = False` figure was left at 10 for the same
|
||||
# reason. It is 13.
|
||||
#
|
||||
# The rows below `output freeze` are this ticket's own mechanisms: removing the
|
||||
# EXIT restores release/1.0's unbounded freeze; making `unasked_power()` return
|
||||
# True gives an exit to the limit case as well, which is the half of the
|
||||
# decision that has to stay unbounded; deriving SETTLE_CYCLES from
|
||||
# PLANT_SETTLE_S (18 s, the UP ramp) instead of PLANT_SETTLE_DOWN_S (23 s) is
|
||||
# the round-1 defect itself; and pinning `_SAT_COUNT_CAP` back at release/1.0's
|
||||
# literal 10 caps the counter below the bound and deletes the exit silently.
|
||||
#
|
||||
# ⚠️ ONLY THE ROWS THIS BRANCH MOVES WERE RE-MEASURED. Everything not marked
|
||||
# (S13) still carries 389d9ec's figures and was not re-run here.
|
||||
#
|
||||
# ⚠️ THE SLEW ROWS WERE RE-MEASURED AT SAFETY-39 AND ONE OF THEM MOVED: the
|
||||
# slew limit was 4 and is now 5, because the SAFETY-39 section grades it too.
|
||||
# The new row is the RAIL DEFAULT specifically - `slew_limit_w()` reduced to
|
||||
# `return abs(float(tuning.slew_w))`, which leaves the limit intact and only
|
||||
# removes the 0-means-the-rail resolution. Three checks notice, which is over
|
||||
# the invariant's floor of two. Both figures were reproduced by deleting the
|
||||
# mechanism, running this file and counting, on a tree with __pycache__ purged.
|
||||
#
|
||||
# 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 5
|
||||
# slew follows the rail (SAFETY-39), `slew_w` read raw 3
|
||||
# output freeze (S13) 6
|
||||
# the output freeze's EXIT (S13), freeze made unconditional again 4
|
||||
# `unasked_power()` discriminates, replaced by `return True` (S13) 6
|
||||
# SETTLE_CYCLES from PLANT_SETTLE_S, not _DOWN_S (S13) 9
|
||||
# `_SAT_COUNT_CAP` pinned back at release/1.0's 10 (S13) 6
|
||||
# deadband 5
|
||||
# quantisation 2
|
||||
# saturation detector, `saturated_now = False` (S13) 15
|
||||
# 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
|
||||
# 13 on this tree (10 on release/1.0). Reproduce the same form or the number
|
||||
# will not match.
|
||||
# mechanism in compute(), and the exact form deleted checks that fail
|
||||
# --------------------------------------------------------- ----------------
|
||||
# integrator freeze: `if not frozen or i_w == 0.0` -> `if True` 6
|
||||
# integrator clamp: `bounded = i_w` 6
|
||||
# integrator bound follows max_w: default `limit = 5000.0` 3
|
||||
# output clamp: `target = want` 3
|
||||
# slew limit: `slewed = target` 17
|
||||
# slew follows the rail (SAFETY-39): `slew_limit_w()` returns
|
||||
# `abs(float(tuning.slew_w))` * 9
|
||||
# output freeze: the `target = min(...)/max(...)` line removed 7
|
||||
# output freeze: `if frozen:` -> `if False:` * 14
|
||||
# the output freeze's EXIT (S13): its `if` -> `if False` * 11
|
||||
# `unasked_power()` returns True (S13) 6
|
||||
# SETTLE_CYCLES from PLANT_SETTLE_S, not _DOWN_S (S13) 9
|
||||
# `_SAT_COUNT_CAP = 10`, release/1.0's literal (S13) * 11
|
||||
# deadband: its `if` -> `if False` 7
|
||||
# quantisation: `target = target` 4
|
||||
# saturation detector: `saturated_now = False` * 27
|
||||
# saturation detector: `frozen = False` * 25
|
||||
# saturation duration fires instantly: `sat_count >= 1` 5
|
||||
# sat counter not reset on a good cycle: `else sat_count` 5
|
||||
# target_grid_w bias: `error = grid_w` 5
|
||||
# i_w=None seeding from prev_w: `i_w = 0.0` 11
|
||||
#
|
||||
# ⚠️ 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
|
||||
@@ -142,10 +109,9 @@ def check(name, cond):
|
||||
# mutation run always has one, because the previous run wrote it. -B is still
|
||||
# worth passing, for the NEXT edit rather than this one.
|
||||
#
|
||||
# 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.
|
||||
# Two things under-report a figure here, both in the same direction: stale
|
||||
# bytecode, and a check that raises and stops the file before the rest are
|
||||
# graded (MUTATION.md, FCST-06). The second is what the * rows above had.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
print("control law")
|
||||
@@ -520,7 +486,7 @@ for _ in range(20):
|
||||
rows.append((prev, d.target_w, d.i_w, d.reason))
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
pinned = [r for r in rows if r[3] == "saturated-freeze"]
|
||||
check(f"non-zero prev_w, another writer: pinned at {pinned[0][1]:.0f} W...",
|
||||
check(f"non-zero prev_w, another writer: pinned at {nth(pinned, 0)[1]:.0f} W...",
|
||||
pinned and all(r[1] == -800.0 for r in pinned))
|
||||
# ⚠️ SAFETY-48 MOVED THIS NUMBER FROM -2390 W TO -1200 W, DELIBERATELY, AND
|
||||
# THE PROPERTY IT IS HERE FOR IS UNCHANGED. The release lets the command walk
|
||||
@@ -550,9 +516,9 @@ for c in range(24):
|
||||
cmds.append(d.target_w)
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
check("the card's case: the block does hold at first, at 0 W",
|
||||
cmds[6] == 0.0 and cmds[7] == 0.0)
|
||||
nth(cmds, 6) == 0.0 and nth(cmds, 7) == 0.0)
|
||||
started = [i for i, c in enumerate(cmds) if c != 0.0]
|
||||
check(f"the card's case: the charge starts at cycle {started[0]}"
|
||||
check(f"the card's case: the charge starts at cycle {nth(started, 0)}"
|
||||
f" instead of never", bool(started))
|
||||
# ⚠️ AND IT STOPS AT THE FROZEN INTEGRATOR, NOT AT THE RAIL. This is AC 4: the
|
||||
# integrator moved while the output was blocked, and the release must not turn
|
||||
@@ -991,7 +957,8 @@ check("the shipped Tuning default is 0, i.e. the rail, so the law carries no "
|
||||
# serial, per goodwe/NOTES.md §1.1b. It is here because the ARITHMETIC is what
|
||||
# is being graded, and the arithmetic does not care.
|
||||
_rails = [100.0, 500.0, 1500.0, 2000.0, 5000.0, 9600.0, 10000.0]
|
||||
_ticks = {r: r / slew_limit_w(Tuning(max_w=r)) for r in _rails}
|
||||
# A zero limit grades as nan ticks (a named FAIL), not a ZeroDivisionError.
|
||||
_ticks = {r: r / (slew_limit_w(Tuning(max_w=r)) or float("nan")) for r in _rails}
|
||||
print(" ticks to cross the rail at the default, by rail: "
|
||||
+ ", ".join(f"{r:.0f}W={t:.1f}" for r, t in _ticks.items()))
|
||||
check("⭐ AT EVERY RAIL THE DEFAULT TAKES THE SAME NUMBER OF TICKS TO CROSS IT - "
|
||||
@@ -1010,7 +977,7 @@ check("...and that is the same as saying the crossing takes the plant's own "
|
||||
# refuses on, and it is asserted HERE too so that the law and the gate cannot
|
||||
# drift apart - the rule can only ever refuse what this ratio says is too fast.
|
||||
_broken = Tuning(max_w=5000.0, slew_w=1000.0)
|
||||
_broken_s = 5000.0 / slew_limit_w(_broken) * LOOP_TICK_S
|
||||
_broken_s = 5000.0 / (slew_limit_w(_broken) or float("nan")) * LOOP_TICK_S
|
||||
print(f" the 2026-09-05 configuration (max_w 5000, slew_w 1000) crosses its "
|
||||
f"rail in {_broken_s:.0f} s against a plant measured at {PLANT_SETTLE_S:.0f} s")
|
||||
check("⭐ RESTORING slew_w 1000 AT max_w 5000 IS FASTER THAN THE PLANT, and by "
|
||||
@@ -1023,13 +990,11 @@ check("⭐ RESTORING slew_w 1000 AT max_w 5000 IS FASTER THAN THE PLANT, and by
|
||||
#
|
||||
# ⚠️ THE PLANT BELOW IS MODELLED HERE AND IS NOT THE RIG. admin/goodwe's
|
||||
# `plantsim.Plant` is the battery model this project grades control changes
|
||||
# against, and this repository's CI cannot reach it - that repository is private
|
||||
# and this job's token is minted for this one (see .gitea/workflows/checks.yml).
|
||||
# So the rig evidence lives in `tools/slew_sim.py`, is quoted in the pull
|
||||
# request and in DECISIONS/SAFETY-39-slew-against-the-rail.md, and what runs
|
||||
# HERE is a first-order lag with the time constant that reproduces §5.10's
|
||||
# measured ~18 s to the deadband. test_alloc.py makes the same split for the
|
||||
# same reason and says so in the same words.
|
||||
# against. CI checks admin/goodwe out at `.gitea/infra-pin` (CI-12), but this
|
||||
# file does not import `plantsim`. The rig evidence lives in `tools/slew_sim.py`,
|
||||
# is quoted in the pull request and in DECISIONS/SAFETY-39-slew-against-the-rail.md,
|
||||
# and what runs HERE is a first-order lag with the time constant that reproduces
|
||||
# §5.10's measured ~18 s to the deadband. test_alloc.py makes the same split.
|
||||
#
|
||||
# ⚠️ AND IT CANNOT SHOW A BATTERY ABOVE ITS RATING, WHICH IS SAID BEFORE THE
|
||||
# RESULT RATHER THAN AFTER IT. Neither this model nor `plantsim.Plant` lets the
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""Runnable check for SAFETY-61: the control task's death. `python3 test_control_task.py`
|
||||
|
||||
If `run_control()` raised, nothing noticed: the task ended, `stop` was never
|
||||
set, the web UI kept serving and the Supervisor saw a healthy container. The
|
||||
card's fix and its reasons are in DECISIONS/SAFETY-61-control-task-death.md.
|
||||
|
||||
Parts 1-3 drive the REAL `app.main.main()` - the process entry point - in a
|
||||
child process (this file re-invoked with `--driver`), with only the outside
|
||||
world faked: options, Home Assistant, the store, the web server and the core
|
||||
API probe. The child's exit status is the real one. Each child runs under a
|
||||
deadline, because the defect this card closes is a HANG (MUTATION.md habit 5).
|
||||
|
||||
Takes ~15 s. Purge `__pycache__` before a mutation run (MUTATION.md).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
FAULT = "injected SAFETY-61 fault"
|
||||
DEADLINE_S = 40
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The child: the real main() with the outside world faked.
|
||||
# --------------------------------------------------------------------------- #
|
||||
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": 0,
|
||||
"deadband_w": 15, "target_grid_w": -10, "step_w": 10,
|
||||
"heartbeat_s": 10, "stale_input_s": 15, "cycle_floor_s": 1,
|
||||
"auto_start": True}
|
||||
|
||||
|
||||
def driver(scenario):
|
||||
import aiohttp
|
||||
import app.main as M
|
||||
import app.store
|
||||
import app.web
|
||||
|
||||
def event(*e):
|
||||
"""Streamed as it happens, so a child killed at the deadline or by
|
||||
os._exit() still reports what it did."""
|
||||
print("EV " + json.dumps(list(e)), flush=True)
|
||||
t0 = time.monotonic()
|
||||
|
||||
class FakeHass:
|
||||
def __init__(self):
|
||||
self.batt = 0.0
|
||||
self.limit_calls = 0
|
||||
|
||||
async def limits(self, entity):
|
||||
self.limit_calls += 1
|
||||
# "refuse": ESPHome not up at the startup gate, then a rating
|
||||
# below max_w once run_control() re-reads it -> its refusal path.
|
||||
if scenario == "refuse":
|
||||
return (None, None, None) if self.limit_calls == 1 else (-1000.0, 1000.0, 1.0)
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
if entity == "sensor.grid":
|
||||
# a steady 600 W import, alternating by 1 W so every pass cycles
|
||||
return 600.0 + (int(time.monotonic() - t0) % 2)
|
||||
if entity == "sensor.batt":
|
||||
return self.batt
|
||||
if entity == "sensor.soc":
|
||||
return 50.0
|
||||
return None
|
||||
|
||||
async def state(self, entity):
|
||||
return None
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
event("w", float(value))
|
||||
self.batt = float(value)
|
||||
return True
|
||||
|
||||
async def call(self, *a, **k):
|
||||
return True
|
||||
|
||||
async def mqtt_service(self):
|
||||
raise RuntimeError("no broker in this test")
|
||||
|
||||
class _Store:
|
||||
data = {}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return default
|
||||
|
||||
def set(self, *a, **k):
|
||||
pass
|
||||
|
||||
class _Runner:
|
||||
async def cleanup(self):
|
||||
event("cleanup")
|
||||
|
||||
async def _web_start(controller, port=8099, selfcfg=None):
|
||||
return _Runner()
|
||||
|
||||
class _NoProbeSession:
|
||||
"""The core API probe in amain() must not touch a network."""
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
def get(self, *a, **k):
|
||||
raise OSError("no network in this test")
|
||||
|
||||
M.load_options = lambda: dict(BASE)
|
||||
M.HomeAssistant = lambda session: FakeHass()
|
||||
app.store.Store = _Store
|
||||
app.web.start = _web_start
|
||||
aiohttp.ClientSession = _NoProbeSession
|
||||
|
||||
real_cycle = M.Controller.cycle
|
||||
n = {"cycles": 0}
|
||||
|
||||
async def cycle(self):
|
||||
n["cycles"] += 1
|
||||
if scenario == "fault" and n["cycles"] == 4:
|
||||
event("fault")
|
||||
raise RuntimeError(FAULT)
|
||||
await real_cycle(self)
|
||||
if scenario == "clean" and n["cycles"] == 4:
|
||||
os.kill(os.getpid(), signal.SIGTERM) # the Supervisor's stop
|
||||
|
||||
M.Controller.cycle = cycle
|
||||
M.main()
|
||||
|
||||
|
||||
def run_child(scenario):
|
||||
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
|
||||
try:
|
||||
p = subprocess.run([sys.executable, "-B", __file__, "--driver", scenario],
|
||||
cwd=HERE, env=env, capture_output=True, text=True,
|
||||
timeout=DEADLINE_S)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
rc, out, err = None, e.stdout or "", e.stderr or ""
|
||||
else:
|
||||
rc, out, err = p.returncode, p.stdout, p.stderr
|
||||
out = out.decode() if isinstance(out, bytes) else out
|
||||
err = err.decode() if isinstance(err, bytes) else err
|
||||
events = [json.loads(ln[3:]) for ln in out.splitlines() if ln.startswith("EV ")]
|
||||
return rc, out, err, events
|
||||
|
||||
|
||||
def tail(text, n=15):
|
||||
return "\n".join(" | " + ln for ln in text.splitlines()[-n:])
|
||||
|
||||
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "--driver":
|
||||
driver(sys.argv[2])
|
||||
sys.exit(0) # unreachable in every scenario: main() exits itself
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. an exception inside cycle() - the real main(), in a child process")
|
||||
rc, out, err, ev = run_child("fault")
|
||||
print(f" exit status {rc}; events {ev}")
|
||||
check(f"the process ENDS inside {DEADLINE_S} s rather than sitting there "
|
||||
"healthy (the defect is a hang)", rc is not None)
|
||||
check("exit status is 70 (EX_SOFTWARE), not 0 and not an unhandled crash's 1",
|
||||
rc == 70)
|
||||
fault_at = ev.index(["fault"]) if ["fault"] in ev else None
|
||||
before = [e[1] for e in ev[:fault_at] if e[0] == "w"] if fault_at is not None else []
|
||||
after = [e[1] for e in ev[fault_at + 1:] if e[0] == "w"] if fault_at is not None else []
|
||||
# Precondition (habit 23): the inverter was holding a NON-ZERO command when the
|
||||
# fault hit, so a 0 W write afterwards is evidence of the wind-down, not rest.
|
||||
check("precondition: the fault hit while the last command was non-zero "
|
||||
f"(last before fault = {before[-1] if before else None})",
|
||||
fault_at is not None and bool(before) and before[-1] != 0.0)
|
||||
check(f"the wind-down ran: 0 W written AFTER the fault (writes after = {after})",
|
||||
after == [0.0])
|
||||
check("the web runner was cleaned up, i.e. the ordinary shutdown path ran",
|
||||
["cleanup"] in ev[(fault_at or 0):])
|
||||
died_line = next((i for i, ln in enumerate(err.splitlines())
|
||||
if "CONTROL LOOP DIED (RuntimeError('" + FAULT + "'))" in ln), None)
|
||||
wind_line = next((i for i, ln in enumerate(err.splitlines())
|
||||
if "shutting down - commanding 0 W" in ln), None)
|
||||
check("a CRITICAL line names the exception and the exit status",
|
||||
died_line is not None
|
||||
and "CRITICAL" in err.splitlines()[died_line]
|
||||
and "commanding 0 W and exiting with status 70" in err.splitlines()[died_line])
|
||||
check("the traceback is logged, down to the frame that raised",
|
||||
"Traceback (most recent call last)" in err
|
||||
and f"RuntimeError: {FAULT}" in err and "in cycle" in err)
|
||||
check("it is logged AT THE DEATH, before the wind-down - not only at exit",
|
||||
died_line is not None and wind_line is not None and died_line < wind_line)
|
||||
check("exactly one traceback: main() does not print a second one",
|
||||
err.count("Traceback (most recent call last)") == 1)
|
||||
if fails:
|
||||
print(tail(err, 30))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("2. the same entry point stopped by SIGTERM - the callback must stay quiet")
|
||||
rc2, out2, err2, ev2 = run_child("clean")
|
||||
print(f" exit status {rc2}; events {ev2}")
|
||||
check("an ordinary stop still exits 0", rc2 == 0)
|
||||
check("an ordinary stop logs no CONTROL LOOP DIED", "CONTROL LOOP DIED" not in err2)
|
||||
check("an ordinary stop still winds down to 0 W, from a non-zero command",
|
||||
any(e[0] == "w" and e[1] != 0.0 for e in ev2)
|
||||
and [e for e in ev2 if e[0] == "w"][-1:] == [["w", 0.0]])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("3. run_control()'s own refusal path returns on purpose - still exit 78")
|
||||
rc3, out3, err3, ev3 = run_child("refuse")
|
||||
print(f" exit status {rc3}")
|
||||
check("a late-arriving device rating refusal exits 78 (EX_CONFIG), not 70",
|
||||
rc3 == 78)
|
||||
check("and is not reported as a death", "CONTROL LOOP DIED" not in err3)
|
||||
if rc3 != 78:
|
||||
print(tail(err3))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("4. hass.py: a body that is not a state object is an ABSENCE")
|
||||
import aiohttp # noqa: E402
|
||||
from aiohttp import web as aioweb # noqa: E402
|
||||
|
||||
import app.hass as H # noqa: E402
|
||||
|
||||
|
||||
async def hass_cases():
|
||||
async def handler(req):
|
||||
k = req.match_info["k"]
|
||||
if k == "sensor.good":
|
||||
return aioweb.json_response({"entity_id": k, "state": "42"})
|
||||
if k == "sensor.badjson":
|
||||
return aioweb.Response(text="{not json", content_type="application/json")
|
||||
if k == "sensor.list":
|
||||
return aioweb.Response(text="[1, 2]", content_type="application/json")
|
||||
if k == "sensor.html":
|
||||
return aioweb.Response(text="<html>", content_type="text/html")
|
||||
return aioweb.Response(status=404)
|
||||
|
||||
async def svc(req):
|
||||
return aioweb.Response(body=b"\xff\xfe\xfa", status=400,
|
||||
headers={"Content-Type": "text/plain; charset=utf-8"})
|
||||
|
||||
app_ = aioweb.Application()
|
||||
app_.router.add_get("/states/{k}", handler)
|
||||
app_.router.add_post("/services/{d}/{s}", svc)
|
||||
runner = aioweb.AppRunner(app_)
|
||||
await runner.setup()
|
||||
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
H.CORE_API = f"http://127.0.0.1:{port}"
|
||||
got = {}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
h = H.HomeAssistant(session, token="t")
|
||||
for e in ("sensor.good", "sensor.badjson", "sensor.list", "sensor.html"):
|
||||
try:
|
||||
got[e] = ("ok", await h.number(e))
|
||||
except Exception as err: # noqa: BLE001
|
||||
got[e] = ("raised", type(err).__name__)
|
||||
try:
|
||||
got["call"] = ("ok", await h.set_number("number.sp", 0))
|
||||
except Exception as err: # noqa: BLE001
|
||||
got["call"] = ("raised", type(err).__name__)
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
return got
|
||||
|
||||
got = asyncio.run(hass_cases())
|
||||
print(f" {got}")
|
||||
check("baseline: a well-formed state reads as its value (42.0)",
|
||||
got["sensor.good"] == ("ok", 42.0))
|
||||
check("a malformed JSON body labelled application/json reads as None, not a raise",
|
||||
got["sensor.badjson"] == ("ok", None))
|
||||
check("a JSON body that is not an object reads as None, not a raise",
|
||||
got["sensor.list"] == ("ok", None))
|
||||
check("a text/html 200 reads as None (already true before SAFETY-61)",
|
||||
got["sensor.html"] == ("ok", None))
|
||||
check("a rejected write with an undecodable body is a failed write (False)",
|
||||
got["call"] == ("ok", False))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("5. the control-loop status row")
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Store:
|
||||
data = {}
|
||||
|
||||
def set(self, *a, **k):
|
||||
pass
|
||||
|
||||
|
||||
class _RowHass:
|
||||
async def limits(self, entity):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
return {"sensor.grid": 600.0, "sensor.soc": 50.0}.get(entity, 0.0)
|
||||
|
||||
async def state(self, entity):
|
||||
return None
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
return True
|
||||
|
||||
async def call(self, *a, **k):
|
||||
return True
|
||||
|
||||
|
||||
def rows_for(pass_age):
|
||||
c = Controller(dict(BASE), _RowHass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.loop_pass_mono = None if pass_age is None else time.monotonic() - pass_age
|
||||
return c.checks()
|
||||
|
||||
|
||||
r_new, r_29, r_31 = rows_for(None), rows_for(29), rows_for(31)
|
||||
print(f" {r_new[0]} | {r_29[0]} | {r_31[0]}")
|
||||
check("before the first pass the row is ok and says 'starting'",
|
||||
r_new[0] == {"ok": True, "warn": False, "text": "control loop: starting"})
|
||||
check("29 s after a pass it is ok",
|
||||
r_29[0] == {"ok": True, "warn": False,
|
||||
"text": "control loop: last pass 29 s ago"})
|
||||
check("31 s after a pass it is RED and first, naming the age and the veto",
|
||||
r_31[0] == {"ok": False, "warn": False,
|
||||
"text": "control loop: NO PASS FOR 31 s (layer 1 zeroes the "
|
||||
"inverter after 30 s without a heartbeat)"})
|
||||
|
||||
|
||||
async def one_pass():
|
||||
c = Controller(dict(BASE), _RowHass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
before = getattr(c, "loop_pass_mono", "absent")
|
||||
t = asyncio.create_task(c.run_control())
|
||||
await asyncio.sleep(2.5)
|
||||
c.stopping = True
|
||||
await asyncio.wait_for(t, timeout=10)
|
||||
return before, getattr(c, "loop_pass_mono", None)
|
||||
|
||||
_before, _after = asyncio.run(one_pass())
|
||||
print(f" stamp before run: {_before}; age after 2.5 s: "
|
||||
f"{None if _after is None else round(time.monotonic() - _after, 2)} s")
|
||||
check("the real run_control() stamps each pass",
|
||||
_before is None and _after is not None and time.monotonic() - _after < 3)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all SAFETY-61 checks passed")
|
||||
@@ -31,6 +31,8 @@ import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from named_absence import given, present
|
||||
|
||||
from app.control import Tuning
|
||||
from app.delivery import (
|
||||
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
|
||||
@@ -738,8 +740,8 @@ async def _at_saturation_w(sat, b_frac=_SAT_B_FRAC):
|
||||
_c_ship, _r_ship = asyncio.run(_at_saturation_w(_SAT_SHIPPED))
|
||||
_c_up, _r_up = asyncio.run(_at_saturation_w(_SAT_RAISED))
|
||||
_b_ship, _b_up = _c_ship.inverters[1], _c_up.inverters[1]
|
||||
_gap_ship = short_by(_b_ship.standing_w(), _b_ship.batt)
|
||||
_gap_up = short_by(_b_up.standing_w(), _b_up.batt)
|
||||
_gap_ship = given(short_by(_b_ship.standing_w(), _b_ship.batt), "B not short")
|
||||
_gap_up = given(short_by(_b_up.standing_w(), _b_up.batt), "B not short")
|
||||
|
||||
check(f"the raised threshold is a config an operator can actually enter: "
|
||||
f"{_SAT_RAISED} W is inside the shipped schema "
|
||||
@@ -762,7 +764,7 @@ check("the raised arm REACHED the comparison: B was asked for a discharge "
|
||||
"share, is short of it, is live, is not parked, and NOTHING accounts for "
|
||||
"it - the only thing standing between this unit and an alarm is the "
|
||||
"configured watts",
|
||||
float(_b_up.standing_w()) > 0 and _gap_up is not None
|
||||
float(_b_up.standing_w()) > 0 and present(_gap_up)
|
||||
and not _b_up.sign_wd.tripped
|
||||
and account(live=True, parked=False, soc=_b_up.soc,
|
||||
bms_floor=_b_up.bms_soc_floor) is None)
|
||||
@@ -780,7 +782,7 @@ check("...and SAFETY-43's RELATIVE arm is silent on both arms of this fixture, "
|
||||
check("...and its shortfall lands STRICTLY BETWEEN the two configured "
|
||||
"thresholds, which is the one place the two answers can differ. Part 4's "
|
||||
"fixtures are all above both, which is exactly why they cannot see this",
|
||||
_gap_up is not None and float(_SAT_SHIPPED) < _gap_up < float(_SAT_RAISED))
|
||||
present(_gap_up) and float(_SAT_SHIPPED) < _gap_up < float(_SAT_RAISED))
|
||||
|
||||
check("AT THE SHIPPED THRESHOLD THE UNIT IS NAMED",
|
||||
_r_ship[-1]["named"] == ("inverter-b",))
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Runnable check that the operator docs say what the code does. `python3 test_docs.py`
|
||||
|
||||
DOC-1. Two things the docs got wrong and an installer would have copied:
|
||||
|
||||
1. The `inverters:` example in DOCS.md must be ACCEPTED by the shipped
|
||||
validator (`app/validate.py`) and carry every key config.yaml's schema
|
||||
requires per entry. It omitted `bms_soc_floor` and would not start.
|
||||
2. The P1 age entity the ESP32 watchdog subscribes to must be documented under
|
||||
the id Home Assistant composes from what `app/mqtt.py` announces, and the
|
||||
old wrong id must appear in no shipped doc.
|
||||
|
||||
Each part also runs its own mutation (habit 1 of MUTATION.md): the spoilt input
|
||||
must turn it red, or the green above it means nothing.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from app import mqtt
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.dirname(HERE)
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def read(*parts):
|
||||
with open(os.path.join(*parts), encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
DOCS = read(HERE, "DOCS.md")
|
||||
CFG = read(HERE, "config.yaml")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# A reader for the two YAML shapes used here. No PyYAML in the checks image
|
||||
# (test_defaults_pin.py says why), so this is by hand, and part 1a checks it
|
||||
# actually found something.
|
||||
def scalar(raw):
|
||||
raw = raw.split("#", 1)[0].strip()
|
||||
if raw[:1] in "\"'" and raw[-1:] == raw[:1] and len(raw) >= 2:
|
||||
return raw[1:-1]
|
||||
if raw in ("true", "false"):
|
||||
return raw == "true"
|
||||
if raw == "[]":
|
||||
return []
|
||||
for cast in (int, float):
|
||||
try:
|
||||
return cast(raw)
|
||||
except ValueError:
|
||||
pass
|
||||
return raw
|
||||
|
||||
|
||||
def parse_example(text):
|
||||
"""Top-level `key: value` lines plus one list of flat mappings."""
|
||||
out, cur_list, cur = {}, None, None
|
||||
for line in text.splitlines():
|
||||
if not line.split("#", 1)[0].strip():
|
||||
continue
|
||||
m = re.match(r"^([a-z0-9_]+):\s*(.*)$", line)
|
||||
if m:
|
||||
if m.group(2).split("#", 1)[0].strip():
|
||||
out[m.group(1)] = scalar(m.group(2))
|
||||
cur_list = None
|
||||
else:
|
||||
cur_list = out[m.group(1)] = []
|
||||
continue
|
||||
m = re.match(r"^\s+-\s+([a-z0-9_]+):\s*(.*)$", line)
|
||||
if m and cur_list is not None:
|
||||
cur = {m.group(1): scalar(m.group(2))}
|
||||
cur_list.append(cur)
|
||||
continue
|
||||
m = re.match(r"^\s+([a-z0-9_]+):\s*(.*)$", line)
|
||||
if m and cur is not None:
|
||||
cur[m.group(1)] = scalar(m.group(2))
|
||||
continue
|
||||
raise ValueError(f"unparsed line in the DOCS.md example: {line!r}")
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("DOC-1 - the docs say what the code does\n")
|
||||
print("1. the `inverters:` example in DOCS.md starts")
|
||||
|
||||
section = DOCS[DOCS.index("### More than one inverter (`inverters`)"):]
|
||||
section = section[:section.index("\n### ", 1)]
|
||||
block = re.search(r"```yaml\n(.*?)```", section, re.S).group(1)
|
||||
example = parse_example(block)
|
||||
entries = example.get("inverters") or []
|
||||
|
||||
# The options a fresh install is given, from config.yaml itself: the example is
|
||||
# pasted on top of these, exactly as an installer does.
|
||||
opts_block = CFG[CFG.index("\noptions:"):CFG.index("\nschema:")]
|
||||
shipped = {m.group(1): scalar(m.group(2))
|
||||
for m in re.finditer(r"^ ([a-z0-9_]+): *(.*)$", opts_block, re.M)}
|
||||
|
||||
# config.yaml's per-entry schema: required keys are the ones without `?`.
|
||||
schema_block = CFG[CFG.index("\nschema:"):]
|
||||
inv_schema = re.search(r"^ inverters:\n((?: .*\n)+)", schema_block, re.M).group(1)
|
||||
schema_keys = dict(re.findall(r"^ -? ?\s*([a-z0-9_]+): *(\S+)", inv_schema, re.M))
|
||||
required = sorted(k for k, t in schema_keys.items() if not t.endswith("?"))
|
||||
|
||||
check(f"1a. the reader found the example: {len(entries)} entries, "
|
||||
f"{len(shipped)} shipped options, schema keys {sorted(schema_keys)}",
|
||||
len(entries) == 2 and all(len(e) >= 4 for e in entries)
|
||||
and len(shipped) > 50 and "bms_soc_floor" in required)
|
||||
|
||||
missing = [(e.get("name"), k) for e in entries for k in required if k not in e]
|
||||
check(f"1b. every entry carries every key the schema requires {required} "
|
||||
f"(missing: {missing})", not missing)
|
||||
|
||||
unknown = [(e.get("name"), k) for e in entries for k in e if k not in schema_keys]
|
||||
check(f"1c. no entry uses a key the schema does not declare (found: {unknown})",
|
||||
not unknown)
|
||||
|
||||
|
||||
def with_example(ex):
|
||||
opts = dict(shipped)
|
||||
opts.update(ex)
|
||||
return opts
|
||||
|
||||
|
||||
problems = validate_startup_config(with_example(example))
|
||||
for p in problems:
|
||||
print(f" refused: {p[:160]}")
|
||||
check("1d. the shipped validator accepts the example as documented", problems == [])
|
||||
|
||||
# Precondition: the example clears the flat setpoint_entity, so without its
|
||||
# roster there is no actuator. If this passed, 1d graded the flat keys, not the
|
||||
# roster.
|
||||
flat_only = {k: v for k, v in example.items() if k != "inverters"}
|
||||
check("1e. ...and it is the ROSTER that 1d graded (the example without it is "
|
||||
"refused)", validate_startup_config(with_example(flat_only)) != [])
|
||||
|
||||
# The mutation this card is about: the key the docs used to leave out.
|
||||
spoilt = dict(example, inverters=[
|
||||
{k: v for k, v in e.items() if k != "bms_soc_floor"} for e in entries])
|
||||
sp = validate_startup_config(with_example(spoilt))
|
||||
check("1f. MUTATION: the example with `bms_soc_floor` removed is refused, "
|
||||
"and the refusal names it",
|
||||
sp != [] and any("bms_soc_floor" in p for p in sp))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. the P1 age entity is documented under its real id")
|
||||
|
||||
|
||||
def compose(device_name, entity_name):
|
||||
"""HA's entity_id from device name + entity name (mqtt.py's note on
|
||||
DEVICE). Written here, not borrowed from mqtt._slug, so a change to that
|
||||
helper cannot move both sides of the comparison."""
|
||||
s = re.sub(r"[^a-z0-9]+", "_", f"{device_name} {entity_name}".lower())
|
||||
return "sensor." + s.strip("_")
|
||||
|
||||
|
||||
p1_rows = [r for r in mqtt.SENSORS if r[0] == "p1_age"]
|
||||
check("2a. mqtt.py announces exactly one `p1_age` sensor", len(p1_rows) == 1)
|
||||
real_id = compose(mqtt.DEVICE["name"], p1_rows[0][2]) if p1_rows else ""
|
||||
print(f" composed from mqtt.py: {real_id}")
|
||||
|
||||
# Anchor outside mqtt.py: the id recorded from the live install.
|
||||
recorded = read(HERE, "tools", "rehearsal", "announced-reference.txt")
|
||||
check("2b. ...and that composes to the id recorded from the live install "
|
||||
"(tools/rehearsal/announced-reference.txt)",
|
||||
re.search(rf"^{re.escape(real_id)}\s+goodwe_ctl_p1_age\b", recorded, re.M)
|
||||
is not None)
|
||||
|
||||
m = re.search(r"^#### `(sensor\.[a-z0-9_]+)`\n\n(?:.*\n){0,6}?.*stale-input watchdog",
|
||||
DOCS, re.M)
|
||||
documented = m.group(1) if m else None
|
||||
check(f"2c. DOCS.md's watchdog-entity section is headed with that id "
|
||||
f"(found {documented!r})", documented == real_id)
|
||||
|
||||
# The wrong id, anywhere an installer reads. CHANGELOG.md and DECISIONS/ are
|
||||
# history and keep what was said at the time.
|
||||
SHIPPED_DOCS = [
|
||||
os.path.join(HERE, "DOCS.md"), os.path.join(HERE, "config.yaml"),
|
||||
os.path.join(REPO, "README.md"), os.path.join(REPO, "FIELD-GUIDE.md"),
|
||||
os.path.join(REPO, "docs", "build_guide.py"),
|
||||
os.path.join(REPO, "docs", "field-guide.html"),
|
||||
]
|
||||
WRONG = "p1_sample_age_s"
|
||||
hits = []
|
||||
for path in SHIPPED_DOCS:
|
||||
for n, line in enumerate(read(path).splitlines(), 1):
|
||||
if WRONG in line:
|
||||
hits.append(f"{os.path.relpath(path, REPO)}:{n}")
|
||||
check(f"2d. `{WRONG}` appears in no shipped doc (hits: {hits})", not hits)
|
||||
|
||||
# MUTATION: the old heading must fail 2c's comparison.
|
||||
bad = DOCS.replace(f"#### `{real_id}`", f"#### `sensor.{WRONG}`")
|
||||
mb = re.search(r"^#### `(sensor\.[a-z0-9_]+)`\n\n(?:.*\n){0,6}?.*stale-input watchdog",
|
||||
bad, re.M)
|
||||
check("2e. MUTATION: the old heading is found and does not match",
|
||||
mb is not None and mb.group(1) != real_id)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
sys.exit(1)
|
||||
print("all ok")
|
||||
@@ -19,7 +19,8 @@ shipped `meter_freeze_s` itself:
|
||||
a check that calls the predicate directly would not be testing the thing
|
||||
that ships. Uses the rig's own `sim/hwsim.py --fault freeze` when the infra
|
||||
repo is next to this one (or `$GOODWE_SIM`), and an equivalent local stub
|
||||
otherwise. Both are exercised; the run says which one it used.
|
||||
otherwise. Both are exercised; the run says which one it used. CI sets
|
||||
`GOODWE_REQUIRE_HWSIM=1`, which refuses the stub (CI-12).
|
||||
|
||||
4. THE WIRING: the real `Controller.cycle()`, asserted on the service call
|
||||
that leaves the process;
|
||||
@@ -600,6 +601,15 @@ hw = find_hwsim()
|
||||
# that cannot fail, from the other end.
|
||||
PART3_TRANSPORT = f"the rig hwsim at {hw}" if hw else "the local stub"
|
||||
print(f"frozen meter over HTTP - using {PART3_TRANSPORT}")
|
||||
# CI-12: CI sets GOODWE_REQUIRE_HWSIM=1, so a missing infra checkout fails here
|
||||
# instead of passing on the stub. Local runs may still use the stub, and say so.
|
||||
if not hw:
|
||||
if os.environ.get("GOODWE_REQUIRE_HWSIM"):
|
||||
print("FAIL: GOODWE_REQUIRE_HWSIM is set but sim/hwsim.py was not found "
|
||||
"(sibling ../goodwe or $GOODWE_SIM) - refusing to fall back to the stub")
|
||||
sys.exit(1)
|
||||
print("STUB USED: part 3 ran against the local stub, NOT the infra repo's "
|
||||
"sim/hwsim.py - not a CI-grade result")
|
||||
if hw:
|
||||
def argv_for(mode):
|
||||
return [HWSIM_DRIVER, "8791" if mode == "freeze" else "8792", mode,
|
||||
@@ -821,9 +831,9 @@ all_notes = all_notes_of(hass_f) # the whole surface
|
||||
# install has. Flipped here rather than plumbing a whole P1 transport in - the
|
||||
# row's own content is what is under test.
|
||||
ctl_f.p1_enabled = True
|
||||
rows = [c for c in ctl_f.checks() if "METER MAY BE FROZEN" in c["text"]]
|
||||
frozen_rows = [c for c in ctl_f.checks() if "METER MAY BE FROZEN" in c["text"]]
|
||||
print(f" frozen at +4000 W: command ran to {ctl_f.target:.0f} W, "
|
||||
f"{len(notes)} notification(s), {len(rows)} red status row(s)")
|
||||
f"{len(notes)} notification(s), {len(frozen_rows)} red status row(s)")
|
||||
# SURFACE-WIDE, deliberately, and the second guard has now landed. SAFETY-03
|
||||
# ships `goodwe_output_clamp`, and this scenario - a runaway loop winding into
|
||||
# the rail - raises it legitimately: the loop asks for more than the rating and
|
||||
@@ -849,7 +859,7 @@ check("...with a fixed notification_id so repeats replace, not stack",
|
||||
check("...and it says the controller is still running",
|
||||
bool(notes) and "STILL RUNNING" in notes[0]["message"])
|
||||
check("the status page carries a failed row saying so",
|
||||
len(rows) == 1 and not rows[0]["ok"] and not rows[0]["warn"])
|
||||
len(frozen_rows) == 1 and not frozen_rows[0]["ok"] and not frozen_rows[0]["warn"])
|
||||
check("the event log names it", any("METER FROZEN" in e for e in ctl_f.events))
|
||||
# ⚠️ NOTIFY, NOT TRIP. If a later cut turns this into a trip it must come here
|
||||
# and change this expectation deliberately, not discover it in the field.
|
||||
@@ -1263,7 +1273,8 @@ check("a Controller built with the key absent lands on the SHIPPED hold, so "
|
||||
_ctl_c.freeze_wd.cfg.hold_s == SHIPPED_HOLD_S)
|
||||
|
||||
print()
|
||||
print(f"part 3 (the live HTTP transport) ran against: {PART3_TRANSPORT}")
|
||||
# `CI: ` lines are echoed into the CI log even on a pass (checks.yml).
|
||||
print(f"CI: test_freeze part 3 (the live HTTP transport) ran against: {PART3_TRANSPORT}")
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -69,6 +69,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app import freshness as F
|
||||
from named_absence import first, given, nth, present
|
||||
from app.circulation import circulating_w
|
||||
from app.freshness import BOUNDS, BATT_OBLIGED_S, BATT_UNCHANGED_S, MOVE_W, Freshness
|
||||
from app.inverter import Inverter
|
||||
@@ -253,11 +254,12 @@ check("FRESH AND UNCHANGED is not stale. Forty identical readings over 39 s "
|
||||
check("⚠️ STALE IS. The same forty readings, the same instants, the same "
|
||||
"numbers - and the command moved 2500 W underneath them. The reading "
|
||||
"becomes None to its consumers, which is the whole card",
|
||||
_stale.stale and _stale_read[-1] is None)
|
||||
_stale.stale and bool(_stale_read) and _stale_read[-1] is None)
|
||||
check("...and it becomes None at the BOUND, not before: 35.0 s of unchanged "
|
||||
"arrivals, so the last fresh verdict is at t=34 and the first None at "
|
||||
"t=35",
|
||||
_stale_read[34] == A_FROZEN_W and _stale_read[35] is None
|
||||
len(_stale_read) > 35
|
||||
and _stale_read[34] == A_FROZEN_W and _stale_read[35] is None
|
||||
and BATT_UNCHANGED_S == 35.0)
|
||||
check("...and NEVER a held number and never a zero - `reading()` has exactly "
|
||||
"two shapes, the arrived value or None",
|
||||
@@ -449,14 +451,14 @@ _cu, _circu, _tripu, _standu, _ = run(_cycles(False))
|
||||
_WD_S = 2.0 # `unit_sign_disagree_s` as this fixture sets it, above
|
||||
print(f" standing on A (gated): {[round(v) for v in _standg]}")
|
||||
print(f" gated : sign trip tick={_tripg} circ={_circg[:6]}...")
|
||||
print(f" gate closed after {_tgate:.2f} s of WALL clock, against the "
|
||||
f"{_WD_S:.0f} s watchdog window ({_WD_S / _tgate:.1f}x margin)")
|
||||
print(f" gate closed after {given(_tgate):.2f} s of WALL clock, against the "
|
||||
f"{_WD_S:.0f} s watchdog window ({_WD_S / given(_tgate):.1f}x margin)")
|
||||
print(f" counterfact: sign trip tick={_tripu} circ={_circu[:6]}...")
|
||||
|
||||
check("⚠️ PRECONDITION ON THE WALL CLOCK, AND IT IS A CHECK RATHER THAN AN "
|
||||
"ASSUMPTION (CI-06). The gate closes on the INJECTED clock, the watchdog "
|
||||
"on the real one, so this fixture only means something while the gate "
|
||||
f"closes well inside the window: it closed at {_tgate:.2f} s against "
|
||||
f"closes well inside the window: it closed at {given(_tgate):.2f} s against "
|
||||
f"{_WD_S:.0f} s. If this fails the machine was slow, not the code - and "
|
||||
"saying so here is what stops that being read as a defect",
|
||||
_tgate is not None and _tgate < 0.5 * _WD_S)
|
||||
@@ -667,13 +669,13 @@ print("6. THE BOUNDS, graded against the SHIPPED config.yaml at BOTH ends")
|
||||
|
||||
|
||||
def option_default(key):
|
||||
"""`key`'s default out of config.yaml's `options:` block, at two spaces."""
|
||||
"""`key`'s default out of config.yaml's `options:` block, at two spaces - or ABSENT."""
|
||||
body = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
||||
body = body[body.index("\noptions:"):body.index("\nschema:")]
|
||||
for line in body.splitlines():
|
||||
if line.startswith(f" {key}:") and not line[2:3].isspace():
|
||||
return float(line.split(":", 1)[1].strip())
|
||||
return None
|
||||
return given(None, f"{key} not in config.yaml options")
|
||||
|
||||
|
||||
_circ_hold = option_default("circulation_hold_s")
|
||||
@@ -922,11 +924,11 @@ for _day in _loop_days:
|
||||
_prev8b = _f8b.stale
|
||||
_t += 1.0
|
||||
_waits.sort()
|
||||
_p50 = _waits[len(_waits) // 2]
|
||||
_p50 = nth(_waits, len(_waits) // 2)
|
||||
print(f" {len(_loop_days)} days replayed at 1 Hz in "
|
||||
f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips")
|
||||
print(f" wait for the register's next change after a trip: "
|
||||
f"min {_waits[0]:.1f} s median {_p50:.1f} s max {_waits[-1]:.1f} s; "
|
||||
f"min {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_waits, -1):.1f} s; "
|
||||
f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's "
|
||||
f"710 s")
|
||||
|
||||
@@ -940,7 +942,7 @@ check("⚠️ THE OLD EXIT WAS NOT RARE AND IT WAS NOT SHORT. Ninety-three trips
|
||||
"143.4 s and as long as 21440.1 s. Seventeen of the ninety-three are at "
|
||||
"or beyond the 710 s the live site was parked for on 2026-09-21",
|
||||
_trips8b == 93 and abs(_p50 - 143.4) < 0.1
|
||||
and abs(_waits[-1] - 21440.1) < 0.1
|
||||
and abs(nth(_waits, -1) - 21440.1) < 0.1
|
||||
and sum(1 for w in _waits if w >= 710.0) == 17)
|
||||
check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE - "
|
||||
"which is the whole reason the exit could not stay as it was. The short "
|
||||
@@ -1072,6 +1074,8 @@ def _decoder():
|
||||
|
||||
|
||||
def hms(t):
|
||||
if not present(t):
|
||||
return repr(t)
|
||||
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{int(t % 60):02d}"
|
||||
|
||||
|
||||
@@ -1241,9 +1245,9 @@ _holds = [(_bt_moves[i + 1][0] - _bt_moves[i][0], _bt_moves[i][0],
|
||||
_bt_moves[i + 1][0], _bt_moves[i][1])
|
||||
for i in range(len(_bt_moves) - 1)]
|
||||
_HOLD_S, _HOLD_FROM, _REG_MOVES, _HOLD_W = max(_holds)
|
||||
_TRIP = next(t for t, s, _h, _sw, _r in _rows_after if s and t > _HOLD_FROM)
|
||||
_PARK = next(t for t, v in _sp if t > _TRIP and v == 0.0)
|
||||
_HUMAN = next(t for t, v in _sp if t > _PARK and v != 0.0)
|
||||
_TRIP = first((t for t, s, _h, _sw, _r in _rows_after if s and t > _HOLD_FROM), "no trip")
|
||||
_PARK = first((t for t, v in _sp if t > _TRIP and v == 0.0), "no 0 W after the trip")
|
||||
_HUMAN = first((t for t, v in _sp if t > _PARK and v != 0.0), "no command after the park")
|
||||
_blips = [t for t, s, _h, _sw, _r in _edges(_rows_after)
|
||||
if s and t < _HOLD_FROM]
|
||||
print(f" longest register hold {_HOLD_S:.1f} s at {_HOLD_W:+.0f} W, "
|
||||
@@ -1259,8 +1263,8 @@ def _first_clear_after(rows, t_from):
|
||||
return next((t for t, s, _h, _sw, _r in rows if t > t_from and not s), None)
|
||||
|
||||
|
||||
_clear_before = _first_clear_after(_rows_before, _TRIP)
|
||||
_clear_after = _first_clear_after(_rows_after, _TRIP)
|
||||
_clear_before = given(_first_clear_after(_rows_before, _TRIP), "no clear")
|
||||
_clear_after = given(_first_clear_after(_rows_after, _TRIP), "no clear")
|
||||
print(f" BEFORE (release_s=inf): stale {hms(_TRIP)} -> clear "
|
||||
f"{hms(_clear_before)} {_clear_before - _TRIP:.0f} s")
|
||||
print(f" AFTER (release_s={BOUNDS['batt'].release_s:.0f}): stale "
|
||||
@@ -1279,7 +1283,8 @@ check("PRECONDITION: the episode is in the capture where the manifest says it "
|
||||
"several. 0x0518 answers -3 W for 850.0 s from 10:51:24, the gate arms "
|
||||
"at 10:52:00, the site's own 0 W reaches the wire at 10:52:21, and "
|
||||
"nothing else moves until a human does at 11:05:31",
|
||||
abs(_HOLD_S - 850.0) < 0.1 and _HOLD_W == -3.0
|
||||
present(_TRIP, _PARK, _HUMAN)
|
||||
and abs(_HOLD_S - 850.0) < 0.1 and _HOLD_W == -3.0
|
||||
and hms(_HOLD_FROM) == "10:51:24" and hms(_TRIP) == "10:52:00"
|
||||
and hms(_PARK) == "10:52:21" and hms(_HUMAN) == "11:05:31"
|
||||
and hms(_REG_MOVES) == "11:05:34"
|
||||
@@ -1310,7 +1315,7 @@ check("⚠️ GREEN AFTER, AND THE REGISTER NEVER MOVES - THIS IS THE CARD. The
|
||||
< _PARK + BOUNDS["batt"].release_s + 1.0
|
||||
and hms(_clear_after) == "10:52:39" and _f_after.releases >= 1
|
||||
and _D.held_at(_bt, _clear_after) == _D.held_at(_bt, _TRIP) == -3.0
|
||||
and next(r for r in _rows_after if r[0] == _clear_after)[2] == -3.0)
|
||||
and first(r for r in _rows_after if r[0] == _clear_after)[2] == -3.0)
|
||||
check("...and the recovery is worth 776 s of this one episode: the site is "
|
||||
"handed its telemetry back at 10:52:39 instead of at 11:05:35, and 772 "
|
||||
"of those seconds are BEFORE the human touched anything. That is the "
|
||||
@@ -1555,8 +1560,9 @@ async def _wired_release():
|
||||
|
||||
|
||||
_c57, _seen57 = run(_wired_release())
|
||||
_trip57 = next(t for t, b, _ok in _seen57 if b is None)
|
||||
_back57 = next(t for t, b, _ok in _seen57 if t > _trip57 and b is not None)
|
||||
_trip57 = first((t for t, b, _ok in _seen57 if b is None), "batt never None")
|
||||
_back57 = first((t for t, b, _ok in _seen57 if t > _trip57 and b is not None),
|
||||
"batt never back")
|
||||
print(f" real read path: batt None from t={_trip57}, a number again at "
|
||||
f"t={_back57}, register held {A_FROZEN_W:+.0f} throughout")
|
||||
check("⚠️ THROUGH THE SHIPPED READ PATH, NOT THE POLICY OBJECT. "
|
||||
@@ -1581,12 +1587,12 @@ check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATO
|
||||
"the event log and the logger, in order, naming the unit",
|
||||
len(_ev57) == 2 and "STALE inverter-a" in _ev57[1]
|
||||
and "STALE WITHDRAWN inverter-a" in _ev57[0]
|
||||
and len(_rel57) == 1 and "inverter-a" in _rel57[0])
|
||||
and len(_rel57) == 1 and "inverter-a" in nth(_rel57, 0))
|
||||
check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has "
|
||||
"happened: it says in its own words that nothing has been learned about "
|
||||
"the register, and it prints the value the register is STILL holding",
|
||||
"NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in _rel57[0]
|
||||
and f"still holding {A_FROZEN_W:+.0f}" in _rel57[0])
|
||||
"NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in nth(_rel57, 0)
|
||||
and f"still holding {A_FROZEN_W:+.0f}" in nth(_rel57, 0))
|
||||
|
||||
|
||||
# --- 10h. the two sentences, as ORDERED PAIRS (habit 14) ---------------------
|
||||
@@ -1596,7 +1602,7 @@ check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has
|
||||
# escape clause, and 250 W and 17.0 s again in the release note - so each is
|
||||
# asserted with its value embedded in its own literal position.
|
||||
_stale57 = [m for m in _msgs57 if "SAFETY-37" in m]
|
||||
print(f" escape clause: ...{_stale57[0][-150:]}")
|
||||
print(f" escape clause: ...{nth(_stale57, 0)[-150:]}")
|
||||
check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDERED "
|
||||
"PAIR. It used to end 'It clears itself the moment the register reports "
|
||||
"a different value.' - which was FALSE, because the 0 W in the sentence "
|
||||
@@ -1604,16 +1610,16 @@ check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDER
|
||||
"17 s in the TIME slot and 250 W in the POWER slot; swapped, this goes "
|
||||
"red",
|
||||
f"failing that {BOUNDS['batt'].release_s:.0f} s after the standing "
|
||||
f"command falls below {BOUNDS['batt'].move_w:.0f} W" in _stale57[0])
|
||||
f"command falls below {BOUNDS['batt'].move_w:.0f} W" in nth(_stale57, 0))
|
||||
check("...and the release note names 250 W in the POWER slot and 17 s in the "
|
||||
"TIME slot, in that order, which is the reverse arrangement of the same "
|
||||
"two numbers",
|
||||
f"sat below {BOUNDS['batt'].move_w:.0f} W for "
|
||||
f"{BOUNDS['batt'].release_s:.0f} s" in _rel57[0])
|
||||
f"{BOUNDS['batt'].release_s:.0f} s" in nth(_rel57, 0))
|
||||
check("...and the 35.0 s the register is re-graded over is named in the note "
|
||||
"too, because it is what the operator is being told to wait out",
|
||||
f"re-graded over a fresh {BOUNDS['batt'].unchanged_s:.0f} s window"
|
||||
in _rel57[0])
|
||||
in nth(_rel57, 0))
|
||||
check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN, "
|
||||
"WHICH IS WHAT THIS CHECK WAS FOR. It used to require 'battery-power "
|
||||
"register is not following its own command' to still be present, so "
|
||||
@@ -1622,7 +1628,8 @@ check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN,
|
||||
"requirement is now the inverse: the accusation is GONE from this line "
|
||||
"and part 11 grades what replaced it. The escape clause above is "
|
||||
"SAFETY-57's and is untouched by it",
|
||||
"battery-power register is not following its own command"
|
||||
present(nth(_stale57, 0))
|
||||
and "battery-power register is not following its own command"
|
||||
not in _stale57[0]
|
||||
and "did not move under a command that did" in _stale57[0])
|
||||
check("...and a re-trip's reason names the TWO durations separately, each "
|
||||
@@ -1678,11 +1685,11 @@ print("11. SAFETY-56: the sentence this gate can honestly say")
|
||||
# story. Every boundary is derived in part 10b from the register's own longest
|
||||
# hold; nothing here is typed.
|
||||
_S56_CMDS_HELD = sorted({_D.held_at(_sp, t)
|
||||
for t in range(int(_HOLD_FROM), int(_PARK))})
|
||||
for t in range(int(_HOLD_FROM), int(_PARK) if present(_PARK) else 0)})
|
||||
_S56_SOC_HELD = sorted({_D.held_at(_soc, t)
|
||||
for t in range(int(_HOLD_FROM), int(_HUMAN))})
|
||||
_S56_CHARGE_T, _S56_CHARGE_W = next((t, v) for t, v in _sp
|
||||
if t > _PARK and v < 0.0)
|
||||
for t in range(int(_HOLD_FROM), int(_HUMAN) if present(_HUMAN) else 0)})
|
||||
_S56_CHARGE = first(((t, v) for t, v in _sp if t > _PARK and v < 0.0), "no charge command")
|
||||
_S56_CHARGE_T, _S56_CHARGE_W = _S56_CHARGE[0], _S56_CHARGE[1]
|
||||
_S56_AFTER = _D.held_at(_bt, _REG_MOVES + 5.0)
|
||||
print(f" during the 850 s hold: commands {_S56_CMDS_HELD} W, "
|
||||
f"SoC {_S56_SOC_HELD} %, register {_HOLD_W:+.0f} W")
|
||||
@@ -1695,7 +1702,7 @@ check("11a. ⚠️ THE OLD DIAGNOSIS WAS FALSE ON THE ONE SITE IT WAS EVER "
|
||||
"-3 W. Then the first CHARGE command arrives and it moves within ONE "
|
||||
"17.0 s obligation window. A register that answers a reversed command in "
|
||||
"one window had not stopped reporting; what would not move was the PACK",
|
||||
all(v > 0.0 for v in _S56_CMDS_HELD)
|
||||
bool(_S56_CMDS_HELD) and all(v > 0.0 for v in _S56_CMDS_HELD)
|
||||
and min(_S56_CMDS_HELD) == 260.0 and max(_S56_CMDS_HELD) == 1060.0
|
||||
and _S56_SOC_HELD == [11.0] and _HOLD_W == -3.0
|
||||
and _S56_CHARGE_W < 0.0
|
||||
@@ -1711,7 +1718,7 @@ check("...and the gate was RIGHT to fire anyway, which is why this card touches
|
||||
and BOUNDS["batt"].move_w == MOVE_W
|
||||
and BOUNDS["batt"].release_s == F.BATT_OBLIGED_S
|
||||
and hms(_TRIP) == "10:52:00" and _f_after.releases == 1
|
||||
and next(r for r in _rows_after if r[0] == _TRIP)[1] is True)
|
||||
and first(r for r in _rows_after if r[0] == _TRIP)[1] is True)
|
||||
|
||||
# --- 11b. two worlds, one published stream, one verdict ----------------------
|
||||
# ⚠️ GROUND TRUTH IS WRITTEN DOWN, NOT DERIVED FROM THE PUBLISHED STREAM
|
||||
@@ -1817,10 +1824,10 @@ def _s56_row(c):
|
||||
retried rather than read. Measured: 0 named reds before, 2 after.
|
||||
"""
|
||||
hits = [r for r in c.checks() if r["text"].startswith("battery power")]
|
||||
if len(hits) != 1:
|
||||
return {"ok": None, "warn": None,
|
||||
"text": f"<NO SINGLE `battery power` ROW: {len(hits)} found>"}
|
||||
return hits[0]
|
||||
if len(hits) == 1:
|
||||
return hits[0]
|
||||
return {"ok": None, "warn": None,
|
||||
"text": f"<NO SINGLE `battery power` ROW: {len(hits)} found>"}
|
||||
|
||||
|
||||
_row56_ref = _s56_row(_c56_ref)
|
||||
|
||||
@@ -73,6 +73,8 @@ import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from named_absence import first, given, nth, present
|
||||
|
||||
from app import main as app_main
|
||||
from app.inverter import LINK_STALE_S, UNIT_A, Inverter, build_roster
|
||||
from app.main import WRITE_BUDGET_S, Controller
|
||||
@@ -993,8 +995,9 @@ _compute_calls = [n for n in ast.walk(MAIN_AST)
|
||||
and n.func.id == "compute"]
|
||||
check("main.py has exactly ONE `compute(` call site, so the argument graded "
|
||||
"below is unambiguously the shipped one", len(_compute_calls) == 1)
|
||||
_actual_src = ast.unparse(
|
||||
next(k.value for k in _compute_calls[0].keywords if k.arg == "actual_w"))
|
||||
_actual_kw = first((k.value for k in nth(_compute_calls, 0).keywords if k.arg == "actual_w"),
|
||||
"no actual_w= keyword")
|
||||
_actual_src = ast.unparse(_actual_kw) if present(_actual_kw) else repr(_actual_kw)
|
||||
_site_batt_assign = [ast.unparse(n.value) for n in ast.walk(CTRL)
|
||||
if isinstance(n, ast.Assign) and len(n.targets) == 1
|
||||
and isinstance(n.targets[0], ast.Name)
|
||||
@@ -1073,7 +1076,7 @@ print("6. AC 6: independent heartbeats, with the hang INJECTED")
|
||||
#
|
||||
# Two things fix it and both are graded: the writes go out CONCURRENTLY, and
|
||||
# each one is bounded by `WRITE_BUDGET_S`.
|
||||
_HB_MAX = read_schema_max("heartbeat_s")
|
||||
_HB_MAX = given(read_schema_max("heartbeat_s"), "heartbeat_s bound")
|
||||
print(f" WRITE_BUDGET_S {WRITE_BUDGET_S:.0f} s + the schema's largest "
|
||||
f"heartbeat_s {_HB_MAX:.0f} s = {WRITE_BUDGET_S + _HB_MAX:.0f} s, against "
|
||||
f"layer 1's {FIRMWARE_INGEST_VETO_S:.0f} s veto")
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Runnable check for TEST-26 - a missing value must be a NAMED red. `python3 test_named_absence.py`
|
||||
|
||||
Every test file here is one script. A check or print that RAISES on a missing
|
||||
value (an empty list indexed, `next()` exhausted, `:.0f` on None) kills every
|
||||
check after it: the mutation it should name shows up as one unnamed traceback,
|
||||
and the checks below it are never graded (MUTATION.md, FCST-06).
|
||||
|
||||
1. `named_absence.py`, the shared non-raising accessors.
|
||||
2. A scan of every `test_*.py` for the shapes that raise, which must find none.
|
||||
3. What the scan cannot see, counted on every run.
|
||||
"""
|
||||
import ast
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from named_absence import field, first, given, nth, present
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print("1. the accessors never raise, and an absence is never a pass")
|
||||
# ==============================================================================
|
||||
_a = nth([], 0)
|
||||
_b = first(x for x in (1, 2) if x > 5)
|
||||
_c = field({"a": 1}, "pv")
|
||||
_d = given(None)
|
||||
print(f" {_a!r} {_b!r} {_c!r} {_d!r}")
|
||||
check("each accessor hands back the value when it is there",
|
||||
nth([7, 8], -1) == 8 and first(iter([3])) == 3
|
||||
and field({"pv": 0.0}, "pv") == 0.0 and given(0.0) == 0.0
|
||||
and present(0.0, None, "", []))
|
||||
check("...and a stand-in that says what was missing when it is not",
|
||||
repr(_a) == "<ABSENT [0] of 0 item(s)>" and repr(_c) == "<ABSENT key 'pv'>"
|
||||
and not present(_a) and not present(1, _b) and not present(_c, _d))
|
||||
check("formatting, indexing, attributes and arithmetic do not raise",
|
||||
f"{_a:.2f}|{_a[2]['x']:+.0f}|{_d.attr.get('k'):,}|{_d / 2 - 1:.1f}"
|
||||
== "|".join(["<ABSENT [0] of 0 item(s)>"] * 2 + ["<ABSENT None>"] * 2))
|
||||
check("an absence is never equal, unequal, ordered or truthy - so `==`, `!=` "
|
||||
"and `<` grade it FAIL",
|
||||
not (_a == 0) and not (_a != 0) and not (_a < 1) and not (_a >= 1)
|
||||
and not bool(_a) and not (_a == _a))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("2. no test file reads a possibly-missing value in a way that raises")
|
||||
# ==============================================================================
|
||||
# The scan flags, anywhere in a file:
|
||||
# index `name[k]` (constant k) where `name` holds a FILTERED list - a
|
||||
# comprehension with an `if`, `filter()`, or an accumulator started
|
||||
# as `[]` - directly or as returned by a function in the same file;
|
||||
# next `next(it)` without a default;
|
||||
# format a numeric format spec on a name that may be None - assigned None,
|
||||
# an `... else None`, a one-argument `.get()`, or tested `is None`.
|
||||
# A site is guarded when an earlier operand of the same `and`/`or`, or the
|
||||
# test of an enclosing `if`/`while`/conditional expression, names the value,
|
||||
# or it sits in a `try`. A check's NAME is evaluated before its condition, so
|
||||
# a guard in the condition does not guard the name.
|
||||
_NUMFMT = set("fdeEgGn%xXbo")
|
||||
_FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)
|
||||
_COMPS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
|
||||
|
||||
|
||||
def _filtered(rhs, returns):
|
||||
if isinstance(rhs, (ast.ListComp, ast.GeneratorExp)):
|
||||
return any(g.ifs for g in rhs.generators)
|
||||
if isinstance(rhs, ast.List):
|
||||
return not rhs.elts
|
||||
if isinstance(rhs, ast.Call) and isinstance(rhs.func, ast.Name):
|
||||
f = rhs.func.id
|
||||
if f == "filter":
|
||||
return True
|
||||
if f in ("list", "sorted", "tuple") and rhs.args:
|
||||
return _filtered(rhs.args[0], returns)
|
||||
return "listy" in returns.get((f, None), ())
|
||||
return False
|
||||
|
||||
|
||||
def _maybe_none(rhs, returns):
|
||||
if isinstance(rhs, ast.Constant):
|
||||
return rhs.value is None
|
||||
if isinstance(rhs, ast.IfExp):
|
||||
return _maybe_none(rhs.body, returns) or _maybe_none(rhs.orelse, returns)
|
||||
if isinstance(rhs, ast.Call):
|
||||
if isinstance(rhs.func, ast.Attribute) and rhs.func.attr == "get":
|
||||
return len(rhs.args) == 1 or (len(rhs.args) == 2 and _maybe_none(rhs.args[1], returns))
|
||||
if isinstance(rhs.func, ast.Name):
|
||||
return "noney" in returns.get((rhs.func.id, None), ())
|
||||
return False
|
||||
|
||||
|
||||
class _Scope:
|
||||
def __init__(self, node, parent):
|
||||
self.node, self.parent = node, parent
|
||||
self.bound, self.listy, self.noney = set(), set(), set()
|
||||
|
||||
|
||||
def _dump(n):
|
||||
return ast.dump(n)
|
||||
|
||||
|
||||
def _mentions(expr, base):
|
||||
"""`base` appears in `expr` other than as the thing being indexed, so
|
||||
`x[0] == 1 and x[1]` does not count as guarding `x[1]`."""
|
||||
b = _dump(base)
|
||||
inner = {id(n.value) for n in ast.walk(expr) if isinstance(n, ast.Subscript)}
|
||||
return any(_dump(x) == b and id(x) not in inner for x in ast.walk(expr))
|
||||
|
||||
|
||||
def _exits_on(st, base):
|
||||
"""`if not x: return` (or continue/break/raise) - x is guarded below it."""
|
||||
return (isinstance(st, ast.If) and _mentions(st.test, base) and st.body
|
||||
and isinstance(st.body[-1], (ast.Return, ast.Continue, ast.Break, ast.Raise)))
|
||||
|
||||
|
||||
def scan(src, census=None):
|
||||
"""[(line, kind, text)] for every unguarded raising read in `src`."""
|
||||
tree = ast.parse(src)
|
||||
parent = {c: p for p in ast.walk(tree) for c in ast.iter_child_nodes(p)}
|
||||
|
||||
def scope_node(n):
|
||||
p = parent.get(n)
|
||||
while p is not None and not isinstance(p, _FUNCS + _COMPS + (ast.Module,)):
|
||||
p = parent.get(p)
|
||||
return p
|
||||
|
||||
scopes = {tree: _Scope(tree, None)}
|
||||
for n in ast.walk(tree):
|
||||
if isinstance(n, _FUNCS + _COMPS):
|
||||
scopes[n] = None
|
||||
for n in list(scopes):
|
||||
if n is not tree:
|
||||
scopes[n] = _Scope(n, None)
|
||||
for n, s in scopes.items():
|
||||
if n is not tree:
|
||||
s.parent = scopes[scope_node(n)]
|
||||
|
||||
def targets(t):
|
||||
if isinstance(t, ast.Name):
|
||||
yield t
|
||||
elif isinstance(t, (ast.Tuple, ast.List)):
|
||||
for e in t.elts:
|
||||
yield from targets(e)
|
||||
elif isinstance(t, ast.Starred):
|
||||
yield from targets(t.value)
|
||||
|
||||
for n in ast.walk(tree):
|
||||
if isinstance(n, _FUNCS):
|
||||
a = n.args
|
||||
for arg in a.posonlyargs + a.args + a.kwonlyargs + [a.vararg, a.kwarg]:
|
||||
if arg is not None:
|
||||
scopes[n].bound.add(arg.arg)
|
||||
elif isinstance(n, ast.comprehension):
|
||||
for t in targets(n.target):
|
||||
scopes[scope_node(n)].bound.add(t.id)
|
||||
elif isinstance(n, (ast.For, ast.AsyncFor, ast.With, ast.AugAssign, ast.NamedExpr)):
|
||||
ts = ([n.target] if not isinstance(n, ast.With)
|
||||
else [i.optional_vars for i in n.items if i.optional_vars])
|
||||
for t0 in ts:
|
||||
for t in targets(t0):
|
||||
scopes[scope_node(n)].bound.add(t.id)
|
||||
|
||||
# `x = <default> if x is None else x` makes x not-None: a scope that
|
||||
# normalises x this way does not get x marked by its `is None` tests.
|
||||
normalised = set()
|
||||
for n in ast.walk(tree):
|
||||
if (isinstance(n, ast.Assign) and isinstance(n.value, ast.IfExp)
|
||||
and isinstance(n.value.test, ast.Compare)):
|
||||
for t in n.targets:
|
||||
if isinstance(t, ast.Name) and any(isinstance(x, ast.Name) and x.id == t.id
|
||||
for x in ast.walk(n.value.test)):
|
||||
normalised.add((scope_node(n), t.id))
|
||||
|
||||
# Two passes, so a function's return shape is known before its callers.
|
||||
returns = {}
|
||||
for _ in range(2):
|
||||
for n in ast.walk(tree):
|
||||
if isinstance(n, (ast.Assign, ast.AnnAssign)) and n.value is not None:
|
||||
s = scopes[scope_node(n)]
|
||||
for t0 in (n.targets if isinstance(n, ast.Assign) else [n.target]):
|
||||
if isinstance(t0, ast.Name):
|
||||
s.bound.add(t0.id)
|
||||
if _filtered(n.value, returns):
|
||||
s.listy.add(t0.id)
|
||||
if _maybe_none(n.value, returns):
|
||||
s.noney.add(t0.id)
|
||||
elif (isinstance(t0, ast.Tuple) and isinstance(n.value, ast.Tuple)
|
||||
and len(t0.elts) == len(n.value.elts)):
|
||||
for e, v in zip(t0.elts, n.value.elts):
|
||||
for t in targets(e):
|
||||
s.bound.add(t.id)
|
||||
if _filtered(v, returns):
|
||||
s.listy.add(t.id)
|
||||
if _maybe_none(v, returns):
|
||||
s.noney.add(t.id)
|
||||
elif (isinstance(t0, ast.Tuple) and isinstance(n.value, ast.Call)
|
||||
and isinstance(n.value.func, ast.Name)):
|
||||
for i, e in enumerate(t0.elts):
|
||||
for t in targets(e):
|
||||
s.bound.add(t.id)
|
||||
got = returns.get((n.value.func.id, i), ())
|
||||
if "listy" in got:
|
||||
s.listy.add(t.id)
|
||||
if "noney" in got:
|
||||
s.noney.add(t.id)
|
||||
else:
|
||||
for t in targets(t0):
|
||||
s.bound.add(t.id)
|
||||
elif (isinstance(n, ast.Compare)
|
||||
and any(isinstance(o, (ast.Is, ast.IsNot)) for o in n.ops)
|
||||
and any(isinstance(c, ast.Constant) and c.value is None
|
||||
for c in [n.left] + n.comparators)):
|
||||
for x in [n.left] + n.comparators:
|
||||
if isinstance(x, ast.Name) and (scope_node(n), x.id) not in normalised:
|
||||
s = scopes[scope_node(n)]
|
||||
while s.parent is not None and x.id not in s.bound:
|
||||
s = s.parent
|
||||
s.noney.add(x.id)
|
||||
for fn in tree.body:
|
||||
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
s = scopes[fn]
|
||||
for r in ast.walk(fn):
|
||||
if not isinstance(r, ast.Return) or r.value is None or scope_node(r) is not fn:
|
||||
continue
|
||||
vals = r.value.elts if isinstance(r.value, ast.Tuple) else [r.value]
|
||||
for i, v in enumerate(vals):
|
||||
key = (fn.name, i if isinstance(r.value, ast.Tuple) else None)
|
||||
tags = returns.setdefault(key, set())
|
||||
if isinstance(v, ast.Name) and v.id in s.listy or _filtered(v, returns):
|
||||
tags.add("listy")
|
||||
if isinstance(v, ast.Name) and v.id in s.noney or _maybe_none(v, returns):
|
||||
tags.add("noney")
|
||||
|
||||
def resolve(name_node, attr):
|
||||
s = scopes[scope_node(name_node)]
|
||||
while s is not None:
|
||||
if name_node.id in s.bound or name_node.id in getattr(s, attr):
|
||||
return name_node.id in getattr(s, attr)
|
||||
s = s.parent
|
||||
return False
|
||||
|
||||
def guarded(node, base):
|
||||
ch, p = node, parent.get(node)
|
||||
while p is not None:
|
||||
if isinstance(p, ast.BoolOp) and ch in p.values:
|
||||
if any(_mentions(v, base) for v in p.values[:p.values.index(ch)]):
|
||||
return True
|
||||
elif isinstance(p, (ast.IfExp, ast.If, ast.While)):
|
||||
if ch is not p.test and _mentions(p.test, base):
|
||||
return True
|
||||
elif isinstance(p, (ast.ListComp, ast.GeneratorExp, ast.SetComp)):
|
||||
if ch is p.elt and any(_mentions(i, base) for g in p.generators for i in g.ifs):
|
||||
return True
|
||||
elif isinstance(p, ast.Try) and ch in p.body:
|
||||
return True
|
||||
for field_ in ("body", "orelse"):
|
||||
block = getattr(p, field_, None)
|
||||
if (isinstance(block, list) and ch in block
|
||||
and any(_exits_on(st, base) for st in block[:block.index(ch)])):
|
||||
return True
|
||||
if isinstance(p, _FUNCS):
|
||||
return False
|
||||
ch, p = p, parent.get(p)
|
||||
return False
|
||||
|
||||
out = []
|
||||
for n in ast.walk(tree):
|
||||
if (isinstance(n, ast.Subscript) and isinstance(n.ctx, ast.Load)
|
||||
and isinstance(n.value, ast.Name)):
|
||||
k = n.slice
|
||||
if isinstance(k, ast.UnaryOp) and isinstance(k.op, ast.USub):
|
||||
k = k.operand
|
||||
if (isinstance(k, ast.Constant) and type(k.value) is int
|
||||
and resolve(n.value, "listy") and not guarded(n, n.value)):
|
||||
out.append((n.lineno, "index", ast.unparse(n)))
|
||||
elif (isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id == "next" and len(n.args) == 1 and not n.keywords):
|
||||
if not guarded(n, n.args[0]):
|
||||
out.append((n.lineno, "next", ast.unparse(n)[:60]))
|
||||
elif (isinstance(n, ast.FormattedValue) and n.format_spec is not None
|
||||
and isinstance(n.value, ast.Name)):
|
||||
spec = "".join(v.value for v in n.format_spec.values
|
||||
if isinstance(v, ast.Constant))
|
||||
if (spec and (spec[-1] in _NUMFMT or "," in spec)
|
||||
and resolve(n.value, "noney") and not guarded(n, n.value)):
|
||||
out.append((n.lineno, "format", f"{{{n.value.id}:{spec}}}"))
|
||||
if census is not None:
|
||||
_count_blind(tree, resolve, census)
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def _count_blind(tree, resolve, census):
|
||||
"""Count, inside check() and print() arguments, the forms part 2 skips."""
|
||||
for call in ast.walk(tree):
|
||||
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)
|
||||
and call.func.id in ("check", "print")):
|
||||
continue
|
||||
for n in (x for a in call.args for x in ast.walk(a)):
|
||||
if isinstance(n, ast.Subscript) and isinstance(n.ctx, ast.Load):
|
||||
k = n.slice.operand if isinstance(n.slice, ast.UnaryOp) else n.slice
|
||||
if not isinstance(k, ast.Constant):
|
||||
continue
|
||||
if isinstance(k.value, str):
|
||||
census["key"] += 1
|
||||
elif isinstance(n.value, ast.Name):
|
||||
if not resolve(n.value, "listy"):
|
||||
census["name-list"] += 1
|
||||
elif isinstance(n.value, ast.Attribute):
|
||||
census["attr-list"] += 1
|
||||
else:
|
||||
census["other-index"] += 1
|
||||
elif isinstance(n, ast.FormattedValue) and n.format_spec is not None:
|
||||
if not isinstance(n.value, ast.Name):
|
||||
census["format-expr"] += 1
|
||||
elif not resolve(n.value, "noney"):
|
||||
census["format-name"] += 1
|
||||
elif isinstance(n, ast.BinOp) and isinstance(n.op, (ast.Div, ast.FloorDiv)):
|
||||
census["division"] += 1
|
||||
|
||||
|
||||
# The scan must bite before its silence means anything: one planted site of
|
||||
# each kind, and the guarded spelling of each, which must NOT be flagged.
|
||||
_PLANTED = '''
|
||||
def run():
|
||||
seen = []
|
||||
t_gate = None
|
||||
return seen, t_gate
|
||||
rows = [r for r in range(3) if r > 9]
|
||||
seen, t_gate = run()
|
||||
waits, n = [], 0
|
||||
check(f"first {rows[0]}", bool(rows))
|
||||
print(waits[0])
|
||||
check("pair", rows[0] == 1 and rows[1] == 2)
|
||||
print(seen[-1])
|
||||
print(f"{t_gate:.2f}")
|
||||
print(next(r for r in rows))
|
||||
'''
|
||||
_GUARDED = '''
|
||||
def run():
|
||||
seen = []
|
||||
t_gate = None
|
||||
return seen, t_gate
|
||||
rows = [r for r in range(3) if r > 9]
|
||||
seen, t_gate = run()
|
||||
check("first", bool(rows) and rows[0] == 1)
|
||||
print(seen[-1] if seen else "-")
|
||||
check("pair", len(rows) == 2 and rows[0] < rows[1])
|
||||
print(f"{t_gate:.2f}" if t_gate is not None else "-")
|
||||
print(next((r for r in rows), None))
|
||||
def show(rows):
|
||||
print(rows[0])
|
||||
def head():
|
||||
if not rows:
|
||||
return None
|
||||
return rows[0]
|
||||
'''
|
||||
_hit = scan(_PLANTED)
|
||||
print(f" planted: {_hit}")
|
||||
check("the scan flags a planted site of each kind - a filtered list indexed in "
|
||||
"a check NAME whose condition guards it, an accumulator bound in a tuple "
|
||||
"and one returned from a function, a maybe-None formatted, and next() "
|
||||
"without a default",
|
||||
[(k, t) for _l, k, t in _hit] == [
|
||||
("index", "rows[0]"), ("index", "waits[0]"), ("index", "rows[0]"),
|
||||
("index", "rows[1]"), ("index", "seen[-1]"),
|
||||
("format", "{t_gate:.2f}"),
|
||||
("next", "next((r for r in rows))")])
|
||||
check("...and flags none of the guarded spellings - `and`, a conditional, "
|
||||
"an early return, a default - nor a parameter that shadows a filtered "
|
||||
"name", scan(_GUARDED) == [])
|
||||
|
||||
_SUITES = sorted(HERE.glob("test_*.py"))
|
||||
_found = {p.name: scan(p.read_text(encoding="utf-8")) for p in _SUITES}
|
||||
_all = [(f, *h) for f, hs in _found.items() for h in hs]
|
||||
for f, line, kind, text in _all:
|
||||
print(f" {f}:{line} {kind:6} {text}")
|
||||
check(f"no unguarded raising read in any of the {len(_SUITES)} test files "
|
||||
f"({len(_all)} found) - read it through named_absence.py and conjoin "
|
||||
f"present() in the check", _all == [] and len(_SUITES) >= 30)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("3. what part 2 cannot see, counted - these can still raise")
|
||||
# ==============================================================================
|
||||
# The scan knows a list is filtered, or a name may be None, only from its own
|
||||
# file's syntax. Each form below can still raise and is NOT graded; the counts
|
||||
# are of every such site in check() and print() arguments, largest first.
|
||||
_BLIND = {
|
||||
"key": "`x['k']` - a dict key the code under test may omit",
|
||||
"name-list": "`x[k]` on a list the scan cannot see is filtered - returned "
|
||||
"by another module or a method, or of a fixed length",
|
||||
"format-name": "a numeric format on a name the scan has no reason to think "
|
||||
"is None - e.g. set from another module's function",
|
||||
"format-expr": "a numeric format on an attribute, subscript or call",
|
||||
"attr-list": "`obj.items[k]` - an attribute list",
|
||||
"other-index": "`x[k]` on a call, a slice or a nested subscript",
|
||||
"division": "`/` or `//` by a value that can be 0",
|
||||
}
|
||||
_census = Counter()
|
||||
for p in _SUITES:
|
||||
scan(p.read_text(encoding="utf-8"), _census)
|
||||
for k, why in sorted(_BLIND.items(), key=lambda kv: -_census[kv[0]]):
|
||||
print(f" {_census[k]:5d} {why}")
|
||||
print(" never counted: these forms outside check()/print() arguments; "
|
||||
"attribute access, iteration, int() or range() on a None or an ABSENT; "
|
||||
"a raise inside a fixture function or inside app/.")
|
||||
check("every counted form is in the printed table", set(_census) <= set(_BLIND))
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} CHECK(S) FAILED")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1320,8 +1320,8 @@ check("homewizard_local is enabled here too",
|
||||
row = [s for s in SENSORS if s[0] == "p1_age"]
|
||||
check("the age sensor is declared exactly once", len(row) == 1)
|
||||
check("its object_id pins entity_id to sensor.p1_sample_age_s",
|
||||
row[0][1] == "p1_sample_age_s")
|
||||
check("it is published in seconds", row[0][3] == "s")
|
||||
len(row) == 1 and row[0][1] == "p1_sample_age_s")
|
||||
check("it is published in seconds", len(row) == 1 and row[0][3] == "s")
|
||||
|
||||
|
||||
class _RecordingClient:
|
||||
|
||||
@@ -36,6 +36,7 @@ from math import inf
|
||||
from pathlib import Path
|
||||
|
||||
from app import arbitrage, packshare
|
||||
from named_absence import first, nth, present
|
||||
from app.allocator import plan
|
||||
from app.arbiter import (
|
||||
ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_SAFETY, SCOPE_UNIT, Claim, resolve,
|
||||
@@ -347,12 +348,12 @@ check("...and the shipped behaviour really was the other one, so the line "
|
||||
"above is not vacuous: an equal split of 1500 W is 750/750, and 750 W is "
|
||||
"what a pack at its cut-out was being told to produce",
|
||||
_r_now.shares == {A: 750, Bee: 750})
|
||||
_plan_a = next(p for p in _r_opt.allocation.plans if p.unit == A)
|
||||
_plan_a = first((p for p in _r_opt.allocation.plans if p.unit == A), "no plan for A")
|
||||
print(f" {_plan_a.explain()}")
|
||||
check("...and the RECORD says why, in the line an operator is already reading. "
|
||||
"`UnitPlan.within_envelope` is asked rather than assumed, and SAFETY-28's "
|
||||
"write gate refuses a record that contradicts itself",
|
||||
_plan_a.within_envelope and _plan_a.share_w == 0
|
||||
present(_plan_a) and _plan_a.within_envelope and _plan_a.share_w == 0
|
||||
and _plan_a.hi == 0.0 and _r_opt.allocated)
|
||||
check("a pack at 100 % is not allocated a CHARGE either, and the sibling takes "
|
||||
"the whole of it - the same sentence in the other direction",
|
||||
@@ -477,12 +478,12 @@ check("...and the two halves are separated from EACH OTHER as well: on a pack "
|
||||
"grades the ENVELOPE. Case three - both packs with room, nothing closed - "
|
||||
"is the one that can only be the WEIGHTS",
|
||||
_env_only.shares == {A: 0, Bee: 1500}
|
||||
and _broken[2][1] != _broken[2][2])
|
||||
and len(_broken) > 2 and _broken[2][1] != _broken[2][2])
|
||||
check("...and the third case is the one that isolates the WEIGHTS from the "
|
||||
"ENVELOPE: both packs have room, no interval is closed, nothing is "
|
||||
"refused - and the split still moves from 500/500 to 97/903 because the "
|
||||
"energy headroom says so",
|
||||
_broken[2][1] == {A: 97, Bee: 903} and _broken[2][2] == {A: 500, Bee: 500})
|
||||
nth(_broken, 2)[1] == {A: 97, Bee: 903} and nth(_broken, 2)[2] == {A: 500, Bee: 500})
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -1156,7 +1157,7 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE
|
||||
# reads the allocation's own `unmet_w`, is EDGE-TRIGGERED against the previous
|
||||
# cycle's value, and logs. What it does NOT grade is the sentence it logs or
|
||||
# that a real cycle reaches it; that is stated here rather than implied.
|
||||
_cycle_src = next(
|
||||
_cycle_src = first(
|
||||
f for cls in ast.walk(ast.parse(
|
||||
(Path(__file__).resolve().parent / "app" / "main.py")
|
||||
.read_text(encoding="utf-8")))
|
||||
|
||||
@@ -115,6 +115,12 @@ from math import inf, isinf
|
||||
from pathlib import Path
|
||||
|
||||
from app import peakguard
|
||||
from named_absence import first, given, nth, present
|
||||
|
||||
|
||||
def _src(node):
|
||||
"""`ast.unparse(node)`, or what was there instead - never a raise."""
|
||||
return ast.unparse(node) if isinstance(node, ast.AST) else repr(node)
|
||||
# ⚠️ THE DROP AND REFUSAL REASONS ARE IMPORTED, NOT TRANSCRIBED. A check that
|
||||
# matched the English would stay green against an allocator that renamed the
|
||||
# case out from under it, which is this repository's own named failure mode.
|
||||
@@ -265,18 +271,18 @@ def _schema_bound(key: str, which: int):
|
||||
|
||||
|
||||
# Everything numeric below is read out of the shipped file, not typed here.
|
||||
CAP_W = read_option("peak_cap_w")
|
||||
MAX_HOLD_S = read_option("meter_max_age_s")
|
||||
FREEZE_S = read_option("meter_freeze_s")
|
||||
CYCLE_S = read_option("cycle_floor_s")
|
||||
CAP_W = given(read_option("peak_cap_w"), "peak_cap_w not in config.yaml")
|
||||
MAX_HOLD_S = given(read_option("meter_max_age_s"), "meter_max_age_s not in config.yaml")
|
||||
FREEZE_S = given(read_option("meter_freeze_s"), "meter_freeze_s not in config.yaml")
|
||||
CYCLE_S = given(read_option("cycle_floor_s"), "cycle_floor_s not in config.yaml")
|
||||
# The live install runs max_w 5000 (NOTES.md, freeze.py). That is also the schema
|
||||
# ceiling and the GW5000S-BP's rating, which is why the two agree - so the
|
||||
# device figure is taken from the schema rather than typed.
|
||||
LIVE_MAX_W = read_schema_max("max_w")
|
||||
LIVE_MAX_W = given(read_schema_max("max_w"), "max_w not in config.yaml")
|
||||
# SAFETY-24's two options, out of the shipped file for the same reason.
|
||||
SOC_FLOOR = read_option("peak_soc_floor")
|
||||
BMS_FLOOR = read_option("bms_soc_floor")
|
||||
MAINT_FLOOR = read_option("maintenance_soc_floor")
|
||||
SOC_FLOOR = given(read_option("peak_soc_floor"), "peak_soc_floor not in config.yaml")
|
||||
BMS_FLOOR = given(read_option("bms_soc_floor"), "bms_soc_floor not in config.yaml")
|
||||
MAINT_FLOOR = given(read_option("maintenance_soc_floor"), "maintenance_soc_floor not in config.yaml")
|
||||
# A state of charge that comfortably permits a forced discharge, DERIVED from
|
||||
# the floor rather than typed: every part of this file that is not about
|
||||
# SAFETY-24 has to grade SAFETY-07's behaviour at a SoC where the new floor is
|
||||
@@ -291,8 +297,8 @@ print(f" read from config.yaml: peak_cap_w {CAP_W:.0f} W, "
|
||||
print(f" ...and peak_soc_floor {SOC_FLOOR:.0f} %, bms_soc_floor "
|
||||
f"{BMS_FLOOR:.0f} %, maintenance_soc_floor {MAINT_FLOOR:.0f} %")
|
||||
check("every threshold this check grades against came out of config.yaml",
|
||||
None not in (CAP_W, MAX_HOLD_S, FREEZE_S, CYCLE_S, LIVE_MAX_W,
|
||||
SOC_FLOOR, BMS_FLOOR, MAINT_FLOOR))
|
||||
present(CAP_W, MAX_HOLD_S, FREEZE_S, CYCLE_S, LIVE_MAX_W,
|
||||
SOC_FLOOR, BMS_FLOOR, MAINT_FLOOR))
|
||||
check("...and the SoC these checks use as 'plenty of battery' is comfortably "
|
||||
"clear of the floor, so nothing outside part 9 is silently grading "
|
||||
"SAFETY-24 instead of SAFETY-07", SOC_OK > SOC_FLOOR + 10.0)
|
||||
@@ -437,7 +443,7 @@ print(f" five cycles with the inverter not following: delivered-power floor "
|
||||
check("a floor sized from DELIVERED battery power is stationary while the "
|
||||
"inverter fails to follow; one sized from the standing command winds up",
|
||||
len(set(f"{v:.3f}" for v in delivered_floor)) == 1
|
||||
and intent_floor[-1] > intent_floor[0] * 4)
|
||||
and nth(intent_floor, -1) > nth(intent_floor, 0) * 4)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -796,7 +802,7 @@ def replay(path, *, guarded: bool, cap_w=CAP_W, max_w=LIVE_MAX_W, pack=None,
|
||||
"""
|
||||
ing = P1Ingest(phases=1, max_age_s=MAX_HOLD_S)
|
||||
store = fresh_store(f"{path.stem}-{'g' if guarded else 'u'}"
|
||||
f"{'' if pack is None else f'-p{soc_floor:.0f}'}")
|
||||
f"{'' if pack is None else f'-p{given(soc_floor):.0f}'}")
|
||||
tracker = PeakTracker(store, cap_w=cap_w)
|
||||
batt = 0.0 # delivered discharge, W (positive)
|
||||
next_cycle = None
|
||||
@@ -1049,7 +1055,7 @@ c_off.auto, c_off.soc, c_off.batt, c_off.grid = True, 55.0, 0.0, -6000.0
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c_off.cycle())
|
||||
off_rows = [r for r in c_off.checks() if "peak guard" in r["text"]]
|
||||
print(f" meter_source off: guard={c_off.peak_guard}, wrote "
|
||||
f"{hass_off.writes[-1]:+.0f} W")
|
||||
f"{nth(hass_off.writes, -1):+.0f} W")
|
||||
print(" status row: " + (off_rows[0]["text"] if off_rows else "(none)"))
|
||||
check("with no P1 the guard does not run at all - it files nothing, and the "
|
||||
"legacy path can still charge", c_off.peak_guard is None
|
||||
@@ -1242,7 +1248,7 @@ check("...once per episode, not once per cycle",
|
||||
hass2.notifications.count("goodwe_peak_saturated") == 1)
|
||||
check("...and the command is still bounded by the rating, so a saturated guard "
|
||||
"never asks the hardware for more than it has",
|
||||
abs(hass2.writes[-1]) <= LIVE_MAX_W + 1e-6)
|
||||
bool(hass2.writes) and abs(hass2.writes[-1]) <= LIVE_MAX_W + 1e-6)
|
||||
|
||||
# ==============================================================================
|
||||
# THE WIRING'S OWN MUTATIONS. Everything above this line grades `peakguard.py`,
|
||||
@@ -1439,7 +1445,7 @@ check("...and the consequence is the wind-up part 1 demonstrates, now on the "
|
||||
"REAL controller's own projection: the intent-sized floor re-adds its own "
|
||||
"unmet ask every cycle and reaches the rail SATURATED, while the "
|
||||
"delivered-power floor does not move at all",
|
||||
_i_sat_at is not None and _i_track[-1] > _i_track[0]
|
||||
_i_sat_at is not None and len(_i_track) > 1 and _i_track[-1] > _i_track[0]
|
||||
and len({f"{v:.3f}" for v in _d_track}) == 1
|
||||
and not b3.peak_guard.saturated)
|
||||
# Graded on the parsed call syntax too, so a rename cannot quietly reintroduce
|
||||
@@ -1456,9 +1462,9 @@ _peak_calls = [n for n in ast.walk(ast.parse(_mainsrc))
|
||||
and n.func.attr == "decide"
|
||||
and isinstance(n.func.value, ast.Name)
|
||||
and n.func.value.id == "peakguard"]
|
||||
_call = _peak_calls[0]
|
||||
_batt_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "batt_w"))
|
||||
_soc_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "soc"))
|
||||
_call = nth(_peak_calls, 0, "peakguard.decide() call in main.py")
|
||||
_batt_src = _src(first(k.value for k in _call.keywords if k.arg == "batt_w"))
|
||||
_soc_src = _src(first(k.value for k in _call.keywords if k.arg == "soc"))
|
||||
print(f" main.py's decide(batt_w=...) is `{_batt_src}`, decide(soc=...) is "
|
||||
f"`{_soc_src}`")
|
||||
check("...and the call site says so in the source: the argument is the measured "
|
||||
@@ -1497,8 +1503,8 @@ check(f"...and main.py has exactly ONE `peakguard.decide(` call site (it has "
|
||||
# no fixture separated them - which is exactly the coupling the card forbids and
|
||||
# exactly the kind of survivor a value-based check cannot see. This grades the
|
||||
# NAME, and part 9 below now also separates them behaviourally.
|
||||
_floor_src = ast.unparse(next(k.value for k in _call.keywords
|
||||
if k.arg == "soc_floor")).replace("'", '"')
|
||||
_floor_src = _src(first(k.value for k in _call.keywords
|
||||
if k.arg == "soc_floor")).replace("'", '"')
|
||||
print(f" main.py's decide(soc_floor=...) is `{_floor_src}`")
|
||||
check("...and the floor comes from `peak_soc_floor`, defaulted from the ONE "
|
||||
"mirror - not from `maintenance_soc_floor` (a drain target, not a spend "
|
||||
@@ -1535,8 +1541,9 @@ check("SAFETY-24 adds exactly the two keys it names and no more: §14's kW/W "
|
||||
# Graded on the parsed call syntax, not on the text: the module docstring names
|
||||
# `peak_cap_w` while arguing that it does not read it, so a grep would be
|
||||
# satisfied by deleting the explanation and would fail on writing one.
|
||||
_decide = next(n for n in ast.walk(ast.parse(src))
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "decide")
|
||||
_decide = first((n for n in ast.walk(ast.parse(src))
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "decide"),
|
||||
"def decide in peakguard.py")
|
||||
_DECIDE_ARGS = [a.arg for a in _decide.args.args + _decide.args.kwonlyargs]
|
||||
_getters = [n for n in ast.walk(ast.parse(src))
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
||||
@@ -2010,7 +2017,8 @@ check("...while one point above is accepted, so this refuses the disarmed case "
|
||||
"only and not 'close to the limit'", above == [])
|
||||
check("...and the refusal NAMES BOTH NUMBERS and the shipped default, so the "
|
||||
"operator can act on it without reading the source",
|
||||
f"{BMS_FLOOR:.0f} %" in below[0]
|
||||
len(below) == 1
|
||||
and f"{BMS_FLOOR:.0f} %" in below[0]
|
||||
and f"{BMS_FLOOR - 5:.0f} %" in below[0]
|
||||
and f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %" in below[0])
|
||||
check("...and a non-numeric floor is refused rather than defaulted, for the "
|
||||
@@ -2245,7 +2253,7 @@ def replay_loop(window, *, start_soc, soc_floor, usable_wh=REF_PACK_WH):
|
||||
# measuring something else.
|
||||
EVE = evening(19, 24)
|
||||
EVE_W = [w for _, w in EVE]
|
||||
print(f" the evening of 2026-08-20, {EVE[0][0]:%H:%M}-{EVE[-1][0]:%H:%M} UTC: "
|
||||
print(f" the evening of 2026-08-20, {nth(EVE, 0)[0]:%H:%M}-{nth(EVE, -1)[0]:%H:%M} UTC: "
|
||||
f"{len(EVE)} telegrams, mean {sum(EVE_W) / len(EVE_W):.0f} W, "
|
||||
f"min {min(EVE_W):.0f} W, max {max(EVE_W):.0f} W")
|
||||
check("the window really is a SUSTAINED-LOAD evening and not a quiet one - "
|
||||
@@ -2324,7 +2332,7 @@ check("...and the sweep spans the whole schema range, rule 8's refused low end "
|
||||
HOT = evening(15, 17)
|
||||
HOT_R = replay_loop(HOT, start_soc=SOC_FLOOR - 1.0, soc_floor=SOC_FLOOR)
|
||||
print(f" the same day's high-load block "
|
||||
f"{HOT[0][0]:%H:%M}-{HOT[-1][0]:%H:%M} UTC, pack started one point BELOW "
|
||||
f"{nth(HOT, 0)[0]:%H:%M}-{nth(HOT, -1)[0]:%H:%M} UTC, pack started one point BELOW "
|
||||
f"the floor:")
|
||||
print(f" {HOT_R['limited']} cycles reporting PEAK GUARD FLOOR-LIMITED, "
|
||||
f"{HOT_R['limited_discharging']} of them with the loop simultaneously "
|
||||
@@ -2492,13 +2500,14 @@ print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}")
|
||||
# The guard's own line, which the banner has always carried and which is not
|
||||
# this card's to shorten: `explain()` is SAFETY-24's sentence about a quarter
|
||||
# that is going over the ceiling.
|
||||
_expl25 = rows25[0]["text"].split(". NOTE:")[0]
|
||||
_expl25 = nth(rows25, 0)["text"].split(". NOTE:")[0]
|
||||
check("the NOT READY banner is the guard's own line plus a SHORT scope tag - "
|
||||
"under 90 characters of it - and NOT the row's whole paragraph repeated "
|
||||
"at the top of the page. A headline that is a paragraph is a headline "
|
||||
"nobody reads, and this one is followed three lines later by the same "
|
||||
"text verbatim",
|
||||
_pay25["level"] == "bad"
|
||||
present(_expl25)
|
||||
and _pay25["level"] == "bad"
|
||||
and _pay25["banner"].startswith("NOT READY")
|
||||
and _expl25 in _pay25["banner"]
|
||||
and "BOUNDS THE PEAK GUARD ONLY" in _pay25["banner"].upper()
|
||||
@@ -2507,7 +2516,8 @@ check("the NOT READY banner is the guard's own line plus a SHORT scope tag - "
|
||||
check("...and shortening the banner did NOT shorten the row: the full AC 3 "
|
||||
"sentence is still what the checks list carries, which is the surface the "
|
||||
"operator reads to find out what the floor does not bound",
|
||||
len(rows25[0]["text"]) > 300
|
||||
len(rows25) == 1
|
||||
and len(rows25[0]["text"]) > 300
|
||||
and "not a pack-wide reserve" in rows25[0]["text"]
|
||||
and "BMS cut-out" in rows25[0]["text"])
|
||||
|
||||
@@ -2627,12 +2637,13 @@ CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
|
||||
CLAIM_BUILDERS = {"peak_claim": "peak", "arb_claim": "arbitrage"}
|
||||
|
||||
_main_tree = ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8"))
|
||||
_cycle_def = next(
|
||||
_cycle_def = first((
|
||||
f
|
||||
for cls in ast.walk(_main_tree)
|
||||
if isinstance(cls, ast.ClassDef) and cls.name == "Controller"
|
||||
for f in cls.body
|
||||
if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle")
|
||||
if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle"),
|
||||
"Controller.cycle in main.py")
|
||||
|
||||
|
||||
def claim_source(node):
|
||||
@@ -2957,9 +2968,7 @@ def append_actuator(node):
|
||||
dynamic check in this file is blind to the swap at N=1.
|
||||
"""
|
||||
kw = [k for k in node.keywords if k.arg == "actuator"]
|
||||
if len(kw) != 1:
|
||||
return None
|
||||
return ast.unparse(kw[0].value)
|
||||
return ast.unparse(kw[0].value) if len(kw) == 1 else None
|
||||
|
||||
|
||||
# The appends that BUILD their claim in place - the nine whose routing is
|
||||
@@ -3959,8 +3968,8 @@ check("...and with the key absent entirely the one pack takes the SHIPPED "
|
||||
# the floor comes from is a claim genuinely about the code's shape and is pinned
|
||||
# on the source in part 4, where it already lives; what is graded here is the
|
||||
# VALUE that reaches `decide()`.
|
||||
_floor_call = ast.unparse(next(k.value for k in _call.keywords
|
||||
if k.arg == "soc_floor"))
|
||||
_floor_call = _src(first(k.value for k in _call.keywords
|
||||
if k.arg == "soc_floor"))
|
||||
print(f" main.py's decide(soc_floor=...) is `{_floor_call}`")
|
||||
|
||||
|
||||
@@ -4243,12 +4252,12 @@ check("...and the baseline the table is measured against is the shipped call "
|
||||
# rewritten as `self.inverters[:2]` leaves this expression identical, and it is
|
||||
# the three-pack runs' `len(ctl.packs) == 3` that goes red on it. And it cannot
|
||||
# see the reduction INSIDE `binding_soc_floor()` at all; that is 12c2's.
|
||||
_floor_node = next(k.value for k in _call.keywords if k.arg == "soc_floor")
|
||||
_floor_node = first(k.value for k in _call.keywords if k.arg == "soc_floor")
|
||||
_floor_roster_arg = (_floor_node.args[0]
|
||||
if isinstance(_floor_node, ast.Call) and _floor_node.args
|
||||
else None)
|
||||
print(f" the ROSTER argument main.py reduces is "
|
||||
f"`{ast.unparse(_floor_roster_arg)}`, an AST "
|
||||
f"`{_src(_floor_roster_arg)}`, an AST "
|
||||
f"{type(_floor_roster_arg).__name__}")
|
||||
check("⚠️ THE STRUCTURAL HALF, AND IT IS EXACT RATHER THAN A SUBSTRING. The "
|
||||
"roster handed to `binding_soc_floor()` is the WHOLE property "
|
||||
@@ -4744,28 +4753,31 @@ check("⚠️ THE NONE GUARD IS THE SAME FAMILY. One silent pack at ANY position
|
||||
# killed by the roster set), it cannot see `inv.soc` become `inv.bms_soc_floor`
|
||||
# (killed by the runs' held-flat cut-outs), and it cannot see a change inside
|
||||
# `Inverter.soc` at all.
|
||||
_pfs_fn = next(n for n in ast.walk(ast.parse(_mainsrc))
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and n.name == "pack_floor_soc")
|
||||
_pfs_vals = next(n.value for n in _pfs_fn.body
|
||||
if isinstance(n, ast.Assign)
|
||||
and [ast.unparse(t) for t in n.targets] == ["vals"])
|
||||
_pfs_fn = first((n for n in ast.walk(ast.parse(_mainsrc))
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and n.name == "pack_floor_soc"), "def pack_floor_soc in main.py")
|
||||
_pfs_vals = first((n.value for n in _pfs_fn.body
|
||||
if isinstance(n, ast.Assign)
|
||||
and [ast.unparse(t) for t in n.targets] == ["vals"]),
|
||||
"`vals = ...` in pack_floor_soc")
|
||||
|
||||
|
||||
def _gen_iter_of(fn_node, name):
|
||||
"""The iterable of the comprehension inside the single `name(...)` call."""
|
||||
call = next(n for n in ast.walk(fn_node)
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id == name)
|
||||
comp = call.args[0]
|
||||
return comp.generators[0].iter if len(comp.generators) == 1 else None
|
||||
call = first((n for n in ast.walk(fn_node)
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id == name), f"{name}() call")
|
||||
comp = nth(call.args, 0)
|
||||
gens = getattr(comp, "generators", [])
|
||||
return gens[0].iter if len(gens) == 1 else None
|
||||
|
||||
|
||||
_pfs_min_iter = _gen_iter_of(_pfs_fn, "min")
|
||||
_pfs_vals_iter = nth(getattr(_pfs_vals, "generators", []), 0).iter
|
||||
_pfs_any_iter = _gen_iter_of(_pfs_fn, "any")
|
||||
print(f" pack_floor_soc reduces: list over `{ast.unparse(_pfs_vals.generators[0].iter)}`"
|
||||
f", any() over `{ast.unparse(_pfs_any_iter)}`, min() over "
|
||||
f"`{ast.unparse(_pfs_min_iter)}`")
|
||||
print(f" pack_floor_soc reduces: list over `{_src(_pfs_vals_iter)}`"
|
||||
f", any() over `{_src(_pfs_any_iter)}`, min() over "
|
||||
f"`{_src(_pfs_min_iter)}`")
|
||||
check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduces "
|
||||
"the WHOLE list `vals` - a bare Name, not a Subscript, not a call, not a "
|
||||
"second comprehension - and `vals` is built by ONE comprehension over the "
|
||||
@@ -4777,7 +4789,7 @@ check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduce
|
||||
"to length 12. This grades the code's SHAPE and nothing about the values; "
|
||||
"what the expression MEANS is graded by the runs",
|
||||
isinstance(_pfs_min_iter, ast.Name) and _pfs_min_iter.id == "vals"
|
||||
and ast.unparse(_pfs_min_iter) == "vals"
|
||||
and _src(_pfs_min_iter) == "vals"
|
||||
and isinstance(_pfs_vals, ast.ListComp)
|
||||
and len(_pfs_vals.generators) == 1
|
||||
and not _pfs_vals.generators[0].ifs
|
||||
@@ -4785,13 +4797,13 @@ check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduce
|
||||
and isinstance(_pfs_vals.generators[0].iter.value, ast.Name)
|
||||
and _pfs_vals.generators[0].iter.value.id == "self"
|
||||
and _pfs_vals.generators[0].iter.attr == "inverters"
|
||||
and ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters")
|
||||
and _src(_pfs_vals_iter) == "self.inverters")
|
||||
check("...and the NONE GUARD reduces that same whole list, pinned the same way. "
|
||||
"`any(v is None for v in vals[:1])` is +4 bytes and leaves every other "
|
||||
"line in this file identical; the sweep above reaches it up to length 12 "
|
||||
"and this reaches it at every length",
|
||||
isinstance(_pfs_any_iter, ast.Name) and _pfs_any_iter.id == "vals"
|
||||
and ast.unparse(_pfs_any_iter) == "vals")
|
||||
and _src(_pfs_any_iter) == "vals")
|
||||
check("...and the counterfactual that shows these pins are not vacuous (habit "
|
||||
"7): the very slices they exist for really do unparse to something else, "
|
||||
"so the equalities above are doing work rather than being trivially true "
|
||||
@@ -4813,8 +4825,8 @@ _arb_calls = [n for n in ast.walk(ast.parse(_mainsrc))
|
||||
and n.func.attr == "decide"
|
||||
and isinstance(n.func.value, ast.Name)
|
||||
and n.func.value.id == "arbitrage"]
|
||||
_arb_soc = ast.unparse(next(k.value for k in _arb_calls[0].keywords
|
||||
if k.arg == "soc"))
|
||||
_arb_soc = _src(first(k.value for k in nth(_arb_calls, 0).keywords
|
||||
if k.arg == "soc"))
|
||||
print(f" main.py's arbitrage.decide(soc=...) is `{_arb_soc}` "
|
||||
f"({len(_arb_calls)} call site)")
|
||||
check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()` "
|
||||
@@ -5688,8 +5700,8 @@ MUT.append(("`binding_soc_floor()` reduced over a WINDOW - `max(floors[-2:])` -
|
||||
# cut-out at index 0, 1 and 2 reach it.
|
||||
MUT.append(("main.py grading the site against a WINDOW of roster positions - "
|
||||
"`self.packs[:2]` - ROUND 3 SURVIVOR 2, +4 bytes",
|
||||
_g3_runs[2][3].soc_floor == float(_G3_DEEP_SIM)
|
||||
and binding_soc_floor(_g3_runs[2][2].packs[:2], SOC_FLOOR)
|
||||
nth(_g3_runs, 2)[3].soc_floor == float(_G3_DEEP_SIM)
|
||||
and binding_soc_floor(nth(_g3_runs, 2)[2].packs[:2], SOC_FLOOR)
|
||||
== float(SOC_FLOOR)
|
||||
and all(r[3].soc_floor == float(_G3_DEEP_SIM) for r in _g3_runs)
|
||||
and all(_slice_kills.values()),
|
||||
@@ -5711,7 +5723,7 @@ MUT.append(("main.py grading the site against a WINDOW of roster positions - "
|
||||
MUT.append(("main.py grading the site against a THREE-wide window - "
|
||||
"`self.packs[:3]` / `self.packs[-3:]`, found by THIS round's "
|
||||
"battery, invisible to every behavioural run in this file",
|
||||
ast.unparse(_floor_roster_arg) == "self.packs"
|
||||
_src(_floor_roster_arg) == "self.packs"
|
||||
and isinstance(_floor_roster_arg, ast.Attribute)
|
||||
and all(binding_soc_floor(ctl.packs[:3], SOC_FLOOR) == dec.soc_floor
|
||||
and binding_soc_floor(ctl.packs[-3:], SOC_FLOOR)
|
||||
@@ -5758,7 +5770,7 @@ MUT.append(("`pack_floor_soc` reduced over a WINDOW - `vals[:2]` (+4 bytes) or "
|
||||
MUT.append(("`pack_floor_soc` reduced over a THREE-wide window - `vals[:3]` / "
|
||||
"`vals[-3:]` - invisible to every cycle in this file",
|
||||
_s_head_kill[3] and _s_tail_kill[3]
|
||||
and ast.unparse(_pfs_min_iter) == "vals",
|
||||
and _src(_pfs_min_iter) == "vals",
|
||||
"part 12i's LENGTH SWEEP (widths 1-11, on the REAL property over "
|
||||
"rosters of 1 to 12 real inverters) and its STRUCTURAL PIN. The "
|
||||
"six real cycles cannot see it - three packs is the longest roster "
|
||||
@@ -5777,7 +5789,7 @@ MUT.append(("`pack_floor_soc` reduced with `max()` - the FULLEST pack, which is
|
||||
MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - "
|
||||
"`any(v is None for v in vals[:1])`, +4 bytes",
|
||||
all(g is None for _n, _p, g in _s_none_rows)
|
||||
and ast.unparse(_pfs_any_iter) == "vals",
|
||||
and _src(_pfs_any_iter) == "vals",
|
||||
"part 12i's NONE-GUARD SWEEP - one silent pack at every position of "
|
||||
"every length 1..12 - and the same structural pin. ⚠️ 12d's "
|
||||
"two-pack fixture silences the SECOND pack only, so a guard "
|
||||
@@ -5786,7 +5798,7 @@ MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - "
|
||||
# 32. and the roster the list itself is built from.
|
||||
MUT.append(("`pack_floor_soc` building `vals` from a SLICE of the roster - "
|
||||
"`[inv.soc for inv in self.inverters[:2]]`, +4 bytes",
|
||||
ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters"
|
||||
_src(_pfs_vals_iter) == "self.inverters"
|
||||
and _s_head_kill[2] and not _SLICED,
|
||||
"part 12i's sweep reads the REAL property on real rosters so it "
|
||||
"sees this too, its pin is on the comprehension's own iterable, "
|
||||
@@ -5818,7 +5830,7 @@ check("...and the seventh is honestly reported as not caught, because it cannot
|
||||
"the expression rather than by a check. THAT is why the ceiling is a max "
|
||||
"and not a subtraction - `headroom = cap - peak` would have turned the "
|
||||
"same zero into FULL freedom, which part 3 shows is why headroom_w() is "
|
||||
"not consumed here", not MUT[6][1]
|
||||
"not consumed here", present(nth(MUT, 6)) and not MUT[6][1]
|
||||
and quarter_ceiling_w(CAP_W, _zero_peak)[0] == CAP_W)
|
||||
check("...and SAFETY-32's own eight defeats are each caught too - the flat "
|
||||
"cut-out inherited, the site floor graded in place of the pack's, the "
|
||||
@@ -5851,7 +5863,7 @@ check("...⚠️ AND ONE IS RECORDED AS NOT CAUGHT, which is the only honest row
|
||||
"pin would refuse honest refactors, so the hole is reported with its size "
|
||||
"instead of hidden. THIS is the row that a fifth review should argue "
|
||||
"with, and it exists to be argued with",
|
||||
not MUT[27][1]
|
||||
present(nth(MUT, 27)) and not MUT[27][1]
|
||||
and all(_head_kill[w] and _tail_kill[w] for w in range(1, _SWEEP_N)))
|
||||
check("...⚠️ AND ROUND 4's SIX, WHICH ARE THE SAME FAMILY AT THE SIBLING "
|
||||
"ARGUMENT OF THE SAME CALL. `pack_floor_soc` is the MINIMUM over the "
|
||||
@@ -5873,7 +5885,7 @@ check("...and so is the last one, which is not a defeat at all but the EDGE OF "
|
||||
"and a `lo` limit cannot cap a discharge. Saying so here is worth more "
|
||||
"than a green line that would be untrue - and part 10 now says it with a "
|
||||
"number instead of a sentence, which is worth more again",
|
||||
not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05)
|
||||
present(nth(MUT, 12)) and not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -5953,7 +5965,8 @@ check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), re
|
||||
check("...and the row is green and un-warned, which is what makes the "
|
||||
"transposed version dangerous rather than merely untidy: an OK row "
|
||||
"carrying a projection 2713 W OVER its own ceiling is the one an "
|
||||
"operator scrolls past", _rows38[0]["ok"] and not _rows38[0]["warn"]
|
||||
"operator scrolls past", len(_rows38) == 1
|
||||
and _rows38[0]["ok"] and not _rows38[0]["warn"]
|
||||
and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0)
|
||||
|
||||
# ==============================================================================
|
||||
@@ -6211,8 +6224,9 @@ check("a FLOOR-LIMITED decision - the guard wanted a discharge and SoC refused "
|
||||
# been inverted.
|
||||
_H45_SRC = (HERE / "app" / "peakguard.py").read_text(encoding="utf-8")
|
||||
_H45_TREE = ast.parse(_H45_SRC)
|
||||
_h45_fn = next(n for n in ast.walk(_H45_TREE)
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "decide")
|
||||
_h45_fn = first((n for n in ast.walk(_H45_TREE)
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "decide"),
|
||||
"def decide in peakguard.py")
|
||||
_H45_RUNG = [n for n in ast.walk(_h45_fn) if isinstance(n, ast.If)
|
||||
and ast.unparse(n.test) == "wanted <= 0.0"]
|
||||
check("the `wanted <= 0.0` rung is still in decide(), spelled exactly that "
|
||||
@@ -6223,8 +6237,9 @@ check("...and it still DISENGAGES rather than merely reporting: the fixture "
|
||||
"13a is built on reaches it and files no claim, so the pin above is not "
|
||||
"grading a rung that has been inverted under it",
|
||||
peakguard.claim(_h45_idle) is None)
|
||||
_h45_head_fn = next(n for n in _H45_TREE.body if isinstance(n, ast.FunctionDef)
|
||||
and n.name == "charge_headroom_w")
|
||||
_h45_head_fn = first((n for n in _H45_TREE.body if isinstance(n, ast.FunctionDef)
|
||||
and n.name == "charge_headroom_w"),
|
||||
"def charge_headroom_w in peakguard.py")
|
||||
check("...and charge_headroom_w() is a PURE READ of the decision - one "
|
||||
"positional parameter, no projection, no clock and no options - so it "
|
||||
"cannot become a second answer to the question decide() already answers",
|
||||
|
||||
@@ -60,6 +60,8 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from named_absence import first, nth
|
||||
import tempfile
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from textwrap import dedent
|
||||
@@ -310,8 +312,10 @@ check("...and every one of them is in the schema block too", _unschemad == [])
|
||||
# it. So the schema is deliberately WIDER than the registry, by exactly
|
||||
# RETIRED_PROVIDERS and by nothing else. Both halves are graded: an id that is
|
||||
# in neither set, and a retired id that has gone missing.
|
||||
_line = next(ln for ln in _schema_block if ln.startswith(" price_provider:"))
|
||||
_schema_ids = set(_line.split("list(", 1)[1].rstrip(")").split("|"))
|
||||
_line = first((ln for ln in _schema_block if ln.startswith(" price_provider:")),
|
||||
"price_provider in the schema")
|
||||
_schema_ids = (set(_line.split("list(", 1)[1].rstrip(")").split("|"))
|
||||
if "list(" in _line else set())
|
||||
_wanted = set(PROVIDERS) | set(RETIRED_PROVIDERS) | {"off"}
|
||||
check(f"the schema offers exactly the registered plugins, the retired ids and "
|
||||
f"'off' {sorted(_schema_ids)}",
|
||||
@@ -954,7 +958,7 @@ def asked_url(since, until, extra=None):
|
||||
t = canned(_REAL[_TD])
|
||||
o = opts(price_provider="generic_dayahead", **(extra or {}))
|
||||
run(load_prices(o, since, until, transport=t))
|
||||
return t.calls[0][0]
|
||||
return nth(t.calls, 0)[0]
|
||||
|
||||
|
||||
# The expectation is built HERE, from the window, by different arithmetic than
|
||||
@@ -2069,7 +2073,7 @@ check(f"every networked plugin declares at least one host, and manual_csv "
|
||||
check("the timeout the plugin passes to the transport is the configured one, "
|
||||
"not a hardcoded default",
|
||||
(lambda t: (load("generic_dayahead", {"price_timeout_s": 7},
|
||||
transport=t), t.calls[0][1] == 7.0)[1])(
|
||||
transport=t), nth(t.calls, 0)[1] == 7.0)[1])(
|
||||
canned(columnar_feed(EPEX_EUR_MWH))))
|
||||
|
||||
|
||||
@@ -2477,9 +2481,9 @@ def _started(sites, opts):
|
||||
|
||||
_MAIN_TREE = derived(lambda: ast.parse(open(os.path.join(_APP, "main.py"),
|
||||
encoding="utf-8").read()))
|
||||
_AMAIN = derived(lambda: next(n for n in ast.walk(_MAIN_TREE)
|
||||
if isinstance(n, ast.AsyncFunctionDef)
|
||||
and n.name == "amain"))
|
||||
_AMAIN = derived(lambda: next((n for n in ast.walk(_MAIN_TREE)
|
||||
if isinstance(n, ast.AsyncFunctionDef)
|
||||
and n.name == "amain"), None))
|
||||
_SITES = derived(lambda: _task_sites(_AMAIN))
|
||||
_GRAPH = derived(lambda: _call_graph(_APP))
|
||||
_FETCH_ENTRY = "resolve_tariff"
|
||||
@@ -3491,8 +3495,8 @@ _cf_t = canned(_REAL[_TD])
|
||||
run(load_prices(opts(price_provider="custom_formula", **_CF_OPTS),
|
||||
_w0, _w0 + timedelta(hours=48), transport=_cf_t))
|
||||
check(f"...and asks for the same Brussels range generic_dayahead asks for "
|
||||
f"{_cf_t.calls[0][0]!r}",
|
||||
_cf_t.calls[0][0] == asked_url(_w0, _w0 + timedelta(hours=48)))
|
||||
f"{nth(_cf_t.calls, 0)[0]!r}",
|
||||
nth(_cf_t.calls, 0)[0] == asked_url(_w0, _w0 + timedelta(hours=48)))
|
||||
|
||||
# The price at each instant is the document's own index through the card's
|
||||
# arithmetic - computed HERE from the captured bytes, by different arithmetic.
|
||||
@@ -4684,9 +4688,25 @@ _T17_TAB_OUT = derived(lambda: assemble(_T17_CAPS, _T17_RAW, MARKUP,
|
||||
_T17_TABLE))
|
||||
check("the table parses, through static_tariff's OWN grammar",
|
||||
not isinstance(_T17_TABLE, _Raised))
|
||||
# Graded on this file's own two calls: the arms may differ only in `taxes`.
|
||||
_T17_ARMS = {t.id: [ast.unparse(a) for a in n.value.args[0].body.args]
|
||||
for n in ast.parse(open(__file__, encoding="utf-8").read()).body
|
||||
if isinstance(n, ast.Assign) and isinstance(n.value, ast.Call)
|
||||
and isinstance(n.value.func, ast.Name)
|
||||
and n.value.func.id == "derived" and n.value.args
|
||||
and isinstance(n.value.args[0], ast.Lambda)
|
||||
and isinstance(n.value.args[0].body, ast.Call)
|
||||
for t in n.targets if isinstance(t, ast.Name)
|
||||
and t.id in ("_T17_FLAT_OUT", "_T17_TAB_OUT")}
|
||||
print(f" arms: {_T17_ARMS}")
|
||||
check(f"the two arms are the same feed: same caps, same {len(_T17_RAW)} "
|
||||
f"RawPoints and the same markup {MARKUP}, by IDENTITY and not by value",
|
||||
_T17_CAPS is _T17_CAPS and _T17_RAW is _T17_RAW)
|
||||
f"RawPoints and the same markup {MARKUP} - the two `assemble()` calls "
|
||||
f"share their first three arguments by NAME and differ only in the fourth",
|
||||
sorted(_T17_ARMS) == ["_T17_FLAT_OUT", "_T17_TAB_OUT"]
|
||||
and [len(v) for v in _T17_ARMS.values()] == [4, 4]
|
||||
and _T17_ARMS["_T17_FLAT_OUT"][:3] == _T17_ARMS["_T17_TAB_OUT"][:3]
|
||||
== ["_T17_CAPS", "_T17_RAW", "MARKUP"]
|
||||
and _T17_ARMS["_T17_FLAT_OUT"][3] != _T17_ARMS["_T17_TAB_OUT"][3])
|
||||
_T17_BANDS = derived(lambda: sorted({p.eur_per_kwh
|
||||
for p in _T17_TABLE.periods}))
|
||||
check(f"...and the table is genuinely hour-varying: {_T17_BANDS} EUR/kWh, two "
|
||||
@@ -5562,10 +5582,11 @@ print("17f. the SCALAR path is unchanged - arithmetically, not merely in "
|
||||
# ⚠️ THE ACCEPTANCE CRITERION TARIFF-17 GRADED THE SAME WAY (16f). A site with
|
||||
# a flat injection price is every site that exists today, and "we did not mean
|
||||
# to change it" is not a measurement.
|
||||
_T16_OLD_SCALAR = [ast.unparse(n) for n in ast.walk(next(
|
||||
n for n in ast.parse(open(os.path.join(HERE, "app", "price.py"),
|
||||
encoding="utf-8").read()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "export_price_eur_kwh"))
|
||||
_T16_OLD_SCALAR = [ast.unparse(n) for n in ast.walk(first(
|
||||
(n for n in ast.parse(open(os.path.join(HERE, "app", "price.py"),
|
||||
encoding="utf-8").read()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == "export_price_eur_kwh"),
|
||||
"def export_price_eur_kwh in price.py"))
|
||||
if isinstance(n, ast.Return)]
|
||||
check(f"`export_price_eur_kwh()`'s BODY is still the single expression it has "
|
||||
f"always been, pinned on the AST rather than on the source text: "
|
||||
|
||||
@@ -62,6 +62,8 @@ import inspect
|
||||
import itertools
|
||||
import os
|
||||
import sys
|
||||
|
||||
from named_absence import first, nth
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app import price as _price
|
||||
@@ -638,7 +640,7 @@ _late = fill_from_yesterday(
|
||||
series_of(DAY, lambda u: t_price(brussels(u).hour),
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", provisional=False),
|
||||
yesterday=YESTERDAY, since=D0, until=D1, stamp=D1)
|
||||
_h20 = DAY[20]
|
||||
_h20 = nth(DAY, 20)
|
||||
_before = derived(lambda: FILLED.at(_h20))
|
||||
_after = derived(lambda: _late.at(_h20))
|
||||
check("before the print, hour 20 is provisional and carries YESTERDAY's number",
|
||||
@@ -728,7 +730,8 @@ check("an action that commits nothing is not an irreversible one and cannot be "
|
||||
# prices that were published for the morning and guessed for the evening.
|
||||
_m = maint()
|
||||
_gate_bad = derived(lambda: begin_phase(_m, START, FILLED, MAINTENANCE_DRAIN))
|
||||
_first_prov = derived(lambda: next(u for u in _hours if FILLED.at(u).provisional))
|
||||
_first_prov = derived(lambda: first((u for u in _hours if FILLED.at(u).provisional),
|
||||
"a carried hour in the window"))
|
||||
check("the drain's window really does reach past the published hours while "
|
||||
"staying INSIDE the series, so the refusal below is about provenance and "
|
||||
"not about a short series",
|
||||
@@ -743,7 +746,7 @@ check("...and the phase machine did not move: it is still idle, so the "
|
||||
check("...and the reason names §8.3, the offending hour, and when the real "
|
||||
"price is expected",
|
||||
"§8.3" in _gate_bad.reason
|
||||
and _first_prov.isoformat() in _gate_bad.reason
|
||||
and bool(_first_prov) and _first_prov.isoformat() in _gate_bad.reason
|
||||
and "14-16" in _gate_bad.reason)
|
||||
|
||||
# The positive arm, so the block above is discriminating rather than universal.
|
||||
|
||||
@@ -104,6 +104,8 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from named_absence import first, nth
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -239,7 +241,8 @@ YDAY_H = bday_hours(YDAY_D)
|
||||
|
||||
def at_local(bdate: str, hh: int) -> datetime:
|
||||
"""The UTC instant whose Brussels wall clock reads hh:00 on `bdate`."""
|
||||
return next(u for u in bday_hours(bdate) if brussels(u).hour == hh)
|
||||
return first((u for u in bday_hours(bdate) if brussels(u).hour == hh),
|
||||
f"{bdate} {hh:02d}:00 Brussels")
|
||||
|
||||
|
||||
# ⚠️ AND IT IS NOT ON THE HOUR. A controller's `now` is
|
||||
@@ -791,7 +794,7 @@ print("2b. a QUARTER-HOURLY feed keeps every print, and its spread with them")
|
||||
_QSTEP_MIN = 15
|
||||
_qrows = []
|
||||
for _n in range(len(TODAY_H) * (60 // _QSTEP_MIN)):
|
||||
_qu = TODAY_H[0] + timedelta(minutes=_QSTEP_MIN * _n)
|
||||
_qu = nth(TODAY_H, 0) + timedelta(minutes=_QSTEP_MIN * _n)
|
||||
# A spiky within-the-hour profile, so decimation is visible in the SPREAD
|
||||
# and not only in a count.
|
||||
_qrows.append((_qu,
|
||||
@@ -2009,9 +2012,9 @@ check("...so calling it without one is a TypeError, not a quiet regression",
|
||||
|
||||
_MAIN = open(os.path.join(_APP, "main.py"), encoding="utf-8").read()
|
||||
_main_tree = ast.parse(_MAIN)
|
||||
_refresh = next(n for n in ast.walk(_main_tree)
|
||||
if isinstance(n, ast.AsyncFunctionDef)
|
||||
and n.name == "run_tariff_refresh")
|
||||
_refresh = first((n for n in ast.walk(_main_tree)
|
||||
if isinstance(n, ast.AsyncFunctionDef)
|
||||
and n.name == "run_tariff_refresh"), "run_tariff_refresh in main.py")
|
||||
_kw = [k.arg for n in ast.walk(_refresh) if isinstance(n, ast.Call)
|
||||
for k in n.keywords]
|
||||
check(f"the shipped refresh task passes a history {_kw} - the wiring is what "
|
||||
@@ -2033,9 +2036,9 @@ check("...and `words` reaches the STATUS PAGE as its own row, so what the fill "
|
||||
"has to carry from is a fact an operator can read rather than one that "
|
||||
"only exists in a log line",
|
||||
'("Price history", self.price_history.words)' in _MAIN)
|
||||
_rows_fn = next(n for n in ast.walk(_main_tree)
|
||||
if isinstance(n, ast.FunctionDef)
|
||||
and n.name == "status_payload")
|
||||
_rows_fn = first((n for n in ast.walk(_main_tree)
|
||||
if isinstance(n, ast.FunctionDef)
|
||||
and n.name == "status_payload"), "status_payload in main.py")
|
||||
_row_labels = [e.elts[0].value for e in ast.walk(_rows_fn)
|
||||
if isinstance(e, ast.Tuple) and len(e.elts) == 2
|
||||
and isinstance(e.elts[0], ast.Constant)
|
||||
|
||||
+236
-23
@@ -56,7 +56,8 @@ absence every clear evening.
|
||||
Part 8 ⭐⭐ THE SUM, AND WHAT ONE ABSENT MEMBER DOES TO IT.
|
||||
Part 9 ⭐ publication at a roster of three, as payload bytes.
|
||||
Part 10 the operator surfaces at three - and 0.11.0's, unchanged, at one.
|
||||
Part 11 what this file cannot see, with sizes, and both mutation tables.
|
||||
Part 11 what this file cannot see, with sizes, and the mutation tables.
|
||||
Part 12 TEL-14: the sign, declared per entity (runs before part 11).
|
||||
|
||||
⚠️ MUTATING A MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
@@ -69,6 +70,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app import mqtt, pv
|
||||
from named_absence import field, nth, present
|
||||
from app.main import Controller
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
@@ -348,12 +350,12 @@ check("...and a kW sensor is read in kW: 3.21 kW is 3210 W, not 3.21 W. There "
|
||||
|
||||
# ⚠️ THE ONE THAT SEPARATES "ABSENT" FROM "STALE", AND IT NEEDS TWO CYCLES.
|
||||
c_drop, _, p_drop = run(cycles=2, states=[state("4100", "W"), None], **PV_ON)
|
||||
print(f" published pv by cycle: {[f['pv'] for f in p_drop.frames]}")
|
||||
print(f" published pv by cycle: {[field(f, 'pv') for f in p_drop.frames]}")
|
||||
check("⭐ A FAILED READ CLEARS THE PREVIOUS GOOD ONE. The cycle after a 4100 W "
|
||||
"reading disappears publishes an ABSENCE, not 4100 W again - a value "
|
||||
"that survives its own sensor is indistinguishable from a real one at "
|
||||
"the moment somebody is working out whether the sun was shining",
|
||||
[f["pv"] for f in p_drop.frames] == [4100.0, None]
|
||||
[field(f, "pv") for f in p_drop.frames] == [4100.0, None]
|
||||
and c_drop.pv_w is None and "no state" in c_drop.pv_why)
|
||||
|
||||
|
||||
@@ -378,9 +380,9 @@ def wire(values, include=("pv",)):
|
||||
return pub.client.msgs
|
||||
|
||||
|
||||
_zero = run(cycles=1, states=[state("0", "W")], **PV_ON)[2].frames[-1]
|
||||
_absent = run(cycles=1, states=[None], **PV_ON)[2].frames[-1]
|
||||
_nosrc = p_off.frames[-1]
|
||||
_zero = nth(run(cycles=1, states=[state("0", "W")], **PV_ON)[2].frames, -1)
|
||||
_absent = nth(run(cycles=1, states=[None], **PV_ON)[2].frames, -1)
|
||||
_nosrc = nth(p_off.frames, -1)
|
||||
print(f" no source : 'pv' in frame = {'pv' in _nosrc}")
|
||||
def pv_msg(values):
|
||||
"""The `goodwe_ctl/pv` message the shipped publisher emits, or None."""
|
||||
@@ -388,17 +390,17 @@ def pv_msg(values):
|
||||
return got[0] if got else None
|
||||
|
||||
|
||||
print(f" no reading: {_absent['pv']!r} -> wire {pv_msg(_absent)}")
|
||||
print(f" zero watts: {_zero['pv']!r} -> wire {pv_msg(_zero)}")
|
||||
print(f" no reading: {field(_absent, 'pv')!r} -> wire {pv_msg(_absent)}")
|
||||
print(f" zero watts: {field(_zero, 'pv')!r} -> wire {pv_msg(_zero)}")
|
||||
check("NO SOURCE: an install that never set `pv_entity` publishes no `pv` key "
|
||||
"at all - there is no entity to feed, so feeding one would be a topic "
|
||||
"nothing announced",
|
||||
"pv" not in _nosrc)
|
||||
present(_nosrc) and "pv" not in _nosrc)
|
||||
check("NO READING: the key is PRESENT and its value is None, which is a "
|
||||
"different frame from the one above. 'the option is off' and 'the sensor "
|
||||
"did not answer' are two facts and one field cannot carry both",
|
||||
"pv" in _absent and _absent["pv"] is None)
|
||||
check("ZERO WATTS: 0.0, published as a number", _zero["pv"] == 0.0)
|
||||
check("ZERO WATTS: 0.0, published as a number", field(_zero, "pv") == 0.0)
|
||||
check("⭐ AND ON THE WIRE THE LAST TWO ARE DIFFERENT BYTES ON THE SAME TOPIC: "
|
||||
"`None` - which is Home Assistant's own payload for 'this sensor has no "
|
||||
"value', so the entity goes to `unknown` and the recorder records a GAP "
|
||||
@@ -441,7 +443,7 @@ def announced(opts):
|
||||
check("with a source: `pv` is announced AND published",
|
||||
"pv" in announced(PV_ON) and "pv" in _absent)
|
||||
check("without one: announced by nothing and published by nothing",
|
||||
"pv" not in announced({}) and "pv" not in _nosrc)
|
||||
"pv" not in announced({}) and present(_nosrc) and "pv" not in _nosrc)
|
||||
check("...and the predicate is ONE expression, `mqtt.optional_keys()`, read by "
|
||||
"the announcement and by `Controller.publish()` alike - two spellings is "
|
||||
"how an entity gets announced that nothing feeds",
|
||||
@@ -473,7 +475,7 @@ _a1 = mqtt.discovery_payloads(include=mqtt.optional_keys(PV_ON))
|
||||
_a2 = mqtt.discovery_payloads(include=mqtt.optional_keys(PV_ON))
|
||||
_pv_cfg = [c for _t, c in _a1 if c["unique_id"] == "goodwe_ctl_pv"]
|
||||
print(f" composed entity_id: "
|
||||
f"{compose(mqtt.DEVICE['name'], _pv_cfg[0]['name'])}")
|
||||
f"{compose(mqtt.DEVICE['name'], nth(_pv_cfg, 0)['name'])}")
|
||||
check("a restart re-announces the SAME bytes, so Home Assistant sees the entity "
|
||||
"it already has rather than a new one beside a dead one - the history is "
|
||||
"continuous across a restart of the add-on",
|
||||
@@ -483,7 +485,8 @@ check("a restart re-announces the SAME bytes, so Home Assistant sees the entity
|
||||
check("...and the id Home Assistant composes for it is "
|
||||
"`sensor.goodwe_pv_power`, which is what DOCS.md tells an operator to put "
|
||||
"on a dashboard - device name plus entity name, NOT the object_id",
|
||||
compose(mqtt.DEVICE["name"], _pv_cfg[0]["name"])
|
||||
len(_pv_cfg) == 1
|
||||
and compose(mqtt.DEVICE["name"], _pv_cfg[0]["name"])
|
||||
== "sensor.goodwe_pv_power"
|
||||
and _pv_cfg[0]["state_topic"] == "goodwe_ctl/pv")
|
||||
|
||||
@@ -536,7 +539,8 @@ check("⭐ ...and it is a CAUTION, never a `bad` row: `status_payload()` builds
|
||||
"NOT READY out of rows that are neither ok nor warn, and a telemetry "
|
||||
"channel that could put a battery site into NOT READY would be "
|
||||
"observability with a foot in the control path",
|
||||
_bad_row[0] == {"ok": False, "warn": True, "text": _bad_row[0]["text"]}
|
||||
len(_bad_row) == 1
|
||||
and _bad_row[0] == {"ok": False, "warn": True, "text": _bad_row[0]["text"]}
|
||||
and not c_bad.status_payload()["banner"].startswith("NOT READY")
|
||||
and c_bad.status_payload()["level"] != "bad"
|
||||
and c_bad.status_payload()["banner"]
|
||||
@@ -663,7 +667,9 @@ NEEDLES = ("pv_entity", "pv_w", "pv_why", "optional_keys", "ABSENT_AS_UNKNOWN",
|
||||
# TEL-12's own names. `pv_entities` and `pv_entity` share a prefix so
|
||||
# the first would have caught it anyway; spelled out because a scope
|
||||
# that only works by accident stops working when a name is renamed.
|
||||
"pv_entities", "pv_parts", "pv_roster", "entity_is_ignored")
|
||||
"pv_entities", "pv_parts", "pv_roster", "entity_is_ignored",
|
||||
# TEL-14's.
|
||||
"pv_invert_entities", "stray_inverts", "NEGATIVE_W")
|
||||
|
||||
|
||||
def touches(src: str) -> bool:
|
||||
@@ -867,8 +873,8 @@ print("9. ⭐ publication at a roster of three, as PAYLOAD BYTES")
|
||||
# Part 3 did this for one entity. The new state is "several configured, one of
|
||||
# them gone", and the number that must NOT appear on the topic is a specific
|
||||
# plausible number rather than a wrong shape.
|
||||
_m_full = run(cycles=1, by_entity=FULL, **PV_MANY)[2].frames[-1]
|
||||
print(f" 3 healthy : {_m_full['pv']!r} -> wire {pv_msg(_m_full)}")
|
||||
_m_full = nth(run(cycles=1, by_entity=FULL, **PV_MANY)[2].frames, -1)
|
||||
print(f" 3 healthy : {field(_m_full, 'pv')!r} -> wire {pv_msg(_m_full)}")
|
||||
check("⭐ THREE HEALTHY: one topic, one number, the SITE total - 3750.0. There "
|
||||
"is deliberately no per-entity topic: the recorder statistic OPT-10 reads "
|
||||
"back is the site figure, and a per-inverter breakdown is a dashboard "
|
||||
@@ -878,7 +884,7 @@ check("⭐ THREE HEALTHY: one topic, one number, the SITE total - 3750.0. There
|
||||
== [pv_msg(_m_full)])
|
||||
_holed = []
|
||||
for _miss, (_script, _partial) in HOLES.items():
|
||||
_fr = run(cycles=1, by_entity=_script, **PV_MANY)[2].frames[-1]
|
||||
_fr = nth(run(cycles=1, by_entity=_script, **PV_MANY)[2].frames, -1)
|
||||
_msg = pv_msg(_fr)
|
||||
print(f" {_miss} dead -> wire {_msg} (partial would have been "
|
||||
f"{_partial:.0f} W -> {str(_partial)!r})")
|
||||
@@ -950,7 +956,8 @@ check("⭐ ...and it is a CAUTION, never a `bad` row, at a roster exactly as at
|
||||
"neither ok nor warn, and a telemetry channel that could put a battery "
|
||||
"site into NOT READY would be observability with a foot in the control "
|
||||
"path",
|
||||
_gap_row[0] == {"ok": False, "warn": True, "text": _gap_row[0]["text"]}
|
||||
len(_gap_row) == 1
|
||||
and _gap_row[0] == {"ok": False, "warn": True, "text": _gap_row[0]["text"]}
|
||||
and _c_gap.status_payload()["level"] != "bad"
|
||||
and _c_gap.status_payload()["banner"]
|
||||
== c_off.status_payload()["banner"])
|
||||
@@ -1016,6 +1023,209 @@ check("...and an install with NO PV roster still adds no row of any kind - the "
|
||||
and run(cycles=1, pv_entities=[" "])[1].state_calls == [])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("12. TEL-14: the sign, declared PER ENTITY")
|
||||
# ==============================================================================
|
||||
# Some installs' PV entities read POSITIVE for consumption and NEGATIVE for
|
||||
# production (a kWh meter or an ESPHome clamp in the PV circuit). The fixture is
|
||||
# a MIXED roster, because that is the case a global flag gets wrong:
|
||||
# E1 an inverter, positive = producing 1200 W -> 1200
|
||||
# E2 a meter, NEGATIVE = producing, flipped -2.1 kW -> 2100
|
||||
# E3 a meter at night, correctly signed, NOT flipped: -30 W standby -> -30
|
||||
# and the values keep every wrong reduction apart:
|
||||
# right 3270; no flip -930; flip all 930; abs() of every member 3330;
|
||||
# flip E3 instead of E2 -870.
|
||||
# ⚠️ EVERY CHECK BELOW READS THROUGH `_at`/`_row` AND NEVER INDEXES BLIND, so a
|
||||
# missing value is a NAMED FAIL rather than a traceback that hides the rest.
|
||||
|
||||
|
||||
def _at(seq, i):
|
||||
"""`seq[i]`, or None when there is no such element."""
|
||||
try:
|
||||
return seq[i]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _row(c, needle):
|
||||
"""Every status-row dict whose text contains `needle` (a list, maybe [])."""
|
||||
try:
|
||||
return [r for r in c.checks() if needle in r.get("text", "")]
|
||||
except Exception as e: # noqa: BLE001 - named, below
|
||||
return [{"text": f"checks() raised {e!r}", "ok": None, "warn": None}]
|
||||
|
||||
|
||||
MIX = {E1: [state("1200", "W")], E2: [state("-2.1", "kW")],
|
||||
E3: [state("-30", "W")]}
|
||||
MIX_TOTAL = 3270.0
|
||||
_MIX_WRONG = {"no flip": -930.0, "flip all": 930.0, "abs": 3330.0,
|
||||
"flip E3 not E2": -870.0}
|
||||
PV_FLIP = dict(PV_MANY, pv_invert_entities=[E2])
|
||||
check(f"PRECONDITION: the mixed fixture's right answer {MIX_TOTAL:g} W is apart "
|
||||
f"from every wrong reduction {_MIX_WRONG}",
|
||||
MIX_TOTAL not in _MIX_WRONG.values()
|
||||
and len(set(_MIX_WRONG.values())) == len(_MIX_WRONG))
|
||||
|
||||
# --- the option ---------------------------------------------------------------
|
||||
check("inverted() is the stripped, non-empty entries of `pv_invert_entities`, "
|
||||
"and a 0.12.0 blob (no key at all) flips nothing",
|
||||
pv.inverted({"pv_invert_entities": [" sensor.a ", "", None, "sensor.b"]})
|
||||
== frozenset({"sensor.a", "sensor.b"})
|
||||
and pv.inverted({}) == frozenset()
|
||||
and pv.inverted({"pv_invert_entities": None}) == frozenset())
|
||||
|
||||
# --- the arithmetic -----------------------------------------------------------
|
||||
_t_mix = pv.total([(e, _at(MIX[e], 0)) for e in (E1, E2, E3)],
|
||||
pv.inverted(PV_FLIP))
|
||||
_t_raw = pv.total([(e, _at(MIX[e], 0)) for e in (E1, E2, E3)])
|
||||
print(f" mixed, E2 flipped: {_t_mix[0]} no flip: {_t_raw[0]}")
|
||||
check(f"⭐ A MIXED ROSTER: only the listed entity is flipped, before the sum - "
|
||||
f"{MIX_TOTAL:g} W, not any of {_MIX_WRONG}",
|
||||
_t_mix[0] == MIX_TOTAL
|
||||
and [w for _e, w, _y in _t_mix[2]] == [1200.0, 2100.0, -30.0])
|
||||
check("⭐ DEFAULT IS 0.12.0: with no flip `total()` answers exactly what it "
|
||||
"always did - the raw sum, the same `why`, the same parts",
|
||||
_t_raw == (-930.0, "3 entities",
|
||||
[(E1, 1200.0, "W"), (E2, -2100.0, "kW"), (E3, -30.0, "W")]))
|
||||
_t_zero = pv.total([(E2, state("0", "W"))], frozenset({E2}))
|
||||
check("a flipped 0 W is +0.0, never -0.0 - so the wire says `0.0` and the page "
|
||||
"says `0 W`, not `-0`",
|
||||
_t_zero[0] == 0.0 and str(_t_zero[0]) == "0.0"
|
||||
and f"{_t_zero[0]:.0f}" == "0")
|
||||
_t_gone = pv.total([(E1, state("1200")), (E2, None)], frozenset({E2}))
|
||||
check("a flipped member that is ABSENT is still an absence of the total - the "
|
||||
"flip never turns None into a number",
|
||||
_t_gone[0] is None and _at(_at(_t_gone[2], 1), 1) is None)
|
||||
check("a single flipped entity keeps 0.11.0's pass-through `why` (the unit)",
|
||||
pv.total([(E2, state("-13", "W"))], frozenset({E2}))
|
||||
== (13.0, "W", [(E2, 13.0, "W")]))
|
||||
|
||||
# --- the read path, the wire and the page -------------------------------------
|
||||
_c_mix, _, _p_mix = run(cycles=1, by_entity=MIX, **PV_FLIP)
|
||||
_f_mix = _at(_p_mix.frames, -1) or {}
|
||||
print(f" controller pv_w {_c_mix.pv_w} wire {pv_msg(_f_mix)}")
|
||||
check("⭐ END TO END: the controller reads the option, and the MQTT `pv` row "
|
||||
"carries the corrected site total",
|
||||
_c_mix.pv_w == MIX_TOTAL
|
||||
and pv_msg(_f_mix) == ("goodwe_ctl/pv", "3270.0", False))
|
||||
_mix_row = [r["text"] for r in _row(_c_mix, "W from 3 entities")]
|
||||
print(f" {_mix_row}")
|
||||
check("the status row prints each entity's watts AFTER the sign and marks "
|
||||
"ONLY the flipped one",
|
||||
_mix_row == [f"PV production: 3270 W from 3 entities ({E1} 1200 W in W, "
|
||||
f"{E2} 2100 W in kW [sign inverted], {E3} -30 W in W)"])
|
||||
check("...and the page's PV row is the corrected total",
|
||||
("PV", "3 270 W") in page_rows(_c_mix))
|
||||
_c_hole_f, _, _ = run(cycles=1, by_entity=dict(MIX, **{E1: [None]}), **PV_FLIP)
|
||||
check("a holed roster's 'Reporting:' list marks the flipped entity too",
|
||||
[r["text"] for r in _row(_c_hole_f, "Reporting:")]
|
||||
and f"{E2} 2100 W [sign inverted]" in
|
||||
(_at(_row(_c_hole_f, "Reporting:"), 0) or {}).get("text", ""))
|
||||
|
||||
# The owner's report, at a roster of one: a meter reading +13 W at night.
|
||||
_OWN = "sensor.pv_meter_power"
|
||||
_c_own, _, _ = run(cycles=1, by_entity={_OWN: [state("13", "W")]},
|
||||
pv_entity=_OWN, pv_invert_entities=[_OWN])
|
||||
_c_own0, _, _ = run(cycles=1, by_entity={_OWN: [state("13", "W")]},
|
||||
pv_entity=_OWN)
|
||||
check("⭐ THE REPORTED CASE: 13 W of night-time standby on a reversed meter is "
|
||||
"-13 W once flipped, the row says so and marks it - and it raises NO "
|
||||
"negative caution, because -13 W is standby and not a reversed sign - "
|
||||
"and NO stray caution, because a lone `pv_entity` is in the roster",
|
||||
_c_own.pv_w == -13.0
|
||||
and [r["text"] for r in _row(_c_own, "PV production: -13 W")]
|
||||
== [f"PV production: -13 W from {_OWN} (reported in W) [sign inverted]"]
|
||||
and _row(_c_own, "probably reversed") == []
|
||||
and _row(_c_own, "flips nothing") == [])
|
||||
check("...and WITHOUT the option it is 0.12.0's row, byte for byte",
|
||||
[r["text"] for r in _row(_c_own0, "PV production:")]
|
||||
== [f"PV production: 13 W from {_OWN} (reported in W)"])
|
||||
|
||||
# Whitespace: the roster entry and the invert entry differ only by padding. Both
|
||||
# sides must normalise the same way, or the read path leaves the sign unflipped
|
||||
# while the page marks it, or the page calls a flipped entity a stray.
|
||||
_c_pad, _, _ = run(cycles=1, by_entity=MIX,
|
||||
pv_entities=[E1, f" {E2}", E3],
|
||||
pv_invert_entities=[f"{E2} "])
|
||||
check("⭐ PADDING: ` sensor.x` in the roster and `sensor.x ` in "
|
||||
"`pv_invert_entities` are the same entity - flipped on the read path, "
|
||||
"marked on the page, and no stray caution",
|
||||
_c_pad.pv_w == MIX_TOTAL
|
||||
and f"{E2} 2100 W in kW [sign inverted]"
|
||||
in (_at(_row(_c_pad, "W from 3 entities"), 0) or {}).get("text", "")
|
||||
and _row(_c_pad, "flips nothing") == [])
|
||||
|
||||
# --- the negative caution -------------------------------------------------------
|
||||
def _neg(watts_by_entity, flip=()):
|
||||
opts = dict(PV_MANY, pv_invert_entities=list(flip))
|
||||
c, _, _ = run(cycles=1, by_entity={e: [state(str(w), "W")]
|
||||
for e, w in watts_by_entity.items()},
|
||||
**opts)
|
||||
return _row(c, "probably reversed")
|
||||
|
||||
|
||||
_n_add = _neg({E1: 1200, E2: -1500, E3: 450})
|
||||
_n_rm = _neg({E1: 1200, E2: 1500, E3: 450}, flip=[E2])
|
||||
print(f" {[r['text'] for r in _n_add + _n_rm]}")
|
||||
check("⭐ AN UNFLIPPED ENTITY FAR BELOW ZERO gets a caution that names it, its "
|
||||
"watts, and says to ADD it to `pv_invert_entities`",
|
||||
[r["text"] for r in _n_add]
|
||||
== [f"PV production: {E2} reads -1500 W - far below zero, so its sign "
|
||||
"is probably reversed. If it is producing now, ADD it to "
|
||||
"`pv_invert_entities` in the Configuration tab"])
|
||||
check("...and a FLIPPED one far below zero is told to REMOVE it instead",
|
||||
len(_n_rm) == 1 and "REMOVE it from `pv_invert_entities`"
|
||||
in (_at(_n_rm, 0) or {}).get("text", ""))
|
||||
check("⭐ ...and it is a CAUTION, never a `bad` row - a telemetry sign cannot "
|
||||
"put a battery site into NOT READY",
|
||||
bool(_n_add) and all(r.get("ok") is False and r.get("warn") is True
|
||||
for r in _n_add + _n_rm))
|
||||
check(f"the threshold is {pv.NEGATIVE_W:g} W per ENTITY: -50 W is standby and "
|
||||
"stays quiet, -51 W cautions",
|
||||
pv.NEGATIVE_W == -50.0
|
||||
and _neg({E1: -50, E2: 0, E3: 0}) == []
|
||||
and len(_neg({E1: -51, E2: 0, E3: 0})) == 1)
|
||||
check("⭐ ...PER ENTITY, NOT ON THE TOTAL: three correctly signed meters at "
|
||||
"-20 W standby each (total -60 W) raise nothing, and a reversed -1500 W "
|
||||
"hidden in a positive total (+1650 W) is still caught",
|
||||
_neg({E1: -20, E2: -20, E3: -20}) == []
|
||||
and len(_neg({E1: 2700, E2: -1500, E3: 450})) == 1)
|
||||
|
||||
# --- the stray entry ------------------------------------------------------------
|
||||
_c_typo, _, _ = run(cycles=1, by_entity=FULL,
|
||||
**dict(PV_MANY, pv_invert_entities=["sensor.pv_wset"]))
|
||||
check("a `pv_invert_entities` entry naming no PV entity in use is a named "
|
||||
"caution (a typo would otherwise leave the sign silently unflipped)",
|
||||
[(r["text"], r.get("ok"), r.get("warn"))
|
||||
for r in _row(_c_typo, "flips nothing")]
|
||||
== [("PV production: `pv_invert_entities` names sensor.pv_wset, which "
|
||||
"is not a PV entity in use, so it flips nothing - check the "
|
||||
"spelling against `pv_entities`", False, True)]
|
||||
and _row(_c_mix, "flips nothing") == [])
|
||||
|
||||
# --- default: byte-identical to 0.12.0 ------------------------------------------
|
||||
_c_d0, _, _p_d0 = run(cycles=1, by_entity=MIX, **PV_MANY)
|
||||
_c_d1, _, _p_d1 = run(cycles=1, by_entity=MIX,
|
||||
**dict(PV_MANY, pv_invert_entities=[]))
|
||||
check("⭐ DEFAULT: `pv_invert_entities: []` and a 0.12.0 blob with no such key "
|
||||
"give the same rows, the same frame and the raw sum",
|
||||
[r["text"] for r in _c_d0.checks()] == [r["text"] for r in _c_d1.checks()]
|
||||
and _at(_p_d0.frames, -1) == _at(_p_d1.frames, -1)
|
||||
and _c_d0.pv_w == -930.0)
|
||||
|
||||
# --- still not a control input --------------------------------------------------
|
||||
_BIG_FLIP = {E1: [state("1500", "W")], E2: [state("-4.5", "kW")],
|
||||
E3: [state("2000", "W")]}
|
||||
_c_bf, _h_bf, _ = run(**_stim, by_entity=_BIG_FLIP,
|
||||
**dict(PV_MANY, pv_invert_entities=[E2]))
|
||||
check("⭐ NOT A CONTROL INPUT, WITH A FLIP: 40 cycles against the settled 600 W "
|
||||
"import write exactly what `unset` writes, and the flip really did move "
|
||||
"the reading (8000 W, part 6's shift precondition)",
|
||||
_h_bf.writes == _w_none and _c_bf.target == _c_none.target
|
||||
and _c_bf.pv_w == BIG_TOTAL)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("11. what this file CANNOT see, with sizes - and the mutation table")
|
||||
@@ -1028,10 +1238,9 @@ for line in [
|
||||
" (mqtt/const.py PAYLOAD_NONE, mqtt/sensor.py's `_message_received`,",
|
||||
" retrieved 2026-09-24) and quoted in app/mqtt.py. No HA runs in this",
|
||||
" suite, so what is graded here is the BYTES this add-on publishes.",
|
||||
"Whether the operator's PV sensor is telling the truth. A sensor wired",
|
||||
" backwards reads -4 kW at noon; this file asserts that the sign is",
|
||||
" published unchanged, never that it is right. That is what the history",
|
||||
" the card creates is FOR - see DECISIONS/TEL-11-pv-production.md §2.",
|
||||
"Whether the operator's PV sensor is telling the truth. TEL-14 lets the",
|
||||
" installer declare a reversed one; this file grades the flip and the",
|
||||
" -50 W caution, never that the declaration is right.",
|
||||
"The control-path claim OUTSIDE app/. `tools/`, the ESPHome firmware and",
|
||||
" the ingress page are not scanned by part 6's census.",
|
||||
"A roster of more than one INVERTER (CFG-04's battery kind). Every",
|
||||
@@ -1138,6 +1347,10 @@ print(" time - see DECISIONS/TEL-11-pv-production.md §5 for the transcript."
|
||||
print(" ⚠️ A RED COUNT IS A PROPERTY OF THE SUITE AS MUCH AS OF THE MUTATION:")
|
||||
print(" these name THIS tree. Re-measure rather than inherit.")
|
||||
|
||||
print()
|
||||
print(" TEL-14: 14 mutations, all red, no survivors - bytes and named reds in")
|
||||
print(" DECISIONS/TEL-14-pv-sign.md §4.")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
|
||||
@@ -75,9 +75,10 @@ print("1. the harness's own preconditions")
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import rehearse_roster as RR # noqa: E402
|
||||
|
||||
check("the tool refuses rather than passing when it cannot run: its three exit "
|
||||
"codes are distinct and 2 is not 0",
|
||||
(RR.EXIT_OK, RR.EXIT_FINDING, RR.EXIT_COULD_NOT_RUN) == (0, 1, 2))
|
||||
check("the tool refuses rather than passing when it cannot run: its four exit "
|
||||
"codes are distinct and only 0 is a pass",
|
||||
(RR.EXIT_OK, RR.EXIT_FINDING, RR.EXIT_COULD_NOT_RUN,
|
||||
RR.EXIT_DEGRADED) == (0, 1, 2, 3))
|
||||
|
||||
# ⚠️ THE ANCHOR. `app/mqtt.py` records `sensor.goodwe_p1_sample_age` as VERIFIED
|
||||
# against the live system on 2026-08-26, and the ESP32's ingest guard subscribes
|
||||
@@ -204,6 +205,10 @@ def mutant_tree(edits) -> str:
|
||||
shutil.copytree(FIX, dest / "tools" / "rehearsal", ignore=ignore)
|
||||
shutil.copyfile(HERE / "tools" / "rehearse_roster.py",
|
||||
dest / "tools" / "rehearse_roster.py")
|
||||
# The input-shape gate (SAFETY-62) and the option names it checks against.
|
||||
shutil.copyfile(HERE / "tools" / "predeploy_check.py",
|
||||
dest / "tools" / "predeploy_check.py")
|
||||
shutil.copyfile(HERE / "config.yaml", dest / "config.yaml")
|
||||
for rel, old, new in edits:
|
||||
path = dest / rel
|
||||
text = path.read_text(encoding="utf-8")
|
||||
@@ -356,8 +361,9 @@ check("omitting --live-entities REFUSES with exit 2 rather than reporting a "
|
||||
_no_code == RR.EXIT_COULD_NOT_RUN and "REFUSED" in _no_out + _no_err)
|
||||
|
||||
_deg_code, _deg_out, _ = raw("--no-live-entities")
|
||||
check("...and the explicit opt-out RUNS, so the escape hatch exists",
|
||||
_deg_code in (RR.EXIT_OK, RR.EXIT_FINDING))
|
||||
check("...and the explicit opt-out RUNS, so the escape hatch exists - but a "
|
||||
"clean degraded run is exit 3, never 0 (SAFETY-62)",
|
||||
_deg_code == RR.EXIT_DEGRADED and "NO FINDINGS" in _deg_out)
|
||||
check("⚠️ ...carrying a standing banner in the report HEADER and again in "
|
||||
"the closing block, because a report is skimmed from the bottom",
|
||||
_deg_out.count("DEGRADED RUN") >= 2
|
||||
|
||||
@@ -63,6 +63,8 @@ import ast
|
||||
import gzip
|
||||
import json
|
||||
import sys
|
||||
|
||||
from named_absence import first
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1213,8 +1215,9 @@ check("...and the pin is not vacuous: each of those names really is a "
|
||||
|
||||
def _t17_sig(module, name):
|
||||
tree = ast.parse((HERE / "app" / module).read_text(encoding="utf-8"))
|
||||
fn = next(n for n in tree.body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
fn = first((n for n in tree.body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name),
|
||||
f"def {name} in {module}")
|
||||
return [a.arg for a in fn.args.args]
|
||||
|
||||
|
||||
|
||||
@@ -90,9 +90,9 @@ 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.
|
||||
export), as `scenarios/p1-age-2026-08-26.jsonl.gz` already is. When they were
|
||||
committed, CI could not see the infra repo; since CI-12 it checks admin/goodwe
|
||||
out at `.gitea/infra-pin`, but these fixtures stay here with the tests.
|
||||
|
||||
The recorder's window rolls: 2026-08-01 leaves it around 2026-09-15, which is
|
||||
why the committed copy is the artefact and not the API call.
|
||||
@@ -114,6 +114,8 @@ import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from named_absence import given, nth, present
|
||||
|
||||
from app.main import Controller
|
||||
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
|
||||
from app.tariff import (
|
||||
@@ -260,7 +262,7 @@ 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
|
||||
d_block = nth(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):
|
||||
@@ -311,7 +313,7 @@ def feed(avg, t0_s, t1_s, w=RUN_W):
|
||||
# (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
|
||||
CTRL_W = nth(ctrl_closed, 0).offtake_avg_w
|
||||
|
||||
# (b) restarted at minute 10, checkpoint restored through a real SQLite file.
|
||||
st = fresh_store("restart")
|
||||
@@ -332,8 +334,8 @@ 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")
|
||||
print(f" uninterrupted {CTRL_W:.2f} W | restored {given(RESTART_W):.2f} W | "
|
||||
f"restart with no restore {given(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 "
|
||||
@@ -357,18 +359,18 @@ 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")
|
||||
f"{nth(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,
|
||||
near(CTRL_W - nth(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])
|
||||
if ctrl_closed:
|
||||
for _ in range(3):
|
||||
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",
|
||||
@@ -376,7 +378,9 @@ check("banking the same quarter three times leaves ONE row - quarter_start is "
|
||||
|
||||
# ...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))
|
||||
if present(CTRL_W):
|
||||
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 "
|
||||
@@ -665,10 +669,10 @@ 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]
|
||||
blk = nth(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()))
|
||||
+ ", ".join(f"{k}={given(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 "
|
||||
@@ -687,7 +691,7 @@ 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)}
|
||||
b_vals = {p: billed_w(nth(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))
|
||||
@@ -746,7 +750,7 @@ check(f"...and record_block() with NO policy argument banks the NET figure "
|
||||
# fixture change ever made two of these coincide, the triple above would start
|
||||
# passing under a hardcoded policy and would be silently back where it started.
|
||||
print(f" banked through the store: "
|
||||
+ ", ".join(f"{k}={v:.0f} W" for k, v in _banked.items()))
|
||||
+ ", ".join(f"{k}={given(v):.0f} W" for k, v in _banked.items()))
|
||||
check("the three BANKED figures are three distinct numbers, so this triple is "
|
||||
"not itself degenerate - it can only pass if the argument is read",
|
||||
len({round(v, 6) for v in _banked.values() if v is not None}) == 3)
|
||||
@@ -855,16 +859,16 @@ for path in REPLAY:
|
||||
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")
|
||||
f"{given(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))
|
||||
bool(months) and 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"{given(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 and this run is {replay_days:.1f}, so it "
|
||||
f"does not settle it either way. The 30 days DO now exist - "
|
||||
@@ -928,7 +932,7 @@ def built(db_path, opts=None):
|
||||
|
||||
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"{given(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.
|
||||
@@ -981,7 +985,7 @@ check("the status page carries exactly one capacity-tariff peak row",
|
||||
check("...naming the peak, the soft limit and a coverage figure, 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 f"{given(SHIPPED_CAP_W):.0f} W soft limit" in peak_rows[0]["text"]
|
||||
and "quarter(s) banked" in peak_rows[0]["text"])
|
||||
|
||||
# ⚠️ AND THE COVERAGE FIGURE IN THAT ROW IS SCOPED TO THE LIVE BILLING MONTH,
|
||||
@@ -1006,7 +1010,7 @@ check("the row's coverage figure is the LIVE month's tally - graded against a "
|
||||
bool(peak_rows)
|
||||
and f"; {_live_rows} 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",
|
||||
f"on a {given(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.
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""SAFETY-60/62: the operator tools refuse input they cannot read. `python3 test_tool_inputs.py`
|
||||
|
||||
Each tool is driven as the operator runs it, first on good input (the accepted
|
||||
baseline, MUTATION.md habit 7) and then on the shapes that used to pass.
|
||||
"""
|
||||
import contextlib
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
TOOLS = HERE / "tools"
|
||||
FIX = TOOLS / "rehearsal"
|
||||
CAPTURE = HERE / "scenarios" / "safety57-2026-09-21" / \
|
||||
"bus-a-2026-09-21-1030-1110.log.gz"
|
||||
TMP = tempfile.mkdtemp(prefix="test_tool_inputs.")
|
||||
ENV = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, ok):
|
||||
print(f" {'ok ' if ok else 'FAIL'} {name}")
|
||||
if not ok:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def dump(name, obj=None, text=None):
|
||||
p = Path(TMP) / name
|
||||
p.write_text(text if text is not None else json.dumps(obj, indent=1),
|
||||
encoding="utf-8")
|
||||
return str(p)
|
||||
|
||||
|
||||
def tool(script, *args):
|
||||
r = subprocess.run([sys.executable, str(TOOLS / script), *args],
|
||||
capture_output=True, text=True, env=ENV)
|
||||
return r.returncode, r.stdout + r.stderr
|
||||
|
||||
|
||||
def ha_apps_info(options):
|
||||
"""What `ha apps info <slug> --raw-json` prints: the options sit under data.options."""
|
||||
text = {"en": ("Setpoint entity", "The ESPHome number carrying the setpoint."),
|
||||
"nl": ("Setpoint-entiteit", "Het ESPHome-getal met het setpoint.")}
|
||||
return {"result": "ok", "data": {
|
||||
"name": "GoodWe RS485 Controller", "slug": "local_goodwe_controller",
|
||||
"version": "0.12.0", "version_latest": "0.12.0", "state": "started",
|
||||
"boot": "auto", "ingress": True, "hassio_api": True,
|
||||
"options": copy.deepcopy(options),
|
||||
"schema": [{"name": k, "required": True, "optional": False,
|
||||
"type": "string"} for k in options],
|
||||
"translations": {lang: {"configuration": {
|
||||
"setpoint_entity": {"name": name, "description": desc},
|
||||
"inverters": {"name": "Inverters", "description": "Roster.",
|
||||
"fields": {"setpoint_entity": {
|
||||
"name": name, "description": f"{desc} (per unit)"}}}}}
|
||||
for lang, (name, desc) in text.items()}}}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. predeploy_check.py --options (SAFETY-60)")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# A deployed version that differs from the shipped one, so only the options half decides.
|
||||
BARE = {"setpoint_entity": "number.goodwe_setpoint_w", "inverters": [],
|
||||
"max_w": 5000, "slew_w": 0, "gain": 0.6, "target_grid_w": -10,
|
||||
"deadband_w": 15, "meter_source": "off", "log_level": "info"}
|
||||
NOREPO = tempfile.mkdtemp(prefix="norepo.", dir=TMP)
|
||||
|
||||
|
||||
def gate(opts_path):
|
||||
return tool("predeploy_check.py", "--deployed-version", "0.0.0-test",
|
||||
"--repo", NOREPO, "--options", opts_path)
|
||||
|
||||
|
||||
code, out = gate(dump("bare.json", BARE))
|
||||
print(f" bare options -> exit {code}")
|
||||
check("baseline: the bare options object is CLEARED (exit 0)",
|
||||
code == 0 and "CLEARED for deploy" in out)
|
||||
code, out = gate(dump("bare-bad.json", dict(BARE, slew_w=5000)))
|
||||
check("baseline: the bare object with slew_w 5000 is REFUSED by the validator",
|
||||
code == 1 and "slew_w is 5000 W" in out)
|
||||
|
||||
for label, doc in (("wrapped", ha_apps_info(BARE)),
|
||||
("wrapped, slew_w 5000", ha_apps_info(dict(BARE, slew_w=5000)))):
|
||||
code, out = gate(dump("wrapped.json", doc))
|
||||
print(f" {label} -> exit {code}")
|
||||
check(f"the `ha apps info` capture ({label}) is REFUSED, exit 1",
|
||||
code == 1 and "CLEARED" not in out)
|
||||
check("...naming the shape and the fix, not a validator rule",
|
||||
"0 of 2 top-level key(s) recognised" in out and ".data.options" in out
|
||||
and "VALIDATOR" not in out)
|
||||
|
||||
from app.validate import actuator_writers, validate_startup_config # noqa: E402
|
||||
_w = ha_apps_info(dict(BARE, slew_w=5000))
|
||||
print(f" (for reference: the validator alone, handed that capture, finds "
|
||||
f"{len(validate_startup_config(_w))} problem(s) and "
|
||||
f"{len(actuator_writers(_w))} 'actuators' - the gate has to refuse it)")
|
||||
|
||||
code, out = gate(dump("list.json", [BARE]))
|
||||
check("a list-wrapped options object is REFUSED, naming the type",
|
||||
code == 1 and "is a JSON list, not an object" in out)
|
||||
code, out = gate(dump("mostly-foreign.json",
|
||||
{"setpoint_entity": "number.x", "a": 1, "b": 2}))
|
||||
check("an object with fewer than half its keys recognised is REFUSED",
|
||||
code == 1 and "1 of 3 top-level key(s) recognised" in out)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. rehearse_roster.py --state/--before/--after (SAFETY-62)")
|
||||
# --------------------------------------------------------------------------- #
|
||||
sys.path.insert(0, str(TOOLS))
|
||||
import rehearse_roster as RR # noqa: E402
|
||||
|
||||
STATE = FIX / "state-legacy.json"
|
||||
LIVE = ["--live-entities", str(FIX / "live-entities-recorded.txt"),
|
||||
"--reference", str(FIX / "announced-reference.txt")]
|
||||
|
||||
|
||||
def rehearse(state=str(STATE), before=str(FIX / "flat.yaml"),
|
||||
after=str(FIX / "roster.yaml"), extra=LIVE):
|
||||
return tool("rehearse_roster.py", "--state", state, "--before", before,
|
||||
"--after", after, *extra)
|
||||
|
||||
|
||||
code, out = rehearse()
|
||||
print(f" control -> exit {code}")
|
||||
check("baseline: the control run reports its 2 findings, exit 1",
|
||||
code == RR.EXIT_FINDING and "2 FINDING(S)" in out)
|
||||
code, out = rehearse(state=str(FIX / "state-fresh.json"))
|
||||
check("baseline: a fresh (comment-only) state file still runs clean, exit 0",
|
||||
code == RR.EXIT_OK and "NO FINDINGS" in out)
|
||||
|
||||
raw = STATE.read_text(encoding="utf-8")
|
||||
state = json.loads(raw)
|
||||
cases = {
|
||||
"wrapped": (dump("s-wrapped.json", {"result": "ok", "data": state}),
|
||||
"0 of 2 top-level key(s) recognised"),
|
||||
"truncated by 20 bytes": (dump("s-trunc.json", text=raw[:-20]),
|
||||
"not a readable JSON state file"),
|
||||
"list-wrapped": (dump("s-list.json", [state]),
|
||||
"is a JSON list, not an object"),
|
||||
}
|
||||
for label, (path, words) in cases.items():
|
||||
code, out = rehearse(state=path)
|
||||
print(f" state {label} -> exit {code}")
|
||||
check(f"state {label}: REFUSED with exit 2 and the reason",
|
||||
code == RR.EXIT_COULD_NOT_RUN and words in out
|
||||
and "NO FINDINGS" not in out)
|
||||
|
||||
try:
|
||||
_auto = RR.load_state(dump("s-auto.json", {"auto": True}))
|
||||
except RR.Refused as err:
|
||||
_auto = str(err)
|
||||
check("a state holding only the Automatic toggle (`auto`, app/main.py) is "
|
||||
"recognised as a store", _auto == {"auto": True})
|
||||
|
||||
flat = RR.load_options(str(FIX / "flat.yaml"))
|
||||
roster = RR.load_options(str(FIX / "roster.yaml"))
|
||||
for side, kw in (("--before", {"before": dump("b.json", ha_apps_info(flat))}),
|
||||
("--after", {"after": dump("a.json", ha_apps_info(roster))})):
|
||||
code, out = rehearse(**kw)
|
||||
print(f" {side} wrapped -> exit {code}")
|
||||
check(f"{side} as an `ha apps info` capture: REFUSED with exit 2",
|
||||
code == RR.EXIT_COULD_NOT_RUN and ".data.options" in out
|
||||
and "NO FINDINGS" not in out)
|
||||
|
||||
code, out = rehearse(after=str(FIX / "roster-inverter-a.yaml"),
|
||||
extra=["--no-live-entities", "--reference",
|
||||
str(FIX / "announced-reference.txt")])
|
||||
check("a clean --no-live-entities run is exit 3, not 0",
|
||||
code == RR.EXIT_DEGRADED and "NO FINDINGS" in out)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. alloc_sim.py - the split-invariance verdict reaches the exit code")
|
||||
# --------------------------------------------------------------------------- #
|
||||
spec = importlib.util.spec_from_file_location("_alloc_sim", TOOLS / "alloc_sim.py")
|
||||
AS = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(AS)
|
||||
|
||||
|
||||
class _Run:
|
||||
"""One rig run, reduced to what `splits()` reads."""
|
||||
|
||||
def __init__(self, grid):
|
||||
self.samples = [{"shares": [1000.0, 1000.0]}]
|
||||
self._grid = grid
|
||||
|
||||
def grids(self):
|
||||
return self._grid
|
||||
|
||||
def targets(self):
|
||||
return [2000.0] * len(self._grid)
|
||||
|
||||
|
||||
class _Sitesim:
|
||||
def load_addon(self, *_):
|
||||
return None
|
||||
|
||||
|
||||
def alloc_main(same):
|
||||
"""`main(--splits)` with the rig stubbed; the real `splits()` grades the traces."""
|
||||
seen = []
|
||||
|
||||
def fake(_sitesim, _control, mode, *a, **kw):
|
||||
seen.append(mode)
|
||||
return _Run([0.0, 1.0] if same or len(seen) == 1 else [0.0, 2.0])
|
||||
|
||||
AS.find_sim = lambda: Path("/nonexistent-rig")
|
||||
AS.load = lambda _sim: (None, _Sitesim(), None, None)
|
||||
AS.run_site_feedback = fake
|
||||
os.environ["GOODWE_ADDON_REPO"] = str(HERE.parent)
|
||||
sys.argv = ["alloc_sim.py", "--splits"]
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = AS.main()
|
||||
return rc, buf.getvalue()
|
||||
|
||||
|
||||
rc, out = alloc_main(same=True)
|
||||
check("baseline: identical traces HOLD, exit 0",
|
||||
rc == 0 and "split-invariance HOLDS" in out)
|
||||
rc, out = alloc_main(same=False)
|
||||
print(f" differing traces -> exit {rc}")
|
||||
check("differing traces FAIL, exit 1",
|
||||
rc == 1 and "split-invariance FAILS" in out)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. decode_bus_capture.py - a file that is not a capture")
|
||||
# --------------------------------------------------------------------------- #
|
||||
code, out = tool("decode_bus_capture.py", str(CAPTURE))
|
||||
check("baseline: the committed capture decodes, exit 0",
|
||||
code == 0 and "writes 1200" in out and "REFUSED" not in out)
|
||||
junk = dump("junk.log", text="hello\nthis is not a capture\n")
|
||||
code, out = tool("decode_bus_capture.py", junk)
|
||||
print(f" junk -> exit {code}")
|
||||
check("a junk file is REFUSED, exit 1",
|
||||
code == 1 and "REFUSED" in out and "0 of 2 line(s) decoded" in out)
|
||||
code, out = tool("decode_bus_capture.py", dump("empty.log", text=""))
|
||||
check("an empty file is REFUSED, exit 1",
|
||||
code == 1 and "0 of 0 line(s) decoded" in out)
|
||||
code, out = tool("decode_bus_capture.py", str(CAPTURE), junk)
|
||||
check("several paths, as the docstring's glob passes them: each is decoded and "
|
||||
"one bad file makes the run exit 1",
|
||||
code == 1 and f"== {CAPTURE}" in out and f"== {junk}" in out)
|
||||
code, out = tool("decode_bus_capture.py", str(CAPTURE), str(CAPTURE))
|
||||
check("...and two good files exit 0", code == 0 and out.count("REFUSED") == 0)
|
||||
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all tool-input checks passed")
|
||||
@@ -20,6 +20,8 @@ Read MUTATION.md before you report a survivor.
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from named_absence import nth, present
|
||||
|
||||
from app.watchdog import (
|
||||
SignWatchdog, SignWatchdogConfig, loop_is_following,
|
||||
)
|
||||
@@ -1102,7 +1104,7 @@ check("...under a fixed notification_id of its own, so a second episode "
|
||||
all(c[2]["notification_id"] == "goodwe_circulating_power"
|
||||
for c in _notes1 + _notes2)
|
||||
and len({c[2]["notification_id"] for c in _notes1 + _notes2}) == 1)
|
||||
_msg = _notes2[0][2]["message"]
|
||||
_msg = nth(_notes2, 0)[2]["message"]
|
||||
check("...and the message tells the operator the three things only this alarm "
|
||||
"can: that the meter cannot see it, that control has been stopped, and "
|
||||
"that if the grid reading does not move afterwards one inverter is no "
|
||||
@@ -1706,7 +1708,7 @@ print(f" {_b30.sign_wd.reason!r}")
|
||||
# the objects - and not off the fixture's constants, so the check cannot be
|
||||
# satisfied by two numbers the fixture happens to hold anyway (habit 2).
|
||||
_preb30 = [x for x in _s30 if not x["b"]]
|
||||
_midb30 = _preb30[-1]
|
||||
_midb30 = nth(_preb30, -1, "no cycle before B latched")
|
||||
print(f" last cycle before B latched: B stood "
|
||||
f"{_midb30['stood'][1]:+.0f} W, B measured {_midb30['meas'][1]:+.0f} W")
|
||||
check("⚠️ ...AND THE ORDERED PAIR, on the ONE SIGN-DISAGREEMENT SENTENCE A "
|
||||
@@ -1840,7 +1842,7 @@ _c31, _h31, _s31 = asyncio.run(_site_reversed())
|
||||
# stopped. This is `Run.p2p_between`'s lesson from the rig, in a unit test: name
|
||||
# the window rather than inferring it.
|
||||
_pre31 = [x for x in _s31 if not x["site"]]
|
||||
_mid31 = _pre31[-1]
|
||||
_mid31 = nth(_pre31, -1, "no cycle before the trip")
|
||||
print(f" last cycle before the trip: stood "
|
||||
f"{[round(x) for x in _mid31['stood']]} "
|
||||
f"measured {[round(x) for x in _mid31['meas']]} "
|
||||
@@ -1849,7 +1851,8 @@ check("PRECONDITION: EVERY inverter is obeying - each unit's measured power "
|
||||
"agrees in sign with its own share - and the site is still charging into "
|
||||
"an importing house. Without this the site trip below could be a "
|
||||
"per-unit fault in disguise",
|
||||
all(s * m > 0 for s, m in zip(_mid31["stood"], _mid31["meas"]))
|
||||
present(_mid31)
|
||||
and all(s * m > 0 for s, m in zip(_mid31["stood"], _mid31["meas"]))
|
||||
and sum(_mid31["stood"]) * _mid31["grid"] < 0)
|
||||
check("⚠️ AC 3. THE SITE WATCHDOG TRIPS AND NOT ONE PER-UNIT WATCHDOG DOES. "
|
||||
"That asymmetry is the card: the fault is in the number the site was "
|
||||
@@ -2053,7 +2056,7 @@ check("...the reason sentence still says 'a meter reading', word for word - it "
|
||||
# consecutive commands IDENTICAL; at a rail-following slew the loop ramps and
|
||||
# they differ by exactly one step, which is how this was found.
|
||||
_first1s = [x for x in _seen1s if x["tripped"]]
|
||||
_mid1s = _first1s[0]
|
||||
_mid1s = nth(_first1s, 0, "no tripped cycle")
|
||||
print(f" the cycle the latch happened on: stood {_mid1s['stood']:+.0f} W, "
|
||||
f"meter {_mid1s['grid']:+.0f} W")
|
||||
print(f" {_inv1.sign_wd.reason!r}")
|
||||
@@ -2501,13 +2504,13 @@ _c38, _s38seen = asyncio.run(_s38_site())
|
||||
# The last sample BEFORE the trip, named rather than indexed - part 5's lesson.
|
||||
# After the trip every command is 0 W, so a fixed index reads the REMEDY.
|
||||
_pre38 = [x for x in _s38seen if not x["site"]]
|
||||
_mid38 = _pre38[-1]
|
||||
_mid38 = nth(_pre38, -1, "no cycle before the trip")
|
||||
_GATE38 = _c38.site_sign_wd.cfg.min_w
|
||||
_share38 = list(_mid38["stood"])
|
||||
_red38 = {"sum": sum(_share38),
|
||||
"mean": sum(_share38) / max(1, len(_share38)),
|
||||
"max(key=abs)": max(_share38, key=abs),
|
||||
"[0]": _share38[0]}
|
||||
"max(key=abs)": max(_share38, key=abs, default=nth([], 0)),
|
||||
"[0]": nth(_share38, 0)}
|
||||
print(f" last cycle before the trip: stood "
|
||||
f"{[round(x) for x in _share38]} measured "
|
||||
f"{[round(x) for x in _mid38['meas']]} meter {_mid38['grid']:+.0f} W")
|
||||
@@ -2538,7 +2541,8 @@ check("PRECONDITION: EVERY inverter is obeying - each unit's measured power "
|
||||
"agrees in sign with its own share - and the site is charging into an "
|
||||
"importing house. Without this the trip below could be a per-unit fault "
|
||||
"in disguise rather than the site fault this grades",
|
||||
all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"]))
|
||||
present(_mid38)
|
||||
and all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"]))
|
||||
and sum(_mid38["stood"]) * _mid38["grid"] < 0
|
||||
and _mid38["following"] is True)
|
||||
check("⚠️ THE SITE WATCHDOG TRIPS ON THE SUM, and no per-unit watchdog does. "
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split
|
||||
python3 tools/alloc_sim.py --all
|
||||
|
||||
Exit 0 only if every split-invariance run HOLDS; 1 if one FAILS; 2 if no rig.
|
||||
|
||||
# DEV-02: the split-invariance finding ALLOC-01's review handed on, and the
|
||||
# sensing change that closed it. The two rows are the evidence.
|
||||
python3 tools/alloc_sim.py --splits --gain 0.4 --feedback unit # 250 W apart
|
||||
@@ -279,13 +281,14 @@ def main():
|
||||
if a.table or a.all:
|
||||
table(sitesim, control, watchdog, a.cycles, a.load)
|
||||
print()
|
||||
held = True
|
||||
if a.splits or a.all:
|
||||
splits(sitesim, control, watchdog, a.cycles, a.load, a.gain,
|
||||
a.feedback)
|
||||
held = splits(sitesim, control, watchdog, a.cycles, a.load, a.gain,
|
||||
a.feedback)
|
||||
print()
|
||||
if a.sweep or a.all:
|
||||
sweep(sitesim, control, watchdog, a.cycles, a.load)
|
||||
return 0
|
||||
return 0 if held else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -195,14 +195,37 @@ def _hms(t):
|
||||
return f"{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:06.3f}"
|
||||
|
||||
|
||||
def unreadable(stats):
|
||||
"""Why a capture decoded to too little to trust, or None: most lines must be frames."""
|
||||
framed = sum(stats[k] for k in ("requests", "responses", "writes",
|
||||
"write_echoes"))
|
||||
if framed > 0 and stats["lines"] - framed <= framed:
|
||||
return None
|
||||
return (f"{framed} of {stats['lines']} line(s) decoded as frames "
|
||||
f"(unparsed {stats['unparsed']}, bad_crc {stats['bad_crc']}, "
|
||||
f"unknown {stats['unknown']}) - not a capture this decoder can read")
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 2:
|
||||
"""Decode each capture named; exit 1 if any is unreadable, 2 on no argument."""
|
||||
if len(argv) < 2:
|
||||
print(__doc__.splitlines()[2].strip())
|
||||
return 2
|
||||
events, stats = decode(argv[1])
|
||||
print(f"== {argv[1]}")
|
||||
rc = 0
|
||||
for path in argv[1:]:
|
||||
rc |= _report(path)
|
||||
return rc
|
||||
|
||||
|
||||
def _report(path):
|
||||
events, stats = decode(path)
|
||||
print(f"== {path}")
|
||||
for k, v in stats.items():
|
||||
print(f" {k:14} {v}")
|
||||
bad = unreadable(stats)
|
||||
if bad:
|
||||
print(f"REFUSED: {path}: {bad}")
|
||||
return 1
|
||||
for kind in ("setpoint_w", "batt_w", "soc_pct"):
|
||||
rows = series(events, kind)
|
||||
if not rows:
|
||||
|
||||
@@ -39,8 +39,10 @@ and not at merge. The check belongs where the mistake is made.
|
||||
|
||||
⚠️ WHAT THIS DOES NOT DO. It does not read the live box. The options blob is
|
||||
reachable only through the Supervisor API, which needs a supervisor token, so
|
||||
the operator exports it and passes `--options`. Without that flag the validator
|
||||
half is SKIPPED and says so - a skipped check must never look like a passed one.
|
||||
the operator exports it and passes `--options` - the bare options object
|
||||
(`.data.options` of `ha apps info <slug> --raw-json`); any other shape is
|
||||
refused. Without that flag the validator half is SKIPPED and says so - a
|
||||
skipped check must never look like a passed one.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
@@ -79,6 +81,43 @@ def shipped_version(tree=ADDON):
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def option_keys(addon=ADDON):
|
||||
"""Top-level option names in `addon`'s config.yaml `options:`/`schema:` blocks."""
|
||||
keys, section = set(), None
|
||||
with open(os.path.join(addon, "config.yaml"), encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if not line.strip() or line.startswith("#"):
|
||||
continue
|
||||
if not line[0].isspace():
|
||||
section = line.split(":", 1)[0].strip()
|
||||
continue
|
||||
m = re.match(r" ([A-Za-z_][A-Za-z0-9_]*):", line)
|
||||
if m and section in ("options", "schema"):
|
||||
keys.add(m.group(1))
|
||||
return keys
|
||||
|
||||
|
||||
def shape_problem(doc, known, what, allow_empty=False):
|
||||
"""Why `doc` is not a document of `known` keys, or None (SAFETY-60/62).
|
||||
|
||||
Positive property: a JSON object, at least half of whose top-level keys
|
||||
(`_`-prefixed comments aside) `known(key)` recognises.
|
||||
"""
|
||||
if not isinstance(doc, dict):
|
||||
return f"{what} is a JSON {type(doc).__name__}, not an object"
|
||||
keys = [k for k in doc if not str(k).startswith("_")]
|
||||
hits = [k for k in keys if known(k)]
|
||||
if (keys or allow_empty) and len(hits) * 2 >= len(keys):
|
||||
return None
|
||||
msg = (f"{what} is not the expected document: {len(hits)} of {len(keys)} "
|
||||
f"top-level key(s) recognised {sorted(map(str, keys))[:8]}")
|
||||
inner = doc.get("data")
|
||||
if isinstance(inner, dict) and isinstance(inner.get("options"), dict):
|
||||
msg += ("; this is `ha apps info --raw-json` output - pass its "
|
||||
"`.data.options` instead")
|
||||
return msg
|
||||
|
||||
|
||||
def is_runtime(path):
|
||||
return any(path == p or path.startswith(p) for p in RUNTIME)
|
||||
|
||||
@@ -256,6 +295,12 @@ def main():
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
||||
problems.append(f"--options file could not be read as JSON "
|
||||
f"({type(exc).__name__}: {exc}); the config was NOT checked")
|
||||
# SAFETY-60: the validator accepts any dict, so the shape is checked here.
|
||||
bad = (shape_problem(opts, option_keys().__contains__, "--options")
|
||||
if opts is not None else None)
|
||||
if bad:
|
||||
problems.append(f"{bad}; the config was NOT checked")
|
||||
opts = None
|
||||
if a.options and opts is not None:
|
||||
try:
|
||||
from app.validate import validate_startup_config
|
||||
@@ -281,7 +326,7 @@ def main():
|
||||
print(f" - {p}")
|
||||
else:
|
||||
print(" live options pass this version's validator")
|
||||
else:
|
||||
elif not a.options:
|
||||
skipped.append("--options was not given, so the live config was NEVER CHECKED "
|
||||
"against this version's validator. A config refused at startup is "
|
||||
"exit 78. Export the options and re-run before deploying.")
|
||||
|
||||
@@ -46,8 +46,11 @@ EXIT CODES
|
||||
1 ⭐ AT LEAST ONE OF THOSE HAPPENED. This is the code that must be
|
||||
reachable: `test_rehearse.py` mutates the shipped migration nine
|
||||
different ways and requires this exit code for each.
|
||||
2 the rehearsal could not be performed (bad file, unparseable config, the
|
||||
entity-id model failed its own anchor). ⚠️ NOT A PASS.
|
||||
2 the rehearsal could not be performed (bad file, unparseable config, a
|
||||
state or options file of the wrong shape, the entity-id model failed
|
||||
its own anchor). ⚠️ NOT A PASS.
|
||||
3 no findings, but --no-live-entities: the orphan check was not graded
|
||||
against the install. ⚠️ NOT A PASS.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -64,10 +67,12 @@ from app.inverter import UNIT_A, UNIT_STORE_KEYS, Inverter, roster_names # noqa
|
||||
from app.main import Controller # noqa: E402
|
||||
from app import mqtt # noqa: E402
|
||||
from app.p1 import is_enabled # noqa: E402
|
||||
from app.store import (DEFAULTS, MAINT_UNIT_KEYS, Store, # noqa: E402
|
||||
unit_key)
|
||||
from app.store import (DEFAULTS, MAINT_SCHEMA_KEY, MAINT_UNIT_KEYS, # noqa: E402
|
||||
SCHEMA_KEY, Store, unit_key)
|
||||
from predeploy_check import option_keys, shape_problem # noqa: E402
|
||||
|
||||
EXIT_OK, EXIT_FINDING, EXIT_COULD_NOT_RUN = 0, 1, 2
|
||||
EXIT_OK, EXIT_FINDING, EXIT_COULD_NOT_RUN, EXIT_DEGRADED = 0, 1, 2, 3
|
||||
ADDON = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
@@ -184,8 +189,30 @@ def load_options(path: str) -> dict:
|
||||
obj = _mini_yaml(text)
|
||||
else:
|
||||
obj = yaml.safe_load(text)
|
||||
if not isinstance(obj, dict):
|
||||
raise Refused(f"{path}: options must be a mapping, got {type(obj).__name__}")
|
||||
# SAFETY-62: a wrapped export is a dict too, so assert the keys are options.
|
||||
bad = shape_problem(obj, option_keys(str(ADDON)).__contains__, path)
|
||||
if bad:
|
||||
raise Refused(bad)
|
||||
return obj
|
||||
|
||||
|
||||
_UNIT_PREFIX = unit_key("\0", "").split("\0")[0]
|
||||
# "auto" is the Automatic toggle, written by app/main.py outside DEFAULTS.
|
||||
_STORE_KEYS = {*DEFAULTS, *MAINT_UNIT_KEYS, SCHEMA_KEY, MAINT_SCHEMA_KEY, "auto"}
|
||||
|
||||
|
||||
def load_state(path: str) -> dict:
|
||||
"""Refuse a state file `Store.load()` would silently start fresh on (SAFETY-62)."""
|
||||
try:
|
||||
obj = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except ValueError as err:
|
||||
raise Refused(f"{path}: not a readable JSON state file ({err}); "
|
||||
"Store.load() would start fresh and grade an empty store")
|
||||
bad = shape_problem(
|
||||
obj, lambda k: k in _STORE_KEYS or str(k).startswith(_UNIT_PREFIX),
|
||||
path, allow_empty=True)
|
||||
if bad:
|
||||
raise Refused(bad)
|
||||
return obj
|
||||
|
||||
|
||||
@@ -1061,6 +1088,7 @@ def main(argv=None) -> int:
|
||||
if args.live_entities else None)
|
||||
if not Path(args.state).is_file():
|
||||
raise Refused(f"{args.state}: not a file")
|
||||
load_state(args.state)
|
||||
except (OSError, Refused) as err:
|
||||
print(f"REFUSED: {err}", file=sys.stderr)
|
||||
return EXIT_COULD_NOT_RUN
|
||||
@@ -1179,7 +1207,9 @@ def main(argv=None) -> int:
|
||||
print(" the install's own entity list. This result does not cover "
|
||||
"orphaned entities.")
|
||||
print("=" * 78)
|
||||
return EXIT_FINDING if findings else EXIT_OK
|
||||
if findings:
|
||||
return EXIT_FINDING
|
||||
return EXIT_DEGRADED if degraded else EXIT_OK
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -683,6 +683,72 @@ except vol.Invalid as e:
|
||||
str(e))
|
||||
|
||||
|
||||
print("\n== H. TEL-14: the SHAPE of a per-entity PV sign option ==")
|
||||
# Four candidate shapes, each against a 0.12.0 blob (`pv_entities` set, no new
|
||||
# key). Transcript and decision: DECISIONS/TEL-14-pv-sign.md.
|
||||
PV12 = {"pv_entity": "", "pv_entities": ["sensor.pv_inverter", "sensor.pv_meter"]}
|
||||
_pv12 = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(PV12))
|
||||
_nk = "pv_invert_entities"
|
||||
|
||||
|
||||
def _ui_of(sch, name):
|
||||
return [e for e in UiOptions(None)(sch) if e.get("name") == name]
|
||||
|
||||
|
||||
# (a) a `-` prefix on a list item, under the shipped `- str`.
|
||||
ok_a, out_a = validate(dict(_pv12, pv_entities=["sensor.pv_inverter",
|
||||
"-sensor.pv_meter"]))
|
||||
show("(a) PREFIX: `-sensor.pv_meter` validates under the shipped `- str` - "
|
||||
"Supervisor does not look inside the string, so the sign would live in a "
|
||||
"syntax that only the description text explains",
|
||||
ok_a and out_a.get("pv_entities") == ["sensor.pv_inverter",
|
||||
"-sensor.pv_meter"],
|
||||
"" if ok_a else out_a)
|
||||
|
||||
# (b) a parallel list - THE SHIPPED SHAPE.
|
||||
ok_b, out_b = validate(_pv12)
|
||||
show("(b) ⭐ PARALLEL LIST, SHIPPED: the 0.12.0 blob (no `pv_invert_entities` "
|
||||
"key) validates unchanged and the new key defaults to []",
|
||||
ok_b and out_b.get(_nk) == []
|
||||
and out_b.get("pv_entities") == PV12["pv_entities"],
|
||||
"" if ok_b else out_b)
|
||||
ok_b2, out_b2 = validate(dict(_pv12, **{_nk: ["sensor.pv_meter"]}))
|
||||
show("...and one entity marked inverted validates",
|
||||
ok_b2 and out_b2.get(_nk) == ["sensor.pv_meter"], "" if ok_b2 else out_b2)
|
||||
ok_b3, out_b3 = validate({k: v for k, v in defaults.items() if k != _nk})
|
||||
show("⭐ ...and the shipped `options:` block with ONLY `pv_invert_entities` "
|
||||
"removed is REFUSED BY NAME - its `[]` default is load-bearing, as "
|
||||
"`pv_entities: []` is",
|
||||
(not ok_b3) and f"Missing option '{_nk}' in root" in str(out_b3), out_b3)
|
||||
_ui_b = _ui_of(schema, _nk)
|
||||
show("...and the Configuration tab renders it as a REPEATABLE string field "
|
||||
"with its own top-level (translated) label",
|
||||
len(_ui_b) == 1 and _ui_b[0].get("multiple") is True
|
||||
and _ui_b[0].get("type") == "string", json.dumps(_ui_b))
|
||||
|
||||
# (c) a list of dicts - on `pv_entities` itself, and as a new key.
|
||||
_s_dicts = dict(schema, pv_entities=[{"entity": "str", "invert": "bool?"}])
|
||||
ok_c, out_c = validate_with(_s_dicts, _pv12)
|
||||
show("(c) LIST OF DICTS ON `pv_entities`: the STRINGS a 0.12.0 install stored "
|
||||
"are REFUSED - that shape would stop the add-on starting on upgrade",
|
||||
(not ok_c) and "pv_entities" in str(out_c), out_c)
|
||||
_s_src = dict(schema, pv_sources=[{"entity": "str", "invert": "bool?"}])
|
||||
ok_c2, out_c2 = validate_with(_s_src, dict(_pv12, pv_sources=[]))
|
||||
_ui_c = _ui_of(_s_src, "pv_sources")
|
||||
show("(c2) ...as a THIRD key it validates, but renders as a type-schema "
|
||||
"repeatable section whose per-entry keys reach the screen RAW "
|
||||
"(`entity`, `invert`) on this frontend - see section F",
|
||||
ok_c2 and len(_ui_c) == 1 and _ui_c[0].get("type") == "schema"
|
||||
and _ui_c[0].get("multiple") is True,
|
||||
json.dumps(_ui_c)[:200] if ok_c2 else out_c2)
|
||||
|
||||
# (d) one global bool.
|
||||
_s_bool = dict(schema, pv_invert="bool")
|
||||
ok_d, out_d = validate_with(_s_bool, dict(_pv12, pv_invert=False))
|
||||
show("(d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one "
|
||||
"sign for a mixed roster), not on the schema", ok_d, "" if ok_d else out_d)
|
||||
|
||||
|
||||
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
|
||||
# and still succeeds, and a transcript is only evidence if a wrong answer is
|
||||
# distinguishable from a right one by something other than reading it.
|
||||
|
||||
@@ -86,10 +86,13 @@ configuration:
|
||||
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
|
||||
pv_entity:
|
||||
name: "Hardware · PV production entity (W)"
|
||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Point it at your solar inverter's own power sensor (any brand Home Assistant reads; it does not have to be on the RS485 bus) and the add-on records production beside the grid and battery figures, so a day of unstored surplus can be told apart from a day with no sun. POSITIVE MUST MEAN PRODUCING. The unit is read from the entity itself - W, kW and the rest of Home Assistant's power units - so a sensor with no unit is refused rather than guessed at, and the status page says so. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Point it at your solar inverter's own power sensor (any brand Home Assistant reads; it does not have to be on the RS485 bus) and the add-on records production beside the grid and battery figures, so a day of unstored surplus can be told apart from a day with no sun. POSITIVE MUST MEAN PRODUCING - if yours reads negative while producing, list it under 'sign is reversed' below. The unit is read from the entity itself - W, kW and the rest of Home Assistant's power units - so a sensor with no unit is refused rather than guessed at, and the status page says so. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
||||
pv_entities:
|
||||
name: "Hardware · PV production entities, one per inverter (W)"
|
||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Use this instead of the single entity above when your roof has SEVERAL solar inverters: add one row per inverter, pointing at each one's own power sensor, and the add-on records the SITE total. Each entity's own unit is read, so a kW inverter beside a W one adds up correctly. ⚠️ SETTING THIS IGNORES THE SINGLE ENTITY ABOVE - clear that one when you fill this in, or you will see a caution on the status page saying so. ⚠️ AND IF ANY ONE OF THESE STOPS REPORTING THERE IS NO SITE TOTAL: the sensor goes to 'unknown' and the history records a gap, rather than publishing the sum of the ones that did answer. Two inverters out of three add up to a smaller number that looks exactly like a real one, and a week later that reads as 'there was less sun than there was' - which is the one conclusion this recording exists to test. The status page names which entity is missing, why, and what the others reported. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
||||
pv_invert_entities:
|
||||
name: "Hardware · PV entities whose sign is reversed"
|
||||
description: "Optional, MEASUREMENT ONLY. Some sensors - typically a kWh meter or a power clamp in the PV circuit - read NEGATIVE while producing and a few watts POSITIVE at night. Add each such entity here, spelled exactly as it is above, and its sign is flipped before it is added up; leave the others out. The status page marks every flipped entity. History recorded before you set this keeps the old sign."
|
||||
meter_source:
|
||||
name: "Meter · Where the meter reading comes from"
|
||||
description: "'off' keeps the single Home Assistant entity below, which is what an existing installation runs on until you choose otherwise. ha_dsmr subscribes to the DSMR integration; mqtt_p1 reads an MQTT topic; ha_signed reads one signed entity, which is what a HomeWizard P1 publishes; homewizard_local polls the meter's own local API and skips Home Assistant entirely. ⚠️ Only homewizard_local can tell 'the meter has not answered' apart from 'the value has not changed', so it is the only source on which the frozen-meter alarm means anything."
|
||||
|
||||
@@ -66,10 +66,13 @@ configuration:
|
||||
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
|
||||
pv_entity:
|
||||
name: "Hardware · Zonneproductie-entiteit (W)"
|
||||
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Verwijs naar de vermogenssensor van je omvormer (elk merk dat Home Assistant kan lezen; hij hoeft niet op de RS485-bus te zitten) en de add-on registreert de productie naast de meter- en batterijwaarden, zodat een dag met niet-opgeslagen overschot te onderscheiden is van een dag zonder zon. POSITIEF MOET PRODUCEREN BETEKENEN. De eenheid wordt van de entiteit zelf gelezen - W, kW en de overige vermogenseenheden van Home Assistant - dus een sensor zonder eenheid wordt geweigerd in plaats van geraden, en de statuspagina zegt waarom. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
|
||||
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Verwijs naar de vermogenssensor van je omvormer (elk merk dat Home Assistant kan lezen; hij hoeft niet op de RS485-bus te zitten) en de add-on registreert de productie naast de meter- en batterijwaarden, zodat een dag met niet-opgeslagen overschot te onderscheiden is van een dag zonder zon. POSITIEF MOET PRODUCEREN BETEKENEN - leest de uwe negatief tijdens productie, zet hem dan ook bij 'teken omgekeerd' hieronder. De eenheid wordt van de entiteit zelf gelezen - W, kW en de overige vermogenseenheden van Home Assistant - dus een sensor zonder eenheid wordt geweigerd in plaats van geraden, en de statuspagina zegt waarom. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
|
||||
pv_entities:
|
||||
name: "Hardware · Zonneproductie-entiteiten, één per omvormer (W)"
|
||||
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Gebruik dit in plaats van de losse entiteit hierboven wanneer uw dak MEERDERE zonne-omvormers heeft: voeg één rij per omvormer toe, elk verwijzend naar haar eigen vermogenssensor, en de add-on registreert het totaal van de installatie. De eenheid van elke entiteit wordt afzonderlijk gelezen, dus een omvormer in kW naast een in W telt correct op. ⚠️ ALS U DIT INVULT WORDT DE LOSSE ENTITEIT HIERBOVEN GENEGEERD - maak die leeg, anders verschijnt er een waarschuwing op de statuspagina die dat meldt. ⚠️ EN ALS ÉÉN VAN DEZE ENTITEITEN NIETS MEER DOORGEEFT, IS ER GEEN TOTAAL: de sensor gaat naar 'unknown' en de geschiedenis bewaart een gat, in plaats van de som van de entiteiten die wél antwoordden te publiceren. Twee omvormers van de drie tellen op tot een kleiner getal dat precies op een echt getal lijkt, en een week later leest dat als 'er was minder zon dan er was' - net de conclusie die deze registratie moet kunnen testen. De statuspagina noemt welke entiteit ontbreekt, waarom, en wat de andere doorgaven. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
|
||||
pv_invert_entities:
|
||||
name: "Hardware · Zonneproductie-entiteiten met omgekeerd teken"
|
||||
description: "Optioneel, UITSLUITEND EEN METING. Sommige sensoren - meestal een kWh-meter of een stroomtang in het PV-circuit - lezen NEGATIEF tijdens productie en 's nachts enkele watt POSITIEF. Zet elke zulke entiteit hier, exact gespeld zoals hierboven, en haar teken wordt omgedraaid vóór het optellen; laat de andere weg. De statuspagina markeert elke omgedraaide entiteit. Geschiedenis van vóór deze instelling behoudt het oude teken."
|
||||
meter_source:
|
||||
name: "Meter · Waar de meterstand vandaan komt"
|
||||
description: "'off' houdt de losse Home Assistant-entiteit hieronder aan, waar een bestaande installatie op draait tot u iets anders kiest. ha_dsmr luistert mee met de DSMR-integratie; mqtt_p1 leest een MQTT-topic; ha_signed leest één entiteit met teken, wat een HomeWizard P1 publiceert; homewizard_local bevraagt de lokale API van de meter zelf en slaat Home Assistant helemaal over. ⚠️ Alleen homewizard_local kan 'de meter heeft niet geantwoord' onderscheiden van 'de waarde is niet veranderd', en is dus de enige bron waarop het alarm voor een vastgelopen meter iets betekent."
|
||||
|
||||
Reference in New Issue
Block a user