Compare commits

...
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 24f955cb26 CI: bump infra pin to 7081df4 (admin/goodwe#59, UX-08)
checks / unit (pull_request) Successful in 8m54s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:31:35 +02:00
admin 48ef06ac1f Merge pull request 'UX-08 phase 2a: short, plain notifications, and a ratchet for the rest' (#131) from UX-08-plain-notifications into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 01:29:46 +02:00
adminandClaude Opus 5.5 7b65a3e69a UX-08 review: tighter notification texts, and two meaning fixes
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 8m48s
- not delivering: the body said control keeps running and then "start
  control"; clearing it takes stop then start, and it now says so.
- peak reserve: the "reserve not set" branch is really soc_floor None,
  i.e. an inverter's cut-out level missing; it now names that. The title
  is true on all three branches ("Battery stopped limiting the peak").
- no semicolon run-ons in bodies (STYLE.md + test); loop-bound, circulating,
  meter-frozen and multi-unit over-rating bodies rewritten to match.
- allowlist cap is now exact, so a shrink cannot leave room to regrow.
- DOCS names the settings behind "peak reserve" and "cut-out level";
  DECISIONS no longer states the HA anchor behaviour as fact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:18:55 +02:00
glenn schrooyenandClaude Opus 5.5 50ccea3dd7 UX-08 phase 2a: short, plain notifications, and a ratchet for the rest
checks / unit (pull_request) Successful in 8m54s
infra-pin / fresh (pull_request) Successful in 1s
Every HA notification the add-on raises now has a title of at most 5
everyday words and a one-sentence body; a red alarm may use three so no
action is lost. The texts live in app/messages.py, the rule in STYLE.md,
and the detail that moved out in DOCS.md "Notifications", which each body
links to. No notification_id, trigger or cadence changed.

test_messages.py enforces the rule on every notification, fails any
notification worded outside app/messages.py, and ratchets the kinds not yet
rewritten (rows, banners, refusals, logs, options) through a shrinking
allowlist of 200. Six tests re-pinned from prose to meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 01:00:54 +02:00
admin 2503de124e Merge pull request 'SAFETY-40: the integrator bound is not a BUG SIGNAL; alarm numbers from the clamp tick' (#130) from SAFETY-40-clamp-alarm into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-26 23:19:11 +02:00
glenn schrooyenandClaude Opus 5.5 55a6d5dcdb SAFETY-40 review: integrator-bound WARNING gets its own notification id
checks / unit (pull_request) Successful in 8m46s
infra-pin / fresh (pull_request) Successful in 1s
A later anti-windup notice could replace a write-path BUG SIGNAL notice
under the shared goodwe_output_clamp id (escalation ignores the budget).
The WARNING now uses goodwe_integrator_bound and points to DOCS; the
cadence sentence in DOCS is corrected. Tests pin both ids (red on fd14569).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:08:44 +02:00
glenn schrooyenandClaude Opus 5.5 fd14569a71 SAFETY-40: the integrator bound is not a BUG SIGNAL; numbers from the clamp tick
checks / unit (pull_request) Successful in 8m47s
infra-pin / fresh (pull_request) Successful in 1s
The clamp alarm gave `i_clamped` (normal anti-windup) the over-rating BUG
SIGNAL text, printed `Decision.i_w` as "the loop asked for", and printed
`self.target` (after slew/freeze) as "was clamped to".

- clamp.loop_clamp_note(): i_clamped says "the loop's integrator reached its
  N W anti-windup bound" with this tick's command; clamped keeps BUG SIGNAL
  with the requested command and the clamp's own result.
- Decision carries i_bound_w / requested_w / clamped_w, set at the clamp.
- i_clamped still counts and notifies (same id, cadence, SAFETY-41
  escalation) but as a WARNING, amber row, plain title. A bug has its own
  notification budget and its text is not overwritten within an episode.
- test_clamp.py part 9: red on the old code per defect; 5 mutations killed.
- DOCS.md alarm section (and its stale 30 s cadence), DECISIONS/SAFETY-40.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-26 22:37:01 +02:00
admin 59415ea83f Merge pull request 'CI: bump infra pin to 7689816 (admin/goodwe#58, SAFETY-34)' (#129) from pin-bump-7689816 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-09-26 16:15:23 +02:00
glenn schrooyenandClaude Opus 5.5 f37268f3e4 CI: bump infra pin to 7689816 (admin/goodwe#58, SAFETY-34)
checks / unit (pull_request) Successful in 8m48s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:05:02 +02:00
admin 397dd15dba SAFETY-34: re-assert latched alarms after a Core-only restart (#128)
infra-pin / fresh (push) Failing after 1s
2026-09-26 16:03:16 +02:00
glenn schrooyenandClaude Opus 5.5 6028e21298 SAFETY-34: re-assert latched alarms, because a Core restart keeps the add-on
checks / unit (pull_request) Successful in 8m55s
infra-pin / fresh (pull_request) Successful in 1s
A Core-only restart wipes persistent_notification and does NOT restart the
add-on (measured on TEST-23: the add-on's SAFETY-31 card was gone 4 min after
`docker restart test23-ha`, add-on StartedAt unchanged). The latched SAFETY-31
and SAFETY-42 cards were re-raised only on add-on start, so they stayed gone.

They are now re-asserted every REANNOUNCE_S = 300 s while tripped (same
notification_id, so it replaces). test_watchdog part 6 and test_delivery part
8b grade it with the same process across the "restart"; red on the old code.
The docstrings that said the add-on restarts with HA are corrected.
Record: goodwe docs/DECISIONS.md, SAFETY-34 D4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-26 12:37:47 +02:00
admin fe132515fe Merge pull request 'CI: bump infra pin to a1d0618 (admin/goodwe#57, TOOL-9)' (#127) from pin-bump-a1d0618 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-25 20:52:59 +02:00
glenn schrooyenandClaude Opus 5.5 00e9a7b4e1 CI: bump infra pin to a1d0618 (admin/goodwe#57, TOOL-9)
checks / unit (pull_request) Successful in 8m46s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:42:44 +02:00
admin 82f032e8cf Merge pull request 'TOOL-9: pin checks print FAIL under pipefail; job tokens via header' (#126) from TOOL-9 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-25 20:39:47 +02:00
glenn schrooyenandClaude Opus 5.5 b963153de6 Restore .gitea/infra-pin (revert the TOOL-9 proof throwaway)
checks / unit (pull_request) Successful in 8m45s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:20:19 +02:00
glenn schrooyenandClaude Opus 5.5 a2c571b20e THROWAWAY (TOOL-9 proof, reverted next): remove .gitea/infra-pin
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:08:22 +02:00
glenn schrooyenandClaude Opus 5.5 550bd6fa52 TOOL-9: pin checks print their FAIL line under pipefail; job tokens via header
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Successful in 1s
A missing or comment-only .gitea/infra-pin now ends on the workflow's own
FAIL line instead of git's `fatal: path ... does not exist` (still red). Every
x-access-token remote URL becomes an env-set Authorization header, so no
token lands in .git/config or argv; the pin workflows' temp dir is removed
by an EXIT trap, also on failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:07:34 +02:00
admin 6a8a5ffa27 Merge pull request 'CI-12: CI grades test_freeze.py against the real hwsim at a pinned admin/goodwe' (#125) from CI-12 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-25 18:19:43 +02:00
glenn schrooyenandClaude Opus 5.5 8ff42ea85d CI-12: .gitea/infra-pin to admin/goodwe release/1.0 HEAD (2d026df, goodwe#56 merged)
checks / unit (pull_request) Successful in 8m42s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-25 18:10:28 +02:00
glenn schrooyenandClaude Opus 5.5 841424463a CI-12 review: INFRA_READ_TOKEN via an env-set header, not a remote URL
checks / unit (pull_request) Successful in 8m42s
infra-pin / fresh (pull_request) Successful in 1s
The long-lived token no longer lands in $WS/goodwe/.git/config (mounted into
the tests' container, left behind by a failed job) or in git's argv. A missing
secret (fork PR) fails with its own message. README: addon-pin is not re-run
after a bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-25 18:01:15 +02:00
glenn schrooyenandClaude Opus 5.5 c63eda0b0f CI-12: restore .gitea/infra-pin to admin/goodwe release/1.0 HEAD (56a933e)
checks / unit (pull_request) Successful in 8m43s
infra-pin / fresh (pull_request) Successful in 1s
This reverts commit 54cc5263fb (the proof-2 throwaway).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:45:36 +02:00
glenn schrooyenandClaude Opus 5.5 54cc5263fb THROWAWAY (CI-12 proof 2): pin one release/1.0 merge behind (d84ac01); reverted next
checks / unit (pull_request) Successful in 8m43s
infra-pin / fresh (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:36:36 +02:00
glenn schrooyenandClaude Opus 5.5 c493a6d67a CI-12: test_tariff.py no longer says CI cannot see the infra repo
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 8m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:27:06 +02:00
glenn schrooyenandClaude Opus 5.5 d0891a6bca CI-12: .gitea/infra-pin to admin/goodwe release/1.0 HEAD (56a933e), restoring the proof pin
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:10:47 +02:00
glenn schrooyenandClaude Opus 5.5 fe80047235 CI-12 round 2: INFRA_READ_TOKEN for admin/goodwe; the pin tracks release/1.0
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Failing after 1s
- checks.yml's infra checkout and infra-pin.yml's admin/goodwe ls-remote use
  the INFRA_READ_TOKEN secret; the self fetch keeps GITHUB_TOKEN.
- infra-pin.yml compares against admin/goodwe refs/heads/release/1.0; STALE
  message, comments and README follow.
- test_control.py / test_alloc.py: drop the claim that CI cannot reach
  admin/goodwe.
- .gitea/infra-pin is DELIBERATELY one release/1.0 merge behind (d84ac01) for
  the "infra-pin / fresh goes red" proof; the next commit sets it to HEAD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:08:55 +02:00
adminandClaude Opus 5.5 6e3399322a CI-12: CI grades test_freeze.py against the real hwsim at a pinned admin/goodwe
infra-pin / fresh (pull_request) Failing after 1s
checks / unit (pull_request) Failing after 2s
- .gitea/infra-pin: the one place the admin/goodwe commit is pinned.
- checks.yml: checks out admin/goodwe at the pin as the sibling test_freeze.py
  looks for; any failure fails the job. Tests run with GOODWE_REQUIRE_HWSIM=1,
  and `CI: ` lines are echoed on PASS so the log shows which transport ran.
- test_freeze.py: GOODWE_REQUIRE_HWSIM refuses the stub; a local stub run
  prints a STUB USED line.
- infra-pin.yml: a separate, non-required check that goes red naming both
  SHAs when admin/goodwe master has moved past the pin.
- README: when and how to bump the pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 01:57:04 +02:00
admin 16c796fa30 Merge pull request 'TEL-14: PV sign declared per entity (pv_invert_entities)' (#124) from TEL-14-pv-sign into release/1.0 2026-09-25 01:30:33 +02:00
admin 20260e86ec Merge pull request 'DOC-5: tell installers to turn on the Supervisor Watchdog' (#123) from DOC-5-watchdog into release/1.0 2026-09-25 01:17:35 +02:00
adminandClaude Opus 5.5 7fa045af85 TEL-14 N1: trim DECISIONS (drop probe transcript, §1 to the reason), move the mutation ledger there with M2/M5
checks / unit (pull_request) Successful in 8m44s
test_pv.py part 11 now prints a one-line pointer instead of the 12-row table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 01:11:40 +02:00
adminandClaude Opus 5.5 93f349a6af TEL-14 B1: pin stray caution on the reported case and whitespace normalisation end to end
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 01:10:13 +02:00
glenn schrooyenandClaude Opus 5.5 d71f7dbd35 Merge release/1.0 (43b2740) into DOC-5-watchdog to sync base
checks / unit (pull_request) Successful in 8m45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 01:08:15 +02:00
admin 43b274035f Merge pull request 'TEST-26: a missing value in a check is a named FAIL, not a traceback' (#122) from TEST-26 into release/1.0 2026-09-25 00:57:20 +02:00
glenn schrooyenandClaude Opus 5.5 6dbe06e32e DOC-5: add the Watchdog step to README's Install section
checks / unit (pull_request) Successful in 8m31s
One line after the install step, linking to the DOCS.md paragraph that
gives the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:30:44 +02:00
glenn schrooyenandClaude Opus 5.5 7559d21eda TEL-14: PV sign declared per entity (pv_invert_entities)
checks / unit (pull_request) Successful in 8m39s
Some PV entities (kWh meters, ESPHome clamps in the PV circuit) read positive
for consumption and negative for production. `pv_invert_entities` lists the
ones to flip; the flip is applied per member inside pv.total(), before the sum,
so the MQTT `pv` row and the status page carry the corrected figure. Default []
is byte-identical to 0.12.0.

Shape measured in supervisor_shape_probe.py section H (Supervisor 2026.08.0):
a parallel list validates the 0.12.0 blob unchanged and renders as its own
translated repeatable field; dicts on pv_entities refuse 0.12.0's stored
strings; a prefix is invisible syntax; a global bool cannot describe a mixed
roster. Status page marks flipped entities and cautions per entity below
-50 W, saying ADD or REMOVE. Telemetry only: control documents unchanged.

Record: DECISIONS/TEL-14-pv-sign.md (reverses TEL-11 section 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:25:24 +02:00
admin 3e38bafcee Merge pull request 'SAFETY-62/SAFETY-60: operator tools refuse input they cannot read' (#120) from SAFETY-62-refuse-unread-input into release/1.0 2026-09-25 00:25:22 +02:00
glenn schrooyenandClaude Opus 5.5 02a822af98 DOC-5: tell installers to turn on the Supervisor Watchdog
checks / unit (pull_request) Successful in 8m41s
Since SAFETY-61 a dead control loop writes 0 W and exits 70. The Supervisor
restarts a stopped add-on only when the per-install Watchdog toggle is on,
and it defaults to off. One step in DOCS.md "Before you start", with a
pointer to the DECISIONS file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:20:52 +02:00
admin 6e6527224a Merge pull request 'SAFETY-61: the control task's death ends the process, after the wind-down' (#121) from safety-61-control-task-death into release/1.0 2026-09-25 00:07:41 +02:00
glenn schrooyenandClaude Opus 5.5 770988c902 TEST-26: a missing value in a check is a named FAIL, not a traceback
checks / unit (pull_request) Successful in 8m44s
Checks and prints that indexed an empty list, exhausted next(), or formatted
None killed the rest of their file (FCST-06). Read them through the new
named_absence.py (nth/first/field/given/present) and conjoin a presence term.

- every site in the brief, plus the family the new scan and a sweep found
- test_named_absence.py: scans every test_*.py for unguarded filtered-list
  indexing, next() without a default and numeric formats on maybe-None
  names; proven to bite; prints what it cannot see, counted
- test_price.py 16a: the `_T17_CAPS is _T17_CAPS` tautology now grades the
  two assemble() calls' arguments
- test_control.py: coverage table re-measured on runs graded to the end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:06:58 +02:00
glenn schrooyenandClaude Opus 5.5 b06f1fad5f SAFETY-62 review: DOCS exit 3 at the command block (R1); recognise the auto store key (R2)
checks / unit (pull_request) Successful in 8m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:02:33 +02:00
admin fa3d237ce1 Merge pull request 'DOC-1: operator docs name the real P1 entity and a roster that starts' (#119) from doc-1-operator-docs into release/1.0 2026-09-24 23:42:13 +02:00
glenn schrooyenandClaude Opus 5.5 f4f72211a9 SAFETY-60/62: operator tools refuse input they cannot read
checks / unit (pull_request) Successful in 8m31s
predeploy_check.py and rehearse_roster.py now assert a positive property of
their input (a JSON object, most of whose top-level keys are known option or
store keys) and refuse anything else with the shape named. The `ha apps info
--raw-json` wrapper, a truncated or list-wrapped state file and a wrapped
config no longer produce "CLEARED" / "NO FINDINGS" with exit 0.

Also: a clean --no-live-entities rehearsal exits 3, not 0; alloc_sim.py
exits 1 when split-invariance FAILS; decode_bus_capture.py takes several
paths and exits 1 on a file that is mostly not frames.

Graded by the new test_tool_inputs.py; DOCS.md says to pass .data.options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:27:50 +02:00
adminandClaude Opus 5.5 29ca9b5157 DOC-1: operator docs name the real P1 entity and a roster that starts
checks / unit (pull_request) Successful in 8m8s
- DOCS.md / config.yaml: the watchdog's P1 age entity is
  sensor.goodwe_p1_sample_age (device "GoodWe" + "P1 sample age", as
  app/mqtt.py announces and the live install recorded), not
  sensor.p1_sample_age_s.
- DOCS.md: the inverters: example and per-entry table carry the required
  bms_soc_floor (and capacity_kwh); config.yaml's schema comment says four
  required keys, not three.
- Field guide: marked v0.1.0/outdated; slew_w row no longer recommends the
  1000 W that SAFETY-39 now refuses. HTML regenerated; the PDF is NOT
  rebuilt (no Chrome here). README/DOCS pointers fixed.
- test_docs.py: the documented roster must pass app/validate.py and the
  schema's per-entry keys; the documented entity id must equal the id
  composed from app/mqtt.py; the wrong id may appear in no shipped doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:24:50 +02:00
52 changed files with 3693 additions and 651 deletions
+2
View File
@@ -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").
7081df4276ad3cf788355b97a3b3f40ea28d72bd
+33 -13
View File
@@ -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
+46
View File
@@ -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
View File
@@ -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
+12 -3
View File
@@ -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
+48
View File
@@ -0,0 +1,48 @@
# STYLE.md - how user-facing text is written (UX-08)
This applies to everything a home owner or installer reads: notifications, phone pushes, the add-on page, refusals, add-on log lines at WARNING and above, and the names and descriptions of options. It is enforced by `goodwe_controller/test_messages.py`. Kinds that have not been rewritten yet are on its allowlist, and that allowlist only shrinks.
## 1. Shape
- **Title**: at most 5 everyday words. It says what is wrong ("Meter reading may be stuck"). No "GoodWe:" prefix, no full stop.
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
- **Status row**: at most 15 words. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
- **Log line** (WARNING/ERROR): one line, at most 25 words. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated.
- **Sentences**: one idea each, active voice, present tense, with nothing nested in brackets and no semicolons (a semicolon is a second sentence in disguise). These translate well into Dutch and French.
## 2. Numbers
Use a number only if it helps the reader act, for example the peak limit, or what was asked and what was sent in a fault report. Write it the way `app/messages.py` does: `5 000 W`, `20 %`, `10 minutes`, `14:05`.
## 3. Severity
Severity is shown by the title and the colour, never by CAPITALS or by words like "BUG SIGNAL".
| Level | Colour | When |
|---|---|---|
| alarm | red | Control stopped, money or energy lost now, a fault, or a person must act now. Up to 3 sentences. |
| warning | orange | Control is still running, but something needs a look. 1 sentence. |
| info | grey | No action needed. 1 sentence. |
| resolved | green | An earlier alarm or warning has cleared. 1 sentence. |
A rewrite never changes an alarm's severity, `notification_id` or cadence.
## 4. Words - the same word for the same thing everywhere
| Say | Not |
|---|---|
| battery | pack, BMS pack |
| inverter, or its name | unit, device, actuator |
| controller | loop, planner |
| meter | P1 feed, telegram |
| peak limit / this month's peak | ceiling, capaciteitstarief, billed quarter |
| 15-minute period | quarter |
| battery level | SoC, state of charge |
| cut-out level | BMS floor, `bms_soc_floor` |
| peak reserve | `peak_soc_floor`, EMS floor |
| inverter rating | `max_w`, rail |
| control is stopped / every inverter is set to 0 W | failsafe, trip, latch, parked |
| software fault | BUG SIGNAL |
| control board | master, ESP32 |
| shadow mode | a mode that makes every decision but commands 0 W. The name is kept. |
**Banned in titles and bodies** (the test fails on them): SAFETY-/TARIFF-/TEL-/CFG-/FW-/HW-nn and other card ids, integrator, anti-windup, windup, clamp, clamped, saturated, BUG SIGNAL, latch, latched, arbiter, allocator, register, `0x…`, layer 1/2/3, heartbeat, watchdog, telegram, ACK, CRC, OTA, Modbus, planner, `§n`, file names, IP addresses, option keys in snake_case, **HA entity ids**, and runs of 2 or more CAPITALISED words.
HA entity ids are allowed only in refusals and validator messages, where the installer has to find that entity. They are never allowed in alarms or status rows.
+2 -2
View File
@@ -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 &amp; commissioning guide — v0.1.0</div></div>
<div class="sub">Field installation &amp; 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>&nbsp;/&nbsp;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>
+2 -2
View File
@@ -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 &amp; commissioning guide — v0.1.0</div></div>
<div class="sub">Field installation &amp; 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>&nbsp;/&nbsp;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,28 @@
# SAFETY-40: the integrator bound is not a bug
**Decision.** `i_clamped` (the integrator reaching `integrator_max_w`, i.e.
anti-windup) keeps alarming through the same `ClampWatchdog`: it is counted,
notified on the same cadence, and escalates on a sustained run
(SAFETY-41). It gets its own text, a `WARNING` and an amber status row. Only
`clamped` (a command over `max_w`) and write-path clamps keep the BUG SIGNAL,
the `ERROR` and the red row.
**Why it still notifies.** On a default install `clamped` is unreachable
(`control.py`, at the integrator bound), so the integrator bound is the only
loop-side signal of a runaway such as the 14 768 W one. Silencing it would
remove that. The card says the same: "the fix is to say the true thing, not to
say less."
**Why a bug has its own budget.** A genuine over-rating must not wait up to
`REASSERT_S` behind anti-windup notices. `ClampWatchdog` tracks a separate
`last_bug_alarm`, and a bug's text is never overwritten by later anti-windup
text within an episode. Across episodes the WARNING uses its own
notification id (`goodwe_integrator_bound`), so it cannot replace a BUG SIGNAL
notice still on screen (review of #130). With only bug engagements, the cadence is the same as
before (`test_clamp.py` part 9).
**Numbers.** They come from the `Decision` of the clamping tick (`i_bound_w`,
`requested_w`, `clamped_w`, `target_w`), not from `i_w` or `self.target`.
**Open for the owner.** Whether scattered one-cycle anti-windup episodes should
notify at all, or only log and count. They notify now, as before.
@@ -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.
@@ -0,0 +1,14 @@
# UX-08 phase 2a: short notifications, and what moved out of them
The owner decided on 2026-09-27: a title of at most about 5 everyday words and a one-sentence body. A red safety alarm may use up to 3 sentences, so no action-relevant information is lost. Titles are neutral, with no "GoodWe:" prefix. HA entity ids appear only in refusals. The rule is `STYLE.md` at the repository root, the texts are in `app/messages.py`, and `test_messages.py` enforces both. The detail each notification used to carry is in DOCS.md under "Notifications", and the body links to it.
**Unchanged:** every `notification_id`, every trigger and cadence (edges, SAFETY-34 re-announce, per-unit ids), every log line and every status row. The status banner and rows are phase 2b, refusals and option texts 2c, logs 2d; `ux08_allowlist.txt` lists them until then.
## Where a choice was made
1. **Severity of "not delivering" (SAFETY-42).** The notification is an *alarm*, so it may use 3 sentences. Its log line is ERROR and the alarm is latched. The page banner stays amber, as SAFETY-42 decided, because control keeps running; the two answer different questions (is control running / must a person act) and HA shows no colour on a notification. The three checks stay in their original order. Clearing it takes stop then start, and the body says so.
2. **Figures left the bodies** of the frozen-meter card (the rail and the command), the circulating-power card (the watts and the time window) and the peak cards (the projection). None of them is needed to act, and all of them are still in the reason text that the log line and the status row carry. `test_freeze.py` and `test_watchdog.py` now pin that. The frozen-meter card can therefore no longer quote a wrong rail (SAFETY-20), because it quotes none.
3. **The over-rating alarm keeps its numbers** (asked, sent, and which limit cut it), because they are what a fault report needs. `ClampWatchdog.facts` carries them under the same keep-the-bug rule as `reason`, so the words and the numbers always describe the same tick, with asked before sent (SAFETY-40, `test_clamp.py` part 6).
4. **SAFETY-25 stays in the peak-reserve alarm** as its last sentence ("the reserve only limits peak shaving … down to its cut-out level"). The alarm now gives each branch its own action: at the reserve, lower it; battery level unread, check the sensor; floor unknown, set the cut-out level. That last branch is `soc_floor is None`, which happens when an inverter's cut-out level is missing, not the peak reserve (that option always has a value). Before, every branch said "lower peak_soc_floor". The title, "Battery stopped limiting the peak", is true on all three branches.
5. **SAFETY-32 at a roster of one.** The body no longer ends in a separate "WHICH PACK" paragraph. The pack's name is part of the first sentence ("The battery of inverter-b …"), and at a roster of one that is the only difference.
6. **Dropped as background, now in DOCS:** "charging stays blocked" (peak reserve), "why no other guard fired" (circulating power, not delivering), and the saturation-freeze distinction (over-rating). "The meter cannot see this" stays in the circulating-power card, as the owner asked.
7. **The Details link** opens the add-on's documentation page (`/hassio/addon/<slug>/documentation`, with the slug taken from the container hostname) and names the section. A `#heading` anchor was not used because nobody has checked that Home Assistant's documentation tab renders heading ids; if it does, adding one is a one-line change. Outside an add-on container it names the section without a link.
+176 -32
View File
@@ -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
@@ -1625,7 +1630,9 @@ share no reason code, no notification id, no status row and no counter.
running - this is not the sign-disagreement failsafe, which commands 0 W. You
get a persistent notification (`goodwe_output_clamp`, replaced rather than
stacked), an `ERROR` in the log with the reason code `OUTPUT_CLAMP`, a row in
the event list, and a red status banner while it is engaged. Once it clears, a
the event list, and a red status banner while it is engaged. The message quotes
the command the loop computed and the value it was clamped to, both from the
cycle that clamped. Once it clears, a
warning row keeps the engagement count so the evidence does not vanish with the
condition. Consecutive engagements share one event row carrying the exact count,
because the event list keeps only 40 rows and a row per cycle would flush every
@@ -1634,9 +1641,11 @@ other guard's entries out of it.
**There is nothing to configure**, deliberately. The bound is `max_w`, which you
already set; giving the guard a limit of its own would be a second number that
can disagree with the first, which is exactly the failure the startup validation
exists to prevent. The alarm's cadence - first engagement, then re-asserted every
30 s while it persists, cleared after 3 clean cycles - is fixed for the same
reason `RAIL_FRAC` is: it describes the notification bus, not the plant.
exists to prevent. The alarm's cadence - normally at most one notification per
150 s (an episode that has not yet notified does so once it has engaged 3
cycles in a row, and an over-rating has its own 150 s budget), cleared after 3
clean cycles - is fixed for the same reason `RAIL_FRAC` is: it describes the
notification bus, not the plant.
WARNING: **on a default install the engagement you will see is the integrator's,
not the output's.** The control law bounds the integrator to `max_w` and *then*
@@ -1645,6 +1654,17 @@ rating unless `integrator_max_w` has been deliberately raised above `max_w`.
Both are watched. A guard that watched only the emitted value would be silent on
every standard configuration.
**The integrator bound is not a bug, and it is reported as what it is.** It
reads *"the loop's integrator reached its +5000 W anti-windup bound; the loop's
command this cycle was +2000 W"*, titled *"the control loop is at its integrator
bound"*, logged as a `WARNING`, with an amber (not red) status row and its own
notification (`goodwe_integrator_bound`), so it never replaces a BUG SIGNAL
notice still on screen. Brief
engagements after a large load or solar step are normal. If it stays engaged,
check that the battery is following its commands. Only a command over the rating
carries the BUG SIGNAL text, and it has its own notification budget.
Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md).
### The frozen-meter alarm
A meter that has died is easy — it stops answering, the sample age climbs past
@@ -2782,6 +2802,126 @@ it.
| `estop_fitted` | Whether the RS485 e-stop is installed. Drives the warning banner |
| `log_level` | `trace`/`debug`/`info`/`warning`/`error` |
## Notifications
Each Home Assistant notification this add-on raises has a short title, one
sentence (a red alarm may use three), and a **Details** link to the add-on
documentation that names the section below. This section holds what the
notification leaves out. The wording rule is `STYLE.md` in the repository; the
texts live in `app/messages.py`. Every notification keeps a fixed
`notification_id`, so a repeat replaces the card instead of adding one.
| Title | Severity | id |
|---|---|---|
| Battery is not keeping up | warning | `goodwe_integrator_bound` |
| Power request too high | alarm | `goodwe_output_clamp` |
| Load too high for battery | alarm | `goodwe_peak_saturated` |
| Battery stopped limiting the peak | alarm | `goodwe_peak_soc_floor` |
| Inverters working against each other | alarm | `goodwe_circulating_power` |
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` |
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` |
### Battery is not keeping up
The grid-following loop's integrator reached its anti-windup bound
(`integrator_max_w`): the controller has been asking for more than the battery
delivers for a while. This is SAFETY-40, and it is **not** a fault. No command
went over the rating and control keeps running. Short spells after a big load
or solar step are normal. If it keeps coming back, check that the battery
follows its commands. The bound and the command of that tick are in the add-on
log (WARNING, reason code `OUTPUT_CLAMP`). See *The winding-loop alarm* for the
full story.
### Power request too high
The controller computed a command above a limit, and the command was cut to that
limit before it was written. The notification names what was asked, what was
sent, and which limit cut it: the configured inverter rating (`max_w`), the
inverter's own limit (the setpoint device's range), or the inverter rating on
the loop side. Asked always comes first. This is a **software fault** (the old
wording was "BUG SIGNAL"). It is not the inverter failing to follow a legal
command, which is the saturation freeze and does not alarm. Control keeps
running. Report it with the add-on log, whose ERROR line carries the reason code
`OUTPUT_CLAMP` and the full figures. See *The winding-loop alarm*.
### Load too high for battery
The capacity-peak guard (SAFETY-07) is asking for everything the inverter can
discharge, and the running 15-minute average is still projected over the peak
limit. That 15-minute period will set or raise the month's billed peak
(capaciteitstarief). The controller is not at fault: the house load is larger
than the inverter. Switching off load in the next few minutes is the only thing
that still changes the bill. The projection and the watts are in the add-on log
and on the status page. See *The capacity-peak guard*.
### Battery stopped limiting the peak
Two words in this notification are settings. The **peak reserve** is
*Peak guard will not discharge below (%)* (`peak_soc_floor`). The
**cut-out level** is *Battery cut-out level (%)* (`bms_soc_floor`), where the
battery's own system stops discharging.
The peak guard wanted to discharge to hold the 15-minute peak, but it may not go
below the peak reserve (`peak_soc_floor`, SAFETY-24). It also stops when the
battery level cannot be read, or when an inverter's cut-out level is not set,
because the reserve is graded against it. So this 15-minute
period will go over the peak limit and set or raise the month's billed peak.
Charging from the grid stays blocked meanwhile. On a site with several inverters
the notification names the battery at its limit (SAFETY-32): each battery is
graded against its own cut-out level (`bms_soc_floor`), and raising the other
batteries' settings will not change this one.
⚠️ **The reserve binds the peak guard only (SAFETY-25).** It is not a
whole-battery reserve. Ordinary self-consumption has no such floor and goes on
discharging below it, down to the battery's own cut-out level. See *What
`peak_soc_floor` does not protect*.
### Inverters working against each other
Two inverters were pushing power into each other: one discharging while another
charged (SAFETY-31). **The meter cannot see this.** The two flows cancel at the
P1 meter, so the site reads idle while both batteries cycle through two
conversion stages. Every other guard reads that net figure, so none of them
fires. The add-on has **stopped control and commands 0 W on every inverter**.
If the grid reading does not move after that, one inverter is no longer
listening to the add-on; this has happened on this hardware, once for
6 h 25 min. Switch the inverters off at the isolator. The alarm is latched: a
Home Assistant or add-on restart does not clear it, and the card is raised
again every 5 min until you start control on the add-on page. The watts and the time
window are in the add-on log and on the status page. See *The circulating-power
alarm*.
### An inverter is not delivering
One inverter has stood well short of its share of the site target for longer
than the hold time (SAFETY-42), while the others have not. No other check sees
this. A unit at 0 W is absent rather than pushing the wrong way, so the per-unit
sign check and the circulation check stay quiet. **Nothing is commanded
differently.** The guard only reports: commanding an inverter that has stopped
listening achieves nothing, and the only fallback the add-on has is
all-or-nothing. The other inverters keep regulating, and the site is short by up
to the missing unit's rating. Check, in this order:
1. Is the inverter answering on RS485 at all?
2. Is its battery colder or emptier than its configured cut-out level
(`bms_soc_floor`) says? If so, that setting does not describe this battery.
3. Is anything else writing to it?
The alarm is latched and survives restarts. Control is still running, so to
clear it, press *Start / stop control* twice (stop, then start) once you know
why. See *A unit that stops DELIVERING is a third fault again*.
### Meter reading may be stuck
The meter keeps answering, but its value has not changed for longer than the
hold time while the command stands at the power limit (SAFETY-18). The loop is
regulating against a number that has stopped moving. The age is still fresh, so
no other guard stops it. This is an alarm only: control keeps running and keeps
following the reading. Held for a whole 15-minute period, it can set the month's
billed peak. Check the meter; if it is stuck, stop control on the add-on page.
The limit and command figures are in the add-on log and on the status page. See
*The frozen-meter alarm*.
## The Web UI
The ingress panel shows live values, why the controller is commanding what it
@@ -2820,8 +2960,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 +2983,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 +2998,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 +3147,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
+71 -20
View File
@@ -73,6 +73,7 @@ An alarm wired only to this module's `ClampResult.engaged` would therefore be
silent on the exact configuration the whole ticket is about. The winding is
visible one layer up, at the integrator bound, and that is why `Decision` now
carries `i_clamped` / `clamped` BOOLEANS and the caller ORs them in here.
They alarm in different words: see `loop_clamp_note()` (SAFETY-40).
AND WHY BOOLEANS RATHER THAN `Decision.reason`. `reason` is a single string that
later stages overwrite: a cycle that clamps the integrator and is then slew
@@ -200,9 +201,8 @@ REASON_CODE = "OUTPUT_CLAMP"
# ceiling rests on one number a test author chose; if that number is ever
# revisited, this constant moves with it.
#
# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still
# calls the integrator bound a BUG SIGNAL and still pairs a request from one
# moment with a `self.target` read at another - SAFETY-40. Nothing here is
# ⚠️ THIS WAS A STOP-GAP; the wording and the numbers were SAFETY-40's, fixed
# in `loop_clamp_note()` below. Nothing here is
# suppressed: every engagement is still counted, the action-log row is written on
# every engaged cycle regardless, and the rising edge of every episode alarms
# unconditionally, so no alarm is DROPPED - only delayed.
@@ -234,6 +234,31 @@ CLEAR_CYCLES = 3
SUSTAINED_CYCLES = 3
BUG_SIGNAL = ("This is a BUG SIGNAL, not a limit being used: something "
"upstream computed a setpoint the actuator cannot deliver.")
def loop_clamp_note(d) -> str:
"""The loop-side alarm text for one `control.Decision`, or "".
SAFETY-40: the integrator bound is anti-windup doing its job and says so;
only a command over the rating is a BUG SIGNAL. Every number is the
decision's own, i.e. from the tick that clamped.
"""
if d.clamped:
return (f"{REASON_CODE}: the loop computed {d.requested_w:+.0f} W, over "
f"the {abs(d.clamped_w):.0f} W rating, and it was clamped to "
f"{d.clamped_w:+.0f} W; the loop's command this cycle was "
f"{d.target_w:+.0f} W. {BUG_SIGNAL}")
if d.i_clamped:
return (f"{REASON_CODE}: the loop's integrator reached its "
f"{d.i_bound_w:+.0f} W anti-windup bound; the loop's command "
f"this cycle was {d.target_w:+.0f} W. Brief "
f"engagements after a large load or solar step are normal. If it "
f"persists, check that the battery is following its commands.")
return ""
@dataclass(frozen=True)
class ClampResult:
"""What the guard did, with the RAW value preserved.
@@ -337,28 +362,60 @@ class ClampWatchdog:
self.since: datetime | None = None
self.last_alarm: datetime | None = None
self.reason = ""
# UX-08: the numbers behind `reason`, for the notification - set under
# the same rule as `reason`, so both always describe the same tick.
self.facts = ()
self.clean_cycles = 0
# SAFETY-41. Consecutive engaged cycles, and whether THIS episode has
# spoken yet - the two the escalation clause needs.
self.run_len = 0
self.spoken = False
# SAFETY-40. Did this episode include a genuine over-rating (`bug`), and
# has THAT spoken? A bug has its own budget, so anti-windup notices can
# never delay it; with bug=True throughout, cadence is as before.
self.bug = False
self.bug_spoken = False
self.last_bug_alarm: datetime | None = None
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
def _due(self, last, now) -> bool:
return last is None or (now - last).total_seconds() >= REASSERT_S
def _speak(self, now, bug) -> bool:
self.last_alarm = now
self.spoken = True
if bug:
self.last_bug_alarm = now
self.bug_spoken = True
return True
def note(self, engaged: bool, reason: str, now: datetime,
bug: bool = True, facts=()) -> bool:
"""Advance one cycle. True when the caller should raise the alarm.
`now` is the caller's clock, the same discipline as
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`.
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`. `bug` False
is the integrator bound (SAFETY-40): counted and notified like the
rest, but it never replaces a bug's text within an episode.
"""
if engaged:
self.engagements += 1
self.clean_cycles = 0
self.reason = reason
self.run_len += 1
if not self.active:
rising = not self.active
if rising:
self.active = True
self.since = now
self.episodes += 1
self.spoken = False
self.bug = False
self.bug_spoken = False
if bug or not self.bug:
self.reason = reason
self.facts = tuple(facts)
self.bug = self.bug or bug
if bug and not self.bug_spoken and self._due(self.last_bug_alarm, now):
return self._speak(now, True)
if rising:
# ⚠️ SAFETY-41. THE RISING EDGE IS RATE-LIMITED TOO, and it did
# not used to be. It fired unconditionally, so a condition that
# engages and clears repeatedly raised one notification PER
@@ -384,11 +441,8 @@ class ClampWatchdog:
# still written on every engaged cycle, and a genuine runaway -
# which by definition keeps engaging - is picked up by the
# re-assert path within REASSERT_S. Delay, not loss.
if (self.last_alarm is None
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
self.last_alarm = now
self.spoken = True
return True
if self._due(self.last_alarm, now):
return self._speak(now, self.bug)
return False
# ⚠️ THE ESCALATION. An episode that has not yet spoken because the
# budget was spent by an EARLIER, unrelated episode speaks anyway
@@ -396,15 +450,10 @@ class ClampWatchdog:
# engages every cycle and reaches it; the scattered one-cycle
# railing that produced the live flood never does.
if not self.spoken and self.run_len >= SUSTAINED_CYCLES:
self.last_alarm = now
self.spoken = True
return True
return self._speak(now, self.bug)
# Still engaged: re-assert on the slow cadence, not every cycle.
if (self.last_alarm is None
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
self.last_alarm = now
self.spoken = True
return True
if self._due(self.last_alarm, now):
return self._speak(now, self.bug)
return False
self.run_len = 0
@@ -420,5 +469,7 @@ class ClampWatchdog:
# above. The episode ends; the notification budget does not reset
# with it.
self.reason = ""
self.facts = ()
self.bug = False
self.clean_cycles = 0
return False
+10 -1
View File
@@ -340,6 +340,12 @@ class Decision:
# the comment at the bound below, and clamp.py.
i_clamped: bool = False
clamped: bool = False
# SAFETY-40. The alarm's numbers, taken at the clamp so they are one tick's:
# the bound the integrator reached, and the command before/after the
# rating clamp. `target_w` is this tick's emitted command.
i_bound_w: float = 0.0
requested_w: float = 0.0
clamped_w: float = 0.0
def slew_limit_w(tuning: Tuning) -> float:
@@ -563,6 +569,7 @@ def compute(
# winding, "clamped" is a command that came out over the rating anyway.
reason = "i-clamped"
i_w = bounded
i_bound_w = bounded if i_clamped else 0.0
want = i_w
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
@@ -574,6 +581,7 @@ def compute(
# An early draft applied a floor after the clamp and let demand escape it.
target = max(-tuning.max_w, min(tuning.max_w, want))
clamped = target != want
clamped_w = target
if clamped:
reason = "clamped"
@@ -740,7 +748,8 @@ def compute(
target = round(target / step) * step
return Decision(float(target), sat_count, frozen, reason, float(i_w),
i_clamped, clamped)
i_clamped, clamped, float(i_bound_w), float(want),
float(clamped_w))
def maintenance_charge_floor(
+135 -155
View File
@@ -49,9 +49,11 @@ from .circulation import (
circulating_w, opposing,
)
from .clamp import (
REASON_CODE as CLAMP_REASON, ClampWatchdog, clamp_to_rating, is_commandable,
BUG_SIGNAL as CLAMP_BUG_SIGNAL, REASON_CODE as CLAMP_REASON, ClampWatchdog,
clamp_to_rating, is_commandable, loop_clamp_note,
)
from . import freshness
from . import messages
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
)
@@ -113,6 +115,12 @@ __all__ = ["UNIT_A", "Controller", "amain", "main"]
# the re-issue happens by itself) than hold the control loop for ten seconds.
WRITE_BUDGET_S = 4.0
# SAFETY-34: a Core-only restart wipes persistent_notification and does NOT
# restart this add-on (measured on TEST-23), so a latched alarm is re-asserted
# this often. Why: goodwe docs/DECISIONS.md, SAFETY-34.
REANNOUNCE_S = 300.0
_mono = time.monotonic # patched by the checks; asyncio keeps the real one
# --------------------------------------------------------------------------- #
# OPT-04: THE BACKGROUND TARIFF REFRESH, AND WHY IT IS NOT OPTIONAL
#
@@ -691,6 +699,9 @@ class Controller:
# does, so the latch alone is not the alarm - re-raising it is.
# `run_control()` does that, once, before the first cycle.
self._circ_announced = False
# SAFETY-34: when each latched alarm was last put in front of HA, by
# notification_id - see REANNOUNCE_S.
self._announced_mono = {}
if self.circ_wd.tripped:
_LOG.error(
"SAFETY-31 was TRIPPED before this restart and stays tripped: "
@@ -985,7 +996,10 @@ class Controller:
# the counterpart to `clamp_last` moving onto the Inverter: the integrator
# that wound is the SITE integrator, of which there is exactly one, while
# the value a clamp bit is a value written to ONE device.
self.loop_clamped: tuple[bool, str] = (False, "")
# (engaged, note, over_rating) - SAFETY-40 splits the last one out.
self.loop_clamped: tuple[bool, str, bool] = (False, "", False)
# UX-08: the numbers of a loop-side over-rating, for the notification.
self.loop_clamp_facts: tuple = ()
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
@@ -1726,7 +1740,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.
@@ -3262,12 +3276,16 @@ class Controller:
# it - so a string test is silent on the two cases a winding loop
# most often presents as. These flags are set where the clamp
# happens and nothing downstream touches them.
# SAFETY-40: every number comes from THIS decision, at clamp time,
# never from `self.target` after slew/freeze/arbiter rewrote it.
self.loop_clamped = (
decision.i_clamped or decision.clamped,
(f"the loop asked for {decision.i_w:+.0f} W"
+ (" (integrator bound)" if decision.i_clamped else "")
+ (" (output bound)" if decision.clamped else "")),
loop_clamp_note(decision),
decision.clamped,
)
self.loop_clamp_facts = (
(messages.Clamp(None, decision.requested_w, decision.clamped_w,
"rating"),) if decision.clamped else ())
# SITE. compute() regulates `grid_w` - the P1 measurement - to
# zero. Its subject is the meter, and §6.2's architecture is ONE
# integrator on P1 whose output is then SPLIT. This is the claim the
@@ -3290,7 +3308,8 @@ class Controller:
# A loop that is not running cannot be winding. Clearing this here
# rather than letting the last value stand is what makes the alarm
# de-assert when control is stopped.
self.loop_clamped = (False, "")
self.loop_clamped = (False, "", False)
self.loop_clamp_facts = ()
# SAFETY-28. A new resolution is a new tick, and the previous record is
# stale from this line onwards. Incremented BEFORE resolve() so that the
@@ -3707,20 +3726,24 @@ class Controller:
clamped_units = [inv for inv in self.inverters
if inv.clamp_last is not None and inv.clamp_last.engaged]
engaged_write = bool(clamped_units)
engaged_loop, loop_note = self.loop_clamped
engaged_loop, loop_note, loop_bug = self.loop_clamped
engaged = engaged_write or engaged_loop
if engaged:
where = (", ".join(f"{inv.name}: {inv.clamp_last.describe()}"
for inv in clamped_units) if engaged_write
else f"{CLAMP_REASON}: {loop_note}, above the "
f"{self.site_max_w:.0f} W rating, and "
f"was clamped to {self.target:+.0f} W")
reason = (f"{where}. This is a BUG SIGNAL, not a limit being used: "
f"something upstream computed a setpoint the actuator "
f"cannot deliver.")
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
bug = engaged_write or loop_bug
if engaged_write:
reason = (", ".join(f"{inv.name}: {inv.clamp_last.describe()}"
for inv in clamped_units) + ". " + CLAMP_BUG_SIGNAL)
_named = len(self.inverters) > 1
facts = [messages.Clamp(inv.name if _named else None,
inv.clamp_last.requested_w,
inv.clamp_last.applied_w,
inv.clamp_last.bound)
for inv in clamped_units]
else:
reason = ""
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc))
reason = loop_note if engaged else ""
facts = self.loop_clamp_facts if engaged else ()
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc),
bug=bug, facts=facts)
# --- THE ACTION LOG, AND IT IS COALESCED PER EPISODE ------------------
# Every engagement is recorded, but consecutive ones are recorded by
@@ -3751,6 +3774,15 @@ class Controller:
self.log_event(row)
if not fired:
return
if not self.clamp_wd.bug:
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
# it can never replace a BUG SIGNAL notice still on screen.
_LOG.warning("SAFETY-03 - %s Engagements so far: %d.",
self.clamp_wd.reason, self.clamp_wd.engagements)
await self.hass.call("persistent_notification", "create",
messages.loop_bound().data())
return
_LOG.error(
"SAFETY-03 - %s Silently clipping this is how a winding integrator "
"reached 14 768 W on this project before: the clamp makes every "
@@ -3761,16 +3793,10 @@ class Controller:
# A fixed notification_id, for the reason check_meter_frozen() gives -
# and a DIFFERENT one from every other alarm this add-on raises, because
# a clamp is not a frozen meter and neither is a saturation freeze.
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_output_clamp",
"title": "GoodWe: a computed setpoint exceeded the rating",
"message": (
f"{self.clamp_wd.reason}\n\nThe command has been clamped and "
f"the controller is STILL RUNNING. This is NOT the inverter "
f"failing to follow a legal command (that is the saturation "
f"freeze, which is normal and does not alarm) - the value asked "
f"for was itself out of range."),
})
# UX-08: the words are messages.output_clamp()'s; the numbers are the
# ones `reason` was written from (ClampWatchdog.facts), asked first.
await self.hass.call("persistent_notification", "create",
messages.output_clamp(self.clamp_wd.facts).data())
async def check_peak_saturated(self) -> None:
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter.
@@ -3812,18 +3838,8 @@ class Controller:
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
# is not a frozen meter and is not an out-of-range setpoint.
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_peak_saturated",
"title": "GoodWe: the capacity-peak guard has run out of inverter",
"message": (
f"{g.reason}.\n\nThe battery is being commanded to everything "
f"it can give and the quarter is STILL projected over the "
f"{g.ceiling_w:.0f} W ceiling, so this quarter will set or raise "
f"the month's billed capaciteitstarief peak. This is not a "
f"controller fault - the load is simply larger than the "
f"inverter. Shedding load in the next few minutes is the only "
f"thing that changes the bill."),
})
await self.hass.call("persistent_notification", "create",
messages.peak_saturated(g.ceiling_w).data())
async def check_peak_floor_limited(self) -> None:
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving.
@@ -3888,45 +3904,23 @@ class Controller:
# think the pack is AT it. `decide()` guards its own reason string the
# same way; a f-string that formats a None here would turn the one
# notification that matters into a traceback.
_at_floor = ("at the floor" if g.soc_floor is None
else f"at {float(g.soc_floor):.0f} %")
# ⚠️ SAFETY-32. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor
# the decision was actually taken with, so this re-grades nothing and
# cannot disagree with the alarm it is printed inside. At a roster of one
# the sentence is omitted entirely and this notification is byte-for-byte
# what SAFETY-25 shipped - there is exactly one pack and naming it would
# be noise on every existing install.
# no pack is named - there is exactly one.
_blocked = [name for name, why
in peakguard.pack_permissions(self.packs, g.soc_floor)
if why is not None]
_which = ("" if len(self.inverters) < 2 else
f"\n\nSAFETY-32 - WHICH PACK: this is graded per pack against "
f"that pack's own BMS cut-out (bms_soc_floor), and the "
f"pack(s) at or below their own floor right now are "
f"{', '.join(_blocked) if _blocked else '(none - see the reason above)'}"
f". The other packs on the roster are not the reason the guard "
f"stopped and raising their cut-out will not change this.")
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_peak_soc_floor",
"title": "GoodWe: the capacity-peak guard has run out of battery",
"message": (
f"{g.reason}.\n\nTHIS QUARTER WILL GO OVER THE "
f"{g.ceiling_w:.0f} W ceiling and will set or raise the "
f"month's billed capaciteitstarief peak, because the peak guard "
f"is not allowed to discharge any further: {g.soc_floor_reason}."
f" This is not a controller fault - it is the EMS-side energy "
f"floor (peak_soc_floor) doing what it is set to do, and it sits "
f"deliberately above the pack's own BMS cut-out. Charging stays "
f"blocked. Shedding load is the only thing that changes the bill "
f"now; if this pack can afford a deeper discharge, lower "
f"peak_soc_floor.\n\nSAFETY-25 - WHAT THIS FLOOR IS NOT: "
f"peak_soc_floor bounds THE PEAK GUARD ONLY. It is not a "
f"pack-wide reserve, and the battery is NOT being held "
f"{_at_floor} - ordinary self-consumption has no SoC "
f"floor at all and will go on discharging past it, down to the "
f"pack's own BMS cut-out. What has stopped is the guard buying "
f"you a lower bill, not the pack being spent." + _which),
})
# ⚠️ SAFETY-25's sentence (the reserve binds the peak guard only) is
# part of messages.peak_reserve() on every branch, the unreadable-SoC
# one included.
await self.hass.call("persistent_notification", "create",
messages.peak_reserve(
g.ceiling_w, soc_unread=g.soc is None,
floor_unknown=(g.soc is not None
and g.soc_floor is None),
packs=(_blocked if len(self.inverters) > 1
else ())).data())
async def check_circulating(self) -> None:
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart.
@@ -3943,15 +3937,17 @@ class Controller:
The latch says "this site is tripped"; the flag says "this process has
put that in front of a human". A restart clears the flag and keeps the
latch, so the notification is RE-RAISED on the first cycle after every
add-on start - which is also the first cycle after a Home Assistant
restart, because the add-on is restarted with it.
add-on start. A Core-only restart does NOT restart the add-on, so it is
also re-asserted every REANNOUNCE_S (SAFETY-34).
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE. A fresh trip and a restart into
an existing trip raise the alarm through these same lines. Two paths
would be two chances for the restart one to rot unnoticed, and the
restart one is the case nobody exercises by hand.
"""
if not self.circ_wd.tripped or self._circ_announced:
if not self.circ_wd.tripped:
return
if self._circ_announced and not self._reannounce_due("goodwe_circulating_power"):
return
# Set BEFORE the await. `hass.call()` can raise or hang, and a retry
# every cycle for a broker that is down would be an alarm storm on the
@@ -3959,39 +3955,22 @@ class Controller:
# already durable and the log line above already fired; this surface is
# best-effort by design.
self._circ_announced = True
self._announced_mono["goodwe_circulating_power"] = _mono()
names = self.units
pos, neg = opposing([inv.batt for inv in self.inverters],
min_w=self.circ_wd.cfg.min_w / 2.0)
who = ""
if pos and neg:
who = ("\n\nRight now " + ", ".join(names[i] for i in pos)
+ " is discharging while " + ", ".join(names[i] for i in neg)
+ " charges.")
# A fixed notification_id, and a DIFFERENT one from every other alarm
# this add-on raises - check_meter_frozen() gives the reasoning. A
# circulating loop is not a frozen meter, is not an out-of-range
# setpoint and is not a saturated peak guard.
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_circulating_power",
"title": "GoodWe: the inverters are pushing against each other",
"message": (
f"{self.circ_wd.reason}{who}\n\nTHE METER CANNOT SEE THIS. "
f"Circulating power nets to zero at P1, so the site reads idle "
f"while the packs are cycled through two conversion stages "
f"continuously. Every other guard in this add-on is a consumer "
f"of that net figure, which is why none of them fired.\n\n"
f"CONTROL HAS BEEN STOPPED - every inverter is commanded 0 W "
f"and held there. That removes this controller as one of the "
f"two participants, and it makes anything still happening "
f"VISIBLE at the meter instead of cancelled at it.\n\n"
f"⚠️ IF THE GRID READING DOES NOT MOVE AFTER THIS, one inverter "
f"is no longer listening to this add-on and no command will "
f"reach it - that has happened twice on this hardware, once for "
f"6 h 25 m. The remaining actuator is a human at the isolator.\n"
f"\nThis alarm survives a Home Assistant restart. Restarting "
f"will not clear it; start control from the add-on UI once you "
f"know why two inverters were opposing."),
})
# this add-on raises - check_meter_frozen() gives the reasoning.
await self.hass.call("persistent_notification", "create",
messages.circulating(
[names[i] for i in pos] if pos and neg else (),
[names[i] for i in neg] if pos and neg else (),
).data())
def _reannounce_due(self, notification_id: str) -> bool:
"""SAFETY-34: has this latched alarm gone REANNOUNCE_S without being re-asserted?"""
t = self._announced_mono.get(notification_id)
return t is None or _mono() - t >= REANNOUNCE_S
async def check_not_delivering(self) -> None:
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
@@ -4012,8 +3991,8 @@ class Controller:
not persisted. The latch says "this unit is tripped"; membership says
"this process has put that in front of a human". A restart empties the
set and keeps the latch, so the notification is RE-RAISED on the first
cycle after every add-on start - which is also the first cycle after a
Home Assistant restart, because the add-on is restarted with it.
cycle after every add-on start, and re-asserted every REANNOUNCE_S
because a Core-only restart does not restart the add-on (SAFETY-34).
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE - `check_circulating()`'s
argument. A fresh trip and a restart into an existing trip raise the
@@ -4031,7 +4010,10 @@ class Controller:
names = self.units
slugs = unit_slugs(names)
for inv, slug in zip(self.inverters, slugs):
if not inv.delivery_wd.tripped or inv.name in self._delivery_announced:
nid = f"goodwe_not_delivering_{slug}"
if not inv.delivery_wd.tripped:
continue
if inv.name in self._delivery_announced and not self._reannounce_due(nid):
continue
# Added BEFORE the await, for `check_circulating()`'s reason:
# `hass.call()` can raise or hang, and a retry every cycle for a
@@ -4039,38 +4021,9 @@ class Controller:
# latch is already durable and the log line already fired; this
# surface is best-effort by design.
self._delivery_announced.add(inv.name)
peers = ", ".join(n for n in names if n != inv.name) or "no sibling"
await self.hass.call("persistent_notification", "create", {
"notification_id": f"goodwe_not_delivering_{slug}",
"title": f"GoodWe: {inv.name} is not delivering its share",
"message": (
f"{inv.delivery_wd.reason}\n\nWHY NOTHING ELSE SAID SO. A "
f"unit sitting at 0 W against a non-zero share is not "
f"pushing the wrong way, it is ABSENT: the per-unit sign "
f"check needs opposition on both sides of "
f"{self.unit_sign_cfg.min_w:.0f} W and never opens, the "
f"site sum still agrees with the meter in "
f"direction, and circulating power is exactly zero when one "
f"unit does nothing. The only other trace is a saturation "
f"freeze, which this add-on's own code calls an alarm "
f"operators learn to ignore.\n\nNOTHING HAS BEEN COMMANDED "
f"DIFFERENTLY. This guard reports and does not act. "
f"Commanding an inverter that has stopped listening "
f"achieves nothing - that has happened on this hardware, "
f"once for 6 h 25 m - and the only re-allocation this "
f"add-on can express is all-or-nothing, which would throw "
f"away the output of a unit that is delivering PART of its "
f"share. So {peers} is still regulating and the site is "
f"short by up to {inv.rating_w():.0f} W until somebody "
f"looks at {inv.name}.\n\nWHAT TO CHECK, in order: is "
f"{inv.name} answering on RS485 at all; is its pack colder "
f"or emptier than its configured bms_soc_floor says (if it "
f"is, that setting does not describe this battery and this "
f"alarm is telling you so); and is anything else writing to "
f"it.\n\nThis alarm survives a Home Assistant restart. "
f"Restarting will not clear it; start control from the "
f"add-on UI once you know why."),
})
self._announced_mono[nid] = _mono()
await self.hass.call("persistent_notification", "create",
messages.not_delivering(inv.name, slug).data())
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
@@ -4108,14 +4061,8 @@ class Controller:
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
# rather than stacking. An operator who has to dismiss eleven identical
# notifications learns to dismiss the twelfth without reading it.
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_meter_frozen",
"title": "GoodWe: the P1 meter may be frozen",
"message": (
f"{self.freeze_wd.reason}.\n\nThe controller is STILL RUNNING "
f"and still following this reading. Check the meter; if it is "
f"stuck, stop control from the add-on UI."),
})
await self.hass.call("persistent_notification", "create",
messages.meter_frozen(unchanged).data())
# -- tasks ---------------------------------------------------------------
# -- OPT-04: the background tariff refresh -------------------------------
@@ -4872,16 +4819,21 @@ class Controller:
out.append({"ok": False, "warn": "NOT COMMANDABLE" not in note,
"text": note})
# SAFETY-03. Red while a clamp is engaged, and the engagement COUNT is
# shown even after it clears - a loop that wound once and stopped is
# still a bug, and a row that vanishes with the condition takes the
# SAFETY-03. Red while an over-rating clamp is engaged (amber for the
# integrator bound, SAFETY-40), and the engagement COUNT is shown even
# after it clears - a row that vanishes with the condition takes the
# evidence with it. Reported only when there is something to report:
# unlike the freeze detector this guard has no "not covered" state, it
# runs on every write on every transport.
if self.clamp_wd.active:
if self.clamp_wd.active and self.clamp_wd.bug:
out.append({"ok": False, "warn": False,
"text": "OUTPUT CLAMP ENGAGED - " + self.clamp_wd.reason
+ f" Engagements: {self.clamp_wd.engagements}."})
elif self.clamp_wd.active:
# SAFETY-40: the integrator bound alone is amber, not red.
out.append({"ok": False, "warn": True,
"text": "LOOP AT INTEGRATOR BOUND - " + self.clamp_wd.reason
+ f" Engagements: {self.clamp_wd.engagements}."})
elif self.clamp_wd.engagements:
out.append({"ok": False, "warn": True,
"text": f"output clamp: not engaged now, but it has "
@@ -5075,6 +5027,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,
@@ -5089,7 +5048,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
@@ -5104,7 +5063,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:
@@ -5117,6 +5076,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
@@ -5136,6 +5096,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:
+250
View File
@@ -0,0 +1,250 @@
"""Every Home Assistant notification this add-on raises, in plain words (UX-08).
The rule is `STYLE.md` at the repository root; `test_messages.py` enforces it.
Titles, bodies and severities live here and nowhere else, so `main.py` only
decides WHEN to raise a notice and this file decides WHAT it says. The detail
that used to fill the bodies is in DOCS.md, under "Notifications", one section
per title; the body ends with a link to it.
"""
import os
import re
from dataclasses import dataclass
# Severity (STYLE.md section 3). An ALARM may use up to three sentences so
# that no action a person must take is lost; everything else gets one.
ALARM, WARNING, INFO, RESOLVED = "alarm", "warning", "info", "resolved"
DOCS_SECTION = "Notifications"
def watts(value) -> str:
"""`7412` -> `7 412 W`: a magnitude, grouped for reading, no sign."""
return f"{abs(float(value)):,.0f} W".replace(",", " ")
def duration(seconds) -> str:
s = float(seconds)
if s < 120:
return f"{s:.0f} seconds"
return f"{s / 60:.0f} minutes"
def names(items) -> str:
items = list(items)
if len(items) <= 1:
return "".join(items)
return ", ".join(items[:-1]) + " and " + items[-1]
def addon_slug() -> str | None:
"""This add-on's Supervisor slug, from the container hostname.
The Supervisor names an add-on's host after its slug with `_` spelled `-`
(`local_goodwe_controller` -> `local-goodwe-controller`). Anything else,
e.g. a test run, gets no link rather than a wrong one.
"""
host = os.environ.get("HOSTNAME", "")
if re.fullmatch(r"[a-z0-9]+-goodwe-controller", host):
return host.replace("-", "_")
return None
def details(title: str) -> str:
where = f"section “{title}” under {DOCS_SECTION}"
slug = addon_slug()
if slug is None:
return f"Details: add-on documentation, {where}."
return (f"Details: [add-on documentation](/hassio/addon/{slug}/documentation),"
f" {where}.")
@dataclass(frozen=True)
class Notice:
id: str
severity: str
title: str
body: str
section: str = "" # the DOCS.md heading, when it is not the title itself
@property
def docs(self) -> str:
return self.section or self.title
def data(self) -> dict:
"""The `persistent_notification.create` payload."""
return {"notification_id": self.id, "title": self.title,
"message": f"{self.body}\n\n{details(self.docs)}"}
# --- the catalogue ----------------------------------------------------------
# Each title below is also a DOCS.md heading (test_messages.py checks it).
T_LOOP_BOUND = "Battery is not keeping up"
T_OUTPUT_CLAMP = "Power request too high"
T_PEAK_SATURATED = "Load too high for battery"
T_PEAK_RESERVE = "Battery stopped limiting the peak"
T_CIRCULATING = "Inverters working against each other"
T_NOT_DELIVERING = "{unit} is not delivering"
S_NOT_DELIVERING = "An inverter is not delivering"
T_METER_FROZEN = "Meter reading may be stuck"
ID_LOOP_BOUND = "goodwe_integrator_bound"
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
ID_PEAK_SATURATED = "goodwe_peak_saturated"
ID_PEAK_RESERVE = "goodwe_peak_soc_floor"
ID_CIRCULATING = "goodwe_circulating_power"
ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
ID_METER_FROZEN = "goodwe_meter_frozen"
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
LIMIT_WORDS = {
"max_w": "the configured inverter rating",
"device": "the inverter's own limit",
"max_w+device": "the configured inverter rating and the inverter's own limit",
"rating": "the inverter rating",
}
def loop_bound() -> Notice:
"""SAFETY-40: the integrator bound. Normal in short bursts; not a fault."""
return Notice(ID_LOOP_BOUND, WARNING, T_LOOP_BOUND,
"The battery cannot give what the controller asks, which is "
"normal for a moment but needs a check if it keeps happening.")
@dataclass(frozen=True)
class Clamp:
"""One command that was cut: what was asked, what was sent, and by what."""
unit: str | None
asked_w: float
sent_w: float
limit: str # a key of LIMIT_WORDS
def output_clamp(clamps) -> Notice:
"""SAFETY-03: a computed command above the limit. A software fault.
Asked comes before sent, always - reading them the other way round turns
the alarm backwards (test_clamp.py part 6).
"""
if not clamps:
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
"The controller asked for more power than the inverter "
"rating and was cut back. Control is still running. This "
"is a software fault, so please report it to your "
"installer.")
c = clamps[0]
who = f"For {c.unit}, the" if c.unit else "The"
others = [x.unit for x in clamps[1:] if x.unit]
running = (f"{names(others)} {'was' if len(others) == 1 else 'were'} cut "
f"too, and control is still running." if others
else "Control is still running.")
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
f"{who} controller asked for {watts(c.asked_w)}, above "
f"{LIMIT_WORDS.get(c.limit, LIMIT_WORDS['rating'])}, and sent "
f"{watts(c.sent_w)} instead. {running} "
f"This is a software fault, so please report it to your "
f"installer.")
def peak_saturated(ceiling_w) -> Notice:
"""SAFETY-07: the battery gives all it can and the peak still goes up."""
return Notice(ID_PEAK_SATURATED, ALARM, T_PEAK_SATURATED,
f"The battery is giving all it can, but this 15-minute period "
f"will still go over your {watts(ceiling_w)} peak limit and "
f"raise this month's peak. Switch off large loads in the next "
f"few minutes. This is not a fault: the load is bigger than "
f"the inverter.")
def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
packs=()) -> Notice:
"""SAFETY-24/25/32: the peak guard may not discharge any further.
The last sentence is SAFETY-25 and must stay: the reserve binds the peak
guard only, and normal home use goes on discharging below it.
`packs` names the batteries at their limit, only on a site with several.
`floor_unknown`: the floor the guard grades against is None, which at a
roster means some inverter's cut-out level is missing (the peak reserve
itself always has a value) - so the action names the cut-out level.
"""
if soc_unread:
why = "The battery level cannot be read"
act = "Switch off large loads, and check the battery level sensor."
elif floor_unknown:
why = "The cut-out level of a battery is not known"
act = ("Switch off large loads, and set each battery's cut-out level "
"in the settings.")
else:
why = (f"The battery of {names(packs)} is at its peak reserve" if packs
else "The battery is at its peak reserve")
act = ("Switch off large loads, or lower the peak reserve if the "
"battery allows it.")
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
f"{why}, so this 15-minute period will go over your "
f"{watts(ceiling_w)} peak limit and raise this month's peak. "
f"{act} The reserve only applies to keeping the peak down: normal home use "
f"can still discharge the battery below it, down to its "
f"cut-out level.")
def circulating(discharging=(), charging=()) -> Notice:
"""SAFETY-31: the inverters push power round each other. Control stopped."""
if discharging and charging:
what = (f"{names(discharging)} was discharging while "
f"{names(charging)} was charging, and your meter cannot see "
f"this.")
else:
what = ("One inverter was charging from another, and your meter "
"cannot see this.")
return Notice(ID_CIRCULATING, ALARM, T_CIRCULATING,
f"{what} Every inverter is set to 0 W: if the grid power does "
f"not change now, switch the inverters off at the isolator. "
f"Control stays stopped, even after a restart, until you "
f"start it on the add-on page once you know why.")
def not_delivering(unit: str, slug: str) -> Notice:
"""SAFETY-42: one inverter gives less than its share. Reports, never acts."""
return Notice(
ID_NOT_DELIVERING.format(slug=slug), ALARM,
T_NOT_DELIVERING.format(unit=unit),
f"{unit} gives less power than it is asked for, but nothing was "
f"changed and control keeps running. Check that {unit} is "
f"on and connected, that its battery is not empty or cold, and that "
f"nothing else controls it. A restart does not clear this: once you "
f"know why, stop and start control on the add-on page.",
section=S_NOT_DELIVERING)
def meter_frozen(unchanged_s) -> Notice:
"""SAFETY-18: the meter answers but its value does not move."""
return Notice(ID_METER_FROZEN, ALARM, T_METER_FROZEN,
f"The meter has shown the same value for "
f"{duration(unchanged_s)} while the controller asks the "
f"battery for full power. Control is still running and still "
f"follows this reading. Check the meter, and if it is stuck, "
f"stop control on the add-on page.")
def samples() -> list:
"""One rendering of every notice, with realistic values, for the test."""
return [
loop_bound(),
output_clamp([Clamp(None, 7412, 5000, "rating")]),
output_clamp(()),
output_clamp([Clamp("inverter-a", -3000, -2500, "device"),
Clamp("inverter-b", 6000, 4600, "max_w+device")]),
peak_saturated(5000),
peak_reserve(5000, soc_unread=False, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=False,
packs=["inverter-b"]),
peak_reserve(5000, soc_unread=True, floor_unknown=False),
peak_reserve(5000, soc_unread=False, floor_unknown=True),
circulating(["inverter-a"], ["inverter-b"]),
circulating(),
not_delivering("inverter-b", "inverter_b"),
meter_frozen(600),
meter_frozen(90),
]
+32 -17
View File
@@ -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.
+18 -11
View File
@@ -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
+99
View File
@@ -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
+3 -5
View File
@@ -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
+13 -11
View File
@@ -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.
+3 -4
View File
@@ -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.
+224 -27
View File
@@ -38,6 +38,9 @@ acceptance criteria; part 6 is TEST-11's and part 7 is SAFETY-28's:
the check that replaced a config-shape proxy: `ACTUATOR_WRITE_KEYS` graded
how many option KEYS exist, which stays 1 while a per-inverter config adds
a second real writer.
9. SAFETY-40. The integrator bound (normal anti-windup) is reported in its
own words and never as a BUG SIGNAL; the over-rating keeps it; and every
number in either message is the clamp tick's own Decision.
------------------------------------------------------------------------------
WHY PART 4 ALSO ASSERTS ON `Decision`'s BOOLEANS AND NOT ON `reason`
@@ -78,6 +81,9 @@ import re
import sys
from datetime import datetime, timedelta, timezone
from named_absence import given, nth, present
from app import messages
from app.clamp import (
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
clamp_to_rating,
@@ -97,6 +103,9 @@ T0 = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
# which is also the device rating, so this is the bound the alarm is written at.
LIVE_MAX_W = 5000.0
CLAMP_ID = "goodwe_output_clamp"
# SAFETY-40: the integrator-bound WARNING has its own id, so it can never
# replace a BUG SIGNAL notice still on screen.
BOUND_ID = "goodwe_integrator_bound"
def check(name, cond):
@@ -322,15 +331,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
@@ -456,7 +465,7 @@ def wired(grid, cycles=40, batt=0.0, track=True, **over):
def clamp_notes(hass):
return [d for dom, svc, d in hass.calls
if (dom, svc) == ("persistent_notification", "create")
and d.get("notification_id") == CLAMP_ID]
and d.get("notification_id") in (CLAMP_ID, BOUND_ID)]
# THE 10.3 RUNAWAY, replayed. "With the inverter switched off entirely, an
@@ -495,12 +504,16 @@ print(f" the loop clamped on {ctl_r.clamp_wd.engagements} of {60} cycles, "
f"notification(s)")
check("AC 3: and it ALARMED - silently clipping this is the defect",
ctl_r.clamp_wd.engagements > 0 and len(notes_r) >= 1)
check("...with its own notification_id, not shared with any other guard",
all(d["notification_id"] == CLAMP_ID for d in notes_r))
check("...with its own notification_id, not shared with any other guard - "
"on a default install the integrator bound's (SAFETY-40)",
all(d["notification_id"] == BOUND_ID for d in notes_r))
check("...one episode, re-asserted rather than one per cycle",
ctl_r.clamp_wd.episodes == 1 and len(notes_r) < ctl_r.clamp_wd.engagements)
check("...and it names itself a bug signal, not a limit in use",
bool(notes_r) and "out of range" in notes_r[0]["message"])
# SAFETY-40: on a default install this runaway is the INTEGRATOR bound, so it
# says so, and does not call anti-windup a bug (part 8 grades the wording).
check("...and it is the integrator-bound notice, which calls nothing a fault",
bool(notes_r) and notes_r[0]["title"] == messages.T_LOOP_BOUND
and "fault" not in notes_r[0]["message"])
rows_r = [e for e in ctl_r.events if REASON_CODE in e]
check(f"the action log records every engagement, coalesced into one row per "
f"episode ({len(rows_r)} row(s), {ctl_r.clamp_wd.engagements} "
@@ -523,11 +536,12 @@ 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",
any(not c["ok"] and not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
check("the status page flags it WHILE it is engaged - amber, since it is the "
"integrator bound (SAFETY-40; red is part 9's over-rating case)",
any(not c["ok"] and c["warn"] and "LOOP AT INTEGRATOR BOUND" in c["text"]
for c in ctl_r.red_snapshot))
check("...and once clear, a warn row still says how many times it engaged",
any(c["warn"] and f"{ctl_r.clamp_wd.engagements} time(s)" in c["text"]
@@ -804,11 +818,11 @@ class _SpyWatchdog(ClampWatchdog):
self.seen = [] # clamp_last.requested_w at each consultation
self.seen_write = [] # was the WRITE-path half engaged, per cycle
def note(self, engaged, reason, now):
def note(self, engaged, reason, now, **kw):
cl = self.owner.clamp_last
self.seen.append(None if cl is None else cl.requested_w)
self.seen_write.append(bool(cl and cl.engaged))
return super().note(engaged, reason, now)
return super().note(engaged, reason, now, **kw)
def probed(cls=Controller, grid=9000.0, cycles=5, **over):
@@ -984,12 +998,14 @@ check("PRECONDITION: the write really did clamp, on the DEVICE bound",
and hass_w.writes[-1] == 2500.0)
check("...and the loop-side half is SILENT here, so self.clamp_last is the "
"only evidence that this happened at all",
ctl_w.loop_clamped == (False, ""))
ctl_w.loop_clamped == (False, "", False))
check("AC 3: the guard alarmed on write-path evidence alone",
ctl_w.clamp_wd.engagements == 1 and len(notes_w) == 1)
check("...and the operator is told WHICH bound bit, so a device-rating clamp "
"is not reported as a max_w one",
bool(notes_w) and "device bound" in notes_w[0]["message"])
bool(notes_w)
and messages.LIMIT_WORDS["device"] in notes_w[0]["message"]
and messages.LIMIT_WORDS["max_w"] not in notes_w[0]["message"])
check("...and the action log carries the row, written by write_setpoint()",
any(REASON_CODE in e and "device bound" in e for e in ctl_w.events))
@@ -1081,19 +1097,25 @@ check("⚠️ THE ORDERED PAIR IN THE §15 ACTION-LOG ROW (main.py:1366). The "
"reading backwards on the one line an operator is woken by",
any(f"the computed setpoint {_asked_x:+.0f} W was clamped to "
f"{_sent_x:+.0f} W" in e for e in _rows_x))
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION REASON (main.py:2452), "
"which is a SECOND surface off the same sentence and has to be pinned "
"separately - SAFETY-38 round 3's whole lesson was that pinning one call "
"site leaves the other shipping",
bool(notes_x)
and f"the computed setpoint {_asked_x:+.0f} W was clamped to "
f"{_sent_x:+.0f} W" in notes_x[0]["message"])
# UX-08: the notification is a SECOND surface off the same numbers, in its own
# words (messages.output_clamp): asked first, sent second.
def _asked_then_sent(msg, asked, sent):
return (f"asked for {messages.watts(asked)}," in msg
and f"and sent {messages.watts(sent)} instead" in msg
and msg.index(messages.watts(asked)) < msg.index("and sent"))
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION, which is a SECOND surface "
"off the same numbers and has to be pinned separately - SAFETY-38 round "
"3's whole lesson was that pinning one call site leaves the other "
"shipping",
bool(notes_x) and _asked_then_sent(notes_x[0]["message"], _asked_x, _sent_x))
check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
"checks above discriminate rather than decorate",
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"])
and not _asked_then_sent(nth(notes_x, 0)["message"], _sent_x, _asked_x)
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.
@@ -1104,8 +1126,7 @@ check("...and the same ordered pair holds on the DEVICE-bound row, where the "
f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}"
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"])
and _asked_then_sent(nth(notes_w, 0)["message"], _asked_w, _sent_w))
# ==============================================================================
@@ -1855,6 +1876,182 @@ check("...EDGE-logged, not level-logged: 25 cycles did not flush the 40-row "
len(_shadow_lines) < 25 and len(_shad_c.events) <= 40)
# ==============================================================================
print()
print("9. SAFETY-40: the integrator bound is not a BUG SIGNAL, and the numbers "
"are the clamp tick's own")
# Three defects, each graded on the wired Controller.cycle() with compute()
# spied, so every number is compared with the Decision of the SAME tick:
# 1. `i_clamped` (normal anti-windup) used the over-rating BUG SIGNAL text;
# 2. "the loop asked for" printed `Decision.i_w`, the integrator, as a request;
# 3. "was clamped to" printed `self.target`, read after slew/freeze rewrote it.
# The fixtures are chosen so the numbers the old text used DIFFER from the true
# ones (MUTATION.md habit 4); the PRECONDITION checks pin that.
import logging
import app.main as _main_mod
_real_compute = _main_mod.compute
class _LogCap(logging.Handler):
def __init__(self):
super().__init__()
self.records = []
def emit(self, record):
self.records.append(record)
def s40_run(grid, cycles, track, **over):
"""Per tick: (Decision, self.target, clamp_wd.reason, engaged) + hass + log."""
ticks, decisions = [], []
def spy(*a, **k):
d = _real_compute(*a, **k)
decisions.append(d)
return d
cap = _LogCap()
lg = logging.getLogger("goodwe")
lg.addHandler(cap)
_main_mod.compute = spy
async def go():
hass = _Hass(grid, track=track)
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15},
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
red = []
for _ in range(cycles):
n = len(decisions)
before = c.clamp_wd.engagements
await c.read_inputs()
await c.cycle()
d = decisions[-1] if len(decisions) > n else None
ticks.append((d, c.target, c.clamp_wd.reason,
c.clamp_wd.engagements > before))
if c.clamp_wd.active and not red:
red = c.checks()
return hass, c, red
try:
hass, c, red = asyncio.run(go())
finally:
_main_mod.compute = _real_compute
lg.removeHandler(cap)
return ticks, hass, c, red, cap.records
def _f(d, name):
"""A Decision field, NaN when absent - so an old tree goes red, not crashes."""
return getattr(d, name, float("nan"))
# --- 9a. default install: only the INTEGRATOR bound can engage ---------------
ticks_i, hass_i, ctl_i, red_i, log_i = s40_run(6000.0, 20, track=False)
wind = [(d, t, r) for d, t, r, e in ticks_i
if e and d is not None and d.i_clamped and not d.clamped]
for d, t, r in wind:
print(f" tick: bound {_f(d, 'i_bound_w'):+.0f} i_w {d.i_w:+.0f} command "
f"{d.target_w:+.0f} self.target {t:+.0f}\n -> {r}")
check("PRECONDITION: the integrator bound engaged, and on a tick where the "
"carried integrator AND the emitted command both differ from the bound",
any(d.i_w != _f(d, 'i_bound_w') and d.target_w != _f(d, 'i_bound_w')
for d, t, r in wind))
check("defect 1: the integrator bound is NOT reported as a BUG SIGNAL",
wind and all("BUG SIGNAL" not in r for d, t, r in wind))
check("defect 1: ...it names the anti-windup bound, from the same tick",
wind and all(f"its {_f(d, 'i_bound_w'):+.0f} W anti-windup bound" in r
for d, t, r in wind))
check("defect 2: the integrator accumulator is not printed as a request",
wind and all(f"{d.i_w:+.0f} W" not in r and "asked for" not in r
for d, t, r in wind))
check("the command quoted is that tick's Decision, not a later read",
wind and all(f"command this cycle was {d.target_w:+.0f} W" in r
for d, t, r in wind))
check("at the bound nothing is described as 'above' or 'over' the rating",
wind and all(" above " not in r and " over " not in r
for d, t, r in wind))
notes_i = clamp_notes(hass_i)
check("it still NOTIFIES (not suppressed), under a title that claims no bug",
notes_i and all(n["title"] == messages.T_LOOP_BOUND
and "fault" not in n["message"] for n in notes_i))
check("...under its OWN notification_id, so it cannot replace a BUG SIGNAL "
"notice still on screen",
notes_i and all(n["notification_id"] == BOUND_ID for n in notes_i))
check("...logged as a WARNING, not an ERROR",
any(x.levelno == logging.WARNING and "anti-windup" in x.getMessage()
for x in log_i)
and not any(x.levelno >= logging.ERROR and "OUTPUT_CLAMP" in x.getMessage()
for x in log_i))
check("...and the status row is amber, not red",
any(c["warn"] and "LOOP AT INTEGRATOR BOUND" in c["text"] for c in red_i)
and not any(not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
for c in red_i))
# --- 9b. the genuine over-rating: integrator_max_w above max_w ----------------
ticks_o, hass_o, ctl_o, red_o, log_o = s40_run(9000.0, 12, track=True,
integrator_max_w=9000)
over = [(d, t, r) for d, t, r, e in ticks_o
if e and d is not None and d.clamped]
for d, t, r in over[:2]:
print(f" tick: requested {_f(d, 'requested_w'):+.0f} clamped {_f(d, 'clamped_w'):+.0f}"
f" i_w {d.i_w:+.0f} self.target {t:+.0f}\n -> {r}")
check("PRECONDITION: the output clamp engaged on a tick where the slew limit "
"rewrote the command AND the integrator differs from the request",
any(t != _f(d, 'clamped_w') and d.i_w != _f(d, 'requested_w') for d, t, r in over))
check("the over-rating KEEPS the BUG SIGNAL",
over and all("BUG SIGNAL" in r for d, t, r in over))
check("defect 2: it quotes the requested COMMAND of that tick, not i_w",
over and all(f"computed {_f(d, 'requested_w'):+.0f} W" in r for d, t, r in over))
check("defect 3: 'clamped to' is the clamp's own result, not self.target",
over and all(f"clamped to {_f(d, 'clamped_w'):+.0f} W" in r for d, t, r in over))
check("...and the 14 768 W rationale is still in its ERROR",
any(x.levelno == logging.ERROR and "14 768 W" in x.getMessage()
for x in log_o))
check("...under the over-rating title, quoting a tick's own asked and sent "
"numbers in that order, with a red status row",
clamp_notes(hass_o)
and all(n["title"] == messages.T_OUTPUT_CLAMP
and n["notification_id"] == CLAMP_ID
and any(_asked_then_sent(n["message"], _f(d, 'requested_w'),
_f(d, 'clamped_w')) for d, t, r in over)
for n in clamp_notes(hass_o))
and any(not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
for c in red_o))
# --- 9c. the text, directly, and the two budgets -------------------------------
from app.clamp import loop_clamp_note # noqa: E402
d_at = compute(prev_w=1000.0, grid_w=9000.0, actual_w=1000.0,
tuning=Tuning(max_w=5000.0, slew_w=1000.0, gain=0.6), i_w=4500.0)
check(f"exactly at the rating ({d_at.i_bound_w:+.0f} W) is 'reached', never "
f"'above' - {loop_clamp_note(d_at)!r}",
d_at.i_clamped and not d_at.clamped and abs(d_at.i_bound_w) == 5000.0
and "above" not in loop_clamp_note(d_at))
check("a healthy decision produces no text",
loop_clamp_note(compute(0.0, 100.0, 0.0, Tuning(max_w=5000.0))) == "")
# Anti-windup notices must never spend the budget a BUG SIGNAL needs.
wd = ClampWatchdog()
fired_w = wd.note(True, "wind", T0, bug=False)
fired_b = wd.note(True, "bug", T0 + timedelta(seconds=5), bug=True)
wd.note(True, "wind again", T0 + timedelta(seconds=10), bug=False)
check("an anti-windup notice does not delay a bug in the same episode, and "
"does not overwrite its text",
fired_w and fired_b and wd.bug and wd.reason == "bug")
wd2 = ClampWatchdog()
seq = [wd2.note(True, "b", T0 + timedelta(seconds=5 * i)) for i in range(40)]
check("bug-only cadence is unchanged: rising edge, then every REASSERT_S",
seq[0] and sum(seq) == 1 + int(5 * 39 // REASSERT_S))
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
+42 -77
View File
@@ -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
+44 -7
View File
@@ -31,6 +31,9 @@ import re
import sys
from datetime import datetime, timedelta, timezone
from named_absence import given, present
from app import messages
from app.control import Tuning
from app.delivery import (
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
@@ -738,8 +741,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 +765,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 +783,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",))
@@ -1193,8 +1196,8 @@ async def _restart():
c1 = _ctrl(h1, store=shared)
await _drive(c1)
# A NEW process, a NEW HomeAssistant object (so the in-memory notification
# is gone with it), the SAME durable file. That is what a Home Assistant
# restart is from this add-on's point of view: the add-on goes with it.
# is gone with it), the SAME durable file: an ADD-ON restart. A Core-only
# restart keeps the process; graded in part 8b (SAFETY-34).
h2 = _DeliveryHass(b_soc=50.0)
c2 = _ctrl(h2, store=shared)
await c2.check_not_delivering()
@@ -1230,7 +1233,7 @@ check("the notification NAMES THE UNIT - in its id, its title and its body - "
check("...and it says, in the operator's own sentence, that nothing has been "
"commanded differently. An alarm that let somebody believe the add-on "
"had handled it would be worse than no alarm",
"NOTHING HAS BEEN COMMANDED DIFFERENTLY" in _note[2]["message"])
"nothing was changed and control keeps running" in _note[2]["message"])
print(f" id={_note[2]['notification_id']!r} title={_note[2]['title']!r}")
_before_calls = len(_rh2.calls)
await_again = asyncio.run(_r2.check_not_delivering())
@@ -1248,6 +1251,40 @@ check("the operator's START press is the only way out, and it clears the "
and _rs.data.get(unit_key("inverter-b", "delivery_reason")) == ""
and _r2._delivery_announced == set())
print()
print("8b. SAFETY-34: a Core-only restart - same add-on process, HA's memory wiped")
import app.main as _main_mod # noqa: E402
async def _core_restart():
shared = _MemStore()
c = _ctrl(_DeliveryHass(b_soc=50.0), store=shared)
await _drive(c)
return c
_c = asyncio.run(_core_restart())
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
_main_mod._mono = lambda: _clock[0]
try:
_c.hass = _DeliveryHass(b_soc=50.0)
asyncio.run(_c.check_not_delivering())
_early = [x for x in _c.hass.calls
if "not_delivering" in str(x[2].get("notification_id", ""))]
_clock[0] += _main_mod.REANNOUNCE_S
asyncio.run(_c.check_not_delivering())
_late = [x for x in _c.hass.calls
if "not_delivering" in str(x[2].get("notification_id", ""))]
finally:
_main_mod._mono = _real_mono
check("SAFETY-34: the latch tripped in this process",
_c.inverters[1].delivery_wd.tripped)
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise",
len(_early) == 0)
check("SAFETY-34: after REANNOUNCE_S the SAME process re-asserts the card, "
"because a Core-only restart wiped it and did not restart the add-on",
len(_late) == 1 and "inverter_b" in _late[0][2]["notification_id"])
print()
if fails:
+210
View File
@@ -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")
+31 -13
View File
@@ -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;
@@ -84,6 +85,7 @@ from pathlib import Path
import aiohttp
from app import messages
from app.control import Tuning, compute
from app.freeze import (
RAIL_FRAC, FreezeWatchdog, FreezeWatchdogConfig, looks_frozen,
@@ -600,6 +602,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,
@@ -763,7 +774,7 @@ def _is_freeze_note(d):
calls itself frozen, which would be a confusable alarm on its own account.
"""
return (d.get("notification_id") == FREEZE_NOTE_ID
or "frozen" in str(d.get("title", "")).lower())
or d.get("title") == messages.T_METER_FROZEN)
def notes_of(hass):
@@ -778,7 +789,7 @@ def notes_by_text_of(hass):
both would be true by construction.
"""
return [d for d in all_notes_of(hass)
if "frozen" in str(d.get("title", "")).lower()]
if d.get("title") == messages.T_METER_FROZEN]
def wired(unchanged_s, grid, cycles=60, dev=(-5000.0, 5000.0), **over):
@@ -821,11 +832,12 @@ 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
# ships `goodwe_output_clamp` (and, for the integrator bound on a default
# install, `goodwe_integrator_bound` - SAFETY-40), and this scenario - a runaway loop winding into
# the rail - raises it legitimately: the loop asks for more than the rating and
# is clamped. So the answer to "is this scenario still meant to raise exactly
# one alarm" is NO, it raises exactly two, and BOTH ARE NAMED here rather than
@@ -836,7 +848,7 @@ _ids = sorted(d.get("notification_id") for d in all_notes)
print(f" the whole notification surface this run: {_ids}")
check("the whole notification surface carried exactly two alarms this run - "
"SAFETY-18's and SAFETY-03's, both named",
_ids == ["goodwe_meter_frozen", "goodwe_output_clamp"])
_ids == ["goodwe_integrator_bound", "goodwe_meter_frozen"])
# THIS GUARD'S, keyed on the id.
check("...and the freeze detector is the one that raised it", len(notes) == 1)
# ⚠️ Selected by TEXT, not by id - see notes_by_text_of(). One of the two
@@ -847,9 +859,9 @@ check("...with a fixed notification_id so repeats replace, not stack",
[d.get("notification_id") for d in notes_by_text_of(hass_f)]
== [FREEZE_NOTE_ID])
check("...and it says the controller is still running",
bool(notes) and "STILL RUNNING" in notes[0]["message"])
bool(notes) and "Control is 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.
@@ -1169,11 +1181,16 @@ check("...and at or above the device rail, which is why it must",
check("THE ALARM FIRES through the real cycle() - the rating reaches the "
"predicate from the controller, not just from a unit call",
len(_n20) == 1 and _c20.freeze_wd.active)
check("...and the notification the operator gets quotes the capped rail, "
"not the max_w one",
# UX-08: the notification no longer quotes a rail at all (the reader's action
# is "check the meter"), so it cannot quote the wrong one; the capped rail is
# still what the reason - the log line and the status row - carries.
check("...and the notification the operator gets names no max_w rail, and the "
"reason behind it quotes the capped rail",
bool(_n20)
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W"
not in str(_n20[0].get("message", ""))
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
in str(_n20[0].get("message", "")))
in _c20.freeze_wd.reason)
# p1_enabled is what a homewizard_local install has - set by hand here for the
# same reason part 4 sets it, see freeze_rows().
_c20.p1_enabled = True
@@ -1263,7 +1280,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)
+44 -37
View File
@@ -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)
+6 -3
View File
@@ -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")
+409
View File
@@ -0,0 +1,409 @@
"""UX-08: user-facing text follows STYLE.md. RUN: python3 -B test_messages.py
1. Every notification in `app/messages.py` obeys the rule. The test renders
each one with realistic values and checks the title length, the number of
sentences and words for its severity, the banned terms, the entity ids and
the Details line.
2. No notification is worded anywhere else. An AST scan of `app/` fails any
`persistent_notification` call whose payload is not built by
`app/messages.py`.
3. THE RATCHET for the kinds not rewritten yet. Every status-row and banner
literal, every validator refusal, every WARNING+ log line and every option
name and description in `translations/en.yaml` must either obey its limit
or appear in `ux08_allowlist.txt`. A new string that breaks the rule and is
not listed goes red. So does a listed entry that no longer matches a
failing string, so the list can only shrink: remove the line when you
rewrite the string.
4. DOCS.md has a section for every notification, under "## Notifications".
5. Mutations: each rule is shown to catch a spoiled input (habit 1 of
MUTATION.md).
Run `python3 -B test_messages.py --violations` to print what part 3 sees.
"""
import ast
import hashlib
import os
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / "tools"))
from app import messages # noqa: E402
import translations_lint as TL # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# --- the rule (STYLE.md sections 1 and 4) --------------------------------------
BANNED = re.compile(
r"\b(?:(?:SAFETY|TARIFF|TEL|CFG|FCST|OPT|UX|HW|FW|WITNESS|PKG|ARCH)-\d+|"
r"integrator|anti-?windup|windup|clamp(?:ed|s)?|i_clamped|saturat\w*|"
r"BUG SIGNAL|latch(?:ed)?|arbiter|allocator|registers?|0x[0-9A-Fa-f]+|"
r"layer [123]|heartbeat|watchdog|telegrams?|ACKs?|CRC|OTA|Modbus|planner|"
r"capaciteitstarief|journalctl|ssh|grep|"
r"\d+\.\d+\.\d+\.\d+|[\w-]+\.(?:py|md|yaml|json|db))\b|§\s?\d", re.I)
ENTITY = re.compile(r"\b(?:sensor|number|binary_sensor|input_\w+|switch|select)"
r"\.[a-z0-9_*]+")
SNAKE = re.compile(r"\b[a-z0-9]+(?:_[a-z0-9]+)+\b")
SHOUT = re.compile(r"\b[A-Z]{2,}[A-Z0-9]*(?:[\s-]+[A-Z]{2,}[A-Z0-9]*)+\b")
PLACEHOLDER = re.compile(r"\{[^{}]*\}|%[-+ #0-9.]*[sdrfgx%]")
def words(text):
return [w for w in re.findall(r"\S+", PLACEHOLDER.sub("X", text))
if re.search(r"[A-Za-z0-9]", w)]
def sentences(text):
"""Sentence count: a stop followed by a space and a capital, or the end."""
t = PLACEHOLDER.sub("X", text.strip())
t = re.sub(r"\b(e\.g|i\.e|etc)\.", "eg", t)
return len([s for s in re.split(r"[.!?](?:\s+|$)", t) if s.strip()])
def title_problems(title):
out = []
if len(words(title)) > 5:
out.append(f"title over 5 words ({len(words(title))})")
if title.lower().startswith("goodwe"):
out.append("title carries a GoodWe prefix")
if title.rstrip().endswith("."):
out.append("title ends in a full stop")
return out + text_problems(title, keys=False, entities=False)
def text_problems(text, *, keys, entities):
t = PLACEHOLDER.sub("X", text)
out = []
hit = sorted({m.group(0) for m in BANNED.finditer(t)})
if hit:
out.append("banned: " + ", ".join(hit[:4]))
if not entities and ENTITY.search(t):
out.append("entity id: " + ENTITY.search(t).group(0))
if not keys and SNAKE.search(ENTITY.sub("", t)):
out.append("option key: " + SNAKE.search(ENTITY.sub("", t)).group(0))
if SHOUT.search(t):
out.append("capitals: " + SHOUT.search(t).group(0))
return out
def body_problems(body, severity):
out = []
n_s, n_w = sentences(body), len(words(body))
if severity == messages.ALARM:
if n_s > 3:
out.append(f"alarm body over 3 sentences ({n_s})")
if n_w > 70:
out.append(f"alarm body over 70 words ({n_w})")
long = [s for s in re.split(r"(?<=[.!?])\s+", body) if len(words(s)) > 30]
if long:
out.append(f"sentence over 30 words ({len(words(long[0]))})")
else:
if n_s > 1:
out.append(f"{severity} body over 1 sentence ({n_s})")
if n_w > 25:
out.append(f"{severity} body over 25 words ({n_w})")
if ";" in body:
out.append("semicolon: split it into sentences or drop a clause")
return out + text_problems(body, keys=False, entities=False)
def notice_problems(n):
out = []
if n.severity not in (messages.ALARM, messages.WARNING, messages.INFO,
messages.RESOLVED):
out.append(f"unknown severity {n.severity!r}")
out += title_problems(n.title) + body_problems(n.body, n.severity)
if not ends_in_details(n.data()["message"], n.docs):
out.append("message does not end in its Details line")
return out
def ends_in_details(message, docs):
return message.endswith("\n\n" + messages.details(docs))
# ============================================================================== #
print("1. every notification obeys the rule")
SAMPLES = messages.samples()
for n in SAMPLES:
p = notice_problems(n)
check(f"{n.severity:8} {n.title!r}: {len(words(n.body))} words, "
f"{sentences(n.body)} sentence(s){' - ' + '; '.join(p) if p else ''}",
not p)
ids = {v for k, v in vars(messages).items() if k.startswith("ID_")}
seen = {re.sub(r"inverter_b$", "{slug}", n.id) for n in SAMPLES}
check(f"the samples cover every notification id in the catalogue "
f"(missing: {sorted(ids - seen)})", ids <= seen)
check("the payload carries exactly the three keys Home Assistant accepts",
all(set(n.data()) == {"notification_id", "title", "message"}
for n in SAMPLES))
os.environ["HOSTNAME"] = "abcd1234-goodwe-controller"
check("inside the add-on the Details line links to its documentation page",
"(/hassio/addon/abcd1234_goodwe_controller/documentation)"
in SAMPLES[0].data()["message"])
os.environ["HOSTNAME"] = "some-laptop"
check("...and anywhere else it names the section without a guessed link",
"](" not in SAMPLES[0].data()["message"]
and "under Notifications" in SAMPLES[0].data()["message"])
del os.environ["HOSTNAME"]
check("the output-clamp alarm quotes asked before sent",
messages.output_clamp([messages.Clamp(None, 7412, 5000, "rating")]).body
.index("7 412 W") < messages.output_clamp(
[messages.Clamp(None, 7412, 5000, "rating")]).body.index("5 000 W"))
# ============================================================================== #
print("\n2. no notification is worded outside app/messages.py")
APP = HERE / "app"
def render(node):
"""A string expression as text, placeholders as {expr}; None otherwise."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.JoinedStr):
return "".join(v.value if isinstance(v, ast.Constant)
else "{" + ast.unparse(v.value) + "}" for v in node.values)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
a, b = render(node.left), render(node.right)
if a is None and b is None:
return None
return (a if a is not None else "{…}") + (b if b is not None else "{…}")
if isinstance(node, ast.IfExp):
a, b = render(node.body), render(node.orelse)
if a is None and b is None:
return None
return (a or "") + " / " + (b or "")
return None
def notification_calls(tree):
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and node.args
and ast.unparse(node.func).endswith("call")
and render(node.args[0]) == "persistent_notification"):
yield node
bad_calls, n_calls = [], 0
for f in sorted(APP.glob("*.py")):
for call in notification_calls(ast.parse(f.read_text(encoding="utf-8"))):
n_calls += 1
payload = call.args[2] if len(call.args) > 2 else None
ok = (isinstance(payload, ast.Call)
and isinstance(payload.func, ast.Attribute)
and payload.func.attr == "data"
and "messages." in ast.unparse(payload.func.value))
if not ok:
bad_calls.append(f"{f.name}:{call.lineno}")
check(f"the scan found the add-on's notification calls ({n_calls})", n_calls >= 7)
check(f"every one is built by app/messages.py (not: {bad_calls})", not bad_calls)
# ============================================================================== #
print("\n3. the ratchet: other user-facing text obeys its limit or is listed")
LIMITS = { # kind: (words, keys ok, entity ids ok)
"row": (15, False, False),
"banner": (12, False, False),
"refusal": (30, True, True),
"log": (25, True, True),
"option-name": (6, False, False),
"option-desc": (30, True, True),
}
LOG_CALL = re.compile(r"(?:^|\.)(?:_LOG|log|logger|LOG)\.(warning|error|critical|exception)$")
def strings_in(node):
"""Top-level string expressions inside `node` (not their parts)."""
t = render(node)
if t is not None:
yield node, t
return
for child in ast.iter_child_nodes(node):
yield from strings_in(child)
def collect():
"""(kind, where, text) for every user-facing literal of a ratcheted kind."""
out = []
for f in sorted(APP.glob("*.py")):
if f.name == "messages.py":
continue
tree = ast.parse(f.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.Dict):
for k, v in zip(node.keys, node.values):
key = render(k) if k is not None else None
if key in ("text", "banner"):
for n, t in strings_in(v):
out.append(("row" if key == "text" else "banner",
f"{f.name}:{n.lineno}", t))
elif isinstance(node, ast.Assign) and f.name == "main.py":
tgt = ast.unparse(node.targets[0]).replace(" ", "").strip("()")
if tgt in ("level,banner", "banner"):
for n, t in strings_in(node.value):
if len(words(t)) > 1:
out.append(("banner", f"{f.name}:{n.lineno}", t))
elif isinstance(node, ast.Call):
name = ast.unparse(node.func)
if LOG_CALL.search(name) or name.endswith("._moan"):
arg = node.args[1 if name.endswith("._moan") else 0] \
if node.args else None
if arg is not None:
for n, t in strings_in(arg):
out.append(("log", f"{f.name}:{n.lineno}", t))
elif re.search(r"problems\.(append|extend)$", name):
for a in node.args:
for n, t in strings_in(a):
if len(words(t)) > 1:
out.append(("refusal", f"{f.name}:{n.lineno}", t))
tr = TL.parse_translation_file(HERE / "translations" / "en.yaml")
def walk(conf, path):
for key, v in (conf or {}).items():
if not isinstance(v, dict):
continue
if isinstance(v.get("name"), str):
out.append(("option-name", f"en.yaml:{path}{key}",
v["name"].split("·")[-1].strip()))
if isinstance(v.get("description"), str):
out.append(("option-desc", f"en.yaml:{path}{key}",
v["description"]))
if isinstance(v.get("fields"), dict):
walk(v["fields"], f"{path}{key}.")
walk(tr.get("configuration"), "")
return out
def problems_of(kind, text):
lim, keys, ents = LIMITS[kind]
p = []
if len(words(text)) > lim:
p.append(f"over {lim} words ({len(words(text))})")
return p + text_problems(text, keys=keys, entities=ents)
def key_of(kind, text):
return f"{kind} {hashlib.sha1(text.encode('utf-8')).hexdigest()[:12]}"
ALLOW_FILE = HERE / "ux08_allowlist.txt"
# The allowlist may never grow. The test demands the list be exactly this long,
# so deleting lines means lowering this number in the same change, and adding
# one means raising it where a reviewer sees it.
ALLOWLIST_MAX = 200
def read_allowlist(path):
keys = set()
for line in path.read_text(encoding="utf-8").splitlines():
line = line.split("#", 1)[0].strip()
if line:
keys.add(" ".join(line.split()[:2]))
return keys
found = collect()
violating = {}
for kind, where, text in found:
p = problems_of(kind, text)
if p:
violating.setdefault(key_of(kind, text), (kind, where, text, p))
if "--violations" in sys.argv:
for k, (kind, where, text, p) in sorted(violating.items(),
key=lambda kv: kv[1][:2]):
print(f"{k} # {where} {'; '.join(p)} :: {text[:70]!r}")
sys.exit(0)
allow = read_allowlist(ALLOW_FILE)
by_kind = {}
for kind, *_ in found:
by_kind[kind] = by_kind.get(kind, 0) + 1
check(f"the scan found every ratcheted kind ({by_kind})",
all(by_kind.get(k, 0) >= 5 for k in LIMITS))
new = sorted(k for k in violating if k not in allow)
for k in new:
kind, where, text, p = violating[k]
print(f" NEW {where}: {'; '.join(p)} :: {text[:90]!r}\n"
f" (rewrite it to STYLE.md; listing it needs a reason)")
check(f"no new string breaks the rule ({len(new)} new)", not new)
stale = sorted(k for k in allow if k not in violating)
for k in stale:
print(f" STALE {k} - that string now obeys the rule or is gone: "
f"delete its line from ux08_allowlist.txt")
check(f"no allowlist entry is stale, so the list only shrinks "
f"({len(stale)} stale)", not stale)
check(f"the allowlist is exactly its cap ({len(allow)} entries, cap "
f"{ALLOWLIST_MAX}): lower the cap when you delete lines",
len(allow) == ALLOWLIST_MAX)
print(f"CI: UX-08 allowlist {len(allow)} entries "
+ ", ".join(f"{k} {sum(1 for x in allow if x.startswith(k + ' '))}"
for k in LIMITS))
# ============================================================================== #
print("\n4. DOCS.md holds the detail of every notification")
DOCS = (HERE / "DOCS.md").read_text(encoding="utf-8")
part = DOCS.split("\n## Notifications\n", 1)
check("DOCS.md has a '## Notifications' section", len(part) == 2)
section = part[-1].split("\n## ", 1)[0] if len(part) == 2 else ""
heads = set(re.findall(r"^### (.+)$", section, re.M))
missing = sorted({n.docs for n in SAMPLES} - heads)
check(f"...with a heading for every Details line (missing: {missing})", not missing)
# ============================================================================== #
print("\n5. mutations: each rule catches a spoiled input")
N = messages.Notice
check("a six-word title is refused",
bool(title_problems("The meter reading may be stuck")))
check("a GoodWe prefix is refused", bool(title_problems("GoodWe: meter stuck")))
check("a second sentence in a warning is refused",
bool(body_problems("The battery lags. Check it.", messages.WARNING)))
check("a semicolon run-on is refused, in an alarm too",
bool(body_problems("It stopped; check it.", messages.ALARM)))
check("a fourth sentence in an alarm is refused",
bool(body_problems("One. Two. Three. Four.", messages.ALARM)))
check("an internal name is refused", bool(body_problems(
"The integrator reached its bound.", messages.WARNING)))
check("a card id is refused", bool(body_problems(
"See SAFETY-25.", messages.WARNING)))
check("shouting is refused", bool(body_problems(
"Control is STILL RUNNING.", messages.WARNING)))
check("an entity id in an alarm is refused", bool(body_problems(
"Check sensor.battery_power now.", messages.ALARM)))
check("...but allowed in a refusal", not problems_of(
"refusal", "Check sensor.battery_power in Developer Tools."))
check("an option key in an alarm is refused", bool(body_problems(
"Lower peak_soc_floor.", messages.ALARM)))
check("a message that lost its Details line is refused",
ends_in_details(N("x", messages.WARNING, "Meter stuck", "It is.")
.data()["message"], "Meter stuck")
and not ends_in_details("It is.", "Meter stuck"))
_fake = " x = {'text': 'THE METER IS FROZEN AND NOTHING WORKS AT ALL'}\n"
_n = [t for node in ast.walk(ast.parse(_fake.strip()))
if isinstance(node, ast.Dict) for _, t in strings_in(node.values[0])]
check("a new shouting status row is seen by the scan and is not listed",
_n and problems_of("row", _n[0])
and key_of("row", _n[0]) not in allow)
_bad = ast.parse("await self.hass.call('persistent_notification', 'create', "
"{'title': 'x', 'message': 'y'})", mode="exec")
check("a hand-written notification payload is seen by part 2",
any(not (isinstance(c.args[2], ast.Call)) for c in notification_calls(_bad)))
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+426
View File
@@ -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")
+2 -2
View File
@@ -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:
+6 -5
View File
@@ -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")))
+108 -84
View File
@@ -114,7 +114,14 @@ from datetime import datetime, timedelta, timezone
from math import inf, isinf
from pathlib import Path
from app import messages
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 +272,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 +298,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 +444,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 +803,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 +1056,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 +1249,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 +1446,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 +1463,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 +1504,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 +1542,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 +2018,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 +2254,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 +2333,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 +2501,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,18 +2517,27 @@ 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"])
_body25 = hass25.last_message("goodwe_peak_soc_floor")
def _says_scope(body):
"""SAFETY-25 in messages.peak_reserve()'s words: the reserve binds the peak
guard only, and home use goes on discharging below it to the cut-out."""
return ("The reserve only applies to keeping the peak down" in body
and "normal home use can still discharge the battery below it" in body
and "down to its cut-out level" in body)
print(f" notification body tail: ...{_body25[-180:]}")
check("...and so does the persistent notification, which outlives the page and "
"is the thing an operator reads on a phone at 19:00. It names what has "
"stopped - the guard buying a lower bill - and what has not",
"peak_soc_floor bounds THE PEAK GUARD ONLY" in _body25
and "not a" in _body25 and "pack-wide reserve" in _body25
and "BMS cut-out" in _body25)
_says_scope(_body25))
# --- 10f. THE CLAIM-SOURCE SET, PINNED --------------------------------------
@@ -2627,12 +2646,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 +2977,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 +3977,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 +4261,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 "
@@ -4323,8 +4341,8 @@ check("the notification an operator reads at 19:00 NAMES THE PACK that stopped "
"the site, and names only that one. 'the battery is at its floor' on a "
"site with two batteries is an instruction to go and look at both, and "
"the wrong one is the one that is fine",
"SAFETY-32" in _body32 and "inverter-b" in _body32
and "inverter-a" not in _body32.split("WHICH PACK")[-1])
"The battery of inverter-b is at its peak reserve" in _body32
and "inverter-a" not in _body32)
# The mirror again, on the SoC axis: swap which pack is empty and the name
# swaps. A hardwired `self.inverters[1].name` passes the line above.
_body32b = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
@@ -4332,8 +4350,8 @@ _body32b = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
check("...and with the SAME two readings swapped between the packs it names "
"inverter-a instead - so the sentence follows the measurement rather "
"than a position in the roster",
"inverter-a" in _body32b
and "inverter-b" not in _body32b.split("WHICH PACK")[-1])
"The battery of inverter-a is at its peak reserve" in _body32b
and "inverter-b" not in _body32b)
# ⚠️ AC 5, THE OTHER HALF: THE SAME METHOD ON A ROSTER OF ONE MUST PRINT WHAT IT
@@ -4357,13 +4375,14 @@ async def _notify32_one():
_body32one = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
_notify32_one())
check("⚠️ AC 5. At a roster of ONE the same notification carries NO per-pack "
"sentence at all - byte for byte the message SAFETY-25 shipped. There is "
check("⚠️ AC 5. At a roster of ONE the same notification names NO pack - "
"byte for byte the two-pack message minus the name (UX-08). There is "
"one battery, naming it is noise, and this alarm reaches every existing "
"install",
"SAFETY-32" not in _body32one and "WHICH PACK" not in _body32one
and _body32one == _body32.split("\n\nSAFETY-32")[0]
and "peak_soc_floor bounds THE PEAK GUARD ONLY" in _body32one)
"inverter-" not in _body32one
and _body32one == _body32.replace("The battery of inverter-b is",
"The battery is")
and _says_scope(_body32one))
# --- 12i. THE SIBLING ARGUMENT: `pack_floor_soc`, THE OTHER HALF OF THE PAIR --
@@ -4744,28 +4763,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 +4799,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 +4807,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 +4835,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()` "
@@ -5592,8 +5614,7 @@ MUT.append(("rule 8's refusal naming a hardwired inverter",
# 21. the notification naming a fixed roster position rather than the pack that
# is actually at its floor.
MUT.append(("the per-pack notification naming a fixed roster position",
"inverter-a" in _body32b
and "inverter-b" not in _body32b.split("WHICH PACK")[-1],
"inverter-a" in _body32b and "inverter-b" not in _body32b,
"part 12h: the same two readings swapped between the packs"))
# ---- the two PR #48 SURVIVORS, and why they survived -------------------------
@@ -5688,8 +5709,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 +5732,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 +5779,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 +5798,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 +5807,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 +5839,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 +5872,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 +5894,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 +5974,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 +6233,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 +6246,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",
+36 -15
View File
@@ -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: "
+6 -3
View File
@@ -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.
+11 -8
View File
@@ -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
View File
@@ -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:")
+11 -5
View File
@@ -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
+5 -2
View File
@@ -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]
+27 -23
View File
@@ -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.
+262
View File
@@ -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")
+60 -21
View File
@@ -20,6 +20,9 @@ Read MUTATION.md before you report a survivor.
import sys
from datetime import datetime, timedelta, timezone
from named_absence import nth, present
from app import messages # noqa: E402
from app.watchdog import (
SignWatchdog, SignWatchdogConfig, loop_is_following,
)
@@ -1053,8 +1056,8 @@ async def _circ_restart():
await a.read_inputs()
await a.cycle()
await asyncio.sleep(0.25)
# THE RESTART. Same store, brand new Controller and Hass - which is exactly
# what a Home Assistant restart does to this add-on. The house has stopped
# THE RESTART. Same store, brand new Controller and Hass - an ADD-ON
# restart. A Core-only restart keeps the process; graded below (SAFETY-34). The house has stopped
# circulating in the meantime, so nothing in the new process's own
# measurements could re-derive the trip.
h2 = _CircHass(a_w=0.0, b_w=0.0)
@@ -1102,18 +1105,50 @@ 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"]
def _says_three(m):
"""UX-08: the three things only this alarm can say, in its plain words
(messages.circulating): the meter cannot see it, control is stopped at
0 W, and if the grid does not move a person at the isolator is next."""
return ("meter cannot see this" in m and "Control stays stopped" in m
and "Every inverter is set to 0 W" in m and "isolator" in m
and "if the grid power does not change" in m)
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 "
"longer listening and a human at the isolator is the last actuator",
"METER CANNOT SEE" in _msg and "CONTROL HAS BEEN STOPPED" in _msg
and "isolator" in _msg)
_says_three(_msg))
check("...and it does not raise the same card on every cycle - a per-PROCESS "
"flag, deliberately not persisted, is what makes 'has a human been shown "
"this' a different question from 'is this site tripped'",
len(_notes2) == 1)
# SAFETY-34: a Core-ONLY restart. The add-on process lives on (measured on
# TEST-23), HA's memory is wiped, so the SAME process must re-assert the card.
import app.main as _main_mod # noqa: E402
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
_main_mod._mono = lambda: _clock[0]
try:
_h3 = _CircHass(a_w=0.0, b_w=0.0)
_b.hass = _h3
asyncio.run(_b.check_circulating())
_early = [c for c in _h3.calls if c[0] == "persistent_notification"]
_clock[0] += _main_mod.REANNOUNCE_S
asyncio.run(_b.check_circulating())
_late = [c for c in _h3.calls if c[0] == "persistent_notification"]
finally:
_main_mod._mono = _real_mono
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise - no "
"storm on the event bus", len(_early) == 0)
check("SAFETY-34: a Core-only restart wipes the card and the add-on process "
"survives it, so after REANNOUNCE_S the SAME process re-asserts it",
len(_late) == 1
and _late[0][2]["notification_id"] == "goodwe_circulating_power")
print()
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
@@ -1238,8 +1273,8 @@ check("PRECONDITION: both runs actually tripped and actually raised the card, "
and len(_notes_g) == 1 and len(_notes_s) == 1)
# --- AC 4: THE ATTRIBUTION ITSELF -------------------------------------------
_WHO_G = "Right now garage is discharging while shed charges."
_WHO_S = "Right now shed is discharging while garage charges."
_WHO_G = "garage was discharging while shed was charging"
_WHO_S = "shed was discharging while garage was charging"
check("⚠️ THE ALARM NAMES THE TWO PACKS AND SAYS WHICH WAY EACH ONE IS PULLING. "
"`circulation.py` calls this the first thing somebody woken at 3 a.m. "
"needs and impossible to reconstruct from the watts alone, and until this "
@@ -1260,12 +1295,14 @@ check("...and the RESTING pack is not named. `cellar` is on the roster, is read
"beside a 6 kW loop is not reported as one of its two participants",
"cellar" not in _msg_g and "cellar" not in _msg_s
and _cg.inverters[2].batt == _REST_W)
check("...and the sentence sits with the figure, not instead of it: the same "
"card still carries the watts, the window and the three things only this "
"alarm can say",
all("CIRCULATING_POWER" in m and "METER CANNOT SEE" in m
and "CONTROL HAS BEEN STOPPED" in m and "isolator" in m
for m in (_msg_g, _msg_s)))
# UX-08: the watts and the window moved out of the card into the reason the
# log line and the status row carry; the card keeps the three things.
check("...and the sentence sits with the three things only this alarm can say, "
"while the watts and the window stay in the reason behind it",
all(_says_three(m) for m in (_msg_g, _msg_s))
and all("CIRCULATING_POWER" in c.circ_wd.reason
and "W measured going round" in c.circ_wd.reason
for c in (_cg, _cs)))
print()
print(" the operator's press is the only way out, and it clears BOTH halves")
@@ -1706,7 +1743,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 +1877,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 +1886,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 +2091,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 +2539,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 +2576,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 -3
View File
@@ -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__":
+26 -3
View File
@@ -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:
+48 -3
View File
@@ -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.")
+38 -8
View File
@@ -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.
+4 -1
View File
@@ -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."
+4 -1
View File
@@ -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."
+207
View File
@@ -0,0 +1,207 @@
# UX-08 ratchet (test_messages.py part 3). Each line is a user-facing string that
# does not follow STYLE.md yet: "<kind> <sha1-12 of its text> # where :: start".
# These are waiting for phase 2b (row, banner), 2c (refusal, option-name,
# option-desc) and 2d (log). Delete the line when you rewrite the string, because
# a stale line fails the test. Never add a line for a new string: write the new
# string to STYLE.md instead.
banner c185022e391f # main.py banned: SAFETY-25; option key: peak_soc_floor; capitals: THE PEAK GUARD ONLY :: '{…} - and peak_soc_floor bounds THE PEAK GUARD ONLY (SAFETY-25, see be'
banner 4a246fc26def # main.py over 12 words (14); capitals: CIRCULATING POWER :: 'CIRCULATING POWER — the inverters were pushing against each other. Sit'
banner 750ccd4f2126 # main.py over 12 words (14) :: "FAILSAFE — the site's total command opposed the meter. Every inverter "
banner 637f334f130a # main.py over 12 words (26) :: 'FAILSAFE — the command opposed the meter. Inverter held at 0 W. / FAIL'
banner 0a3d2d9c4232 # main.py capitals: NOT READY :: "NOT READY — {bad[0].get('banner') or bad[0]['text']}"
banner dbcc227a9589 # main.py over 12 words (26); capitals: NOT DELIVERING :: 'NOT DELIVERING — {_nd} is standing at a share it is not producing. The'
log a5e99679fd80 # main.py banned: SAFETY-02, latch :: 'SAFETY-02: the per-inverter store migration did not verify (%s). The l'
log 128b18b32d86 # main.py over 25 words (41); banned: LATCHED, SAFETY-44, latched :: 'SAFETY-44: this store carries a LATCHED sign-disagreement failsafe und'
log f60446541ae5 # main.py over 25 words (83); banned: LATCH, LATCHED, SAFETY-02, latch; capitals: LATCH GUARD :: 'SAFETY-02 LATCH GUARD: this store carries a LATCHED sign-disagreement '
log 04d06bf223c8 # main.py over 25 words (73); banned: REGISTER, SAFETY-37, SAFETY-57, register; capitals: WHICH IS NOT BY ITSELF :: 'SAFETY-37: %s battery-power reading did not move under a command that '
log aec01642bd07 # main.py banned: SAFETY-57 :: 'SAFETY-57: %s'
log aebbca17acb3 # main.py over 25 words (52); banned: allocator :: 'write_setpoint() was asked to put %+.0f W on every one of %d inverters'
log b74c3018461e # main.py over 25 words (30); banned: SAFETY-28, allocator :: 'SAFETY-28 REFUSED a write of %+.0f W to %s: %s. Commanding 0 W instead'
log cefffafa8b27 # main.py over 25 words (31) :: 'the write of %+.0f W to %s (%s) did not complete in %.0f s and was aba'
log 1001c6250b85 # main.py over 25 words (29); banned: SAFETY-02 :: 'SAFETY-02 TRIPPED on %s - %s. Commanding 0 W on that inverter and hold'
log 8252ef5eefad # main.py over 25 words (48); banned: SAFETY-30 :: 'SAFETY-30 TRIPPED at the SITE - %s. That is the SUM of every standing '
log 8a65cf945e00 # main.py over 25 words (32); banned: SAFETY-31 :: 'SAFETY-31 TRIPPED - %s. Commanding 0 W on every inverter and holding i'
log 5d2beed2f1a2 # main.py over 25 words (65); banned: HW-01, SAFETY-42; capitals: NOTHING IS COMMANDED DIFFERENTLY :: 'SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED DIFFERENTLY: this g'
log d50b775b9cd4 # main.py banned: ARBITER; capitals: ARBITER SCOPE :: 'ARBITER SCOPE: %s'
log 749c2c6fe81d # main.py over 25 words (26); banned: SAFETY-10, heartbeat; capitals: SHADOW MODE IS ON :: 'SAFETY-10 SHADOW MODE IS ON. The full decision path runs and NOTHING i'
log 4d271dfc6f1c # main.py banned: SAFETY-10 :: 'SAFETY-10 shadow mode is OFF. This controller is commanding the batter'
log 730387e0da2b # main.py banned: SAFETY-03 :: 'SAFETY-03 - %s Engagements so far: %d.'
log 3bf899e99297 # main.py over 25 words (56); banned: SAFETY-03, clamp, clamped, integrator; capitals: STILL RUNNING :: 'SAFETY-03 - %s Silently clipping this is how a winding integrator reac'
log 78cb6bd09633 # main.py over 25 words (48); banned: SAFETY-07; capitals: STILL RUNNING :: 'SAFETY-07 - %s. The guard is asking for everything the inverter has an'
log cc735997adc7 # main.py over 25 words (44); banned: SAFETY-24; capitals: STILL RUNNING :: 'SAFETY-24 - %s. The controller is STILL RUNNING and charging is still '
log ebcb428a70d0 # main.py over 25 words (70); banned: SAFETY-18, arbiter, watchdog; capitals: CHECK THE METER :: 'SAFETY-18 - %s. The grid-following loop is regulating against a number'
log 38018b887495 # main.py over 25 words (43) :: 'tariff refresh degraded (%s); KEEPING the plan resolved at %s, %.0f s '
log 0eab1ec96fe8 # main.py capitals: CONTROL LOOP DIED :: 'CONTROL LOOP DIED (%s) - commanding 0 W and exiting with status %d'
log db529f2c419d # main.py over 25 words (40); banned: SAFETY-02 :: 'SAFETY-02 was TRIPPED on %s before this restart and stays tripped: %s.'
log c5aadd5c8bf9 # main.py over 25 words (45); banned: SAFETY-42; capitals: NOTHING IS BEING COMMANDED DIFFERENTLY :: 'SAFETY-42 was TRIPPED on %s before this restart and stays tripped: %s.'
log d72bbdb7e595 # main.py over 25 words (39); banned: SAFETY-30 :: 'SAFETY-30 (site) was TRIPPED before this restart and stays tripped: %s'
log 8b880e65a7c3 # main.py over 25 words (38); banned: SAFETY-31 :: 'SAFETY-31 was TRIPPED before this restart and stays tripped: %s. Every'
log 23f58b7f305c # p1.py:1264 over 25 words (30) :: 'meter_poll_s %.1f leaves no room under meter_max_age_s %.1f: one misse'
log 88a7251e842b # p1.py:470 banned: telegram :: 'P1 telegram rejected: %s'
log 0dd83d6dfaf4 # price.py over 25 words (30) :: 'the feed published no price for %d of %d instants (index %s); those in'
log bcfd7062e28e # price.py over 25 words (79); banned: §8 :: 'price_feed_url is set, so %s is requesting %s exactly as given and is '
log b57bc6de4a6b # price.py over 25 words (47); banned: validate.py :: 'price_taxes_profile cannot be used (%s: %s), so this fetch has NO tax '
log d5595aaaacc4 # price_history.py banned: §8 :: 'price history at %s is %s - starting from empty. §8.3 carry-forward ha'
log dbed0b8e35ea # static_tariff.py banned: validate.py :: 'static_profile is unusable (%s); no cheap window. This should have bee'
log 8fbe9f5407ea # static_tariff.py over 25 words (28); banned: §1, §6 :: '§11.4: %s, and no static_profile is configured to fall back to. The pl'
log 4ce29e043d89 # static_tariff.py banned: §1 :: '§11.4: %s - falling back to the static profile, plan marked PROVISIONA'
log a3c7c9719ed2 # store.py capitals: LOST STATE :: 'per-inverter store migration LOST STATE and has not been marked done: '
log 5b537a77052b # validate.py over 25 words (41); banned: arbiter, saturation :: 'integrator_max_w %.0f W is above max_w %.0f W. Not refused - the arbit'
log 12ae40f9d289 # validate.py over 25 words (73); banned: arbiter, clamp, clamps :: 'maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not refused - '
log c6dc799502cf # validate.py over 25 words (32) :: '%s carries negative levies %s EUR/kWh. That is accepted, but a tax com'
log 1688a7478ceb # validate.py over 25 words (36) :: 'price_formula is filled in but price_provider is %r, so the coefficien'
log f37f9fce4433 # validate.py over 25 words (96) :: '%s has no "card" block, so its coefficients are UNGRADED: nothing reco'
log 396fbdd009c2 # validate.py over 25 words (52); banned: §1 :: "price_provider is %r but static_profile is empty. §11.4's degradation "
log 37bba6ed37e3 # validate.py capitals: REFUSING TO START :: 'REFUSING TO START: the add-on configuration is not usable.'
option-desc b927826d01f0 # en.yaml over 30 words (112); capitals: LEAVING IT OFF DOES NOT TURN THE PRICE FEED OFF :: 'Charge in the cheapest published hour and discharge in the dearest, bu'
option-desc b98c42e34df7 # en.yaml over 30 words (34) :: 'The reserve price trading may not spend. Deliberately not the same key'
option-desc 84e054da385b # en.yaml over 30 words (39) :: 'Off by default. Leave it off until the site has been commissioned: wit'
option-desc 0f55d3b37afa # en.yaml over 30 words (38); banned: 0x0518, register, watchdog :: "The sensor that reports this inverter's own battery power, positive wh"
option-desc 307afdd74f84 # en.yaml over 30 words (98); banned: 0x0560, Register; capitals: GW5000S-BP :: "MEASURED, not chosen: the percent at which the pack's OWN battery mana"
option-desc 4da37ff6250d # en.yaml over 30 words (66); banned: SAFETY-31; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen contr'
option-desc db464169ed66 # en.yaml over 30 words (144); banned: SAFETY-31; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY; with one inverter the quantity is identica'
option-desc df9735452fa1 # en.yaml over 30 words (88); banned: OPT-08; capitals: THIS ONE IS NOT DERIVED FROM :: 'What one full round trip costs per kWh: wear on the pack plus conversi'
option-desc 8147a82a437e # en.yaml over 30 words (51) :: 'A deadline on the gap between cycles, reset by every cycle. On a healt'
option-desc cb66f230ef63 # en.yaml over 30 words (45); banned: register :: 'Errors smaller than this are ignored. ⚠️ The deadband is a one-way rat'
option-desc 4167af90cfc2 # en.yaml over 30 words (288); banned: SAFETY-42, delivery_window.py, saturation; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire a'
option-desc 3c0898cc1149 # en.yaml over 30 words (63) :: 'What you are paid for feeding a kWh back into the grid, all in. One nu'
option-desc 05560996cb2e # en.yaml capitals: MEASURED ON REAL HARDWARE :: 'How much of the remaining error the loop corrects each cycle. MEASURED'
option-desc f225d7a8f3a5 # en.yaml over 30 words (32); banned: watchdog :: 'How often the setpoint is rewritten even when it has not changed. It m'
option-desc ef70d16a93bd # en.yaml over 30 words (98); banned: clamp, saturation :: 'Caps how much stale error can be waiting to unwind when the sign flips'
option-desc bde7357af38d # en.yaml over 30 words (107); capitals: EMPTY DOES NOT MEAN :: 'Leave this empty unless the site really has more than one inverter. EM'
option-desc 217c5ae9599c # en.yaml over 30 words (66); banned: 0x0560, register :: "The percent at which THIS pack's own battery management system cuts ou"
option-desc 746a0f9ae506 # en.yaml over 30 words (143) :: "THIS pack's usable capacity in kilowatt-hours, from its datasheet or i"
option-desc cb6ad204f59d # en.yaml over 30 words (52) :: 'The supervised ceiling on what may be written to THIS inverter; the si'
option-desc 46142bcc1ab1 # en.yaml over 30 words (36); banned: heartbeat, latch :: 'A short identifier for this inverter, for example inverter-a. It gives'
option-desc d787e9f2454d # en.yaml over 30 words (63); banned: arbiter; capitals: MUST STAY AT OR BELOW :: '⚠️ MUST STAY AT OR BELOW the inverter rating, and the add-on refuses t'
option-desc a90fced63631 # en.yaml over 30 words (52); banned: CLAMPED :: 'How fast the cycle drains the pack; the surplus is exported. Above the'
option-desc ab58c8fae4fb # en.yaml over 30 words (57); capitals: THIS IS NOT THE PEAK GUARD :: 'The depth the monthly recalibration deliberately goes to, once a month'
option-desc 73f51bdd3d6e # en.yaml over 30 words (83); capitals: THIS IS YOUR NUMBER :: 'THIS IS YOUR NUMBER, not a derived one: the hard ceiling on what may e'
option-desc dd9023e6c812 # en.yaml over 30 words (45); capitals: POSITIVE MUST MEAN DRAWING FROM THE GRID :: "The net grid power sensor, used when the meter source is 'off'. POSITI"
option-desc aa5b929f3c22 # en.yaml over 30 words (114); banned: SAFETY-18, latch; capitals: NOT CHOSEN :: 'MEASURED, NOT CHOSEN (SAFETY-18). How long the meter may keep answerin'
option-desc 1b3038c5c9e5 # en.yaml over 30 words (46) :: 'Beyond this the grid figure counts as missing. On its own it does not '
option-desc bbf26329f056 # en.yaml over 30 words (48); banned: telegram, telegrams :: 'Must match your connection. A telegram whose phase count disagrees is '
option-desc 92da72e54e77 # en.yaml over 30 words (52) :: "For meter source homewizard_local only. 5 s is the meter's own update "
option-desc 24f329136309 # en.yaml over 30 words (84) :: "'off' keeps the single Home Assistant entity below, which is what an e"
option-desc 47e5fa0dba52 # en.yaml banned: register :: 'The UNSIGNED injection register, the counterpart of the import registe'
option-desc 9b8f5ea6c656 # en.yaml banned: 192.168.2.250 :: "For meter source homewizard_local only: the meter's own address on you"
option-desc d5d3e30ac4d2 # en.yaml over 30 words (37); banned: register :: 'The UNSIGNED consumption register. Do not point this at a signed templ'
option-desc 0b05d89400ba # en.yaml over 30 words (39) :: 'For meter source ha_signed only. ONE signed sensor: positive is drawn '
option-desc e74a5db257e1 # en.yaml over 30 words (46) :: 'For meter source ha_signed only. Optional, in L1..L3 order, each signe'
option-desc 3f07a88a61fd # en.yaml over 30 words (34); capitals: THIS IS YOUR NUMBER :: 'THIS IS YOUR NUMBER: the quarter-hour average demand the peak guard tr'
option-desc 6ec195081ef9 # en.yaml over 30 words (112); banned: SAFETY-24; capitals: IT BOUNDS THE PEAK GUARD ONLY :: 'DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usa'
option-desc 228fdc168458 # en.yaml over 30 words (270); capitals: INJECTION IS EXEMPT FROM VAT :: 'Optional, and only if your supplier pays you a price that moves with t'
option-desc 98987808a7f0 # en.yaml over 30 words (98); capitals: WHAT YOU TYPE IS SENT EXACTLY AS WRITTEN :: "Leave empty to use the chosen source's own feed. Only a host that sour"
option-desc 26f17751b2f7 # en.yaml over 30 words (234); capitals: IN THE UNITS THE CARD PRINTS :: "For price source custom_formula only: your own contract's two coeffici"
option-desc 011841a82d7d # en.yaml over 30 words (34) :: "Your supplier's margin. The add-on adds it ONLY when the chosen price "
option-desc d34ef16ba99a # en.yaml over 30 words (63) :: 'The older way of getting a price, kept because retiring it would take '
option-desc c70929091e71 # en.yaml over 30 words (252); capitals: SWITCHING THIS ON IS WHAT STARTS THE PRICE FETCH :: "Where day-ahead or real-time prices come from. 'off' is what ships, an"
option-desc a62e17ccf445 # en.yaml over 30 words (230); banned: TARIFF-18; capitals: THIS NUMBER IS NOT :: 'Everything on your bill that is not the energy itself - distribution, '
option-desc 56d56f58c43e # en.yaml over 30 words (206); capitals: THE NUMBERS HERE ARE THE LEVIES ALONE :: 'Leave this empty unless your network tariff really does charge a diffe'
option-desc 313bdd035b0b # en.yaml over 30 words (193); capitals: MEASUREMENT ONLY :: 'Optional, and MEASUREMENT ONLY - it changes no control behaviour whats'
option-desc 4172d9920d65 # en.yaml over 30 words (128); capitals: MEASUREMENT ONLY :: 'Optional, and MEASUREMENT ONLY - it changes no control behaviour whats'
option-desc 09ceba2c6afb # en.yaml over 30 words (70); banned: clamp; capitals: MEASUREMENT ONLY :: 'Optional, MEASUREMENT ONLY. Some sensors - typically a kWh meter or a '
option-desc 42a7eaac6ff1 # en.yaml banned: saturated :: 'How many consecutive saturated cycles before the loop stops winding up'
option-desc 12b0dffec9dd # en.yaml over 30 words (42) :: 'The gap between what was commanded and what the inverter delivers that'
option-desc 91fd58c08a59 # en.yaml over 30 words (54) :: 'The ESPHome number entity this add-on writes the power setpoint to, no'
option-desc 01e1af709cd5 # en.yaml over 30 words (201); banned: arbiter, clamps, heartbeat, latch :: 'Runs the whole decision path — strategies, arbiter, clamps and guards '
option-desc 96b077d4e2e9 # en.yaml over 30 words (72); banned: SAFETY-02; capitals: NOT CHOSEN :: 'MEASURED, NOT CHOSEN (SAFETY-02). How long the setpoint and the meter '
option-desc addf5c495ae8 # en.yaml over 30 words (90); banned: SAFETY-02, watchdog; capitals: NOT CHOSEN :: 'MEASURED, NOT CHOSEN (SAFETY-02). Both the setpoint being emitted and '
option-desc 0a66ac772b27 # en.yaml over 30 words (117); banned: SAFETY-30; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (S'
option-desc c0deabb69ab6 # en.yaml over 30 words (60); banned: SAFETY-30; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. The same 300 W as the single-inverter thre'
option-desc 7d7272743277 # en.yaml over 30 words (123); banned: SAFETY-39; capitals: MEANS FOLLOW THE INVERTER RATING :: 'How far the setpoint may move in one control cycle. ⚠️ 0 MEANS FOLLOW '
option-desc 436efca21a7d # en.yaml over 30 words (31) :: "The sensor that reports the pack's state of charge in percent. Use the"
option-desc 9ac8a3b21a1e # en.yaml over 30 words (43) :: 'How long the inputs may be missing before the add-on commands 0 W. Wit'
option-desc 86cbfd6a5693 # en.yaml over 30 words (130); capitals: RECOMMENDED EVEN ON :: 'Your fixed or day/night contract, written as a single line of JSON. RE'
option-desc 996dffbf563d # en.yaml over 30 words (74); banned: register :: 'What the meter should sit at when nothing is happening; negative means'
option-desc faf346256008 # en.yaml over 30 words (52); banned: SAFETY-30; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (S'
option-desc 5323134f3131 # en.yaml over 30 words (95); banned: SAFETY-30, saturation; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY; with one inverter this setting does nothin'
option-name ca3a62bb8260 # en.yaml over 6 words (7) :: 'Circulating-power hold (s) — derived, do not change'
option-name edee163fdc2b # en.yaml over 6 words (7) :: 'Circulating-power threshold (W) — measured, do not change'
option-name 99b3beeab621 # en.yaml over 6 words (7) :: 'Cost of one full charge/discharge cycle (EUR/kWh)'
option-name cccd8a42f51a # en.yaml over 6 words (8) :: 'Not-delivering report after (s) — derived, do not change'
option-name 989891e2ce23 # en.yaml over 6 words (10); banned: Integrator :: 'Integrator bound (W, 0 means the same as the rating)'
option-name 42c6b595320c # en.yaml over 6 words (9) :: 'Inverter list (leave empty if you have one inverter)'
option-name 230e6dc9d872 # en.yaml over 6 words (7) :: 'Hour of day a due cycle starts'
option-name 18e1de907c54 # en.yaml over 6 words (8) :: 'Frozen-meter alarm after (s) — measured, do not change'
option-name 9071c4cfa460 # en.yaml over 6 words (7) :: 'A reading counts as stale after (s)'
option-name e3d06ed93e2e # en.yaml banned: register :: 'Export register entity (W)'
option-name a0f59ea5de36 # en.yaml banned: register :: 'Import register entity (W)'
option-name ed757924f40f # en.yaml over 6 words (7) :: 'Per-phase signed net entities (L1, L2, L3)'
option-name 52d03808da01 # en.yaml over 6 words (8) :: 'Your own INJECTION formula (one line of JSON)'
option-name bf22f2121af4 # en.yaml over 6 words (8) :: 'Your own tariff formula (one line of JSON)'
option-name 5593992fcf5b # en.yaml over 6 words (7) :: 'How far ahead to fetch prices (hours)'
option-name d9fee5514a4b # en.yaml over 6 words (11) :: 'Levies and VAT by time of day (one line of JSON)'
option-name 7d8d691905e0 # en.yaml over 6 words (7) :: 'PV production entities, one per inverter (W)'
option-name eac7ee084aaa # en.yaml banned: Saturated :: 'Saturated cycles before freezing (count)'
option-name 5c6bbc5cab40 # en.yaml banned: Saturation :: 'Saturation threshold (W)'
option-name 99a1ec154d36 # en.yaml over 6 words (7) :: 'Shadow mode — run every decision, command nothing'
option-name 82fcd0faa687 # en.yaml over 6 words (8); banned: watchdog :: 'Sign watchdog hold (s) — measured, do not change'
option-name 8fc34172dc01 # en.yaml over 6 words (8); banned: watchdog :: 'Sign watchdog threshold (W) — measured, do not change'
option-name 8516934704f5 # en.yaml over 6 words (8) :: 'Site-wide sign hold (s) — derived, do not change'
option-name 2b8278ab2b16 # en.yaml over 6 words (8) :: 'Site-wide sign threshold (W) — measured, do not change'
option-name bd52e795ea97 # en.yaml over 6 words (8) :: 'Command 0 W after inputs missing for (s)'
option-name 9e33febf4481 # en.yaml over 6 words (8) :: 'Fixed or day/night tariff (one line of JSON)'
option-name 40e1e3ba3841 # en.yaml over 6 words (8) :: 'Per-inverter sign hold (s) — derived, do not change'
option-name 4ba7087578dc # en.yaml over 6 words (8) :: 'Per-inverter sign threshold (W) — derived, do not change'
refusal 87620efa0704 # p1.py:1176 banned: telegram :: 'p1_phase_net_entities has {len(phase_net)} entities but meter_phases i'
refusal 5d1b8147792b # p1.py:1186 banned: 192.168.2.250 :: "meter_source {SOURCE_HOMEWIZARD} needs p1_host (the meter's own addres"
refusal 701db346696d # validate.py over 30 words (85); banned: SAFETY-30, watchdog :: 'unit_sign_disagree_w is {unit_sign_w} W but max_w{unit_where} is {weak'
refusal 710b4d89a9d7 # validate.py over 30 words (88); banned: ARBITER, arbiter; capitals: THE CEILING WINS :: 'maintenance_charge_w is {charge_w} W but max_w{maint_where} is {maint_'
refusal 16d40434116b # validate.py over 30 words (193); banned: NOTES.md, §5; capitals: THE PLANT WINS :: 'slew_w is {slew_w} W against a site rail of {rail} W, so the loop cros'
refusal 613375fb6b5f # validate.py over 30 words (107); banned: SAFETY-07; capitals: THE BMS WINS :: "peak_soc_floor is {soc_floor} % but the pack's own cut-out (bms_soc_fl"
refusal ba21c5ba8c0d # validate.py over 30 words (224); banned: TARIFF-07, §1; capitals: IT HAS NEVER FETCHED :: 'price_provider is {pid}, which WAS a shipped plugin and has been WITHD'
refusal de8df32186b6 # validate.py over 30 words (46); banned: §1 :: 'price_provider is {pid}, which is not a shipped plugin. The ids that e'
refusal 0ee7c15eafef # validate.py over 30 words (103); banned: arbitrage.py, main.py, static_tariff.py; capitals: THE HOME ASSISTANT ENTITIES WIN :: "price_provider is {pid} AND {' and '.join(ha_price)} {'is' if len(ha_p"
refusal c4149a762c39 # validate.py over 30 words (62); banned: main.py; capitals: THE HOME ASSISTANT ENTITIES WIN :: "static_profile is set AND {' and '.join(ha_price)} {'is' if len(ha_pri"
refusal 6af34466bbc2 # validate.py over 30 words (83); capitals: THE TABLE WINS :: '{TAXES_PROFILE_OPTION} is set AND price_taxes_eur_kwh is {scalar} rath'
refusal 8f69bdbbf3fc # validate.py over 30 words (55) :: '{TAXES_PROFILE_OPTION} carries {steep} EUR/kWh. Belgian levies and dis'
refusal 96a090d2ad97 # validate.py over 30 words (72); banned: DOCS.md, §1 :: "price_provider is 'custom_formula' but price_formula is empty, so the "
refusal db9d0d53c4f1 # validate.py over 30 words (45); banned: §1 :: "cycle_cost_threshold_eur is {opts.get('cycle_cost_threshold_eur')} and"
refusal 58ccb3bbd560 # validate.py over 30 words (63); banned: §1; capitals: NO PRICES AT ALL :: "arbitrage_enabled is on but price_provider is 'off' and static_profile"
refusal 780592c897fb # validate.py over 30 words (85); banned: arbitrage.py, telegram, §9 :: "arbitrage_enabled is on but meter_source is 'off', so there is no P1 t"
refusal 4f80c091ca95 # validate.py over 30 words (69) :: 'arbitrage_soc_floor is {floor} % and arbitrage_soc_ceiling is {ceiling'
refusal 0c089bb2f200 # validate.py over 30 words (57); banned: §1 :: "price_provider is 'manual_csv' but price_csv_path is empty, so the off"
refusal 152dfec79224 # validate.py over 30 words (48); banned: SAFETY-02, heartbeat, latch :: '{where} has no name. Every inverter needs one: the Store namespaces th'
refusal eb14af760f5c # validate.py over 30 words (51); banned: SAFETY-02, heartbeat, latch :: '{where} and {seen[name]} are both called {name}. The Store keys every '
refusal 36f53c989b4a # validate.py over 30 words (55) :: '{who} has no setpoint_entity, so this add-on cannot command it. It wou'
refusal c98c6ab66e9f # validate.py over 30 words (56) :: "{who} has no usable max_w (got {entry.get('max_w')}). It is the superv"
refusal 5ca16a4d0a1e # validate.py over 30 words (71) :: '{who} has capacity_kwh {raw}, which is not a usable number of kilowatt'
refusal 63a864d2e0a3 # validate.py over 30 words (73) :: "capacity_kwh is stated on {', '.join(stated)} and left empty on {', '."
refusal 8d82500ecb1d # validate.py over 30 words (64) :: 'setpoint_entity is set to {flat_sp} AND an inverters list of {len(entr'
refusal 3d778e0ab5e1 # validate.py over 30 words (36); capitals: THE DEVICE WINS :: '{name}: max_w is {cap} W but {entity} accepts at most {float(dev_max)}'
refusal ecd0883f5649 # validate.py over 30 words (36); capitals: THE DEVICE WINS :: '{name}: max_w is {cap} W, so this controller may command {-cap} W, but'
refusal eff7efac51f8 # validate.py over 30 words (56); banned: heartbeat :: 'setpoint_entity is not set, so this add-on has no actuator: every writ'
refusal b126f1a88b77 # validate.py over 30 words (76); banned: §1 :: '{len(who)} configured writers can reach the one actuator {actuator}: {'
refusal 07a88756c864 # validate.py over 30 words (48); banned: arbiter :: 'max_w is {max_w} W and must be positive - it is a magnitude, not a sig'
refusal c4a1d0371e31 # validate.py over 30 words (42); capitals: THE DEVICE WINS :: "max_w is {max_w} W but {opts.get('setpoint_entity') or 'the setpoint d"
refusal a15624d1aa10 # validate.py over 30 words (33); capitals: THE DEVICE WINS :: 'max_w is {max_w} W, so this controller may command {-max_w} W, but {op'
refusal 26d0602c32d7 # validate.py over 30 words (79); capitals: THE FIRMWARE WINS :: 'meter_max_age_s is {max_age_s} s but the ESP32 firmware vetoes any P1 '
refusal 2ade09aaa579 # validate.py over 30 words (73); banned: SAFETY-02, arbiter, watchdog :: 'sign_disagree_w is {sign_w} W but max_w{where} is {weakest} W, so the '
row c24ab8bd9cb3 # main.py over 15 words (18); banned: heartbeat, layer 1; capitals: NO PASS FOR :: 'control loop: NO PASS FOR {_pass_age} s (layer 1 zeroes the inverter a'
row c771d4bc1e0a # main.py over 15 words (17); banned: telegrams :: "P1 meter ({o.get('meter_source')}): {self.p1.net_w} W, {age} s old, {s"
row f6195fee8575 # main.py over 15 words (33); capitals: METER MAY BE FROZEN :: 'METER MAY BE FROZEN - {…}. The loop is STILL FOLLOWING it and is still'
row db05be7cd99c # main.py over 15 words (16); option key: meter_source; capitals: NOT COVERED :: "freeze detector: NOT COVERED on meter_source {o.get('meter_source')} -"
row 9a21960bbbe8 # main.py capitals: NOT COVERED :: 'freeze detector: NOT COVERED - {…}'
row c38b746a6585 # main.py banned: SATURATED; capitals: PEAK GUARD SATURATED :: 'PEAK GUARD SATURATED - {…}'
row dd0629834473 # main.py over 15 words (36); banned: SAFETY-25; option key: peak_soc_floor; capitals: THE PEAK GUARD ONLY :: '{…}. NOTE: peak_soc_floor bounds THE PEAK GUARD ONLY - it is not a pac'
row 4132e246e675 # main.py over 15 words (26); banned: telegrams; option key: meter_source; capitals: NOT COVERED :: "peak guard: NOT COVERED on meter_source {o.get('meter_source')} - the "
row 7b34b16d8bb2 # main.py over 15 words (61) :: '{label}: no usable value this cycle from {entity} - and this row canno'
row 04e0381f16c2 # main.py banned: CLAMP; capitals: OUTPUT CLAMP ENGAGED :: 'OUTPUT CLAMP ENGAGED - {…} Engagements: {self.clamp_wd.engagements}.'
row 9f8b8adcbc6c # main.py banned: INTEGRATOR; capitals: LOOP AT INTEGRATOR BOUND :: 'LOOP AT INTEGRATOR BOUND - {…} Engagements: {self.clamp_wd.engagements'
row f00cc9688eca # main.py over 15 words (20); banned: clamp :: 'output clamp: not engaged now, but it has engaged {self.clamp_wd.engag'
row a44333431331 # main.py over 15 words (31); capitals: SIGN-DISAGREEMENT FAILSAFEX :: 'SIGN-DISAGREEMENT FAILSAFE{tag} - {…}. The inverter is held at 0 W. Fi'
row 3ef4e60f2328 # main.py over 15 words (17) :: 'sign watchdog{tag}: armed{vs} (trips after {inv.sign_wd.cfg.hold_s} s '
row 9f879b339282 # main.py over 15 words (50); banned: integrator; capitals: SITE SIGN-DISAGREEMENT FAILSAFE :: 'SITE SIGN-DISAGREEMENT FAILSAFE - {…}. EVERY inverter is held at 0 W. '
row 96e93e34bdbf # main.py over 15 words (24); banned: watchdog :: 'site sign watchdog: armed against the sum of the commands (trips after'
row 9ee8d293fea5 # main.py over 15 words (38); capitals: CIRCULATING POWER :: 'CIRCULATING POWER - {…}. EVERY inverter is held at 0 W. The meter cann'
row 55eb950acd5f # main.py over 15 words (17); banned: watchdog :: 'circulation watchdog: armed (trips after {self.circ_wd.cfg.hold_s} s o'
row 1817192a7f1c # main.py over 15 words (42); capitals: NOT DELIVERINGX :: 'NOT DELIVERING{tag} - {…}. Nothing has been commanded differently and '
row 685c1416364b # main.py over 15 words (26) :: 'delivery watchdog{tag}: armed (reports after {inv.delivery_wd.cfg.hold'
row d3cd52827dd0 # main.py over 15 words (29); banned: watchdog :: 'delivery watchdog: n/a at one inverter - a unit short of its share is '
row d6981c8d7b56 # main.py banned: latched :: 'no e-stop fitted: if this host dies the battery stays latched at its l'
row 685c8f02c97a # main.py capitals: NO SITE TOTAL :: 'PV production: NO SITE TOTAL - {…}. Reporting: {…}'
row abef339bf192 # main.py over 15 words (25); option key: pv_entities :: 'PV production: `pv_entities` is set, so `pv_entity` is IGNORED - the {'
row 514511bd0a93 # main.py over 15 words (32); option key: pv_invert_entities :: 'PV production: {ent} reads {watt} W - far below zero, so its sign is p'
row 328abd6171ce # main.py over 15 words (22); option key: pv_invert_entities :: 'PV production: `pv_invert_entities` names {ent}, which is not a PV ent'